Compare commits

...

6 Commits

Author SHA1 Message Date
agentson
7e9a573390 fix: BUY 결정 전 기존 포지션 체크 추가 — 중복 매수 방지 (#191)
Some checks failed
CI / test (pull_request) Has been cancelled
어제(2026-02-20) 거래 로그에서 NP 7번, KNRX 5번 중복 매수 발생.
trading_cycle()의 BUY 브랜치에 get_open_position() 체크를 추가하여
이미 보유 중인 종목은 HOLD로 전환, 재매수를 차단함.

- src/main.py: BUY 결정 직후 기존 포지션 확인 → 있으면 HOLD 변환
- tests/test_main.py: 테스트 2개 추가
  - test_buy_suppressed_when_open_position_exists
  - test_buy_proceeds_when_no_open_position

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 09:35:39 +09:00
7dbc48260c Merge pull request 'fix: 해외주식 모의투자 SELL TR_ID 오류 수정 VTTT1006U → VTTT1001U (#189)' (#190) from feature/issue-189-overseas-sell-tr-id-fix into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #190
2026-02-21 03:14:34 +09:00
agentson
4b883a4fc4 docs: KIS API TR_ID 공식 문서 참조 규칙 추가 (#189)
Some checks failed
CI / test (pull_request) Has been cancelled
docs/commands.md에 "KIS API TR_ID 참조 문서" 섹션 추가:
- 공식 문서 경로 명시: 한국투자증권_오픈API_전체문서_20260221_030000.xlsx
- 모의투자/실전투자 TR_ID 표 정리
- 비공식 자료(블로그 등) 사용 금지 경고
- 출처 주석 작성 가이드

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 03:14:00 +09:00
agentson
98071a8ee3 fix: 해외주식 모의투자 SELL TR_ID 오류 수정 VTTT1006U → VTTT1001U (#189)
Some checks failed
CI / test (pull_request) Has been cancelled
KIS 공식 문서(20260221) '해외주식 주문' 시트 확인 결과:
- 모의투자 미국 매수: VTTT1002U (기존 정상)
- 모의투자 미국 매도: VTTT1001U (기존 VTTT1006U → 잘못된 TR_ID)

VTTT1006U는 존재하지 않는 TR_ID로, 모든 해외 SELL 주문이
"모의투자에서는 해당업무가 제공되지 않습니다." 오류로 거부되었음.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 03:12:00 +09:00
agentson
f2ad270e8b docs: 2026-02-21 요구사항 로그 업데이트 (#187)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 00:34:16 +09:00
04c73a1a06 Merge pull request 'fix: SELL 주문에서 Fat Finger 오탐 수정 — 손절/익절 차단 버그 (#187)' (#188) from feature/issue-187-sell-fat-finger-fix into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #188
2026-02-21 00:33:46 +09:00
6 changed files with 224 additions and 3 deletions

View File

@@ -192,6 +192,27 @@ When `TELEGRAM_COMMANDS_ENABLED=true` (default), the bot accepts these interacti
Commands are only processed from the authorized `TELEGRAM_CHAT_ID`. Commands are only processed from the authorized `TELEGRAM_CHAT_ID`.
## KIS API TR_ID 참조 문서
**TR_ID를 추가하거나 수정할 때 반드시 공식 문서를 먼저 확인할 것.**
공식 문서: `docs/한국투자증권_오픈API_전체문서_20260221_030000.xlsx`
> ⚠️ 커뮤니티 블로그, GitHub 예제 등 비공식 자료의 TR_ID는 오래되거나 틀릴 수 있음.
> 실제로 `VTTT1006U`(미국 매도 — 잘못됨)가 오랫동안 코드에 남아있던 사례가 있음 (Issue #189).
### 주요 TR_ID 목록
| 구분 | 모의투자 TR_ID | 실전투자 TR_ID | 시트명 |
|------|---------------|---------------|--------|
| 해외주식 매수 (미국) | `VTTT1002U` | `TTTT1002U` | 해외주식 주문 |
| 해외주식 매도 (미국) | `VTTT1001U` | `TTTT1006U` | 해외주식 주문 |
새로운 TR_ID가 필요할 때:
1. 위 xlsx 파일에서 해당 거래 유형의 시트를 찾는다.
2. 모의투자(`VTTT`) / 실전투자(`TTTT`) 컬럼을 구분하여 정확한 값을 사용한다.
3. 코드에 출처 주석을 남긴다: `# Source: 한국투자증권_오픈API_전체문서 — '<시트명>' 시트`
## Environment Setup ## Environment Setup
```bash ```bash

View File

@@ -7,6 +7,32 @@
--- ---
## 2026-02-21
### 거래 상태 확인 중 발견된 버그 (#187)
- 거래 상태 점검 요청 → SELL 주문(손절/익절)이 Fat Finger에 막혀 전혀 실행 안 됨 발견
- **#187 (Critical)**: SELL 주문에서 Fat Finger 오탐 — `order_amount/total_cash > 30%`가 SELL에도 적용되어 대형 포지션 매도 불가
- JELD stop-loss -6.20% → 차단, RXT take-profit +46.13% → 차단
- 수정: SELL은 `check_circuit_breaker`만 호출, `validate_order`(Fat Finger 포함) 미호출
---
## 2026-02-20
### 지속적 모니터링 및 개선점 도출 (이슈 #178~#182)
- Dashboard 포함해서 실행하며 간헐적 문제 모니터링 및 개선점 자동 도출 요청
- 모니터링 결과 발견된 이슈 목록:
- **#178**: uvicorn 미설치 → dashboard 미작동 + 오해의 소지 있는 시작 로그 → uvicorn 설치 완료
- **#179 (Critical)**: 잔액 부족 주문 실패 후 매 사이클마다 무한 재시도 (MLECW 20분 이상 반복)
- **#180**: 다중 인스턴스 실행 시 Telegram 409 충돌
- **#181**: implied_rsi 공식 포화 문제 (change_rate≥12.5% → RSI=100)
- **#182 (Critical)**: 보유 종목이 SmartScanner 변동성 필터에 걸려 SELL 신호 미생성 → SELL 체결 0건, 잔고 소진
- 요구사항: 모니터링 자동화 및 주기적 개선점 리포트 도출
---
## 2026-02-05 ## 2026-02-05
### API 효율화 ### API 효율화

View File

@@ -230,7 +230,9 @@ class OverseasBroker:
session = self._broker._get_session() session = self._broker._get_session()
# Virtual trading TR_IDs for overseas orders # Virtual trading TR_IDs for overseas orders
tr_id = "VTTT1002U" if order_type == "BUY" else "VTTT1006U" # Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 주문' 시트
# VTTT1002U: 모의투자 미국 매수, VTTT1001U: 모의투자 미국 매도
tr_id = "VTTT1002U" if order_type == "BUY" else "VTTT1001U"
body = { body = {
"CANO": self._broker._account_no, "CANO": self._broker._account_no,

View File

@@ -510,6 +510,25 @@ async def trading_cycle(
), ),
) )
# BUY 결정 전 기존 포지션 체크 (중복 매수 방지)
if decision.action == "BUY":
existing_position = get_open_position(db_conn, stock_code, market.code)
if existing_position:
decision = TradeDecision(
action="HOLD",
confidence=decision.confidence,
rationale=(
f"Already holding {stock_code} "
f"(entry={existing_position['price']:.4f}, "
f"qty={existing_position['quantity']})"
),
)
logger.info(
"BUY suppressed for %s (%s): already holding open position",
stock_code,
market.name,
)
if decision.action == "HOLD": if decision.action == "HOLD":
open_position = get_open_position(db_conn, stock_code, market.code) open_position = get_open_position(db_conn, stock_code, market.code)
if open_position: if open_position:

View File

@@ -2848,3 +2848,156 @@ class TestMarketOutlookConfidenceThreshold:
call_args = decision_logger.log_decision.call_args call_args = decision_logger.log_decision.call_args
assert call_args is not None assert call_args is not None
assert call_args.kwargs["action"] == "BUY" assert call_args.kwargs["action"] == "BUY"
@pytest.mark.asyncio
async def test_buy_suppressed_when_open_position_exists() -> None:
"""BUY should be suppressed when an open position already exists for the stock."""
db_conn = init_db(":memory:")
decision_logger = DecisionLogger(db_conn)
# 기존 BUY 포지션 DB에 기록 (중복 매수 상황)
buy_decision_id = decision_logger.log_decision(
stock_code="NP",
market="US",
exchange_code="AMS",
action="BUY",
confidence=90,
rationale="initial entry",
context_snapshot={},
input_data={},
)
log_trade(
conn=db_conn,
stock_code="NP",
action="BUY",
confidence=90,
rationale="initial entry",
quantity=10,
price=50.0,
market="US",
exchange_code="AMS",
decision_id=buy_decision_id,
)
broker = MagicMock()
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
overseas_broker = MagicMock()
overseas_broker.get_overseas_price = AsyncMock(
return_value={"output": {"last": "51.0", "rate": "2.0", "high": "52.0", "low": "50.0", "tvol": "1000000"}}
)
overseas_broker.get_overseas_balance = AsyncMock(
return_value={
"output1": [],
"output2": [{"frcr_dncl_amt_2": "10000", "frcr_evlu_tota": "10000", "frcr_buy_amt_smtl": "0"}],
}
)
overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "OK"})
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=_make_buy_match(stock_code="NP"))
market = MagicMock()
market.name = "United States"
market.code = "US"
market.exchange_code = "AMS"
market.is_domestic = False
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_fat_finger = AsyncMock()
telegram.notify_circuit_breaker = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
await trading_cycle(
broker=broker,
overseas_broker=overseas_broker,
scenario_engine=engine,
playbook=_make_playbook(market="US"),
risk=MagicMock(),
db_conn=db_conn,
decision_logger=decision_logger,
context_store=MagicMock(
get_latest_timeframe=MagicMock(return_value=None),
set_context=MagicMock(),
),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=telegram,
market=market,
stock_code="NP",
scan_candidates={},
)
# 이미 보유 중이므로 주문이 실행되지 않아야 함
broker.send_order.assert_not_called()
overseas_broker.send_overseas_order.assert_not_called()
@pytest.mark.asyncio
async def test_buy_proceeds_when_no_open_position() -> None:
"""BUY should proceed normally when no open position exists for the stock."""
db_conn = init_db(":memory:")
decision_logger = DecisionLogger(db_conn)
# DB가 비어있는 상태 — 기존 포지션 없음
broker = MagicMock()
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
overseas_broker = MagicMock()
overseas_broker.get_overseas_price = AsyncMock(
return_value={"output": {"last": "100.0", "rate": "1.0", "high": "101.0", "low": "99.0", "tvol": "500000"}}
)
overseas_broker.get_overseas_balance = AsyncMock(
return_value={
"output1": [],
"output2": [{"frcr_dncl_amt_2": "50000", "frcr_evlu_tota": "50000", "frcr_buy_amt_smtl": "0"}],
}
)
overseas_broker.send_overseas_order = AsyncMock(return_value={"msg1": "OK"})
engine = MagicMock(spec=ScenarioEngine)
engine.evaluate = MagicMock(return_value=_make_buy_match(stock_code="KNRX"))
market = MagicMock()
market.name = "United States"
market.code = "US"
market.exchange_code = "NAS"
market.is_domestic = False
risk = MagicMock()
risk.validate_order = MagicMock()
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_fat_finger = AsyncMock()
telegram.notify_circuit_breaker = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
await trading_cycle(
broker=broker,
overseas_broker=overseas_broker,
scenario_engine=engine,
playbook=_make_playbook(market="US"),
risk=risk,
db_conn=db_conn,
decision_logger=decision_logger,
context_store=MagicMock(
get_latest_timeframe=MagicMock(return_value=None),
set_context=MagicMock(),
),
criticality_assessor=MagicMock(
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
get_timeout=MagicMock(return_value=5.0),
),
telegram=telegram,
market=market,
stock_code="KNRX",
scan_candidates={},
)
# 포지션이 없으므로 해외 주문이 실행되어야 함
overseas_broker.send_overseas_order.assert_called_once()

View File

@@ -414,7 +414,7 @@ class TestSendOverseasOrder:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_sell_limit_order(self, overseas_broker: OverseasBroker) -> None: async def test_sell_limit_order(self, overseas_broker: OverseasBroker) -> None:
"""Limit sell order should use VTTT1006U and ORD_DVSN=00.""" """Limit sell order should use VTTT1001U and ORD_DVSN=00."""
mock_resp = AsyncMock() mock_resp = AsyncMock()
mock_resp.status = 200 mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"rt_cd": "0"}) mock_resp.json = AsyncMock(return_value={"rt_cd": "0"})
@@ -428,7 +428,7 @@ class TestSendOverseasOrder:
result = await overseas_broker.send_overseas_order("NYSE", "MSFT", "SELL", 5, price=350.0) result = await overseas_broker.send_overseas_order("NYSE", "MSFT", "SELL", 5, price=350.0)
assert result["rt_cd"] == "0" assert result["rt_cd"] == "0"
overseas_broker._broker._auth_headers.assert_called_with("VTTT1006U") overseas_broker._broker._auth_headers.assert_called_with("VTTT1001U")
call_args = mock_session.post.call_args call_args = mock_session.post.call_args
body = call_args[1]["json"] body = call_args[1]["json"]