diff --git a/.claude/scripts/prod_smoke.sh b/.claude/scripts/prod_smoke.sh index a5c83a6..17036ee 100755 --- a/.claude/scripts/prod_smoke.sh +++ b/.claude/scripts/prod_smoke.sh @@ -72,5 +72,25 @@ if w: print("WARNDEG "+"; ".join(w)) *WARNDEG*) echo "WARN HEALTH 운영성 degraded(배포 회귀 아님 가능) — ${hres##*WARNDEG }" ;; esac fi +# health-watch 사이드카 확인 — 경고만(배포 실패로 만들지 않는다). 이 스크립트는 HTTP 만 쓰므로 +# NAS docker 접근이 가능할 때만 동작한다: PROD_SSH_HOST(ssh 대상, 예 "nas") 를 주면 원격에서, +# 없으면 로컬 docker 에 컨테이너가 있을 때만 확인하고 아니면 SKIP. docker 경로/sudo 는 PROD_DOCKER_CMD 로 지정. +DOCKER_CMD="${PROD_DOCKER_CMD:-docker}" +hw_run() { # 인자 그대로 docker 에 전달 + if [ -n "${PROD_SSH_HOST:-}" ]; then ssh -o BatchMode=yes -o ConnectTimeout=10 "$PROD_SSH_HOST" "$DOCKER_CMD $*" 2>/dev/null + else $DOCKER_CMD "$@" 2>/dev/null; fi +} +hw_ps="$(hw_run ps --filter name=autostock-health-watch --format '{{.Status}}' || true)" +if [ -z "$hw_ps" ]; then + echo "SKIP HEALTH-WATCH 컨테이너 확인 불가(PROD_SSH_HOST 미설정이거나 컨테이너 없음)" +else + case "$hw_ps" in + Up*) echo "OK HEALTH-WATCH 컨테이너 $hw_ps" ;; + *) echo "WARN HEALTH-WATCH 컨테이너 비정상: $hw_ps (scripts/health_watch.py 가 디렉터리로 마운트됐는지 확인)" ;; + esac + hw_last="$(hw_run exec autostock-health-watch cat /state/last_run || true)" + if [ -n "$hw_last" ]; then echo "OK HEALTH-WATCH last_run=$hw_last" + else echo "WARN HEALTH-WATCH /state/last_run 없음(장외 시간이면 정상, 장중이면 사이드카 루프 확인)"; fi +fi [ "$fail" = 0 ] && echo "== 스모크 통과 ==" || echo "== 스모크 실패 — Jenkins 로그/컨테이너 상태 확인 (LEAK 이면 인증 회귀) ==" exit "$fail" diff --git a/CLAUDE.md b/CLAUDE.md index 995f285..9a8d4a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,7 @@ adaptive_strategy(_MODE_PARAMS) > strategy_cfg(DB: strategy_configs) > setti - 허용 예외 ②: auth/credentials/bootstrap 시드(회원가입·로그인, `user_kis_credentials` 등록/삭제, 기동 시 관리자·자격증명 시드) — 실시간 계층이 아님. - 허용 예외 ③ (읽기 전용): `daytrading/exit_service.close_stale_positions`, `daytrading/entry_service.populate_daytrading_watchlist`, `daytrading/scheduler` 기동 시 `restore_from_db` — SELECT 전용이며 `commit()` 없음. 각 지점에 주석으로 명시되어 있다. **여기에 쓰기를 추가하지 말 것.** - 허용 예외 ③-c (읽기 전용, 2026-10-02 추가): `daytrading/exit_service._fetch_non_dt_active_qty_impl` — DT 매도 주문 결과 불명(전송 후 응답 유실) 시에만 비-DT active 수량을 1회 SELECT(`not_dt_strategy_clause`)해 재매도 상한에서 스윙 보유분을 뺀다. 그 외 인증 계층의 세션 사용자 상태 SELECT(`core/security.py`, WS 핸드셰이크 포함, 30초 캐시)와 기동 시 1일 지난 FAILED TradeHistory 정리(`main.py`)도 마커와 함께 허용된다. + - 허용 예외 ③-d (읽기 전용, 2026-10-12 추가): `daytrading/exit_service._fetch_dt_ledger_net_qty_impl` — DT 매도 결과 불명의 **재매도 상한**을 잡을 때만 TradeHistory(daytrading, 최근 `DAY_TRADE_ORPHAN_LOOKBACK_DAYS`) 를 1회 SELECT 해 DT 순체결을 구한다(`# DB-READ-EXCEPTION:` 마커). 조회 실패·순체결 ≤0 이면 재매도 보류(fail-closed). - 허용 예외 ③-b (읽기 전용, 2026-10-02 추가): `swing/scheduler.py` 의 사이클별 세션 4곳(청산 판정 `exit_session` / 추천 DB 폴백 `rec_session` / 진입 후보 `entry_session` / 듀얼 스캔 `dual_session`). 전부 SELECT 전용(`commit`·`add` 없음)이며 쓰기는 `db_task_queue` 경유다. 각 지점에 `# DB-READ-EXCEPTION:` 마커가 있다. 하네스 `rules_check` 도 이 마커를 인식한다. 3. 지원 task: insert_trade_history, update_trade_history(+`_by_order_id`), upsert_stock_analysis, bulk_upsert_stock_analysis, upsert_stock_ban, upsert_portfolio, upsert_active_trade, close_active_trade, insert_trade_journal, log_mcp_deny - **전략명 필터 (2026-09-27 추가)**: `close_active_trade`/`upsert_active_trade` payload 가 세 가지 선택 키를 받는다. 우선순위는 `strategy_name`(동등) > `strategy_names`(IN) > `strategy_name_not`(NOT IN, **`strategy_name IS NULL` 행은 제외되지 않음**). 키를 넘기지 않으면 동작은 이전과 동일하다. @@ -170,11 +171,11 @@ adaptive_strategy(_MODE_PARAMS) > strategy_cfg(DB: strategy_configs) > setti - 50% 부분매도: RSI ≥ 55 or 가격 > BB중앙 or 수익 > 2×ATR — **단 최소수익 0.5% 가드**가 있다 - 전량: RSI ≥ 70 or 가격 > BB상단(99%) or 트레일링(고점 − 1.5×ATR) — **트레일링은 1차 분할 익절 이후(remaining_pct ≤ 0.50)에만** 적용(2026-10-02, 사용자 결정). 그 전에는 손절선까지 반납 가능. - 시간손절: **20 KRX 거래일**(`count_krx_trading_days_between`) 보유 + 3% 미달 시 전량 (`check_time_stop_exit` → `swing/exit_service._time_stop_signal_v2`, `SWING_TIME_STOP_ENABLED=True`, 모멘텀 제외, 부분매도 신호보다 우선). 동기화 유입 행은 마지막 전량 청산 이후 TradeHistory BUY 일자를 진입일로. -- 손절 파라미터 단일 소스 `resolve_swing_stop_params_v2`(적응형 → strategy_cfg → config, 범위 검증 `SWING_STOP_LOSS_PCT_MIN/MAX`·`SWING_ATR_STOP_MULT_MIN/MAX`). 폴백 손절은 **지표 판정이 불가할 때만**(판정 예외 포함) 실행되고, 그 경우에도 `-abs(STOP_LOSS_PERCENT)` 하드 바닥이 try 밖에서 보장된다. 현재가 조회 실패 시 대체가(마지막 실시간가 → Portfolio.current_price)로 손절만 판정. +- 손절 파라미터 단일 소스 `resolve_swing_stop_params_v2`(적응형 → strategy_cfg → config, 범위 검증 `SWING_STOP_LOSS_PCT_MIN/MAX`·`SWING_ATR_STOP_MULT_MIN/MAX`). 폴백 손절은 **지표 판정이 불가할 때만**(판정 예외 포함) 실행되고, 그 경우에도 `-abs(STOP_LOSS_PERCENT)` 하드 바닥이 try 밖에서 보장된다. (단 ATR 캐시가 없는 폴백 경로는 `-abs(STOP_LOSS_HIGH_VOL)`(-10%, 느슨한 쪽)을 쓴다 — 2026-10-12 레드팀 확인) 현재가 조회 실패 시 대체가(마지막 실시간가 → Portfolio.current_price)로 손절만 판정. ### 6.4 문서화되지 않았던 진입 차단 게이트 (전부 실재하며 거래 빈도에 큰 영향) - **ML 앙상블 HOLD 게이트** (`trade_executor`): ML이 HOLD이고 신뢰도 ≥60% 면 rule 매수를 차단 -- **지수 서킷브레이커** (`swing/entry_service`): KODEX200 종가 < MA20 이면 신규 진입 **전면 중단** +- **지수 서킷브레이커** (`swing/entry_service`): KODEX200 종가 < MA20 이면 신규 진입 **전면 중단** — **기본값 OFF**(`SWING_INDEX_BREAKER_ENABLED=False`, 2026-09-17 사용자 결정; 켜면 평균회귀·듀얼 공용 적용) - **마켓 플레이북** (`market_playbook`): FOMC·옵션만기 등 이벤트 당일 진입 차단 — 평균회귀·듀얼(모멘텀·눌림목) 모두 적용. position_multiplier 는 사이징에 배선돼 있지 않다. - **듀얼 전략 실행 순서 (2026-10-02)**: 평균회귀 진입 다음에 듀얼 스캔이 **평균회귀 후보 유무·MCP 거부와 무관하게** 실행된다(MDD/AUTO_TRADE/Crisis/플레이북/서킷브레이커/SIDEWAYS 게이트는 적용, **MCP 평가는 적용하지 않음 — 사용자 결정**). 일일 한도(`MAX_DAILY_TRADES`)는 평균회귀가 먼저 쓴다. 모멘텀 돌파는 v2(직전 20봉, 오늘 봉 제외)로 `MOMENTUM_BREAKOUT_ENABLED=True` — v1 은 오늘 고가를 포함해 구조적으로 발화 불가였다. - **거시/국면 position_multiplier** (`market_analyzer`): 장외 0.5, CRASH 0.0~0.1, BEAR ratio @@ -387,10 +388,22 @@ DX(0~100)에 합산형 평활을 적용한 버그. 측정: `up 1361 vs 97 / flat 33. **주문 결과 불명의 경로 간 오귀속(6회차 H-2, 미해결)**: 스윙 executor·swing exit·DT·MDD 가 각자 "결과 불명" 상태를 갖고 **잔고 증감**으로 체결을 판정한다. 같은 종목에 두 경로가 동시에 불명이면 다른 경로의 체결을 자기 체결로 오인할 수 있다(DT 포지션 고아화, MDD 이중 차감). 해결 방향: 종목별 공용 불명 레지스트리(수량·경로·시각) + 판정을 "자기 주문 수량과 일치하는 감소 + 주문내역 ODNO 일치"로 강화, 타 경로 불명이 걸려 있으면 판정 보류. 스윙 시장가 매도는 매 주문 직전 KIS 잔고 상한(`min(ord_psbl, hldg) − DT`)으로 DT 물량 매도를 구조적으로 막는다. 34. 결과 불명 BUY 는 PENDING 으로 남아 체결 확인 태스크가 없다 → 실제 미접수여도 당일 재매수 차단·일일 슬롯 1개 소진(거래 확대 관점 손실). N초 후 `_find_recent_order`+잔고로 FAILED 정정 필요. 35. 지정가 익절 미체결 취소: 체결조회 5회 실패 시 취소 미시도(warning), 취소 수량을 폴링 시점 잔량으로 지정(부분체결 경합 시 거부) → 전량 취소(`quantity=None`)로 바꾸고 텔레그램 경보 필요. 그동안 묶인 수량이 스윙·MDD 시장가 손절을 거부시킬 수 있다. MDD 'wait'(주문가능<보유) 분기에 상한 없음. -37. **`get_balance` 는 첫 페이지만 조회한다(연속조회 없음 — 모의 20행/실전 50행).** 스윙 매도 직전 잔고 상한은 "미발견 + 페이지 가득"을 조회 실패로 보고 매도를 보류(error)한다(`SWING_BALANCE_FULL_PAGE_ROWS`). 보유 종목이 많아지면 손절이 보류되므로 연속조회(`get_balance_all_v2`) 구현이 필요하다. `sync_portfolio` 도 첫 페이지만 보고 "응답에 없는" 행을 삭제한다(기존 결함). -38. 스윙 결과 불명 매도 잔여(7회차): (H-B) 불명 주문이 미결(주문가능<보유)인 동안에도 같은 분할 단계가 재매도되고 정상 매도의 잔고 감소를 불명 체결로 이중 기록할 수 있다 — 대기 중 종목은 매도 skip, accepted 시 `pre_hldg` 갱신 필요. (H-C) 보유 목록에서 빠진 종목의 불명 대기는 대조되지 않고 남아, 재매수한 새 포지션에 가짜 체결을 기록할 수 있다 — 주기 시작 시 잔고 스냅샷 1회로 전체 대기를 대조 + TTL + 매수 확인 시 폐기. 둘 다 결과 불명 주문이 있어야만 발생. +37. **`get_balance` 는 첫 페이지만 조회한다(연속조회 없음 — 모의 20행/실전 50행).** ✅ **2026-10-12 부분 해소**: `get_balance_all_v2`(tr_cont 연속조회, `KIS_BALANCE_MAX_PAGES`=5, 불완전하면 `_partial=True`) 를 신설해 **스윙 매도 직전 상한(`_fetch_kis_holding_v3`)·MDD 축소** 가 쓴다 — `_partial` 이면 기존처럼 보류, 완전 응답+미발견은 미보유로 확정. **`sync_portfolio` 와 DT `_fetch_kis_holdings_v3_impl`, `get_todays_orders(_v2)` 는 여전히 첫 페이지만 본다.** (이하 원문) 스윙 매도 직전 잔고 상한은 "미발견 + 페이지 가득"을 조회 실패로 보고 매도를 보류(error)한다(`SWING_BALANCE_FULL_PAGE_ROWS`). 보유 종목이 많아지면 손절이 보류되므로 연속조회(`get_balance_all_v2`) 구현이 필요하다. `sync_portfolio` 도 첫 페이지만 보고 "응답에 없는" 행을 삭제한다(기존 결함). +38. ✅ **2026-10-12 해소(9회차)**: H-B — 불명 대기 중에는 부분익절(sell_pct<1) 신호를 건너뛰고(손절·전량은 KIS 상한 하에 계속), 불명 등록 시 `_recent_exits` 쿨다운 기록, 정상 접수 시 `pre_hldg` 차감. H-C — 대기 항목은 등록일 15:30 KST 경과 또는 같은 종목 재매수 관측 시 `_reconcile` 1회 후 폐기(warning+텔레그램). 원문: 스윙 결과 불명 매도 잔여(7회차): (H-B) 불명 주문이 미결(주문가능<보유)인 동안에도 같은 분할 단계가 재매도되고 정상 매도의 잔고 감소를 불명 체결로 이중 기록할 수 있다 — 대기 중 종목은 매도 skip, accepted 시 `pre_hldg` 갱신 필요. (H-C) 보유 목록에서 빠진 종목의 불명 대기는 대조되지 않고 남아, 재매수한 새 포지션에 가짜 체결을 기록할 수 있다 — 주기 시작 시 잔고 스냅샷 1회로 전체 대기를 대조 + TTL + 매수 확인 시 폐기. 둘 다 결과 불명 주문이 있어야만 발생. 36. KIS 주문 엔드포인트가 EGW00201·토큰 만료를 HTTP 500 으로 주는지 **실측 필요** — 5xx+JSON 본문은 dict 로 반환해 msg_cd 로 분류(2026-10-02), 본문 없는 5xx 는 결과 불명. +**2026-10-12 하드닝 9회차 후 잔여** (발견 5·수정 5·검증 2 에이전트, Critical 0 / High 4 → 코드로 닫은 것 2 + 사용자 결정 1 + 구조 보류 1) +39. ⚠️ **[사용자 결정 필요] 적응형 Crisis 모드가 기존 보유분 손절선을 소급 조정한다.** `mean_reversion_strategy.resolve_swing_stop_params_v2` 는 청산 판정마다 **현재** 모드의 `stop_loss_pct`/`atr_stop_multiplier` 를 읽는다(진입 시점 값 고정 없음). 2026-10-12(월) 09:00~10:01 스윙 보유 10종목 전량 손절 순손익 **-236,765원**(trade_history id 450~459, exit_reason 전부 `-3.0%`/`-5.0%기준`·ATR 1.5배 = Crisis 값). 퍼센트 기준 단독 발동 5건(450·455·456·457·458, -95,242원)은 Normal(-7/-10%) 이면 미발동이고, 나머지 5건도 ATR 2.5배로는 미발동(타 세션 반사실 계산, 메모리 `adaptive-crisis-retroactive-stops`). 선택지: (a) 손절 파라미터를 진입 시점 모드로 ActiveTrade 에 고정, (b) Crisis 는 신규 진입 차단(`max_daily_trades=0`)만 하고 손절은 Normal 유지, (c) 현행 유지. **백테스트 없이 바꾸지 말 것(§8.1-5)** — 단 §8.3 `_MODE_PARAMS` 숫자는 그대로 두고 소비 방식만 바꾸는 것이 전제. +40. [사용자 결정] DT `check_day_trade_exit` 가 **시간손절을 트레일링보다 먼저** 판정한다(`day_trading_strategy.py:816-823` vs `841-857`). 고점 후 -0.3% 아래로 떨어진 청산이 "시간 손절"로 라벨돼 loss_tracker 제외(§5.1 예외 확대)·TradeJournal·ML 라벨 오염. 순서 변경은 청산 의미 변경이라 백테스트 대상. +41. 스윙 **진입 게이트가 Redis 추천 캐시 히트 여부에 따라 다르다**(`recommendations.py:920-925` vs `683-689`·`736`): 캐시 히트면 DB 규칙 id 1(RSI<30+거래량 1.5배)이 일반 매처를 거쳐 `rule_id` 강제매수 후보가 되고 평균회귀 게이트(own_trend·ADX·%B)를 거치지 않는다. 캐시 미스면 DB 빌더가 MEAN_REVERSION 외 규칙을 불일치 처리해 대체 목록에서만 게이트를 적용. 두 빌더의 후보 판정을 한 함수로 통일해야 한다(메모리 `swing-entry-path-mismatch`). +42. `get_todays_orders_v2`(실패 시 None) 는 DT 결과 불명 재시도 경로만 쓴다. **`trade_executor.py:352` 등 나머지 결과 불명 판정은 여전히 `get_todays_orders`(실패 시 [] → "미접수" 편향)** 를 쓴다. DT 부분체결 미종결 상태에서 불명 매도가 주문 없이 accepted 로 오판되는 경로(인메모리 10 vs 실보유 4)도 남아 있다. 당일 주문 조회는 첫 페이지(모의 15건)만 본다. +43. **DT 결과 불명 재매도의 잔여 위험**(`_resolve_unknown_exit_v3_impl` 판정 순서: 주문조회 None→보류 / 등록시각−skew 윈도우 이후·수량 q 일치·거부/취소0체결 아닌 SELL 행→accepted / 행 없음+비온전→보류 / 원장 실패·≤0→보류 / []+원장>0→`min(dt_sellable, ledger, q)` 재매도): ① 당일 주문내역이 첫 페이지(모의 15건)를 넘겨 비면 수동 보유분이 q 까지 팔릴 수 있다. ② DT 주문 직전 60초 안의 같은 수량 스윙 매도가 accepted 로 오인되면 DT 포지션만 닫히고 주식은 미관리로 남는다(과소매도, 고아 감지가 후속). ③ 종일 보류는 EOD 청산까지 막아 오버나이트가 되고 익일 stale 청산은 수동분을 구분하지 못한다. ④ 스윙 불명 대기의 만료 대조는 사실상 익일 첫 사이클에 돌아, 재매수와 겹치면 체결된 불명 매도를 '미체결'로 조용히 정리할 수 있다(warning). 결과 불명 DT SELL 은 TradeHistory 행을 남기지 않아 원장이 BUY 수량 그대로다(PENDING SELL 행 기록이 구조적 해법). +44. `config.py` 에 **미선언 키 약 40개**(`MDD_*`, `SWING_*` 알림·타임아웃, `DAY_TRADE_*` 일부)가 `getattr(settings, KEY, default)` 로만 읽힌다 — `.env` 로 바꿀 수 없다. 선언하면 기존 테스트 ~40건이 패치하는 모듈 기본값(`_DEFAULT_*`)보다 settings 가 우선해 목킹이 무력화되므로 **테스트를 settings 패치로 이관한 뒤** 선언할 것. `MDD_UPDATE_STALE_SEC` 는 실효 기본값 900(`mdd_manager.py:28`) 과 다른 파일의 300 리터럴이 공존한다. 2026-10-12 에 12개 키만 선언(소비처 기본값과 동일, 동작 무변경). +45. KIS 서킷브레이커 **면제가 엔드포인트 단위**(잔고·현재가·당일체결)라 스캐너의 현재가 호출도 OPEN 을 우회한다. OPEN/HALF_OPEN 중 면제 호출은 성공·실패를 집계하지 않아 브레이커는 비면제 프로브로만 닫힌다. 호출부 플래그(청산 경로만 면제) 방식이 후보. +46. 테스트 커버리지: 기존 14개 파일이 `get_balance_all_v2` 를 `get_balance` 목으로 별칭(`_pages` 없음)해 **완전/불완전 응답 분기는 `test_swing_exit_r9_20261012.py` 만 검증**한다. `test_mdd_state_backup` 은 단독 실행 시 Redis 실접속(전체 스위트에서는 앞선 파일의 목으로 통과 — §4.1 순서 의존). `_swing_redis_guard` autouse 가 `test_swing*`·round5 에 들어갔다. +47. 백로그(8회차 건너뜀 157건 재판정: 83그룹 중 해소 18 / 열림 25 / 백테스트·결정 필요 34 / 판정 불가 6): Redis `volatile-lru`+`appendonly` 없음(`daytrading:daily:*` 축출 가능), 스윙 SELL `executed_price=None` 소비처(AI 장후 리뷰·일지 가격 0), `fill_sweep` 이 전일 이전 미확인 체결 행을 보정하지 않음(`created_at >= 당일`), `StockAnalysis` 에 `price_date`/`adj_factor` 부재, `ActiveTrade.stop_loss` 퍼센트·가격 혼재, `trade_executor.py:3203` DT 수량 조회 예외를 로그 없이 0 처리, adaptive 스케줄러가 `_spawn_scheduler_v2` 감독 밖. +48. 운영: health-watch 사이드카 마운트 `scripts/health_watch.py` 가 구버전 배포에서 **디렉터리로 생성돼 있으면** Jenkins 가 제거를 시도하고 실패 시 경고만 남긴다(NAS 수동 삭제 필요). `prod_smoke.sh` 의 사이드카 확인은 `PROD_SSH_HOST` 미설정 시 SKIP. 스트림 무수신 경보는 희망 구독(대시보드) 기준 v4, 스케줄러 첫 하트비트 없음은 가동 `HB_FIRST_BEAT_GRACE_SEC`(600초) 후 stale. + 32. 2026-10-02 거래 확대 변경(§8.2 표 ⚠️ 항목, SIDEWAYS H2, 폴백 손절 축소, 트레일링 범위, 듀얼 독립)은 **모의계좌 사용자 결정이며 백테스트로 검증되지 않았다.** `KIS_MODE=real` 전환 전 §8.4 요건으로 재검토할 것. ## 9. 하네스 (`.claude/`) — 규칙을 훅·스크립트가 강제한다 @@ -415,3 +428,5 @@ DX(0~100)에 합산형 평활을 적용한 버그. 측정: `up 1361 vs 97 / flat *2026-09-28 — 운영 파라미터 alembic 0012 배포 완료(RSI 30→46, 손절 -6%→-7%) / ML 앙상블 전면 미작동 수정(시계열 역방향·시퀀스 워밍업 NaN·`except Exception: pass`) + 회귀 테스트 20건 / 유닛 스위트 579 passed·0 failed. **ML 은 재학습 전까지 신뢰 금지(§8.5-20).*** *2026-10-02 — 하네스 구성 + 프로그램 검증 루프 5회차(규칙 위반 117→0, Critical/High 1회차 22건 → 5회차 해소) / 거래 확대 사용자 결정 반영(미검증, §8.5-32) / 유닛 스위트 1341 passed·0 failed.* + +*2026-10-12 — 하드닝 9회차(발견 5·수정 5·검증 2 에이전트): 8회차 HIGH 수정 16건 중 12 확인·3 부분·1 미수정 → 전부 보강 / 늦은 0체결·부분체결 매수 원복, 잔고 연속조회(`get_balance_all_v2`), 브레이커 청산 조회 면제, 결과 불명 매도 재매도·대기 TTL, 청산 타임아웃 시 MDD·진입 보류, DT 수동 보유분 재매도 차단, 스트림·하트비트 경보 보강 / **Crisis 손절 소급(§8.5-39)은 사용자 결정 대기** / 유닛 스위트 2781 passed·0 failed(소켓 차단).* diff --git a/Jenkinsfile b/Jenkinsfile index 4cdc2e1..4b83fe6 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -67,6 +67,9 @@ print('All files OK') cp docker-compose.yml ${DEPLOY_DIR}/ cp -r docker/ ${DEPLOY_DIR}/ 2>/dev/null || true # health-watch 사이드카가 ./scripts/health_watch.py 를 마운트한다 — 없으면 컨테이너가 Created 로 멈춘다(2026-10-12) + # 이전 배포(0feaa61 이전)에 파일이 없어 Docker 가 마운트 소스를 '디렉터리'로 만들어 둔 경우, + # cp 가 그 디렉터리 안쪽에 복사돼 마운트는 여전히 디렉터리 → 사이드카 python 실행 실패(|| true 로 은폐). 먼저 제거. + if [ -d "${DEPLOY_DIR}/scripts/health_watch.py" ]; then rm -rf "${DEPLOY_DIR}/scripts/health_watch.py" || echo "WARN: health_watch.py 디렉터리 제거 실패 — NAS 에서 수동 삭제 필요"; fi mkdir -p ${DEPLOY_DIR}/scripts && cp scripts/health_watch.py ${DEPLOY_DIR}/scripts/ rsync -a --delete backend/ ${DEPLOY_DIR}/backend/ || cp -r backend/ ${DEPLOY_DIR}/backend/ rsync -a --delete mcp-server/ ${DEPLOY_DIR}/mcp-server/ || cp -r mcp-server/ ${DEPLOY_DIR}/mcp-server/ diff --git a/backend/app/api/endpoints/recommendations.py b/backend/app/api/endpoints/recommendations.py index 24d86f7..bc5c345 100644 --- a/backend/app/api/endpoints/recommendations.py +++ b/backend/app/api/endpoints/recommendations.py @@ -581,7 +581,7 @@ def is_mean_reversion_candidate(stock, conditions: dict, market_regime_str: str) if not all([rsi_val, close, bb_lower, bb_upper, bb_middle]): return False - if close < settings.MIN_PRICE_KRW: + if close <= settings.MIN_PRICE_KRW: # CLAUDE.md §5.1 '>5,000원' (정확히 5,000원 제외) return False # [안전형 개선①] own_trend 게이트 (검증 완료: Sharpe 0.27→0.57) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 8883bb0..f76931f 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -5,6 +5,7 @@ - DB, Redis, KIS API, AI 프로바이더 등 전역 설정 관리 """ import os +from typing import Optional from pathlib import Path from pydantic_settings import BaseSettings from functools import lru_cache @@ -641,6 +642,9 @@ class Settings(BaseSettings): SWING_FALLBACK_SKIP_ALERT_AFTER: int = 5 # [7회차 H-A] get_balance 첫 페이지 행 수(모의 20 / 실전 50). 종목 미발견 + 이 행 수 이상이면 페이지 초과로 보고 매도 보류. SWING_BALANCE_FULL_PAGE_ROWS: int = 20 + # [9회차 D-1, §8.5-37] korea_investment_api.get_balance_all_v2 잔고 연속조회 최대 페이지 수 + # (모의 20행 / 실전 50행 × 5). 상한 도달 후에도 남은 페이지가 있으면 결과에 _partial=True. + KIS_BALANCE_MAX_PAGES: int = 5 # ML 단독 SELL 트리거(신뢰도≥0.5). 운영 LSTM 재학습 전 난수(§8.5-20)이므로 OFF. ML_SELL_SIGNAL_ENABLED: bool = False @@ -748,6 +752,24 @@ class Settings(BaseSettings): self.KOREA_INVESTMENT_ACCOUNT_NUMBER = mode_account or fallback_account or self.KOREA_INVESTMENT_ACCOUNT_NUMBER self.KOREA_INVESTMENT_API_BASE_URL = mode_url or fallback_url or self.KOREA_INVESTMENT_API_BASE_URL + # ── [R9 E-5] 소비처가 getattr(settings, KEY, default) 로만 읽던 키의 선언 ────────────── + # 기본값은 각 소비처의 기존 기본값과 동일(동작 무변경). 선언 목적은 .env 로 조정 가능하게 하는 것. + # (CORS_ORIGINS 는 의도적으로 제외: .env 에 값이 이미 있어 선언하면 지금껏 무시되던 값이 적용돼 CORS 동작이 바뀐다.) + # [보류] MDD_*/SWING_*(알림·타임아웃)/DAY_TRADE_* 키는 선언하지 않았다: 기존 테스트 ~40건이 모듈 기본값(_DEFAULT_*) + # 을 monkeypatch 하는데, 선언하면 settings 값이 우선해 그 목킹이 무력화된다(테스트 이관 후 선언할 것). + # None 기본값은 "소비처 모듈 상수 사용" 의미라 Optional 로 둔다. + HB_FIRST_BEAT_GRACE_SEC: float = 600.0 # main: 장중 첫 하트비트 없음 정체 판정 유예(가동 초) + SWING_ADAPTIVE_TRAILING_ENABLED: bool = False + MEAN_REVERSION_RULE_MERGE_ENABLED: bool = False + KIS_WS_MAX_REGISTRATIONS: int = 41 + KIS_WS_ORIGIN: Optional[str] = None + KIS_WS_SUBPROTOCOLS: Optional[str] = None + NEWS_EMPTY_ALERT_THRESHOLD: int = 5 + RISK_MAX_DAILY_LOSS_RATIO: float = 0.03 + RISK_MAX_CONSECUTIVE_LOSSES: int = 3 + BATCH_ANALYSIS_TIMEOUT_SEC: Optional[float] = None # None → schedulers 안전 기본값 + ZERO_BUY_ALERT_HOURS: Optional[float] = None # None → schedulers 기본값 + @property def is_virtual_trading(self) -> bool: """모의투자 모드 여부""" diff --git a/backend/app/main.py b/backend/app/main.py index 5b385d6..15e847b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -203,6 +203,42 @@ def _scheduler_health_v2(application, now: float | None = None, market_open: boo return out +# 프로세스 기동 시각(monotonic). 첫 하트비트 유예 판정(HB_FIRST_BEAT_GRACE_SEC)의 기준. +_PROCESS_START_MONO: float = time.monotonic() + + +def _scheduler_health_v3(application, now: float | None = None, market_open: bool | None = None, + uptime_sec: float | None = None) -> dict: + """v2 + '첫 하트비트를 못 찍은 살아 있는 스케줄러'도 정체로 판정한다(2026-10-12 R9). + + 장중 재기동 후 restore_from_db 등 기동 처리에서 멈추면 age=None·alive=True 라 v2 는 + stale/dead_silent 둘 다 False 였다. 장중 + beat 없음 + 가동 시간이 HB_FIRST_BEAT_GRACE_SEC + (기본 600초) 초과면 stale 로 보고 ``no_first_beat=True`` 를 붙여 경보 문구에 쓴다. + """ + n = now if now is not None else time.monotonic() + out = _scheduler_health_v2(application, now=n, market_open=market_open) + if market_open is None: + try: + market_open = bool(is_market_open()) + except Exception as _swallowed: + warn_throttled(logger, "main:hb_mkt3", "[health] 장 상태 확인 실패: %s", _swallowed) + market_open = False + up = uptime_sec if uptime_sec is not None else (n - _PROCESS_START_MONO) + try: + grace = float(getattr(settings, "HB_FIRST_BEAT_GRACE_SEC", 600.0)) + except (TypeError, ValueError): + grace = 600.0 + for name, info in out.items(): + no_first = bool( + market_open and name in _HEARTBEAT_WATCHED + and info["last_cycle_age"] is None and info["alive"] and up > grace + ) + info["no_first_beat"] = no_first + if no_first: + info["stale"] = True + return out + + # ── KIS 실시간 스트림 건강 감시 (하드닝 R1-3) ──────────────────────────── _kis_stream_last_msg_mono: float | None = None _kis_stream_probe_installed = False @@ -308,6 +344,41 @@ def _kis_stream_health_v3(application, now: float | None = None, market_open: bo return base +def _kis_stream_desired_subscriptions_v2() -> int | None: + """대시보드가 원하는 구독 종목 수(연결 상태와 무관). 조회 실패면 None.""" + try: + from app.services.websocket_manager import websocket_manager + return len(websocket_manager.get_all_subscribed_codes()) + except Exception as _swallowed: + warn_throttled(logger, "main:ks_desired", "[health] 희망 구독 수 확인 실패: %s", _swallowed) + return None + + +def _kis_stream_health_v4(application, now: float | None = None, market_open: bool | None = None, + registrations: int | None = -1, desired: int | None = -1) -> dict | None: + """v3 + 희망 구독 수 기준 보정(2026-10-12 R9). + + v3 는 KIS 등록 0건이면 무수신을 정상으로 보지만, 등록은 연결 단절·ACK 실패로도 0이 된다. + 대시보드 구독자가 있는데(희망>0) 등록이 0이면 연결 문제이므로 v2 규칙(수신 이력 있고 임계 + 초과면 silent)으로 되돌린다. 희망 구독도 0이면 v3 와 동일(정상). 희망 수를 모르면 v3. + """ + global _kis_stream_regs_since + regs = _kis_stream_registrations_v2() if registrations == -1 else registrations + want = _kis_stream_desired_subscriptions_v2() if desired == -1 else desired + if regs is not None and regs <= 0 and want is not None and want > 0: + base = _kis_stream_health_v2(application, now=now, market_open=market_open) + if base is None: + return None + _kis_stream_regs_since = None + base["registrations"] = regs + base["desired"] = want + return base + base = _kis_stream_health_v3(application, now=now, market_open=market_open, registrations=regs) + if base is not None: + base["desired"] = want + return base + + def _db_worker_health_v2(application) -> dict: task = getattr(application.state, "db_worker_task", None) try: @@ -376,7 +447,7 @@ async def _heartbeat_watchdog_v2(application): while True: await asyncio.sleep(interval) try: - h = _scheduler_health_v2(application) + h = _scheduler_health_v3(application) now = time.monotonic() for name, info in h.items(): if not info["stale"]: @@ -385,11 +456,17 @@ async def _heartbeat_watchdog_v2(application): if now - _stale_alerted_at.get(name, -1e9) < realert: continue _stale_alerted_at[name] = now + if info.get("no_first_beat"): + logger.error(f"🚨 스케줄러 '{name}' 장중 정체: 첫 하트비트 없음(기동 후 유예 초과)") + _notify_system_warning_bg( + "SCHEDULER", f"'{name}' 첫 하트비트 없음 — 기동 처리 정체 의심 (alive={info['alive']})" + ) + continue logger.error(f"🚨 스케줄러 '{name}' 장중 정체: 마지막 사이클 {info['last_cycle_age']}초 전") _notify_system_warning_bg( "SCHEDULER", f"'{name}' 장중 사이클 정체 {info['last_cycle_age']}초 (alive={info['alive']})" ) - ks = _kis_stream_health_v3(application) + ks = _kis_stream_health_v4(application) if ks is None or not ks["silent"]: _stale_alerted_at.pop("kis_stream", None) elif now - _stale_alerted_at.get("kis_stream", -1e9) >= realert: @@ -1334,7 +1411,7 @@ def _runtime_health_v2(market_open: bool | None = None) -> tuple[dict, list]: detail["swing"] = "unavailable" reasons.append("swing_health_unavailable") try: - ks = _kis_stream_health_v3(app, market_open=market_open) + ks = _kis_stream_health_v4(app, market_open=market_open) if ks is not None: detail["kis_stream"] = ks if ks["silent"]: @@ -1430,7 +1507,7 @@ async def _health_check_impl(): warn_throttled(logger, "main:626", "[health] Redis ping 실패: %s", _swallowed) status = "healthy" if (db_ok and redis_ok) else "degraded" - sched_info = _scheduler_health_v2(app) + sched_info = _scheduler_health_v3(app) dbw_info = _db_worker_health_v2(app) if (not dbw_info["alive"]) or any(v["stale"] or not v["alive"] for v in sched_info.values()): status = "degraded" diff --git a/backend/app/services/day_trading_strategy.py b/backend/app/services/day_trading_strategy.py index ffb56df..ad950e0 100644 --- a/backend/app/services/day_trading_strategy.py +++ b/backend/app/services/day_trading_strategy.py @@ -791,7 +791,7 @@ def check_day_trade_exit( 2. 손절 (현재가 ≤ stop_loss) 3. 시간 손절 — 진입 후 DAY_TRADE_TIME_STOP_MINUTES 경과 이후 수익률 < DAY_TRADE_TIME_STOP_MIN_GAIN 이면 청산 4. 목표가 달성 → 50% 분할 매도 (CLAUDE.md 5.2) - 5. 트레일링 스탑 (1.5% 수익 후 ATR 트레일) + 5. 트레일링 스탑 (수익률 ≥ DAY_TRADE_TRAILING_TRIGGER 이후 고점 − ATR×DAY_TRADE_TRAILING_ATR_MULT) 6. 5분봉 RSI ≥ 70 (과매수 청산) """ from datetime import datetime as _dt diff --git a/backend/app/services/daytrading/entry_service.py b/backend/app/services/daytrading/entry_service.py index 83e9c1e..7b375bb 100644 --- a/backend/app/services/daytrading/entry_service.py +++ b/backend/app/services/daytrading/entry_service.py @@ -1078,6 +1078,12 @@ async def backfill_opening_ranges() -> int: return after +def _dt_daily_loss_limit_v2() -> float: + """[C-3 r9] 로그용 실효 일일 손실 한도(is_daily_loss_exceeded 와 같은 접근자).""" + from app.services.day_trading_strategy import _get_daily_loss_limit + return float(_get_daily_loss_limit()) + + async def scan_and_enter() -> list[dict]: """감시 종목의 5분봉 데이터를 스캔하여 진입 신호 발생 시 매수 실행. @@ -1154,7 +1160,7 @@ async def scan_and_enter() -> list[dict]: # [P0] 일일 손실 한도 체크 — 청산 후 즉시 재진입 방지 if is_daily_loss_exceeded(day_trading_state.last_total_assets, day_trading_state.daily_pnl): - logger.info("[DayEntry] ⛔ 일일 손실 한도(-3%) 초과 — 신규 진입 차단") + logger.info(f"[DayEntry] ⛔ 일일 손실 한도({_dt_daily_loss_limit_v2():.1%}) 초과 — 신규 진입 차단") return entries # [P1] 쿨다운 — 3연패 시 당일 진입 중단 @@ -1239,6 +1245,15 @@ async def scan_and_enter() -> list[dict]: if total_assets <= 0: return entries + # [C-3 r9] 일일 손실 한도 재확인 — 위 [P0] 체크는 잔고 조회 **전** last_total_assets 로만 판정해 + # 재기동 직후(last_total_assets=0)엔 통과했다. 방금 조회한 총자산으로 다시 본다. 가드는 try 밖(§4). + if is_daily_loss_exceeded(total_assets, day_trading_state.daily_pnl): + logger.info( + f"[DayEntry] ⛔ 일일 손실 한도({_dt_daily_loss_limit_v2():.1%}) 초과(잔고 조회 후 재확인: " + f"실현 {day_trading_state.daily_pnl:,.0f} / 총자산 {total_assets:,.0f}) — 신규 진입 차단" + ) + return entries + # [BUG FIX] 글로벌 자본 사용률 가드 — 전체 포트폴리오 투자액이 MAX_TOTAL_CAPITAL_USAGE 초과 시 진입 차단 total_invested = sum( pos.get("entry_price", 0) * pos.get("quantity", 0) diff --git a/backend/app/services/daytrading/exit_service.py b/backend/app/services/daytrading/exit_service.py index d08bf2c..a282877 100644 --- a/backend/app/services/daytrading/exit_service.py +++ b/backend/app/services/daytrading/exit_service.py @@ -1553,6 +1553,8 @@ async def _resolve_unknown_exit_v2_impl( hldg = int(_hh.get("hldg", 0) or 0) sellable = int(_hh.get("sellable", 0) or 0) dt_sellable = max(0, min(sellable, hldg) - int(nd)) + # [C-1 r9] v3 가 '보유 기준 DT 몫 온전 여부'를 보도록 판정 근거를 남긴다(KIS 재조회 없이) + _last_unknown_resolve_snapshot[stock_code] = (hldg, sellable, int(nd)) if dt_sellable <= max(0, int(total_qty) - int(qty)): rows = await _lookup_todays_orders_impl(stock_code) row = _find_accepted_sell_row(rows, stock_code, qty, since) @@ -1595,6 +1597,177 @@ def _note_unknown_lookup_failure(stock_code: str) -> None: logger.error(f"[DayExit] 수동 청산 경보 발송 실패({stock_code}): {e}") +async def _fetch_dt_ledger_net_qty_impl(stock_code: str) -> int | None: + """[C-1 r9] DT 원장(TradeHistory strategy_type='daytrading') 순체결수량. 실패면 None. + + 창은 DAY_TRADE_ORPHAN_LOOKBACK_DAYS(전일 포지션 경로도 덮도록 당일만이 아니다). + 집계는 _dt_net_qty_from_history — BUY 는 COMPLETED 만, SELL 은 FAILED/REJECTED 외 전부 + (PENDING 포함)를 차감한다. 결과 불명 매도의 재매도 상한에서만 호출되는 드문 경로다. + """ + try: + from app.core.database import AsyncSessionLocal + from app.models.models import TradeHistory + from sqlalchemy import select as _select + + _since = datetime.combine( + _kst_now().date() - timedelta(days=int(settings.DAY_TRADE_ORPHAN_LOOKBACK_DAYS)), + datetime.min.time(), + ) + # DB-READ-EXCEPTION: §3.2 ③-c 와 같은 읽기 전용 SELECT 1건, commit/flush/add 없음. + # 쓰기는 db_worker 경유. 결과 불명 매도의 재매도 상한 확인에서만 호출된다. + async with AsyncSessionLocal() as _db: + _rows = (await _db.execute( + _select(TradeHistory).where( + TradeHistory.stock_code == stock_code, + TradeHistory.strategy_type == "daytrading", + TradeHistory.created_at >= _since, + ) + )).scalars().all() + return int(_dt_net_qty_from_history(_rows).get(stock_code, 0)) + except Exception as e: + logger.error(f"[DayExit] DT 원장 순체결 조회 실패({stock_code}): {e}") + return None + + +# [C-1 r9] v2 판정 근거 스냅샷 {code: (hldg, sellable, 비DT)} — v3 가 즉시 pop 한다 +_last_unknown_resolve_snapshot: dict[str, tuple[int, int, int]] = {} + +# [C-1 r9] 재매도 보류 상태(판정 불가) — lookup_failed 와 같은 처리(보류·N회 경보) +_UNKNOWN_HOLD_STATUSES = ("lookup_failed", "ambiguous", "ledger_failed", "orders_failed", "ledger_nonpositive") + + +async def _lookup_todays_orders_v2_impl(stock_code: str) -> list | None: + """[C-1 r9 2차] 당일 주문내역 — **조회 실패·시간초과면 None**(get_todays_orders_v2 계약). + + v1(_lookup_todays_orders_impl)은 실패에도 [] 를 받아 "주문 없음"과 구분할 수 없었다. + 호출부는 None 을 판정 보류(재매도 금지)로 취급한다. + """ + try: + return await asyncio.wait_for( + korea_investment_api.get_todays_orders_v2(stock_code), + timeout=float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC), + ) + except asyncio.TimeoutError: + logger.warning(f"[DayExit] 당일 주문내역(v2) 조회 시간초과 ({stock_code}) — 판정 보류") + except Exception as e: + logger.warning(f"[DayExit] 당일 주문내역(v2) 조회 실패 ({stock_code}): {e} — 판정 보류") + return None + + +def _find_accepted_sell_row_strict_v2(rows, stock_code: str, qty: int, since) -> dict | None: + """[C-1 r9 2차] 불명 주문 등록 시각 이후·수량 일치·유효(체결>0 또는 접수 상태) 매도 행. + + _find_accepted_sell_row 와 달리 **주문시각(ord_tmd)이 없거나 since 가 없으면 매칭하지 않는다** + (시각 근거 없는 같은 수량 행 — 스윙 매도 등 — 을 DT 접수로 오인하지 않게). since 에서 + DAY_TRADE_EXIT_UNKNOWN_MATCH_WINDOW_SEC 만큼만 앞당겨 시계 오차를 허용한다. + """ + if since is None: + return None + _w = float(settings.DAY_TRADE_EXIT_UNKNOWN_MATCH_WINDOW_SEC) + _since_hms = (since - timedelta(seconds=_w)).strftime("%H%M%S") + for r in rows or []: + if not _row_is_live_or_filled_sell(r, stock_code): + continue + try: + if int(float(r.get("ord_qty", 0) or 0)) != int(qty): + continue + except (TypeError, ValueError): + continue + t = str(r.get("ord_tmd", "") or "") + if not t or t < _since_hms: + continue + odno = _norm_odno(r.get("odno", "")) + if odno and (_today_key(), odno) in _finalized_sell_odnos: + continue + return r + return None + + +async def _resolve_unknown_exit_v3_impl( + stock_code: str, qty: int, since, total_qty: int, +) -> tuple[str, dict | None, int]: + """[C-1 r9, 2차 2026-10-12] v2 판정 + HTS·수동 보유분(ActiveTrade 없음) 재매도 방지. + + v2 는 "DT 몫 = min(주문가능, 보유) − 비-DT active" 로 본다. ActiveTrade 없는 수동 보유분 M 이 + 있으면 원 주문이 체결됐어도 not_accepted 가 되고 재시도가 수동 물량을 판다. 결과 불명 SELL 은 + TradeHistory 행을 남기지 않아 원장도 매수 수량 그대로다(원장만으로는 못 막는다). + v2 가 not_accepted 일 때 순서대로: + 1) 당일 주문내역 v2(실패면 None). None → "orders_failed"(보류). 등록 시각 이후·수량 일치·유효 + 매도 행이 있으면 **온전 여부와 무관하게** accepted. + 2) 행 없음 + 보유 기준 DT 몫(보유 − 비DT) < 주문 전 수량 → "ambiguous"(보류). + 3) 원장 순체결 실패 → "ledger_failed", ≤ 0 → "ledger_nonpositive"(둘 다 보류 + error·경보). + 4) 그 외(주문내역 [] = KIS 미접수 실증, 원장 > 0) → 재매도, 상한 min(DT 매도가능, 원장, q). + ⚠️ 잔여: 주문내역이 첫 페이지에 안 보이면(당일 주문 다수) 4) 로 떨어져 수동 물량을 최대 q 주 + 팔 수 있다. 같은 수량·등록 이후 스윙 매도는 1) 에서 DT 접수로 오인될 수 있다(과소매도 쪽). + 상한 계산은 try 밖. 보류 상태들은 _UNKNOWN_HOLD_STATUSES. + """ + st, resp, dt_sellable = await _resolve_unknown_exit_v2_impl(stock_code, qty, since, total_qty) + _snap = _last_unknown_resolve_snapshot.pop(stock_code, None) + if st != "not_accepted": + return st, resp, dt_sellable + # 1) 주문내역(v2) — 조회 실패(None)면 보류. 행이 있으면 온전 판정과 무관하게 접수 확정. + rows = await _lookup_todays_orders_v2_impl(stock_code) + if rows is None: + return "orders_failed", None, 0 + row = _find_accepted_sell_row_strict_v2(rows, stock_code, qty, since) + if row is not None: + odno = str(row.get("odno", "") or "") + logger.warning( + f"[DayExit] {stock_code} 결과 불명 매도 → 당일 주문내역 일치 행(등록 이후·{qty}주, " + f"ODNO={odno or '미확인'})으로 접수 판정 (잔고상 DT 매도가능 {dt_sellable}주)" + ) + return "accepted", {"rt_cd": "0", "msg1": "resolved_by_orders", "output": {"ODNO": odno}}, dt_sellable + # 2) 행 없음 + 보유 기준 DT 몫이 온전하지 않음 → 판정 불가(보류) + intact = _snap is None or (int(_snap[0]) - int(_snap[2])) >= int(total_qty) + if not intact: + logger.error( + f"[DayExit] 🚨 {stock_code} 결과 불명 매도 판정 불가 — 보유 {_snap[0]}주 − 비DT {_snap[2]}주가 " + f"주문 전 DT {total_qty}주보다 적은데 DT 매도가능 {dt_sellable}주가 주문 후 기대치" + f"({max(0, int(total_qty) - int(qty))}주)보다 많고 주문내역 일치 행도 없음. " + f"ActiveTrade 없는 수동·HTS 보유분일 수 있어 재매도 보류" + ) + return "ambiguous", None, 0 + # 3) 원장 — 실패·순체결 ≤ 0 이면 보류(과다매도보다 보류, §3.5) + ledger = await _fetch_dt_ledger_net_qty_impl(stock_code) + if ledger is None: + return "ledger_failed", None, 0 + if ledger <= 0: + logger.error( + f"[DayExit] 🚨 {stock_code} 결과 불명 매도 — DT 원장 순체결 {ledger}주(≤0)라 DT 몫을 확인할 수 " + f"없음. 재매도 보류(원장 반영 지연이면 다음 확인에서 해소)" + ) + return "ledger_nonpositive", None, 0 + # 4) 주문내역 [] 실증(KIS 가 접수한 적 없음) + 원장 > 0 → 재매도 허용, 상한 min(DT 매도가능, 원장, q) + capped = min(int(dt_sellable), int(ledger), int(qty)) + if capped < dt_sellable: + logger.warning( + f"[DayExit] {stock_code} 결과 불명 재매도 상한 축소: DT 매도가능 {dt_sellable} → " + f"min(원장 {ledger}, 원 주문 {qty}) = {capped}주" + ) + return "not_accepted", None, capped + + +def _note_unknown_hold_v2(stock_code: str, status: str) -> None: + """[C-1 r9] 보류 사유별 로그. 조회 실패 계열은 기존 N회 연속 텔레그램 경보를 공유한다.""" + if status == "orders_failed": + _error_throttled( + f"dt_exit:unknown_orders_failed:{stock_code}", + f"[DayExit] 🚨 {stock_code} 직전 매도 결과 불명 + 당일 주문내역 조회 실패 — 재매도 보류 중", + ) + if status == "ledger_nonpositive": + _error_throttled( + f"dt_exit:unknown_ledger_nonpos:{stock_code}", + f"[DayExit] 🚨 {stock_code} 직전 매도 결과 불명 + DT 원장 순체결 ≤ 0 — 재매도 보류 중. 계좌 확인 필요", + ) + if status == "ambiguous": + _error_throttled( + f"dt_exit:unknown_ambiguous:{stock_code}", + f"[DayExit] 🚨 {stock_code} 직전 매도 결과 불명 + 접수 여부 판정 불가(수동 보유분 혼재 의심) — " + f"재매도 보류 중. 계좌 확인 필요", + ) + _note_unknown_lookup_failure(stock_code) + + async def _place_and_finalize_exit_impl( stock_code: str, pos: dict, price: float, reason: str, sell_pct: float, quantity: int, total_qty: int, is_full_exit: bool, is_stale: bool, @@ -1625,14 +1798,15 @@ async def _place_and_finalize_exit_impl( # [H-2] 직전 불명 주문이 미해결이면 새 주문 전에 다시 확인한다(잔고 기준). _prev = _unresolved_exit_unknown.get(_ukey) if _prev is not None: - _st, _resp, _dt_sellable = await _resolve_unknown_exit_v2_impl( + # [C-1 r9] v3 — 수동·HTS 보유분 혼재 시 not_accepted 오판 방지 + DT 원장 순체결 상한 + _st, _resp, _dt_sellable = await _resolve_unknown_exit_v3_impl( stock_code, int(_prev["qty"]), _prev["since"], int(_prev.get("total_qty", total_qty)), ) - if _st == "lookup_failed": + if _st in _UNKNOWN_HOLD_STATUSES: # 판단 불가 — 재매도하지 않고 보류를 이어간다(guarded finally 가 HOLD 뒤 해제) _last_exit_order_outcome[stock_code] = "unknown" - _note_unknown_lookup_failure(stock_code) + _note_unknown_hold_v2(stock_code, _st) return None _unknown_lookup_failures.pop(_ukey, None) if _st == "accepted": @@ -2888,6 +3062,14 @@ async def _detect_dt_orphans_impl(db, active_dt_rows, stale: dict, today) -> tup return orphans, restores +async def _restore_lost_today_positions_v2_impl(rows, kis_holdings: dict, other_qty: dict) -> int: + """[C-2 r9] 판정 수량을 min(주문가능, 보유) 로 — 접수·미체결 DT 매도 물량을 복원해 재매도하지 않게.""" + from app.services.daytrading.position_manager import _cap_hldg_by_sellable_v2 + + _capped = {c: _cap_hldg_by_sellable_v2(v) for c, v in (kis_holdings or {}).items()} + return await _restore_lost_today_positions_impl(rows, _capped, other_qty) + + async def _restore_lost_today_positions_impl(rows, kis_holdings: dict, other_qty: dict) -> int: """당일 active 행 → 인메모리 복원 (restore_from_db 와 같은 변환). 복원 수 반환.""" from app.services.daytrading.position_manager import _active_trade_row_to_position @@ -3061,7 +3243,7 @@ async def close_stale_positions() -> list[dict]: continue _judgeable.append(_r) if _judgeable: - await _restore_lost_today_positions_impl(_judgeable, kis_holdings, other_strategy_qty) + await _restore_lost_today_positions_v2_impl(_judgeable, kis_holdings, other_strategy_qty) if not stale: return results diff --git a/backend/app/services/daytrading/position_manager.py b/backend/app/services/daytrading/position_manager.py index 20f3e32..c4283bc 100644 --- a/backend/app/services/daytrading/position_manager.py +++ b/backend/app/services/daytrading/position_manager.py @@ -859,6 +859,82 @@ class DayTradingState: return out + async def verify_restored_today_positions_v3( + self, session_factory=None, holdings_fetcher=None, today_rows_fetcher=None, + ) -> dict: + """[C-2 r9] v2 + 주문가능수량·당일 PENDING DT 매도 반영. + + v2 는 KIS **보유수량(hldg)** 만 봤다. 재기동 직전 접수된 미체결 DT 매도(VI 단일가 등)가 + 있으면 hldg 는 그대로라 포지션이 복원되고, 일반 청산이 다시 팔아 같은 종목 스윙 보유분이 + 팔릴 수 있다(§3.5). 이제: + - 판정 수량 = min(주문가능, 보유) − 비DT active (주문가능에서는 접수된 매도가 이미 빠진다) + - 당일 TradeHistory 에 DT SELL PENDING 이 있는 종목은 복원 보류(warning). + ⚠️ **현재 DT 경로는 PENDING SELL 행을 쓰지 않는다** — 매도 TradeHistory 는 접수 확정 뒤 + COMPLETED 로만 들어가고, 결과 불명 매도는 행을 남기지 않는다. 따라서 이 분기는 현재 발동하지 + 않는 방어 코드다(PENDING 을 쓰는 경로가 생길 때 대비). 재기동 직전 미체결 매도의 **실효 방어는 + 위의 min(주문가능, 보유)** 이다. 장중 close_stale_positions 도 같은 규칙 + (_restore_lost_today_positions_v2_impl)으로 재검증한다. + - 원장 조회 실패: DAY_TRADE_ORPHAN_DETECT_ENABLED 면 전부 보류(v2 의 판정 불가 규칙과 동일). + 이 보류는 실효 동작이다(조회 실패 시 기동 복원을 장중 재검증에 맡김). + DB 는 SELECT 전용(§3.2 ③ restore 경로, commit 없음). + """ + from app.core.config import settings + + today = _today_kst() + cand_codes = [ + c for c, p in self.positions.items() + if p.get("restored_from_db") and _entry_date(p.get("entry_time")) == today + ] + pre_held: list[str] = [] + if cand_codes: + rows = None + try: + _f = today_rows_fetcher or (lambda: _fetch_today_trade_rows_impl(today, session_factory)) + rows = await asyncio.wait_for(_f(), timeout=_DAILY_STATE_REBUILD_DB_TIMEOUT_SEC) + except asyncio.TimeoutError: + logger.warning("[DayTrading] 복원 검증: 당일 원장(PENDING 매도) 조회 시간 초과") + except Exception as e: + logger.warning(f"[DayTrading] 복원 검증: 당일 원장(PENDING 매도) 조회 실패: {type(e).__name__}: {e}") + # 보류 판정은 try 밖 (§4) + if rows is None: + hold_codes = set(cand_codes) if settings.DAY_TRADE_ORPHAN_DETECT_ENABLED else set() + _why = "당일 PENDING 매도 확인 불가" + else: + hold_codes = { + getattr(r, "stock_code", None) for r in rows + if str(getattr(r, "strategy_type", "") or "") == "daytrading" + and str(getattr(r, "order_type", "") or "").upper() == "SELL" + and str(getattr(r, "status", "") or "").upper() == "PENDING" + } & set(cand_codes) + _why = "당일 DT 매도 PENDING(결과 미확정)" + if hold_codes: + async with self.trade_lock: + for c in sorted(hold_codes): + if self.positions.pop(c, None) is not None: + pre_held.append(c) + logger.warning( + f"[DayTrading] ⚠️ 기동 복원 보류 {pre_held} — {_why}. 미체결 매도 물량 재매도 방지, " + f"장중 close_stale_positions 가 다음 사이클에 재검증" + ) + + async def _sellable_capped_holdings(): + if holdings_fetcher is None: + from app.services.daytrading.exit_service import _fetch_kis_holdings_v2_impl as _hf + else: + _hf = holdings_fetcher + h = await _hf() + if h is None: + return None + return {c: _cap_hldg_by_sellable_v2(v) for c, v in h.items()} + + out = await self.verify_restored_today_positions_v2( + session_factory=session_factory, holdings_fetcher=_sellable_capped_holdings, + ) + out["held"] = pre_held + list(out.get("held") or []) + out["checked"] = int(out.get("checked", 0)) + len(pre_held) + return out + + def _active_trade_row_to_position(trade) -> dict: """ActiveTrade(day_trade) 행 → 인메모리 포지션 dict (restore_from_db 와 고아 복원 공용). @@ -924,6 +1000,17 @@ def _entry_date(entry_time) -> date | None: return None +def _cap_hldg_by_sellable_v2(v) -> dict: + """[C-2 r9] {"hldg","sellable"} → hldg 를 min(주문가능, 보유) 로 낮춘 사본. sellable 없으면 그대로.""" + d = dict(v or {}) + if d.get("sellable") is not None: + try: + d["hldg"] = min(int(d.get("hldg", 0) or 0), int(d.get("sellable", 0) or 0)) + except (TypeError, ValueError): + d["hldg"] = 0 # 파싱 불가 → 복원 근거 없음(보류 쪽) + return d + + async def _fetch_non_dt_active_qty_by_code_impl(codes: list, session_factory=None) -> dict[str, int]: """비-DT(스윙·유입·legacy NULL) active ActiveTrade 종목별 수량 합 — SELECT 전용(§3.2 ③, commit 없음). diff --git a/backend/app/services/daytrading/scheduler.py b/backend/app/services/daytrading/scheduler.py index de26933..5dbff1b 100644 --- a/backend/app/services/daytrading/scheduler.py +++ b/backend/app/services/daytrading/scheduler.py @@ -347,7 +347,7 @@ async def daytrading_scheduler() -> None: # 불일치는 복원 보류 + 경보(장중 close_stale_positions 가 같은 규칙으로 재복원). 예외를 던지지 않는다. if restored: try: - _vr = await day_trading_state.verify_restored_today_positions_v2() + _vr = await day_trading_state.verify_restored_today_positions_v3() # [C-2 r9] 주문가능·PENDING 매도 if _vr.get("held"): restored = max(0, restored - len(_vr["held"])) except Exception as e: diff --git a/backend/app/services/korea_investment_api.py b/backend/app/services/korea_investment_api.py index ba163aa..0702272 100644 --- a/backend/app/services/korea_investment_api.py +++ b/backend/app/services/korea_investment_api.py @@ -89,6 +89,12 @@ _TOKEN_REFRESH_RETRIED: contextvars.ContextVar[bool] = contextvars.ContextVar( "kis_token_refresh_retried", default=False ) +# [9회차 D-2b] 현재 요청이 "브레이커 비-CLOSED 중의 면제 조회" 인가 — 참이면 성공·실패 모두 +# 브레이커에 기록하지 않는다(OPEN 해제는 비면제 조회의 HALF_OPEN 프로브로만). +_BREAKER_UNCOUNTED: contextvars.ContextVar[bool] = contextvars.ContextVar( + "kis_breaker_uncounted", default=False +) + class KISTokenUnavailableError(Exception): """토큰을 지금 얻을 수 없다(재발급 최소 간격 내 + 유효 토큰 없음). @@ -774,11 +780,36 @@ class KoreaInvestmentAPI: # Circuit Breaker 상태 확인 # [R4] 주문 엔드포인트는 breaker 로 차단하지 않는다 — 시세/잔고 조회 장애(EGW00201·타임아웃)가 # 손절·EOD·MDD 매도까지 막으면 §8.1-11(청산 우선) 위반. 주문 실패는 breaker 에 세지도 않는다. - if endpoint not in self._NON_IDEMPOTENT_ENDPOINTS and not self.circuit_breaker.can_request(): + # [9회차 D-2] 청산 경로가 쓰는 읽기 조회(_CIRCUIT_EXEMPT_READ_ENDPOINTS)도 게이트 면제. + # 면제 호출이 실패하면 예외가 나므로 청산 보류는 KIS 가 실제로 죽었을 때만 일어난다. + # [9회차 D-2b] 브레이커가 CLOSED 가 아닐 때(OPEN/HALF_OPEN) 면제 호출은 can_request() 를 + # 부르지 않고(HALF_OPEN 전이·프로브 소비 금지) 성공·실패 모두 브레이커에 기록하지 않는다. + # - 면제 성공이 record_success 로 OPEN 을 닫으면 EGW00201 폭주 중 부하 완화가 사라진다. + # - 면제 실패가 last_failure_time 을 갱신하면 비면제 조회가 HALF_OPEN 프로브를 영영 못 얻는다. + # CLOSED 상태의 면제 호출은 기존대로 집계한다(장애 감지는 유지). + uncounted = False + if endpoint in self._CIRCUIT_EXEMPT_READ_ENDPOINTS: + if self.circuit_breaker.state != CircuitBreakerState.CLOSED: + uncounted = True + warn_throttled( + logger, f"korea_investment_api:breaker_exempt:{endpoint}", + "⚡ Circuit breaker %s — 청산 경로 조회는 게이트 면제로 전송(브레이커 집계 제외): %s (차단 %s초)", + self.circuit_breaker.state.value, endpoint, self.circuit_breaker.recovery_timeout, + interval=60.0, + ) + elif endpoint not in self._NON_IDEMPOTENT_ENDPOINTS and not self.circuit_breaker.can_request(): wait_sec = self.circuit_breaker.recovery_timeout logger.warning(f"⚡ Circuit breaker OPEN — {wait_sec}초 후 재시도 가능. 요청 거부: {endpoint}") raise Exception(f"Circuit breaker open: KIS API 일시 차단 ({wait_sec}s)") + _uc_tok = _BREAKER_UNCOUNTED.set(uncounted) + try: + return await self._make_request_dispatch_v2(method, endpoint, tr_id, extra_headers=extra_headers, **kwargs) + finally: + _BREAKER_UNCOUNTED.reset(_uc_tok) + + async def _make_request_dispatch_v2(self, method: str, endpoint: str, tr_id: str, extra_headers: Optional[Dict[str, str]] = None, **kwargs) -> (Dict[str, Any], Any): + """_make_request 의 게이트 이후 본체(토큰 확보 + 세마포어 + 재시도 + 토큰 무효 1회 재전송).""" # [H-3] 토큰은 세마포어 **밖에서** 확보한다. 세마포어를 잡은 채 토큰 발급(최대 # HTTP 30초)·락 대기를 하면 다른 모든 KIS 호출(매도·EOD 청산 포함)이 슬롯을 못 얻는다. # 여기서 캐시가 채워지므로 아래 재시도 루프의 토큰 조회는 fast path 로 끝난다. @@ -807,6 +838,18 @@ class KoreaInvestmentAPI: "/uapi/overseas-stock/v1/trading/order", ) + # [9회차 D-2, CLAUDE.md §8.1-11 청산 우선] 서킷브레이커 **게이트 면제** 읽기 엔드포인트. + # 조회 실패 누적으로 breaker 가 OPEN 이면 recovery_timeout 동안 모든 조회가 거부되는데, + # 스윙 매도 직전 잔고 상한(_fetch_kis_holding_v2)·손절 판정용 현재가·결과 불명 주문 확인 + # (당일 주문/체결)까지 막히면 손절·전량 청산이 보류된다. 이 집합은 게이트만 통과시키고 + # 성공/실패 집계(record_success/record_failure)는 그대로 한다 — 면제 호출이 실패하면 + # 평소처럼 예외/오류 응답이 나므로 청산 보류는 KIS 가 실제로 죽었을 때만 일어난다. + _CIRCUIT_EXEMPT_READ_ENDPOINTS = ( + "/uapi/domestic-stock/v1/trading/inquire-balance", + "/uapi/domestic-stock/v1/quotations/inquire-price", + "/uapi/domestic-stock/v1/trading/inquire-daily-ccld", + ) + @staticmethod def _safe_json(response) -> Any: try: @@ -816,12 +859,12 @@ class KoreaInvestmentAPI: def _record_breaker_failure_v2(self, endpoint: str) -> None: """주문 엔드포인트 실패는 조회 breaker 에 세지 않는다(주문·조회 장애 분리).""" - if endpoint in self._NON_IDEMPOTENT_ENDPOINTS: + if endpoint in self._NON_IDEMPOTENT_ENDPOINTS or _BREAKER_UNCOUNTED.get(): return self.circuit_breaker.record_failure() def _record_breaker_success_v2(self, endpoint: str) -> None: - if endpoint in self._NON_IDEMPOTENT_ENDPOINTS: + if endpoint in self._NON_IDEMPOTENT_ENDPOINTS or _BREAKER_UNCOUNTED.get(): return self.circuit_breaker.record_success() @@ -1618,12 +1661,142 @@ class KoreaInvestmentAPI: logger.error(f"Error fetching balance: {e}") raise + # ── [9회차 D-1] 잔고 연속조회 ────────────────────────────────────────── + _BALANCE_ENDPOINT = "/uapi/domestic-stock/v1/trading/inquire-balance" + + def _balance_request_v2(self, ctx_fk100: str = "", ctx_nk100: str = "") -> tuple: + """inquire-balance 요청 (tr_id, params) — get_balance() 와 동일한 파라미터 + 연속조회 키.""" + is_virtual = "vts" in self.base_url.lower() + tr_id = "VTTC8434R" if is_virtual else "TTTC8434R" + params = { + "CANO": self.account_number[:8], + "ACNT_PRDT_CD": self.account_number[8:], + "AFHR_FLPR_YN": "N", + "OFL_YN": "", + "INQR_DVSN": "02", + "UNPR_DVSN": "01", + "FUND_STTL_ICLD_YN": "N", + "FNCG_AMT_AUTO_RDPT_YN": "N", + "PRCS_DVSN": "01", + "CTX_AREA_FK100": ctx_fk100 or "", + "CTX_AREA_NK100": ctx_nk100 or "", + } + return tr_id, params + + async def get_balance_all_v2(self) -> Dict[str, Any]: + """계좌 잔고 조회 — 연속조회(tr_cont F/M → 요청 헤더 tr_cont=N)로 전 페이지 합산. + + 반환 형태는 get_balance() 와 같다(rt_cd/msg_cd/msg1/output1/output2 ...). + - output1: 전 페이지 합산(pdno 기준 dedupe, 먼저 나온 행 유지) + - output2: 첫 페이지 것(총자산 등) + - _pages: 실제로 받은 페이지 수(성공 페이지만) + - _partial: 합산이 불완전하면 True — 2페이지 이후 조회 실패(예외·rt_cd≠0), 다음 페이지가 + 있다는데 CTX_AREA_NK100 이 비어 있음, KIS_BALANCE_MAX_PAGES 상한 도달 후에도 남은 페이지. + 완전하면 키가 없다. 호출부는 _partial 이면 "미발견 = 미보유" 로 판단하지 말 것. + 첫 페이지 실패는 get_balance() 와 동일(예외는 그대로 raise, rt_cd≠0 은 error 로그 후 그 dict 반환). + """ + max_pages = max(1, int(getattr(settings, "KIS_BALANCE_MAX_PAGES", 5) or 1)) + try: + tr_id, params = self._balance_request_v2() + first, headers = await self._make_request("GET", self._BALANCE_ENDPOINT, tr_id, params=params) + except Exception as e: + logger.error(f"Error fetching balance (all pages): {e}") + raise + if not isinstance(first, dict) or first.get("rt_cd") != "0": + _r = first if isinstance(first, dict) else {} + logger.error(f"❌ Balance API error - rt_cd={_r.get('rt_cd')}, msg1={_r.get('msg1')}") + return first + + merged: list = [] + seen: set = set() + + def _absorb(rows: Any) -> None: + if isinstance(rows, dict): + rows = [rows] + if not isinstance(rows, list): + return + for row in rows: + code = str(row.get("pdno") or "").strip() if isinstance(row, dict) else "" + if code: + if code in seen: + continue + seen.add(code) + merged.append(row) + + _absorb(first.get("output1")) + pages = 1 + partial = False + tr_cont = ((headers.get("tr_cont") if headers is not None else None) or "").strip() + page_result = first + while tr_cont in ("F", "M"): + ctx_fk = str(page_result.get("ctx_area_fk100") or "").strip() + ctx_nk = str(page_result.get("ctx_area_nk100") or "").strip() + if not ctx_nk: + partial = True + logger.warning( + f"[get_balance_all_v2] tr_cont={tr_cont} 인데 ctx_area_nk100 비어 있음 — " + f"{pages}페이지까지만 합산(불완전)" + ) + break + if pages >= max_pages: + partial = True + logger.warning( + f"[get_balance_all_v2] 연속조회 상한 KIS_BALANCE_MAX_PAGES={max_pages} 도달 — " + f"남은 페이지 있음(tr_cont={tr_cont}), 불완전" + ) + break + try: + tr_id, params = self._balance_request_v2(ctx_fk100=ctx_fk, ctx_nk100=ctx_nk) + page_result, headers = await self._make_request( + "GET", self._BALANCE_ENDPOINT, tr_id, extra_headers={"tr_cont": "N"}, params=params, + ) + except Exception as e: + partial = True + logger.warning( + f"[get_balance_all_v2] {pages + 1}페이지 조회 예외 — {pages}페이지까지만 합산(불완전): " + f"{type(e).__name__}: {e}" + ) + break + if not isinstance(page_result, dict) or page_result.get("rt_cd") != "0": + partial = True + _r = page_result if isinstance(page_result, dict) else {} + logger.warning( + f"[get_balance_all_v2] {pages + 1}페이지 조회 실패 rt_cd={_r.get('rt_cd')} " + f"msg_cd={_r.get('msg_cd')} msg1={_r.get('msg1')} — {pages}페이지까지만 합산(불완전)" + ) + break + pages += 1 + _absorb(page_result.get("output1")) + tr_cont = ((headers.get("tr_cont") if headers is not None else None) or "").strip() + + out = dict(first) + out["output1"] = merged + out["output2"] = first.get("output2") + out["_pages"] = pages + if partial: + out["_partial"] = True + return out + async def get_todays_orders(self, stock_code: str = "") -> list[dict]: """당일 주문체결 내역 조회 (inquire-daily-ccld) — 첫 페이지만(실전 100건 / 모의 15건, 역순). 주문 전송 후 응답을 못 받았을 때 '실제로 접수됐는지' 확인해 중복 주문을 막는 용도. TR: 현행 TTTC0081R/VTTC0081R 우선, 실패 시 구 TTTC8001R/VTTC8001R. 실패 시 빈 리스트. + (실패와 '주문 없음' 을 구분해야 하면 get_todays_orders_v2 — 실패 시 None) """ + return await self._get_todays_orders_impl(stock_code=stock_code, strict=False) + + async def get_todays_orders_v2(self, stock_code: str = "") -> Optional[list]: + """[9회차 D-3] get_todays_orders 와 같되 **조회 실패면 None**(예외·rt_cd≠0·output1 파싱 불가). + + 성공 시 list(주문 없음이면 []). 결과 불명 주문 판정에서 '조회 실패' 를 '미접수' 로 + 오인해 재주문(과다매도)하지 않도록 호출부는 None 을 "판정 보류"로 취급할 것. + """ + return await self._get_todays_orders_impl(stock_code=stock_code, strict=True) + + async def _get_todays_orders_impl(self, stock_code: str = "", strict: bool = False) -> Optional[list]: + """get_todays_orders(_v2) 공통 본체. strict=False 면 실패 시 [](기존 동작), True 면 None.""" + fail: Optional[list] = None if strict else [] try: result, _ = await self._daily_ccld_fetch_v2(stock_code=stock_code) if result.get("rt_cd") != "0": @@ -1632,13 +1805,21 @@ class KoreaInvestmentAPI: 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 [] + return fail out = result.get("output1") or [] - return out if isinstance(out, list) else [out] + if isinstance(out, list): + return out + if strict and not isinstance(out, dict): + logger.warning( + f"[get_todays_orders_v2] {stock_code or '*'} output1 파싱 불가 " + f"(type={type(out).__name__}) — 조회 실패로 취급" + ) + return None + return [out] except Exception as e: # CLAUDE.md §4: 예외 로그 debug 금지 (중복주문 방지·DT 체결가 확인의 근거 조회) logger.warning(f"[get_todays_orders] {stock_code or '*'} 조회 예외: {type(e).__name__}: {e}") - return [] + return fail def _daily_ccld_request_v2( self, diff --git a/backend/app/services/mdd_manager.py b/backend/app/services/mdd_manager.py index a05e51a..1bd2605 100644 --- a/backend/app/services/mdd_manager.py +++ b/backend/app/services/mdd_manager.py @@ -41,6 +41,18 @@ def _balance_page_full_v2(holdings) -> bool: return rows > 0 and len(holdings) >= rows +def _balance_incomplete_v2(balance, holdings) -> bool: + """[하드닝 R9 2026-10-12] 잔고 응답이 불완전할 수 있는지(응답 밖 보유 종목 누락 가능). + + get_balance_all_v2 응답(_pages 키 있음)은 연속조회 결과라 `_partial` 이 True 일 때만 불완전이다 + (행 수가 페이지 한도 이상이어도 완전 응답이면 누락이 없다). `_pages` 가 없는 응답(구 get_balance + 형태)은 기존 휴리스틱(첫 페이지 가득 = 불완전)을 그대로 쓴다. + """ + if isinstance(balance, dict) and "_pages" in balance: + return bool(balance.get("_partial")) + return _balance_page_full_v2(holdings) + + def _reduction_net_pnl_v2(avg_price: float, current_price: float, sell_qty: int) -> dict: """[R4 2026-10-09] MDD 축소 매도의 비용 차감 순손익 (CLAUDE.md §5.1 순손익 기준). @@ -842,7 +854,8 @@ class MDDManager: from app.services.db_worker import enqueue_trade_history # 현재 보유 종목 조회 - balance = await korea_investment_api.get_balance() + # [하드닝 R9 2026-10-12] 연속조회(get_balance_all_v2) — 첫 페이지 밖 보유 종목도 축소 대상(§8.5-37) + balance = await korea_investment_api.get_balance_all_v2() # [C-2 3회차] 오류 응답(rt_cd≠0: EGW00201·40910000 등)은 output1 이 없다 — # '보유 없음'으로 처리하면 executed 가 서고 이 레벨 축소가 영구 생략됐다. if not _balance_response_ok(balance): @@ -872,12 +885,13 @@ class MDDManager: results = [] skipped = 0 pending: list[str] = [] # 다음 주기 재시도 대상 (실패·보류) - if _balance_page_full_v2(holdings): + if _balance_incomplete_v2(balance, holdings): # [R2 LOW] 첫 페이지만 조회 — 응답 밖 종목은 축소 대상에서 빠진다. 보이는 종목은 # 축소하되 executed 를 세우지 않아 완료로 오인되지 않게 한다. logger.error( - f"[MDD] {action} 잔고 응답이 페이지 한도({len(holdings)}행)에 도달 — 응답 밖 보유 종목은 " - f"축소되지 않았을 수 있음(수동 확인 필요, get_balance 연속조회 미구현)" + f"[MDD] {action} 잔고 응답이 불완전({len(holdings)}행, " + f"_partial={balance.get('_partial')}) — 응답 밖 보유 종목은 " + f"축소되지 않았을 수 있음(수동 확인 필요)" ) pending.append(_PAGE_FULL_SENTINEL) for h in holdings: @@ -1086,7 +1100,8 @@ class MDDManager: enqueue_active_trade_upsert, ) - balance = await kis.get_balance() + # [하드닝 R9 2026-10-12] 연속조회(get_balance_all_v2) — §8.5-37 + balance = await kis.get_balance_all_v2() if not _balance_response_ok(balance): logger.warning( f"[MDD v2] user={user_id} {action} 잔고 조회 실패 응답 — 축소 보류, 다음 주기 재시도 " @@ -1113,9 +1128,9 @@ class MDDManager: results = [] skipped = 0 pending: list[str] = [] # 다음 주기 재시도 대상 (실패·보류) - if _balance_page_full_v2(holdings): + if _balance_incomplete_v2(balance, holdings): logger.error( - f"[MDD v2] user={user_id} {action} 잔고 응답이 페이지 한도({len(holdings)}행)에 도달 — " + f"[MDD v2] user={user_id} {action} 잔고 응답이 불완전({len(holdings)}행, _partial={balance.get('_partial')}) — " f"응답 밖 보유 종목은 축소되지 않았을 수 있음(수동 확인 필요)" ) pending.append(_PAGE_FULL_SENTINEL) diff --git a/backend/app/services/swing/exit_service.py b/backend/app/services/swing/exit_service.py index 4bd35de..4dbbdcb 100644 --- a/backend/app/services/swing/exit_service.py +++ b/backend/app/services/swing/exit_service.py @@ -592,6 +592,8 @@ def _parse_restored_unknown_sell_v2(stock_code: str, raw) -> "dict | None": entry["prior_realized_net"] = float(entry["prior_realized_net"]) if entry["qty"] <= 0: raise ValueError("qty<=0") + # [R9] monotonic 시각은 프로세스 간 비교 불가 — 복원 대기는 "이 프로세스의 모든 매수보다 먼저 등록"으로 본다 + entry.pop("since_mono", None) return entry except (TypeError, ValueError) as e: logger.error( @@ -652,6 +654,88 @@ async def _restore_unknown_sells_v2() -> bool: return False +def _unknown_sell_expired_v2(pend: dict, now_kst_dt=None) -> bool: + """[하드닝 R9 2026-10-12, §8.5-38 H-C] 등록일(KST) 장 마감(15:30) 이후면 True. 등록 시각 불명이면 False.""" + from datetime import datetime, time as dtime + from app.core.timeutil import KST, now_kst + since = pend.get("since") + try: + since_f = float(since) + except (TypeError, ValueError): + return False + now_dt = now_kst_dt or now_kst() # KST naive (timeutil 규약) + reg = datetime.fromtimestamp(since_f, tz=KST).replace(tzinfo=None) + close_dt = datetime.combine(reg.date(), dtime(15, 30)) + if reg >= close_dt: + # 장 마감 이후 등록(정상 운영에선 없음)은 다음 날 장 마감까지 유지 + from datetime import timedelta + close_dt += timedelta(days=1) + return now_dt > close_dt + + +async def _try_reconcile_before_expire_v2(code: str) -> bool: + """TTL 폐기 직전 대조 1회. 대기가 해제됐으면(체결 반영/미체결 확인) True. 조회 실패·대기 유지면 False.""" + try: + snap = await _presell_kis_holding_v2(code) + if snap is not None: + await _reconcile_unknown_sell_v2(code, *snap) + except Exception as e: + logger.warning(f"[SwingExit] {code}: TTL 폐기 전 결과 불명 매도 대조 실패 — {type(e).__name__}: {e}") + return False + return code not in _pending_unknown_sells + + +async def _expire_unknown_sells_v2(now_kst_dt=None) -> list: + """[하드닝 R9 2026-10-12, §8.5-38 H-C] 결과 불명 매도 대기 폐기 — 반환 = 폐기한 종목. + + (1) 등록일 장 마감(15:30 KST) 이후: 당일 미체결 주문은 소멸하므로 대조 없이 폐기. + (2) 대기 등록 이후 같은 종목의 신규 매수 접수가 관측됨(trade_executor 사이클 매수 누적): + 대기의 pre_hldg 가 새 포지션과 섞여 가짜 체결이 기록될 수 있으므로 폐기. + 어느 쪽이든 체결분 장부 반영이 누락됐을 수 있어 warning + 텔레그램(수동 확인)을 남긴다. + 판정 자체는 try 밖 — 매수 누적 조회 실패만 warning 후 (1) 만 적용한다. + """ + if not _pending_unknown_sells: + return [] + recent_buys: dict = {} + try: + from app.services.trade_executor import _recent_cycle_buys_v2 + recent_buys = _recent_cycle_buys_v2() or {} + except Exception as e: + logger.warning(f"[SwingExit] 사이클 매수 누적 조회 실패 — 재매수 폐기 판정 생략: {type(e).__name__}: {e}") + dropped = [] + for code, pend in list(_pending_unknown_sells.items()): + why = None + if _unknown_sell_expired_v2(pend, now_kst_dt): + # 폐기 전 KIS 잔고 대조 1회 — 장 후반 등록 후 사이클이 없었던 항목이 대조 없이 버려지지 않게. + # 대조로 판정(체결 반영 또는 미체결 확인 해제)되면 그 결과로 종결, 못 하면 폐기+경보. + if await _try_reconcile_before_expire_v2(code): + continue + why = "등록일 장 마감 경과" + else: + buy = recent_buys.get(code) + if isinstance(buy, dict): + try: + buy_ts = float(buy.get("ts", 0)) + reg_mono = pend.get("since_mono") + # 복원된 대기(since_mono 없음)는 이 프로세스의 모든 매수보다 먼저 등록된 것 + if reg_mono is None or buy_ts > float(reg_mono): + why = "등록 이후 같은 종목 신규 매수 접수" + except (TypeError, ValueError) as e: + logger.warning(f"[SwingExit] {code}: 재매수 폐기 판정 시각 형식 이상 — 대기 유지 ({e})") + if why is None: + continue + _pending_unknown_sells.pop(code, None) + await _forget_unknown_sell_v2(code) + dropped.append(code) + msg = ( + f"[SwingExit] {code}: 결과 불명 매도 대기({pend.get('qty')}주) 대조 없이 폐기 — {why}. " + "체결분 장부(TradeHistory·ActiveTrade·loss_tracker) 반영이 누락됐을 수 있음, 수동 확인 필요" + ) + logger.warning(msg) + _notify_swing_exit_alert_v2(f"unknown_sell_expired:{code}", "SWING_EXIT", msg) + return dropped + + async def _reconcile_orphan_unknown_sells_v2(holding_codes: set) -> None: """보유 목록에 없는 종목의 결과 불명 대기를 KIS 잔고로 대조. @@ -732,6 +816,65 @@ async def _fetch_kis_holding_v2(stock_code: str) -> "tuple[int, int] | None": return hq, sq +def _holding_from_rows_v2(rows: list, stock_code: str) -> "tuple[bool, int, int] | None": + """잔고 행 목록에서 (발견 여부, 보유, 주문가능). 형식 이상이면 None. 주문가능 없으면 보유로 본다.""" + hq = sq = 0 + found = False + try: + for h in rows: + if not isinstance(h, dict) or str(h.get("pdno", "") or "") != stock_code: + continue + found = True + _h = max(0, int(float(h.get("hldg_qty", 0) or 0))) + _s = _h + if h.get("ord_psbl_qty") not in (None, ""): + _s = max(0, min(_h, int(float(h.get("ord_psbl_qty") or 0)))) + hq += _h + sq += _s + except (TypeError, ValueError): + return None + return found, hq, sq + + +async def _fetch_kis_holding_v3(stock_code: str) -> "tuple[int, int] | None": + """[하드닝 R9 2026-10-12, §8.5-37] v2 + 연속조회(get_balance_all_v2). + + - 예외·오류 응답(rt_cd≠0)·형식 이상: None(보류) — v2 와 동일. + - 완전 응답(`_pages` 있음, `_partial` 없음): 미발견 = 미보유 (0, 0) 확정. 행 수가 페이지 한도 + 이상이어도 보류하지 않는다(20종목 이상 보유 시 2페이지 종목의 손절이 풀린다). + - 불완전 응답(`_partial` True): 미발견이면 다음 페이지에 있을 수 있음 → None(보류, error). + - `_pages` 없는 응답(구 형태): v2 의 "첫 페이지 가득 + 미발견 = 보류" 휴리스틱 유지. + """ + try: + bal = await korea_investment_api.get_balance_all_v2() + except Exception as e: + logger.error(f"[SwingExit] KIS 잔고(연속조회) 조회 예외 ({stock_code}): {type(e).__name__}: {e} — 매도 보류") + return None + if not isinstance(bal, dict) or bal.get("rt_cd") not in (None, "0"): + return None + rows = bal.get("output1") + if not isinstance(rows, list): + return None + got = _holding_from_rows_v2(rows, stock_code) + if got is None: + return None + found, hq, sq = got + if not found: + if "_pages" in bal: + if bal.get("_partial"): + logger.error( + f"[SwingExit] {stock_code} KIS 잔고 연속조회 불완전({bal.get('_pages')}페이지, {len(rows)}행)" + f"에 없음 — 다음 페이지 가능, 매도 보류" + ) + return None + elif len(rows) >= int(settings.SWING_BALANCE_FULL_PAGE_ROWS): + logger.error( + f"[SwingExit] {stock_code} KIS 잔고 첫 페이지({len(rows)}행)에 없음 — 페이지 초과 가능, 매도 보류" + ) + return None + return hq, sq + + # [C-1 2026-10-02 6회차] 종목별 매도 직전 KIS 잔고 확인 연속 실패 횟수 _presell_check_failures: dict[str, int] = {} @@ -743,7 +886,7 @@ async def _presell_kis_holding_v2(stock_code: str) -> "tuple[int, int] | None": (결과 불명 체결·외부 매도 등). 매도 수량 상한을 항상 KIS 실보유로 잡아 DT 물량 매도를 구조적으로 막는다. 조회 실패 시 매도 보류 — 청산 지연을 감수한다(과다매도보다 낫다). """ - got = await _fetch_kis_holding_v2(stock_code) + got = await _fetch_kis_holding_v3(stock_code) # [R9] 연속조회(§8.5-37) if got is None: n = _presell_check_failures.get(stock_code, 0) + 1 _presell_check_failures[stock_code] = n @@ -1686,6 +1829,8 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: _apply_census_v2(_census, len(_run_holding_codes)) except Exception as e: logger.warning(f"[SwingExit] 보유 집계 경보 처리 실패(판정 계속): {type(e).__name__}: {e}") + # [하드닝 R9] 불명 대기 TTL(장 마감)·재매수 폐기 — 대조보다 먼저(§8.5-38 H-C) + await _expire_unknown_sells_v2() # [하드닝 R2] 보유 목록 밖(전량 체결 후 sync 로 행 삭제 등)의 불명 대기 대조 — 유령 ActiveTrade 방지 if _pending_unknown_sells: await _reconcile_orphan_unknown_sells_v2( @@ -2034,6 +2179,18 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: ) continue + # [하드닝 R9 2026-10-12, §8.5-38 H-B] 결과 불명 매도 대기(미결) 중: 분할 익절은 건너뛴다 — + # remaining_pct 가 아직 갱신되지 않아 같은 단계가 재발동한다. 손절·전량 청산은 계속 허용 + # (§8.1-11). 수량 상한은 아래 KIS 상한 min(주문가능, 보유) − DT — 불명 주문이 접수됐다면 + # 주문가능이 이미 줄어 있어 과다매도가 안 난다. 판정은 try 밖(§4). + if (domestic and stock_code in _pending_unknown_sells + and float(getattr(exit_signal, "sell_pct", 1.0) or 1.0) < 1.0): + logger.warning( + f"[SwingExit] {stock_code}: 결과 불명 매도 대기 중 — 분할 익절({exit_signal.reason}) 건너뜀, " + "손절·전량 신호만 허용" + ) + continue + # ── 매도 수량 결정 (소량 분할 방지) ───────────────────── # [M5 2026-09-27] sell_pct 의 의미 = '진입 시 원본 수량 대비' 비율. # check_exit_signal / _check_momentum_exit 의 remaining_pct 게이트가 @@ -2164,10 +2321,23 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: f"{err} — 해외 잔고 확인 미지원, 수동 확인 필요" ) elif _outcome == "unknown": + # [하드닝 R9] 이미 대기 중(분할 불명 → 손절 불명)이면 덮어써서 앞선 불명 수량을 + # 잃지 않도록 합산한다. 기준 잔고는 앞선 대기의 pre_hldg(더 이른 스냅샷)를 유지. + _prev_pend = _pending_unknown_sells.get(stock_code) + _new_qty = int(sell_qty) + _new_pre = int(_pre_hldg if _pre_hldg is not None else total_qty) + if _prev_pend: + logger.warning( + f"[SwingExit] {stock_code}: 기존 결과 불명 대기({_prev_pend.get('qty')}주)에 " + f"새 불명 매도 {sell_qty}주 합산" + ) + _new_qty += int(_prev_pend.get("qty") or 0) + _new_pre = max(_new_pre, int(_prev_pend.get("pre_hldg") or 0)) _pending_unknown_sells[stock_code] = { - "qty": int(sell_qty), - "pre_hldg": int(_pre_hldg if _pre_hldg is not None else total_qty), + "qty": _new_qty, + "pre_hldg": _new_pre, "since": time.time(), + "since_mono": time.monotonic(), "price": float(latest_price), "avg_price": float(avg_price), "swing_qty": int(_swing_basis), @@ -2184,6 +2354,9 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: } # [하드닝 R2] 재기동에도 대조가 이어지도록 Redis 에 미러링(실패는 warning) await _persist_unknown_sell_v2(stock_code, _pending_unknown_sells[stock_code]) + # [하드닝 R9, H-B] 불명 경로도 청산 쿨다운 기록 — 추천 경로 _execute_sell 공용 + # 쿨다운과 분할 신호 재매도를 막는다(전량형은 쿨다운 우회 규칙 그대로). + _recent_exits[stock_code] = time.time() _trigger_portfolio_sync_v2() logger.error( f"[SwingExit] 🚨 매도 주문 결과 불명 ({stock_code}, {sell_qty}주, msg_cd={_mc}): " @@ -2229,6 +2402,17 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: _recent_exits[stock_code] = time.time() + # [하드닝 R9, §8.5-38 H-B] 결과 불명 대기 중 정상 접수된 매도(손절·전량)는 대기 기준 잔고에서 + # 차감한다 — 안 하면 다음 대조의 (pre_hldg − KIS 보유)에 이 매도분이 섞여 불명 체결로 이중 기록. + _pend_now = _pending_unknown_sells.get(stock_code) if domestic else None + if _pend_now is not None: + _pend_now["pre_hldg"] = max(0, int(_pend_now.get("pre_hldg") or 0) - int(sell_qty)) + logger.warning( + f"[SwingExit] {stock_code}: 결과 불명 대기 중 정상 매도 {sell_qty}주 접수 — " + f"대기 기준 잔고 → {_pend_now['pre_hldg']}주" + ) + await _persist_unknown_sell_v2(stock_code, _pend_now) + # [2026-10-08] 실제 체결가·체결수량 확인 → TradeHistory 체결가·금액·순손익 보정 # (백그라운드, db_worker 큐 경유). 이전엔 신호가(latest_price)가 그대로 체결가로 # 남았다. 국내만 — 해외 체결조회 경로 없음(US_TRADING_ENABLED=False). diff --git a/backend/app/services/swing/fill_sweep.py b/backend/app/services/swing/fill_sweep.py index 02d307b..0af4b6c 100644 --- a/backend/app/services/swing/fill_sweep.py +++ b/backend/app/services/swing/fill_sweep.py @@ -299,6 +299,84 @@ async def _enqueue_buy_entry_adjust_v2(row, filled_qty: int, filled_price: float return True +# ── [하드닝 R9 2026-10-12] 늦게 확정된 0체결·부분체결 스윙 BUY 의 ActiveTrade 원복 ───────────── +# 직후 확인(trade_executor._verify_and_sync_execution_v2)이 15초 폴링 동안 잔량 대기로 끝나면 확정은 +# 여기 몫인데, 이전에는 0체결 확정을 warning 후 continue(CANCELLED 강등·ActiveTrade close 없음), +# 부분체결 확정은 TradeHistory·진입가 보정만 하고 ActiveTrade.quantity 를 주문수량으로 남겼다 +# → 유령 active 행·수량 과대가 sync 유령 정리 전까지 분할매도 수량·보유 슬롯을 왜곡했다. +# 원복 정보는 (1) 같은 프로세스면 trade_executor 레지스트리(직후 확인이 재등록), (2) 재기동 등으로 +# 없으면 TradeHistory 행(entry_reason·order_quantity)과 active 행으로 재구성한다. +# 멱등: 행 id 를 `_queued_ids` 에 먼저 넣고, close 는 이미 닫힌 행에 no-op, 축소는 절대값(=체결수량). +# Portfolio 는 스윙 주문 시점에 갱신하지 않으므로(KIS sync 가 진실) 되돌릴 것이 없다. + + +def _is_swing_buy_row_v2(row) -> bool: + return (getattr(row, "order_type", None) == "BUY" + and getattr(row, "strategy_type", None) in _SWING_BUY_STRATEGY_TYPES) + + +def reconstruct_buy_rollback_v2(row, active_rows, filled_qty: int) -> tuple[dict | None, str]: + """TradeHistory BUY 행 + 그 종목 active 행들로 원복 정보를 재구성 (순수 함수). + + 반환 (rollback | None, 사유). rollback 은 trade_executor 레지스트리와 같은 모양 + {stock_code, strategy_name, quantity, accumulate}. + - 대상 행 확정은 진입가 보정과 같은 규칙(resolve_entry_fill_target_v2) — 확정 불가면 원복 안 함. + - 행 수량 == 주문수량 → 신규 진입(accumulate False). + - 행 수량 > 주문수량 → 추가매수(accumulate True — 원복 함수가 수동 확인 경보). + - 행 수량 == 체결수량(< 주문수량) → 이미 원복됨(None, "already"). + - 그 외(행 수량 < 주문수량: 이미 일부 매도 등) → 역산 근거 없음(None). + """ + ordered = int(getattr(row, "order_quantity", 0) or 0) + if ordered <= 0: + return None, "order_quantity 없음" + target, why = resolve_entry_fill_target_v2(row, active_rows) + if not target: + return None, why + same = [r for r in (active_rows or []) if getattr(r, "strategy_name", None) == target] + newest = max(same, key=lambda r: int(getattr(r, "id", 0) or 0)) + at_qty = int(getattr(newest, "quantity", 0) or 0) + rb = { + "stock_code": row.stock_code, + "strategy_name": target, + "quantity": ordered, + "accumulate": at_qty > ordered, + } + if at_qty >= ordered: + return rb, "ok" + if filled_qty > 0 and at_qty == int(filled_qty): + return None, "already" + return None, f"active 행 수량 {at_qty} < 주문수량 {ordered}(일부 매도 등) — 역산 근거 없음" + + +async def _resolve_buy_rollback_v2(row, db, filled_qty: int) -> dict | None: + """레지스트리 우선, 없으면 TradeHistory 행으로 재구성. 확정 불가면 warning 후 None.""" + from app.services.trade_executor import _pop_buy_entry_rollback_v2 + rb = _pop_buy_entry_rollback_v2(row.order_id, row.stock_code) + if rb: + return rb + if db is None: + logger.warning( + f"[FillSweep] {row.stock_code} #{getattr(row, 'id', None)} BUY 원복 정보 없음·조회 세션 없음 " + f"— ActiveTrade 수동 확인 필요" + ) + return None + try: + active_rows = await _load_active_rows_for_fill_v2(db, row.stock_code) + except Exception as e: + logger.warning( + f"[FillSweep] {row.stock_code} #{getattr(row, 'id', None)} BUY 원복용 ActiveTrade 조회 실패 — " + f"수동 확인 필요: {type(e).__name__}: {e}" + ) + return None + rb, why = reconstruct_buy_rollback_v2(row, active_rows, filled_qty) + if rb is None and why != "already": + logger.warning( + f"[FillSweep] {row.stock_code} #{getattr(row, 'id', None)} BUY 체결 {filled_qty}/" + f"{int(row.order_quantity or 0)}주 확정 — ActiveTrade 원복 생략: {why} (수동 확인)" + ) + return rb + + def unconfirmed_fill_clause_v2(): """'체결가 미확인' TradeHistory 판정식 (모니터링 지표와 sweep 이 공유하는 단일 정의).""" return or_( @@ -404,6 +482,26 @@ async def sweep_unconfirmed_fills_v2(db) -> int: f"order_id={row.order_id}" ) continue + if ( + filled_qty <= 0 and _is_swing_buy_row_v2(row) and info.get("found") + and (remaining == 0 or info.get("cancelled")) + ): + # [하드닝 R9] 0체결 + 잔량 소멸 확정 = 매수 불성립 → CANCELLED + ActiveTrade close. + from app.services.trade_executor import ( + _mark_trade_cancelled_v2, _rollback_buy_active_trade_v2, + ) + _queued_ids.add(row.id) # 먼저 표시 — 이후 예외여도 당일 재처리 안 함(멱등) + _attempts.pop(row.id, None) + await _mark_trade_cancelled_v2(str(row.order_id), row.id, filled_qty=0) + _rb = await _resolve_buy_rollback_v2(row, db, 0) + _res = await _rollback_buy_active_trade_v2(str(row.order_id), _rb, 0) + queued += 1 + _last_stats["queued"] = int(_last_stats.get("queued", 0)) + 1 + logger.warning( + f"[FillSweep] {row.stock_code} #{row.id} BUY 0체결 소멸 확정 — CANCELLED 강등, " + f"ActiveTrade 원복={_res} (order_id={row.order_id})" + ) + continue if filled_qty <= 0 or filled_price <= 0: logger.warning( f"[FillSweep] {row.stock_code} #{row.id} {row.order_type} 체결 미확인 " @@ -441,6 +539,16 @@ async def sweep_unconfirmed_fills_v2(db) -> int: _queued_ids.add(row.id) _attempts.pop(row.id, None) await enqueue_update_trade_history(row.id, fields) + if _is_swing_buy_row_v2(row) and ordered > 0 and filled_qty < ordered: + # [하드닝 R9] 부분체결 확정 → ActiveTrade.quantity 를 체결수량으로(절대값 — 멱등). + # 진입가 보정보다 먼저 큐잉해야 보정 분모(행 수량)가 체결수량이 된다(직후 확인과 같은 순서). + from app.services.trade_executor import _rollback_buy_active_trade_v2 + _rb = await _resolve_buy_rollback_v2(row, db, filled_qty) + await _rollback_buy_active_trade_v2(str(row.order_id), _rb, filled_qty) + elif row.order_type == "BUY": + # 전량 체결 확정 — 남은 원복 정보 폐기(레지스트리 정리) + from app.services.trade_executor import _pop_buy_entry_rollback_v2 + _pop_buy_entry_rollback_v2(row.order_id, row.stock_code) if row.order_type == "BUY": await _enqueue_buy_entry_adjust_v2(row, filled_qty, filled_price, db) queued += 1 diff --git a/backend/app/services/swing/scheduler.py b/backend/app/services/swing/scheduler.py index 79fbf65..a1b3b04 100644 --- a/backend/app/services/swing/scheduler.py +++ b/backend/app/services/swing/scheduler.py @@ -1184,6 +1184,75 @@ async def _run_recommendation_entry_v2( await execute_swing_entries(rec, entry_session) +# ── [하드닝 R9 2026-10-12] 청산 미종결 사이클의 MDD 축소·진입 생략 (redteam #8) ───────────── +# 청산 판정이 상한 초과(TIMED_OUT)로 백그라운드에서 계속 돌거나, 이전 판정이 아직 진행 중이라 +# 생략(SKIPPED)됐거나, 예외로 끝났으면 그 사이클의 MDD 축소·신규 진입을 하지 않는다. detached 청산과 +# MDD 축소가 같은 종목을 동시에 매도하면 분할 단계 판정이 이중 적용될 수 있고, 청산 판정을 끝내지 +# 못한 상태에서 매수를 내는 것은 '청산 우선'(§8.1-11) 위반이다. 청산 자체는 계속 실행된다 — +# 다음 사이클이 청산 판정을 정상 완료하면 자동 재개(_inflight_steps 의 '청산 판정' 태스크 기준). +_EXIT_STEP_NAME = "청산 판정" + + +# [R9 재수정] 예외로 끝난 청산 단계는 태스크가 종료돼 동시 매도가 없으므로 **진입만** 생략하고 +# MDD 축소는 실행한다. TIMED_OUT·SKIPPED·백그라운드 진행 중만 축소·진입 둘 다 생략. +_DEFAULT_SWING_EXIT_UNSETTLED_ESCALATE_CYCLES = 3 +# MDD 축소가 '청산 진행 중'으로 연속 생략된 사이클 수(정상 청산 사이클에서 리셋) +_mdd_unsettled_skip_state: dict = {"count": 0, "escalated": False} + + +def _exit_phase_unsettled_reason_v2(exit_res, raised: bool) -> str | None: + """진입 생략 사유(예외 포함). 정상이면 None (순수 판정 + 인메모리 조회).""" + if raised: + return "청산 단계 예외" + return _exit_inflight_reason_v2(exit_res) + + +def _exit_inflight_reason_v2(exit_res) -> str | None: + """청산 판정이 아직 돌고 있을 수 있는 사유(MDD 축소·진입 모두 생략). 아니면 None.""" + if exit_res is _STEP_TIMED_OUT: + return "청산 판정 상한 초과(백그라운드 진행 중)" + if exit_res is _STEP_SKIPPED: + return "이전 청산 판정이 아직 진행 중" + _t = _inflight_steps.get(_EXIT_STEP_NAME) + if _t is not None and not _t.done(): + return "청산 판정 백그라운드 진행 중" + return None + + +def _skip_for_unsettled_exit_v2(reason: str | None, stage: str) -> bool: + """reason 이 있으면 warning + (스로틀) 텔레그램 후 True — 호출부가 해당 단계를 생략한다.""" + if not reason: + return False + msg = (f"[Swing] {stage} 생략 — {reason}. 같은 종목 이중 매도·청산 전 매수 방지, " + f"청산 판정이 정상 완료되는 사이클에 재개") + logger.warning(msg) + _notify_throttled_v2(f"exit_unsettled:{stage}", "SWING_SCHEDULER", msg) + return True + + +def _note_mdd_reduction_unsettled_v2(skipped: bool) -> None: + """MDD 축소 연속 생략 에스컬레이션. skipped=False 면 리셋. + + 축소를 강제 실행하지는 않는다 — 청산 판정이 백그라운드에서 같은 종목을 매도 중일 수 있어 + 이중 매도 위험이 축소 지연보다 크다. 대신 N사이클 연속이면 error + 텔레그램(스트릭당 1회). + """ + if not skipped: + _mdd_unsettled_skip_state["count"] = 0 + _mdd_unsettled_skip_state["escalated"] = False + return + _mdd_unsettled_skip_state["count"] += 1 + n = _mdd_unsettled_skip_state["count"] + limit = _cfg_int_v2( + "SWING_EXIT_UNSETTLED_ESCALATE_CYCLES", _DEFAULT_SWING_EXIT_UNSETTLED_ESCALATE_CYCLES, + ) + if n >= limit and not _mdd_unsettled_skip_state["escalated"]: + _mdd_unsettled_skip_state["escalated"] = True + msg = (f"MDD 축소 {n}사이클 연속 미실행(청산 판정 미종결, 기준 {limit}사이클) — 수동 확인 필요. " + f"이중 매도 방지로 축소를 강제하지 않음") + logger.error(f"[Swing] 🚨 {msg}") + _notify_throttled_v2("exit_unsettled:mdd_escalate", "SWING_SCHEDULER", msg) + + async def swing_scheduler() -> None: """60초 주기 스윙 트레이딩 스케줄러. @@ -1248,6 +1317,8 @@ async def swing_scheduler() -> None: # 매도가 같은 종목에 겹쳐 분할 매도가 이중 실행될 수 있다(과다매도). _note_market_open_v2(_now_mono_v2()) _exit_timeout = _cfg_float_v2("SWING_EXIT_STEP_TIMEOUT_SEC", _DEFAULT_EXIT_STEP_TIMEOUT_SEC) + _exit_res = None + _exit_raised = False try: _exit_res = await _guard_detached_v2(_run_exit_phase_v2, "청산 판정", _exit_timeout) if _exit_res is _STEP_TIMED_OUT: @@ -1255,8 +1326,14 @@ async def swing_scheduler() -> None: elif _exit_res is _STEP_SKIPPED: _record_exit_failure_v2("이전 청산 판정이 아직 진행 중(상한 초과 상태)") except Exception as e: + _exit_raised = True logger.error(f"[Swing] 청산 단계 오류: {e}", exc_info=True) _record_exit_failure_v2(f"{type(e).__name__}: {e}") + # [하드닝 R9] 청산 미종결이면 이번 사이클 MDD 축소·진입 생략(판정은 try 밖 — 가드) + _exit_unsettled = _exit_phase_unsettled_reason_v2(_exit_res, _exit_raised) + _exit_inflight = _exit_inflight_reason_v2(_exit_res) + if _exit_inflight is None: + _note_mdd_reduction_unsettled_v2(False) # 청산 판정 종결(정상/예외) → 리셋 try: from app.core import heartbeat as _hb _hb.beat("swing", "exit_phase_done") @@ -1378,7 +1455,12 @@ async def swing_scheduler() -> None: ) else: logger.warning("[Swing] MDD 초과 — 신규 진입 차단") - if mdd_manager.reduction_applied: + _mdd_skip = bool(mdd_manager.reduction_applied) and _skip_for_unsettled_exit_v2( + _exit_inflight, "MDD 축소", + ) + if _mdd_skip: + _note_mdd_reduction_unsettled_v2(True) + if mdd_manager.reduction_applied and not _mdd_skip: try: reduce_pct = 0.50 if mdd_manager.reduction_applied == "20pct" else 0.30 mdd_action = { @@ -1416,6 +1498,9 @@ async def swing_scheduler() -> None: # scan_dual_strategy_signals 가 일봉 조회 전에 생략한다. # [하드닝 R1] 진입(평균회귀→듀얼)은 주문 경로라 비취소형 상한. 초과 시 루프는 # 다음 사이클(청산 우선)로 진행하고, 끝날 때까지 새 진입을 시작하지 않는다. + if _skip_for_unsettled_exit_v2(_exit_unsettled, "진입"): + await asyncio.sleep(60) + continue await _guard_detached_v2( lambda: _run_entry_phase_v2( get_json, diff --git a/backend/app/services/trade_executor.py b/backend/app/services/trade_executor.py index 637a6e7..07d7a05 100644 --- a/backend/app/services/trade_executor.py +++ b/backend/app/services/trade_executor.py @@ -1026,6 +1026,21 @@ def _pop_buy_entry_rollback_v2(order_id, stock_code: str) -> dict | None: return rb +def _restore_buy_entry_rollback_v2(order_id, rollback: dict | None) -> None: + """[하드닝 R9 2026-10-12] 체결 확인이 **종결을 못 본 채** 끝난 BUY 의 원복 정보를 되돌려 놓는다. + + `_verify_and_sync_execution_v2` 는 폴링 전에 레지스트리를 pop 한다. 지정가 BUY 가 폴링 동안 + 잔량 대기(0체결·부분체결)로 끝나면 최종 확정은 swing/fill_sweep 이 하므로, 그때 쓸 수 있게 + 같은 키로 재등록한다(당일 항목만 — 날짜가 바뀌었으면 버린다). + """ + if not rollback: + return + key = _entry_fill_key_v2(order_id) + if not key or rollback.get("day") != _get_kst_now().date().isoformat(): + return + _buy_entry_rollbacks[key] = dict(rollback) + + def _notify_buy_ledger_v2(message: str) -> None: try: from app.services.telegram_notifier import telegram_notifier @@ -1873,6 +1888,8 @@ async def _verify_and_sync_execution_v3( # 맡기면 그 사이 청산 판정이 없는 포지션을 보고, 장부(BUY만 있는 고아)가 남는다. await _rollback_buy_active_trade_v2(order_id, _buy_rb, 0) elif order_type == "BUY": + # [하드닝 R9] 종결 미확인 — 원복 정보를 fill_sweep 의 최종 확정용으로 되돌려 둔다. + _restore_buy_entry_rollback_v2(order_id, _buy_rb) # 잔량이 살아 있거나(지정가 대기) 확인 불가 — 매수 미체결 주문은 취소하지 않는다 # (4회차 정책). TradeHistory 의 COMPLETED·전량은 **잠정치**이고 확정은 # swing/fill_sweep 의 후속 조회에 맡긴다. 끝내 체결되지 않으면 장부가 거짓이 되므로 @@ -1936,6 +1953,9 @@ async def _verify_and_sync_execution_v3( partial = 0 < filled_qty < ordered_quantity remainder_dead = partial and remaining_qty == 0 if partial and not remainder_dead: + if order_type == "BUY": + # [하드닝 R9] 부분체결 미종결 — 최종 체결수량 확정(fill_sweep) 때 원복할 수 있게 재등록 + _restore_buy_entry_rollback_v2(order_id, _buy_rb) # 잔량이 KIS 에 미체결 지정가로 살아 있다(또는 모른다) → 재주문하면 중복 주문. # 최종 체결 수량이 아직 정해지지 않았으므로 원장도 건드리지 않는다(포트폴리오 sync 가 진실). logger.warning( diff --git a/backend/app/services/trade_helpers.py b/backend/app/services/trade_helpers.py index 78006d4..72604d4 100644 --- a/backend/app/services/trade_helpers.py +++ b/backend/app/services/trade_helpers.py @@ -11,7 +11,8 @@ from datetime import datetime as _dt_utc from app.core.config import settings from app.services.korea_investment_api import korea_investment_api from app.services.mean_reversion_strategy import ( - calculate_rsi, calculate_bollinger, calculate_atr, MarketRegime + calculate_rsi, calculate_bollinger, calculate_atr, MarketRegime, + resolve_swing_trailing_atr_mult_v2, ) from app.services.technical_indicators import ( TREND_FILTER_ENABLED, TREND_MA_SHORT, TREND_MA_LONG, TREND_TOLERANCE_PCT, _ma, _ema, @@ -380,7 +381,7 @@ def calculate_atr_based_stops( stop_loss = max(stop_loss, atr_stop_pct) # TIGHTER = 0에 가까운 쪽 (음수이므로 max) high_price = float(stock_dict.get("high_price") or current_price) - trailing_stop_price = high_price - 1.5 * atr + trailing_stop_price = high_price - resolve_swing_trailing_atr_mult_v2() * atr # 단일 출처(기본 1.5) profit_loss_rate = calculate_profit_loss_rate(current_price, average_price) if trailing_stop_price and current_price <= trailing_stop_price and profit_loss_rate > 0: diff --git a/backend/tests/_swing_redis_guard.py b/backend/tests/_swing_redis_guard.py new file mode 100644 index 0000000..ae265c4 --- /dev/null +++ b/backend/tests/_swing_redis_guard.py @@ -0,0 +1,30 @@ +"""스윙 테스트 단독 실행 시 Redis 실접속(localhost:6379) 방지용 autouse 픽스처 (R9 E-6). + +test_swing*.py 가 모듈 끝에서 import 한다. 인메모리 대체로 get_json/setex_json/delete_key 를 +치환하고 get_redis 는 None(미연결)을 돌려준다. 개별 테스트가 다시 monkeypatch 하면 그쪽이 우선. +""" +import pytest + + +@pytest.fixture(autouse=True) +def _swing_redis_guard(monkeypatch): + import app.core.redis as r + + store: dict = {} + + async def _get_json(key): + return store.get(key) + + async def _setex_json(key, ttl, data): + store[key] = data + return True + + async def _delete_key(key): + store.pop(key, None) + return True + + monkeypatch.setattr(r, "get_json", _get_json) + monkeypatch.setattr(r, "setex_json", _setex_json) + monkeypatch.setattr(r, "delete_key", _delete_key) + monkeypatch.setattr(r, "get_redis", lambda: None) + yield store diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index b8af60f..b9a4001 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -108,10 +108,12 @@ _KIS_CLASS_METHODS = ( "get_minute_chart_full_day", "get_minute_chart_full_day_v2", # 진입 판정 경로(전일 워밍업 포함) — 2026-09-29 "get_balance", + "get_balance_all_v2", # 연속조회 잔고 — 2026-10-12 R9 "get_buyable_cash", "get_overseas_balance", # ── 체결 조회 계열 (2026-10-01: DT 체결가 확인이 백그라운드로 호출) ── "get_todays_orders", + "get_todays_orders_v2", # 조회 실패=None 계약 — 2026-10-12 R9 "get_order_execution", ) diff --git a/backend/tests/test_daytrading_r9_20261012.py b/backend/tests/test_daytrading_r9_20261012.py new file mode 100644 index 0000000..c756e8e --- /dev/null +++ b/backend/tests/test_daytrading_r9_20261012.py @@ -0,0 +1,403 @@ +"""하드닝 9회차 그룹 C(데이트레이딩) 회귀 테스트. + +C-1 결과 불명 DT 매도 + ActiveTrade 없는 수동·HTS 보유분 → 수동 물량 재매도 방지(v3 판정) +C-2 기동 복원 검증이 주문가능수량·당일 PENDING DT 매도를 본다(v3) +C-3 잔고 조회 직후 일일 손실 한도 재확인 +""" +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from app.core.config import settings +from app.services.daytrading import entry_service as ens +from app.services.daytrading import exit_service as es +from app.services.daytrading import position_manager as pm +from app.services.daytrading.position_manager import DayTradingState, day_trading_state + +CODE = "005930" + + +class _MustNotCall(BaseException): + """호출되면 안 되는 경로 — 프로덕션 except Exception 에 삼켜지지 않게 BaseException.""" + + +class OrderReached(BaseException): + """safe_place_order 도달 — kwargs 를 실어 수량을 검증한다(값 반환 스텁 대신 예외형).""" + + +async def _order_reached(*a, **k): + raise OrderReached(k) + + +def _raise(name): + async def _f(*a, **k): + raise _MustNotCall(name) + return _f + + +class _Result: + def __init__(self, rows): + self._rows = rows + + def scalars(self): + return self + + def all(self): + return list(self._rows) + + +class _Session: + def __init__(self, rows): + self._rows = rows + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def execute(self, *_a, **_k): + return _Result(self._rows) + + +def _th(ot, st, q, code=CODE): + return SimpleNamespace(stock_code=code, order_type=ot, status=st, executed_quantity=q, + order_quantity=q, strategy_type="daytrading", created_at=datetime.now()) + + +@pytest.fixture(autouse=True) +def _isolate(monkeypatch): + import app.core.redis as _r + es._unresolved_exit_unknown.clear() + es._unknown_lookup_failures.clear() + monkeypatch.setattr(settings, "DAY_TRADE_EXIT_UNKNOWN_LOOKUP_ALERT_COUNT", 10_000) + monkeypatch.setattr(_r, "setex_json", AsyncMock(return_value=True)) + monkeypatch.setattr(_r, "get_json", AsyncMock(return_value=None)) + monkeypatch.setattr(pm, "_notify_daily_state_impl", AsyncMock(return_value=None)) + yield + es._unresolved_exit_unknown.clear() + es._unknown_lookup_failures.clear() + + +def _kis(monkeypatch, hldg, sellable, nd=0, orders=(), ledger=None): + async def _h(): + return ({CODE: {"hldg": hldg, "sellable": sellable}}, True) + + async def _nd(code): + return nd + + async def _o(code): + return None if orders is None else list(orders) + monkeypatch.setattr(es, "_fetch_kis_holdings_v3_impl", _h) + monkeypatch.setattr(es, "_fetch_non_dt_active_qty_impl", _nd) + monkeypatch.setattr(es, "_lookup_todays_orders_impl", _raise("v1 주문내역(실패=[]) 사용 금지")) + monkeypatch.setattr(es, "_lookup_todays_orders_v2_impl", _o) + if ledger is not None: + monkeypatch.setattr(es, "_fetch_dt_ledger_net_qty_impl", ledger) + + +async def _ledger(n): + async def _f(code): + return n + return _f + + +# ═══ C-1 ═════════════════════════════════════════════════════════════════════ + +@pytest.mark.asyncio +async def test_c1_manual_shares_after_filled_unknown_sell_not_resold(monkeypatch): + """dt.md 재현: DT 10주 불명 매도(실체결) + HTS 5주(ActiveTrade 없음) → 재매도 0.""" + _kis(monkeypatch, 5, 5, ledger=_raise("원장 조회 전에 보류되어야 한다")) + st, resp, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10) + assert (st, resp, cap) == ("ambiguous", None, 0) + + # 재시도 경로 종단: 주문이 나가지 않고 보류가 유지된다 + es._unresolved_exit_unknown[(es._today_key(), CODE)] = { + "qty": 10, "since": es._kst_now(), "total_qty": 10, "reason": "손절", "price": 9_700.0, "sell_pct": 1.0, + } + with patch("app.services.trade_executor.safe_place_order", _order_reached): + res = await es._place_and_finalize_exit_impl( + CODE, {"quantity": 10}, 9_700.0, "손절", 1.0, 10, 10, True, False) + assert res is None + assert (es._today_key(), CODE) in es._unresolved_exit_unknown + assert es._last_exit_order_outcome[CODE] == "unknown" + + +@pytest.mark.asyncio +async def test_c1_genuine_not_accepted_still_resells(monkeypatch): + """정상 not_accepted(원장 순체결 10, 잔고 10) → 10주 재매도.""" + _kis(monkeypatch, 10, 10, ledger=await _ledger(10)) + st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10) + assert (st, cap) == ("not_accepted", 10) + + es._unresolved_exit_unknown[(es._today_key(), CODE)] = { + "qty": 10, "since": es._kst_now(), "total_qty": 10, "reason": "손절", "price": 9_700.0, "sell_pct": 1.0, + } + with patch("app.services.trade_executor.safe_place_order", _order_reached): + with pytest.raises(OrderReached) as ei: + await es._place_and_finalize_exit_impl( + CODE, {"quantity": 10}, 9_700.0, "손절", 1.0, 10, 10, True, False) + assert ei.value.args[0]["quantity"] == 10 + + +@pytest.mark.asyncio +async def test_c1_ledger_caps_resell(monkeypatch): + """잔고상 DT 몫이 원장보다 크면(ActiveTrade 없는 수동분) 원장 순체결로 상한.""" + _kis(monkeypatch, 15, 15, ledger=await _ledger(10)) + st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10) + assert (st, cap) == ("not_accepted", 10) + + +@pytest.mark.asyncio +async def test_c1_ledger_failure_holds(monkeypatch): + """원장 조회 예외 → None → ledger_failed(재매도 보류, fail-closed).""" + def _boom(): + raise RuntimeError("db down") + monkeypatch.setattr("app.core.database.AsyncSessionLocal", _boom) + assert await es._fetch_dt_ledger_net_qty_impl(CODE) is None + + _kis(monkeypatch, 10, 10) + st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10) + assert (st, cap) == ("ledger_failed", 0) + es._unresolved_exit_unknown[(es._today_key(), CODE)] = { + "qty": 10, "since": es._kst_now(), "total_qty": 10, "reason": "손절", "price": 9_700.0, "sell_pct": 1.0, + } + with patch("app.services.trade_executor.safe_place_order", _order_reached): + res = await es._place_and_finalize_exit_impl( + CODE, {"quantity": 10}, 9_700.0, "손절", 1.0, 10, 10, True, False) + assert res is None + + +@pytest.mark.asyncio +async def test_c1_ledger_nonpositive_holds(monkeypatch): + """원장 순체결 ≤ 0 → 재매도 보류(과다매도보다 보류, §3.5). 재시도 종단에서도 주문 0.""" + _kis(monkeypatch, 10, 10, ledger=await _ledger(0)) + st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10) + assert (st, cap) == ("ledger_nonpositive", 0) + es._unresolved_exit_unknown[(es._today_key(), CODE)] = { + "qty": 10, "since": es._kst_now(), "total_qty": 10, "reason": "손절", "price": 9_700.0, "sell_pct": 1.0, + } + with patch("app.services.trade_executor.safe_place_order", _order_reached): + res = await es._place_and_finalize_exit_impl( + CODE, {"quantity": 10}, 9_700.0, "손절", 1.0, 10, 10, True, False) + assert res is None and (es._today_key(), CODE) in es._unresolved_exit_unknown + + +@pytest.mark.asyncio +async def test_c1_orders_lookup_none_holds(monkeypatch): + """주문내역 v2 조회 실패(None) → 보류(원장까지 가지 않는다).""" + _kis(monkeypatch, 10, 10, orders=None, ledger=_raise("주문내역 실패 시 원장 불필요")) + st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10) + assert (st, cap) == ("orders_failed", 0) + + +@pytest.mark.asyncio +async def test_c1_v_repro_manual_ge_q_filled_not_resold(monkeypatch): + """v_repro_dt: 수동 M=10 ≥ q=10, DT 10주 체결(잔고 온전해 보임). 주문내역에 등록 이후 행 → 접수 확정.""" + now = es._kst_now() + row = {"pdno": CODE, "sll_buy_dvsn_cd": "01", "ord_qty": "10", "tot_ccld_qty": "10", + "rjct_qty": "0", "cncl_yn": "N", "odno": "0000999", "ord_tmd": now.strftime("%H%M%S")} + _kis(monkeypatch, 10, 10, orders=[row], ledger=_raise("접수 확정 시 원장 불필요")) + st, resp, _ = await es._resolve_unknown_exit_v3_impl(CODE, 10, now, 10) + assert st == "accepted" and resp["output"]["ODNO"] == "0000999" + + +@pytest.mark.asyncio +async def test_c1_v_repro_rows_invisible_ledger_zero_holds(monkeypatch): + """v_repro_dt 원본(주문내역 [] + 원장 0) → 재매도 0(보류).""" + _kis(monkeypatch, 10, 10, orders=[], ledger=await _ledger(0)) + st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, None, 10) + assert (st, cap) == ("ledger_nonpositive", 0) + + +@pytest.mark.asyncio +async def test_c1_empty_orders_positive_ledger_capped_by_q(monkeypatch): + """주문내역 [](KIS 미접수 실증) + 원장 > 0 → 재매도, 상한 min(DT 매도가능, 원장, q).""" + _kis(monkeypatch, 15, 15, orders=[], ledger=await _ledger(20)) + st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 5, es._kst_now(), 10) + assert (st, cap) == ("not_accepted", 5) + + +@pytest.mark.asyncio +async def test_c1_row_before_registration_or_without_time_ignored(monkeypatch): + """등록 시각(−허용 오차) 이전 행·주문시각 없는 행은 같은 수량이어도 접수로 보지 않는다(r5 L-1 유지).""" + from datetime import timedelta + now = es._kst_now() + early = (now - timedelta(seconds=float(settings.DAY_TRADE_EXIT_UNKNOWN_MATCH_WINDOW_SEC) + 60)).strftime("%H%M%S") + base = {"pdno": CODE, "sll_buy_dvsn_cd": "01", "ord_qty": "10", "tot_ccld_qty": "10", + "rjct_qty": "0", "cncl_yn": "N"} + rows = [dict(base, odno="0000111", ord_tmd=early), dict(base, odno="0000222")] + _kis(monkeypatch, 10, 10, orders=rows, ledger=await _ledger(10)) + st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, now, 10) + assert (st, cap) == ("not_accepted", 10) + + +@pytest.mark.asyncio +async def test_c1_pending_other_order_reduces_sellable_still_resells(monkeypatch): + """보유 30·주문가능 26·비DT 20: 보유 기준 DT 몫 10 온전 → v2 대로 6주 재매도(보류 아님).""" + _kis(monkeypatch, 30, 26, nd=20, ledger=await _ledger(10)) + st, _r, cap = await es._resolve_unknown_exit_v3_impl(CODE, 10, es._kst_now(), 10) + assert (st, cap) == ("not_accepted", 6) + + +@pytest.mark.asyncio +async def test_c1_matching_order_row_is_accepted(monkeypatch): + now = es._kst_now() + row = {"pdno": CODE, "sll_buy_dvsn_cd": "01", "ord_qty": "10", "tot_ccld_qty": "10", + "rjct_qty": "0", "cncl_yn": "N", "odno": "0000777", "ord_tmd": now.strftime("%H%M%S")} + _kis(monkeypatch, 5, 5, orders=[row], ledger=_raise("접수 확정 시 원장 불필요")) + st, resp, _ = await es._resolve_unknown_exit_v3_impl(CODE, 10, now, 10) + assert st == "accepted" and resp["output"]["ODNO"] == "0000777" + + +@pytest.mark.asyncio +async def test_c1_ledger_query_nets_pending_sell(monkeypatch): + rows = [_th("BUY", "COMPLETED", 10), _th("SELL", "PENDING", 4), _th("SELL", "FAILED", 6)] + monkeypatch.setattr("app.core.database.AsyncSessionLocal", lambda: _Session(rows)) + assert await es._fetch_dt_ledger_net_qty_impl(CODE) == 6 + + +# ═══ C-2 ═════════════════════════════════════════════════════════════════════ + +def _restored_state(qty=10): + st = DayTradingState() + st.positions[CODE] = { + "stock_name": "T", "strategy": "day_trade", "entry_price": 10_000.0, "stop_loss": 9_800.0, + "target_price": 10_300.0, "quantity": qty, "highest_price": 10_000.0, + "entry_time": datetime.combine(pm._today_kst(), datetime.min.time()).replace(hour=9, minute=10), + "reason": "", "order_id": "", "restored_from_db": True, + } + return st + + +def _hold(hldg, sellable): + async def _f(): + return {CODE: {"hldg": hldg, "sellable": sellable}} + return _f + + +def _rows(rows): + async def _f(): + return rows + return _f + + +@pytest.mark.asyncio +async def test_c2_sellable_below_hldg_holds_restore(): + st = _restored_state(10) + out = await st.verify_restored_today_positions_v3( + session_factory=lambda: _Session([]), holdings_fetcher=_hold(10, 0), today_rows_fetcher=_rows([])) + assert CODE in out["held"] and CODE not in st.positions + + +@pytest.mark.asyncio +async def test_c2_pending_dt_sell_holds_restore(): + st = _restored_state(10) + out = await st.verify_restored_today_positions_v3( + session_factory=lambda: _Session([]), holdings_fetcher=_raise("PENDING 보류 후 잔고 조회 불필요"), + today_rows_fetcher=_rows([_th("SELL", "PENDING", 10)])) + assert out["held"] == [CODE] and CODE not in st.positions + + +@pytest.mark.asyncio +async def test_c2_normal_restore_kept(): + st = _restored_state(10) + out = await st.verify_restored_today_positions_v3( + session_factory=lambda: _Session([]), holdings_fetcher=_hold(10, 10), + today_rows_fetcher=_rows([_th("BUY", "COMPLETED", 10)])) + assert out["held"] == [] and CODE in st.positions + + +@pytest.mark.asyncio +async def test_c2_ledger_failure_holds_when_orphan_detect_enabled(monkeypatch): + monkeypatch.setattr(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", True) + st = _restored_state(10) + out = await st.verify_restored_today_positions_v3( + session_factory=lambda: _Session([]), holdings_fetcher=_raise("보류 후 잔고 조회 불필요"), + today_rows_fetcher=_raise_plain()) + assert out["held"] == [CODE] + + +def _raise_plain(): + async def _f(): + raise RuntimeError("db down") + return _f + + +@pytest.mark.asyncio +async def test_c2_intraday_restore_uses_sellable(monkeypatch): + row = SimpleNamespace(stock_code=CODE, quantity=10) + monkeypatch.setattr(day_trading_state, "positions", {}) + n = await es._restore_lost_today_positions_v2_impl([row], {CODE: {"hldg": 10, "sellable": 0}}, {}) + assert n == 0 and CODE not in day_trading_state.positions + + +# ═══ C-3 ═════════════════════════════════════════════════════════════════════ + +TOTAL = 100_000_000.0 +CASH = 50_000_000.0 + + +def _at(hh, mm): + fake_now = datetime.now(ens._KST).replace(hour=hh, minute=mm, second=0) + + class _DT(datetime): + @classmethod + def now(cls, tz=None): + return fake_now if tz else fake_now.replace(tzinfo=None) + return _DT + + +class _Adp: + fallback_used = False + daytrading_enabled = True + trading_mode = "normal" + + +async def _scan(monkeypatch, daily_pnl): + captured: list = [] + + async def fake_try_enter(code, name, total_assets, available_cash, is_lunch_break=False): + captured.append(code) + return None + + monkeypatch.setattr(day_trading_state, "positions", {}) + monkeypatch.setattr(day_trading_state, "closed_today", set()) + monkeypatch.setattr(day_trading_state, "last_total_assets", 0.0) # 재기동 직후 + monkeypatch.setattr(day_trading_state, "daily_pnl", daily_pnl) + monkeypatch.setattr(day_trading_state, "last_reset_date", pm._today_kst()) # 날짜 리셋 방지 + monkeypatch.setattr(day_trading_state, "loss_streak_today", 0, raising=False) + monkeypatch.setattr(ens, "_non_dt_held_db", set()) + monkeypatch.setattr(ens, "_kis_holdings_snapshot", {}) + monkeypatch.setattr(ens, "_kis_holdings_verified", True) + monkeypatch.setattr(ens, "datetime", _at(10, 30)) + ens.set_watchlist([{"code": CODE, "name": "삼성"}]) + balance = {"output1": [], "output2": [{"tot_evlu_amt": str(TOTAL), "prvs_rcdl_excc_amt": str(CASH)}]} + try: + with patch.object(ens.mdd_manager, "is_short_term_strategy_enabled", AsyncMock(return_value=True)), \ + patch.object(ens.mdd_manager, "can_open_new_position", AsyncMock(return_value=True)), \ + patch("app.services.adaptive_strategy.get_current_adaptive_params", return_value=_Adp()), \ + patch.object(ens, "is_daytrading_allowed_by_playbook", return_value=True), \ + patch.object(ens, "check_market_regime", AsyncMock(return_value={"regime": "neutral"})), \ + patch.object(ens.korea_investment_api, "get_balance", AsyncMock(return_value=balance)), \ + patch.object(ens.korea_investment_api, "get_buyable_cash", AsyncMock(return_value=CASH)), \ + patch.object(ens, "_prefetch_minute_charts", AsyncMock(return_value=0)), \ + patch.object(ens, "_try_enter", fake_try_enter): + await ens.scan_and_enter() + finally: + ens.set_watchlist([]) + return captured + + +@pytest.mark.asyncio +async def test_c3_loss_limit_rechecked_after_balance(monkeypatch): + from app.services.day_trading_strategy import _get_daily_loss_limit + pnl = TOTAL * _get_daily_loss_limit() * 1.5 # 한도 초과 손실 + assert await _scan(monkeypatch, pnl) == [] + + +@pytest.mark.asyncio +async def test_c3_control_no_loss_reaches_entry(monkeypatch): + assert await _scan(monkeypatch, 0.0) == [CODE] diff --git a/backend/tests/test_daytrading_stale_position_guard.py b/backend/tests/test_daytrading_stale_position_guard.py index cd5cf86..2669b87 100644 --- a/backend/tests/test_daytrading_stale_position_guard.py +++ b/backend/tests/test_daytrading_stale_position_guard.py @@ -230,3 +230,17 @@ async def test_exit_cycle_invokes_stale_guard(): assert order == ["guard"], f"안전장치가 먼저 실행되어야 함: {order}" assert len(out) == 1 and out[0]["stock_code"] == "064400" + + +@pytest.fixture(autouse=True) +def _no_live_redis(): + """[C-4 r9] close_stale_positions → save_daily_state 가 app.core.redis 에 실접속하던 누수 차단. + + 단독 실행 시 앞선 파일의 Redis 목킹이 없어 localhost:6379 로 나가 4건이 실패했다(§4.1 순서 의존). + 이 파일은 저장 호출 여부만 볼 뿐 값은 검증하지 않으므로 no-op 기록형으로 덮는다. + """ + import app.core.redis as _r + + with patch.object(_r, "setex_json", AsyncMock(return_value=True)) as _set, \ + patch.object(_r, "get_json", AsyncMock(return_value=None)) as _get: + yield SimpleNamespace(setex_json=_set, get_json=_get) diff --git a/backend/tests/test_dt_restore_verify_r3_20261009.py b/backend/tests/test_dt_restore_verify_r3_20261009.py index 21297d9..f6263ba 100644 --- a/backend/tests/test_dt_restore_verify_r3_20261009.py +++ b/backend/tests/test_dt_restore_verify_r3_20261009.py @@ -218,7 +218,7 @@ def test_restore_from_db_signature_unchanged_and_scheduler_wires_verify(): from app.services.daytrading import scheduler as sch src = inspect.getsource(sch) i_restore = src.index("restore_from_db(db)") - i_verify = src.index("verify_restored_today_positions_v2()") + i_verify = src.index("verify_restored_today_positions_v3()") # [C-2 r9] v2 → v3 (v3 가 v2 를 감쌈) i_stale = src.index('close_stale_positions(), "기동 시') assert i_restore < i_verify < i_stale diff --git a/backend/tests/test_dt_safety_fixes_20261002.py b/backend/tests/test_dt_safety_fixes_20261002.py index 0a4fb5b..3f58efc 100644 --- a/backend/tests/test_dt_safety_fixes_20261002.py +++ b/backend/tests/test_dt_safety_fixes_20261002.py @@ -1256,6 +1256,9 @@ async def test_h2_unresolved_unknown_caps_resell_by_sellable(monkeypatch): monkeypatch.setattr(te, "safe_place_order", _order) monkeypatch.setattr(es, "_finalize_exit", AsyncMock(return_value={"ok": 1})) monkeypatch.setattr(es.korea_investment_api, "get_todays_orders", AsyncMock(return_value=[])) + # [C-1 r9] 재시도 판정은 get_todays_orders_v2(실패=None) + DT 원장 상한을 쓴다(목킹 보정) + monkeypatch.setattr(es.korea_investment_api, "get_todays_orders_v2", AsyncMock(return_value=[])) + monkeypatch.setattr(es, "_fetch_dt_ledger_net_qty_impl", AsyncMock(return_value=10)) # 보유 30, 주문가능 26, 비DT(스윙) 20 → DT 매도가능 6 (> 10−10=0 → 미접수) monkeypatch.setattr(es.korea_investment_api, "get_balance", _bal_seq("222222", (30, 26))) monkeypatch.setattr("app.core.database.AsyncSessionLocal", @@ -1530,7 +1533,11 @@ async def test_r5_stale_row_without_holdings_drop_is_not_acceptance(monkeypatch) rows = [{"pdno": "064400", "sll_buy_dvsn_cd": "01", "ord_qty": "4", "tot_ccld_qty": "4", "odno": "0000555"}] - with patch.object(es.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)): + # [C-1 r9] 재시도 판정은 get_todays_orders_v2 + DT 원장 상한(목킹 보정). 행에 주문시각이 없어 + # 등록 이후임을 입증하지 못하므로 v3 도 접수로 보지 않는다. + with patch.object(es.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)), \ + patch.object(es.korea_investment_api, "get_todays_orders_v2", AsyncMock(return_value=rows)), \ + patch.object(es, "_fetch_dt_ledger_net_qty_impl", AsyncMock(return_value=4)): await _run_stale(2, _stale_env(_order, bal=_bal_seq("064400", (4, 4)))) assert len(calls) == 2, "잔고 감소 없는 주문내역 행을 접수로 오인해 재매도를 막았다" diff --git a/backend/tests/test_exec_r9_20261012.py b/backend/tests/test_exec_r9_20261012.py new file mode 100644 index 0000000..e2be359 --- /dev/null +++ b/backend/tests/test_exec_r9_20261012.py @@ -0,0 +1,216 @@ +"""하드닝 R9 그룹 A — 집행·스케줄러 회귀 테스트 (2026-10-12). + +A-1 늦게 확정된 0체결·부분체결 스윙 BUY 의 ActiveTrade 원복(fill_sweep) + 직후 확인의 레지스트리 재등록 +A-2 는 test_swing_scheduler_r9_20261012.py + +목: KIS 체결조회는 테스트별 지정 응답만 가진 SimpleNamespace(다른 속성 접근은 AttributeError), +db_worker 큐 함수는 호출 기록용 AsyncMock. 실 KIS·Redis·DB 호출 없음. +""" +import asyncio +from datetime import datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import app.services.trade_executor as te +import app.services.swing.fill_sweep as fs +import app.services.db_worker as dbw +import app.services.korea_investment_api as kia + +_real_sleep = asyncio.sleep + + +@pytest.fixture(autouse=True) +def _queue(monkeypatch): + rec = SimpleNamespace( + close=AsyncMock(), upsert=AsyncMock(), th=AsyncMock(), th_oid=AsyncMock(), + ) + monkeypatch.setattr(dbw, "enqueue_close_active_trade", rec.close) + monkeypatch.setattr(dbw, "enqueue_active_trade_upsert", rec.upsert) + monkeypatch.setattr(dbw, "enqueue_update_trade_history", rec.th) + monkeypatch.setattr(dbw, "enqueue_update_trade_history_by_order_id", rec.th_oid) + monkeypatch.setattr(te, "_buy_entry_rollbacks", {}) + monkeypatch.setattr(te, "_entry_fill_adjusted_orders", {}) + monkeypatch.setattr(fs, "_queued_ids", set()) + monkeypatch.setattr(fs, "_attempts", {}) + monkeypatch.setattr(fs, "_auction_fill_pending_v2", lambda *a, **k: False) + yield rec + + +def _th_row(**kw): + base = dict( + id=101, stock_code="005930", order_id="0000012345", order_type="BUY", + order_quantity=10, order_price=70000.0, total_amount=700000.0, + strategy_type="swing", entry_reason="평균회귀 RSI 반등", status="COMPLETED", + created_at=datetime.now() - timedelta(minutes=10), net_profit_loss=None, commission=None, + ) + base.update(kw) + return SimpleNamespace(**base) + + +def _at_row(**kw): + base = dict(id=7, strategy_name="mean_reversion", entry_reason="평균회귀 RSI 반등", + quantity=10, status="active") + base.update(kw) + return SimpleNamespace(**base) + + +def _db_with(rows): + res = MagicMock() + res.scalars.return_value.all.return_value = rows + db = MagicMock() + db.execute = AsyncMock(return_value=res) + return db + + +def _kis(monkeypatch, info): + api = SimpleNamespace(get_order_execution=AsyncMock(return_value=info)) + monkeypatch.setattr(kia, "korea_investment_api", api) + return api + + +def _active(monkeypatch, rows): + monkeypatch.setattr(fs, "_load_active_rows_for_fill_v2", AsyncMock(return_value=rows)) + + +# ═══════════════════════ A-1 fill_sweep ════════════════════════════════ + +@pytest.mark.asyncio +async def test_late_zero_fill_cancels_and_closes_active_trade_from_registry(monkeypatch, _queue): + te.register_buy_entry_rollback_v2("0000012345", "005930", "mean_reversion", 10, False) + _kis(monkeypatch, {"ok": True, "found": True, "filled_qty": 0, "filled_price": 0, "remaining_qty": 0}) + _active(monkeypatch, [_at_row()]) + n = await fs.sweep_unconfirmed_fills_v2(_db_with([_th_row()])) + assert n == 1 + _queue.th.assert_awaited_once_with(101, {"status": "CANCELLED", "executed_quantity": 0}) + _queue.close.assert_awaited_once_with("005930", "mean_reversion") + assert te._buy_entry_rollbacks == {} + + +@pytest.mark.asyncio +async def test_late_zero_fill_after_restart_reconstructs_from_trade_history(monkeypatch, _queue): + # 레지스트리 비어 있음(재기동) — TH entry_reason 으로 active 행 확정 + _kis(monkeypatch, {"ok": True, "found": True, "filled_qty": 0, "filled_price": 0, + "remaining_qty": None, "cancelled": True}) + _active(monkeypatch, [_at_row(), _at_row(id=3, strategy_name="synced", entry_reason="동기화 유입")]) + await fs.sweep_unconfirmed_fills_v2(_db_with([_th_row()])) + _queue.close.assert_awaited_once_with("005930", "mean_reversion") + + +@pytest.mark.asyncio +async def test_zero_fill_with_live_remainder_waits(monkeypatch, _queue): + _kis(monkeypatch, {"ok": True, "found": True, "filled_qty": 0, "filled_price": 0, "remaining_qty": 10}) + _active(monkeypatch, [_at_row()]) + assert await fs.sweep_unconfirmed_fills_v2(_db_with([_th_row()])) == 0 + _queue.th.assert_not_awaited() + _queue.close.assert_not_awaited() + assert 101 not in fs._queued_ids + + +@pytest.mark.asyncio +async def test_late_partial_fill_resizes_before_entry_adjust(monkeypatch, _queue): + te.register_buy_entry_rollback_v2("0000012345", "005930", "mean_reversion", 10, False) + _kis(monkeypatch, {"ok": True, "found": True, "filled_qty": 4, "filled_price": 69900.0, "remaining_qty": 0}) + _active(monkeypatch, [_at_row()]) + await fs.sweep_unconfirmed_fills_v2(_db_with([_th_row()])) + payloads = [c.args[0] for c in _queue.upsert.await_args_list] + assert payloads[0] == {"stock_code": "005930", "strategy_name": "mean_reversion", "quantity": 4} + assert "entry_fill_adjust" in payloads[1] and payloads[1]["strategy_name"] == "mean_reversion" + _queue.close.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_late_partial_fill_after_restart_reconstructs(monkeypatch, _queue): + _kis(monkeypatch, {"ok": True, "found": True, "filled_qty": 6, "filled_price": 70000.0, "remaining_qty": 0}) + _active(monkeypatch, [_at_row()]) + await fs.sweep_unconfirmed_fills_v2(_db_with([_th_row()])) + assert _queue.upsert.await_args_list[0].args[0]["quantity"] == 6 + + +@pytest.mark.asyncio +async def test_rollback_is_idempotent_across_sweeps(monkeypatch, _queue): + te.register_buy_entry_rollback_v2("0000012345", "005930", "mean_reversion", 10, False) + _kis(monkeypatch, {"ok": True, "found": True, "filled_qty": 0, "filled_price": 0, "remaining_qty": 0}) + _active(monkeypatch, [_at_row()]) + db = _db_with([_th_row()]) + await fs.sweep_unconfirmed_fills_v2(db) + await fs.sweep_unconfirmed_fills_v2(db) # 워커 반영 전 같은 행이 다시 조회돼도 + assert _queue.close.await_count == 1 + assert _queue.th.await_count == 1 + + +def test_reconstruct_already_resized_is_noop(): + rb, why = fs.reconstruct_buy_rollback_v2(_th_row(), [_at_row(quantity=4)], 4) + assert rb is None and why == "already" + + +def test_reconstruct_accumulate_and_ambiguous(): + rb, _ = fs.reconstruct_buy_rollback_v2(_th_row(), [_at_row(quantity=25)], 4) + assert rb["accumulate"] is True + rb, why = fs.reconstruct_buy_rollback_v2(_th_row(), [_at_row(quantity=7)], 4) + assert rb is None and "역산" in why + rb, _ = fs.reconstruct_buy_rollback_v2(_th_row(), [], 0) + assert rb is None + + +@pytest.mark.asyncio +async def test_accumulate_partial_requests_manual_not_resize(monkeypatch, _queue): + monkeypatch.setattr(te, "_notify_buy_ledger_v2", lambda m: None) + _kis(monkeypatch, {"ok": True, "found": True, "filled_qty": 4, "filled_price": 70000.0, "remaining_qty": 0}) + _active(monkeypatch, [_at_row(quantity=25)]) + await fs.sweep_unconfirmed_fills_v2(_db_with([_th_row()])) + assert all("quantity" not in c.args[0] for c in _queue.upsert.await_args_list) + + +@pytest.mark.asyncio +async def test_dt_or_manual_buy_rows_not_rolled_back(monkeypatch, _queue): + _kis(monkeypatch, {"ok": True, "found": True, "filled_qty": 0, "filled_price": 0, "remaining_qty": 0}) + _active(monkeypatch, [_at_row()]) + await fs.sweep_unconfirmed_fills_v2(_db_with([_th_row(strategy_type="manual")])) + _queue.close.assert_not_awaited() + _queue.th.assert_not_awaited() + + +# ═══════════════════════ A-1 직후 확인 재등록 ═══════════════════════════ + +async def _fast_sleep(_s): + await _real_sleep(0) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("info", [ + {"ok": True, "found": True, "filled_qty": 0, "filled_price": 0, "remaining_qty": 10}, + {"ok": True, "found": True, "filled_qty": 3, "filled_price": 70000.0, "remaining_qty": 7}, +]) +async def test_verify_restores_registry_when_not_terminal(monkeypatch, _queue, info): + monkeypatch.setattr(te.asyncio, "sleep", _fast_sleep) + monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock()) + monkeypatch.setattr(te, "korea_investment_api", + SimpleNamespace(get_order_execution=AsyncMock(return_value=info))) + te.register_buy_entry_rollback_v2("0000012345", "005930", "mean_reversion", 10, False) + await te._verify_and_sync_execution_v2( + "0000012345", "005930", "삼성전자", 10, 70000.0, "BUY", True, "KRX", 0, delay=0, + ) + rb = te._pop_buy_entry_rollback_v2("0000012345", "005930") + assert rb and rb["strategy_name"] == "mean_reversion" and rb["quantity"] == 10 + _queue.close.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_verify_terminal_zero_fill_consumes_registry(monkeypatch, _queue): + monkeypatch.setattr(te.asyncio, "sleep", _fast_sleep) + monkeypatch.setattr(te, "_delayed_sync_portfolio", AsyncMock()) + monkeypatch.setattr(te, "korea_investment_api", SimpleNamespace(get_order_execution=AsyncMock( + return_value={"ok": True, "found": True, "filled_qty": 0, "filled_price": 0, "remaining_qty": 0}))) + te.register_buy_entry_rollback_v2("0000012345", "005930", "mean_reversion", 10, False) + await te._verify_and_sync_execution_v2( + "0000012345", "005930", "삼성전자", 10, 70000.0, "BUY", True, "KRX", 0, delay=0, + ) + _queue.close.assert_awaited_once_with("005930", "mean_reversion") + assert te._buy_entry_rollbacks == {} + + + + +from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지) diff --git a/backend/tests/test_guard_bypass_endpoints_20261002.py b/backend/tests/test_guard_bypass_endpoints_20261002.py index 3b77f8a..261787c 100644 --- a/backend/tests/test_guard_bypass_endpoints_20261002.py +++ b/backend/tests/test_guard_bypass_endpoints_20261002.py @@ -263,6 +263,7 @@ def manual_buy_env(monkeypatch): k = mb.korea_investment_api monkeypatch.setattr(k, "get_current_price", AsyncMock(return_value={"output": {"stck_prpr": str(int(px)), "hts_kor_isnm": "삼성전자"}})) monkeypatch.setattr(k, "get_balance", AsyncMock(return_value={"output2": [{"tot_evlu_amt": str(total), "dnca_tot_amt": "-1"}]})) + monkeypatch.setattr(k, "get_balance_all_v2", AsyncMock(return_value={"output2": [{"tot_evlu_amt": str(total), "dnca_tot_amt": "-1"}]})) # [R9] 연속조회 목 보정 — 같은 목을 공유 monkeypatch.setattr(k, "get_buyable_cash", AsyncMock(return_value=cash)) monkeypatch.setattr(mb.loss_tracker, "is_banned_safe", AsyncMock(return_value=False)) monkeypatch.setattr(mb.mdd_manager, "can_open_new_position", AsyncMock(return_value=True)) @@ -329,6 +330,7 @@ async def test_h4_manual_buy_rejects_on_balance_failure(manual_buy_env, monkeypa async def _fail(*a, **k): raise RuntimeError("KIS down") monkeypatch.setattr(e.k, "get_balance", _fail) + monkeypatch.setattr(e.k, "get_balance_all_v2", _fail) # [R9] 연속조회 목 보정 — 같은 목을 공유 with pytest.raises(HTTPException) as ei: await e.mb._buy_stock_impl(e.mb.BuyRequest(stock_code="005930", quantity=1), "005930", _FakeDB([None, 0, []])) assert ei.value.status_code == 502 diff --git a/backend/tests/test_kis_breaker_orders_r4.py b/backend/tests/test_kis_breaker_orders_r4.py index 92df3b4..cf847da 100644 --- a/backend/tests/test_kis_breaker_orders_r4.py +++ b/backend/tests/test_kis_breaker_orders_r4.py @@ -6,7 +6,8 @@ from app.services import korea_investment_api as kia from app.services.utils import CircuitBreakerState ORDER_EP = "/uapi/domestic-stock/v1/trading/order-cash" -QUOTE_EP = "/uapi/domestic-stock/v1/quotations/inquire-price" +# [9회차 D-2] inquire-price 는 청산 경로 조회라 게이트 면제됨 → 차단 예시는 비면제 조회(일봉)로 +QUOTE_EP = "/uapi/domestic-stock/v1/quotations/inquire-daily-price" class _Resp: diff --git a/backend/tests/test_kis_layer_r9_20261012.py b/backend/tests/test_kis_layer_r9_20261012.py new file mode 100644 index 0000000..375d16e --- /dev/null +++ b/backend/tests/test_kis_layer_r9_20261012.py @@ -0,0 +1,380 @@ +"""9회차 D 그룹(KIS 계층) 회귀 테스트. + +D-1 get_balance_all_v2 잔고 연속조회 / D-2 청산 경로 조회의 서킷브레이커 게이트 면제 / +D-3 get_todays_orders_v2 실패=None. +목킹은 공통 종단(_make_request / http client)에 건다. 실 KIS 호출 0건. +""" +import asyncio + +import pytest + +from app.core.config import settings +from app.services import korea_investment_api as kia +from app.services.utils import CircuitBreakerState + +BAL_EP = "/uapi/domestic-stock/v1/trading/inquire-balance" +PRICE_EP = "/uapi/domestic-stock/v1/quotations/inquire-price" +CCLD_EP = "/uapi/domestic-stock/v1/trading/inquire-daily-ccld" +DAILY_EP = "/uapi/domestic-stock/v1/quotations/inquire-daily-price" + +# [R9 B 그룹 목킹 보정] conftest 가 get_balance_all_v2·get_todays_orders_v2 를 클래스 단위로 차단한다. +# 이 파일은 그 메서드 자체를 검증하므로 import 시점 원본을 테스트마다 되돌린다(test_infra_security_round2 +# 와 같은 패턴). 내부 전송(_make_request)은 각 테스트가 목킹하고 토큰·소켓 차단은 그대로 유효하다. +_ORIG_GET_BALANCE_ALL_V2 = kia.KoreaInvestmentAPI.__dict__["get_balance_all_v2"] +_ORIG_GET_TODAYS_ORDERS_V2 = kia.KoreaInvestmentAPI.__dict__["get_todays_orders_v2"] + + +@pytest.fixture(autouse=True) +def _restore_methods_under_test(monkeypatch): + monkeypatch.setattr(kia.KoreaInvestmentAPI, "get_balance_all_v2", _ORIG_GET_BALANCE_ALL_V2) + monkeypatch.setattr(kia.KoreaInvestmentAPI, "get_todays_orders_v2", _ORIG_GET_TODAYS_ORDERS_V2) + + +def _bare_api(): + api = kia.KoreaInvestmentAPI.__new__(kia.KoreaInvestmentAPI) + api.base_url = "https://openapivts.example" + api.account_number = "1234567801" + return api + + +def _row(code): + return {"pdno": code, "hldg_qty": "1"} + + +def _page(codes, nk="", fk="", rt="0"): + return {"rt_cd": rt, "msg_cd": "M", "msg1": "ok", "output1": [_row(c) for c in codes], + "output2": [{"tot_evlu_amt": "1000"}], "ctx_area_fk100": fk, "ctx_area_nk100": nk} + + +class _Seq: + """_make_request 대체 — 준비된 (result, headers) 또는 예외를 순서대로. 소진 시 예외.""" + + def __init__(self, items): + self.items = list(items) + self.calls = [] + + async def __call__(self, method, endpoint, tr_id, extra_headers=None, **kwargs): + self.calls.append({"endpoint": endpoint, "extra_headers": extra_headers, + "params": kwargs.get("params")}) + if not self.items: + raise AssertionError("unexpected extra _make_request call") + it = self.items.pop(0) + if isinstance(it, BaseException): + raise it + return it + + +# ── D-1 ────────────────────────────────────────────────────────────────── +@pytest.mark.asyncio +async def test_balance_single_page(monkeypatch): + api = _bare_api() + seq = _Seq([(_page(["A", "B"]), {"tr_cont": "D"})]) + monkeypatch.setattr(api, "_make_request", seq, raising=False) + out = await api.get_balance_all_v2() + assert [r["pdno"] for r in out["output1"]] == ["A", "B"] + assert out["_pages"] == 1 and "_partial" not in out + assert out["output2"] == [{"tot_evlu_amt": "1000"}] + assert len(seq.calls) == 1 and seq.calls[0]["endpoint"] == BAL_EP + + +@pytest.mark.asyncio +async def test_balance_three_pages_merge_dedupe(monkeypatch): + api = _bare_api() + p1 = _page(["A", "B"], nk="N1", fk="F1") + p2 = _page(["B", "C"], nk="N2", fk="F2") + p2["output2"] = [{"tot_evlu_amt": "999"}] + p3 = _page(["D"]) + seq = _Seq([(p1, {"tr_cont": "F"}), (p2, {"tr_cont": "M"}), (p3, {"tr_cont": "D"})]) + monkeypatch.setattr(api, "_make_request", seq, raising=False) + out = await api.get_balance_all_v2() + assert [r["pdno"] for r in out["output1"]] == ["A", "B", "C", "D"] + assert out["_pages"] == 3 and "_partial" not in out + assert out["output2"] == [{"tot_evlu_amt": "1000"}] # 첫 페이지 것 + assert seq.calls[0]["extra_headers"] is None + assert seq.calls[1]["extra_headers"] == {"tr_cont": "N"} + assert seq.calls[1]["params"]["CTX_AREA_NK100"] == "N1" + assert seq.calls[1]["params"]["CTX_AREA_FK100"] == "F1" + assert seq.calls[2]["params"]["CTX_AREA_NK100"] == "N2" + + +@pytest.mark.asyncio +async def test_balance_second_page_exception_partial(monkeypatch): + api = _bare_api() + seq = _Seq([(_page(["A"], nk="N1"), {"tr_cont": "F"}), RuntimeError("boom")]) + monkeypatch.setattr(api, "_make_request", seq, raising=False) + out = await api.get_balance_all_v2() + assert out["_partial"] is True and out["_pages"] == 1 + assert [r["pdno"] for r in out["output1"]] == ["A"] + + +@pytest.mark.asyncio +async def test_balance_second_page_rt_cd_fail_partial(monkeypatch): + api = _bare_api() + seq = _Seq([(_page(["A"], nk="N1"), {"tr_cont": "F"}), (_page([], rt="1"), {})]) + monkeypatch.setattr(api, "_make_request", seq, raising=False) + out = await api.get_balance_all_v2() + assert out["_partial"] is True and out["_pages"] == 1 + + +@pytest.mark.asyncio +async def test_balance_missing_ctx_is_partial(monkeypatch): + api = _bare_api() + seq = _Seq([(_page(["A"], nk=""), {"tr_cont": "M"})]) + monkeypatch.setattr(api, "_make_request", seq, raising=False) + out = await api.get_balance_all_v2() + assert out["_partial"] is True and len(seq.calls) == 1 + + +@pytest.mark.asyncio +async def test_balance_max_pages_cap(monkeypatch): + api = _bare_api() + cap = settings.KIS_BALANCE_MAX_PAGES + items = [(_page([f"S{i}"], nk=f"N{i}"), {"tr_cont": "M"}) for i in range(cap + 3)] + seq = _Seq(items) + monkeypatch.setattr(api, "_make_request", seq, raising=False) + out = await api.get_balance_all_v2() + assert len(seq.calls) == cap + assert out["_pages"] == cap and out["_partial"] is True + assert len(out["output1"]) == cap + + +@pytest.mark.asyncio +async def test_balance_first_page_failure_same_as_get_balance(monkeypatch): + api = _bare_api() + bad = _page([], rt="1") + monkeypatch.setattr(api, "_make_request", _Seq([(bad, {"tr_cont": "F"})]), raising=False) + out = await api.get_balance_all_v2() + assert out is bad and "_pages" not in out + + monkeypatch.setattr(api, "_make_request", _Seq([RuntimeError("down")]), raising=False) + with pytest.raises(RuntimeError): + await api.get_balance_all_v2() + + +@pytest.mark.asyncio +async def test_balance_params_match_get_balance(monkeypatch): + """첫 페이지 요청 파라미터가 기존 get_balance() 와 동일해야 한다(get_balance 는 conftest 가 차단 → 고정값 비교).""" + api = _bare_api() + seq = _Seq([(_page(["A"]), {})]) + monkeypatch.setattr(api, "_make_request", seq, raising=False) + await api.get_balance_all_v2() + assert seq.calls[0]["params"] == { + "CANO": "12345678", "ACNT_PRDT_CD": "01", "AFHR_FLPR_YN": "N", "OFL_YN": "", + "INQR_DVSN": "02", "UNPR_DVSN": "01", "FUND_STTL_ICLD_YN": "N", + "FNCG_AMT_AUTO_RDPT_YN": "N", "PRCS_DVSN": "01", "CTX_AREA_FK100": "", "CTX_AREA_NK100": "", + } + + +@pytest.mark.asyncio +async def test_balance_unmocked_hits_conftest_block(monkeypatch): + """목킹 없이 호출하면 conftest 의 _get_access_token 차단(BaseException)이 그대로 올라와야 한다.""" + api, client = _net_api(monkeypatch, _Resp(200, {"rt_cd": "0"}), patch_token=False) + api.account_number = "1234567801" + with pytest.raises(BaseException) as ei: + await api.get_balance_all_v2() + assert type(ei.value).__name__ == "KISCallBlocked" + assert client.calls == [] + + +# ── D-2 ────────────────────────────────────────────────────────────────── +class _Resp: + def __init__(self, status=200, body=None): + self.status_code, self._b, self.headers, self.text = status, body or {}, {}, "" + + def json(self): + return self._b + + +class _Client: + def __init__(self, resp): + self.resp, self.calls = resp, [] + + async def request(self, method, url, **k): + self.calls.append(url) + if isinstance(self.resp, BaseException): + raise self.resp + return self.resp + + +def _net_api(monkeypatch, resp, patch_token=True): + api = kia.KoreaInvestmentAPI.__new__(kia.KoreaInvestmentAPI) + api.circuit_breaker = kia.CircuitBreaker(failure_threshold=2, recovery_timeout=120) + api.monitor = kia.APICallMonitor() + api.semaphore = asyncio.Semaphore(2) + api._throttle_lock = asyncio.Lock() + api.last_request_time = None + api.min_request_interval = 0 + api.max_retries = 0 + api.retry_delay = 0 + api.retry_backoff = 1 + api.timeout = 1 + api.app_key = api.app_secret = "x" + api.base_url = "http://x" + client = _Client(resp) + + async def _tok(*a, **k): + return "tok" + + async def _cl(): + return client + if patch_token: + monkeypatch.setattr(api, "_get_access_token", _tok, raising=False) + monkeypatch.setattr(api, "_get_http_client", _cl, raising=False) + return api, client + + +def _open(api): + for _ in range(api.circuit_breaker.failure_threshold): + api.circuit_breaker.record_failure() + assert api.circuit_breaker.state == CircuitBreakerState.OPEN + + +@pytest.mark.asyncio +@pytest.mark.parametrize("ep", [BAL_EP, PRICE_EP, CCLD_EP]) +async def test_exempt_reads_pass_gate_when_open(monkeypatch, ep): + api, client = _net_api(monkeypatch, _Resp(200, {"rt_cd": "0"})) + _open(api) + out, _ = await api._make_request("GET", ep, "X") + assert out["rt_cd"] == "0" and len(client.calls) == 1 + + +@pytest.mark.asyncio +async def test_non_exempt_read_still_blocked(monkeypatch): + api, client = _net_api(monkeypatch, _Resp(200, {"rt_cd": "0"})) + _open(api) + with pytest.raises(Exception, match="Circuit breaker open"): + await api._make_request("GET", DAILY_EP, "X") + assert client.calls == [] + + +@pytest.mark.asyncio +async def test_exempt_success_while_open_keeps_breaker_open(monkeypatch): + """[D-2b] OPEN 중 면제 성공은 record_success 하지 않는다 → 여전히 OPEN.""" + api, client = _net_api(monkeypatch, _Resp(200, {"rt_cd": "0"})) + _open(api) + cnt = api.circuit_breaker.failure_count + out, _ = await api._make_request("GET", PRICE_EP, "X") + assert out["rt_cd"] == "0" and len(client.calls) == 1 + assert api.circuit_breaker.state == CircuitBreakerState.OPEN + assert api.circuit_breaker.failure_count == cnt + + +@pytest.mark.asyncio +async def test_exempt_failure_while_open_not_recorded(monkeypatch): + """[D-2b] OPEN 중 면제 실패는 last_failure_time·failure_count 를 건드리지 않는다.""" + api, _ = _net_api(monkeypatch, RuntimeError("conn reset")) + _open(api) + api.circuit_breaker.last_failure_time -= 50 # 경과 시간 표시 + lft, cnt = api.circuit_breaker.last_failure_time, api.circuit_breaker.failure_count + with pytest.raises(RuntimeError): + await api._make_request("GET", BAL_EP, "X") + assert api.circuit_breaker.last_failure_time == lft + assert api.circuit_breaker.failure_count == cnt + assert api.circuit_breaker.state == CircuitBreakerState.OPEN + + +@pytest.mark.asyncio +async def test_exempt_does_not_consume_half_open_probe(monkeypatch): + """[D-2b] 복구 대기 경과 후에도 면제 호출은 HALF_OPEN 전이를 일으키지 않고 집계도 없다.""" + api, _ = _net_api(monkeypatch, _Resp(200, {"rt_cd": "0"})) + _open(api) + api.circuit_breaker.last_failure_time -= api.circuit_breaker.recovery_timeout + 1 + await api._make_request("GET", CCLD_EP, "X") + assert api.circuit_breaker.state == CircuitBreakerState.OPEN + + +@pytest.mark.asyncio +async def test_exempt_failure_while_closed_is_counted(monkeypatch): + """[D-2b] CLOSED 중 면제 실패는 기존대로 집계(장애 감지 유지).""" + api, _ = _net_api(monkeypatch, RuntimeError("conn reset")) + assert api.circuit_breaker.state == CircuitBreakerState.CLOSED + with pytest.raises(RuntimeError): + await api._make_request("GET", BAL_EP, "X") + assert api.circuit_breaker.failure_count == 1 + with pytest.raises(RuntimeError): + await api._make_request("GET", PRICE_EP, "X") + assert api.circuit_breaker.state == CircuitBreakerState.OPEN # threshold=2 + + +@pytest.mark.asyncio +async def test_non_exempt_half_open_probe_success_closes(monkeypatch): + """[D-2b] OPEN 해제는 비면제 조회의 HALF_OPEN 프로브 성공으로만.""" + api, client = _net_api(monkeypatch, _Resp(200, {"rt_cd": "0"})) + _open(api) + api.circuit_breaker.last_failure_time -= api.circuit_breaker.recovery_timeout + 1 + await api._make_request("GET", DAILY_EP, "X") + assert len(client.calls) == 1 + assert api.circuit_breaker.state == CircuitBreakerState.CLOSED + + +@pytest.mark.asyncio +async def test_uncounted_flag_does_not_leak_to_next_call(monkeypatch): + """면제 호출 후 컨텍스트 플래그가 초기화돼 이어지는 비면제 실패는 집계된다.""" + api, client = _net_api(monkeypatch, _Resp(200, {"rt_cd": "0"})) + _open(api) + await api._make_request("GET", PRICE_EP, "X") + assert kia._BREAKER_UNCOUNTED.get() is False + api.circuit_breaker.last_failure_time -= api.circuit_breaker.recovery_timeout + 1 + client.resp = RuntimeError("down") + lft = api.circuit_breaker.last_failure_time + with pytest.raises(RuntimeError): + await api._make_request("GET", DAILY_EP, "X") # HALF_OPEN 프로브 실패 → 집계 + assert api.circuit_breaker.last_failure_time > lft + + +def test_exempt_set_excludes_orders(): + assert set(kia.KoreaInvestmentAPI._CIRCUIT_EXEMPT_READ_ENDPOINTS).isdisjoint( + kia.KoreaInvestmentAPI._NON_IDEMPOTENT_ENDPOINTS) + assert kia.DAILY_CCLD_ENDPOINT in kia.KoreaInvestmentAPI._CIRCUIT_EXEMPT_READ_ENDPOINTS + + +# ── D-3 ────────────────────────────────────────────────────────────────── +def _ccld(monkeypatch, api, item): + async def _f(*a, **k): + if isinstance(item, BaseException): + raise item + return item, {} + monkeypatch.setattr(api, "_daily_ccld_fetch_v2", _f, raising=False) + + +@pytest.mark.asyncio +async def test_orders_v2_success_list(monkeypatch): + api = _bare_api() + _ccld(monkeypatch, api, {"rt_cd": "0", "output1": [{"odno": "1"}]}) + assert await api.get_todays_orders_v2("005930") == [{"odno": "1"}] + _ccld(monkeypatch, api, {"rt_cd": "0", "output1": []}) + assert await api.get_todays_orders_v2() == [] + _ccld(monkeypatch, api, {"rt_cd": "0", "output1": {"odno": "2"}}) + assert await api.get_todays_orders_v2() == [{"odno": "2"}] + + +@pytest.mark.asyncio +async def test_orders_v2_rt_cd_fail_none_legacy_empty(monkeypatch): + api = _bare_api() + _ccld(monkeypatch, api, {"rt_cd": "1", "msg_cd": "X"}) + assert await api.get_todays_orders_v2() is None + assert await api._get_todays_orders_impl(strict=False) == [] + + +@pytest.mark.asyncio +async def test_orders_v2_exception_none_legacy_empty(monkeypatch): + api = _bare_api() + _ccld(monkeypatch, api, RuntimeError("timeout")) + assert await api.get_todays_orders_v2() is None + assert await api._get_todays_orders_impl(strict=False) == [] + + +@pytest.mark.asyncio +async def test_orders_v2_unparseable_none(monkeypatch): + api = _bare_api() + _ccld(monkeypatch, api, {"rt_cd": "0", "output1": "garbage"}) + assert await api.get_todays_orders_v2() is None + assert await api._get_todays_orders_impl(strict=False) == ["garbage"] # 기존 동작 불변 + _ccld(monkeypatch, api, ["not-a-dict"]) + assert await api.get_todays_orders_v2() is None + + +def test_legacy_get_todays_orders_delegates_non_strict(): + import inspect + src = inspect.getsource(kia.KoreaInvestmentAPI._get_todays_orders_impl) + assert "None if strict else []" in src diff --git a/backend/tests/test_main_kis_stream_regs_20261012.py b/backend/tests/test_main_kis_stream_regs_20261012.py index 995e40f..ee18c4f 100644 --- a/backend/tests/test_main_kis_stream_regs_20261012.py +++ b/backend/tests/test_main_kis_stream_regs_20261012.py @@ -59,3 +59,68 @@ def test_unknown_registrations_falls_back_to_v2(monkeypatch): monkeypatch.setattr(m, "_kis_stream_last_msg_mono", 100.0) monkeypatch.setattr(m, "_kis_stream_regs_since", None) assert m._kis_stream_health_v3(_app(), now=100.0 + limit + 1, market_open=True, registrations=None)["silent"] is True + + +# ── R9 E-1: 희망 구독 수 기준 v4 ───────────────────────────────────────── +def test_v4_desired_subscribers_zero_regs_silent(monkeypatch): + """구독자는 있는데 등록 0(연결 단절) + 수신 이력 있고 임계 초과 → silent.""" + limit = m._HB_KIS_STREAM_SILENT_SECONDS + monkeypatch.setattr(m, "_kis_stream_last_msg_mono", 100.0) + monkeypatch.setattr(m, "_kis_stream_regs_since", 5.0) + r = m._kis_stream_health_v4(_app(), now=100.0 + limit + 1, market_open=True, registrations=0, desired=3) + assert r["silent"] is True and r["desired"] == 3 + assert m._kis_stream_regs_since is None + + +def test_v4_no_desired_subscribers_is_normal(monkeypatch): + limit = m._HB_KIS_STREAM_SILENT_SECONDS + monkeypatch.setattr(m, "_kis_stream_last_msg_mono", 100.0) + monkeypatch.setattr(m, "_kis_stream_regs_since", None) + r = m._kis_stream_health_v4(_app(), now=100.0 + limit + 1, market_open=True, registrations=0, desired=0) + assert r["silent"] is False + + +def test_v4_regs_transition_resets_regs_since(monkeypatch): + """regs>0 -> 0 -> >0: 0 구간에서 기준점이 리셋되고 재등록 시각이 새 기준점이 된다.""" + limit = m._HB_KIS_STREAM_SILENT_SECONDS + monkeypatch.setattr(m, "_kis_stream_last_msg_mono", 100.0) + monkeypatch.setattr(m, "_kis_stream_regs_since", None) + t0 = 100.0 + limit + 10 + m._kis_stream_health_v4(_app(), now=t0, market_open=True, registrations=5, desired=5) + assert m._kis_stream_regs_since == t0 + m._kis_stream_health_v4(_app(), now=t0 + 1, market_open=True, registrations=0, desired=0) + assert m._kis_stream_regs_since is None + t1 = t0 + 50 + m._kis_stream_health_v4(_app(), now=t1, market_open=True, registrations=5, desired=5) + assert m._kis_stream_regs_since == t1 + + +def test_v4_sentinel_reads_live_sources(monkeypatch): + """registrations/desired 기본 센티널(-1) 이면 실제 조회 함수를 쓴다.""" + limit = m._HB_KIS_STREAM_SILENT_SECONDS + monkeypatch.setattr(m, "_kis_stream_last_msg_mono", 100.0) + monkeypatch.setattr(m, "_kis_stream_regs_since", None) + monkeypatch.setattr(m, "_kis_stream_registrations_v2", lambda: 0) + monkeypatch.setattr(m, "_kis_stream_desired_subscriptions_v2", lambda: 2) + assert m._kis_stream_health_v4(_app(), now=100.0 + limit + 1, market_open=True)["silent"] is True + monkeypatch.setattr(m, "_kis_stream_desired_subscriptions_v2", lambda: 0) + assert m._kis_stream_health_v4(_app(), now=100.0 + limit + 1, market_open=True)["silent"] is False + + +def test_v4_unknown_desired_falls_back_to_v3(monkeypatch): + limit = m._HB_KIS_STREAM_SILENT_SECONDS + monkeypatch.setattr(m, "_kis_stream_last_msg_mono", 100.0) + monkeypatch.setattr(m, "_kis_stream_regs_since", None) + r = m._kis_stream_health_v4(_app(), now=100.0 + limit + 1, market_open=True, registrations=0, desired=None) + assert r["silent"] is False + + +def test_v4_dead_task_none_stream_and_off_hours(monkeypatch): + monkeypatch.setattr(m, "_kis_stream_last_msg_mono", 100.0) + assert m._kis_stream_health_v4(_app(done=True), now=101.0, market_open=True, registrations=0, desired=0)["silent"] is True + + class _NoTask: + state = type("S", (), {})() + assert m._kis_stream_health_v4(_NoTask(), now=101.0, market_open=True, registrations=0, desired=3) is None + limit = m._HB_KIS_STREAM_SILENT_SECONDS + assert m._kis_stream_health_v4(_app(), now=100.0 + limit + 1, market_open=False, registrations=0, desired=3)["silent"] is False diff --git a/backend/tests/test_main_ops_r9_20261012.py b/backend/tests/test_main_ops_r9_20261012.py new file mode 100644 index 0000000..a62f1ae --- /dev/null +++ b/backend/tests/test_main_ops_r9_20261012.py @@ -0,0 +1,111 @@ +"""R9 E-2/E-5/E-6: 첫 하트비트 유예 판정, config 키 선언 동일성, 소규모 수정.""" +import asyncio + +import pytest + +import app.main as m +from app.core import heartbeat as hb +from app.core.config import settings, Settings + + +class _T: + def __init__(self, done=False): + self._d = done + + def done(self): + return self._d + + +def _app(alive=True): + a = type("A", (), {})() + a.state = type("S", (), {})() + for attr in ("swing_task", "dt_task"): + setattr(a.state, attr, _T(not alive)) + return a + + +@pytest.fixture(autouse=True) +def _reset_hb(monkeypatch): + monkeypatch.setattr(m, "_scheduler_tasks", {"swing": "swing_task", "daytrading": "dt_task"}) + hb.reset() + yield + hb.reset() + + +def test_no_first_beat_after_grace_in_market_is_stale(): + over = settings.HB_FIRST_BEAT_GRACE_SEC + 100 + h = m._scheduler_health_v3(_app(), now=1.0e6, market_open=True, uptime_sec=over) + for name in m._HEARTBEAT_WATCHED: + assert h[name]["stale"] is True and h[name]["no_first_beat"] is True + + +def test_no_first_beat_within_grace_is_normal(): + under = settings.HB_FIRST_BEAT_GRACE_SEC - 500 + h = m._scheduler_health_v3(_app(), now=1.0e6, market_open=True, uptime_sec=under) + for name in m._HEARTBEAT_WATCHED: + assert h[name]["stale"] is False and h[name]["no_first_beat"] is False + + +def test_no_first_beat_off_hours_is_normal(): + over = settings.HB_FIRST_BEAT_GRACE_SEC + 100 + h = m._scheduler_health_v3(_app(), now=1.0e6, market_open=False, uptime_sec=over) + assert all(v["stale"] is False for v in h.values()) + + +def test_beat_present_not_flagged_as_no_first_beat(): + hb.beat("swing") + over = settings.HB_FIRST_BEAT_GRACE_SEC + 100 + h = m._scheduler_health_v3(_app(), market_open=True, uptime_sec=over) + assert h["swing"]["no_first_beat"] is False and h["swing"]["stale"] is False + + +def test_v2_signature_and_behavior_unchanged(): + h = m._scheduler_health_v2(_app(), now=1.0e6, market_open=True) + assert "no_first_beat" not in h["swing"] and h["swing"]["stale"] is False + + +# ── E-5: 선언된 config 키 값이 소비처 기본값과 동일 ────────────────────────── +def _mod_default(modname, const): + import importlib + return getattr(importlib.import_module(modname), const) + + +@pytest.mark.parametrize("key,const_mod,const", [ + ("NEWS_EMPTY_ALERT_THRESHOLD", "app.services.news_sentiment", "_EMPTY_ALERT_DEFAULT"), +]) +def test_declared_keys_match_consumer_defaults(key, const_mod, const): + assert key in Settings.model_fields + assert getattr(settings, key) == _mod_default(const_mod, const) + + +@pytest.mark.parametrize("key,expected", [ + ("KIS_WS_MAX_REGISTRATIONS", 41), ("RISK_MAX_DAILY_LOSS_RATIO", 0.03), ("RISK_MAX_CONSECUTIVE_LOSSES", 3), + ("SWING_ADAPTIVE_TRAILING_ENABLED", False), ("MEAN_REVERSION_RULE_MERGE_ENABLED", False), + ("HB_FIRST_BEAT_GRACE_SEC", 600.0), +]) +def test_declared_keys_literal_defaults(key, expected): + assert getattr(settings, key) == expected + + +@pytest.mark.parametrize("key", [ + "KIS_WS_ORIGIN", "KIS_WS_SUBPROTOCOLS", + "BATCH_ANALYSIS_TIMEOUT_SEC", "ZERO_BUY_ALERT_HOURS", +]) +def test_declared_none_keys_preserve_none_semantics(key): + assert key in Settings.model_fields + assert getattr(settings, key) is None + + +def test_cors_origins_stays_undeclared(): + """.env 에 값이 있어 선언하면 CORS 동작이 바뀐다 — 선언 금지 회귀 가드.""" + assert "CORS_ORIGINS" not in Settings.model_fields + + +# ── E-6 ──────────────────────────────────────────────────────────────────── +def test_trailing_stop_uses_resolved_multiplier(monkeypatch): + import app.services.trade_helpers as th + monkeypatch.setattr(th, "resolve_swing_trailing_atr_mult_v2", lambda: 2.0) + import inspect + src = inspect.getsource(th) + assert "high_price - 1.5 * atr" not in src + assert "resolve_swing_trailing_atr_mult_v2() * atr" in src diff --git a/backend/tests/test_mdd_reduction_net_pnl_r4_20261009.py b/backend/tests/test_mdd_reduction_net_pnl_r4_20261009.py index 9849ceb..270e2a5 100644 --- a/backend/tests/test_mdd_reduction_net_pnl_r4_20261009.py +++ b/backend/tests/test_mdd_reduction_net_pnl_r4_20261009.py @@ -69,6 +69,7 @@ async def test_reduction_payload_has_net_pnl(monkeypatch): async def _order(stock_code, order_type, quantity, price=None, **kw): return {"rt_cd": "0", "output": {"ODNO": "O1"}} monkeypatch.setattr(korea_investment_api, "get_balance", _bal) + monkeypatch.setattr(korea_investment_api, "get_balance_all_v2", _bal) # [R9] 연속조회 목 보정 — 같은 목을 공유 monkeypatch.setattr(te, "safe_place_order", _order) # 알림 fire-and-forget 태스크가 다른 테스트 루프로 새지 않게 차단 diff --git a/backend/tests/test_mdd_reduction_retry_20261002.py b/backend/tests/test_mdd_reduction_retry_20261002.py index 5fd2c1e..e604c5a 100644 --- a/backend/tests/test_mdd_reduction_retry_20261002.py +++ b/backend/tests/test_mdd_reduction_retry_20261002.py @@ -96,6 +96,7 @@ def env(monkeypatch): def _make(account): monkeypatch.setattr(korea_investment_api, "get_balance", account.get_balance) + monkeypatch.setattr(korea_investment_api, "get_balance_all_v2", account.get_balance) # [R9] 연속조회 목 보정 — 같은 목을 공유 monkeypatch.setattr(te, "safe_place_order", account.safe_place_order) return account @@ -320,11 +321,13 @@ async def test_c2_balance_error_is_not_empty_holdings(env, bad): import pytest as _pt mp = _pt.MonkeyPatch() mp.setattr(korea, "get_balance", _bad) + mp.setattr(korea, "get_balance_all_v2", _bad) # [R9] 연속조회 목 보정 — 같은 목을 공유 try: assert await m.execute_reduction(_action(m)) == [] assert m._reduction_executed_10pct is False, "오류 응답을 '보유 없음'으로 처리해 레벨 축소가 생략됐다" finally: mp.setattr(korea, "get_balance", good_balance) + mp.setattr(korea, "get_balance_all_v2", good_balance) # [R9] 연속조회 목 보정 — 같은 목을 공유 await m.execute_reduction(_action(m)) assert acct.sells["005930"] == [30] diff --git a/backend/tests/test_realtime_chart_fixes_20261009.py b/backend/tests/test_realtime_chart_fixes_20261009.py index e820b12..73dbb0f 100644 --- a/backend/tests/test_realtime_chart_fixes_20261009.py +++ b/backend/tests/test_realtime_chart_fixes_20261009.py @@ -484,7 +484,8 @@ def test_in_session_without_tick_uses_short_cache_ttl(): with patch("app.services.naver_finance.get_naver_daily_chart", naver), \ patch.object(md, "_fetch_chart_from_db", AsyncMock(return_value=list(bars))), \ patch.object(md, "_merge_recent_daily_bars", AsyncMock(side_effect=lambda c, b: (b, False))), \ - patch.object(md, "_chart_session_state", lambda now: ("20250103", False)): + patch.object(md, "_chart_session_state", lambda now: ("20250103", False)), \ + patch.object(md, "_is_post_close_window", lambda now: False): # R9: 실제 시계 15:30~15:40 창 제거(시계 의존 flake) _run(md.get_chart_data_cached_v2("005930", "D", 200)) assert md.chart_cache["005930__D__200"]["ttl"] == md._CHART_CACHE_TTL diff --git a/backend/tests/test_restart_durability_20260928.py b/backend/tests/test_restart_durability_20260928.py index a27e773..d92a441 100644 --- a/backend/tests/test_restart_durability_20260928.py +++ b/backend/tests/test_restart_durability_20260928.py @@ -563,6 +563,7 @@ async def test_truncated_scan_rotates_start_offset_next_cycle(monkeypatch, caplo monkeypatch.setattr(es.korea_investment_api, "get_balance", AsyncMock( return_value={"output2": [{"tot_evlu_amt": "100000000", "prvs_rcdl_excc_amt": "50000000"}]})) + monkeypatch.setattr(es.korea_investment_api, "get_balance_all_v2", es.korea_investment_api.get_balance) # [R9] 연속조회 목 보정 — 같은 목을 공유 monkeypatch.setattr(es.korea_investment_api, "get_buyable_cash", AsyncMock(return_value=50_000_000.0)) # 벽시계 의존 제거 — 컷오프(15:00) 이후에 돌리면 조기 리턴해 빈 리스트가 된다 @@ -1498,6 +1499,7 @@ async def test_cache_is_cleared_each_cycle_so_signals_are_reevaluated(monkeypatc monkeypatch.setattr(es.korea_investment_api, "get_balance", AsyncMock( return_value={"output2": [{"tot_evlu_amt": "100000000", "prvs_rcdl_excc_amt": "50000000"}]})) + monkeypatch.setattr(es.korea_investment_api, "get_balance_all_v2", es.korea_investment_api.get_balance) # [R9] 연속조회 목 보정 — 같은 목을 공유 monkeypatch.setattr(es.korea_investment_api, "get_buyable_cash", AsyncMock(return_value=50_000_000.0)) diff --git a/backend/tests/test_round5_fixes_20261002.py b/backend/tests/test_round5_fixes_20261002.py index e9e60bc..682c90f 100644 --- a/backend/tests/test_round5_fixes_20261002.py +++ b/backend/tests/test_round5_fixes_20261002.py @@ -163,7 +163,9 @@ def mdd_env(monkeypatch): # 다를 수 있으므로 둘 다 덮는다. import app.services.korea_investment_api as kmod monkeypatch.setattr(kmod.korea_investment_api, "get_balance", acct.get_balance) + monkeypatch.setattr(kmod.korea_investment_api, "get_balance_all_v2", acct.get_balance) # [R9] 연속조회 목 보정 — 같은 목을 공유 monkeypatch.setattr(te.korea_investment_api, "get_balance", acct.get_balance) + monkeypatch.setattr(te.korea_investment_api, "get_balance_all_v2", acct.get_balance) # [R9] 연속조회 목 보정 — 같은 목을 공유 monkeypatch.setattr(te, "safe_place_order", acct.order) m = mm.MDDManager() m.save_to_redis = AsyncMock() @@ -355,3 +357,6 @@ def test_h1_execute_sell_passes_org_no_from_order_response(): import inspect src = inspect.getsource(te._execute_sell) assert "order_org_no=_extract_org_no_v2(order_result)" in src + + +from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: Redis 실접속 방지) diff --git a/backend/tests/test_swing_entry_hardening_r2_20261009.py b/backend/tests/test_swing_entry_hardening_r2_20261009.py index 61f348b..3911092 100644 --- a/backend/tests/test_swing_entry_hardening_r2_20261009.py +++ b/backend/tests/test_swing_entry_hardening_r2_20261009.py @@ -241,3 +241,6 @@ async def test_dual_scan_rejects_when_chart_close_at_min_price(monkeypatch, dual result = await dual_env.es.scan_dual_strategy_signals(_DualDB([dual_env.stock])) assert result == [] dual_env.delegate.assert_not_called() + + +from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지) diff --git a/backend/tests/test_swing_exit_cooldown_r8_20261010.py b/backend/tests/test_swing_exit_cooldown_r8_20261010.py index 2f26c21..d8543eb 100644 --- a/backend/tests/test_swing_exit_cooldown_r8_20261010.py +++ b/backend/tests/test_swing_exit_cooldown_r8_20261010.py @@ -72,6 +72,7 @@ def _env(monkeypatch, *, signal, price=8000, qty=10, kis=(10, 10), remaining_pct api.get_daily_chart = AsyncMock(return_value={"output2": bars}) api.get_current_price = AsyncMock(return_value={"output": {"stck_prpr": str(price)}}) api.get_balance = AsyncMock(side_effect=AssertionError("잔고 조회는 _presell_kis_holding_v2 목으로")) + api.get_balance_all_v2 = api.get_balance # [R9] 연속조회 목 보정 — 같은 목을 공유 monkeypatch.setattr(es, "korea_investment_api", api) sig = {"s": signal} monkeypatch.setattr(es, "check_exit_signal", lambda **kw: sig["s"]) @@ -194,3 +195,6 @@ async def test_bypass_respects_kis_zero_cap(monkeypatch, _reset): _mark_recent_exit(env.es) await env.es.check_and_execute_exit_v2(MagicMock()) assert env.sent == [] + + +from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지) diff --git a/backend/tests/test_swing_exit_hardening_r1_20261009.py b/backend/tests/test_swing_exit_hardening_r1_20261009.py index 0476ddc..05fe792 100644 --- a/backend/tests/test_swing_exit_hardening_r1_20261009.py +++ b/backend/tests/test_swing_exit_hardening_r1_20261009.py @@ -91,6 +91,7 @@ def _env(monkeypatch, *, avg=10000, price, high_vol=True, chart=None, signal=Non api.get_current_price = AsyncMock(return_value={"output": {"stck_prpr": str(price)}}) api.get_balance = AsyncMock(return_value={"rt_cd": "0", "output1": [ {"pdno": "005930", "hldg_qty": "10", "ord_psbl_qty": "10"}]}) + api.get_balance_all_v2 = api.get_balance # [R9] 연속조회 목 보정 — 같은 목을 공유 monkeypatch.setattr(es, "korea_investment_api", api) if signal is not None: monkeypatch.setattr(es, "check_exit_signal", lambda **kw: signal) @@ -346,3 +347,6 @@ async def test_unknown_reconcile_uses_prior_realized_net(monkeypatch): } assert await es._reconcile_unknown_sell_v2("005930", 0, 0) is True assert loss.await_args.kwargs["is_loss"] is False + + +from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지) diff --git a/backend/tests/test_swing_exit_hardening_r1b_20261009.py b/backend/tests/test_swing_exit_hardening_r1b_20261009.py index 6263245..60c903c 100644 --- a/backend/tests/test_swing_exit_hardening_r1b_20261009.py +++ b/backend/tests/test_swing_exit_hardening_r1b_20261009.py @@ -87,6 +87,7 @@ def _env(monkeypatch, *, codes=("005930",), qty=10, price=8000, signal=None, _s = _h if sellable is None else sellable api.get_balance = AsyncMock(return_value={"rt_cd": "0", "output1": [ {"pdno": c, "hldg_qty": str(_h), "ord_psbl_qty": str(_s)} for c in codes]}) + api.get_balance_all_v2 = api.get_balance # [R9] 연속조회 목 보정 — 같은 목을 공유 monkeypatch.setattr(es, "korea_investment_api", api) sig = signal or ExitSignal(action="SELL_ALL", reason="손절 테스트", sell_pct=1.0) monkeypatch.setattr(es, "check_exit_signal", lambda **kw: sig) @@ -241,3 +242,6 @@ async def test_balance_page_near_full_alerts(monkeypatch, _reset): await _cycle(env.es, _db(rows=rows)) assert any(k == "balance_page_near_full" for k, _ in _reset.alerts) assert "balance_page_near_full" in env.es.get_swing_exit_health_v2()["degraded_reasons"] + + +from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지) diff --git a/backend/tests/test_swing_exit_hardening_r2_20261009.py b/backend/tests/test_swing_exit_hardening_r2_20261009.py index 8eea8ce..fafc22c 100644 --- a/backend/tests/test_swing_exit_hardening_r2_20261009.py +++ b/backend/tests/test_swing_exit_hardening_r2_20261009.py @@ -251,3 +251,6 @@ def test_no_bare_create_task_in_swing_exit_service(): src = inspect.getsource(es) assert "create_task(" not in src _ = ExitSignal, MagicMock # import 유지(타 테스트와 동일 헬퍼) + + +from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지) diff --git a/backend/tests/test_swing_exit_hardening_r4_20261009.py b/backend/tests/test_swing_exit_hardening_r4_20261009.py index 7d9dff1..ffe5ff6 100644 --- a/backend/tests/test_swing_exit_hardening_r4_20261009.py +++ b/backend/tests/test_swing_exit_hardening_r4_20261009.py @@ -100,6 +100,7 @@ def _env(monkeypatch, *, codes=("005930",), price=8000, order=None, holdings_exc api.get_current_price = AsyncMock(return_value={"output": {"stck_prpr": str(price)}}) api.get_balance = AsyncMock(return_value={"rt_cd": "0", "output1": [ {"pdno": c, "hldg_qty": "10", "ord_psbl_qty": "10"} for c in codes]}) + api.get_balance_all_v2 = api.get_balance # [R9] 연속조회 목 보정 — 같은 목을 공유 monkeypatch.setattr(es, "korea_investment_api", api) monkeypatch.setattr(es, "check_exit_signal", lambda **kw: ExitSignal( action="SELL_ALL", reason="손절 테스트", sell_pct=1.0)) @@ -301,3 +302,6 @@ def test_threshold_rejects_disable_values(monkeypatch): assert es._exit_alert_threshold_v2("SWING_SELL_REJECT_ALERT_COUNT", 3) == 3 monkeypatch.setattr(es, "settings", SimpleNamespace(SWING_SELL_REJECT_ALERT_COUNT=5)) assert es._exit_alert_threshold_v2("SWING_SELL_REJECT_ALERT_COUNT", 3) == 5 + + +from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지) diff --git a/backend/tests/test_swing_exit_r9_20261012.py b/backend/tests/test_swing_exit_r9_20261012.py new file mode 100644 index 0000000..164324c --- /dev/null +++ b/backend/tests/test_swing_exit_r9_20261012.py @@ -0,0 +1,320 @@ +"""9회차 B 그룹(스윙 청산·MDD) 회귀 테스트 (2026-10-12). + +B-1 잔고 연속조회(get_balance_all_v2)로 2페이지 종목 손절 / _partial 보류 / 완전 응답 미발견 = 미보유 / 예외 보류, + MDD 축소의 불완전 판정. +B-2 결과 불명 매도 대기 중 분할 익절 재발화 금지 · 손절 허용 · 정상 매도 잔고 감소의 불명 체결 오귀속 방지 · + 불명 등록 시 쿨다운 기록. +B-3 불명 대기 TTL(장 마감) · 재매수 시 폐기. +B-3b TTL 폐기 전 KIS 잔고 대조 1회. + +시뮬레이터는 5·6회차 테스트의 상태형 KIS 시뮬레이터(_Sim)를 재사용한다(주문 종단 safe_place_order 목, +체결 확인 종단 기록형 목, 예외 발생형 차단은 conftest). 기대값은 settings.* 참조. +""" +import logging +import time +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.core.config import settings +from app.core.timeutil import KST +from app.services.mean_reversion_strategy import ExitSignal + +# 상태형 시뮬레이터와 autouse 리셋 픽스처(_reset)를 그대로 가져온다 — 이름으로 import 해야 픽스처가 등록된다. +from test_swing_r5_pending_sell_20261002 import _Sim, _reset, UNKNOWN, _partial_signal # noqa: F401 + +CODE = "005930" + + +@pytest.fixture(autouse=True) +def _no_redis_and_alerts(monkeypatch): + """불명 대기 Redis 미러링·텔레그램 경보는 기록형 목(단독 실행 시 실 Redis 접속 방지).""" + import app.services.swing.exit_service as es + monkeypatch.setattr(es, "_persist_unknown_sell_v2", AsyncMock(return_value=True)) + monkeypatch.setattr(es, "_forget_unknown_sell_v2", AsyncMock()) + monkeypatch.setattr(es, "_restore_unknown_sells_v2", AsyncMock(return_value=False)) + alerts = [] + monkeypatch.setattr(es, "_notify_swing_exit_alert_v2", lambda k, c, m: alerts.append((k, m))) + import app.services.trade_executor as te + monkeypatch.setattr(te, "_recent_cycle_buys_v2", lambda: {}) + return alerts + + +def _filler(n): + return [{"pdno": f"9{i:05d}", "hldg_qty": "1", "ord_psbl_qty": "1"} for i in range(n)] + + +def _stop_signal(_remaining): + return ExitSignal("SELL_ALL", "손절", 1.0) + + +# ── B-1 ──────────────────────────────────────────────────────────────────── +@pytest.mark.asyncio +async def test_b1_second_page_stock_stop_loss_executes(monkeypatch): + """보유가 페이지 한도를 넘어 종목이 2페이지에 있어도(완전 응답) 손절 주문이 나간다.""" + sim = _Sim(monkeypatch, swing=10, dt=0, signal=_stop_signal) + rows = int(settings.SWING_BALANCE_FULL_PAGE_ROWS) + + async def _all(): + return {"rt_cd": "0", "_pages": 2, + "output1": _filler(rows) + [{"pdno": CODE, "hldg_qty": str(sim.kis["hldg"]), + "ord_psbl_qty": str(sim.kis["psbl"])}]} + sim.api.get_balance_all_v2 = AsyncMock(side_effect=_all) + await sim.cycles(1) + assert sim.sent == [10] + sim.api.get_balance.assert_not_awaited() # 첫 페이지 전용 조회는 더 이상 쓰지 않는다 + + +@pytest.mark.asyncio +async def test_b1_partial_response_missing_stock_holds(monkeypatch): + import app.services.swing.exit_service as es + monkeypatch.setattr(es.korea_investment_api, "get_balance_all_v2", + AsyncMock(return_value={"rt_cd": "0", "_pages": 2, "_partial": True, + "output1": _filler(3)})) + assert await es._fetch_kis_holding_v3(CODE) is None + + +@pytest.mark.asyncio +async def test_b1_partial_response_found_stock_is_used(monkeypatch): + import app.services.swing.exit_service as es + monkeypatch.setattr(es.korea_investment_api, "get_balance_all_v2", + AsyncMock(return_value={"rt_cd": "0", "_pages": 2, "_partial": True, + "output1": [{"pdno": CODE, "hldg_qty": "7", "ord_psbl_qty": "5"}]})) + assert await es._fetch_kis_holding_v3(CODE) == (7, 5) + + +@pytest.mark.asyncio +async def test_b1_complete_response_missing_is_not_held_even_if_full_page(monkeypatch): + import app.services.swing.exit_service as es + rows = int(settings.SWING_BALANCE_FULL_PAGE_ROWS) + 5 + monkeypatch.setattr(es.korea_investment_api, "get_balance_all_v2", + AsyncMock(return_value={"rt_cd": "0", "_pages": 2, "output1": _filler(rows)})) + assert await es._fetch_kis_holding_v3(CODE) == (0, 0) + + +@pytest.mark.asyncio +async def test_b1_legacy_shape_keeps_full_page_heuristic(monkeypatch): + import app.services.swing.exit_service as es + rows = int(settings.SWING_BALANCE_FULL_PAGE_ROWS) + monkeypatch.setattr(es.korea_investment_api, "get_balance_all_v2", + AsyncMock(return_value={"rt_cd": "0", "output1": _filler(rows)})) + assert await es._fetch_kis_holding_v3(CODE) is None + + +@pytest.mark.asyncio +async def test_b1_exception_holds_and_logs_error(monkeypatch, caplog): + import app.services.swing.exit_service as es + monkeypatch.setattr(es.korea_investment_api, "get_balance_all_v2", + AsyncMock(side_effect=RuntimeError("KIS down"))) + with caplog.at_level(logging.ERROR): + assert await es._fetch_kis_holding_v3(CODE) is None + assert any("매도 보류" in r.message for r in caplog.records if r.levelno >= logging.ERROR) + + +@pytest.mark.asyncio +async def test_b1_rt_cd_error_holds(monkeypatch): + import app.services.swing.exit_service as es + monkeypatch.setattr(es.korea_investment_api, "get_balance_all_v2", + AsyncMock(return_value={"rt_cd": "1", "msg1": "EGW00201"})) + assert await es._fetch_kis_holding_v3(CODE) is None + + +def test_b1_mdd_incomplete_uses_partial_flag(): + from app.services.mdd_manager import _balance_incomplete_v2 + rows = int(settings.SWING_BALANCE_FULL_PAGE_ROWS) + full = _filler(rows) + assert _balance_incomplete_v2({"_pages": 2, "output1": full}, full) is False + assert _balance_incomplete_v2({"_pages": 1, "_partial": True, "output1": full[:2]}, full[:2]) is True + # _pages 없는 구 형태 = 기존 페이지 가득 휴리스틱 + assert _balance_incomplete_v2({"output1": full}, full) is True + assert _balance_incomplete_v2({"output1": full[:1]}, full[:1]) is False + + +@pytest.mark.asyncio +async def test_b1_mdd_reduction_calls_paginated_balance(monkeypatch): + """MDD 축소는 get_balance_all_v2 를 쓴다 — 첫 페이지 전용 get_balance 는 호출하지 않는다.""" + from app.services import mdd_manager as mm + from app.services.korea_investment_api import korea_investment_api + allv2 = AsyncMock(return_value={"rt_cd": "1", "msg1": "fail"}) # 실패 응답 → 축소 보류(주문 0) + legacy = AsyncMock(side_effect=AssertionError("get_balance 호출 금지")) + monkeypatch.setattr(korea_investment_api, "get_balance_all_v2", allv2) + monkeypatch.setattr(korea_investment_api, "get_balance", legacy) + out = await mm.mdd_manager._execute_reduction_impl("REDUCE_AND_PAUSE", 0.3, False, "pause", "k") + assert out == [] + allv2.assert_awaited_once() + legacy.assert_not_awaited() + + +# ── B-2 ──────────────────────────────────────────────────────────────────── +def _open_unknown_sim(monkeypatch, signal, swing=20, responses=None): + """첫 매도는 결과 불명 + 브로커 미체결 잔존(보유 불변·주문가능 감소), 이후 주문은 정상 체결.""" + sim = _Sim(monkeypatch, swing=swing, dt=0, price=11500, signal=signal, + responses=responses or [UNKNOWN], fill=lambda q, n: 0 if n == 0 else q) + orig = sim._order + + async def _order(**kw): + n = len(sim.sent) + r = await orig(**kw) + if n == 0: + sim.kis["psbl"] -= kw["quantity"] # 미체결 불명 주문분이 주문가능에서 빠짐 + return r + import app.services.trade_executor as te + monkeypatch.setattr(te, "safe_place_order", _order) + return sim + + +@pytest.mark.asyncio +async def test_b2_pending_partial_take_profit_not_refired(monkeypatch, caplog): + sim = _open_unknown_sim(monkeypatch, _partial_signal) + with caplog.at_level(logging.WARNING): + await sim.cycles(3) + assert len(sim.sent) == 1 # 같은 1차 익절 재발화 0건 + assert CODE in sim.es._pending_unknown_sells + assert any("분할 익절" in r.message and "건너뜀" in r.message for r in caplog.records) + + +@pytest.mark.asyncio +async def test_b2_pending_stop_loss_still_executes_within_kis_cap(monkeypatch): + state = {"stop": False} + + def _sig(remaining): + return _stop_signal(remaining) if state["stop"] else _partial_signal(remaining) + sim = _open_unknown_sim(monkeypatch, _sig) + await sim.cycles(1) + assert sim.sent == [0] and CODE in sim.es._pending_unknown_sells + state["stop"] = True + await sim.cycles(1) + # 손절은 허용 — 상한 = min(주문가능 10, 보유 20) − DT 0 = 10 (불명 주문분 제외, 과다매도 없음) + assert sim.sent == [0, 10] + assert sim.kis["hldg"] == 10 + + +@pytest.mark.asyncio +async def test_b2_normal_sell_during_pending_not_misattributed(monkeypatch): + state = {"stop": False} + + def _sig(remaining): + return _stop_signal(remaining) if state["stop"] else _partial_signal(remaining) + sim = _open_unknown_sim(monkeypatch, _sig) + await sim.cycles(1) + pre = sim.es._pending_unknown_sells[CODE]["pre_hldg"] + state["stop"] = True + await sim.cycles(1) # 손절 10주 정상 접수·체결 → 대기 기준 잔고 차감 + assert sim.es._pending_unknown_sells[CODE]["pre_hldg"] == pre - 10 + state["stop"] = False + await sim.cycles(2) # 대조: 보유 감소 10주는 정상 매도분 → 불명 체결로 기록 금지 + assert not any("결과 불명 → KIS 잔고로 체결 확인" in (h.get("exit_reason") or "") for h in sim.history) + assert CODE in sim.es._pending_unknown_sells # 불명 주문은 여전히 브로커에 미체결 + + +@pytest.mark.asyncio +async def test_b2_unknown_registration_records_cooldown(monkeypatch): + sim = _Sim(monkeypatch, swing=20, dt=0, price=11500, signal=_partial_signal, + responses=[UNKNOWN], fill=lambda q, n: 0) + sim.es._recent_exits.clear() + before = time.time() + await sim.es.check_and_execute_exit_v2(MagicMock()) + assert CODE in sim.es._pending_unknown_sells + assert sim.es._recent_exits.get(CODE, 0) >= before + + +# ── B-3 ──────────────────────────────────────────────────────────────────── +def _epoch_kst(h, m, day=None): + d = day or datetime.now(KST).date() + return KST.localize(datetime(d.year, d.month, d.day, h, m)).timestamp() + + +def test_b3_expired_after_market_close(): + import app.services.swing.exit_service as es + d = datetime(2026, 10, 12).date() + pend = {"since": _epoch_kst(10, 0, d)} + assert es._unknown_sell_expired_v2(pend, datetime(2026, 10, 12, 15, 0)) is False + assert es._unknown_sell_expired_v2(pend, datetime(2026, 10, 12, 15, 31)) is True + assert es._unknown_sell_expired_v2(pend, datetime(2026, 10, 13, 9, 5)) is True + # 장 마감 후 등록은 다음 날 마감까지 유지 / 등록 시각 불명은 만료 판정 안 함 + late = {"since": _epoch_kst(16, 0, d)} + assert es._unknown_sell_expired_v2(late, datetime(2026, 10, 12, 18, 0)) is False + assert es._unknown_sell_expired_v2({"since": None}, datetime(2026, 10, 13, 18, 0)) is False + + +@pytest.mark.asyncio +async def test_b3_expire_drops_with_alert(monkeypatch, _no_redis_and_alerts): + """대조 불가(잔고 조회 실패) → 기존대로 폐기 + 경보.""" + import app.services.swing.exit_service as es + monkeypatch.setattr(es, "_presell_kis_holding_v2", AsyncMock(return_value=None)) + d = datetime(2026, 10, 12).date() + es._pending_unknown_sells[CODE] = {"qty": 5, "pre_hldg": 10, "since": _epoch_kst(10, 0, d)} + dropped = await es._expire_unknown_sells_v2(datetime(2026, 10, 12, 15, 40)) + assert dropped == [CODE] and CODE not in es._pending_unknown_sells + es._forget_unknown_sell_v2.assert_awaited_once_with(CODE) + assert any("수동 확인" in m for _, m in _no_redis_and_alerts) + + +@pytest.mark.asyncio +async def test_b3_rebuy_after_registration_drops(monkeypatch, _no_redis_and_alerts): + import app.services.swing.exit_service as es + import app.services.trade_executor as te + reg = time.monotonic() + es._pending_unknown_sells[CODE] = {"qty": 5, "pre_hldg": 10, "since": time.time(), "since_mono": reg} + es._pending_unknown_sells["000660"] = {"qty": 1, "pre_hldg": 2, "since": time.time(), "since_mono": reg} + monkeypatch.setattr(te, "_recent_cycle_buys_v2", + lambda: {CODE: {"ts": reg + 5}, "000660": {"ts": reg - 5}}) + dropped = await es._expire_unknown_sells_v2() + assert dropped == [CODE] + assert "000660" in es._pending_unknown_sells # 등록 이전 매수는 무관 + + +@pytest.mark.asyncio +async def test_b3_restored_entry_without_mono_dropped_on_any_buy(monkeypatch): + import app.services.swing.exit_service as es + import app.services.trade_executor as te + es._pending_unknown_sells[CODE] = {"qty": 5, "pre_hldg": 10, "since": time.time()} + monkeypatch.setattr(te, "_recent_cycle_buys_v2", lambda: {CODE: {"ts": time.monotonic()}}) + assert await es._expire_unknown_sells_v2() == [CODE] + + +@pytest.mark.asyncio +async def test_b3b_expire_reconciles_first_fill_booked_no_drop(monkeypatch, _no_redis_and_alerts): + """장 마감 경과 항목도 폐기 전 대조 1회 — 체결이 확인되면 그 결과로 종결(경보 없음).""" + import app.services.swing.exit_service as es + d = datetime(2026, 10, 12).date() + es._pending_unknown_sells[CODE] = {"qty": 5, "pre_hldg": 10, "since": _epoch_kst(15, 10, d)} + snap = AsyncMock(return_value=(5, 5)) + monkeypatch.setattr(es, "_presell_kis_holding_v2", snap) + + async def _rec(code, hldg, sellable): + assert (code, hldg, sellable) == (CODE, 5, 5) + es._pending_unknown_sells.pop(code, None) # 체결 반영으로 대기 해제 + return True + rec = AsyncMock(side_effect=_rec) + monkeypatch.setattr(es, "_reconcile_unknown_sell_v2", rec) + dropped = await es._expire_unknown_sells_v2(datetime(2026, 10, 13, 9, 5)) + assert dropped == [] + rec.assert_awaited_once() + assert _no_redis_and_alerts == [] + es._forget_unknown_sell_v2.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_b3b_expire_reconcile_keeps_pending_then_drops(monkeypatch, _no_redis_and_alerts): + """대조했지만 판정 불가(대기 유지) → 폐기 + 경보.""" + import app.services.swing.exit_service as es + d = datetime(2026, 10, 12).date() + es._pending_unknown_sells[CODE] = {"qty": 5, "pre_hldg": 10, "since": _epoch_kst(15, 10, d)} + monkeypatch.setattr(es, "_presell_kis_holding_v2", AsyncMock(return_value=(10, 5))) + rec = AsyncMock(return_value=False) + monkeypatch.setattr(es, "_reconcile_unknown_sell_v2", rec) + dropped = await es._expire_unknown_sells_v2(datetime(2026, 10, 13, 9, 5)) + assert dropped == [CODE] + rec.assert_awaited_once() + assert any("수동 확인" in m for _, m in _no_redis_and_alerts) + + +@pytest.mark.asyncio +async def test_b3b_expire_reconcile_exception_drops(monkeypatch, _no_redis_and_alerts): + import app.services.swing.exit_service as es + d = datetime(2026, 10, 12).date() + es._pending_unknown_sells[CODE] = {"qty": 5, "pre_hldg": 10, "since": _epoch_kst(15, 10, d)} + monkeypatch.setattr(es, "_presell_kis_holding_v2", AsyncMock(side_effect=RuntimeError("boom"))) + assert await es._expire_unknown_sells_v2(datetime(2026, 10, 13, 9, 5)) == [CODE] diff --git a/backend/tests/test_swing_exit_rsi_warmup_r4_20261009.py b/backend/tests/test_swing_exit_rsi_warmup_r4_20261009.py index 3a67f67..e65eda5 100644 --- a/backend/tests/test_swing_exit_rsi_warmup_r4_20261009.py +++ b/backend/tests/test_swing_exit_rsi_warmup_r4_20261009.py @@ -238,6 +238,7 @@ def _loop_env(monkeypatch, es, recent, captured, signal=None): api.get_current_price = AsyncMock(return_value={"output": {"stck_prpr": str(int(price))}}) api.get_balance = AsyncMock(return_value={"rt_cd": "0", "output1": [ {"pdno": "005930", "hldg_qty": "10", "ord_psbl_qty": "10"}]}) + api.get_balance_all_v2 = api.get_balance # [R9] 연속조회 목 보정 — 같은 목을 공유 api.get_daily_ohlcv_range = AsyncMock(side_effect=RuntimeError("kis range down")) monkeypatch.setattr(es, "korea_investment_api", api) @@ -295,3 +296,6 @@ async def test_exit_loop_history_failure_keeps_stop_loss(monkeypatch, _reset): await es.check_and_execute_exit_v2(MagicMock()) assert captured[0] == pytest.approx(calculate_rsi([float(b["stck_clpr"]) for b in recent])) assert sent == [("005930", 10)] + + +from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지) diff --git a/backend/tests/test_swing_hardening_r3_cap_20261009.py b/backend/tests/test_swing_hardening_r3_cap_20261009.py index 2e04f2b..2225708 100644 --- a/backend/tests/test_swing_hardening_r3_cap_20261009.py +++ b/backend/tests/test_swing_hardening_r3_cap_20261009.py @@ -155,3 +155,6 @@ async def test_dt_unreflected_buy_never_sells_dt_inmemory_qty(monkeypatch): await sim.cycles(1) assert sim.sent == [5] assert sim.kis["hldg"] == 5 + + +from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지) diff --git a/backend/tests/test_swing_mdd_input_guard_r2_20261009.py b/backend/tests/test_swing_mdd_input_guard_r2_20261009.py index f514312..8307f21 100644 --- a/backend/tests/test_swing_mdd_input_guard_r2_20261009.py +++ b/backend/tests/test_swing_mdd_input_guard_r2_20261009.py @@ -238,3 +238,6 @@ async def test_loop_partial_response_counts_as_update_failure(monkeypatch, env): await _one_cycle(sch2) sch2.mdd_manager.record_update_failure.assert_awaited_once() sch2.mdd_manager.update.assert_not_awaited() + + +from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지) diff --git a/backend/tests/test_swing_partial_exit_no_active_trade.py b/backend/tests/test_swing_partial_exit_no_active_trade.py index 51034bf..d5623d4 100644 --- a/backend/tests/test_swing_partial_exit_no_active_trade.py +++ b/backend/tests/test_swing_partial_exit_no_active_trade.py @@ -83,6 +83,7 @@ async def test_partial_exit_without_active_trade_runs_once(): get_balance=AsyncMock(return_value={"rt_cd": "0", "output1": [ {"pdno": "034020", "hldg_qty": "18", "ord_psbl_qty": "18"}]}), ) + api.get_balance_all_v2 = api.get_balance # [R9] 연속조회 목 보정 — 같은 목을 공유 place_order = AsyncMock(return_value=order_ok) with patch.object(exit_service, "korea_investment_api", api), \ @@ -149,3 +150,6 @@ def test_real_db_session_is_blocked_in_this_module(): from app.core import database with pytest.raises(_DBBlocked): database.AsyncSessionLocal() + + +from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지) diff --git a/backend/tests/test_swing_pm_hardening_r1_20261009.py b/backend/tests/test_swing_pm_hardening_r1_20261009.py index 9bfd9ae..d2e77e5 100644 --- a/backend/tests/test_swing_pm_hardening_r1_20261009.py +++ b/backend/tests/test_swing_pm_hardening_r1_20261009.py @@ -216,3 +216,6 @@ def test_unsorted_input_and_ignored_rows(): ]) assert len(out) == 1 assert (out[0]["system_qty"], out[0]["inflow_qty"], out[0]["origin"]) == (2, 2, "mixed") + + +from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지) diff --git a/backend/tests/test_swing_r3_fixes_20261002.py b/backend/tests/test_swing_r3_fixes_20261002.py index 87719ac..bd4a4cb 100644 --- a/backend/tests/test_swing_r3_fixes_20261002.py +++ b/backend/tests/test_swing_r3_fixes_20261002.py @@ -72,6 +72,7 @@ def _exit_harness(monkeypatch, *, price, avg, strategy="swing", created_days_ago # [C-1 6회차] 국내 매도 직전 KIS 실보유 확인 — 보유 10주 전부 스윙(DT 없음) api.get_balance = AsyncMock(return_value={"rt_cd": "0", "output1": [ {"pdno": "005930", "hldg_qty": "10", "ord_psbl_qty": "10"}]}) + api.get_balance_all_v2 = api.get_balance # [R9] 연속조회 목 보정 — 같은 목을 공유 monkeypatch.setattr(es, "korea_investment_api", api) # 지표 고정: ATR 1%(일반 변동성), RSI 중립, BB 중앙은 진입가 위 → 익절 없음 monkeypatch.setattr(es, "calculate_atr", lambda h, l, c: avg * 0.01) @@ -733,6 +734,7 @@ def _scheduler_env(monkeypatch, mr_side_effect=None): monkeypatch.setattr(sch, "AsyncSessionLocal", lambda: _SessionCM(MagicMock())) api = MagicMock() api.get_balance = AsyncMock(return_value={"output2": [{"tot_evlu_amt": "10000000"}]}) + api.get_balance_all_v2 = api.get_balance # [R9] 연속조회 목 보정 — 같은 목을 공유 monkeypatch.setattr(kia, "korea_investment_api", api) monkeypatch.setattr(sch.mdd_manager, "update", AsyncMock(return_value={"action": "NONE"})) monkeypatch.setattr(sch.mdd_manager, "record_update_success", AsyncMock()) @@ -885,6 +887,7 @@ async def test_refresh_adaptive_error_balance_uses_failure_path(monkeypatch): monkeypatch.setattr(nf, "get_naver_daily_chart", AsyncMock(return_value=list(reversed(chart)))) api = MagicMock() api.get_balance = AsyncMock(return_value={"rt_cd": "1", "msg1": "EGW00123"}) + api.get_balance_all_v2 = api.get_balance # [R9] 연속조회 목 보정 — 같은 목을 공유 monkeypatch.setattr(kia, "korea_investment_api", api) mgr = SimpleNamespace(peak_balance=10_000_000.0, reduction_applied="20pct", is_paused=True) monkeypatch.setattr(mm, "mdd_manager", mgr) @@ -957,3 +960,6 @@ def test_m4_v2_injected_state_carries_hysteresis(monkeypatch): # 주입 상태가 SIDEWAYS 면 mid 는 해제 임계(>22) 미달 → 횡보 유지 assert mr.detect_market_regime_v2(c, c, c, state) == mr.MarketRegime.SIDEWAYS assert mr._previous_regime is None # 전역 불변 + + +from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지) diff --git a/backend/tests/test_swing_r4_fixes_20261002.py b/backend/tests/test_swing_r4_fixes_20261002.py index 0b1c69e..7bb6c8c 100644 --- a/backend/tests/test_swing_r4_fixes_20261002.py +++ b/backend/tests/test_swing_r4_fixes_20261002.py @@ -69,6 +69,7 @@ def _harness(monkeypatch, *, holding_price, avg=10000, price_resp=None, price_ex # [C-1 6회차] 국내 매도 직전 KIS 실보유 확인 — 보유 10주 전부 스윙(DT 없음) api.get_balance = AsyncMock(return_value={"rt_cd": "0", "output1": [ {"pdno": "005930", "hldg_qty": "10", "ord_psbl_qty": "10"}]}) + api.get_balance_all_v2 = api.get_balance # [R9] 연속조회 목 보정 — 같은 목을 공유 monkeypatch.setattr(es, "korea_investment_api", api) monkeypatch.setattr(es, "calculate_atr", lambda h, l, c: avg * 0.01) monkeypatch.setattr(es, "calculate_rsi", lambda c: 45.0) @@ -381,3 +382,6 @@ def test_l7_cache_follows_setting_changes(monkeypatch): monkeypatch.setattr(settings, "KRX_EXTRA_HOLIDAYS", "2026-10-07", raising=False) assert not ms.is_krx_holiday(date(2026, 10, 6)) assert ms.is_krx_holiday(date(2026, 10, 7)) + + +from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지) diff --git a/backend/tests/test_swing_r5_pending_sell_20261002.py b/backend/tests/test_swing_r5_pending_sell_20261002.py index cb498d9..16c6b61 100644 --- a/backend/tests/test_swing_r5_pending_sell_20261002.py +++ b/backend/tests/test_swing_r5_pending_sell_20261002.py @@ -94,6 +94,7 @@ class _Sim: for _ in range(25)] api.get_daily_chart = AsyncMock(return_value={"output2": bars if signal else []}) api.get_balance = AsyncMock(side_effect=self._balance) + api.get_balance_all_v2 = api.get_balance # [R9] 연속조회 목 보정 — 같은 목을 공유 self.api = api monkeypatch.setattr(es, "korea_investment_api", api) if signal is not None: @@ -256,6 +257,7 @@ async def test_c_balance_failure_while_pending_holds(monkeypatch): async def test_c_bad_balance_response_holds(monkeypatch, bal): sim = _Sim(monkeypatch, swing=10, dt=5) sim.api.get_balance = AsyncMock(return_value=bal) + sim.api.get_balance_all_v2 = sim.api.get_balance # [R9] 연속조회 목 보정 — 같은 목을 공유 await sim.cycles(2) assert sim.sent == [] @@ -472,3 +474,6 @@ async def test_r2_fill_check_real_coroutine_would_hit_kis_is_not_reached(monkeyp await asyncio.sleep(0) sim.api.get_order_execution.assert_not_called() assert len(_FILL_CHECKS) == 1 + + +from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지) diff --git a/backend/tests/test_swing_r6_fallback_stop_20261002.py b/backend/tests/test_swing_r6_fallback_stop_20261002.py index 569308f..1a48b27 100644 --- a/backend/tests/test_swing_r6_fallback_stop_20261002.py +++ b/backend/tests/test_swing_r6_fallback_stop_20261002.py @@ -57,6 +57,7 @@ def _env(monkeypatch, *, avg=10000, price, prices, high_vol=True, holding_update api.get_current_price = _gcp api.get_balance = AsyncMock(return_value={"rt_cd": "0", "output1": [ {"pdno": "005930", "hldg_qty": "10", "ord_psbl_qty": "10"}]}) + api.get_balance_all_v2 = api.get_balance # [R9] 연속조회 목 보정 — 같은 목을 공유 monkeypatch.setattr(es, "korea_investment_api", api) from app.services.daytrading.position_manager import day_trading_state monkeypatch.setattr(day_trading_state, "positions", {}) @@ -185,3 +186,6 @@ async def test_low_regime_and_dt_lookup_failures_warn(monkeypatch, caplog): msgs = [r.getMessage() for r in caplog.records if r.levelno >= logging.WARNING] assert any("시장 국면 조회 실패" in m for m in msgs) assert any("DT 보유분 조회 실패" in m for m in msgs) + + +from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지) diff --git a/backend/tests/test_swing_r7_balance_page_20261002.py b/backend/tests/test_swing_r7_balance_page_20261002.py index c652dae..916f925 100644 --- a/backend/tests/test_swing_r7_balance_page_20261002.py +++ b/backend/tests/test_swing_r7_balance_page_20261002.py @@ -17,6 +17,7 @@ async def test_full_page_without_stock_is_unknown(monkeypatch): return {"rt_cd": "0", "output1": rows} monkeypatch.setattr(es.korea_investment_api, "get_balance", _bal) + monkeypatch.setattr(es.korea_investment_api, "get_balance_all_v2", _bal) # [R9] 연속조회 목 보정 — 같은 목을 공유 assert await es._fetch_kis_holding_v2("005930") is None @@ -28,6 +29,7 @@ async def test_short_page_without_stock_is_zero(monkeypatch): return {"rt_cd": "0", "output1": rows} monkeypatch.setattr(es.korea_investment_api, "get_balance", _bal) + monkeypatch.setattr(es.korea_investment_api, "get_balance_all_v2", _bal) # [R9] 연속조회 목 보정 — 같은 목을 공유 assert await es._fetch_kis_holding_v2("005930") == (0, 0) @@ -41,4 +43,8 @@ async def test_full_page_with_stock_is_found(monkeypatch): return {"rt_cd": "0", "output1": rows} monkeypatch.setattr(es.korea_investment_api, "get_balance", _bal) + monkeypatch.setattr(es.korea_investment_api, "get_balance_all_v2", _bal) # [R9] 연속조회 목 보정 — 같은 목을 공유 assert await es._fetch_kis_holding_v2("005930") == (7, 5) + + +from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지) diff --git a/backend/tests/test_swing_safety_20261002.py b/backend/tests/test_swing_safety_20261002.py index 94c26e7..7ef3781 100644 --- a/backend/tests/test_swing_safety_20261002.py +++ b/backend/tests/test_swing_safety_20261002.py @@ -147,3 +147,6 @@ async def test_adaptive_none_is_fail_closed(monkeypatch, _pipeline): _set_adaptive(monkeypatch, lambda: None) res = await _call_buy() assert res["action"] == "SKIP" and res["reason"] == "adaptive_params_unavailable" + + +from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지) diff --git a/backend/tests/test_swing_scheduler_hardening_r1.py b/backend/tests/test_swing_scheduler_hardening_r1.py index 3bb6b7b..da88819 100644 --- a/backend/tests/test_swing_scheduler_hardening_r1.py +++ b/backend/tests/test_swing_scheduler_hardening_r1.py @@ -106,6 +106,7 @@ def _loop_env(monkeypatch, *, exit_mock=None, balance=None, can_open=True): monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", AsyncMock(return_value=0)) api = MagicMock() api.get_balance = balance or AsyncMock(return_value={"output2": [{"tot_evlu_amt": "10000000"}]}) + api.get_balance_all_v2 = api.get_balance # [R9] 연속조회 목 보정 — 같은 목을 공유 monkeypatch.setattr(kia, "korea_investment_api", api) monkeypatch.setattr(sch.mdd_manager, "update", AsyncMock(return_value={"action": "NONE"})) # [R2] MDD 입력 가드가 실제 peak/단계를 읽는다 — 앞선 테스트의 싱글톤 상태가 새지 않게 고정 @@ -156,7 +157,8 @@ async def test_hung_exit_check_does_not_freeze_loop_and_is_not_cancelled(monkeyp assert started.is_set() api.get_balance.assert_awaited() # MDD 단계 도달 - assert calls["entry"] == 1 # 진입 단계 도달 + # [R9 A-2] 청산 판정이 상한 초과(백그라운드 진행)인 사이클은 진입을 생략한다(redteam #8) + assert calls["entry"] == 0 task = sch._inflight_steps["청산 판정"] assert not task.done() # 취소하지 않음(매도 이중 실행 방지) assert sch._exit_health["timeouts"] == 1 @@ -621,3 +623,6 @@ async def test_repeated_zero_total_on_live_account_triggers_blind_block(monkeypa assert health["empty_account"] is False assert health["consecutive_failures"] == threshold assert real._is_update_blind() is True + + +from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지) diff --git a/backend/tests/test_swing_scheduler_hardening_r3_20261009.py b/backend/tests/test_swing_scheduler_hardening_r3_20261009.py index f6acaa7..121222d 100644 --- a/backend/tests/test_swing_scheduler_hardening_r3_20261009.py +++ b/backend/tests/test_swing_scheduler_hardening_r3_20261009.py @@ -326,3 +326,6 @@ async def test_mdd_knob_db_failure_does_not_break(env, monkeypatch): monkeypatch.setattr(settings, "MDD_PAUSE_THRESHOLD", -8.0) mism = await sch._check_mdd_threshold_knobs_v2() assert [(m["source"], m["key"]) for m in mism] == [("settings", "MDD_PAUSE_THRESHOLD")] + + +from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지) diff --git a/backend/tests/test_swing_scheduler_r9_20261012.py b/backend/tests/test_swing_scheduler_r9_20261012.py new file mode 100644 index 0000000..3d55df8 --- /dev/null +++ b/backend/tests/test_swing_scheduler_r9_20261012.py @@ -0,0 +1,144 @@ +"""하드닝 R9 A-2 — 청산 단계 미종결(TIMED_OUT/SKIPPED/예외) 사이클의 MDD 축소·진입 생략 (redteam #8). + +루프 하네스·상태 초기화는 test_swing_scheduler_hardening_r1 의 것을 그대로 쓴다(autouse _reset_state). +""" +import asyncio +from unittest.mock import AsyncMock + +import pytest + +from tests.test_swing_scheduler_hardening_r1 import ( # noqa: F401 (_reset_state: autouse 픽스처) + _drain, _loop_env, _messages, _one_cycle, _reset_state, +) + + +@pytest.mark.asyncio +async def test_timed_out_exit_skips_mdd_reduction_then_resumes(monkeypatch, _reset_state): + release = asyncio.Event() + + async def _exit(db): + await release.wait() + return [] + + sch, _api, _calls = _loop_env(monkeypatch, exit_mock=AsyncMock(side_effect=_exit), can_open=False) + reduce_mock = AsyncMock(return_value=[]) + monkeypatch.setattr(sch.mdd_manager, "execute_reduction", reduce_mock) + monkeypatch.setattr(sch.mdd_manager, "reduction_applied", "10pct") + monkeypatch.setattr(sch, "_DEFAULT_EXIT_STEP_TIMEOUT_SEC", 0.05) + + await _one_cycle(sch) # 청산 TIMED_OUT + reduce_mock.assert_not_awaited() + await _one_cycle(sch) # 이전 청산 진행 중 → SKIPPED + reduce_mock.assert_not_awaited() + + release.set() + await _drain() + await _one_cycle(sch) # 청산 정상 완료 → 축소 재개 + reduce_mock.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_timed_out_exit_skips_entry_then_resumes(monkeypatch, _reset_state): + release = asyncio.Event() + + async def _exit(db): + await release.wait() + return [] + + sch, _api, calls = _loop_env(monkeypatch, exit_mock=AsyncMock(side_effect=_exit)) + monkeypatch.setattr(sch, "_DEFAULT_EXIT_STEP_TIMEOUT_SEC", 0.05) + + await _one_cycle(sch) + assert calls["entry"] == 0 + await _one_cycle(sch) + assert calls["entry"] == 0 + release.set() + await _drain() + await _one_cycle(sch) + assert calls["entry"] == 1 + + +@pytest.mark.asyncio +async def test_exit_exception_skips_entry_this_cycle_only(monkeypatch, _reset_state): + sch, _api, calls = _loop_env(monkeypatch) + monkeypatch.setattr(sch, "_run_exit_phase_v2", AsyncMock(side_effect=RuntimeError("boom"))) + await _one_cycle(sch) + assert calls["entry"] == 0 + monkeypatch.setattr(sch, "_run_exit_phase_v2", AsyncMock(return_value=None)) + await _one_cycle(sch) + assert calls["entry"] == 1 + + +def test_unsettled_reason_classification(_reset_state): + sch = _reset_state.sch + assert sch._exit_phase_unsettled_reason_v2(sch._STEP_TIMED_OUT, False) + assert sch._exit_phase_unsettled_reason_v2(sch._STEP_SKIPPED, False) + assert sch._exit_phase_unsettled_reason_v2([], True) + assert sch._exit_phase_unsettled_reason_v2([], False) is None + + +@pytest.fixture +def _fresh_escalation(monkeypatch, _reset_state): + monkeypatch.setattr(_reset_state.sch, "_mdd_unsettled_skip_state", {"count": 0, "escalated": False}) + return _reset_state.sch + + +@pytest.mark.asyncio +async def test_exit_exception_still_runs_mdd_reduction(monkeypatch, _reset_state, _fresh_escalation): + sch, _api, calls = _loop_env(monkeypatch, can_open=False) + monkeypatch.setattr(sch, "_run_exit_phase_v2", AsyncMock(side_effect=RuntimeError("boom"))) + reduce_mock = AsyncMock(return_value=[]) + monkeypatch.setattr(sch.mdd_manager, "execute_reduction", reduce_mock) + monkeypatch.setattr(sch.mdd_manager, "reduction_applied", "10pct") + await _one_cycle(sch) + reduce_mock.assert_awaited_once() # 예외(태스크 종료) — 축소는 실행 + assert calls["entry"] == 0 + assert sch._mdd_unsettled_skip_state["count"] == 0 + + +def test_exception_reason_blocks_entry_but_not_reduction(_reset_state): + sch = _reset_state.sch + assert sch._exit_phase_unsettled_reason_v2([], True) + assert sch._exit_inflight_reason_v2([]) is None + assert sch._exit_inflight_reason_v2(sch._STEP_TIMED_OUT) + assert sch._exit_inflight_reason_v2(sch._STEP_SKIPPED) + + +@pytest.mark.asyncio +async def test_mdd_reduction_skip_escalates_once_then_resets(monkeypatch, caplog, _reset_state, _fresh_escalation): + release = asyncio.Event() + + async def _exit(db): + await release.wait() + return [] + + sch, _api, _calls = _loop_env(monkeypatch, exit_mock=AsyncMock(side_effect=_exit), can_open=False) + reduce_mock = AsyncMock(return_value=[]) + monkeypatch.setattr(sch.mdd_manager, "execute_reduction", reduce_mock) + monkeypatch.setattr(sch.mdd_manager, "reduction_applied", "10pct") + monkeypatch.setattr(sch, "_DEFAULT_EXIT_STEP_TIMEOUT_SEC", 0.05) + limit = sch._DEFAULT_SWING_EXIT_UNSETTLED_ESCALATE_CYCLES + + def _escalations(): + return [r for r in caplog.records + if r.levelname == "ERROR" and "연속 미실행" in r.getMessage()] + + for _ in range(limit - 1): + await _one_cycle(sch) + assert _escalations() == [] + await _one_cycle(sch) # limit 번째 → error 1회 + assert len(_escalations()) == 1 + await _drain() # 텔레그램 전송은 태스크로 스폰된다 + assert any("연속 미실행" in m for m in _messages(_reset_state.notify)) + await _one_cycle(sch) # 같은 스트릭 — 반복 안 함 + assert len(_escalations()) == 1 + reduce_mock.assert_not_awaited() # 강제 실행 안 함 + + release.set() + await _drain() + await _one_cycle(sch) # 정상 청산 → 축소 재개 + 리셋 + reduce_mock.assert_awaited_once() + assert sch._mdd_unsettled_skip_state == {"count": 0, "escalated": False} + + +from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지) diff --git a/backend/tests/test_swing_trailing_mult_20261009.py b/backend/tests/test_swing_trailing_mult_20261009.py index 8362b2d..3b878f7 100644 --- a/backend/tests/test_swing_trailing_mult_20261009.py +++ b/backend/tests/test_swing_trailing_mult_20261009.py @@ -155,3 +155,6 @@ def test_momentum_trailing_honors_db_value(no_adaptive): with patch.object(mr, "strategy_cfg", _cfg({"ATR_TRAILING_MULTIPLIER": cfg_mult})): sig2 = exit_service._check_momentum_exit(**kwargs) assert sig2 is None or "트레일링" not in sig2.reason + + +from _swing_redis_guard import _swing_redis_guard # noqa: E402,F401 (R9: 단독 실행 시 Redis 실접속 방지) diff --git a/backend/tests/test_trade_executor_hardening_r1_20261009.py b/backend/tests/test_trade_executor_hardening_r1_20261009.py index 82ca9bf..d20ae72 100644 --- a/backend/tests/test_trade_executor_hardening_r1_20261009.py +++ b/backend/tests/test_trade_executor_hardening_r1_20261009.py @@ -101,7 +101,7 @@ async def _sell(sell_pct=1.0, qty=10, remaining_pct=1.0): @pytest.mark.asyncio async def test_recent_swing_exit_blocks_recommendation_sell(env, monkeypatch): """exit_service 가 막 청산한 종목은 추천 경로가 같은 단계를 다시 팔지 않는다.""" - monkeypatch.setattr(es, "_fetch_kis_holding_v2", _boom("_fetch_kis_holding_v2")) + monkeypatch.setattr(es, "_fetch_kis_holding_v3", _boom("_fetch_kis_holding_v2")) es._recent_exits[CODE] = time.time() - 5 r = await _sell(0.5) assert r["action"] == "SKIP" and r["reason"] == "recent_swing_exit_cooldown" @@ -110,7 +110,7 @@ async def test_recent_swing_exit_blocks_recommendation_sell(env, monkeypatch): @pytest.mark.asyncio async def test_cooldown_expired_allows_sell(env, monkeypatch): - monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(10, 10)) + monkeypatch.setattr(es, "_fetch_kis_holding_v3", _kis(10, 10)) es._recent_exits[CODE] = time.time() - float(es.EXIT_COOLDOWN_SEC) - 1 r = await _sell(1.0) assert r["action"] == "SELL" and env and env[-1]["quantity"] == 10 @@ -119,7 +119,7 @@ async def test_cooldown_expired_allows_sell(env, monkeypatch): @pytest.mark.asyncio async def test_successful_sell_marks_shared_cooldown(env, monkeypatch): """추천 경로 매도 접수 → exit_service 도 같은 쿨다운을 본다.""" - monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(10, 10)) + monkeypatch.setattr(es, "_fetch_kis_holding_v3", _kis(10, 10)) before = time.time() await _sell(1.0) assert es._recent_exits.get(CODE, 0) >= before @@ -128,7 +128,7 @@ async def test_successful_sell_marks_shared_cooldown(env, monkeypatch): @pytest.mark.asyncio async def test_rejected_sell_does_not_mark_cooldown(env, monkeypatch): """업무 거부(FAILED)는 체결이 없으므로 쿨다운에 기록하지 않는다(즉시 재시도 허용).""" - monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(10, 10)) + monkeypatch.setattr(es, "_fetch_kis_holding_v3", _kis(10, 10)) env.result = {"rt_cd": "1", "msg_cd": "APBK0000", "msg1": "거부"} r = await _sell(1.0) assert r.get("success") is False @@ -142,7 +142,7 @@ async def test_kis_cap_limits_stale_portfolio_quantity(env, monkeypatch): """Portfolio 10주(낡음)인데 KIS 실보유 6주 중 DT 4주 → 스윙 매도는 2주까지만.""" from app.services.daytrading.position_manager import day_trading_state day_trading_state.positions[CODE] = {"quantity": 4} - monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(6, 6)) + monkeypatch.setattr(es, "_fetch_kis_holding_v3", _kis(6, 6)) r = await _sell(1.0, qty=10) assert r["action"] == "SELL" assert env[-1]["quantity"] == 2, "KIS 실보유 − DT 를 넘는 수량이 주문됐다(DT 물량 과다매도)" @@ -151,7 +151,7 @@ async def test_kis_cap_limits_stale_portfolio_quantity(env, monkeypatch): @pytest.mark.asyncio async def test_kis_cap_uses_orderable_quantity(env, monkeypatch): """대기 매도 주문분(주문가능 < 보유)은 다시 팔지 않는다.""" - monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(10, 3)) + monkeypatch.setattr(es, "_fetch_kis_holding_v3", _kis(10, 3)) await _sell(1.0, qty=10) assert env[-1]["quantity"] == 3 @@ -160,7 +160,7 @@ async def test_kis_cap_uses_orderable_quantity(env, monkeypatch): async def test_kis_check_failure_holds_sell(env, monkeypatch): async def _none(stock_code): return None - monkeypatch.setattr(es, "_fetch_kis_holding_v2", _none) + monkeypatch.setattr(es, "_fetch_kis_holding_v3", _none) r = await _sell(1.0) assert r["action"] == "SKIP" and r["reason"] == "kis_holding_check_failed" assert env == [] @@ -168,7 +168,7 @@ async def test_kis_check_failure_holds_sell(env, monkeypatch): @pytest.mark.asyncio async def test_kis_sellable_zero_no_order(env, monkeypatch): - monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(0, 0)) + monkeypatch.setattr(es, "_fetch_kis_holding_v3", _kis(0, 0)) r = await _sell(0.5) assert r["action"] == "SKIP" and r["reason"] == "kis_sellable_zero" assert env == [] @@ -177,7 +177,7 @@ async def test_kis_sellable_zero_no_order(env, monkeypatch): @pytest.mark.asyncio async def test_kis_cap_not_applied_when_holding_matches(env, monkeypatch): """정상 상태(장부=KIS)에서는 분할매도 수량이 바뀌지 않는다 — 원본 대비 50% = 5주.""" - monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis(10, 10)) + monkeypatch.setattr(es, "_fetch_kis_holding_v3", _kis(10, 10)) await _sell(0.5, qty=10) assert env[-1]["quantity"] == 5 diff --git a/backend/tests/test_trade_executor_net_loss_r1_20261009.py b/backend/tests/test_trade_executor_net_loss_r1_20261009.py index e651f7e..ea335ca 100644 --- a/backend/tests/test_trade_executor_net_loss_r1_20261009.py +++ b/backend/tests/test_trade_executor_net_loss_r1_20261009.py @@ -86,7 +86,7 @@ def env(monkeypatch): async def _kis(stock_code): return (10, 10) - monkeypatch.setattr(es, "_fetch_kis_holding_v2", _kis) + monkeypatch.setattr(es, "_fetch_kis_holding_v3", _kis) return SimpleNamespace(orders=orders, rec=rec) diff --git a/scripts/health_watch.py b/scripts/health_watch.py index 9205b90..52df8fc 100644 --- a/scripts/health_watch.py +++ b/scripts/health_watch.py @@ -34,6 +34,9 @@ def problems_v1(health) -> list: out.append("db_worker 사망") if ((health.get("dlq") or {}).get("manual") or 0) > 0: out.append(f"DLQ manual={health['dlq']['manual']}") + reasons = health.get("degraded_reasons") + if out and isinstance(reasons, list) and reasons: + out.append("degraded_reasons=" + ",".join(str(r) for r in reasons)) return out