fix: 하드닝 루프 9회차 — 매수 원복·잔고 연속조회·결과 불명 매도·브레이커·경보 보강

- 8회차 HIGH 수정 16건 레드팀 재검증: 12 확인·3 부분·1 미수정 → 전부 보강
- trade_executor/fill_sweep: 늦게 확정된 0체결 BUY → CANCELLED + ActiveTrade close, 부분체결 → 수량 축소(멱등, 재기동 후 TH 재구성)
- korea_investment_api: get_balance_all_v2(tr_cont 연속조회, _partial), get_todays_orders_v2(실패 None), 브레이커 청산용 조회 면제(OPEN 중 미집계)
- swing/exit_service·mdd_manager: 매도 직전 상한·MDD 축소가 전체 잔고 페이지 사용(§8.5-37), 불명 대기 중 부분익절 skip·쿨다운·pre_hldg 차감, 대기 TTL 15:30+재매수 폐기(대조 1회 선행)(§8.5-38 해소)
- swing/scheduler: 청산 TIMED_OUT/SKIPPED/in-flight 시 MDD 축소·진입 보류(예외는 진입만), 3사이클 연속 시 error+텔레그램
- daytrading: 결과 불명 매도 재판정 v3(주문조회 v2 None→보류, 등록 이후 일치 행→accepted, 원장≤0→보류), 재기동 복원 min(주문가능,보유), 잔고 조회 후 일일손실한도 재확인, 단독 실행 Redis 접속 제거
- main: 스트림 무수신 경보 v4(희망 구독 기준), 첫 하트비트 없음 stale(HB_FIRST_BEAT_GRACE_SEC)
- 운영: Jenkinsfile health_watch.py 디렉터리 잔존 복구, prod_smoke 사이드카 확인, config 키 12개 선언(동작 무변경)
- 진입가 v3(max(AT,PA))는 KIS 이동평균 평단 전제 오류로 레드팀 반증 → 폐기
- CLAUDE.md §3.2 ③-d, §8.5-37/38 갱신, §8.5-39~48 신규(Crisis 손절 소급 §8.5-39 는 사용자 결정 대기)
- 유닛 스위트 2781 passed / 0 failed (소켓 차단), 네트워크 시도 0건

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 15:37:16 +09:00
parent 0feaa61bfc
commit d140055e64
60 changed files with 2853 additions and 50 deletions

View File

@@ -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(소켓 차단).*