From 45677c8fbb02566fe83e320fc8619adb5fc0ddce Mon Sep 17 00:00:00 2001 From: shuim Date: Thu, 1 Oct 2026 17:37:29 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=A6=AC=EC=8A=A4=ED=81=AC=20=EA=B2=B0?= =?UTF-8?q?=ED=95=A8=20=EC=9D=BC=EA=B4=84=20=EC=88=98=EC=A0=95=20=E2=80=94?= =?UTF-8?q?=20=EB=88=84=EC=A0=81=204%=20=EC=83=81=ED=95=9C=C2=B7=EC=8A=A4?= =?UTF-8?q?=EC=9C=99=20=EC=86=8C=EC=9C=A0=EA=B6=8C=C2=B7=EC=9D=BC=EC=9D=BC?= =?UTF-8?q?=ED=95=9C=EB=8F=84=20=EB=B6=84=EB=A6=AC=C2=B7DT=20=EC=88=9C?= =?UTF-8?q?=EC=86=90=EC=9D=B5/=EC=B2=B4=EA=B2=B0=EA=B0=80=C2=B7=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EC=86=90=EC=A0=88=20=EB=B0=B4=20=EC=A0=9C=EC=99=B8?= =?UTF-8?q?=C2=B7=EC=B2=B4=EA=B2=B0=EC=A1=B0=ED=9A=8C=20=EA=B2=BD=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trade_executor - 종목당 4% 상한을 기존 보유분 포함 누적으로 (sizing_core.remaining_position_room) - 추천 경로는 스윙 소유 행만 매수·매도 (external/manual/daytrading/us_swing skip, 대칭), 고아 DT 행 warning - 일일 매수한도는 스윙 소유 매수만 집계, 집계 실패 시 fail-closed - 부분매도 반복 차단에서만 DT 매도 제외 (재매수 쿨다운은 유지) - 체결 확인: 부분체결 잔량 자동 재주문 제거(가드 전부 우회·취소/거부 시 발동), PARTIAL→COMPLETED+체결수량, 초과체결 캡 korea_investment_api - get_order_execution 이 존재하지 않는 /trading/inquire-ccnl 을 호출하던 결함 → inquire-daily-ccld (TTTC0081R 우선, 구 TR 폴백, 페이지네이션, ODNO 매칭, EXCG_ID_DVSN_CD=KRX), 실패 로그 warning daytrading - 시간손절은 loss_tracker 중립 (42일 밴이 스윙까지 막던 문제) - 실현손익을 수수료·거래세 차감 순손익으로 (trade_cost_core), 체결가 백그라운드 보정 (fill_reconciler) - legacy _record_trade strategy_type=daytrading 명시 db_worker - update_trade_history_by_order_id 를 당일(KST)+최신 1행으로 한정 (일별 재사용 ODNO 로 과거 행 덮어쓰기 방지) alembic 0013: trade_journals strategy_name='unknown' 을 trade_history.strategy_type 으로 보정 (백업 테이블 포함) CLAUDE.md: 변경 동작 반영, MT 예약 리비전 0013→0014 998 passed Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 21 +- .../0013_fix_unknown_journal_strategy.py | 146 ++++ backend/app/api/endpoints/day_trading.py | 12 +- backend/app/core/config.py | 9 + backend/app/services/day_trading_strategy.py | 15 +- .../app/services/daytrading/entry_service.py | 61 ++ .../app/services/daytrading/exit_service.py | 125 +++- .../services/daytrading/fill_reconciler.py | 143 ++++ .../services/daytrading/position_manager.py | 9 + backend/app/services/db_worker.py | 135 +++- backend/app/services/korea_investment_api.py | 283 ++++++-- backend/app/services/sizing_core.py | 25 + backend/app/services/trade_cost_core.py | 72 ++ backend/app/services/trade_executor.py | 402 +++++++++-- backend/tests/conftest.py | 9 + ...test_db_worker_order_id_update_20261001.py | 176 +++++ .../test_dt_cost_fill_timestop_20261001.py | 338 +++++++++ ...est_order_execution_daily_ccld_20261001.py | 411 +++++++++++ ...test_trade_executor_risk_fixes_20261001.py | 647 ++++++++++++++++++ 19 files changed, 2873 insertions(+), 166 deletions(-) create mode 100644 backend/alembic/versions/0013_fix_unknown_journal_strategy.py create mode 100644 backend/app/services/daytrading/fill_reconciler.py create mode 100644 backend/app/services/trade_cost_core.py create mode 100644 backend/tests/test_db_worker_order_id_update_20261001.py create mode 100644 backend/tests/test_dt_cost_fill_timestop_20261001.py create mode 100644 backend/tests/test_order_execution_daily_ccld_20261001.py create mode 100644 backend/tests/test_trade_executor_risk_fixes_20261001.py diff --git a/CLAUDE.md b/CLAUDE.md index 23c8607..fef8695 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,8 @@ adaptive_strategy(_MODE_PARAMS) > strategy_cfg(DB: strategy_configs) > setti - `services/daytrading/`·`services/swing/` — entry_service, exit_service, position_manager, scheduler - `services/` 순수 계산 모듈 (settings·DB·네트워크 참조 없음 — 이 원칙을 깨지 말 것): - `indicators_core.py` — RSI/BB/ATR/ADX 단일 출처. `rsi_wilder`/`rsi_sma`/`atr_sma`/`bollinger_sma`/`adx_wilder`/`true_ranges` - - `sizing_core.py` — 포지션 사이징 원시 연산. `budget_by_ratios`/`clamp_by_ratios`/`apply_confidence_scale`/`amount_to_quantity`/`half_kelly_fraction`/`effective_min_buy_amount`. **한도 숫자는 이 모듈에 없다**(호출부의 settings 소유) + - `sizing_core.py` — 포지션 사이징 원시 연산. `budget_by_ratios`/`clamp_by_ratios`/`apply_confidence_scale`/`amount_to_quantity`/`half_kelly_fraction`/`effective_min_buy_amount`/`remaining_position_room`. **한도 숫자는 이 모듈에 없다**(호출부의 settings 소유) + - `trade_cost_core.py` — 왕복 비용(수수료×2 + 매도세)·순손익. `trade_helpers.calculate_trade_costs` 와 동일 정의(테스트로 고정) - `services/sector_mapper.py` — 섹터 조회 단일화. **조회 경로는 파일 캐시만 읽고 네트워크를 쓰지 않는다**(§3.1). 수집은 `backend/collect_sector_map.py` 배치 전용 - `services/` 핵심: trade_executor(매매엔진), db_worker(비동기 DB워커), mdd_manager·loss_tracker·correlation_filter(리스크), korea_investment_api·kis_realtime_stream·naver_finance(데이터), feature_engineering·ml_ensemble·technical_indicators(분석), analysis_db_service(DB 기반 스캐너), market_analyzer(국면 게이트), market_playbook(이벤트 차단), ai_strategy_service(AI 필터 계층) - `mcp-server/` — stdio_server, auto_trade_claude, claude_client, gemini_client, trade_monitor, risk_validator, mcp_tools @@ -97,6 +98,7 @@ adaptive_strategy(_MODE_PARAMS) > strategy_cfg(DB: strategy_configs) > setti ## 5. 리스크 규칙 ### 5.1 사이징·필터 - 종목당 **4%** (`MAX_POSITION_SIZE`) — **절대 상한(hard ceiling)**. 적응형 모드가 6~7%를 지시해도 소비 지점에서 `min(_adp.position_size, MAX_POSITION_SIZE)` 로 클램프되고, 패턴메모리 조정 후에도 `try` 밖에서 재클램프된다. + - **누적 기준 (2026-10-01)**: 상한은 주문 1건이 아니라 **기존 보유분 포함 누적**이다(`trade_executor._apply_position_cap_room_v2` → `sizing_core.remaining_position_room`). 기존 보유 = `max(Portfolio 행 수량, DT 인메모리 수량)` × 현재가. 남은 한도 < 실효 최소주문금액이면 skip, 보유가 있는데 총자산 조회 실패면 추가매수 skip. 신규 진입 수치는 이전과 동일. - 최대 **20종목**(`MAX_HOLDINGS_COUNT`), 레버리지/인버스 ETF 최대 **20%**(`LEVERAGE_ETF_MAX_RATIO`), 전 전략 합산 현금 사용 상한 **95%**(`MAX_TOTAL_CAPITAL_USAGE`). - 최소 주문금액 **15만원**(`MIN_BUY_AMOUNT_KRW`) — **단 실효값은 `min(150,000, 총자산 × 적응형포지션상한 × 0.9)`** 다. 소액계좌에서 주문이 전부 탈락하는 것을 막기 위한 의도적 완화(2026-09-23 하루 141건 차단 사례). - 가격 필터: 국내 >5,000원, 미국 >$4 (`MIN_PRICE_KRW`, `MIN_PRICE_USD`). **ETF 예외는 `trade_executor` 경로에만 있고** `market_analyzer`·`portfolio_v2`·`manual_buy` 경로에는 없어 경로별로 동작이 다르다. @@ -107,6 +109,8 @@ adaptive_strategy(_MODE_PARAMS) > strategy_cfg(DB: strategy_configs) > setti - **배포 후 `collect_sector_map.py` 를 한 번 돌려야 캐시가 생긴다**(§1-4). 없으면 44종목 표로만 폴백. - **미매핑 종목의 섹터캡**: `SECTOR_CAP_UNMAPPED_ENABLED`(기본 **False**). 미매핑을 "UNKNOWN" 단일 버킷으로 묶어 3종목 한도를 적용하는 스위치다. 켜기 전 조건 — 운영 환경 커버리지 ≥95% & 캐시 7일 이내, 미매핑 잔여가 ETF/ETN 만(일반 주식이 남아 있으면 UNKNOWN 3종목 상한에 걸려 거래가 죽는다), 최대 버킷 <20%, 그리고 켠 상태 백테스트에서 체결 건수가 유의미하게 줄지 않고 `MAX_HOLDINGS_COUNT=20` 도달이 가능함을 확인. - 연속 손실: 동일 종목 **3연패 → `CONSECUTIVE_LOSS_BAN_DAYS`(42캘린더일 ≈ 30거래일) 진입 금지** (`loss_tracker`). 값은 config 연동이며 하드코딩이 아니다. `record_trade_result()` 후 반드시 `save_to_db()`(db_worker `upsert_stock_ban` 경유). 재시작 시 `load_from_db()` 복원. + - **의도적 예외 (2026-10-01)**: DT **시간손절**(`day_trading_strategy.TIME_STOP_REASON_PREFIX`)은 loss_tracker 에 기록하지 않는다(중립 — 연패를 늘리지도 끊지도 않음). 이 밴이 스윙 진입까지 막아 -0.3% 시간손절이 42일 금지를 유발했기 때문. 하드/폴백 손절·일일한도·EOD·트레일링은 기존대로 기록. DT 당일 3연패 쿨다운(`loss_streak_today`)은 시간손절도 계속 센다. + - 손익 판정은 **비용 차감 순손익** 기준이라 왕복 비용(≈0.21%) 이내의 소폭 이익 청산도 손실로 집계된다. ### 5.2 SL/TP (일봉/스윙) - 기본 SL: **-7%**(안정) / **-10%**(변동성, ATR/종가 > 3%). ATR SL: `진입가 − 2.5×ATR` (`ATR_STOP_MULTIPLIER`). @@ -171,6 +175,9 @@ adaptive_strategy(_MODE_PARAMS) > strategy_cfg(DB: strategy_configs) > setti - **거시/국면 position_multiplier** (`market_analyzer`): 장외 0.5, CRASH 0.0~0.1, BEAR ratio - **적응형 Crisis 모드**: 진입 전면 차단 (§8.4) - **추격매수 방지·추세 필터·이격도 과열·일일 매매한도·재매수 쿨다운** (`trade_executor`) + - 일일 매매한도(`MAX_DAILY_TRADES`)는 **스윙 소유 매수만** 집계한다(`strategy_type IS NULL OR = 'swing'`, `_swing_owned_clause_v2`). 집계 조회가 실패하면 **fail-closed** — 그 사이클 신규 진입 0건 + `logger.error`. + - 재매수 쿨다운은 DT 매도 포함 전체 매도 기준, 부분매도 반복 차단(`_sold_today`)만 비-DT 매도 기준. + - **추천 경로(`execute_auto_trade`)는 스윙 소유 행만 매수·매도한다**(`_sell_ownership_block_reason_v2`): external/manual 은 수동 판단, daytrading 은 DT 엔진 소유, us_swing 등은 타 전략 — 매도도 추가매수도 skip(대칭). MDD 축소·`swing/exit_service`·DT 청산은 별도 주문 경로라 무관. 고아 daytrading 행(DT 인메모리 0)은 warning 으로 수동 확인을 요청한다. - **AI 전략 계층** (`ai_strategy_service`): 08:30 장전 분석 / 08:50 DT 후보 선정 / 뉴스 감시 / 15:40 장후 리뷰. 적합성 필터링과 뉴스 악재 배율로 실제 매매에 영향을 준다. - **듀얼 전략 후보 소스 의존**: `StockAnalysis.analysis_date >= today` 에만 의존하므로 **일일 배치가 실패하면 국면이 BULL이어도 후보 0건**으로 조용히 아무 일도 일어나지 않는다. @@ -199,7 +206,9 @@ adaptive_strategy(_MODE_PARAMS) > strategy_cfg(DB: strategy_configs) > setti - **전일 포지션 청산 손익은 당일 실현손익에 넣지 않는다**(`_finalize_exit_impl(count_daily_pnl=False)`). 넣으면 당일 매매 0건인데 -3% 손실한도가 발동해 정상 포지션까지 전량 청산된다. 전일 손익은 `get_stale_realized_pnl()` + warning 로그 + TradeHistory로 추적한다. stale 청산 종목은 `closed_today` 에 추가해 당일 재진입을 막는다(주문 실패 시에도). - **트레일링 고점(`highest_price`)은 진입 이후에 시작한 1분봉 고가 + 현재가로만 갱신한다**(`daytrading/exit_service._post_entry_candle_high`, 2026-10-01). 진입이 속한 봉은 진입 전 체결이 섞여 제외하고, 라벨 ≥ 진입+60초 봉만 인정하며 날짜까지 비교한다. entry_time 이 없거나 정규장 밖(UTC 저장 추정)이면 현재가만 쓴다. 이전의 `max(candles_5m[-3:].high)` 는 진입 전 고가로 +1% 트리거를 오발동시켰다. - **분봉 조회 실패 시 폴백 손절**이 있다(`_check_fallback_stop_loss`). 현재가만으로 손절선 이탈을 판정해 전량 청산한다. 폴백 손절가는 `min()` 으로 고른 가장 타이트한 하드캡(-2%) 기준이며, 현재가조차 못 구하면 `logger.error` 로 수동 확인을 요청한다. -- 제한: **15:00 이후 신규 마감**(`DAY_TRADE_ENTRY_CUTOFF_*`) / 일일 **-3%** 손실 시 당일 중단(`DAY_TRADE_DAILY_LOSS_LIMIT`) / 당일 3연패 쿨다운(position_manager) / 전량 매도 시 loss_tracker 기록 + `save_to_db()` +- 제한: **15:00 이후 신규 마감**(`DAY_TRADE_ENTRY_CUTOFF_*`) / 일일 **-3%** 손실 시 당일 중단(`DAY_TRADE_DAILY_LOSS_LIMIT`) / 당일 3연패 쿨다운(position_manager) / 전량 매도 시 loss_tracker 기록 + `save_to_db()`(시간손절 제외 — §5.1) +- **실현손익은 비용 차감 순손익**(2026-10-01, `exit_service._finalize_exit_impl`): `daily_pnl`·연패·loss_tracker·TradeHistory(`net_profit_loss`, `profit_loss_rate`, `commission`) 모두 순손익 기준이다. 따라서 일일 -3% 한도가 비용만큼 일찍 발동한다(의도). 미실현 손익은 여전히 비용 전. DT 포지션에 시장 구분이 없어 거래세는 `DAY_TRADE_DEFAULT_TAX_MARKET`(KOSPI) 세율을 쓴다. +- **체결가 보정**(`daytrading/fill_reconciler`, `DAY_TRADE_FILL_CHECK_*`): 주문 후 백그라운드로 `get_todays_orders` 를 ODNO 매칭해 실제 체결가로 매수 진입가(인메모리·ActiveTrade)와 매도 손익·`daily_pnl` 을 보정한다(손절가·목표가는 절대가 유지). 보정 결과 일일한도를 넘으면 다음 사이클에 전량 청산된다. 실패·시간초과면 신호가 유지. 문제 시 `DAY_TRADE_FILL_CHECK_ENABLED=false`. - 감시종목: 09:00에 당일 분석이 없으면 최근 분석일 결과로 선채움(ORB 수집 보장), 자동 목록은 30분마다 갱신(수동 설정 목록은 유지) - **1분봉 데이터 주의**: `get_minute_chart_full_day` 는 과거 데이터 포함 요청(`FID_PW_DATA_INCU_YN="Y"`)이라 전일 캔들이 섞인다. dedupe·정렬 키에 `stck_bsop_date` 를 포함하고 당일 외 캔들을 제외해야 ORB 거래량(`candles_1min[-1]`)·VWAP이 오염되지 않는다. @@ -340,7 +349,13 @@ DX(0~100)에 합산형 평활을 적용한 버그. 측정: `up 1361 vs 97 / flat 18. `naver_finance` 등 **KIS 외 외부 HTTP 경로는 conftest 가 전역 차단하지 않는다**. 현재 소켓 차단 하 전체 통과로 실호출은 없음을 확인했으나, 추가 방어가 필요하면 별도 작업. **멀티테넌트** -19. MT 2단계 잔여 — 스케줄러 사용자별 순회, legacy 라우터 격리, `user_id` 를 실제로 payload 에 싣는 호출부 전환, alembic **0013**(NOT NULL + `uq_portfolio_user_stock`). `db_worker` 의 `MultipleResultsFound` 전제조건 1건은 해소됐다. **`MULTI_TENANT_ENABLED=True` 는 계속 금지.** +19. MT 2단계 잔여 — 스케줄러 사용자별 순회, legacy 라우터 격리, `user_id` 를 실제로 payload 에 싣는 호출부 전환, alembic **0014**(NOT NULL + `uq_portfolio_user_stock`). `db_worker` 의 `MultipleResultsFound` 전제조건 1건은 해소됐다. **`MULTI_TENANT_ENABLED=True` 는 계속 금지.** + +**체결 확인 (2026-10-01 — `get_order_execution` 이 존재하지 않는 경로 `/trading/inquire-ccnl` 을 호출해 스윙 체결 확인이 한 번도 성공하지 못했던 결함 수정)** +27. 체결 조회는 `/trading/inquire-daily-ccld`(TR `TTTC0081R`/`VTTC0081R` 우선 → `rt_cd≠0` 이면 구 TR `*8001R` 폴백, 인스턴스에 기억) + `EXCG_ID_DVSN_CD=KRX`. 배포 후 로그 `[daily-ccld] TR legacy 로 전환` / `대체 TR 재시도` 빈도를 확인할 것 — "결과 없음"을 `rt_cd≠0` 으로 주면 매 조회가 2배가 된다. +28. **부분체결 잔량 자동 재주문은 하지 않는다.** `_reorder_unfilled` 는 MDD/밴/누적 4%/현금/주문 락/TradeHistory 를 전부 우회하고, 잔량 소멸은 사실상 HTS 취소·KIS 거부·만료일 때만 일어난다. 잔량 소멸 시 warning("수동 확인 필요")만 남긴다. 되살리지 말 것. +29. 후속: (a) 체결 0주로 취소·거부된 주문이 `COMPLETED`·전량으로 남는다 → CANCELLED 강등 필요, (b) 스윙 부분체결 매도의 `net_profit_loss`/`commission` 이 주문 전량 기준이라 성과 과대, (c) 폴링 상수(`max_polls=5`, `poll_interval=3`, `ORDER_EXEC_MAX_PAGES=5`) config 화, (d) 모의 서버 초당 2건 제한 대비 주문당 최대 5~10회 조회. +30. `TradeHistory.order_id` 갱신(`db_worker._handle_update_trade_history_by_order_id`)은 **당일(KST) + 최신 1행**만 대상이다. KIS 주문번호가 일별 순번이라 과거 행을 덮어쓰던 위험을 막는다. 0건이면 재시도 없이 warning. **ML 앙상블 (2026-09-28 — 코드는 고쳤으나 모델 재학습 전까지 신뢰 금지)** 20. **운영 LSTM 체크포인트가 무의미하다.** `seq_mean=[0,0,0,0,0]`, `seq_std=[1e-8]×5` — `X_seq` 가 전부 0 인 상태로 학습된 흔적이다(구 `prepare_training_data` 가 시퀀스에 NaN 을 넣어 `X_seq.mean` 이 NaN 이 된 결과). std≈0 이면 정규화가 `x/1e-8` 로 폭주해 **가중치 35% 를 가진 LSTM 출력이 사실상 난수**다. 시계열 방향 수정 + 시퀀스 NaN 수정이 들어갔으므로 **재학습이 필요**하다. 재학습 후 새 `metrics.json` 의 `lstm_seq_std_min` 과 `lstm_per_class` 로 판정할 것 — 정확도만 보면 "전부 HOLD" 모델을 통과시킨다. diff --git a/backend/alembic/versions/0013_fix_unknown_journal_strategy.py b/backend/alembic/versions/0013_fix_unknown_journal_strategy.py new file mode 100644 index 0000000..59d8fe8 --- /dev/null +++ b/backend/alembic/versions/0013_fix_unknown_journal_strategy.py @@ -0,0 +1,146 @@ +"""backfill trade_journals.strategy_name='unknown' from trade_history.strategy_type (data-only) + +Revision ID: 0013 +Revises: 0012 +Create Date: 2026-10-01 + +[배경] +매매일지 생성 경로가 전략명을 판정하지 못해 `trade_journals.strategy_name` 이 +'unknown' 으로 저장된 행(약 187건)이 있다. 코드는 이미 수정됐으나 기존 행은 그대로다. +`trade_history.strategy_type` 이 신뢰 가능한 원본이므로 그 값으로 보정한다. + +[매핑] trade_analyzer._parse_strategy(strategy_type 분기)와 동일하다. + swing -> mean_reversion / daytrading -> day_trade / kosdaq_scan -> kosdaq_scanner + short_term -> short_term / 그 외 -> lower(trim(strategy_type)) 그대로 +tags 는 [strategy, exit_type, ...] 형태(db_worker/trade_analyzer)이므로 첫 요소가 +'unknown' 일 때만 같은 전략명으로 치환한다. 배열이 아니거나 비어 있으면 그대로 둔다. + +[안전] +- 백업 테이블 `trade_journals_bak_strategy_20261001` 에 대상 행의 (id, strategy_name, tags) + 를 복사한다(IF NOT EXISTS + 이미 있는 id 는 재삽입 안 함 -> 재실행 안전). +- 테이블/컬럼이 없으면(신규 볼륨, 앱 기동 전) 조용히 no-op (0012 와 동일한 가드). +- downgrade 는 백업에서 strategy_name/tags 복원. 백업 테이블은 남긴다. +스키마 변경은 백업 테이블 생성뿐이다. +""" +from alembic import op +import sqlalchemy as sa + + +revision = "0013" +down_revision = "0012" +branch_labels = None +depends_on = None + +_BAK = "trade_journals_bak_strategy_20261001" + +# trade_analyzer._parse_strategy 의 strategy_type 매핑과 동일해야 한다. +_MAPPED = """ + CASE lower(btrim(th.strategy_type)) + WHEN 'swing' THEN 'mean_reversion' + WHEN 'daytrading' THEN 'day_trade' + WHEN 'kosdaq_scan' THEN 'kosdaq_scanner' + WHEN 'short_term' THEN 'short_term' + ELSE lower(btrim(th.strategy_type)) + END +""" + +# 대상 조건 (백업/갱신 공통) +_TARGET_FROM = """ + FROM trade_journals j + JOIN trade_history th ON th.id = j.trade_id + WHERE j.strategy_name = 'unknown' + AND j.trade_id > 0 + AND th.strategy_type IS NOT NULL + AND btrim(th.strategy_type) <> '' +""" + +_CREATE_BAK = sa.text( + f"CREATE TABLE IF NOT EXISTS {_BAK} (id integer PRIMARY KEY, strategy_name varchar(100), tags jsonb)" +) + +_INSERT_BAK = sa.text( + f""" + INSERT INTO {_BAK} (id, strategy_name, tags) + SELECT j.id, j.strategy_name, j.tags + {_TARGET_FROM} + AND NOT EXISTS (SELECT 1 FROM {_BAK} b WHERE b.id = j.id) + """ +) + +_UPDATE = sa.text( + f""" + UPDATE trade_journals j + SET strategy_name = {_MAPPED}, + tags = CASE + WHEN jsonb_typeof(j.tags) = 'array' THEN + CASE + WHEN jsonb_array_length(j.tags) > 0 + AND j.tags -> 0 = to_jsonb('unknown'::text) + THEN jsonb_set(j.tags, '{{0}}', to_jsonb({_MAPPED})) + ELSE j.tags + END + ELSE j.tags + END, + updated_at = now() AT TIME ZONE 'Asia/Seoul' + FROM trade_history th + WHERE th.id = j.trade_id + AND j.strategy_name = 'unknown' + AND j.trade_id > 0 + AND th.strategy_type IS NOT NULL + AND btrim(th.strategy_type) <> '' + """ +) + +_RESTORE = sa.text( + f""" + UPDATE trade_journals j + SET strategy_name = b.strategy_name, + tags = b.tags, + updated_at = now() AT TIME ZONE 'Asia/Seoul' + FROM {_BAK} b + WHERE b.id = j.id + """ +) + + +def _has_table(conn, table: str) -> bool: + return conn.execute(sa.text( + "SELECT 1 FROM information_schema.tables WHERE table_name=:t" + ), {"t": table}).fetchone() is not None + + +def _has_columns(conn, table: str, cols) -> bool: + rows = conn.execute(sa.text( + "SELECT column_name FROM information_schema.columns WHERE table_name=:t" + ), {"t": table}).fetchall() + have = {r[0] for r in rows} + return set(cols) <= have + + +def _schema_ready(conn) -> bool: + return ( + _has_table(conn, "trade_journals") + and _has_table(conn, "trade_history") + and _has_columns(conn, "trade_journals", ["id", "trade_id", "strategy_name", "tags", "updated_at"]) + and _has_columns(conn, "trade_history", ["id", "strategy_type"]) + ) + + +def upgrade() -> None: + conn = op.get_bind() + if not _schema_ready(conn): + print("[0013] trade_journals/trade_history 없음 — 보정 스킵") + return + conn.execute(_CREATE_BAK) + bak = conn.execute(_INSERT_BAK).rowcount + upd = conn.execute(_UPDATE).rowcount + print(f"[0013] unknown 일지 보정: 백업 {bak}건 추가, 갱신 {upd}건") + + +def downgrade() -> None: + conn = op.get_bind() + if not _has_table(conn, _BAK) or not _has_table(conn, "trade_journals"): + print("[0013] 백업 테이블 없음 — 복원 스킵") + return + n = conn.execute(_RESTORE).rowcount + print(f"[0013] 일지 strategy_name/tags 복원: {n}건 (백업 테이블은 유지)") diff --git a/backend/app/api/endpoints/day_trading.py b/backend/app/api/endpoints/day_trading.py index 12e6fc8..64614e9 100644 --- a/backend/app/api/endpoints/day_trading.py +++ b/backend/app/api/endpoints/day_trading.py @@ -33,7 +33,7 @@ from app.services.day_trading_strategy import ( parse_minute_candles, aggregate_to_5min, calculate_vwap, calculate_opening_range, check_orb_signal, check_vwap_bounce_signal, check_rsi_bb_signal, - check_day_trade_exit, + check_day_trade_exit, is_time_stop_exit_reason, calculate_day_trade_position_size, is_daily_loss_exceeded, ORB_END, ENTRY_CUTOFF, EOD_FLAT, MAX_POSITIONS, ) @@ -271,6 +271,8 @@ async def _record_trade( "status": "COMPLETED", "entry_reason": reason if order_type == "BUY" else None, "exit_reason": reason if order_type == "SELL" else None, + # [2026-10-01] 명시 — 빠지면 모델 기본값 'swing' 으로 기록돼 DT 거래가 스윙 통계에 섞인다. + "strategy_type": "daytrading", "executed_at": _kst_now(), }) except Exception as e: @@ -480,9 +482,11 @@ async def _check_exits( await enqueue_close_active_trade(code, strategy_name="day_trade") # loss_tracker 기록 — CLAUDE.md 7.3 규칙 준수 - _profit_rate = (current_price - pos["entry_price"]) / pos["entry_price"] if pos["entry_price"] > 0 else 0 - await loss_tracker.record_trade_result_safe(code, _profit_rate < 0) - await loss_tracker.save_to_db(db=None, stock_code=code) + # [2026-10-01] 시간손절은 연속손실 집계 제외(중립) — 엔진 경로(exit_service)와 동일 + if not is_time_stop_exit_reason(exit_signal.reason): + _profit_rate = (current_price - pos["entry_price"]) / pos["entry_price"] if pos["entry_price"] > 0 else 0 + await loss_tracker.record_trade_result_safe(code, _profit_rate < 0) + await loss_tracker.save_to_db(db=None, stock_code=code) exits.append({ "stock_code": code, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 0bd26cb..6782168 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -312,6 +312,15 @@ class Settings(BaseSettings): DAY_TRADE_TRAILING_ATR_MULT: float = 1.5 # 트레일링 ATR 배수 (1.0→1.5 whipsaw 방지) DAY_TRADE_ORB_BREAKOUT_MARGIN: float = 0.002 # ORB 돌파 최소 여유 +0.2% DAY_TRADE_COST_PCT: float = 0.005 # 수수료+슬리피지 합산 (매수+매도) + # [2026-10-01] DT 실제 체결가 확인 (주문 후 백그라운드, 실시간 루프 비차단). + # 실패/시간초과 시 신호가 기록 유지 + warning. 판정 임계값이 아니라 기록 정확도용. + DAY_TRADE_FILL_CHECK_ENABLED: bool = True + DAY_TRADE_FILL_CHECK_DELAY_SEC: float = 2.0 # 주문 직후 첫 조회까지 대기 + DAY_TRADE_FILL_CHECK_ATTEMPTS: int = 3 # 체결 조회 최대 횟수 + DAY_TRADE_FILL_CHECK_INTERVAL_SEC: float = 2.0 # 조회 간격 + DAY_TRADE_FILL_CHECK_TIMEOUT_SEC: float = 15.0 # 전체 상한 (토큰 rate-limit sleep 차단) + # DT 종목의 시장 구분을 모를 때 거래세 적용 시장 (스윙 trade_executor 기본값과 동일) + DAY_TRADE_DEFAULT_TAX_MARKET: str = "KOSPI" DAY_TRADE_ENTRY_CUTOFF_HOUR: int = 15 # 신규 진입 마감 시간 (14:30→15:00, 2026-09-22 시간대 제한 완화) DAY_TRADE_ENTRY_CUTOFF_MIN: int = 0 # 신규 진입 마감 분 DAY_TRADE_EOD_HOUR: int = 15 # EOD 강제 청산 시작 시간 diff --git a/backend/app/services/day_trading_strategy.py b/backend/app/services/day_trading_strategy.py index 343fed9..ca902dc 100644 --- a/backend/app/services/day_trading_strategy.py +++ b/backend/app/services/day_trading_strategy.py @@ -164,6 +164,19 @@ class DayTradeExit: sell_pct: float = 1.0 +# [2026-10-01] 시간손절 사유 접두어 — 사유 문자열 생성과 판별이 이 상수 하나를 공유한다. +# 시간손절은 "진입 후 N분간 목표 방향 진행 미미"를 정리하는 **기회비용 청산**이지 +# 손절선 이탈이 아니다. 이것까지 loss_tracker(3연패 → CONSECUTIVE_LOSS_BAN_DAYS 진입금지)에 +# 집계하면 소폭 손실 시간손절 3회만으로 그 종목의 스윙 진입까지 42일간 막힌다. +# 판별은 exit_service 가 청산 사유로 한다(_execute_exit 시그니처 불변 유지). +TIME_STOP_REASON_PREFIX = "시간 손절" + + +def is_time_stop_exit_reason(reason: Optional[str]) -> bool: + """청산 사유가 DT 시간손절인지 판별 (check_day_trade_exit 가 만든 사유 기준).""" + return bool(reason) and str(reason).startswith(TIME_STOP_REASON_PREFIX) + + # ── 분봉 파싱 헬퍼 ──────────────────────────────────────────────────────────── def parse_minute_candles(raw_output2: list) -> List[MinuteCandle]: @@ -771,7 +784,7 @@ def check_day_trade_exit( if gain_pct < settings.DAY_TRADE_TIME_STOP_MIN_GAIN: # 최소 수익 미달 (수수료 커버 못함) return DayTradeExit( action="SELL_ALL", - reason=f"시간 손절: {elapsed_min:.0f}분 경과, 수익={gain_pct*100:.1f}% < {settings.DAY_TRADE_TIME_STOP_MIN_GAIN*100:.1f}%", + reason=f"{TIME_STOP_REASON_PREFIX}: {elapsed_min:.0f}분 경과, 수익={gain_pct*100:.1f}% < {settings.DAY_TRADE_TIME_STOP_MIN_GAIN*100:.1f}%", ) # 4. 목표가 달성 → 50% 분할 매도 (CLAUDE.md 5.2: +2R 시 50% 분할 매도) diff --git a/backend/app/services/daytrading/entry_service.py b/backend/app/services/daytrading/entry_service.py index a924da8..b2c5f70 100644 --- a/backend/app/services/daytrading/entry_service.py +++ b/backend/app/services/daytrading/entry_service.py @@ -1035,6 +1035,11 @@ async def _try_enter( "take_profit": signal.target_price, }) + # [2026-10-01] 실제 체결가 확인 — 백그라운드(루프 비차단). 위 기록의 executed_price 는 + # 신호 시점 1분봉 종가다. 확인되면 원장·인메모리 진입가·ActiveTrade 를 체결가로 보정하고, + # 실패하면 신호가 기록이 그대로 남는다(warning). + _schedule_buy_fill_reconcile(stock_code, order_id, current_price, quantity) + logger.info( f"[DayEntry] ✅ 진입: {stock_code} {stock_name} " f"수량={quantity} 가격={current_price:,.0f} 신호={signal.strategy}" @@ -1052,3 +1057,59 @@ async def _try_enter( except Exception as e: logger.error(f"[DayEntry] _try_enter 오류 ({stock_code}): {e}", exc_info=True) return None + + +def _schedule_buy_fill_reconcile( + stock_code: str, order_id: str, signal_price: float, quantity: int, +): + """[2026-10-01] 매수 실제 체결가 확인을 백그라운드로 예약 (실시간 루프 비차단). + + 확인되면: + - TradeHistory(order_id) executed_price·total_amount·slippage(%) 갱신 (db_worker 큐) + - 인메모리 포지션 entry_price 를 체결가로 교체(같은 주문번호의 포지션이 남아 있을 때만). + 손절가·목표가는 신호 시점 절대가격 그대로 둔다(전략 파라미터 불변). + - ActiveTrade(day_trade) entry_price 갱신 — 재기동 복원도 체결가 기준이 된다. + 이미 일부/전량 청산된 뒤에 확인되면 그 청산 손익은 신호가 기준으로 남는다(보고된 잔여 위험). + """ + from app.services.daytrading import fill_reconciler + + from app.core.timeutil import now_kst as _now_kst + _order_date = _now_kst().date().isoformat() # 주문일(KST) — 확인이 늦어져도 이 날짜로 조회 + + async def _on_fill(filled_qty: int, avg_price: float) -> None: + if filled_qty < quantity: + logger.warning( + f"[DayEntry] 매수 부분체결 확인: {stock_code} {filled_qty}/{quantity}주 " + f"order_id={order_id} — 인메모리 수량은 주문 수량 기준(수동 확인 필요)" + ) + slippage_pct = ((avg_price - signal_price) / signal_price * 100) if signal_price > 0 else 0.0 + from decimal import Decimal + from app.services.db_worker import enqueue_update_trade_history_by_order_id + await enqueue_update_trade_history_by_order_id(order_id, { + # 식별 키(db_worker 가 setattr 하지 않음): KIS ODNO 는 일별 순번이라 날짜·구분으로 좁힌다 + "order_date": _order_date, + "order_type": "BUY", + "strategy_type": "daytrading", + "executed_price": Decimal(str(round(avg_price, 2))), + "total_amount": Decimal(str(round(avg_price * quantity, 2))), + "slippage": Decimal(str(round(slippage_pct, 4))), + }) + updated = False + async with day_trading_state.trade_lock: + pos = day_trading_state.positions.get(stock_code) + if pos is not None and str(pos.get("order_id", "")) == str(order_id): + pos["entry_price"] = float(avg_price) + updated = True + if updated: + await enqueue_active_trade_upsert({ + "stock_code": stock_code, + "strategy_name": "day_trade", + "entry_price": float(avg_price), + }) + logger.info( + f"[DayEntry] 매수 체결가 확인: {stock_code} 신호가={signal_price:,.0f} " + f"체결가={avg_price:,.2f} (슬리피지 {slippage_pct:+.3f}%)" + + ("" if updated else " — 포지션 이미 청산/교체됨, 원장만 보정") + ) + + return fill_reconciler.schedule(stock_code, order_id, "BUY", _on_fill) diff --git a/backend/app/services/daytrading/exit_service.py b/backend/app/services/daytrading/exit_service.py index 2482fa5..8fdd0de 100644 --- a/backend/app/services/daytrading/exit_service.py +++ b/backend/app/services/daytrading/exit_service.py @@ -31,10 +31,13 @@ from app.services.korea_investment_api import korea_investment_api from app.services.day_trading_strategy import ( parse_minute_candles, aggregate_to_5min, check_day_trade_exit, is_daily_loss_exceeded, + is_time_stop_exit_reason, ) from app.services.daytrading.position_manager import day_trading_state +from app.services.daytrading import fill_reconciler from app.services.db_worker import enqueue_trade_history, enqueue_close_active_trade from app.services.loss_tracker import loss_tracker +from app.services.trade_cost_core import net_realized_pnl logger = logging.getLogger(__name__) @@ -51,6 +54,26 @@ def get_stale_realized_pnl() -> float: return _stale_realized_pnl +def _dt_sell_tax_rate(pos: dict) -> float: + """DT 매도 거래세율 — 포지션에 시장 구분이 없으면 DAY_TRADE_DEFAULT_TAX_MARKET(스윙 기본과 동일).""" + market = str((pos or {}).get("market") or settings.DAY_TRADE_DEFAULT_TAX_MARKET or "KOSPI").upper() + if market == "KOSDAQ": + return float(settings.KR_SELL_TAX_RATE_KOSDAQ) + return float(settings.KR_SELL_TAX_RATE_KOSPI) + + +def dt_net_pnl(entry_price: float, exit_price: float, quantity: int, pos: dict) -> dict: + """[2026-10-01] DT 실현손익(비용 차감). 스윙 집행 경로와 같은 비용 정의(trade_cost_core). + + 매수·매도 수수료(KR_COMMISSION_RATE) + 매도 거래세(KR_SELL_TAX_RATE_*)를 뺀다. + 반환 키: gross_profit_loss / total_cost / net_profit_loss / net_profit_loss_rate + """ + return net_realized_pnl( + entry_price, exit_price, quantity, + float(settings.KR_COMMISSION_RATE), _dt_sell_tax_rate(pos), + ) + + def _fallback_stop_pct() -> float: """[M7] stop_loss 가 없을 때 사용할 폴백 손절 비율. @@ -528,8 +551,15 @@ async def _finalize_exit_impl( entry_price = pos.get("entry_price", 0) stock_name = pos.get("stock_name", stock_code) order_id = order_resp.get("output", {}).get("ODNO", "") - profit_loss = (price - entry_price) * quantity - profit_loss_rate = ((price - entry_price) / entry_price * 100) if entry_price > 0 else 0.0 + # [2026-10-01] 순손익(수수료·거래세 차감)으로 기록·집계한다. + # 일일 손실한도(DAY_TRADE_DAILY_LOSS_LIMIT)·당일 연패·loss_tracker 가 모두 이 값을 본다 + # → 비용만큼 한도가 **약간 더 일찍** 발동한다(보수적 방향, 의도). 손익분기(gross≈0) + # 청산도 순손익으로는 손실이다. + _pnl = dt_net_pnl(entry_price, price, quantity, pos) + gross_profit_loss = _pnl["gross_profit_loss"] + trade_cost = _pnl["total_cost"] + profit_loss = _pnl["net_profit_loss"] + profit_loss_rate = _pnl["net_profit_loss_rate"] if entry_price > 0 else 0.0 # 인메모리 상태 업데이트 (DB 직접 접근 금지) async with day_trading_state.trade_lock: @@ -561,10 +591,22 @@ async def _finalize_exit_impl( await day_trading_state.save_daily_state() # [P0] loss_tracker 연동 — 전량 매도 시 연속 손실 추적 + # [2026-10-01] 시간손절은 **중립**(손실로도 이익으로도 세지 않음 → 기록 자체를 생략). + # 기회비용 청산까지 3연패 → CONSECUTIVE_LOSS_BAN_DAYS 진입금지에 넣으면 그 밴이 + # 스윙 진입(swing/entry_service)까지 막는다. 하드/폴백 손절·일일한도·EOD·트레일링 등 + # 나머지 청산은 기존대로 기록한다. (당일 연패 쿨다운 loss_streak_today 는 위 update_pnl 이 + # 그대로 센다 — DT 전용·당일 한정 스로틀이라 스윙에 번지지 않는다.) if is_full_exit: - is_loss = profit_loss < 0 - await loss_tracker.record_trade_result_safe(stock_code, is_loss) - await loss_tracker.save_to_db(stock_code=stock_code) + if is_time_stop_exit_reason(reason): + logger.info( + f"[DayExit] 시간손절은 연속손실 집계 제외(중립): {stock_code} " + f"순손익={profit_loss:+.0f}원 — {settings.CONSECUTIVE_LOSS_BAN_COUNT}연패/" + f"{settings.CONSECUTIVE_LOSS_BAN_DAYS}일 밴 카운트 변화 없음" + ) + else: + is_loss = profit_loss < 0 + await loss_tracker.record_trade_result_safe(stock_code, is_loss) + await loss_tracker.save_to_db(stock_code=stock_code) # DB 기록 (큐 경유) await enqueue_trade_history({ @@ -579,10 +621,17 @@ async def _finalize_exit_impl( "status": "COMPLETED", "order_id": order_id, "exit_reason": reason, + "commission": trade_cost, "net_profit_loss": profit_loss, "profit_loss_rate": profit_loss_rate, "strategy_type": "daytrading", }) + # [2026-10-01] 실제 체결가 확인 — 백그라운드(루프 비차단). 확인되면 원장·당일 손익 보정. + _schedule_sell_fill_reconcile( + stock_code=stock_code, pos=dict(pos), order_id=order_id, signal_price=price, + quantity=quantity, entry_price=entry_price, recorded_net=profit_loss, + count_daily_pnl=count_daily_pnl, + ) if is_full_exit: await enqueue_close_active_trade(stock_code, strategy_name="day_trade") else: @@ -601,7 +650,8 @@ async def _finalize_exit_impl( sell_label = f"{int(sell_pct * 100)}%" if sell_pct < 1.0 else "전량" logger.info( f"[DayExit] ✅ 청산({sell_label}): {stock_code} {stock_name} " - f"수량={quantity}/{total_qty} 수익률={profit_loss_rate:.1f}% 이유={reason}" + f"수량={quantity}/{total_qty} 순수익률={profit_loss_rate:.2f}% " + f"(총손익={gross_profit_loss:+.0f} 비용={trade_cost:.0f} 순손익={profit_loss:+.0f}원) 이유={reason}" ) return { @@ -609,13 +659,72 @@ async def _finalize_exit_impl( "stock_name": stock_name, "price": price, "quantity": quantity, - "profit_loss": profit_loss, - "profit_loss_rate": profit_loss_rate, + "profit_loss": profit_loss, # 순손익(비용 차감) + "profit_loss_rate": profit_loss_rate, # 순수익률(진입 원금 대비 %) + "gross_profit_loss": gross_profit_loss, + "trade_cost": trade_cost, "reason": reason, "sell_pct": sell_pct, + "order_id": order_id, } +def _schedule_sell_fill_reconcile( + stock_code: str, pos: dict, order_id: str, signal_price: float, quantity: int, + entry_price: float, recorded_net: float, count_daily_pnl: bool, +): + """매도 실제 체결가 확인을 백그라운드로 예약 (실시간 루프 비차단). + + 확인되면: + - TradeHistory(order_id) 의 체결가·비용·순손익·수익률·슬리피지(%)를 db_worker 로 갱신 + - 기록했던 순손익과의 차이를 당일 daily_pnl 에 보정(전일 stale 청산은 _stale_realized_pnl — + §7 당일 미반영 규칙 유지). 당일 연패·loss_tracker 판정은 청산 시점 값 그대로 둔다. + 확인 실패면 신호가 기록이 그대로 남는다(fill_reconciler 가 warning). + """ + from app.core.timeutil import now_kst as _now_kst + _order_date = _now_kst().date().isoformat() # 주문일(KST) — 확인이 늦어져도 이 날짜로 조회 + + async def _on_fill(filled_qty: int, avg_price: float) -> None: + global _stale_realized_pnl + if filled_qty < quantity: + logger.warning( + f"[DayExit] 매도 부분체결 확인: {stock_code} {filled_qty}/{quantity}주 " + f"order_id={order_id} — 인메모리 수량은 주문 수량 기준으로 반영돼 있다(수동 확인 필요)" + ) + new = dt_net_pnl(entry_price, avg_price, quantity, pos) + delta = new["net_profit_loss"] - recorded_net + slippage_pct = ((avg_price - signal_price) / signal_price * 100) if signal_price > 0 else 0.0 + from decimal import Decimal + from app.services.db_worker import enqueue_update_trade_history_by_order_id + await enqueue_update_trade_history_by_order_id(order_id, { + # 식별 키(db_worker 가 setattr 하지 않음): KIS ODNO 는 일별 순번이라 날짜·구분으로 좁힌다 + "order_date": _order_date, + "order_type": "SELL", + "strategy_type": "daytrading", + "executed_price": Decimal(str(round(avg_price, 2))), + "total_amount": Decimal(str(round(avg_price * quantity, 2))), + "commission": Decimal(str(new["total_cost"])), + "net_profit_loss": Decimal(str(round(new["net_profit_loss"], 2))), + "profit_loss_rate": Decimal(str(round(new["net_profit_loss_rate"], 4))) if entry_price > 0 else None, + "slippage": Decimal(str(round(slippage_pct, 4))), + }) + if delta: + if count_daily_pnl: + async with day_trading_state.trade_lock: + day_trading_state.adjust_realized_pnl(delta) + await day_trading_state.save_daily_state() + else: + _stale_realized_pnl += delta + logger.info( + f"[DayExit] 매도 체결가 확인: {stock_code} 신호가={signal_price:,.0f} " + f"체결가={avg_price:,.2f} (슬리피지 {slippage_pct:+.3f}%) " + f"순손익 {recorded_net:+.0f}→{new['net_profit_loss']:+.0f}원 " + f"({'당일' if count_daily_pnl else '전일'} 손익 보정 {delta:+.0f}원)" + ) + + return fill_reconciler.schedule(stock_code, order_id, "SELL", _on_fill) + + async def close_stale_positions() -> list[dict]: """진입일이 오늘이 아닌(전일 미청산) 데이트레이딩 포지션을 시장가 전량 청산. diff --git a/backend/app/services/daytrading/fill_reconciler.py b/backend/app/services/daytrading/fill_reconciler.py new file mode 100644 index 0000000..94ee2a7 --- /dev/null +++ b/backend/app/services/daytrading/fill_reconciler.py @@ -0,0 +1,143 @@ +"""데이트레이딩 실제 체결가 확인 (백그라운드). + +[2026-10-01] DT 는 시장가 주문 후 신호 시점 가격(1분봉 종가)을 체결가로 기록했다. +실제 체결가와의 차이(슬리피지)가 원장·일일 손익·성과 통계에서 사라진다. + +설계: + - 주문 응답(KIS order-cash)에는 체결가가 없다 → 당일 주문체결 조회 + (`korea_investment_api.get_todays_orders`, inquire-daily-ccld)에서 주문번호(ODNO)로 찾는다. + `get_order_execution()` 은 쓰지 않는다 — 국내 경로가 같은 TR(TTTC8001R)을 + `/trading/inquire-ccnl` 로 호출해(같은 TR 의 다른 메서드 `get_todays_orders` 는 + `/trading/inquire-daily-ccld`) 경로가 어긋나 있을 가능성이 높고, 실패를 debug 로 삼켜 + 체결 0 과 구분되지 않는다(운영 확인 필요 — 보고 사항). + - **실시간 루프를 막지 않는다**: `schedule()` 이 asyncio 태스크로 띄우고 즉시 반환. + 전체를 `DAY_TRADE_FILL_CHECK_TIMEOUT_SEC` 로 감싼다 — KIS 토큰 rate-limit 의 + `asyncio.sleep(≈70초)` 에 걸려도 상한에서 끊긴다. + - DB I/O 없음. 반영은 호출부 콜백이 인메모리 + db_worker 큐로 한다(§3). + - 확인 실패 시 None → 호출부는 신호가 기록을 유지하고 warning 을 남긴다. +""" +from __future__ import annotations + +import asyncio +import logging +from typing import Awaitable, Callable, Optional + +from app.core.config import settings +from app.services.korea_investment_api import korea_investment_api + +logger = logging.getLogger(__name__) + +# create_task 결과의 강한 참조 — 참조가 없으면 GC 로 태스크가 중간에 사라질 수 있다. +_pending_tasks: "set[asyncio.Task]" = set() + + +def _norm_odno(value) -> str: + """KIS 주문번호 정규화 — 응답(ODNO)은 0 패딩, 조회(odno)는 경우에 따라 다르다.""" + s = str(value or "").strip() + return s.lstrip("0") or ("0" if s else "") + + +def parse_fill_row(rows: list, order_id: str) -> Optional[tuple[int, float]]: + """inquire-daily-ccld output1 에서 주문번호가 일치하는 행의 (체결수량, 평균체결가). + + 체결수량 0 이거나 평균가를 구할 수 없으면 None. + """ + target = _norm_odno(order_id) + if not target: + return None + for row in rows or []: + if not isinstance(row, dict): + continue + if _norm_odno(row.get("odno")) != target: + continue + try: + qty = int(float(row.get("tot_ccld_qty", 0) or 0)) + avg = float(row.get("avg_prvs", 0) or 0) + if avg <= 0 and qty > 0: + amt = float(row.get("tot_ccld_amt", 0) or 0) + avg = amt / qty if amt > 0 else 0.0 + except (TypeError, ValueError): + return None + if qty > 0 and avg > 0: + return qty, avg + return None + return None + + +async def _poll_fill(stock_code: str, order_id: str) -> Optional[tuple[int, float]]: + """체결 조회 폴링 (타임아웃은 호출부 wait_for 가 건다).""" + attempts = max(1, int(settings.DAY_TRADE_FILL_CHECK_ATTEMPTS)) + interval = max(0.0, float(settings.DAY_TRADE_FILL_CHECK_INTERVAL_SEC)) + await asyncio.sleep(max(0.0, float(settings.DAY_TRADE_FILL_CHECK_DELAY_SEC))) + for i in range(attempts): + rows = await korea_investment_api.get_todays_orders(stock_code) + fill = parse_fill_row(rows, order_id) + if fill is not None: + return fill + if i < attempts - 1: + await asyncio.sleep(interval) + return None + + +async def fetch_fill(stock_code: str, order_id: str, side: str) -> Optional[tuple[int, float]]: + """주문번호의 실제 (체결수량, 평균체결가). 실패·시간초과·미체결이면 None + warning.""" + if not order_id: + logger.warning(f"[DTFill] {stock_code} {side}: 주문번호 없음 — 신호가 기록 유지") + return None + timeout = float(settings.DAY_TRADE_FILL_CHECK_TIMEOUT_SEC) + try: + fill = await asyncio.wait_for(_poll_fill(stock_code, order_id), timeout=timeout) + except asyncio.TimeoutError: + logger.warning( + f"[DTFill] {stock_code} {side} 체결 조회 시간초과({timeout:.0f}초) " + f"order_id={order_id} — 신호가 기록 유지" + ) + return None + except Exception as e: + logger.warning( + f"[DTFill] {stock_code} {side} 체결 조회 실패 order_id={order_id}: " + f"{type(e).__name__}: {e} — 신호가 기록 유지" + ) + return None + if fill is None: + logger.warning( + f"[DTFill] {stock_code} {side} 체결 미확인 " + f"({int(settings.DAY_TRADE_FILL_CHECK_ATTEMPTS)}회 조회) order_id={order_id} — 신호가 기록 유지" + ) + return fill + + +def schedule( + stock_code: str, + order_id: str, + side: str, + on_fill: Callable[[int, float], Awaitable[None]], +) -> Optional[asyncio.Task]: + """체결 확인을 백그라운드로 띄우고 즉시 반환. 확인되면 `on_fill(qty, avg_price)` 호출. + + `DAY_TRADE_FILL_CHECK_ENABLED=False` 면 아무것도 하지 않는다(None). + """ + if not settings.DAY_TRADE_FILL_CHECK_ENABLED: + return None + + async def _run() -> None: + fill = await fetch_fill(stock_code, order_id, side) + if fill is None: + return + try: + await on_fill(*fill) + except Exception as e: + logger.warning( + f"[DTFill] {stock_code} {side} 체결가 반영 실패 order_id={order_id}: " + f"{type(e).__name__}: {e}", + exc_info=True, + ) + + try: + task = asyncio.get_running_loop().create_task(_run()) + except RuntimeError as e: + logger.warning(f"[DTFill] {stock_code} {side} 체결 확인 예약 실패: {e}") + return None + _pending_tasks.add(task) + task.add_done_callback(_pending_tasks.discard) + return task diff --git a/backend/app/services/daytrading/position_manager.py b/backend/app/services/daytrading/position_manager.py index 7d0c461..1e69b4b 100644 --- a/backend/app/services/daytrading/position_manager.py +++ b/backend/app/services/daytrading/position_manager.py @@ -200,6 +200,15 @@ class DayTradingState: # 이익 발생 시 연속 손실 카운트 리셋 (손익분기 realized==0은 중립 — 리셋하지 않음) self.loss_streak_today = 0 + def adjust_realized_pnl(self, delta: float) -> None: + """[2026-10-01] 실현손익 사후 보정 (체결가 확인으로 기록 손익이 바뀐 경우). + + 반드시 trade_lock 획득 후 호출해야 함. + daily_pnl 만 보정하고 loss_streak_today 는 건드리지 않는다 — 연패 판정은 청산 + 시점에 한 번만 한다(같은 청산을 두 번 세지 않기 위함). + """ + self.daily_pnl += float(delta) + def get_position(self, stock_code: str) -> dict | None: """특정 종목의 활성 포지션 정보 반환. diff --git a/backend/app/services/db_worker.py b/backend/app/services/db_worker.py index 5a24ee5..7980672 100644 --- a/backend/app/services/db_worker.py +++ b/backend/app/services/db_worker.py @@ -883,6 +883,117 @@ async def _handle_upsert_portfolio(session, data: dict) -> None: ) +# [2026-10-01] update_trade_history_by_order_id 의 식별·필터 전용 키 — setattr 대상이 아니다. +# strategy_type/order_type 은 중복 행을 좁히는 데만 쓴다(좁히기에 실패해 다른 전략 행이 +# 선택됐을 때 그 행의 전략 구분을 덮어쓰지 않도록). +_ORDER_ID_UPDATE_META_KEYS: frozenset = frozenset( + {"order_id", "order_date", "strategy_type", "order_type"} +) + + +def _norm_kis_order_id(value) -> str: + """KIS 주문번호 정규화(앞자리 0 제거). 저장 형식은 응답 ODNO 원문(0 패딩)이다.""" + s = str(value or "").strip() + return s.lstrip("0") + + +def _order_id_ref_date(data: dict) -> date: + """조회 기준일(KST). 우선순위: order_date(메타) > created_at > executed_at > 오늘. + + executed_at 은 체결 확인 시각(주문 후 수 초)이라 주문일과 같다. DLQ 재처리로 다음 날 + 실행돼도 payload 의 원래 날짜를 쓰므로 당일 필터가 행을 놓치지 않는다. + """ + for key in ("order_date", "created_at", "executed_at"): + v = data.get(key) + if v is None or v == "": + continue + try: + if isinstance(v, datetime): + return v.date() + if isinstance(v, date): + return v + if isinstance(v, str): + return datetime.fromisoformat(v).date() if "T" in v or " " in v else date.fromisoformat(v) + except (TypeError, ValueError): + logger.warning("⚠️ [Worker] order_id 갱신 기준일 파싱 실패 %s=%r — 다음 후보 사용", key, v) + return now_kst().date() + + +async def _handle_update_trade_history_by_order_id(session, data: dict) -> None: + """TradeHistory UPDATE by KIS 주문번호 — 정상 경로 단일 구현. + + [2026-10-01] KIS ODNO 는 **일별 순번**이라 날짜가 바뀌면 같은 번호가 반복된다. 예전 구현 + (`order_id == X` + `scalar_one_or_none()`)은 과거 행과 겹치면 MultipleResultsFound → + 단일 워커가 1+2+4초 재시도로 큐 전체를 멈추고 DLQ 로 보냈고, 당일 INSERT 가 유실된 + 상태면 **과거 행을 덮어써** 원장을 오염시켰다. + - 기준일(KST 00:00~다음날 00:00) created_at 범위로 한정한다(_order_id_ref_date). + - order_id 는 원문 일치 OR 앞자리 0 제거 일치. + - 그래도 여러 행이면 payload 의 strategy_type·order_type 으로 좁히고 created_at desc + 첫 행만 갱신 + warning (예외 없음 → 큐 정체 없음). + - 0건이면 warning 후 종료(재시도·DLQ 없음 — 기존 동작 유지). + """ + from datetime import timedelta + from sqlalchemy import func + + order_id = data.get("order_id") + if not order_id: + return + user_id = data.get("user_id") + ref = _order_id_ref_date(data) + start = datetime.combine(ref, datetime.min.time()) + end = start + timedelta(days=1) + + raw = str(order_id).strip() + norm = _norm_kis_order_id(raw) + id_cond = TradeHistory.order_id == raw + if norm: + id_cond = or_(id_cond, func.ltrim(TradeHistory.order_id, "0") == norm) + + query = select(TradeHistory).where( + id_cond, + TradeHistory.created_at >= start, + TradeHistory.created_at < end, + ) + if user_id is not None: + query = query.where(TradeHistory.user_id == user_id) + query = query.order_by(TradeHistory.created_at.desc(), TradeHistory.id.desc()) + rows = list((await session.execute(query)).scalars().all()) + + if not rows: + logger.warning( + "⚠️ [Worker] TradeHistory not found for order_id=%s (기준일 %s KST) — 갱신 생략", + order_id, ref.isoformat(), + ) + return + + if len(rows) > 1: + narrowed = rows + for key in ("strategy_type", "order_type"): + want = data.get(key) + if want is None: + continue + cand = [r for r in narrowed if getattr(r, key, None) == want] + if cand: + narrowed = cand + logger.warning( + "⚠️ [Worker] order_id=%s 기준일 %s 에 TradeHistory %d건 중복 — " + "strategy_type/order_type 으로 좁힌 %d건 중 최신 1건(id=%s)만 갱신", + order_id, ref.isoformat(), len(rows), len(narrowed), narrowed[0].id, + ) + rows = narrowed + + trade = rows[0] + for key, val in data.items(): + if key in _ORDER_ID_UPDATE_META_KEYS or val is None or not hasattr(trade, key): + continue + setattr(trade, key, val) + await session.commit() + logger.info( + "✅ [Worker] TradeHistory updated by order_id=%s (id=%s) status=%s", + order_id, trade.id, data.get("status", "?"), + ) + + async def _handle_upsert_active_trade(session, data: dict) -> None: """ActiveTrade UPSERT — 정상 경로와 종료 drain 이 공유하는 단일 구현.""" from app.models.models import ActiveTrade @@ -1054,29 +1165,7 @@ async def db_writer_worker() -> None: # ── 2b. TradeHistory UPDATE by order_id (KIS 주문번호 기준) ── elif task_type == "update_trade_history_by_order_id": - order_id = data.get("order_id") - user_id = data.get("user_id") - if order_id: - query = select(TradeHistory).where(TradeHistory.order_id == order_id) - if user_id is not None: - query = query.where(TradeHistory.user_id == user_id) - row = await session.execute(query) - trade = row.scalar_one_or_none() - if trade: - for key, val in data.items(): - if key != "order_id" and val is not None and hasattr(trade, key): - setattr(trade, key, val) - await session.commit() - logger.info( - "✅ [Worker] TradeHistory updated by order_id=%s status=%s", - order_id, - data.get("status", "?"), - ) - else: - logger.warning( - "⚠️ [Worker] TradeHistory not found for order_id=%s", - order_id, - ) + await _handle_update_trade_history_by_order_id(session, data) # ── 3. 단건 StockAnalysis UPSERT ────────────────────────── elif task_type == "upsert_stock_analysis": diff --git a/backend/app/services/korea_investment_api.py b/backend/app/services/korea_investment_api.py index 8d5323e..dd16320 100644 --- a/backend/app/services/korea_investment_api.py +++ b/backend/app/services/korea_investment_api.py @@ -165,6 +165,79 @@ async def _cache_set(key: str, ttl: int, data: Any) -> None: pass +# ── 국내 주문체결 조회 (inquire-daily-ccld) ──────────────────────────────── +# KIS 공식 "주식일별주문체결조회"(3개월 이내): GET /uapi/domestic-stock/v1/trading/inquire-daily-ccld +# TR — 현행 공식 샘플(koreainvestment/open-trading-api examples_llm/domestic_stock/inquire_daily_ccld): +# 실전 TTTC0081R / 모의 VTTC0081R. 구 TR(legacy/ 샘플·Postman v2.6): 실전 TTTC8001R / 모의 VTTC8001R. +# 구 TR 의 현재 지원 여부는 레포 근거로 확정 불가 → 현행 TR 우선, rt_cd != "0" 이면 구 TR 1회 폴백 +# (성공한 쪽을 인스턴스에 기억). 응답 output1[] = 주문 단위 행 +# (odno, pdno, sll_buy_dvsn_cd, ord_qty, tot_ccld_qty, avg_prvs, tot_ccld_amt, rmn_qty, cncl_yn ...). +# 연속조회: 응답 헤더 tr_cont F/M = 다음 있음 → 요청 헤더 tr_cont=N + CTX_AREA_FK100/NK100. +DAILY_CCLD_ENDPOINT = "/uapi/domestic-stock/v1/trading/inquire-daily-ccld" +DAILY_CCLD_TR_IDS = { # (실전, 모의) + "current": ("TTTC0081R", "VTTC0081R"), + "legacy": ("TTTC8001R", "VTTC8001R"), +} +ORDER_EXEC_MAX_PAGES = 5 # 단건 체결조회 연속조회 상한 (ODNO 필터가 무시돼도 무한루프 방지) + + +def _norm_kis_odno(value) -> str: + """KIS 주문번호 정규화 — 주문 응답(ODNO)과 조회 응답(odno)의 0 패딩 차이를 흡수.""" + s = str(value or "").strip() + return s.lstrip("0") or ("0" if s else "") + + +def _to_int_or_none(value) -> Optional[int]: + if value is None or (isinstance(value, str) and not value.strip()): + return None + try: + return int(float(str(value).replace(",", ""))) + except (TypeError, ValueError): + return None + + +def parse_order_execution_rows(rows, order_id: str) -> Optional[Dict[str, Any]]: + """inquire-daily-ccld output1 에서 주문번호가 일치하는 행을 체결 dict 로 변환 (순수 함수). + + 반환 (일치 행 없으면 None): + filled_qty : 총체결수량(tot_ccld_qty) + filled_price : 평균체결가(avg_prvs). 0 이면 tot_ccld_amt / tot_ccld_qty 로 보정 + ordered_qty : 주문수량(ord_qty) — 모르면 None + remaining_qty : 잔여수량(rmn_qty) — **필드가 없으면 None**(0 과 구분: 0 은 '잔량 소멸') + cancelled : cncl_yn == "Y" (취소는 별도 행(orgn_odno)이라 원주문은 N 일 수 있음 — 로그용) + cancel_confirmed_qty : 취소확인수량(cnc_cfrm_qty) — 없으면 None (로그용) + rejected_qty : 거부수량(rjct_qty) — 없으면 None (로그용) + """ + target = _norm_kis_odno(order_id) + if not target: + return None + if isinstance(rows, dict): + rows = [rows] + for row in rows or []: + if not isinstance(row, dict): + continue + if _norm_kis_odno(row.get("odno")) != target: + continue + filled_qty = _to_int_or_none(row.get("tot_ccld_qty")) or 0 + try: + filled_price = float(str(row.get("avg_prvs") or 0).replace(",", "")) + except (TypeError, ValueError): + filled_price = 0.0 + if filled_price <= 0 and filled_qty > 0: + amt = _to_int_or_none(row.get("tot_ccld_amt")) or 0 + filled_price = (amt / filled_qty) if amt > 0 else 0.0 + return { + "filled_qty": max(0, filled_qty), + "filled_price": float(filled_price) if filled_qty > 0 else 0.0, + "ordered_qty": _to_int_or_none(row.get("ord_qty")), + "remaining_qty": _to_int_or_none(row.get("rmn_qty")), + "cancelled": str(row.get("cncl_yn") or "").upper() == "Y", + "cancel_confirmed_qty": _to_int_or_none(row.get("cnc_cfrm_qty")), + "rejected_qty": _to_int_or_none(row.get("rjct_qty")), + } + return None + + def _round_to_krx_tick(price: int) -> int: """KRX 호가단위에 맞춰 가격을 올림(ceil) 처리. 모의투자 호가단위 오류 방지.""" if price < 1_000: @@ -1278,45 +1351,105 @@ class KoreaInvestmentAPI: raise async def get_todays_orders(self, stock_code: str = "") -> list[dict]: - """당일 주문체결 내역 조회 (inquire-daily-ccld). + """당일 주문체결 내역 조회 (inquire-daily-ccld) — 첫 페이지만(실전 100건 / 모의 15건, 역순). 주문 전송 후 응답을 못 받았을 때 '실제로 접수됐는지' 확인해 중복 주문을 막는 용도. - 실전: TTTC8001R / 모의: VTTC8001R. 실패 시 빈 리스트. + TR: 현행 TTTC0081R/VTTC0081R 우선, 실패 시 구 TTTC8001R/VTTC8001R. 실패 시 빈 리스트. """ try: - account_number_prefix = self.account_number[:8] - account_number_suffix = self.account_number[8:] - is_virtual = "vts" in self.base_url.lower() - today = datetime.now().strftime("%Y%m%d") - result, _ = await self._make_request( - "GET", - "/uapi/domestic-stock/v1/trading/inquire-daily-ccld", - "VTTC8001R" if is_virtual else "TTTC8001R", - params={ - "CANO": account_number_prefix, - "ACNT_PRDT_CD": account_number_suffix, - "INQR_STRT_DT": today, - "INQR_END_DT": today, - "SLL_BUY_DVSN_CD": "00", - "INQR_DVSN": "00", - "PDNO": stock_code or "", - "CCLD_DVSN": "00", - "ORD_GNO_BRNO": "", - "ODNO": "", - "INQR_DVSN_3": "00", - "INQR_DVSN_1": "", - "CTX_AREA_FK100": "", - "CTX_AREA_NK100": "", - }, - ) + result, _ = await self._daily_ccld_fetch_v2(stock_code=stock_code) if result.get("rt_cd") != "0": + # [2026-10-01] 이전에는 조용히 [] — 조회 실패와 '주문 없음' 이 구분되지 않았다. + logger.warning( + f"[get_todays_orders] {stock_code or '*'} 조회 실패 " + f"rt_cd={result.get('rt_cd')} msg_cd={result.get('msg_cd')} msg1={result.get('msg1')}" + ) return [] out = result.get("output1") or [] return out if isinstance(out, list) else [out] except Exception as e: - logger.debug(f"[get_todays_orders] 조회 실패: {e}") + # CLAUDE.md §4: 예외 로그 debug 금지 (중복주문 방지·DT 체결가 확인의 근거 조회) + logger.warning(f"[get_todays_orders] {stock_code or '*'} 조회 예외: {type(e).__name__}: {e}") return [] + def _daily_ccld_request_v2( + self, + stock_code: str = "", + order_id: str = "", + ctx_fk100: str = "", + ctx_nk100: str = "", + tr_generation: str = "current", + ) -> tuple: + """inquire-daily-ccld 요청 (tr_id, params) 구성 — 당일 조회. 모의/실전 TR 분기 포함. + + `get_todays_orders` 와 `get_order_execution` 이 같은 요청 구성을 공유한다 + (2026-10-01 이전에는 후자가 존재하지 않는 `/trading/inquire-ccnl` 경로로 따로 조립했다). + tr_generation: "current"(TTTC0081R/VTTC0081R) | "legacy"(TTTC8001R/VTTC8001R) + """ + is_virtual = "vts" in self.base_url.lower() + today = datetime.now().strftime("%Y%m%d") + real_tr, virtual_tr = DAILY_CCLD_TR_IDS[tr_generation] + tr_id = virtual_tr if is_virtual else real_tr + params = { + "CANO": self.account_number[:8], + "ACNT_PRDT_CD": self.account_number[8:], + "INQR_STRT_DT": today, + "INQR_END_DT": today, + "SLL_BUY_DVSN_CD": "00", # 00 전체 / 01 매도 / 02 매수 + "INQR_DVSN": "00", # 00 역순(최신 먼저) + "PDNO": stock_code or "", + "CCLD_DVSN": "00", # 00 전체 / 01 체결 / 02 미체결 + "ORD_GNO_BRNO": "", + "ODNO": order_id or "", + "INQR_DVSN_3": "00", + "INQR_DVSN_1": "", + "CTX_AREA_FK100": ctx_fk100 or "", + "CTX_AREA_NK100": ctx_nk100 or "", + "EXCG_ID_DVSN_CD": "KRX", # 거래소ID구분 (공식 샘플 기본값 KRX / NXT / SOR / ALL) + } + return tr_id, params + + async def _daily_ccld_fetch_v2( + self, + stock_code: str = "", + order_id: str = "", + ctx_fk100: str = "", + ctx_nk100: str = "", + next_page: bool = False, + ) -> tuple: + """inquire-daily-ccld 1페이지 호출 → (result, headers). 예외는 그대로 올린다. + + 현행 TR 이 rt_cd != "0" 이면 구 TR 로 1회 재시도하고, 구 TR 이 성공하면 이후 구 TR 을 + 우선 쓴다(인스턴스 상태). 둘 다 실패하면 마지막 결과를 그대로 반환(호출부가 warning). + 연속조회는 요청 헤더 tr_cont=N (공식 샘플 규약). + """ + preferred = getattr(self, "_daily_ccld_tr_generation", "current") + order = [preferred] + [g for g in ("current", "legacy") if g != preferred] + extra = {"tr_cont": "N"} if next_page else None + result, headers = {}, None + for i, gen in enumerate(order): + tr_id, params = self._daily_ccld_request_v2( + stock_code=stock_code, order_id=order_id, + ctx_fk100=ctx_fk100, ctx_nk100=ctx_nk100, tr_generation=gen, + ) + result, headers = await self._make_request( + "GET", DAILY_CCLD_ENDPOINT, tr_id, extra_headers=extra, params=params, + ) + if isinstance(result, dict) and result.get("rt_cd") == "0": + if gen != preferred: + logger.warning( + f"[daily-ccld] TR {gen} 로 전환 (선호 TR {preferred} 실패) — tr_id={tr_id}" + ) + self._daily_ccld_tr_generation = gen + return result, headers + if i == 0: + _r = result if isinstance(result, dict) else {} + logger.warning( + f"[daily-ccld] tr_id={tr_id} 실패 rt_cd={_r.get('rt_cd')} " + f"msg_cd={_r.get('msg_cd')} msg1={_r.get('msg1')} — 대체 TR 재시도" + ) + return result, headers + async def get_buyable_cash(self, stock_code: str = "005930") -> float: """주문가능현금(ord_psbl_cash) 조회 — 매수가능조회 API. @@ -1448,51 +1581,63 @@ class KoreaInvestmentAPI: async def get_order_execution(self, order_id: str, is_domestic: bool = True) -> Dict[str, Any]: """주문 체결 내역 단건 조회. - 국내: TTTC8001R (실전) / VTTC8001R (모의) + 국내: GET /uapi/domestic-stock/v1/trading/inquire-daily-ccld, + TTTC0081R/VTTC0081R (현행) → 실패 시 TTTC8001R/VTTC8001R (구) Returns: - {"filled_qty": int, "filled_price": float} - 체결 미확인 시 filled_qty=0 반환. - """ - try: - account_number_prefix = self.account_number[:8] - account_number_suffix = self.account_number[8:] - is_virtual = "vts" in self.base_url.lower() - today = datetime.now().strftime("%Y%m%d") + {"filled_qty": int, "filled_price": float} ← 기존 계약(불변) + + 추가 키(국내, 하위호환): "found"(주문행 발견), "ok"(조회 성공), + "ordered_qty", "remaining_qty"(None=모름), "cancelled", + "cancel_confirmed_qty", "rejected_qty" + 체결 미확인·조회 실패 시 filled_qty=0 반환. - if is_domestic: - tr_id = "VTTC8001R" if is_virtual else "TTTC8001R" - result, _ = await self._make_request( - "GET", - "/uapi/domestic-stock/v1/trading/inquire-ccnl", - tr_id, - params={ - "CANO": account_number_prefix, - "ACNT_PRDT_CD": account_number_suffix, - "INQR_STRT_DT": today, - "INQR_END_DT": today, - "SLL_BUY_DVSN_CD": "00", - "INQR_DVSN": "00", - "PDNO": "", - "CCLD_DVSN": "00", - "ORD_GNO_BRNO": "", - "ODNO": order_id, - "INQR_DVSN_3": "00", - "INQR_DVSN_1": "", - "CTX_AREA_FK100": "", - "CTX_AREA_NK100": "", - }, + [2026-10-01] 이전 구현은 `/domestic-stock/v1/trading/inquire-ccnl` 을 호출했다 — KIS 에 + 그런 경로는 없다(국내 `inquire-ccnl` 은 `/quotations/` 아래 시세 체결 FHKST01010300, + `/trading/inquire-ccnl` 은 해외 `/overseas-stock/` 의 체결조회 TTTS3035R). 그리고 실패를 debug 로 + 삼키거나(예외) 아예 로그 없이(4xx 응답) filled_qty=0 을 돌려줬다 → 체결 확인이 항상 실패. + """ + if not is_domestic: + # 해외 체결 조회(TTTS3035R)는 미구현 — 포트폴리오 동기화로 대체 + return {"filled_qty": 0, "filled_price": 0.0} + return await self._get_order_execution_domestic_impl(order_id) + + async def _get_order_execution_domestic_impl(self, order_id: str) -> Dict[str, Any]: + """국내 단건 체결조회 본체 — inquire-daily-ccld 를 ODNO 로 조회 + 클라이언트측 ODNO 매칭. + + 서버측 ODNO 필터가 기대대로 동작하지 않아도(전체 반환) 응답에서 주문번호로 다시 고르고, + 연속조회(tr_cont F/M → N)로 최대 ORDER_EXEC_MAX_PAGES 페이지까지 찾는다. + """ + empty = {"filled_qty": 0, "filled_price": 0.0, "found": False, "ok": False, + "ordered_qty": None, "remaining_qty": None, "cancelled": False, + "cancel_confirmed_qty": None, "rejected_qty": None} + if not order_id: + return empty + try: + ctx_fk, ctx_nk = "", "" + for page in range(ORDER_EXEC_MAX_PAGES): + result, headers = await self._daily_ccld_fetch_v2( + order_id=order_id, ctx_fk100=ctx_fk, ctx_nk100=ctx_nk, next_page=page > 0, ) - output = result.get("output1", []) - if output: - row = output[0] if isinstance(output, list) else output - filled_qty = int(row.get("tot_ccld_qty", 0) or 0) - filled_price = float(row.get("avg_prvs", 0) or 0) - return {"filled_qty": filled_qty, "filled_price": filled_price} - # 해외 체결 조회는 별도 TR_ID 필요 — 현재 미지원 (포트폴리오 동기화로 대체) - return {"filled_qty": 0, "filled_price": 0.0} + if not isinstance(result, dict) or result.get("rt_cd") != "0": + _r = result if isinstance(result, dict) else {} + logger.warning( + f"[get_order_execution] order_id={order_id} 체결조회 실패 " + f"rt_cd={_r.get('rt_cd')} msg_cd={_r.get('msg_cd')} msg1={_r.get('msg1')}" + ) + return empty + parsed = parse_order_execution_rows(result.get("output1") or [], order_id) + if parsed is not None: + return {**parsed, "found": True, "ok": True} + tr_cont = (headers.get("tr_cont") if headers is not None else None) or "" + ctx_fk = str(result.get("ctx_area_fk100") or "").strip() + ctx_nk = str(result.get("ctx_area_nk100") or "").strip() + if tr_cont not in ("F", "M") or not ctx_nk: + break + return {**empty, "ok": True} # 조회는 성공했으나 주문행 없음(접수 전/다른 날) except Exception as e: - logger.debug(f"[get_order_execution] order_id={order_id} 조회 오류: {e}") - return {"filled_qty": 0, "filled_price": 0.0} + logger.warning( + f"[get_order_execution] order_id={order_id} 체결조회 예외: {type(e).__name__}: {e}" + ) + return empty async def cancel_order(self, order_id: str, quantity: int) -> Dict[str, Any]: """주문 취소""" diff --git a/backend/app/services/sizing_core.py b/backend/app/services/sizing_core.py index a711aec..c75ceb4 100644 --- a/backend/app/services/sizing_core.py +++ b/backend/app/services/sizing_core.py @@ -43,6 +43,7 @@ __all__ = [ "amount_to_quantity", "half_kelly_fraction", "effective_min_buy_amount", + "remaining_position_room", ] @@ -198,3 +199,27 @@ def effective_min_buy_amount( if small_account_cap <= 0: return config_min_amount return min(config_min_amount, small_account_cap) + + +# ── 종목당 누적 한도 ───────────────────────────────────────────────────────── + +def remaining_position_room( + total_assets: float, + position_cap_ratio: float, + existing_position_value: float, +) -> float: + """종목당 **누적** 잔여 한도 = `max(0, 총자산 × 종목당상한 − 해당 종목 기존 보유 평가액)`. + + `clamp_by_ratios(..., [MAX_POSITION_SIZE], ...)` 는 **주문 1건**을 상한으로 묶을 뿐이라 + 같은 종목이 매일 추천되면 추가매수가 쌓여 종목 비중이 상한을 넘을 수 있다 + (CLAUDE.md §5.1 "종목당 4% 절대 상한"). 이 함수는 기존 보유분을 차감한 잔여 한도를 + 돌려준다. + + **floor 있음** — 이미 한도를 초과했으면 0. `total_assets <= 0` 또는 + `position_cap_ratio <= 0` 이면 한도를 계산할 수 없으므로 0 을 반환한다 + (호출부가 "검증 불가"를 별도로 판단한다). 음수 보유 평가액은 0 으로 본다. + """ + if total_assets <= 0 or position_cap_ratio <= 0: + return 0.0 + existing = max(0.0, float(existing_position_value or 0.0)) + return max(0.0, total_assets * position_cap_ratio - existing) diff --git a/backend/app/services/trade_cost_core.py b/backend/app/services/trade_cost_core.py new file mode 100644 index 0000000..d21f2d0 --- /dev/null +++ b/backend/app/services/trade_cost_core.py @@ -0,0 +1,72 @@ +"""매매 비용·순손익 순수 계산 모듈. + +settings·DB·네트워크를 참조하지 않는다(indicators_core / sizing_core 와 같은 원칙). +**비용률 숫자는 이 모듈에 없다** — 호출부가 settings(KR_COMMISSION_RATE, +KR_SELL_TAX_RATE_KOSPI/KOSDAQ)에서 꺼내 넘긴다. + +비용 정의는 스윙 집행 경로(`trade_helpers.calculate_trade_costs` → +`trade_executor` 매도 기록)와 동일하다: + - 매수 수수료 = 진입가 × 수량 × 수수료율 + - 매도 수수료 = 청산가 × 수량 × 수수료율 + - 매도 거래세 = 청산가 × 수량 × 거래세율 + - 각 항목을 원 단위로 반올림한 뒤 합산 +슬리피지는 비용률에 넣지 않는다 — 실제 체결가를 쓰면 이미 손익에 반영되어 있고, +`DAY_TRADE_COST_PCT`(수수료+슬리피지 추정치)는 진입/청산 **판정 게이트**용이라 기록에 쓰지 않는다. +""" +from __future__ import annotations + + +def round_trip_costs( + entry_price: float, + exit_price: float, + quantity: int, + commission_rate: float, + sell_tax_rate: float, +) -> dict: + """왕복 매매 비용(원). `trade_helpers.calculate_trade_costs` 와 같은 반올림 규칙. + + Returns: + {"buy_commission", "sell_commission", "sell_tax", "total_cost"} + """ + qty = max(0, int(quantity)) + buy_commission = float(entry_price) * qty * float(commission_rate) + sell_commission = float(exit_price) * qty * float(commission_rate) + sell_tax = float(exit_price) * qty * float(sell_tax_rate) + total_cost = buy_commission + sell_commission + sell_tax + return { + "buy_commission": round(buy_commission, 0), + "sell_commission": round(sell_commission, 0), + "sell_tax": round(sell_tax, 0), + "total_cost": round(total_cost, 0), + } + + +def net_realized_pnl( + entry_price: float, + exit_price: float, + quantity: int, + commission_rate: float, + sell_tax_rate: float, +) -> dict: + """비용 차감 실현손익. + + Returns: + { + "gross_profit_loss": (청산가 − 진입가) × 수량, + "total_cost": 왕복 비용(원), + "net_profit_loss": gross − total_cost, + "net_profit_loss_rate": net / (진입가 × 수량) × 100 (진입 원금 대비 %, 진입가<=0 이면 0.0), + } + """ + qty = max(0, int(quantity)) + gross = (float(exit_price) - float(entry_price)) * qty + costs = round_trip_costs(entry_price, exit_price, qty, commission_rate, sell_tax_rate) + net = gross - costs["total_cost"] + invested = float(entry_price) * qty + rate = (net / invested * 100.0) if invested > 0 else 0.0 + return { + "gross_profit_loss": gross, + "total_cost": costs["total_cost"], + "net_profit_loss": net, + "net_profit_loss_rate": rate, + } diff --git a/backend/app/services/trade_executor.py b/backend/app/services/trade_executor.py index fab2fdf..97b3bbe 100644 --- a/backend/app/services/trade_executor.py +++ b/backend/app/services/trade_executor.py @@ -32,6 +32,7 @@ from app.services.technical_indicators import ( # §8.5-2 포지션 사이징 단일 출처 (순수 계산 모듈 — settings/DB/네트워크 없음) from app.services.sizing_core import ( amount_to_quantity, clamp_by_ratios, effective_min_buy_amount, half_kelly_fraction, + remaining_position_room, ) from app.services.signal_types import BUY_KEYWORDS, SELL_KEYWORDS, STRONG_BUY_KEYWORDS, BLOCK_KEYWORDS from app.services.correlation_filter import validate_correlation_filter @@ -531,16 +532,33 @@ async def _verify_and_sync_execution( db_trade_id: int, delay: int = 3, ) -> None: - """주문 후 KIS 체결 내역 조회 → DB 업데이트 + 슬리피지 기록 + 부분 체결 재주문. + """주문 후 KIS 체결 내역 조회 → DB 업데이트 + 슬리피지 기록 (부분 체결 자동 재주문 없음). [동작 흐름] 1. delay초 대기 (체결 확인 전 KIS 서버 처리 시간 확보) 2. KIS 체결 조회 API 호출 → filled_qty, filled_price 추출 3. 슬리피지 계산: (체결가 - 주문가) / 주문가 × 100 4. db_worker 큐를 통해 TradeHistory UPDATE (CLAUDE.md 3.2 준수) - - 완전 체결(filled_qty == ordered_quantity) → status = "COMPLETED" - - 부분 체결(filled_qty < ordered_quantity) → status = "PARTIAL" + 잔여 재주문 + - 완전 체결(filled_qty >= ordered_quantity) → status = "COMPLETED" + - 부분 체결 + KIS 잔량 소멸(rmn_qty == 0: 취소·거부·만료) → 체결분만 기록(COMPLETED) + + warning("수동 확인"). **자동 재주문하지 않는다** (아래 ③) + - 부분 체결 + 잔량이 KIS 에 살아 있음/모름 → DB 미갱신 + warning (재주문 금지) 5. 포트폴리오 동기화 트리거 + + [2026-10-01] `get_order_execution` 이 존재하지 않는 경로를 호출해 이 함수의 체결 반영 + 분기는 **한 번도 실행된 적이 없다**. 경로를 고치면서 처음 살아나는 분기의 결함을 함께 고쳤다: + ① 첫 부분체결 관측에서 곧바로 break → 잔량이 아직 KIS 에 지정가 주문으로 살아 있는데 + 같은 수량을 재주문 = **중복 주문(과다 매수 / 과다 매도 시도)**. 이제 전량 체결 또는 + 잔량 소멸까지 폴링을 계속한다. + ② status "PARTIAL" 은 매수 재진입 가드(당일 중복매수·일일횟수·쿨다운: EXECUTED/COMPLETED + (/PENDING) 만 셈)와 손익 집계(COMPLETED/FILLED 만 셈)에서 빠진다 → 부분체결 BUY 가 + 당일 재매수를 허용하고 부분체결 SELL 이 성과에서 사라진다. 체결분만 수량에 반영하고 + status 는 COMPLETED 를 유지한다. + ③ 잔량 소멸 시 재주문도 제거했다. 잔량이 사라지는 경우는 사실상 사용자 HTS 취소·KIS 거부· + 만료뿐이고(취소는 별도 행(orgn_odno)으로 기록돼 원주문 cncl_yn 이 N 일 수 있다), + `_reorder_unfilled` 는 MDD fail-safe·밴·종목당 4%·현금·주문 세마포어/Redis 락· + TradeHistory·체결 검증을 전부 우회한다. 취소 의도를 덮어쓰거나 가드 없는 주문을 내느니 + warning 으로 수동 확인을 요청한다. `_reorder_unfilled` 는 시그니처 보존용으로만 남긴다. """ await asyncio.sleep(delay) # KIS 서버 체결 처리 대기 @@ -550,25 +568,29 @@ async def _verify_and_sync_execution( return try: - # KIS 체결 내역 조회 (TR: TTTC8001R / VTTC8001R) + # KIS 체결 내역 조회 (inquire-daily-ccld, TR: TTTC0081R→폴백 TTTC8001R / 모의 V*) # [실전 안정화] 최대 5회 폴링 (3초 간격, 최대 15초) — 체결 지연 대응 filled_qty = 0 filled_price = 0.0 + remaining_qty = None exec_result = {} max_polls = 5 poll_interval = 3 for poll in range(max_polls): exec_result = await korea_investment_api.get_order_execution(order_id, is_domestic) - filled_qty = int(exec_result.get("filled_qty", 0)) + filled_qty = int(exec_result.get("filled_qty", 0) or 0) filled_price = float(exec_result.get("filled_price", order_price) or order_price) + remaining_qty = exec_result.get("remaining_qty") - if filled_qty > 0: - break # 체결 확인됨 + if filled_qty >= ordered_quantity: + break # 전량 체결 + if filled_qty > 0 and remaining_qty == 0: + break # 부분 체결 후 잔량 소멸(취소·거부) — 더 기다려도 변하지 않음 if poll < max_polls - 1: logger.debug( - f"[VERIFY_EXEC] {stock_code} {order_type} 체결 미확인 " + f"[VERIFY_EXEC] {stock_code} {order_type} 체결 {filled_qty}/{ordered_quantity} " f"({poll + 1}/{max_polls}), {poll_interval}초 후 재시도" ) await asyncio.sleep(poll_interval) @@ -576,12 +598,21 @@ async def _verify_and_sync_execution( if filled_qty == 0: logger.warning( f"[VERIFY_EXEC] {stock_code} {order_type} " - f"{max_polls}회 폴링 후에도 체결 미확인 — order_id={order_id}" + f"{max_polls}회 폴링 후에도 체결 미확인 — order_id={order_id} " + f"(조회성공={exec_result.get('ok')}, 주문행발견={exec_result.get('found')})" ) + if filled_qty > ordered_quantity: + # 다른 주문 행과 혼동된 경우 등 — 주문 수량을 넘겨 기록하지 않는다 + logger.warning( + f"[VERIFY_EXEC] {stock_code} {order_type} 체결수량 {filled_qty} > 주문수량 " + f"{ordered_quantity} — 주문수량으로 클램프 (order_id={order_id})" + ) + filled_qty = ordered_quantity + # ── 슬리피지 계산: (체결가 - 주문가) / 주문가 × 100 ───────────────── slippage_pct = 0.0 - if order_price > 0 and filled_price > 0: + if filled_qty > 0 and order_price > 0 and filled_price > 0: slippage_pct = ((filled_price - order_price) / order_price) * 100 logger.info( f"[SLIPPAGE] {stock_code} {order_type} " @@ -589,13 +620,24 @@ async def _verify_and_sync_execution( f"슬리피지={slippage_pct:+.3f}%" ) + partial = 0 < filled_qty < ordered_quantity + remainder_dead = partial and remaining_qty == 0 + if partial and not remainder_dead: + # 잔량이 KIS 에 미체결 지정가로 살아 있다(또는 모른다) → 재주문하면 중복 주문. + # 최종 체결 수량이 아직 정해지지 않았으므로 원장도 건드리지 않는다(포트폴리오 sync 가 진실). + logger.warning( + f"[PARTIAL_FILL] {stock_code} {order_type} {filled_qty}/{ordered_quantity}주 체결, " + f"잔량 {remaining_qty if remaining_qty is not None else '미상'}주 KIS 미체결 주문 유지 " + f"— 재주문·원장 갱신 안 함 (order_id={order_id})" + ) + # ── DB 업데이트 (CLAUDE.md 3.2: db_worker 큐 경유, 직접 세션 금지) ─── - if filled_qty > 0: - partial = filled_qty < ordered_quantity # 부분 체결 여부 + if filled_qty > 0 and (not partial or remainder_dead): update_fields = { "executed_quantity": filled_qty, "executed_price": Decimal(str(filled_price)), - "status": "PARTIAL" if partial else "COMPLETED", + # PARTIAL 금지 — 위 docstring ② (가드·집계 누락) + "status": "COMPLETED", "executed_at": _get_kst_now(), "slippage": Decimal(str(round(slippage_pct, 4))), } @@ -608,24 +650,14 @@ async def _verify_and_sync_execution( from app.services.db_worker import enqueue_update_trade_history_by_order_id await enqueue_update_trade_history_by_order_id(order_id, update_fields) - # ── 부분 체결 → 미체결 수량 재주문 ────────────────────── - if partial: + # ── 부분 체결 + 잔량 소멸 → 자동 재주문 금지, 수동 확인 요청 (docstring ③) ── + if remainder_dead: remain = ordered_quantity - filled_qty logger.warning( - f"[PARTIAL_FILL] {stock_code} {order_type} " - f"{filled_qty}/{ordered_quantity}주 체결 → 잔여 {remain}주 재주문" - ) - # 재주문은 백그라운드 태스크로 분리 (현재 흐름 블록 방지) - asyncio.create_task( - _reorder_unfilled( - stock_code=stock_code, - stock_name=stock_name, - order_type=order_type, - quantity=remain, - price=order_price, - is_domestic=is_domestic, - exchange_code=exchange_code, - ) + f"[PARTIAL_FILL] {stock_code}/{stock_name} {order_type} order_id={order_id} " + f"체결 {filled_qty}/{ordered_quantity}주, 잔량 {remaining_qty}주(미체결 {remain}주 소멸) " + f"취소확인={exec_result.get('cancel_confirmed_qty')} 거부={exec_result.get('rejected_qty')} " + f"cncl_yn={'Y' if exec_result.get('cancelled') else 'N'} — 자동 재주문 안 함, 수동 확인 필요" ) await _delayed_sync_portfolio(0) # KIS 잔고 기준으로 포트폴리오 재동기화 @@ -667,6 +699,83 @@ async def _reorder_unfilled( logger.error(f"[REORDER_ERROR] {stock_code} {order_type}: {type(e).__name__}: {e}") +def _existing_position_qty_v2(holding, stock_code: str) -> int: + """해당 종목의 기존 보유 수량 (종목당 누적 상한 산정용). + + Portfolio 행은 stock_code UNIQUE 라 스윙+DT 수량이 합산돼 있다(CLAUDE.md §3.5). + §5.1 의 종목당 상한은 '계좌 내 그 종목 비중'이므로 **전략 구분 없이 전체**를 센다 + (보수적). 단 DT 가 sync 이전에 산 수량은 행에 아직 없을 수 있으므로 + `max(행 수량, DT 인메모리 수량)` 으로 잡는다 — 더하면 sync 이후 이중 계상된다. + """ + _row_qty = int(getattr(holding, "quantity", 0) or 0) if holding is not None else 0 + try: + from app.services.daytrading.position_manager import day_trading_state as _dts + _dt_qty = int((_dts.positions.get(stock_code) or {}).get("quantity", 0) or 0) + except Exception as _e: + logger.warning( + f"[POSITION_CAP] {stock_code}: DT 인메모리 수량 조회 실패 — Portfolio 행 수량만 반영: {_e}" + ) + _dt_qty = 0 + return max(_row_qty, _dt_qty) + + +def _apply_position_cap_room_v2( + invest_amount: float, + holding, + stock_code: str, + stock_name: str, + current_price: float, + total_assets: float, + position_cap_ratio: float, +) -> tuple[float, str | None]: + """종목당 누적 상한 적용. 반환 = (클램프된 금액, SKIP 사유 또는 None). + + 매수 예산 = min(기존 예산, 총자산 × position_cap_ratio − 기존 보유 평가액). + 잔여 한도가 실효 최소주문금액(effective_min_buy_amount) 미만이면 + 'position_cap_reached' 로 건너뛴다. 보유가 있는데 총자산을 모르면(잔고 조회 실패) + 한도를 검증할 수 없으므로 'position_cap_unverifiable' 로 추가매수를 막는다. + + 리스크 가드이므로 이 함수 자체에 try 로 감싼 판정이 없다(§4, §8.1-10). + 신규 진입(보유 0)은 잔여 한도 = 총자산 × cap 이라 기존 1건 클램프와 동일하게 동작한다. + """ + _existing_qty = _existing_position_qty_v2(holding, stock_code) + if _existing_qty <= 0: + return invest_amount, None + + _existing_value = _existing_qty * float(current_price) + if total_assets <= 0: + logger.warning( + f"[POSITION_CAP] {stock_code}/{stock_name}: 기존 보유 {_existing_qty}주 " + f"(평가 {_existing_value:,.0f}원)인데 총자산 미확인 — 종목당 상한 " + f"{position_cap_ratio:.1%} 검증 불가로 추가매수 차단" + ) + return 0.0, "position_cap_unverifiable" + + _room = remaining_position_room(total_assets, position_cap_ratio, _existing_value) + _min_buy = effective_min_buy_amount( + config_min_amount=settings.MIN_BUY_AMOUNT_KRW, + total_assets=total_assets, + position_cap_ratio=position_cap_ratio, + factor=0.9, + ) + if _room < _min_buy: + logger.info( + f"[POSITION_CAP] {stock_code}/{stock_name}: 종목당 상한 도달 — 기존 " + f"{_existing_qty}주 평가 {_existing_value:,.0f}원 = 총자산의 " + f"{_existing_value / total_assets:.2%} (상한 {position_cap_ratio:.1%}), " + f"잔여 {_room:,.0f}원 < 최소주문 {_min_buy:,.0f}원 — 추가매수 건너뜀" + ) + return 0.0, "position_cap_reached" + + if invest_amount > _room: + logger.info( + f"[POSITION_CAP] {stock_code}/{stock_name}: 기존 보유 {_existing_value:,.0f}원 반영 — " + f"매수 예산 {invest_amount:,.0f} → {_room:,.0f}원 (종목당 상한 {position_cap_ratio:.1%})" + ) + return _room, None + return invest_amount, None + + async def _execute_buy( stock: dict, db: AsyncSession, @@ -985,6 +1094,23 @@ async def _execute_buy( else: invest_amount = min(invest_amount, available_cash) + # ── 7.6. 종목당 **누적** 상한 (CLAUDE.md §5.1) ───────────────────────── + # [2026-10-01] 위 클램프는 '주문 1건' 기준이라, 같은 종목이 매일 추천되면 + # 추가매수가 쌓여 종목 비중이 MAX_POSITION_SIZE 를 넘었다. 기존 보유 평가액을 + # 차감한 잔여 한도로 다시 묶는다. 리스크 가드이므로 try 밖에 둔다(§4, §8.1-10). + _pos_cap_result = _apply_position_cap_room_v2( + invest_amount=invest_amount, + holding=holding, + stock_code=stock_code, + stock_name=stock_name, + current_price=current_price, + total_assets=total_assets, + position_cap_ratio=_adaptive_position_cap, + ) + if _pos_cap_result[1] is not None: + return {"action": "SKIP", "reason": _pos_cap_result[1], "stock": stock_name} + invest_amount = _pos_cap_result[0] + # [BUG FIX] 국면별 배율(_position_multiplier)과 뉴스 배율(_news_position_scale)을 # invest_amount에 반영 — 기존에는 stock dict에 주입만 되고 실제로 사용되지 않았음. try: @@ -1241,6 +1367,64 @@ async def _execute_buy( await _release_order_lock(stock_code, "BUY") +def _dt_inmemory_qty_v2(stock_code: str) -> int | None: + """DT 인메모리 포지션 수량. 조회 실패 시 None(호출부가 '확인 불가'로 취급).""" + try: + from app.services.daytrading.position_manager import day_trading_state as _dts + return int((_dts.positions.get(stock_code) or {}).get("quantity", 0) or 0) + except Exception as e: + logger.warning(f"[SELL] {stock_code}: DT 인메모리 수량 조회 실패: {type(e).__name__}: {e}") + return None + + +# 자동 매도 대상이 아닌 Portfolio.strategy_type — 사용자 수동 판단 원칙. +# external : sync 가 시스템 매수 근거 없이 발견한 유입 잔고(portfolio.EXTERNAL_STRATEGY_TYPE) +# manual : manual_buy / portfolio_v2 수동 매수 +_MANUAL_JUDGMENT_STRATEGY_TYPES = ("external", "manual") + +# 스윙 엔진이 '자기 것'으로 간주하는 strategy_type (NULL 레거시 행은 별도로 포함). +# 매도 소유권(_sell_ownership_block_reason_v2)과 일일 매수 한도 집계 +# (_count_swing_buys_today_v2)가 같은 기준을 쓴다 — 한쪽만 바꾸지 말 것. +# swing/position_manager.STRATEGY_TYPE("swing") 과 동일. +_SWING_OWNER_STRATEGY_TYPES = ("swing",) + + +def _swing_owned_clause_v2(column): + """스윙 소유 행 SQL 필터 = `column IS NULL OR column IN (_SWING_OWNER_STRATEGY_TYPES)`. + + NULL 을 명시 포함한다(SQL 3값 논리 — `IN` 만 쓰면 legacy NULL 행이 빠진다). + """ + return or_(column.is_(None), column.in_(_SWING_OWNER_STRATEGY_TYPES)) + + +def _sell_ownership_block_reason_v2(holding, stock: dict) -> str | None: + """추천 경로 매도(_execute_sell)가 이 보유 행을 팔아도 되는지 판정. + + swing/exit_service 가 `get_swing_holdings()`(strategy_type=='swing') 만 청산하는 것과 + 같은 기준이다. 반환이 None 이면 허용, 문자열이면 SKIP 사유. + + - external / manual → 'manual_judgment_holding' (warning) + - DT 계열(DT_STRATEGY_NAMES) → 'owned_by_daytrading' — DT 엔진 소유 + (단 호출자 태그도 DT 계열이면 자기 보유분이므로 허용) + - 그 외 비-swing(us_swing 등) → 'owned_by_other_strategy' — 호출자 태그와 같을 때만 허용 + + strategy_type 이 비어 있으면 모델 기본값과 같이 'swing' 으로 본다. + MDD 강제축소(mdd_manager)·DT 청산·swing/exit_service 는 이 함수를 거치지 않는다 + (각자 주문 경로를 가진다) — 리스크 청산은 영향받지 않는다(§8.1-11). + """ + from app.services.db_worker import DT_STRATEGY_NAMES + + _holding_type = (getattr(holding, "strategy_type", None) or "swing") + _caller_tag = stock.get("strategy_type") or "swing" + if _holding_type in _SWING_OWNER_STRATEGY_TYPES: + return None + if _holding_type in _MANUAL_JUDGMENT_STRATEGY_TYPES: + return "manual_judgment_holding" + if _holding_type in DT_STRATEGY_NAMES: + return None if _caller_tag in DT_STRATEGY_NAMES else "owned_by_daytrading" + return None if _caller_tag == _holding_type else "owned_by_other_strategy" + + async def _execute_sell( stock: dict, db: AsyncSession, @@ -1269,6 +1453,39 @@ async def _execute_sell( - 기본 손절 -6% (CLAUDE.md 5.2), 익절 2R (ATR 기반) """ + # ── 보유 행 소유권 가드 (2026-10-01) ───────────────────────────────────── + # 추천 경로는 strategy_type 검사 없이 보유 행을 스윙 이름으로 팔았다 + # (external·manual 유입 잔고, strategy_type='daytrading' 행 포함). + # 이 함수의 호출자는 execute_auto_trade 하나뿐이며 MDD 강제축소·DT 청산· + # swing/exit_service 는 별도 주문 경로라 영향이 없다. try 밖 가드(§4). + _ownership_block = _sell_ownership_block_reason_v2(holding, stock) + if _ownership_block is not None: + _h_type = getattr(holding, "strategy_type", None) + _msg = ( + f"[SELL] {stock_code}/{stock_name}: 보유 {getattr(holding, 'quantity', '?')}주 " + f"strategy_type='{_h_type}' — 추천 경로 자동매도 스킵({_ownership_block}, " + f"요청 sell_pct={sell_pct*100:.0f}%, 호출 태그='{stock.get('strategy_type', 'swing')}')" + ) + if _ownership_block == "owned_by_daytrading": + # [2026-10-01] DT 인메모리에 포지션이 없는데 daytrading 행이 남아 있으면 + # 고아 행일 수 있다(재시작·stale 청산 실패 등) — 아무 엔진도 팔지 않으므로 + # info 에 묻히지 않게 warning 으로 올린다. + _dt_mem_qty = _dt_inmemory_qty_v2(stock_code) + if _dt_mem_qty is None or _dt_mem_qty <= 0: + logger.warning( + _msg + f" — DT 인메모리 포지션 " + f"{'조회 실패' if _dt_mem_qty is None else '0주'}인데 daytrading 행 " + f"{getattr(holding, 'quantity', '?')}주가 남아 있음: 고아 행 가능성, " + f"수동 확인 필요 ({stock_name}/{stock_code})" + ) + else: + logger.info(_msg + f" — DT 엔진 소유(인메모리 {_dt_mem_qty}주)") + elif _ownership_block == "manual_judgment_holding": + logger.warning(_msg + " — 유입/수동 보유분은 수동 판단 대상") + else: + logger.warning(_msg + " — 다른 전략 엔진 소유") + return {"action": "SKIP", "reason": _ownership_block, "stock": stock_name} + # [CLAUDE.md 5.2] 분할매도: sell_pct에 따라 매도 수량 결정 # [2026-09-23] Portfolio 행은 stock_code UNIQUE 라 같은 종목의 스윙+데이트레이딩 # 수량이 합산돼 있다. 행 전체를 대상으로 잡으면 다른 전략 보유분까지 매도된다 @@ -1632,6 +1849,29 @@ async def _execute_sell( _auto_trade_bg_lock = asyncio.Lock() +async def _count_swing_buys_today_v2(db: AsyncSession) -> int | None: + """당일 스윙 소유 BUY 종목 수 (distinct stock_code). 조회 실패 시 None. + + None 은 '모름'이다 — 호출부가 fail-closed(신규 매수 차단)로 처리한다. + 여기서 0 으로 폴백하면 한도가 조용히 풀린다(이전 동작). + """ + try: + _res = await db.execute( + select(TradeHistory.stock_code) + .where( + TradeHistory.order_type == "BUY", + TradeHistory.status.in_(["COMPLETED", "PENDING"]), + TradeHistory.created_at >= get_daily_cutoff_utc(), + _swing_owned_clause_v2(TradeHistory.strategy_type), + ) + .distinct() + ) + return len(_res.scalars().all()) + except Exception as e: + logger.error(f"[AUTO_TRADE_BG] 당일 스윙 매수 건수 조회 예외: {type(e).__name__}: {e}") + return None + + async def execute_auto_trade_background(recommendations_response: dict, db: AsyncSession): """recommendations_response dict 에서 추천 종목을 꺼내 자동매매 실행. @@ -1670,24 +1910,23 @@ async def _execute_auto_trade_background_locked(recommendations_response: dict, # 일일 한도는 "오늘 이미 나간 BUY 종목 수"를 차감한 전역 기준으로 적용 # (호출 단위 슬라이스만으로는 여러 사이클에 걸쳐 한도 초과됨) - try: - _today_buys = await db.execute( - select(TradeHistory.stock_code) - .where( - TradeHistory.order_type == "BUY", - TradeHistory.status.in_(["COMPLETED", "PENDING"]), - TradeHistory.created_at >= get_daily_cutoff_utc(), - ) - .distinct() + # [2026-10-01] MAX_DAILY_TRADES 는 스윙 한도다 → 스윙 소유 매수(swing + NULL + # 레거시)만 센다. DT·manual·external·us_swing 매수는 제외(매도 소유권과 같은 기준). + # [2026-10-01] 조회 실패 시 fail-closed — 한도를 검증할 수 없으면 신규 매수를 + # 막는다. 판정은 try 밖(§4). 스윙 청산은 swing/scheduler step 2 + # (check_and_execute_exit_v2)가 이 함수와 독립적으로 수행하므로 영향이 없다. + _bought_today = await _count_swing_buys_today_v2(db) + if _bought_today is None: + logger.error( + f"[AUTO_TRADE_BG] 🚨 당일 스윙 매수 건수 조회 실패 — 일일 한도 " + f"{min(settings.MAX_DAILY_TRADES, regime_max_trades)}종목 검증 불가로 " + f"이번 사이클 신규 진입 차단(fail-closed). 스윙 청산 경로는 영향 없음" ) - _bought_today = len(_today_buys.scalars().all()) - except Exception as _e: - logger.warning(f"[AUTO_TRADE_BG] 당일 매수 건수 조회 실패 (한도 미차감): {_e}") - _bought_today = 0 + return [] max_trades = max(0, max_trades - _bought_today) if max_trades == 0: logger.info( - f"[AUTO_TRADE_BG] 일일 매수 한도 도달 (오늘 {_bought_today}종목 / 한도 " + f"[AUTO_TRADE_BG] 일일 매수 한도 도달 (오늘 스윙 {_bought_today}종목 / 한도 " f"{min(settings.MAX_DAILY_TRADES, regime_max_trades)}) — 신규 진입 스킵" ) return [] @@ -1831,6 +2070,47 @@ def _ml_gate_score_v2(rule_score: float, ml_score: float) -> float: return (rule_score / rule_max) * 100.0 +def _today_sell_conditions_v2(stock_code: str, cutoff_time) -> list: + """당일 SELL 판정 조건 (COMPLETED/PARTIAL 은 executed_at, PENDING 은 created_at 기준).""" + return [ + TradeHistory.stock_code == stock_code, + TradeHistory.order_type == "SELL", + or_( + and_( + TradeHistory.status.in_(["COMPLETED", "PARTIAL"]), + TradeHistory.executed_at >= cutoff_time, + ), + and_( + TradeHistory.status == "PENDING", + TradeHistory.created_at >= cutoff_time, + ), + ), + ] + + +async def _non_dt_sold_today_v2(db: AsyncSession, stock_code: str, cutoff_time) -> bool: + """당일 비-DT SELL 존재 여부 — 스윙 보유분 '부분매도 반복 차단' 판정 전용. + + DT 판정은 db_worker.not_dt_strategy_clause 정본 표현(CLAUDE.md §3.3). + 조회 실패 시 True(보수적: 부분매도만 막고 전량 청산은 허용 — 기존 동작과 동일). + """ + from app.services.db_worker import not_dt_strategy_clause + try: + _res = await db.execute( + select(TradeHistory.id).where( + *_today_sell_conditions_v2(stock_code, cutoff_time), + not_dt_strategy_clause(TradeHistory.strategy_type), + ).limit(1) + ) + return _res.first() is not None + except Exception as e: + logger.warning( + f"[TRADE] {stock_code}: 당일 비-DT 매도 조회 실패 — 부분매도 차단으로 간주 " + f"(전량 청산은 허용): {type(e).__name__}: {e}" + ) + return True + + async def execute_auto_trade(stock: dict, db: AsyncSession): """종목 단건 매매 판단 및 실행. @@ -1944,6 +2224,22 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): _r = _guard("external_inflow_holding") if _r is not None: return _r + elif holding is not None: + # [2026-10-01] "팔 수 없는 행에는 사지 않는다" — 매도 소유권과 대칭. + # manual(수동매수)·daytrading(EOD 미청산 등)·us_swing 행에 스윙이 추가매수하면 + # 그 수량은 swing/exit_service(=='swing' 만)도 _execute_sell 도 팔지 않아 + # SL/TP 없는 보유가 된다. `_guard` 경유라 청산 판정은 그대로 통과(§8.1-11). + _own_block = _sell_ownership_block_reason_v2(holding, stock) + if _own_block is not None: + logger.warning( + f"🚧 비-스윙 보유 행 추가매수 차단: {stock_name}({stock_code}) " + f"보유 {holding.quantity}주 strategy_type='{holding.strategy_type}' " + f"({_own_block}) — 스윙 청산 경로가 이 행을 팔지 않으므로 " + f"추가매수분이 SL/TP 없이 남는다" + ) + _r = _guard(f"non_swing_holding:{_own_block}") + if _r is not None: + return _r held_result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0)) # 적응형 전략: max_holdings 오버라이드 (실패 시 settings 기본값 fallback) @@ -1974,26 +2270,16 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): # 쿨다운을 영구히 걸지 않는다. cutoff_time = _get_kst_now().replace(hour=0, minute=0, second=0, microsecond=0) recent_sell = await db.execute( - select(TradeHistory).where( - TradeHistory.stock_code == stock_code, - TradeHistory.order_type == "SELL", - or_( - and_( - TradeHistory.status.in_(["COMPLETED", "PARTIAL"]), - TradeHistory.executed_at >= cutoff_time, - ), - and_( - TradeHistory.status == "PENDING", - TradeHistory.created_at >= cutoff_time, - ), - ), - ) + select(TradeHistory).where(*_today_sell_conditions_v2(stock_code, cutoff_time)) ) if recent_sell.first(): - _sold_today = True + # 당일 재매수 차단은 DT 매도까지 포함한다(같은 종목 왕복 방지 — 기존 동작). _r = _guard("cooldown_sell_today") if _r is not None: return _r + # [2026-10-01] 단 '부분매도 반복 차단'(_sold_today) 은 스윙 자신의 매도만 본다. + # DT 가 같은 종목을 팔았다고 스윙 보유분의 부분익절이 당일 막히면 안 된다. + _sold_today = await _non_dt_sold_today_v2(db, stock_code, cutoff_time) # [BUG FIX] ActiveTrade.remaining_pct 조회 — 미주입 시 1차 익절 후에도 # remaining_pct가 항상 1.0으로 간주되어 SELL_PARTIAL이 반복 발생하는 문제 방지. diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index fec9ca3..023c1f4 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -108,6 +108,9 @@ _KIS_CLASS_METHODS = ( "get_balance", "get_buyable_cash", "get_overseas_balance", + # ── 체결 조회 계열 (2026-10-01: DT 체결가 확인이 백그라운드로 호출) ── + "get_todays_orders", + "get_order_execution", ) _TRADE_EXECUTOR_FUNCS = ( @@ -202,3 +205,9 @@ def _block_live_kis(request, monkeypatch): monkeypatch.setattr( te_mod, name, _blocked(f"trade_executor.{name}"), raising=True ) + + # [2026-10-01] DT 체결가 확인(daytrading/fill_reconciler)은 주문 직후 백그라운드 태스크를 + # 띄운다. 유닛 테스트에서는 기본 OFF — 테스트 종료 뒤 남은 태스크가 차단된 KIS 를 치거나 + # "Task was destroyed but it is pending" 노이즈를 내지 않게 한다. 검증하는 테스트만 켠다. + from app.core.config import settings as _settings + monkeypatch.setattr(_settings, "DAY_TRADE_FILL_CHECK_ENABLED", False, raising=False) diff --git a/backend/tests/test_db_worker_order_id_update_20261001.py b/backend/tests/test_db_worker_order_id_update_20261001.py new file mode 100644 index 0000000..6c02e04 --- /dev/null +++ b/backend/tests/test_db_worker_order_id_update_20261001.py @@ -0,0 +1,176 @@ +"""update_trade_history_by_order_id 의 주문번호 중복 회귀 테스트 (2026-10-01). + +KIS ODNO 는 일별 순번 → 과거일 행과 번호가 겹친다. 예전 구현(`order_id == X` + +scalar_one_or_none)은 MultipleResultsFound 로 단일 워커가 1+2+4초 재시도하며 큐를 막고 +DLQ 로 보냈으며, 당일 행이 없으면 과거 행을 덮어썼다. + +실제 워커 루프(db_writer_worker)로 큐를 처리하고 SQLite(in-memory)에 결과를 확인한다. +""" +import asyncio +from datetime import datetime, timedelta +from decimal import Decimal +from unittest.mock import patch + +import pytest +import pytest_asyncio +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.ext.compiler import compiles + +from app.core.timeutil import now_kst +from app.models.models import Base, TradeHistory +from app.services import db_worker + + +@compiles(JSONB, "sqlite") +def _jsonb_sqlite(element, compiler, **kw): # 테스트 전용 — SQLite 에 JSONB 타입이 없다 + return "JSON" + + +@pytest_asyncio.fixture +async def session_factory(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(lambda c: Base.metadata.create_all(c, tables=[TradeHistory.__table__])) + yield async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + await engine.dispose() + + +async def _seed(factory, *rows): + async with factory() as s: + for r in rows: + s.add(TradeHistory(**r)) + await s.commit() + + +def _row(order_id, created_at, order_type="SELL", strategy_type="daytrading", price=10_000): + return dict(stock_code="005930", order_type=order_type, order_quantity=1, + order_price=Decimal(price), executed_price=Decimal(price), status="COMPLETED", + order_id=order_id, created_at=created_at, strategy_type=strategy_type) + + +async def _drain(queue, factory): + """실제 워커 경로로 큐 처리. 재시도(1+2+4초)가 일어나면 timeout 으로 실패한다.""" + sleeps = [] + real_sleep = asyncio.sleep + + async def _spy_sleep(d, *a, **k): + sleeps.append(d) + return await real_sleep(0) + + with patch.object(db_worker, "AsyncSessionLocal", factory), \ + patch.object(db_worker, "get_db_task_queue", return_value=queue), \ + patch.object(db_worker, "_save_to_dlq", side_effect=AssertionError("DLQ 적재 금지")), \ + patch.object(db_worker.asyncio, "sleep", _spy_sleep): + worker = asyncio.create_task(db_worker.db_writer_worker()) + await asyncio.wait_for(queue.join(), timeout=5) + worker.cancel() + try: + await worker + except (asyncio.CancelledError, Exception): + pass + return sleeps + + +async def _all(factory): + async with factory() as s: + return {r.id: r for r in (await s.execute(select(TradeHistory))).scalars().all()} + + +@pytest.mark.asyncio +async def test_only_today_row_updated_when_past_day_shares_order_id(session_factory): + today = now_kst().replace(hour=10, minute=0, second=0, microsecond=0) + await _seed(session_factory, + _row("0000123", today - timedelta(days=3), price=5_000), + _row("0000123", today, price=10_000)) + q = asyncio.Queue() + await q.put({"type": "update_trade_history_by_order_id", + "data": {"order_id": "0000123", "executed_price": Decimal("10050")}}) + sleeps = await _drain(q, session_factory) + rows = sorted((await _all(session_factory)).values(), key=lambda r: r.created_at) + assert float(rows[0].executed_price) == 5_000 # 과거 행 불변 + assert float(rows[1].executed_price) == 10_050 # 당일 행 갱신 + assert not [d for d in sleeps if d >= 1] # 재시도 백오프 없음 + + +@pytest.mark.asyncio +async def test_payload_date_takes_priority_over_today(session_factory): + """DLQ 재처리 등으로 다음 날 실행돼도 payload 기준일(order_date/executed_at)의 행을 고른다.""" + today = now_kst().replace(hour=10, minute=0, second=0, microsecond=0) + yday = today - timedelta(days=1) + await _seed(session_factory, _row("777", yday, price=5_000), _row("777", today, price=6_000)) + q = asyncio.Queue() + await q.put({"type": "update_trade_history_by_order_id", + "data": {"order_id": "777", "order_date": yday.date().isoformat(), + "executed_price": Decimal("5100")}}) + await q.put({"type": "update_trade_history_by_order_id", + "data": {"order_id": "777", "executed_at": today, "status": "PARTIAL"}}) + await _drain(q, session_factory) + rows = sorted((await _all(session_factory)).values(), key=lambda r: r.created_at) + assert float(rows[0].executed_price) == 5_100 and rows[0].status == "COMPLETED" + assert float(rows[1].executed_price) == 6_000 and rows[1].status == "PARTIAL" + + +@pytest.mark.asyncio +async def test_same_day_duplicates_update_exactly_one_row(session_factory, caplog): + today = now_kst().replace(hour=10, minute=0, second=0, microsecond=0) + await _seed(session_factory, + _row("0000555", today, order_type="BUY", strategy_type="daytrading"), + _row("0000555", today + timedelta(minutes=1), order_type="SELL", strategy_type="swing"), + _row("0000555", today + timedelta(minutes=2), order_type="SELL", strategy_type="daytrading")) + q = asyncio.Queue() + await q.put({"type": "update_trade_history_by_order_id", + "data": {"order_id": "0000555", "order_type": "SELL", "strategy_type": "daytrading", + "executed_price": Decimal("12345")}}) + await _drain(q, session_factory) + rows = sorted((await _all(session_factory)).values(), key=lambda r: r.created_at) + changed = [r for r in rows if float(r.executed_price) == 12_345] + assert len(changed) == 1 + assert changed[0].order_type == "SELL" and changed[0].strategy_type == "daytrading" + assert any("중복" in r.message for r in caplog.records) + + +@pytest.mark.asyncio +async def test_duplicates_without_filters_pick_latest_and_keep_strategy(session_factory): + """필터 키가 없으면 최신 1행. 필터 키(strategy_type/order_type)는 setattr 되지 않는다.""" + today = now_kst().replace(hour=10, minute=0, second=0, microsecond=0) + await _seed(session_factory, + _row("42", today, strategy_type="swing"), + _row("42", today + timedelta(minutes=5), strategy_type="swing")) + q = asyncio.Queue() + await q.put({"type": "update_trade_history_by_order_id", + "data": {"order_id": "42", "strategy_type": "daytrading", "executed_price": Decimal("1")}}) + await _drain(q, session_factory) + rows = sorted((await _all(session_factory)).values(), key=lambda r: r.created_at) + assert float(rows[1].executed_price) == 1 and float(rows[0].executed_price) == 10_000 + assert all(r.strategy_type == "swing" for r in rows) + + +@pytest.mark.asyncio +async def test_zero_padding_normalized(session_factory): + today = now_kst().replace(hour=10, minute=0, second=0, microsecond=0) + await _seed(session_factory, _row("0000000088", today)) + q = asyncio.Queue() + await q.put({"type": "update_trade_history_by_order_id", + "data": {"order_id": "88", "executed_price": Decimal("9999")}}) + await _drain(q, session_factory) + (row,) = (await _all(session_factory)).values() + assert float(row.executed_price) == 9_999 and row.order_id == "0000000088" + + +@pytest.mark.asyncio +async def test_no_match_does_not_stall_queue(session_factory, caplog): + """0건(당일 행 없음, 과거 행만 존재) → warning 후 종료. 과거 행 불변, 재시도·DLQ 없음, 다음 태스크 진행.""" + today = now_kst().replace(hour=10, minute=0, second=0, microsecond=0) + await _seed(session_factory, _row("999", today - timedelta(days=2), price=5_000)) + q = asyncio.Queue() + await q.put({"type": "update_trade_history_by_order_id", + "data": {"order_id": "999", "executed_price": Decimal("1")}}) + await q.put({"type": "insert_trade_history", "data": _row("1000", today)}) + sleeps = await _drain(q, session_factory) + rows = await _all(session_factory) + assert len(rows) == 2 + assert all(float(r.executed_price) != 1 for r in rows.values()) + assert not [d for d in sleeps if d >= 1] + assert any("not found" in r.message for r in caplog.records) diff --git a/backend/tests/test_dt_cost_fill_timestop_20261001.py b/backend/tests/test_dt_cost_fill_timestop_20261001.py new file mode 100644 index 0000000..cd7b293 --- /dev/null +++ b/backend/tests/test_dt_cost_fill_timestop_20261001.py @@ -0,0 +1,338 @@ +"""DT 결함 3건 회귀 테스트 (2026-10-01). + +1. 시간손절은 loss_tracker(3연패 → CONSECUTIVE_LOSS_BAN_DAYS 진입금지) 집계에서 제외(중립). + 하드 손절·폴백 손절·일일한도 청산 등은 기존대로 기록 + save_to_db. +2. 실현손익은 수수료·거래세 차감 순손익. 실제 체결가는 백그라운드로 확인해 원장·당일 손익 보정. + → 일일 손실한도가 비용만큼 **더 일찍** 발동(보수적, 의도). +3. 레거시 `_record_trade` 가 strategy_type="daytrading" 을 명시. + +목킹 원칙: 주문 경로는 예외 발생형(BaseException) — 목 누락이 거짓 통과가 되지 않게. +기대값은 settings.* 에서 계산(하드코딩 금지). +""" +import asyncio +from datetime import datetime, time, timedelta +from unittest.mock import AsyncMock, patch + +import pytest + +from app.core.config import settings +from app.services import day_trading_strategy as dts +from app.services.daytrading import exit_service as es +from app.services.daytrading import entry_service as ens +from app.services.daytrading import fill_reconciler as fr +from app.services.daytrading.position_manager import day_trading_state +from app.services.trade_cost_core import net_realized_pnl, round_trip_costs + + +class MustNotBeCalled(BaseException): + """호출되면 안 되는 경로 도달 (except Exception 에 삼켜지지 않게 BaseException).""" + + +def _boom(name): + async def _f(*a, **k): + raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") + return _f + + +def _expected_net(entry, exit_, qty, tax_rate=None): + """settings 비용률로 독립 계산한 기대 순손익 (trade_helpers.calculate_trade_costs 와 같은 반올림).""" + tax = settings.KR_SELL_TAX_RATE_KOSPI if tax_rate is None else tax_rate + c = settings.KR_COMMISSION_RATE + cost = round(entry * qty * c + exit_ * qty * c + exit_ * qty * tax, 0) + return (exit_ - entry) * qty - cost, cost + + +@pytest.fixture +def dt_env(): + """_execute_exit → _execute_exit_impl → _finalize_exit_impl 을 실제로 타게 하되 + 외부 경계(주문·DB 큐·캐시·Redis)만 목으로 덮는다.""" + import app.services.trade_executor as te + import app.services.portfolio_cache_bridge as pcb + + captured = {"history": [], "updates": [], "upserts": []} + + async def _hist(payload): + captured["history"].append(payload) + + async def _upd(order_id, fields): + captured["updates"].append((order_id, fields)) + + async def _ups(payload): + captured["upserts"].append(payload) + + saved_positions = dict(day_trading_state.positions) + saved_pnl = day_trading_state.daily_pnl + saved_streak = day_trading_state.loss_streak_today + saved_closed = set(day_trading_state.closed_today) + saved_stale = es._stale_realized_pnl + day_trading_state.positions.clear() + day_trading_state.daily_pnl = 0.0 + day_trading_state.loss_streak_today = 0 + + with patch.object(te, "safe_place_order", + AsyncMock(return_value={"rt_cd": "0", "output": {"ODNO": "0000123"}})), \ + patch.object(es, "enqueue_trade_history", _hist), \ + patch.object(es, "enqueue_close_active_trade", AsyncMock()), \ + patch("app.services.db_worker.enqueue_active_trade_upsert", _ups), \ + patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", _upd), \ + patch.object(ens, "enqueue_active_trade_upsert", _ups), \ + patch.object(pcb, "reflect_sell", AsyncMock()), \ + patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)): + yield captured + + day_trading_state.positions.clear() + day_trading_state.positions.update(saved_positions) + day_trading_state.daily_pnl = saved_pnl + day_trading_state.loss_streak_today = saved_streak + day_trading_state.closed_today.clear() + day_trading_state.closed_today.update(saved_closed) + es._stale_realized_pnl = saved_stale + + +def _pos(entry=10_000.0, qty=10, order_id="0000999"): + return {"stock_name": "테스트", "entry_price": entry, "quantity": qty, + "highest_price": entry, "order_id": order_id} + + +# ── 1. 시간손절 중립 처리 ─────────────────────────────────────────────────── + +def test_time_stop_reason_from_strategy_is_classified(): + """check_day_trade_exit 가 실제로 만드는 시간손절 사유가 판별 함수와 일치해야 한다.""" + from app.core.timeutil import now_kst + entry_time = now_kst() - timedelta(minutes=settings.DAY_TRADE_TIME_STOP_MINUTES + 1) + entry = 10_000.0 + price = entry * (1 + settings.DAY_TRADE_TIME_STOP_MIN_GAIN * 2) # 임계 미달 + sig = dts.check_day_trade_exit( + entry_price=entry, current_price=price, stop_loss=entry * 0.5, + target_price=entry * 2, highest_since_entry=entry, candles_5min=[], + current_time=time(10, 0), entry_time=entry_time, + ) + assert sig is not None and dts.is_time_stop_exit_reason(sig.reason), sig + # 손절·EOD·폴백·일일한도 사유는 시간손절이 아니다 + for r in ("손절 -3.0% (현재=9700 ≤ 손절가=9700)", "EOD 강제 청산 (15:20 ≥ 15:20)", + "폴백 손절(분봉 미확보) -2.1%", "일일 손실 한도 초과", "", None): + assert not dts.is_time_stop_exit_reason(r) + + +@pytest.mark.asyncio +async def test_time_stop_exit_not_recorded_in_loss_tracker(dt_env): + day_trading_state.positions["111111"] = _pos() + with patch.object(es.loss_tracker, "record_trade_result_safe", _boom("record_trade_result_safe")), \ + patch.object(es.loss_tracker, "save_to_db", _boom("save_to_db")): + res = await es._execute_exit( + "111111", day_trading_state.positions["111111"], 9_960.0, + f"{dts.TIME_STOP_REASON_PREFIX}: 31분 경과, 수익=-0.4% < -0.3%", + ) + assert res is not None and res["profit_loss"] < 0 + # 당일 연패 쿨다운(DT 전용·당일 한정)은 그대로 센다 + assert day_trading_state.loss_streak_today == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("reason", [ + "손절 -3.0% (현재=9700 ≤ 손절가=9700)", + "폴백 손절(분봉 미확보) -2.1%", + "일일 손실 한도 초과", +]) +async def test_real_stop_exits_still_recorded_and_saved(dt_env, reason): + day_trading_state.positions["222222"] = _pos() + rec, save = AsyncMock(), AsyncMock() + with patch.object(es.loss_tracker, "record_trade_result_safe", rec), \ + patch.object(es.loss_tracker, "save_to_db", save): + await es._execute_exit("222222", day_trading_state.positions["222222"], 9_700.0, reason) + rec.assert_awaited_once_with("222222", True) + save.assert_awaited_once_with(stock_code="222222") + + +# ── 2. 순손익 ────────────────────────────────────────────────────────────── + +def test_cost_core_matches_swing_definition(): + """trade_cost_core 는 스윙 집행 경로(trade_helpers.calculate_trade_costs)와 같은 정의.""" + from app.services.trade_helpers import calculate_trade_costs + for market, tax in (("KOSPI", settings.KR_SELL_TAX_RATE_KOSPI), + ("KOSDAQ", settings.KR_SELL_TAX_RATE_KOSDAQ)): + for e, x, q in ((10_000, 10_100, 10), (53_400, 52_100, 7), (1_234, 1_250, 333)): + assert round_trip_costs(e, x, q, settings.KR_COMMISSION_RATE, tax) == \ + calculate_trade_costs(e, x, q, True, market) + + +@pytest.mark.asyncio +async def test_exit_records_net_pnl_and_feeds_daily_pnl(dt_env): + entry, exit_, qty = 10_000.0, 10_100.0, 10 + day_trading_state.positions["333333"] = _pos(entry, qty) + with patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()), \ + patch.object(es.loss_tracker, "save_to_db", AsyncMock()): + res = await es._execute_exit("333333", day_trading_state.positions["333333"], exit_, "트레일링 스탑") + net, cost = _expected_net(entry, exit_, qty) + rec = dt_env["history"][-1] + assert rec["net_profit_loss"] == pytest.approx(net) + assert rec["commission"] == pytest.approx(cost) + assert rec["profit_loss_rate"] == pytest.approx(net / (entry * qty) * 100) + assert rec["strategy_type"] == "daytrading" + assert day_trading_state.daily_pnl == pytest.approx(net) + assert res["gross_profit_loss"] == pytest.approx((exit_ - entry) * qty) + + +@pytest.mark.asyncio +async def test_breakeven_exit_counts_as_loss_after_costs(dt_env): + """총손익 0 이어도 순손익은 비용만큼 음수 → loss_tracker 손실, 당일 연패 +1 (의도된 보수화).""" + day_trading_state.positions["444444"] = _pos() + rec = AsyncMock() + with patch.object(es.loss_tracker, "record_trade_result_safe", rec), \ + patch.object(es.loss_tracker, "save_to_db", AsyncMock()): + await es._execute_exit("444444", day_trading_state.positions["444444"], 10_000.0, "EOD 강제 청산") + rec.assert_awaited_once_with("444444", True) + assert day_trading_state.daily_pnl < 0 + + +def test_daily_loss_limit_fires_earlier_with_net_pnl(): + """[의도 고정] 총손익으로는 한도 직전이던 손실이 순손익으로는 한도를 넘는다.""" + total_assets = 10_000_000.0 + limit_amt = abs(dts._get_daily_loss_limit()) * total_assets + entry, qty = 10_000.0, 100 + # 총손실이 한도보다 1원 작게: (exit-entry)*qty = -(limit_amt-1) + exit_ = entry - (limit_amt - 1) / qty + gross = (exit_ - entry) * qty + net = net_realized_pnl(entry, exit_, qty, settings.KR_COMMISSION_RATE, + settings.KR_SELL_TAX_RATE_KOSPI)["net_profit_loss"] + assert not dts.is_daily_loss_exceeded(total_assets, gross) + assert dts.is_daily_loss_exceeded(total_assets, net) + + +@pytest.mark.asyncio +async def test_stale_exit_net_pnl_excluded_from_daily(dt_env): + entry, exit_, qty = 10_000.0, 9_500.0, 10 + pos = _pos(entry, qty) + before = es.get_stale_realized_pnl() + with patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()), \ + patch.object(es.loss_tracker, "save_to_db", AsyncMock()): + await es._execute_exit_stale("555555", pos, exit_, "전일 미청산 포지션 강제 청산") + net, _ = _expected_net(entry, exit_, qty) + assert day_trading_state.daily_pnl == 0.0 + assert es.get_stale_realized_pnl() - before == pytest.approx(net) + + +# ── 2b. 체결가 확인 ──────────────────────────────────────────────────────── + +def test_parse_fill_row_matches_zero_padded_odno(): + rows = [{"odno": "0000000777", "tot_ccld_qty": "5", "avg_prvs": "10050"}, + {"odno": "0000123", "tot_ccld_qty": "10", "avg_prvs": "0", "tot_ccld_amt": "100700"}] + assert fr.parse_fill_row(rows, "0000123") == (10, 10070.0) + assert fr.parse_fill_row(rows, "777") == (5, 10050.0) + assert fr.parse_fill_row(rows, "888") is None + assert fr.parse_fill_row([{"odno": "1", "tot_ccld_qty": "0", "avg_prvs": "0"}], "1") is None + + +@pytest.mark.asyncio +async def test_fetch_fill_timeout_returns_none(monkeypatch, caplog): + monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0) + monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_TIMEOUT_SEC", 0.05) + + async def _slow(*a, **k): + await asyncio.sleep(5) # rate-limit sleep 모사 + return [] + + with patch.object(fr.korea_investment_api, "get_todays_orders", _slow): + assert await fr.fetch_fill("000000", "1", "SELL") is None + assert any("시간초과" in r.message for r in caplog.records) + + +@pytest.mark.asyncio +async def test_schedule_disabled_is_noop(): + # conftest 가 유닛 테스트 기본값으로 OFF + assert settings.DAY_TRADE_FILL_CHECK_ENABLED is False + assert fr.schedule("000000", "1", "BUY", _boom("on_fill")) is None + + +@pytest.fixture +def fill_on(monkeypatch): + monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_ENABLED", True) + monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0) + monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_INTERVAL_SEC", 0.0) + + +@pytest.mark.asyncio +async def test_sell_fill_reconcile_adjusts_daily_pnl_and_history(dt_env, fill_on): + entry, signal, fill, qty = 10_000.0, 10_100.0, 10_080.0, 10 + day_trading_state.positions["666666"] = _pos(entry, qty) + rows = [{"odno": "123", "tot_ccld_qty": str(qty), "avg_prvs": str(fill)}] + with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)), \ + patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()), \ + patch.object(es.loss_tracker, "save_to_db", AsyncMock()): + await es._execute_exit("666666", day_trading_state.positions["666666"], signal, "트레일링 스탑") + await asyncio.gather(*list(fr._pending_tasks)) + net_fill, cost_fill = _expected_net(entry, fill, qty) + assert day_trading_state.daily_pnl == pytest.approx(net_fill) + oid, upd = dt_env["updates"][-1] + assert oid == "0000123" + assert float(upd["executed_price"]) == pytest.approx(fill) + assert float(upd["net_profit_loss"]) == pytest.approx(net_fill) + assert float(upd["commission"]) == pytest.approx(cost_fill) + assert float(upd["slippage"]) == pytest.approx(round((fill - signal) / signal * 100, 4)) + + +@pytest.mark.asyncio +async def test_sell_fill_reconcile_stale_goes_to_stale_bucket(dt_env, fill_on): + entry, signal, fill, qty = 10_000.0, 9_500.0, 9_450.0, 10 + rows = [{"odno": "123", "tot_ccld_qty": str(qty), "avg_prvs": str(fill)}] + before = es.get_stale_realized_pnl() + with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)), \ + patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()), \ + patch.object(es.loss_tracker, "save_to_db", AsyncMock()): + await es._execute_exit_stale("777777", _pos(entry, qty), signal, "전일 미청산 포지션 강제 청산") + await asyncio.gather(*list(fr._pending_tasks)) + net_fill, _ = _expected_net(entry, fill, qty) + assert day_trading_state.daily_pnl == 0.0 + assert es.get_stale_realized_pnl() - before == pytest.approx(net_fill) + + +@pytest.mark.asyncio +async def test_buy_fill_reconcile_updates_entry_price(dt_env, fill_on): + day_trading_state.positions["888888"] = _pos(10_000.0, 10, order_id="0000456") + rows = [{"odno": "456", "tot_ccld_qty": "10", "avg_prvs": "10030"}] + with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)): + ens._schedule_buy_fill_reconcile("888888", "0000456", 10_000.0, 10) + await asyncio.gather(*list(fr._pending_tasks)) + assert day_trading_state.positions["888888"]["entry_price"] == pytest.approx(10_030.0) + assert dt_env["upserts"][-1] == {"stock_code": "888888", "strategy_name": "day_trade", + "entry_price": 10_030.0} + oid, upd = dt_env["updates"][-1] + assert oid == "0000456" and float(upd["executed_price"]) == pytest.approx(10_030.0) + + +@pytest.mark.asyncio +async def test_buy_fill_reconcile_skips_replaced_position(dt_env, fill_on): + """다른 주문으로 교체된 포지션의 진입가는 건드리지 않는다(원장만 보정).""" + day_trading_state.positions["999999"] = _pos(10_000.0, 10, order_id="OTHER") + rows = [{"odno": "456", "tot_ccld_qty": "10", "avg_prvs": "10030"}] + with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)): + ens._schedule_buy_fill_reconcile("999999", "0000456", 10_000.0, 10) + await asyncio.gather(*list(fr._pending_tasks)) + assert day_trading_state.positions["999999"]["entry_price"] == 10_000.0 + assert dt_env["upserts"] == [] + assert len(dt_env["updates"]) == 1 + + +@pytest.mark.asyncio +async def test_fill_check_failure_keeps_signal_price(dt_env, fill_on): + """체결 미확인이면 원장·당일 손익을 건드리지 않는다(신호가 유지).""" + day_trading_state.positions["121212"] = _pos() + with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=[])), \ + patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()), \ + patch.object(es.loss_tracker, "save_to_db", AsyncMock()): + await es._execute_exit("121212", day_trading_state.positions["121212"], 10_100.0, "트레일링 스탑") + pnl_after_exit = day_trading_state.daily_pnl + await asyncio.gather(*list(fr._pending_tasks)) + assert day_trading_state.daily_pnl == pnl_after_exit + assert dt_env["updates"] == [] + + +# ── 3. strategy_type ─────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_legacy_record_trade_sets_daytrading_strategy_type(): + from app.api.endpoints import day_trading as dt_api + cap = AsyncMock() + with patch("app.services.db_worker.enqueue_trade_history", cap): + await dt_api._record_trade(None, "005930", "삼성전자", "SELL", 70_000.0, 1, "EOD_flat", -100.0) + assert cap.await_args.args[0]["strategy_type"] == "daytrading" diff --git a/backend/tests/test_order_execution_daily_ccld_20261001.py b/backend/tests/test_order_execution_daily_ccld_20261001.py new file mode 100644 index 0000000..71e2c5f --- /dev/null +++ b/backend/tests/test_order_execution_daily_ccld_20261001.py @@ -0,0 +1,411 @@ +"""[2026-10-01] get_order_execution 경로 결함 수정 + 처음 살아나는 _verify_and_sync_execution 분기. + +결함: 국내 체결조회가 존재하지 않는 `/domestic-stock/v1/trading/inquire-ccnl` 을 호출해 +체결 확인이 한 번도 성공하지 못했다(실패는 debug 로 삼키거나 4xx 는 로그조차 없음). + +검증 방식 — KIS 는 conftest 가 클래스 메서드 단위로 차단한다. 여기서는 그보다 아래인 +**HTTP 계층**(`_get_http_client().request`)을 인스턴스 패치로 가로챈다. 요청 구성 검사는 +예외 발생형 목(인자 캡처 후 raise)으로, 응답 파싱은 가짜 응답으로 한다. +토큰(`_get_access_token`)은 conftest 차단을 인스턴스 패치로 덮어 고정 토큰을 준다. +""" +import asyncio +import logging +from unittest.mock import AsyncMock, patch + +import pytest + +from app.services import korea_investment_api as kis_mod +from app.services import trade_executor as te +from app.services.korea_investment_api import ( + CircuitBreaker, + DAILY_CCLD_ENDPOINT, + parse_order_execution_rows, +) + +# 모듈 싱글톤(`kis_mod.korea_investment_api`)은 쓰지 않는다 — test_api_endpoints.py 가 수집 시점에 +# MagicMock 으로 바꿔 두고 되돌리지 않는다(세션 전체 누수). 테스트마다 새 인스턴스를 만든다. + +# conftest 는 테스트 실행 시점에 클래스 메서드를 차단 스텁으로 바꾼다. 수집(import) 시점의 +# 원본을 잡아 두고, HTTP 계층만 목킹한 상태에서 **실제 공개 메서드**를 검증한다. +_ORIG_GET_TODAYS = kis_mod.KoreaInvestmentAPI.get_todays_orders +_ORIG_GET_EXEC = kis_mod.KoreaInvestmentAPI.get_order_execution + + +class _HttpCaptured(Exception): + """요청 인자를 캡처한 뒤 던지는 예외 — 실제 전송이 일어나지 않았음을 보장.""" + + +class _Resp: + def __init__(self, body: dict, headers: dict | None = None, status: int = 200): + self._body = body + self.headers = headers or {} + self.status_code = status + self.text = "" + + def json(self): + return self._body + + +class _FakeClient: + """httpx.AsyncClient.request 대체. responses 가 비면 캡처 예외를 던진다.""" + + def __init__(self, responses=None): + self.calls = [] + self.responses = list(responses or []) + self.is_closed = False + + async def request(self, method, url, headers=None, timeout=None, **kwargs): + self.calls.append({"method": method, "url": url, "headers": dict(headers or {}), **kwargs}) + if not self.responses: + raise _HttpCaptured(url) + return self.responses.pop(0) + + +class _Api: + """HTTP 계층만 가로챈 새 KoreaInvestmentAPI 인스턴스 (토큰 캐시 파일·네트워크 미사용).""" + + def __init__(self, client: _FakeClient, base_url: str): + api = object.__new__(kis_mod.KoreaInvestmentAPI) + api.base_url = base_url + api.app_key = "TEST_APP_KEY" + api.app_secret = "TEST_APP_SECRET" + api.account_number = "1234567801" + api.access_token = None + api.token_expires_at = None + api.last_request_time = None + api._throttle_lock = asyncio.Lock() + api.min_request_interval = 0.0 + api.max_retries = 0 + api.retry_delay = 0.0 + api.retry_backoff = 1.0 + api.timeout = 5.0 + api.semaphore = asyncio.Semaphore(5) + api.monitor = kis_mod.APICallMonitor(window_size=10) + api.circuit_breaker = CircuitBreaker(failure_threshold=99, recovery_timeout=1) + # conftest 의 클래스 차단(_get_access_token)을 인스턴스 속성으로 덮는다 + api._get_access_token = AsyncMock(return_value="TEST_TOKEN") + api._get_http_client = AsyncMock(return_value=client) + self.api = api + + def __enter__(self): + return self.api + + def __exit__(self, *exc): + return False + + +REAL = "https://openapi.koreainvestment.com:9443" +VIRTUAL = "https://openapivts.koreainvestment.com:29443" + + +# ───────────────────────────────────────────────────────────────────────────── +# 1. 요청 구성 (URL·TR_ID·파라미터) — 예외 발생형 HTTP 목으로 인자만 검사 +# ───────────────────────────────────────────────────────────────────────────── +@pytest.mark.asyncio +@pytest.mark.parametrize("base_url,expected_tr", [(REAL, "TTTC0081R"), (VIRTUAL, "VTTC0081R")]) +async def test_order_execution_request_uses_daily_ccld(base_url, expected_tr, caplog): + client = _FakeClient() + with _Api(client, base_url) as API, caplog.at_level(logging.WARNING): + out = await API._get_order_execution_domestic_impl("0000117057") + + assert len(client.calls) == 1, "예외 발생 시 대체 TR 재시도·내부 재시도 없이 1회만" + call = client.calls[0] + assert call["method"] == "GET" + assert call["url"] == f"{base_url}{DAILY_CCLD_ENDPOINT}" + assert call["url"].endswith("/uapi/domestic-stock/v1/trading/inquire-daily-ccld") + assert "inquire-ccnl" not in call["url"] + assert call["headers"]["tr_id"] == expected_tr + assert "tr_cont" not in call["headers"], "첫 페이지는 tr_cont 헤더 없음" + p = call["params"] + assert p["CANO"] == "12345678" and p["ACNT_PRDT_CD"] == "01" + assert p["INQR_STRT_DT"] == p["INQR_END_DT"] and len(p["INQR_STRT_DT"]) == 8 + assert p["ODNO"] == "0000117057" + assert p["SLL_BUY_DVSN_CD"] == "00" and p["CCLD_DVSN"] == "00" + assert p["INQR_DVSN"] == "00" and p["INQR_DVSN_3"] == "00" + assert p["CTX_AREA_FK100"] == "" and p["CTX_AREA_NK100"] == "" + assert p["EXCG_ID_DVSN_CD"] == "KRX" + # 실패 → 기존 계약대로 filled_qty=0 + warning(debug 금지) + assert out["filled_qty"] == 0 and out["filled_price"] == 0.0 and out["ok"] is False + assert any("[get_order_execution]" in r.message and r.levelno >= logging.WARNING + for r in caplog.records) + + +@pytest.mark.asyncio +async def test_public_get_order_execution_hits_daily_ccld(): + """공개 메서드(원본) 경유로도 inquire-daily-ccld 를 부르고 기존 2키 계약을 지킨다.""" + body = {"rt_cd": "0", "output1": [{"odno": "0000000042", "tot_ccld_qty": "3", + "avg_prvs": "5000", "ord_qty": "3", "rmn_qty": "0"}]} + client = _FakeClient([_Resp(body)]) + with _Api(client, REAL) as API: + out = await _ORIG_GET_EXEC(API, "0000000042", True) + assert client.calls[0]["url"].endswith("/trading/inquire-daily-ccld") + assert out["filled_qty"] == 3 and out["filled_price"] == 5000.0 + + +@pytest.mark.asyncio +async def test_get_todays_orders_request_and_warning(caplog): + """get_todays_orders 도 같은 요청 구성(ODNO 공란, PDNO=종목)이며 실패는 warning.""" + client = _FakeClient() + with _Api(client, VIRTUAL) as API, caplog.at_level(logging.WARNING): + rows = await _ORIG_GET_TODAYS(API, "005930") + assert rows == [] + call = client.calls[0] + assert call["url"] == f"{VIRTUAL}{DAILY_CCLD_ENDPOINT}" + assert call["headers"]["tr_id"] == "VTTC0081R" + assert call["params"]["PDNO"] == "005930" and call["params"]["ODNO"] == "" + assert any("[get_todays_orders]" in r.message and r.levelno >= logging.WARNING + for r in caplog.records) + _, p_exec = API._daily_ccld_request_v2(order_id="1") + assert set(call["params"]) == set(p_exec), "두 메서드의 파라미터 키 집합이 같아야 한다" + + +@pytest.mark.asyncio +async def test_get_todays_orders_success_returns_rows(): + rows = [{"odno": "1", "pdno": "005930"}] + client = _FakeClient([_Resp({"rt_cd": "0", "output1": rows})]) + with _Api(client, REAL) as API: + assert await _ORIG_GET_TODAYS(API, "005930") == rows + + +@pytest.mark.asyncio +async def test_legacy_tr_fallback_on_error_rt_cd(): + """현행 TR 이 rt_cd!=0 이면 구 TR(TTTC8001R) 1회 재시도, 성공하면 이후 구 TR 우선.""" + ok = {"rt_cd": "0", "output1": [{"odno": "0000000777", "tot_ccld_qty": "5", + "avg_prvs": "10100", "ord_qty": "5", "rmn_qty": "0"}]} + client = _FakeClient([ + _Resp({"rt_cd": "1", "msg_cd": "OPSQ0002", "msg1": "없는 서비스 코드"}), + _Resp(ok), + ]) + with _Api(client, REAL) as API: + out = await API._get_order_execution_domestic_impl("777") + assert [c["headers"]["tr_id"] for c in client.calls] == ["TTTC0081R", "TTTC8001R"] + assert out["filled_qty"] == 5 and out["filled_price"] == 10100.0 and out["found"] + # 다음 호출은 구 TR 부터 + client.responses.append(_Resp(ok)) + await API._get_order_execution_domestic_impl("777") + assert client.calls[-1]["headers"]["tr_id"] == "TTTC8001R" + + +@pytest.mark.asyncio +async def test_pagination_uses_tr_cont_n_and_ctx_keys(): + page1 = _Resp({"rt_cd": "0", "output1": [{"odno": "0000000001", "tot_ccld_qty": "1"}], + "ctx_area_fk100": "FK1", "ctx_area_nk100": "NK1"}, headers={"tr_cont": "M"}) + page2 = _Resp({"rt_cd": "0", "output1": [{"odno": "0000000042", "tot_ccld_qty": "3", + "avg_prvs": "5000", "ord_qty": "3", "rmn_qty": "0"}]}, + headers={"tr_cont": "D"}) + client = _FakeClient([page1, page2]) + with _Api(client, VIRTUAL) as API: + out = await API._get_order_execution_domestic_impl("0000000042") + assert len(client.calls) == 2 + assert client.calls[1]["headers"]["tr_cont"] == "N" + assert client.calls[1]["params"]["CTX_AREA_FK100"] == "FK1" + assert client.calls[1]["params"]["CTX_AREA_NK100"] == "NK1" + assert out["filled_qty"] == 3 and out["filled_price"] == 5000.0 + + +@pytest.mark.asyncio +async def test_404_like_response_logs_warning(caplog): + """옛 경로처럼 4xx 가 돌아와도 로그 없이 0 을 돌려주던 동작 → warning.""" + client = _FakeClient([_Resp({"rt_cd": "404", "msg1": "Not Found"}, status=404), + _Resp({"rt_cd": "404", "msg1": "Not Found"}, status=404)]) + with _Api(client, REAL) as API, caplog.at_level(logging.WARNING): + out = await API._get_order_execution_domestic_impl("123") + assert out["filled_qty"] == 0 and out["ok"] is False + assert any("체결조회 실패" in r.message for r in caplog.records) + + +# ───────────────────────────────────────────────────────────────────────────── +# 2. 응답 파싱 (순수 함수) +# ───────────────────────────────────────────────────────────────────────────── +def test_parse_full_fill_with_zero_padding(): + rows = [{"odno": "0000117057", "tot_ccld_qty": "10", "avg_prvs": "71200", + "ord_qty": "10", "rmn_qty": "0", "cncl_yn": "N"}] + out = parse_order_execution_rows(rows, "117057") + assert out == {"filled_qty": 10, "filled_price": 71200.0, "ordered_qty": 10, + "remaining_qty": 0, "cancelled": False, + "cancel_confirmed_qty": None, "rejected_qty": None} + + +def test_parse_cancel_and_reject_quantities(): + rows = [{"odno": "3", "tot_ccld_qty": "4", "avg_prvs": "100", "ord_qty": "10", + "rmn_qty": "0", "cnc_cfrm_qty": "5", "rjct_qty": "1", "cncl_yn": "N"}] + out = parse_order_execution_rows(rows, "3") + assert out["cancel_confirmed_qty"] == 5 and out["rejected_qty"] == 1 + assert out["cancelled"] is False + + +def test_parse_partial_and_avg_fallback(): + rows = [{"odno": "0000000099", "tot_ccld_qty": "4", "avg_prvs": "0", "tot_ccld_amt": "40400", + "ord_qty": "10", "rmn_qty": "6"}] + out = parse_order_execution_rows(rows, "0000000099") + assert out["filled_qty"] == 4 and out["filled_price"] == 10100.0 + assert out["remaining_qty"] == 6 + + +def test_parse_unfilled_and_missing_remaining_field(): + out = parse_order_execution_rows([{"odno": "5", "tot_ccld_qty": "0", "avg_prvs": "0", + "ord_qty": "3"}], "5") + assert out["filled_qty"] == 0 and out["filled_price"] == 0.0 + assert out["remaining_qty"] is None, "필드 없음은 0(소멸)이 아니라 None(모름)" + + +def test_parse_empty_or_no_match(): + assert parse_order_execution_rows([], "1") is None + assert parse_order_execution_rows(None, "1") is None + assert parse_order_execution_rows([{"odno": "2", "tot_ccld_qty": "1"}], "1") is None + assert parse_order_execution_rows([{"odno": "1"}], "") is None + # 첫 행이 아니라 주문번호가 일치하는 행을 고른다 (옛 구현은 output[0] 고정) + rows = [{"odno": "8", "tot_ccld_qty": "9", "avg_prvs": "1"}, + {"odno": "7", "tot_ccld_qty": "2", "avg_prvs": "300", "ord_qty": "2", "rmn_qty": "0"}] + assert parse_order_execution_rows(rows, "7")["filled_qty"] == 2 + + +@pytest.mark.asyncio +async def test_overseas_returns_legacy_contract_without_http(): + client = _FakeClient() + with _Api(client, REAL) as API: + out = await _ORIG_GET_EXEC(API, "123", False) + assert out == {"filled_qty": 0, "filled_price": 0.0} and client.calls == [] + + +# ───────────────────────────────────────────────────────────────────────────── +# 3. _verify_and_sync_execution — 처음 살아나는 분기의 동작 +# ───────────────────────────────────────────────────────────────────────────── +class _ReorderCalled(BaseException): + """자동 재주문 경로가 호출됨 — BaseException 이라 `except Exception` 에 삼켜지지 않는다.""" + + +def _reorder_forbidden(*args, **kwargs): + raise _ReorderCalled(f"_reorder_unfilled 호출됨: {kwargs}") + + +class _VerifyHarness: + def __init__(self, exec_results): + self.exec_mock = AsyncMock(side_effect=list(exec_results)) + self.upd_by_order = AsyncMock() + self.upd_by_id = AsyncMock() + self.sync = AsyncMock() + self.reorder = _reorder_forbidden + self._real_sleep = asyncio.sleep + + async def _fast_sleep(self, *_a, **_k): + await self._real_sleep(0) + + async def run(self, ordered=10, order_type="BUY", order_price=10000.0): + with patch.object(te.korea_investment_api, "get_order_execution", self.exec_mock), \ + patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", self.upd_by_order), \ + patch("app.services.db_worker.enqueue_update_trade_history", self.upd_by_id), \ + patch.object(te, "_delayed_sync_portfolio", self.sync), \ + patch.object(te, "_reorder_unfilled", self.reorder), \ + patch.object(te.asyncio, "sleep", self._fast_sleep): + await te._verify_and_sync_execution( + order_id="0000000042", stock_code="005930", stock_name="삼성전자", + ordered_quantity=ordered, order_price=order_price, order_type=order_type, + is_domestic=True, exchange_code="KRX", db_trade_id=0, delay=0, + ) + await self._real_sleep(0) # create_task 로 띄운 재주문 태스크 실행 기회 + + +def _ex(filled, price=10050.0, remaining=None, cancelled=False, ordered=10): + return {"filled_qty": filled, "filled_price": price if filled else 0.0, "found": True, "ok": True, + "ordered_qty": ordered, "remaining_qty": remaining, "cancelled": cancelled} + + +@pytest.mark.asyncio +async def test_verify_full_fill_updates_completed_once(): + h = _VerifyHarness([_ex(10, remaining=0)]) + await h.run() + assert h.exec_mock.await_count == 1 + h.upd_by_order.assert_awaited_once() + order_id, fields = h.upd_by_order.await_args.args + assert order_id == "0000000042" + assert fields["status"] == "COMPLETED" and fields["executed_quantity"] == 10 + assert float(fields["executed_price"]) == 10050.0 + assert float(fields["slippage"]) == pytest.approx(0.5) + h.sync.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_verify_partial_with_live_remainder_never_reorders(): + """잔량이 KIS 에 살아 있으면 재주문 = 중복 주문. 옛 코드는 첫 부분체결에서 바로 재주문했다.""" + h = _VerifyHarness([_ex(4, remaining=6)] * 5) + await h.run() + assert h.exec_mock.await_count == 5, "부분체결 첫 관측에서 멈추지 않고 계속 폴링" + h.upd_by_order.assert_not_called() + h.sync.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_verify_partial_then_full_within_polls(): + h = _VerifyHarness([_ex(4, remaining=6), _ex(10, remaining=0)]) + await h.run() + assert h.exec_mock.await_count == 2 + fields = h.upd_by_order.await_args.args[1] + assert fields["executed_quantity"] == 10 and fields["status"] == "COMPLETED" + + +@pytest.mark.asyncio +async def test_verify_partial_unknown_remainder_is_treated_as_live(): + h = _VerifyHarness([_ex(4, remaining=None)] * 5) + await h.run() + h.upd_by_order.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("order_type", ["BUY", "SELL"]) +async def test_verify_partial_dead_remainder_records_and_never_reorders(order_type, caplog): + """잔량 소멸(취소·거부·만료) — 체결분만 기록, 자동 재주문 금지(가드 전부 우회하므로), 수동 확인 warning.""" + res = _ex(4, remaining=0) + res.update({"cancel_confirmed_qty": 6, "rejected_qty": 0}) + h = _VerifyHarness([res]) + with caplog.at_level(logging.WARNING): + await h.run(order_type=order_type) # 재주문 호출 시 _ReorderCalled 로 테스트 실패 + fields = h.upd_by_order.await_args.args[1] + assert fields["executed_quantity"] == 4 + assert fields["status"] == "COMPLETED", "PARTIAL 은 재진입 가드·손익 집계에서 빠진다" + msgs = [r.message for r in caplog.records if "[PARTIAL_FILL]" in r.message] + assert msgs, "잔량 소멸 warning 누락" + m = msgs[-1] + for frag in ("005930", "0000000042", order_type, "4/10", "취소확인=6", "거부=0", "수동 확인"): + assert frag in m, (frag, m) + h.sync.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_verify_partial_cancelled_does_not_reorder(): + h = _VerifyHarness([_ex(4, remaining=0, cancelled=True)]) + await h.run() + h.upd_by_order.assert_awaited_once() + + +def test_verify_source_has_no_reorder_call(): + """정적 고정 — _verify_and_sync_execution 본문에서 _reorder_unfilled 호출 금지.""" + import inspect + body = inspect.getsource(te._verify_and_sync_execution).split('"""', 2)[2] + assert "_reorder_unfilled(" not in body + + +@pytest.mark.asyncio +async def test_verify_unfilled_no_db_write_and_warns(caplog): + h = _VerifyHarness([_ex(0, remaining=10)] * 5) + with caplog.at_level(logging.WARNING): + await h.run() + h.upd_by_order.assert_not_called() + h.sync.assert_awaited_once() + assert any("체결 미확인" in r.message for r in caplog.records) + + +@pytest.mark.asyncio +async def test_verify_overfill_is_clamped(): + h = _VerifyHarness([_ex(15, remaining=0)]) + await h.run() + assert h.upd_by_order.await_args.args[1]["executed_quantity"] == 10 + + +@pytest.mark.asyncio +async def test_verify_legacy_two_key_result_still_works(): + """get_order_execution 의 기존 2키 계약만 주는 목(해외/옛 호출부)과도 동작.""" + h = _VerifyHarness([{"filled_qty": 10, "filled_price": 9950.0}]) + await h.run(order_type="SELL") + fields = h.upd_by_order.await_args.args[1] + assert fields["executed_quantity"] == 10 and fields["status"] == "COMPLETED" diff --git a/backend/tests/test_trade_executor_risk_fixes_20261001.py b/backend/tests/test_trade_executor_risk_fixes_20261001.py new file mode 100644 index 0000000..4f6e4e1 --- /dev/null +++ b/backend/tests/test_trade_executor_risk_fixes_20261001.py @@ -0,0 +1,647 @@ +"""trade_executor 리스크 결함 3건 회귀 테스트 (2026-10-01). + +1. 일일 매수 한도(MAX_DAILY_TRADES) 집계에서 데이트레이딩 매수 제외 +2. 종목당 상한(MAX_POSITION_SIZE)에 기존 보유분 반영 (CLAUDE.md §5.1) +3. 추천 경로 매도(_execute_sell)에서 external/manual/DT 보유 행 제외 + +규약(CLAUDE.md §4.1): + - 주문 경로 목은 **예외 발생형**이다(_Reached). 가드가 빗나가면 테스트가 터진다. + - 목은 공통 종단(_acquire_order_lock — 매수·매도 주문 직전의 공통 관문)에 건다. + - 기대값은 settings.* 참조. +""" +import ast +import inspect +import textwrap +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from sqlalchemy.dialects import postgresql + +from sqlalchemy import select + +from app.core.config import settings +from app.models.models import TradeHistory +from app.services import trade_executor +from app.services.db_worker import DT_STRATEGY_NAMES +from app.services.sizing_core import effective_min_buy_amount, remaining_position_room + + +class _Reached(BaseException): + """가드를 통과해 주문 관문에 도달했다는 신호. + + BaseException 파생 — 프로덕션의 `except Exception:` 폴백에 삼켜지지 않게(§4.1). + """ + + +class _Result: + def __init__(self, rows=None): + self._rows = list(rows or []) + + def scalars(self): + return self + + def first(self): + return self._rows[0] if self._rows else None + + def scalar_one_or_none(self): + return self._rows[0] if self._rows else None + + def all(self): + return list(self._rows) + + +class _FakeDB: + """execute 문장을 기록하고 빈 결과(또는 지정 결과)를 돌려준다. commit 은 호출되면 실패.""" + + def __init__(self, rows_for_first_call=None): + self.statements = [] + self._first_rows = rows_for_first_call + + async def execute(self, stmt): + self.statements.append(stmt) + if len(self.statements) == 1 and self._first_rows is not None: + return _Result(self._first_rows) + return _Result([]) + + async def commit(self): + raise AssertionError("trade_executor 가 db.commit() 을 직접 호출했다 (§3.2 위반)") + + +@pytest.fixture +def _block_order_gate(monkeypatch): + gate = AsyncMock(side_effect=_Reached("order gate reached")) + monkeypatch.setattr(trade_executor, "_acquire_order_lock", gate) + return gate + + +@pytest.fixture +def _empty_dt_state(monkeypatch): + from app.services.daytrading.position_manager import day_trading_state + monkeypatch.setattr(day_trading_state, "positions", {}) + return day_trading_state + + +# ═══════════════════════════════════════════════════════════════════════════ +# 1. 일일 매수 한도에서 DT 제외 +# ═══════════════════════════════════════════════════════════════════════════ + +def _compile(stmt) -> str: + return str(stmt.compile(dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True})) + + +@pytest.mark.asyncio +async def test_daily_buy_limit_counts_only_swing_owned_buys(monkeypatch): + """스윙 한도 → swing + NULL 레거시만 집계. DT·manual·external·us_swing 제외.""" + monkeypatch.setattr(settings, "BEAR_MARKET_FILTER_ENABLED", False) + # 오늘 스윙 매수 = 한도 → 조기 반환(추천 처리 없음) + db = _FakeDB(rows_for_first_call=[f"{i:06d}" for i in range(settings.MAX_DAILY_TRADES)]) + result = await trade_executor._execute_auto_trade_background_locked( + {"recommendations_by_rule": []}, db + ) + assert result == [] + assert db.statements, "당일 매수 집계 쿼리가 실행되지 않았다" + + sql = _compile(db.statements[0]) + assert "trade_history.strategy_type IS NULL" in sql + assert "trade_history.strategy_type IN ('swing')" in sql + for excluded in (*DT_STRATEGY_NAMES, "manual", "external", "us_swing"): + assert f"'{excluded}'" not in sql, f"{excluded} 가 집계 조건에 들어 있다" + + +def test_daily_limit_and_sell_ownership_share_owner_set(): + """일일 한도 집계와 매도 소유권이 같은 스윙 소유 기준을 쓴다.""" + owners = trade_executor._SWING_OWNER_STRATEGY_TYPES + f = trade_executor._sell_ownership_block_reason_v2 + for st in owners: + assert f(SimpleNamespace(strategy_type=st), {"strategy_type": "swing"}) is None + sql = _compile(select(TradeHistory.id).where( + trade_executor._swing_owned_clause_v2(TradeHistory.strategy_type))) + for st in owners: + assert f"'{st}'" in sql + + +class _RaisingDB(_FakeDB): + async def execute(self, stmt): + self.statements.append(stmt) + raise RuntimeError("db down") + + +@pytest.mark.asyncio +async def test_daily_buy_limit_fails_closed_on_query_error(monkeypatch, caplog): + """집계 조회 실패 → 신규 진입 0건(fail-closed) + error 로그. execute_auto_trade 미호출.""" + monkeypatch.setattr(settings, "BEAR_MARKET_FILTER_ENABLED", False) + called = AsyncMock(side_effect=_Reached("execute_auto_trade reached")) + monkeypatch.setattr(trade_executor, "execute_auto_trade", called) + db = _RaisingDB() + recs = {"recommendations_by_rule": [{"rule_name": "r", "stocks": [ + {"stock_code": "005930", "stock_name": "t", "current_price": 70000, "score": 5}, + ]}]} + import logging + with caplog.at_level(logging.ERROR, logger=trade_executor.logger.name): + result = await trade_executor._execute_auto_trade_background_locked(recs, db) + assert result == [] + called.assert_not_called() + assert any(r.levelno >= logging.ERROR and "fail-closed" in r.getMessage() for r in caplog.records) + + +@pytest.mark.asyncio +async def test_count_swing_buys_returns_none_on_error(): + assert await trade_executor._count_swing_buys_today_v2(_RaisingDB()) is None + + +def test_daily_limit_fail_closed_decision_outside_inner_try(): + """fail-closed 판정(`_bought_today is None`)이 내부 try 안에 있지 않다(§4). + + 함수 전체를 감싼 최외곽 try 는 예외 시 빈/부분 결과를 반환하므로 fail-closed 방향이다. + """ + tree = ast.parse(textwrap.dedent(inspect.getsource( + trade_executor._execute_auto_trade_background_locked))) + fn = tree.body[0] + outer = next(s for s in fn.body if isinstance(s, ast.Try)) + + def _is_none_check(n): + return (isinstance(n, ast.Compare) and isinstance(n.left, ast.Name) + and n.left.id == "_bought_today" and isinstance(n.ops[0], ast.Is)) + + assert any(_is_none_check(n) for n in ast.walk(outer)), "fail-closed 판정이 없다" + for n in ast.walk(outer): + if isinstance(n, ast.Try) and n is not outer: + assert not any(_is_none_check(m) for m in ast.walk(n)), "판정이 내부 try 안에 있다" + + +# ── 1-b. 부분매도 반복 차단(_sold_today)에서 DT 매도 제외 ───────────────── + +def test_non_dt_sold_today_query_uses_not_dt_clause(): + import datetime as _dt + captured = [] + + class _DB(_FakeDB): + async def execute(self, stmt): + captured.append(stmt) + return _Result([]) + + import asyncio + found = asyncio.run(trade_executor._non_dt_sold_today_v2( + _DB(), "005930", _dt.datetime(2026, 10, 1))) + assert found is False + sql = _compile(captured[0]) + assert "trade_history.strategy_type IS NULL" in sql + assert "NOT IN" in sql + for name in DT_STRATEGY_NAMES: + assert f"'{name}'" in sql + + +@pytest.mark.asyncio +async def test_non_dt_sold_today_is_conservative_on_error(): + import datetime as _dt + assert await trade_executor._non_dt_sold_today_v2( + _RaisingDB(), "005930", _dt.datetime(2026, 10, 1)) is True + + +class _SellFlowDB: + """execute_auto_trade 의 쿼리 순서를 흉내 낸다. + + 1) 보유 조회 → holding 2) 보유 수 → [holding] 3) 당일 SELL(전체) → dt_sell_row + 4) 당일 비-DT SELL → non_dt_rows 그 외 → 빈 결과 + """ + + def __init__(self, holding, any_sell_rows, non_dt_rows): + self._seq = [[holding], [holding], any_sell_rows, non_dt_rows] + self.n = 0 + + async def execute(self, stmt): + rows = self._seq[self.n] if self.n < len(self._seq) else [] + self.n += 1 + return _Result(rows) + + async def commit(self): + raise AssertionError("commit 금지") + + +def _partial_exit_stock(): + # check_exit_signal 이 부분매도를 내도록 모킹하고, 잔량 추적은 된 상태(remaining_pct=1.0) + return { + "stock_code": "005930", "stock_name": "테스트", "current_price": 11_000.0, + "signals": [], "strategy_type": "swing", "remaining_pct": 1.0, + "rsi": 56.0, "bb": (12_000.0, 10_500.0, 9_000.0), "atr": 200.0, + } + + +def _patch_exit_flow(monkeypatch, sell_pct): + monkeypatch.setattr(trade_executor.mdd_manager, "can_open_new_position", AsyncMock(return_value=True)) + monkeypatch.setattr(trade_executor.loss_tracker, "is_banned_safe", AsyncMock(return_value=False)) + monkeypatch.setattr(settings, "ML_ENSEMBLE_ENABLED", False) + monkeypatch.setattr(settings, "NEWS_ENABLED", False) + monkeypatch.setattr( + trade_executor, "check_exit_signal", + lambda **kw: SimpleNamespace(action="SELL_PARTIAL", sell_pct=sell_pct, reason="RSI test"), + ) + sell = AsyncMock(side_effect=_Reached("sell reached")) + monkeypatch.setattr(trade_executor, "_execute_sell", sell) + return sell + + +@pytest.mark.asyncio +async def test_dt_only_sell_today_does_not_block_swing_partial_exit(monkeypatch): + """오늘 매도가 DT 것뿐이면 스윙 부분매도는 진행된다.""" + sell = _patch_exit_flow(monkeypatch, 0.5) + holding = SimpleNamespace(quantity=10, strategy_type="swing", average_price=10_000.0, + profit_loss_rate=10.0, stock_name="테스트") + db = _SellFlowDB(holding, any_sell_rows=["dt-sell"], non_dt_rows=[]) + with pytest.raises(_Reached): + await trade_executor.execute_auto_trade(_partial_exit_stock(), db) + assert sell.await_args.kwargs.get("sell_pct") == 0.5 + + +@pytest.mark.asyncio +async def test_swing_sell_today_still_blocks_partial_exit(monkeypatch): + """오늘 스윙 매도가 있으면 부분매도는 기존대로 차단된다.""" + sell = _patch_exit_flow(monkeypatch, 0.5) + holding = SimpleNamespace(quantity=10, strategy_type="swing", average_price=10_000.0, + profit_loss_rate=10.0, stock_name="테스트") + db = _SellFlowDB(holding, any_sell_rows=["swing-sell"], non_dt_rows=["swing-sell"]) + res = await trade_executor.execute_auto_trade(_partial_exit_stock(), db) + assert res == {"action": "SKIP", "reason": "cooldown_sell_today", "stock": "테스트"} + sell.assert_not_called() + + +@pytest.mark.asyncio +async def test_dt_sell_today_still_blocks_rebuy_without_holding(monkeypatch): + """당일 재매수 차단은 DT 매도도 포함(무보유 종목 → 즉시 SKIP).""" + monkeypatch.setattr(trade_executor.mdd_manager, "can_open_new_position", AsyncMock(return_value=True)) + monkeypatch.setattr(trade_executor.loss_tracker, "is_banned_safe", AsyncMock(return_value=False)) + buy = AsyncMock(side_effect=_Reached("buy reached")) + monkeypatch.setattr(trade_executor, "_execute_buy", buy) + db = _SellFlowDB(None, any_sell_rows=["dt-sell"], non_dt_rows=[]) + db._seq = [[], [], ["dt-sell"], []] + res = await trade_executor.execute_auto_trade( + {"stock_code": "005930", "stock_name": "테스트", "current_price": 11_000.0, + "signals": ["x"], "rule_id": 1}, db) + assert res["reason"] == "cooldown_sell_today" + buy.assert_not_called() + + +# ═══════════════════════════════════════════════════════════════════════════ +# 2. 종목당 누적 상한 +# ═══════════════════════════════════════════════════════════════════════════ + +TOTAL = 100_000_000.0 + + +def test_remaining_position_room_pure(): + cap = settings.MAX_POSITION_SIZE + assert remaining_position_room(TOTAL, cap, 0) == pytest.approx(TOTAL * cap) + assert remaining_position_room(TOTAL, cap, TOTAL * cap * 0.5) == pytest.approx(TOTAL * cap * 0.5) + assert remaining_position_room(TOTAL, cap, TOTAL * cap * 2) == 0.0 # 초과 → floor 0 + assert remaining_position_room(0, cap, 1) == 0.0 + assert remaining_position_room(TOTAL, 0, 0) == 0.0 + assert remaining_position_room(TOTAL, cap, -5) == pytest.approx(TOTAL * cap) + + +def test_position_cap_no_holding_is_unchanged(_empty_dt_state): + amt, reason = trade_executor._apply_position_cap_room_v2( + invest_amount=1_234_567.0, holding=None, stock_code="005930", stock_name="t", + current_price=10_000.0, total_assets=TOTAL, position_cap_ratio=settings.MAX_POSITION_SIZE, + ) + assert reason is None and amt == 1_234_567.0 + + +def test_position_cap_clamps_to_remaining_room(_empty_dt_state): + cap = settings.MAX_POSITION_SIZE + price = 10_000.0 + held_qty = int(TOTAL * cap * 0.5 / price) # 상한의 절반 보유 + holding = SimpleNamespace(quantity=held_qty, strategy_type="swing") + amt, reason = trade_executor._apply_position_cap_room_v2( + invest_amount=TOTAL * cap, holding=holding, stock_code="005930", stock_name="t", + current_price=price, total_assets=TOTAL, position_cap_ratio=cap, + ) + assert reason is None + assert amt == pytest.approx(TOTAL * cap - held_qty * price) + assert (held_qty * price + amt) / TOTAL <= cap + 1e-9 + + +def test_position_cap_reached_skips(_empty_dt_state): + cap = settings.MAX_POSITION_SIZE + price = 10_000.0 + min_buy = effective_min_buy_amount(settings.MIN_BUY_AMOUNT_KRW, TOTAL, cap, 0.9) + # 잔여 한도가 최소주문금액보다 1주만큼 작게 남도록 보유 + held_value = TOTAL * cap - (min_buy - price) + holding = SimpleNamespace(quantity=int(held_value // price) + 1, strategy_type="swing") + amt, reason = trade_executor._apply_position_cap_room_v2( + invest_amount=TOTAL * cap, holding=holding, stock_code="005930", stock_name="t", + current_price=price, total_assets=TOTAL, position_cap_ratio=cap, + ) + assert reason == "position_cap_reached" and amt == 0.0 + + +def test_position_cap_counts_dt_inmemory_qty(_empty_dt_state): + """sync 전 DT 매수분(행에 미반영)도 센다 — max(행, DT).""" + cap = settings.MAX_POSITION_SIZE + price = 10_000.0 + dt_qty = int(TOTAL * cap / price) # DT 만으로 상한 도달 + _empty_dt_state.positions["005930"] = {"quantity": dt_qty} + amt, reason = trade_executor._apply_position_cap_room_v2( + invest_amount=TOTAL * cap, holding=None, stock_code="005930", stock_name="t", + current_price=price, total_assets=TOTAL, position_cap_ratio=cap, + ) + assert reason == "position_cap_reached" + + +def test_position_cap_unverifiable_without_total_assets(_empty_dt_state): + holding = SimpleNamespace(quantity=10, strategy_type="swing") + amt, reason = trade_executor._apply_position_cap_room_v2( + invest_amount=1_000_000.0, holding=holding, stock_code="005930", stock_name="t", + current_price=10_000.0, total_assets=0.0, position_cap_ratio=settings.MAX_POSITION_SIZE, + ) + assert reason == "position_cap_unverifiable" and amt == 0.0 + + +def test_position_cap_guard_is_outside_try(): + """_execute_buy 에서 누적 상한 가드 호출이 try 블록 밖(함수 최상위 문장)에 있다.""" + tree = ast.parse(textwrap.dedent(inspect.getsource(trade_executor._execute_buy))) + fn = tree.body[0] + + def _calls_guard(node): + return any( + isinstance(n, ast.Call) and isinstance(n.func, ast.Name) + and n.func.id == "_apply_position_cap_room_v2" + for n in ast.walk(node) + ) + + for n in ast.walk(fn): + if isinstance(n, ast.Try): + assert not _calls_guard(n), "누적 상한 가드가 try 안에 있다 (§4 위반)" + assert any(_calls_guard(s) for s in fn.body), "누적 상한 가드 호출이 최상위에 없다" + + # 1건 절대 상한 클램프보다 뒤에 있어야 한다(패턴메모리 상향분까지 묶음) + idx_guard = next(i for i, s in enumerate(fn.body) if _calls_guard(s)) + idx_clamp = next( + i for i, s in enumerate(fn.body) + if any(isinstance(n, ast.Name) and n.id == "clamp_by_ratios" for n in ast.walk(s)) + and any(isinstance(n, ast.Attribute) and n.attr == "MAX_POSITION_SIZE" for n in ast.walk(s)) + and not isinstance(s, ast.Try) + ) + assert idx_clamp < idx_guard + + +def _patch_buy_pipeline(monkeypatch, total, cash, recorded): + monkeypatch.setattr(trade_executor, "check_opening_range_volatility", AsyncMock(return_value=False)) + monkeypatch.setattr(trade_executor, "check_surge_prevention", AsyncMock(return_value={})) + monkeypatch.setattr(trade_executor, "check_trend_filter", AsyncMock(return_value={})) + monkeypatch.setattr(trade_executor, "_get_cached_account", lambda: (cash, total)) + monkeypatch.setattr(trade_executor, "validate_correlation_filter", AsyncMock(return_value=(True, "ok"))) + monkeypatch.setattr(trade_executor, "calculate_cooldown_minutes", lambda *a, **k: 0) + # Kelly 가 상한까지 꽉 채운 예산을 낸다고 가정 + monkeypatch.setattr( + trade_executor, "_calculate_kelly_position", + AsyncMock(return_value=total * settings.MAX_POSITION_SIZE), + ) + import app.services.adaptive_strategy as _ad + monkeypatch.setattr(_ad, "get_current_adaptive_params", lambda: SimpleNamespace(fallback_used=True)) + import app.services.stock_pattern_memory as _pm + + async def _identity(stock_code, base_invest_amount, **kw): + return base_invest_amount + monkeypatch.setattr(_pm, "get_pattern_adjusted_invest_amount", _identity) + + _orig_a2q = trade_executor.amount_to_quantity + + def _rec(amount, price): + recorded.append(amount) + return _orig_a2q(amount, price) + monkeypatch.setattr(trade_executor, "amount_to_quantity", _rec) + + +async def _call_buy(holding, price): + return await trade_executor._execute_buy( + stock={"_regime_str": "bull"}, db=_FakeDB(), holding=holding, + current_price=price, signals=["x"], score_val=5.0, is_domestic=True, + exchange_code="KRX", stock_code="005930", stock_name="테스트", + ) + + +@pytest.mark.asyncio +async def test_execute_buy_add_on_is_capped_by_existing_holding( + monkeypatch, _block_order_gate, _empty_dt_state +): + recorded = [] + _patch_buy_pipeline(monkeypatch, TOTAL, TOTAL, recorded) + cap = settings.MAX_POSITION_SIZE + price = 10_000.0 + held_qty = int(TOTAL * cap * 0.5 / price) + holding = SimpleNamespace(quantity=held_qty, strategy_type="swing", stock_name="테스트", + current_price=price, average_price=price) + with pytest.raises(_Reached): + await _call_buy(holding, price) + assert recorded, "주수 환산까지 도달하지 않았다" + assert recorded[-1] <= TOTAL * cap - held_qty * price + 1e-6 + assert (held_qty * price + recorded[-1]) / TOTAL <= cap + 1e-9 + + +@pytest.mark.asyncio +async def test_execute_buy_skips_when_holding_at_cap( + monkeypatch, _block_order_gate, _empty_dt_state +): + recorded = [] + _patch_buy_pipeline(monkeypatch, TOTAL, TOTAL, recorded) + price = 10_000.0 + held_qty = int(TOTAL * settings.MAX_POSITION_SIZE / price) + holding = SimpleNamespace(quantity=held_qty, strategy_type="swing", stock_name="테스트", + current_price=price, average_price=price) + res = await _call_buy(holding, price) + assert res["action"] == "SKIP" and res["reason"] == "position_cap_reached" + _block_order_gate.assert_not_called() + + +# ═══════════════════════════════════════════════════════════════════════════ +# 3. 추천 경로 매도에서 external / manual / DT 보유 행 제외 +# ═══════════════════════════════════════════════════════════════════════════ + +def _holding(st): + return SimpleNamespace(quantity=10, strategy_type=st, average_price=10_000.0, + profit_loss_rate=0.0, stock_name="테스트") + + +async def _call_sell(holding, stock=None, sell_pct=1.0): + return await trade_executor._execute_sell( + stock=stock if stock is not None else {"strategy_type": "swing"}, + db=_FakeDB(), holding=holding, current_price=9_000.0, signals=["ATR 손절"], + is_domestic=True, exchange_code="KRX", stock_code="005930", stock_name="테스트", + sell_pct=sell_pct, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("st,reason", [ + ("external", "manual_judgment_holding"), + ("manual", "manual_judgment_holding"), + ("daytrading", "owned_by_daytrading"), + ("day_trade", "owned_by_daytrading"), + ("us_swing", "owned_by_other_strategy"), +]) +async def test_sell_skips_non_swing_holdings(st, reason, _block_order_gate, _empty_dt_state): + res = await _call_sell(_holding(st)) + assert res == {"action": "SKIP", "reason": reason, "stock": "테스트"} + _block_order_gate.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("st", ["swing", None]) +async def test_sell_allows_swing_holdings(st, _block_order_gate, _empty_dt_state): + """스윙(또는 legacy NULL) 행은 가드를 통과해 주문 관문까지 간다.""" + with pytest.raises(_Reached): + await _call_sell(_holding(st)) + + +def test_ownership_rule_owner_caller_allowed(): + """호출자 태그가 소유 엔진과 같으면 허용(현재 그런 호출자는 없지만 계약 고정).""" + f = trade_executor._sell_ownership_block_reason_v2 + assert f(_holding("daytrading"), {"strategy_type": "daytrading"}) is None + assert f(_holding("us_swing"), {"strategy_type": "us_swing"}) is None + # external/manual 은 호출자 태그와 무관하게 항상 차단 + assert f(_holding("manual"), {"strategy_type": "manual"}) == "manual_judgment_holding" + assert f(_holding("external"), {"strategy_type": "external"}) == "manual_judgment_holding" + + +def test_ownership_guard_is_outside_try_and_first(): + """_execute_sell 의 소유권 가드는 try 밖, 주문 관문보다 앞에 있다.""" + tree = ast.parse(textwrap.dedent(inspect.getsource(trade_executor._execute_sell))) + fn = tree.body[0] + + def _calls(node, name): + return any( + isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == name + for n in ast.walk(node) + ) + + for n in ast.walk(fn): + if isinstance(n, ast.Try): + assert not _calls(n, "_sell_ownership_block_reason_v2") + idx_guard = next(i for i, s in enumerate(fn.body) if _calls(s, "_sell_ownership_block_reason_v2")) + idx_lock = next(i for i, s in enumerate(fn.body) if _calls(s, "_acquire_order_lock")) + assert idx_guard < idx_lock + + +def test_mdd_reduction_does_not_route_through_execute_sell(): + """MDD 강제축소는 _execute_sell / execute_auto_trade 를 쓰지 않는다 (§8.1-11).""" + from app.services import mdd_manager as _mdd + src = inspect.getsource(_mdd) + assert "_execute_sell" not in src + assert "execute_auto_trade" not in src + + +# ═══════════════════════════════════════════════════════════════════════════ +# H-1. 팔 수 없는 행에는 사지 않는다 (매수·매도 소유권 대칭) +# M-1. 고아 daytrading 행 skip 은 warning +# ═══════════════════════════════════════════════════════════════════════════ + +def _patch_buy_side_flow(monkeypatch, exit_signal=None): + monkeypatch.setattr(trade_executor.mdd_manager, "can_open_new_position", AsyncMock(return_value=True)) + monkeypatch.setattr(trade_executor.loss_tracker, "is_banned_safe", AsyncMock(return_value=False)) + monkeypatch.setattr(settings, "ML_ENSEMBLE_ENABLED", False) + monkeypatch.setattr(settings, "NEWS_ENABLED", False) + monkeypatch.setattr(trade_executor, "check_exit_signal", lambda **kw: exit_signal) + buy = AsyncMock(side_effect=_Reached("buy reached")) + sell = AsyncMock(side_effect=_Reached("sell reached")) + monkeypatch.setattr(trade_executor, "_execute_buy", buy) + monkeypatch.setattr(trade_executor, "_execute_sell", sell) + return buy, sell + + +def _rule_buy_stock(): + return { + "stock_code": "005930", "stock_name": "테스트", "current_price": 10_000.0, + "signals": ["x"], "rule_id": 1, "strategy_type": "swing", "remaining_pct": 1.0, + "rsi": 40.0, "bb": (12_000.0, 10_500.0, 9_000.0), "atr": 200.0, + } + + +def _flow_db(holding): + db = _SellFlowDB(holding, any_sell_rows=[], non_dt_rows=[]) + db._seq = [[holding], [holding], []] + return db + + +@pytest.mark.asyncio +@pytest.mark.parametrize("st,reason", [ + ("manual", "manual_judgment_holding"), + ("daytrading", "owned_by_daytrading"), + ("day_trade", "owned_by_daytrading"), + ("us_swing", "owned_by_other_strategy"), +]) +async def test_buy_blocked_on_unsellable_holding(monkeypatch, st, reason): + buy, _ = _patch_buy_side_flow(monkeypatch) + holding = SimpleNamespace(quantity=10, strategy_type=st, average_price=10_000.0, + profit_loss_rate=0.0, stock_name="테스트") + res = await trade_executor.execute_auto_trade(_rule_buy_stock(), _flow_db(holding)) + assert res == {"action": "SKIP", "reason": f"non_swing_holding:{reason}", "stock": "테스트"} + buy.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("st", ["swing", None]) +async def test_buy_allowed_on_swing_holding(monkeypatch, st): + buy, _ = _patch_buy_side_flow(monkeypatch) + holding = SimpleNamespace(quantity=10, strategy_type=st, average_price=10_000.0, + profit_loss_rate=0.0, stock_name="테스트") + with pytest.raises(_Reached): + await trade_executor.execute_auto_trade(_rule_buy_stock(), _flow_db(holding)) + buy.assert_awaited() + + +@pytest.mark.asyncio +async def test_buy_block_does_not_stop_exit_judgment(monkeypatch): + """_guard 경유 — 매수만 막고 청산 판정은 _execute_sell 까지 도달한다(§8.1-11).""" + _, sell = _patch_buy_side_flow( + monkeypatch, + exit_signal=SimpleNamespace(action="SELL_ALL", sell_pct=1.0, reason="test full exit"), + ) + holding = SimpleNamespace(quantity=10, strategy_type="manual", average_price=10_000.0, + profit_loss_rate=0.0, stock_name="테스트") + with pytest.raises(_Reached): + await trade_executor.execute_auto_trade(_rule_buy_stock(), _flow_db(holding)) + sell.assert_awaited() + + +def test_buy_side_block_mirrors_sell_ownership_and_is_not_in_inner_try(): + tree = ast.parse(textwrap.dedent(inspect.getsource(trade_executor.execute_auto_trade))) + fn = tree.body[0] + outer = next(s for s in fn.body if isinstance(s, ast.Try)) + + def _calls(node): + return any( + isinstance(n, ast.Call) and isinstance(n.func, ast.Name) + and n.func.id == "_sell_ownership_block_reason_v2" + for n in ast.walk(node) + ) + + assert _calls(outer), "매수 측이 매도 소유권 판정을 쓰지 않는다" + for n in ast.walk(outer): + if isinstance(n, ast.Try) and n is not outer: + assert not _calls(n), "소유권 매수 차단이 내부 try 안에 있다" + + +@pytest.mark.asyncio +async def test_orphan_daytrading_row_sell_skip_logs_warning( + _block_order_gate, _empty_dt_state, caplog +): + import logging + with caplog.at_level(logging.INFO, logger=trade_executor.logger.name): + res = await _call_sell(_holding("daytrading")) + assert res["reason"] == "owned_by_daytrading" + warns = [r for r in caplog.records if r.levelno == logging.WARNING and "수동 확인 필요" in r.getMessage()] + assert warns, "고아 daytrading 행이 warning 으로 남지 않았다" + assert "005930" in warns[0].getMessage() and "10주" in warns[0].getMessage() + + +@pytest.mark.asyncio +async def test_live_daytrading_row_sell_skip_stays_info( + _block_order_gate, _empty_dt_state, caplog +): + import logging + _empty_dt_state.positions["005930"] = {"quantity": 10} + with caplog.at_level(logging.INFO, logger=trade_executor.logger.name): + res = await _call_sell(_holding("daytrading")) + assert res["reason"] == "owned_by_daytrading" + assert not [r for r in caplog.records if r.levelno >= logging.WARNING]