From 9de07151655b6a6c7f8dfb8d7b9c968f78a7fc9d Mon Sep 17 00:00:00 2001 From: shuim Date: Wed, 30 Sep 2026 16:50:54 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=8A=A4=EC=9C=99=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=ED=8E=B8=EC=9E=85=20=EC=B0=A8=EB=8B=A8=20+=20DT=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=EB=9F=89=20=EB=B3=B5=EA=B5=AC=20+=20ATR/=EC=96=91?= =?UTF-8?q?=EB=B4=89=20=EC=84=B8=EC=85=98=20=EA=B2=BD=EA=B3=84=20+=20DLQ?= =?UTF-8?q?=20=EC=95=88=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 요구 "스윙으로 편입하는 건 없어야 해" — 시스템이 매수하지 않은 KIS 계좌 잔고가 strategy_type='swing' 이 되는 경로를 전부 닫았다. 2026-09-30 09:26 에 유한양행 8주가 -6.49%/-40,000원 손절된 것이 직접 계기다. ## 스윙 자동편입 차단 - 강등 허용(swing→external). 기존엔 승격만 가능해 유입 잔고가 영구히 자동 손절 대상이었다. 3중 방어로 정상 포지션 강등을 막는다: system_buy_registry(주문 즉시 인메모리) / 연속2회+120초 / db_worker 큐 적체 보류 ※ 레이스는 이론이 아니다 — _verify_and_sync_execution 이 매수 직후 _delayed_sync_portfolio(0) 을 지연 0초로 부른다 - 수량 인식 분류(_classify_holding_strategy_type_v2): _sys<=0 --- backend/app/api/endpoints/manual_buy.py | 6 + backend/app/api/endpoints/portfolio.py | 811 ++++++++++++- backend/app/api/endpoints/portfolio_v2.py | 4 + backend/app/core/config.py | 19 + backend/app/services/day_trading_strategy.py | 147 ++- .../app/services/daytrading/entry_service.py | 181 ++- backend/app/services/db_worker.py | 249 +++- backend/app/services/korea_investment_api.py | 14 +- .../app/services/portfolio_cache_bridge.py | 32 +- backend/app/services/system_buy_registry.py | 129 ++ backend/app/services/trade_executor.py | 33 + backend/app/services/us_market_scheduler.py | 7 + backend/tests/test_bugfixes_20260923.py | 130 +- ...aytrading_atr_session_boundary_20260930.py | 570 +++++++++ ...test_daytrading_watchlist_size_20260930.py | 530 ++++++++ .../test_dt_warmup_and_orb_volume_20260929.py | 14 +- backend/tests/test_external_demotion_race.py | 1078 +++++++++++++++++ .../tests/test_external_inflow_isolation.py | 93 +- .../tests/test_restart_durability_20260928.py | 598 ++++++++- 19 files changed, 4519 insertions(+), 126 deletions(-) create mode 100644 backend/app/services/system_buy_registry.py create mode 100644 backend/tests/test_daytrading_atr_session_boundary_20260930.py create mode 100644 backend/tests/test_daytrading_watchlist_size_20260930.py create mode 100644 backend/tests/test_external_demotion_race.py diff --git a/backend/app/api/endpoints/manual_buy.py b/backend/app/api/endpoints/manual_buy.py index 0c982d9..babd0f9 100644 --- a/backend/app/api/endpoints/manual_buy.py +++ b/backend/app/api/endpoints/manual_buy.py @@ -138,6 +138,12 @@ async def _buy_stock_impl(request: BuyRequest, stock_code: str, db: AsyncSession raise HTTPException(status_code=500, detail=f"매수 주문 실패: {msg}") order_id = (order_result.get("output") or {}).get("ODNO") + # [2026-09-30] 유입 잔고 강등 레이스 방어 — DB(TradeHistory) 반영은 db_worker + # 큐를 거쳐 비동기라, 3초 뒤 `_delayed_sync_portfolio` 가 먼저 돌면 근거가 없어 + # 보인다. 주문 성공 시점에 인메모리 근거를 즉시 남긴다. + from app.services.system_buy_registry import note_system_buy + note_system_buy(stock_code, quantity) + # ── DB 기록 (db_worker 큐) ──────────────────────────────────────────── from app.services.db_worker import enqueue_trade_history, enqueue_portfolio_upsert now = _kst_now() diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py index c33a53d..9837370 100644 --- a/backend/app/api/endpoints/portfolio.py +++ b/backend/app/api/endpoints/portfolio.py @@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm.exc import StaleDataError from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, or_ +from sqlalchemy import select, func, or_, case from typing import List, Optional from decimal import Decimal from pydantic import BaseModel, Field @@ -41,6 +41,146 @@ KST = pytz.timezone('Asia/Seoul') EXTERNAL_STRATEGY_TYPE = "external" # sync 가 자동 시딩한 ActiveTrade 전략명 — '시스템 매수 근거'로 인정하지 않는다. _SEEDED_AT_STRATEGY_NAMES = ("synced",) +# sync 시딩 ActiveTrade 의 entry_reason 접두사. +# [2026-09-30] strategy_name='synced' 규약은 2026-09-29(7abbb34) 에 도입됐고, 그 +# 이전(6207d13)에는 `strategy_name = Portfolio.strategy_type` 즉 제네릭 **'swing'** +# 으로 시딩했다. 그 legacy 행은 strategy_name 만 보면 시딩인지 알 수 없어 '시스템 +# 매수 근거'로 오인되고, 유입 잔고 격리가 스스로 풀린다(강등 불가). entry_reason +# 은 두 시기 모두 동일 문구라 시딩 판별의 유일한 불변 표식이다. +_SEEDED_AT_ENTRY_REASON_PREFIX = "외부 유입 포지션 — 동기화 시 자동 등록" + +# ── '시스템 매수 근거' 로 인정하지 않는 BUY status (거부목록) ──────────────── +# [M1 2026-09-30] 이 판정은 **허용목록이면 안 된다.** +# 허용목록(`status.in_([...])`)은 값이 하나라도 빠지면 근거가 있는데도 못 찾아 +# 정상 보유를 external 로 강등시킨다 — 손절·익절이 조용히 사라진다. +# 반대로 거부목록은 미지의 status 를 자동으로 "근거 있음"으로 처리하므로 +# 불변식 "정상 포지션은 절대 강등되지 않는다" 를 **미래의 새 status 값에 대해서도** +# 지킨다. 유입 잔고 격리가 늦어지는 쪽은 허용 가능하고, 오강등은 허용 불가다. +# +# [실측 2026-09-30 11:39 KST] 운영 원장 전수(GET /api/v1/trades?days=800&limit=5000 +# → 352건, 2026-04-02~09-30)의 status 분포는 **{'COMPLETED': 352}** — 단 하나다. +# 즉 이 수정은 **현재 영향이 없는 순수 방어**이며, 추측이 아니라 실측이다. +# 그리고 코드베이스에서 +# 'FILLED'/'EXECUTED' 를 **쓰는 곳은 없다**(읽는 쪽 5개 파일의 방어적 레거시 값). +# 실제 writer 가 내는 값은 COMPLETED / PARTIAL / PENDING / FAILED 네 개이고, +# 이전 허용목록 `["EXECUTED","COMPLETED","PARTIAL"]` 의 진짜 구멍은 'FILLED' 가 +# 아니라 **'PENDING'** 이었다: `trade_executor._execute_buy` 는 KIS rt_cd=="1" +# (주문접수·체결 미확인)에 PENDING 을 기록하고 `_verify_and_sync_execution` 이 +# 나중에 COMPLETED/PARTIAL 로 갱신한다. 체결 확인이 실패하면 주식은 보유 중인데 +# 원장은 영구 PENDING 으로 남아 강등 대상이 된다. +# +# 'FAILED'(주문 거부)만 제외한다 — 주식이 생기지 않으므로 Portfolio 행 자체가 +# 없고, 근거로 인정하면 '매수 실패' 만으로 유입 잔고가 격리를 면한다. +# +# ⚠️ 다른 5개 파일(`trade_analyzer`·`schedulers`·`journals`·`risk_management`)의 +# 허용목록과 **통합하지 않았다.** 그쪽은 손익·성과 집계라 "확정 체결"만 세야 하고 +# (PENDING 을 넣으면 실현손익이 오염된다) 여기는 "매수 시도 흔적이 있는가"로 +# 의미가 다르다. 같은 상수로 묶으면 한쪽을 고칠 때 반대쪽이 조용히 망가진다. +_NON_FILL_BUY_STATUSES = ("FAILED",) + +# ── 유입 잔고 강등(swing → external) 확정 규칙 ────────────────────────────── +# [2026-09-30] 사용자 결정: "스윙으로 편입하는 건 없어야 해" → 승격뿐 아니라 강등도 +# 허용한다. 단 **정상 포지션은 절대 강등되지 않는다**가 불변식이다. +# +# 레이스: TradeHistory·ActiveTrade 는 둘 다 db_worker 큐를 거쳐 비동기 INSERT 되고 +# (§3.2), 매수 직후 `trade_executor._verify_and_sync_execution()` 이 +# `_delayed_sync_portfolio(0)` 로 즉시 sync 를 돌린다. 그 창에서 강등하면 방금 산 +# 포지션이 손절·익절·트레일링을 통째로 잃는다. +# +# 3중 방어: +# 1) `system_buy_registry` — 주문 성공 시점의 **즉시 인메모리 근거**(주 방어). +# DB 반영 전이라도 근거가 보인다. +# 2) 연속 관측 + 최소 경과시간 — 프로세스 재기동으로 1)이 비었을 때의 2차 방어. +# 같은 종목이 서로 다른 sync 에서 _DEMOTE_CONFIRM_SYNCS 회 이상 근거 없이 +# 관측되고, 첫 관측으로부터 _DEMOTE_MIN_AGE_SEC 이상 지나야 강등한다. +# (매수 직후 sync 가 여러 번 연달아 도는 경우를 횟수만으로는 못 막는다 → +# 벽시계 하한을 함께 둔다.) +# 3) db_worker 큐가 비어 있지 않으면 그 사이클은 강등하지 않는다 — 재시도 +# 백오프(1→2→4초)로 근거 INSERT 가 밀려 있을 수 있다. +# 반대 방향(유입 잔고 격리가 최대 수 분 늦어짐)은 허용 가능하다. +_DEMOTE_CONFIRM_SYNCS = 2 +_DEMOTE_MIN_AGE_SEC = 120.0 +# {stock_code: [첫 미관측 monotonic 시각, 미관측 관측 횟수]} +_demote_miss_state: dict = {} + + +def _demote_state_reset(stock_code: str = None) -> None: + """강등 미관측 카운터 초기화 (테스트·근거 확인 시).""" + if stock_code is None: + _demote_miss_state.clear() + else: + _demote_miss_state.pop(stock_code, None) + + +def _db_queue_busy() -> bool: + """db_worker 큐에 미처리 태스크가 남아 있는가 (강등 보류 판단용). + + 조회 실패는 **보류(True)** 로 해석한다 — 불확실할 때 강등하지 않는 쪽이 안전하다. + """ + try: + from app.services.db_worker import get_db_task_queue + return get_db_task_queue().qsize() > 0 + except Exception as e: + logger.warning(f"db_worker 큐 상태 조회 실패 → 강등 보류: {e}") + return True + + +def _should_demote_to_external( + stock_code: str, + current_type: str, + classified: str, + queue_busy: bool, + now: float = None, +) -> bool: + """기존 Portfolio 행을 swing → external 로 강등할지 판정 (부수효과=미관측 카운터). + + Args: + stock_code: 종목코드 + current_type: 현재 `Portfolio.strategy_type` + classified: `_classify_holding_strategy_type()` 결과 + queue_busy: db_worker 큐에 미처리 태스크가 있는가 + now: monotonic 시각 (테스트 주입용) + + Returns: + bool: True 면 강등. 판정 근거는 위 _DEMOTE_* 주석 참조. + """ + import time as _time + _now = _time.monotonic() if now is None else float(now) + + # 근거가 보이면 즉시 카운터 소멸 — 이후 어떤 사이클도 누적을 물려받지 않는다. + if classified != EXTERNAL_STRATEGY_TYPE: + _demote_state_reset(stock_code) + return False + # 강등 대상은 제네릭 'swing' 뿐이다. 'manual'·'us_swing'·'daytrading' 은 + # 애초에 get_swing_holdings(strategy_type=='swing') 에 안 잡혀 스윙 자동 청산 + # 대상이 아니고, 각 엔진이 strategy_name 으로 자기 행을 매칭한다. + if (current_type or "swing") != "swing": + return False + # 1차 방어: 주문 성공 직후의 즉시 인메모리 근거 + try: + from app.services.system_buy_registry import has_recent_system_buy + if has_recent_system_buy(stock_code): + _demote_state_reset(stock_code) + return False + except Exception as e: + logger.warning(f"system_buy_registry 조회 실패 → 강등 보류 ({stock_code}): {e}") + return False + + # 2차 방어: 연속 관측 + 최소 경과시간 + st = _demote_miss_state.get(stock_code) + if st is None: + _demote_miss_state[stock_code] = [_now, 1] + return False + st[1] += 1 + first_ts, count = st[0], st[1] + + # 3차 방어: db_worker 큐가 밀려 있으면 이번 사이클은 보류 (카운터는 유지) + if queue_busy: + return False + if count < _DEMOTE_CONFIRM_SYNCS or (_now - first_ts) < _DEMOTE_MIN_AGE_SEC: + return False + _demote_state_reset(stock_code) + return True def _external_stop_breaches(holdings: list) -> list: @@ -105,6 +245,472 @@ def _classify_holding_strategy_type( return "swing" return EXTERNAL_STRATEGY_TYPE + +def _classify_holding_strategy_type_v2( + stock_code: str, + quantity: int, + dt_qty: dict, + system_codes: set, + system_qty: dict, +) -> str: + """`_classify_holding_strategy_type` + **수량 인식** 판정 (순수 함수). + + ## 왜 v2 가 필요한가 — 부분 혼재가 격리를 세탁한다 (2026-09-30 운영 실측) + + `Portfolio.stock_code` 는 UNIQUE 라 같은 종목의 유입분·스윙·DT 수량이 **한 행에 + 합산**된다(CLAUDE.md §3.5). v1 은 "DT 전량이면 daytrading, 부분 혼재면 스윙 청산 + 대상 유지" 였기 때문에 유입 잔고에 시스템 매수가 1주라도 섞이면 **전체가 swing + 으로 승격**됐다: + + 11:35 105560 KB금융 external 3주 ← 유입 잔고, 올바르게 격리됨 + 11:45 105560 BUY 3주 (DT VWAP 반등) ← DT 가 3주 매수 + 12:42 105560 KB금융 swing 6주 ← external → swing 세탁 + + 유입 3주가 스윙 자동 손절 대상이 됐다. 사용자 결정("스윙으로 편입하는 건 없어야 + 해")의 정면 위반이고, 000100 과 같은 고아 청산으로 이어지는 경로다. + + ## 추가 분기 (v1 대비 단 하나) + + 시스템 근거 수량이 **정확히 DT 보유분과 같고** 행 수량보다 작으면 → `external`. + 즉 "행에 섞인 시스템 몫이 전부 DT" 인 경우다. 이때 external 유지는 **관리 공백을 + 만들지 않는다** — DT 엔진은 `day_trading_state.positions`(인메모리)로 자기 수량을 + 관리하고 `Portfolio.strategy_type` 을 보지 않으며, 15:20 EOD 에 전량 청산한다. + 청산되면 원장 net(BUY−SELL)이 0 으로 돌아와 순수 유입분만 남는다 → 태그가 + `swing` 으로 굳는 문제가 원천적으로 사라진다. + + ## 왜 `==` 인가 (`<=` 가 아니라) + + `sys_qty <= dt_held` 로 하면 원장이 스윙 몫을 **과소 집계**했을 때(체결 이력 유실) + 스윙 보유분이 있는 행까지 external 이 되어 손절 관리를 잃는다. `==` 는 과소 집계 + 시 분기가 **발동하지 않아** swing 으로 남는다 = 안전한 방향으로 실패한다. + 호출부의 `system_qty` 산출도 같은 이유로 **과대 집계 쪽으로 편향**시킨다 + (BUY 는 PENDING 의 order_quantity 까지 세고, SELL 은 확정 체결만 차감). + + ## 스윙이 섞인 혼재(`dt_held == 0`)는 `swing` 을 유지한다 + + 그쪽을 external 로 돌리면 **시스템이 산 수량이 손절·익절 밖에 남는다** + (CLAUDE.md §8.1-11 위반). 대신 ① `trade_executor` 가 external 행에 대한 스윙 + 추가매수를 차단해 이 혼재가 **발생하지 않게** 하고 ② 기존 행은 호출부가 + `⚠️ 유입분 혼재` warning 으로 노출한다. 근본 해결(스윙 청산 시 유입 수량 차감)은 + `swing/exit_service` 변경이 필요해 이 작업 범위 밖이다. + + Args: + stock_code: 종목코드 + quantity: KIS 잔고 수량 (= Portfolio 행 수량) + dt_qty: 데이트레이딩 인메모리 보유 수량 {code: qty} + system_codes: 시스템 매수 근거가 있는 종목 집합 (v1 과 동일) + system_qty: 시스템 매수 근거 **수량** {code: qty} — `_compose_system_quantities` + + Returns: + "daytrading" | "swing" | "external" + + ## 한계 — 불변식이 보장되지 않는 유일한 경로 (M-3, 2026-09-30) + + "정상 포지션은 절대 강등되지 않는다" 는 `system_qty > 0` 에 의존하고, 그 값은 + **네 소스의 하한 합성**이다(원장 net / 비DT active ActiveTrade 잔량 / DT 인메모리 / + system_buy_registry). 따라서 **네 개가 동시에 0 이 되어야** 정상 포지션이 external + 이 된다. 구체적으로 다음이 **전부** 성립할 때다: + + ① 원장 net ≤ 0 — 같은 매도가 중복 INSERT 되어 수량이 두 번 차감됨 + (`db_worker.insert_trade_history` 는 멱등이 아니고 DLQ 가 기록 계열을 + 24시간 내 재큐잉한다). **`order_id` 단위 중복 제거로 막았으나** + `order_id` 가 NULL 인 행은 중복 판정이 불가능해 남는다. + ② 비DT active ActiveTrade 가 없음 — 정합 루프가 `_actual == 0` 으로 closed + 시킨 뒤 재생성되지 않은 경우. + [2026-09-30] `db_worker` 의 `close_active_trade` 가 상태 계열에서 빠지고 + **충돌 판정만** 받게 바뀌어(나이 상한 7일) 유령 active 행이 줄었다 — + "닫아라" 지시가 시간이 지나도 거짓이 되지 않으므로 유실이 줄고, 동시에 + 유령 행이 유입 잔고를 "시스템 보유" 로 오인시키는 경로도 약해졌다. + 즉 이 하한의 **정확도가 양방향으로 올라갔다**. + ③ DT 인메모리에도 없음 (스윙 포지션이므로 정상) + ④ registry TTL(15분) 경과 + + ①~④ 동시 성립은 확률이 낮지만 **불가능하지는 않다.** 발생하면 그 포지션은 자동 + 손절·익절에서 빠지고 `/portfolio/summary` 의 `external_stop_breaches` 와 + `🚧 유입 잔고 격리` warning 으로 노출된다(조용히 사라지지는 않는다). + 근본 해결은 `insert_trade_history` 의 멱등화(order_id UNIQUE)이며 별도 작업이다. + """ + _base = _classify_holding_strategy_type(stock_code, quantity, dt_qty, system_codes) + if _base != "swing": + return _base + _dt_held = int(dt_qty.get(stock_code, 0) or 0) + _sys = int(system_qty.get(stock_code, 0) or 0) + if _sys <= 0 < int(quantity): + # 근거가 '있었지만' 수량이 남아 있지 않다 → 남은 수량은 유입분이다. + # [2026-09-30] 이 분기가 없으면 DT 가 EOD 청산한 뒤에도 과거 BUY 기록 때문에 + # `system_codes` 멤버십이 영구히 유지돼 순수 유입분이 swing 으로 되돌아간다 + # (105560 사건의 후속 경로 — 팀리드 요구사항 2). + # 과소 집계 위험은 호출부가 막는다: 원장 net 외에 **비시딩 ActiveTrade 잔량**, + # **DT 인메모리 수량**, **system_buy_registry 주문 수량**(원장 INSERT 전)을 + # 모두 하한으로 합성하므로, 살아 있는 시스템 포지션은 _sys > 0 이 보장된다. + return EXTERNAL_STRATEGY_TYPE + if 0 < _sys < int(quantity) and _sys == _dt_held: + # 혼재 + 시스템 몫이 전부 DT → 격리 유지 (DT 는 인메모리로 자기 수량 관리) + return EXTERNAL_STRATEGY_TYPE + return "swing" + + +def _system_buy_codes_stmt(): + """시스템 매수 근거가 있는 종목코드 statement (수량 무관) — `(stock_code,)`. + + status 는 **거부목록**으로 판정한다(`_NON_FILL_BUY_STATUSES`) — 허용목록은 값이 + 하나 빠지는 순간 정상 보유를 강등시킨다. 기간 제한은 두지 않는다: ghost 필터처럼 + 당일만 보면 자정을 넘긴 유입 잔고가 다시 정상 보유로 재편입된다. + """ + return ( + select(TradeHistory.stock_code) + .where( + TradeHistory.order_type == "BUY", + or_( + TradeHistory.status.is_(None), + TradeHistory.status.notin_(_NON_FILL_BUY_STATUSES), + ), + ) + .distinct() + ) + + +def _evidence_active_trade_codes_stmt(): + """'시스템 매수 근거' 로 인정하는 ActiveTrade 종목코드 statement — `(stock_code,)`. + + ActiveTrade 행도 근거로 인정한다(체결 이력 유실 대비). 단 sync 가 유입 포지션에 + 대해 자동 시딩한 행은 근거가 아니다 — 근거로 쓰면 external → swing 자동 승격이라는 + 자기순환이 생긴다. 시딩 판별은 **두 시기 모두** 걸러야 한다: + + · `strategy_name == 'synced'` (2026-09-29 `7abbb34` 이후 규약) + · `entry_reason LIKE '외부 유입 포지션 — 동기화 시 자동 등록%'` + (그 이전 `6207d13` 는 `strategy_name = Portfolio.strategy_type` 즉 제네릭 + 'swing' 으로 시딩해 이름만으로는 구별이 불가능하다) + + entry_reason 필터는 **양방향**으로 필요하다: 빼면 유입 잔고가 자기 시딩 행을 + 근거로 삼아 ① 강등이 영구 불가해지고 ② 이미 격리된 행이 오승격된다. + """ + return ( + select(ActiveTrade.stock_code) + .where( + or_( + ActiveTrade.strategy_name.is_(None), + ActiveTrade.strategy_name.notin_(_SEEDED_AT_STRATEGY_NAMES), + ), + or_( + ActiveTrade.entry_reason.is_(None), + ActiveTrade.entry_reason.notlike( + f"{_SEEDED_AT_ENTRY_REASON_PREFIX}%" + ), + ), + ) + .distinct() + ) + + +def _system_buy_qty_stmt(): + """원장 BUY 수량 합계 statement — `(stock_code, buy_qty)`. + + **과대 집계 편향**: `executed_quantity` 가 0/NULL 인 PENDING 행은 `order_quantity` + 로 센다 — 주문접수 후 체결 확인이 실패하면 주식은 보유 중인데 executed 가 0 으로 + 남는다. 수량이 크게 나오면 "시스템 보유분" 으로 판정돼 관리를 유지하므로 오판의 + 방향이 안전하다(CLAUDE.md §8.1-11). + + BUY 는 `order_id` 중복 제거를 **하지 않는다** — 중복이 있으면 수량이 커지고, 그 + 방향은 안전하다. (SELL 은 반대이므로 제거한다: `_system_sell_qty_stmt`) + """ + _q = func.coalesce( + func.nullif(TradeHistory.executed_quantity, 0), + TradeHistory.order_quantity, + 0, + ) + return ( + select(TradeHistory.stock_code, func.sum(_q)) + .where( + TradeHistory.order_type == "BUY", + or_( + TradeHistory.status.is_(None), + TradeHistory.status.notin_(_NON_FILL_BUY_STATUSES), + ), + ) + .group_by(TradeHistory.stock_code) + ) + + +def _system_sell_qty_stmt(): + """원장 SELL 수량 statement — `(stock_code, dedupe_key, qty)`. 합산은 호출부에서. + + ## SELL 은 **양방향 모두 위험**하다 — 정확해야 한다 (2026-09-30 정정) + + 이전 판 docstring 은 "SELL 과대 집계는 위험, 과소는 안전" 이라고 썼는데 **틀렸다.** + 강등만 있던 시점의 논증이었고, 같은 작업에서 **승격 경로가 함께 살아났기 때문에** + 양쪽이 다 위험하다: + + · **과대 집계**(중복 차감) → `_sys` 축소 → 정상 포지션이 유입분으로 보여 **강등** + (손절·익절 상실) + · **과소 집계**(중복 접힘) → `_sys` 확대 → 유입 행이 `_sys >= quantity` 로 보여 + **승격**(유입분이 스윙 자동 손절 대상) + 근거가 계속 보여 강등 불가(고착) + + ## 중복 제거 키 + + `db_worker.insert_trade_history` 는 멱등이 아니고 DLQ 가 기록 계열 태스크를 + 재큐잉하므로 같은 매도가 두 행으로 들어올 수 있다. 같은 KIS 주문은 `order_id` 가 + 같으므로 그 단위로 1건만 센다. + + ⚠️ **`order_id` 는 NULL 이 아니라 빈 문자열 `''` 로 저장된다.** + `trade_helpers.build_kis_order_id()` 가 미발견 시 `""` 를 반환하고 + (`swing/exit_service.py` 의 `.get("ODNO", "")` 도 동일), 그 행은 + `status="COMPLETED"` + `executed_quantity` 가 채워져 FAILED 필터에 걸리지 않는다. + 따라서 `coalesce(order_id, ...)` 만으로는 `''` 를 통과시켜 **서로 다른 매도들이 한 + 그룹으로 접히고 `max()` 만 남는다**(분할매도 2회로 재현). `nullif(trim(...), '')` + 로 빈 문자열·공백 문자열을 NULL 로 환산한 뒤 `id` 폴백을 쓴다. + `trim` 을 키에 쓰므로 패딩만 다른 같은 주문번호도 같은 키가 된다. + + `order_id` 가 비어 있는 행은 중복 판정이 불가능해 `id` 로 개별 계상된다 = 중복 + 차감 위험이 남는 유일한 경로다(`_classify_holding_strategy_type_v2` docstring 의 + '한계' 항에 명시). + + 확정 체결(`executed_quantity`)만 차감한다 — 미체결 매도를 차감하면 보유분을 + 과소 집계한다. + """ + _key = func.coalesce( + func.nullif(func.trim(TradeHistory.order_id), ""), + func.concat("id:", TradeHistory.id), + ) + return ( + select( + TradeHistory.stock_code, + _key, + func.max(func.coalesce(TradeHistory.executed_quantity, 0)), + ) + .where( + TradeHistory.order_type == "SELL", + or_( + TradeHistory.status.is_(None), + TradeHistory.status.notin_(_NON_FILL_BUY_STATUSES), + ), + ) + .group_by(TradeHistory.stock_code, _key) + ) + + +def _active_trade_qty_stmt(): + """비시딩·**비DT** active ActiveTrade 잔량 statement — `(stock_code, qty)`. + + ## 왜 DT 행을 제외하는가 (C-1, 2026-09-30) + + `sync_portfolio` 의 ActiveTrade 정합 루프는 "같은 종목 active 행이 1개면 그 행이 + 전량을 대표한다"고 보고 **Portfolio 수량으로 교정**한다. 유입 3주 + DT 3주 = 6주 + 행에서 active 행은 DT 1개뿐이므로, DT 행 수량이 **3 → 6 으로 부풀려진다.** + 그 값을 근거 수량으로 쓰면 다음 sync 에서 `_sys = 6 = quantity` 가 되어 + **유입분이 swing 으로 승격되고, 승격 후에는 근거가 계속 보여 강등이 영구 불가** + (고착)가 된다. 즉 DT ActiveTrade 잔량은 구조적으로 'DT 소유 수량' 의 근거가 될 + 수 없다. + + DT 의 정본 수량은 `day_trading_state.positions`(인메모리)이고 호출부가 그것을 + 별도 하한으로 합성한다. 여기서는 DT 행을 뺀다. + + DT 판별은 `db_worker.not_dt_strategy_clause()` **단일 정본**을 쓴다 + (CLAUDE.md §3.3 — 한 곳만 다르게 고치면 더 나쁜 불일치가 된다). + + `status == "active"` 한정이고 집계는 `max` 다(`sum` 아님): 2026-09-23 에 + active_trades 유령 행 129개가 실재했으므로(§8.4.1) 중복 행이 수량을 부풀리면 + 유입분이 시스템 보유로 오인된다. + """ + from app.services.db_worker import not_dt_strategy_clause + + return ( + select(ActiveTrade.stock_code, func.max(ActiveTrade.quantity)) + .where( + ActiveTrade.status == "active", + not_dt_strategy_clause(ActiveTrade.strategy_name), + or_( + ActiveTrade.strategy_name.is_(None), + ActiveTrade.strategy_name.notin_(_SEEDED_AT_STRATEGY_NAMES), + ), + or_( + ActiveTrade.entry_reason.is_(None), + ActiveTrade.entry_reason.notlike( + f"{_SEEDED_AT_ENTRY_REASON_PREFIX}%" + ), + ), + ) + .group_by(ActiveTrade.stock_code) + ) + + +def _compose_system_quantities( + buy_rows, + sell_rows, + at_rows, + dt_qty: dict, + registry_qty: dict, +) -> dict: + """시스템 매수 근거 **수량** {code: qty} 합성 (순수 함수). + + 원장 net(BUY − SELL)을 기본값으로 하고, 세 소스를 **하한(max)** 으로 덮는다. + 하한이 여러 개인 이유는 어느 하나가 무너져도 살아 있는 시스템 포지션의 수량이 + 0 으로 보이지 않게 하기 위함이다(= 정상 포지션이 유입분으로 오인되지 않는다). + + Args: + buy_rows: `_system_buy_qty_stmt()` 결과 `[(code, qty), ...]` + sell_rows: `_system_sell_qty_stmt()` 결과 `[(code, key, qty), ...]` — 키 단위 + 행이므로 여기서 종목별로 합산한다(order_id 중복은 이미 제거됨) + at_rows: `_active_trade_qty_stmt()` 결과 `[(code, qty), ...]` + dt_qty: DT 인메모리 보유 수량 + registry_qty: `system_buy_registry.snapshot_quantities()` (원장 INSERT 전 창) + + Returns: + dict[str, int] + """ + out: dict = {} + for _c, _q in buy_rows: + if _c: + out[_c] = out.get(_c, 0) + int(_q or 0) + # (code, dedupe_key) 단위로 1건만 센다. SQL 이 이미 GROUP BY 로 접었지만 여기서도 + # 접어야 순수 함수만으로 중복 제거를 검증할 수 있다(그리고 두 겹이 손해가 없다). + _sold_by_key: dict = {} + for _row in sell_rows: + _c, _q = _row[0], _row[-1] + if not _c: + continue + _k = _row[1] if len(_row) >= 3 else None + # SQL 과 같은 규칙: 빈 문자열·공백 키는 '중복 판정 불가' 이므로 접지 않는다. + # (order_id 는 NULL 이 아니라 '' 로 저장된다 — _system_sell_qty_stmt docstring) + if _k is None or not str(_k).strip(): + _sold_by_key[(_c, f"__row{len(_sold_by_key)}__")] = int(_q or 0) + continue + _k = str(_k).strip() + _sold_by_key[(_c, _k)] = max(_sold_by_key.get((_c, _k), 0), int(_q or 0)) + _sold: dict = {} + for (_c, _k), _q in _sold_by_key.items(): + _sold[_c] = _sold.get(_c, 0) + _q + for _c, _q in _sold.items(): + out[_c] = out.get(_c, 0) - _q + out = {_c: max(0, _q) for _c, _q in out.items()} + for _src in (at_rows, (dt_qty or {}).items(), (registry_qty or {}).items()): + for _c, _q in _src: + if _c: + out[_c] = max(out.get(_c, 0), int(_q or 0)) + return out + + +def _should_correct_active_trade_quantity( + portfolio_strategy_type: str, + active_row_count: int, + actual_quantity: int, + system_quantity: int = None, +) -> bool: + """ActiveTrade 잔량을 Portfolio 수량으로 교정해도 되는가 (순수 함수). + + 교정의 전제는 **"active 행 1개 = 그 행이 Portfolio 수량 전량을 대표한다"** 다. + `strategy_type == 'external'` 행에서는 그 전제가 거짓이다 — 행 수량에 시스템이 + 소유하지 않은 유입분이 섞여 있다. 그런 행에서 교정하면 + + · 유입 3주 + DT 3주 = 6주 → DT ActiveTrade 가 3 → 6 으로 부풀려지고 + · 그 값이 다음 sync 의 '시스템 근거 수량' 이 되어 유입분이 swing 으로 승격되며 + (승격 후에는 근거가 계속 보여 강등이 영구 불가 = 고착) + · DT 청산 시 잔량 6 으로 과다매도·주문거부 위험(CLAUDE.md §3.5) + + **유령 행 정리(`actual_quantity == 0` → closed)는 이 함수와 무관하게 계속 수행해야 + 한다** — 그쪽은 "보유가 없다"는 사실만 쓰고 수량 귀속을 전제하지 않는다. + 호출부가 `actual_quantity == 0` 분기를 먼저 처리하므로 여기서는 방어적으로만 본다. + + Args: + portfolio_strategy_type: `Portfolio.strategy_type` + active_row_count: 그 종목의 status='active' ActiveTrade 행 수 + actual_quantity: 실제 보유 수량(= Portfolio 수량) + system_quantity: 시스템 매수 근거 수량. **durable 값**(레지스트리 제외)을 넘긴다 + — 휘발성 근거로 ActiveTrade 수량을 영구 변경하면 안 된다. + `None`(미제공)은 "전제 검증 불가" 로 해석해 **교정하지 않는다**. + + Returns: + bool: True 면 교정 가능. + """ + if int(active_row_count) != 1: + return False # 스윙+DT 혼재 등 → 자동 교정하지 않음 (기존 규칙) + if int(actual_quantity) <= 0: + return False # 정리(closed) 대상 — 수량 교정 경로가 아니다 + if (portfolio_strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE: + return False # 유입분이 섞여 있어 수량을 어느 전략에도 귀속시킬 수 없다 + # [M-3 2026-09-30] **전제를 직접 검증한다.** 태그만 보는 것으로는 부족했다 — + # 유입 3 + 스윙 3 = 6 행은 `_classify_holding_strategy_type_v2` 가 `swing` 을 + # 유지하므로(혼재 분기는 `_sys == _dt_held` 일 때만 발동) external 예외를 + # 빠져나가 교정이 통과했고, 스윙 ActiveTrade 가 3 → 6 으로 부풀어 + # ① 다음 스윙 청산이 **유입 3주까지 매도**(§3.5 과다매도·주문거부, §8.1-11 위반) + # ② 부풀린 6 이 `_active_trade_qty_stmt` 하한으로 돌아와 `_sys = 6 = quantity` + # → 승격 후 근거가 계속 보여 **영구 고착** + # 이 되었다. 근거 수량이 행 수량을 덮을 때만 "그 행이 전량을 대표한다"가 참이다. + if system_quantity is None: + return False # 근거 수량 미제공 → 전제를 검증할 수 없다 → 교정하지 않는다 + if int(system_quantity) < int(actual_quantity): + return False # 설명되지 않는 수량(= 유입 추정분)이 남아 있다 + return True + + +def _reconcile_active_trade_quantities( + active_rows_by_code: dict, + held_qty: dict, + strategy_by_code: dict, + system_qty: dict = None, +) -> list: + """active ActiveTrade 행을 실제 보유 수량에 맞춘다 (ORM 객체를 **제자리 수정**). + + 2026-09-23 버그픽스의 본체다 — 분할매도·수동매도 누락으로 DB 잔량이 실제와 + 달라지면 다음 청산이 과다매도가 된다. `sync_portfolio` 에서 분리한 이유는 소스 + 문자열이 아니라 **동작으로 검증**할 수 있게 하기 위함이다(M-5). + + 두 가지를 한다: + 1. **유령 행 정리** — 보유 수량이 0 이면 `status='closed'`. 태그와 무관하게 + **항상** 수행한다(수량 귀속을 전제하지 않는 판정이므로). + 2. **잔량 교정** — `_should_correct_active_trade_quantity()` 가 허용할 때만 + `row.quantity = 실제 보유량`. + + Args: + active_rows_by_code: {stock_code: [status=='active' ActiveTrade 행, ...]} + held_qty: {stock_code: Portfolio 수량} (수량 > 0 인 행만) + strategy_by_code: {stock_code: Portfolio.strategy_type} + system_qty: {stock_code: 근거 수량} — **durable 값**(레지스트리 제외). + 미제공이면 어떤 행도 수량 교정하지 않는다(전제 검증 불가 → 보류). + + Returns: + list[dict]: 수행한 조치 `{"action": "closed"|"corrected"|"skipped_external", + "stock_code", "from", "to"}` — 호출부가 로그로 남긴다(§4: debug 금지). + """ + actions: list = [] + for _code, _rows in (active_rows_by_code or {}).items(): + _actual = int(held_qty.get(_code, 0) or 0) + _tag = strategy_by_code.get(_code, "swing") + if len(_rows) == 1 and _actual == 0: + _rows[0].status = "closed" + actions.append({"action": "closed", "stock_code": _code, + "from": int(_rows[0].quantity or 0), "to": 0}) + continue + _sys = None if system_qty is None else int(system_qty.get(_code, 0) or 0) + if not _should_correct_active_trade_quantity(_tag, len(_rows), _actual, _sys): + if len(_rows) == 1 and int(_rows[0].quantity or 0) != _actual: + # 태그가 external 이든 혼재 swing 이든 "설명되지 않는 수량이 있어 + # 교정을 보류했다" 는 사실은 같다 — 조용히 넘기지 않는다(§4). + actions.append({ + "action": "skipped_unattributable", + "stock_code": _code, + "from": int(_rows[0].quantity or 0), + "to": _actual, + "strategy_type": _tag, + "system_qty": _sys, + }) + continue + _row = _rows[0] + if int(_row.quantity or 0) != _actual: + actions.append({"action": "corrected", "stock_code": _code, + "from": int(_row.quantity or 0), "to": _actual}) + _row.quantity = _actual + return actions + + +def _mixed_inflow_surplus(stock_code: str, quantity: int, system_qty: dict) -> int: + """행 수량 중 시스템 근거로 설명되지 않는 수량(= 유입 추정분). 순수 함수. + + 가시화(warning) 전용이다 — 집행 판단에 쓰지 않는다. 원장 과대 집계 편향 때문에 + 실제 유입분보다 **작게** 나올 수 있다(0 이면 "혼재 아님"이 아니라 "확증 불가"). + """ + return max(0, int(quantity) - int(system_qty.get(stock_code, 0) or 0)) + # Request/Response Models class SellRequest(BaseModel): stock_code: str @@ -708,36 +1314,96 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) # 태깅이 없으면 sync 가 전부 'swing' 으로 편입하고 get_swing_holdings()가 # 곧바로 손절 판정을 내려 유입 잔고를 전략 손절로 처분했다 # (2026-04~09 고아 청산 9건 / -153,661원). - # 근거는 "기간 무제한" 이어야 한다 — ghost 필터처럼 당일만 보면 자정을 - # 넘긴 유입 잔고가 다시 정상 보유로 재편입된다. - _buy_hist_rows = await db.execute( - select(TradeHistory.stock_code) - .where( - TradeHistory.order_type == "BUY", - TradeHistory.status.in_(["EXECUTED", "COMPLETED", "PARTIAL"]), - ) - .distinct() - ) + # 근거 조회 규칙(status 거부목록 / 시딩 ActiveTrade 제외 2중 필터)은 + # `_system_buy_codes_stmt` · `_evidence_active_trade_codes_stmt` docstring 참조. + _buy_hist_rows = await db.execute(_system_buy_codes_stmt()) _system_bought = {c for c in _buy_hist_rows.scalars().all() if c} - # ActiveTrade 행도 근거로 인정한다(체결 이력 유실 대비). 단 sync 가 유입 - # 포지션에 대해 자동 시딩한 행(strategy_name='synced')은 근거가 아니다 — - # 근거로 쓰면 external → swing 자동 승격이라는 자기순환이 생긴다. - _at_rows_prov = await db.execute( - select(ActiveTrade.stock_code) - .where( - or_( - ActiveTrade.strategy_name.is_(None), - ActiveTrade.strategy_name.notin_(_SEEDED_AT_STRATEGY_NAMES), - ) - ) - .distinct() + _at_rows_prov = await db.execute(_evidence_active_trade_codes_stmt()) + # ── 근거 집합 두 벌: durable(레지스트리 제외) / with-registry ────── + # [M-2 2026-09-30] `system_buy_registry` 는 **강등을 막는 근거**로 설계됐다. + # 그런데 같은 집합을 승격 판정에도 쓰면 안전 방향이 뒤집힌다: + # · 강등 방어 → "근거가 있다고 보는 것"이 안전 (보수적 = 관리 유지) + # · 승격 판정 → "근거가 없다고 보는 것"이 안전 (보수적 = 격리 유지) + # 실제 사고 경로: 유입 3주 + DT 3주 행에서 DT 가 15:20 EOD 청산하면 원장 + # net·비DT active·DT 인메모리가 모두 0 이 되는데 레지스트리만 TTL 내라 + # `_sys=3=quantity` → **승격**되고 `strategy_type='swing'` 이 DB 에 굳는다. + # 장이 닫혀 다음 사이클이 오지 않으면 그대로 밤을 넘기고 **다음 날 개장 시 + # 유입분이 자동 손절 대상**이 된다(105560 과 같은 결말). + # → 승격은 durable 근거만 본다. 레지스트리는 신규 행 태깅과 강등 방어에만. + _system_codes_durable = ( + _system_bought | {c for c in _at_rows_prov.scalars().all() if c} | _dt_codes ) - _system_codes = _system_bought | {c for c in _at_rows_prov.scalars().all() if c} - _system_codes |= _dt_codes # 당일 DT 인메모리 포지션도 시스템 매수분 + try: + from app.services.system_buy_registry import snapshot_codes as _sbr_codes + _registry_codes = _sbr_codes() + except Exception as _sbr_err: + logger.warning(f"system_buy_registry 스냅샷 실패: {_sbr_err}") + _registry_codes = set() + _system_codes = _system_codes_durable | _registry_codes + + # ── 시스템 매수 근거 **수량** (부분 혼재 판정용) ─────────────────── + # [2026-09-30] 근거의 '유무'만으로는 부분 혼재를 못 잡는다 — 유입 3주에 + # DT 3주가 섞이자 6주 전체가 swing 으로 세탁된 사건(105560 KB금융). + # 편향·중복제거·DT 제외의 근거는 각 statement 빌더 docstring 참조. + _sys_buy = (await db.execute(_system_buy_qty_stmt())).all() + _sys_sell = (await db.execute(_system_sell_qty_stmt())).all() + _sys_at = (await db.execute(_active_trade_qty_stmt())).all() + try: + from app.services.system_buy_registry import ( + snapshot_quantities as _sbr_qty, + ) + _sbr_q = _sbr_qty() + except Exception as _sbq_err: + logger.warning(f"system_buy_registry 수량 스냅샷 실패: {_sbq_err}") + _sbr_q = {} + # durable = 레지스트리 제외 (승격·ActiveTrade 잔량 교정 판정용) + _system_qty_durable = _compose_system_quantities( + _sys_buy, _sys_sell, _sys_at, _dt_qty, {} + ) + _system_qty = _compose_system_quantities( + _sys_buy, _sys_sell, _sys_at, _dt_qty, _sbr_q + ) + + # db_worker 큐 적체 여부 — 강등 보류 판단(사이클당 1회 조회). + _queue_busy = _db_queue_busy() + + def _classify_durable(_code: str, _qty: int) -> str: + """레지스트리를 **제외한** 근거만으로 판정 — 승격 판정 전용 (M-2). + + 레지스트리는 "방금 주문했다" 는 휘발성 사실이라 `strategy_type` 을 + DB 에 영구 기록하는 승격의 근거로 쓸 수 없다. + """ + return _classify_holding_strategy_type_v2( + _code, _qty, _dt_qty, _system_codes_durable, _system_qty_durable + ) def _classify_strategy_type(_code: str, _qty: int) -> str: - """Portfolio 행의 strategy_type 판정 (모듈 순수 함수 위임).""" - return _classify_holding_strategy_type(_code, _qty, _dt_qty, _system_codes) + """Portfolio 행의 strategy_type 판정 (모듈 순수 함수 위임). + + [2026-09-30] v2 로 승격 — 부분 혼재(유입분 + DT)를 수량으로 구분한다. + """ + _t = _classify_holding_strategy_type_v2( + _code, _qty, _dt_qty, _system_codes, _system_qty + ) + # 혼재를 조용히 넘기지 않는다 (CLAUDE.md §4: logger.debug 금지) + _surplus = _mixed_inflow_surplus(_code, _qty, _system_qty) + if _surplus > 0: + if _t == EXTERNAL_STRATEGY_TYPE and _code in _system_codes: + logger.warning( + f"🧩 부분 혼재 — 격리 유지: {_code} 행 {_qty}주 중 " + f"시스템 근거 {_system_qty.get(_code, 0)}주(전량 DT) / " + f"유입 추정 {_surplus}주 → strategy_type='external' " + f"(DT 는 인메모리로 자기 수량을 관리하므로 관리 공백 없음)" + ) + elif _t == "swing": + logger.warning( + f"🧩 부분 혼재 — 스윙 유지: {_code} 행 {_qty}주 중 " + f"시스템 근거 {_system_qty.get(_code, 0)}주 / " + f"유입 추정 {_surplus}주 → strategy_type='swing' 유지. " + f"시스템 매수분의 손절·익절을 지키기 위한 선택이며, " + f"유입 추정분이 스윙 자동 청산에 함께 노출된다 — 수동 확인 필요" + ) + return _t # 기존 레코드 로드 (삭제 대신 업데이트) result = await db.execute(select(Portfolio)) @@ -827,16 +1493,35 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) # 전량이 DT 포지션일 때만 태깅(혼재 시 스윙 청산 대상 유지) if stock_code in _dt_codes and quantity <= _dt_qty.get(stock_code, 0): h.strategy_type = "daytrading" - elif (h.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE and _classified == "swing": - # 승격만 허용 — 시스템 매수가 실제로 발생한 뒤에만 운용 대상으로. - # 반대 방향(운용 중인 swing/daytrading/manual → external) 강등은 - # 하지 않는다: 체결 이력이 유실된 정상 포지션이 손절·익절 관리를 - # 통째로 잃는 쪽이 더 위험하다. + elif ( + (h.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE + and _classify_durable(stock_code, quantity) == "swing" + ): + # 승격 — 시스템 매수가 실제로 발생한 뒤에만 운용 대상으로. + # [M-2] durable 근거만 본다(레지스트리 제외) — 휘발성 근거로 + # DB 에 영구 기록하면 EOD 이후 격리가 밤새 풀린다. h.strategy_type = "swing" logger.warning( f"⬆️ 유입 격리 해제(승격): {stock_name}({stock_code}) " f"external → swing (시스템 매수 근거 확인)" ) + elif _should_demote_to_external( + stock_code, h.strategy_type or "swing", _classified, _queue_busy + ): + # [2026-09-30] 강등 — 사용자 결정 "스윙으로 편입하는 건 없어야 해". + # 레이스 방어 3중( system_buy_registry / 연속관측+경과시간 / + # db_worker 큐 )을 모두 통과한 경우만 여기 도달한다. + h.strategy_type = EXTERNAL_STRATEGY_TYPE + logger.warning( + f"⬇️ 유입 잔고 격리(기존 강등): {stock_name}({stock_code}) " + f"{quantity}주 평단={average_price} " + f"평가액={float(current_price) * quantity:,.0f}원 " + f"swing → external — 시스템 매수 근거 없음" + f"(TradeHistory BUY·비시딩 ActiveTrade·DT 인메모리 모두 부재, " + f"{_DEMOTE_CONFIRM_SYNCS}회 연속 확인 / " + f"{_DEMOTE_MIN_AGE_SEC:.0f}초 경과) " + f"→ 자동 손절·익절 대상 제외, 수동 판단 필요" + ) new_holdings.append(h) else: if _classified == EXTERNAL_STRATEGY_TYPE: @@ -924,12 +1609,29 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) # 전량이 DT 포지션일 때만 태깅한다(부분 혼재 시 스윙 청산 대상 유지). if stock_code in _dt_codes and quantity <= _dt_qty.get(stock_code, 0): h.strategy_type = "daytrading" - elif (h.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE and _classified == "swing": - # 승격만 허용 (국내 경로와 동일 규칙) + elif ( + (h.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE + and _classify_durable(stock_code, quantity) == "swing" + ): + # 승격 (국내 경로와 동일 규칙 — durable 근거만, M-2) h.strategy_type = "swing" logger.warning( f"⬆️ [해외] 유입 격리 해제(승격): {stock_name}({stock_code}) external → swing" ) + elif _should_demote_to_external( + stock_code, h.strategy_type or "swing", _classified, _queue_busy + ): + # 강등 (국내 경로와 동일 규칙 — 레이스 방어 3중 통과 시만) + h.strategy_type = EXTERNAL_STRATEGY_TYPE + logger.warning( + f"⬇️ [해외] 유입 잔고 격리(기존 강등): {stock_name}({stock_code}) " + f"{quantity}주 평단={average_price} " + f"평가액={float(current_price) * quantity:,.2f} " + f"swing → external — 시스템 매수 근거 없음 " + f"({_DEMOTE_CONFIRM_SYNCS}회 연속 확인 / " + f"{_DEMOTE_MIN_AGE_SEC:.0f}초 경과) " + f"→ 자동 손절·익절 대상 제외, 수동 판단 필요" + ) new_holdings.append(h) else: if _classified == EXTERNAL_STRATEGY_TYPE: @@ -981,19 +1683,33 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) _by_code: dict[str, list] = {} for _row in _at_rows: _by_code.setdefault(_row.stock_code, []).append(_row) - for _code, _rows in _by_code.items(): - if len(_rows) != 1: - continue # 스윙+DT 혼재 등 → 자동 교정하지 않음 - _row = _rows[0] - _actual = _held.get(_code, 0) - if _actual == 0: - _row.status = "closed" - logger.info(f"🧹 ActiveTrade 정리: {_code} 보유 없음 → closed") - elif int(_row.quantity or 0) != _actual: + # 정합 규칙은 `_reconcile_active_trade_quantities()` 가 정본이다 + # (유령 행 정리 + 잔량 교정, external 행은 교정 제외 — C-1). + _strategy_by_code = { + h.stock_code: (h.strategy_type or "swing") for h in new_holdings + } + for _act in _reconcile_active_trade_quantities( + _by_code, _held, _strategy_by_code, _system_qty_durable + ): + if _act["action"] == "closed": logger.info( - f"🧹 ActiveTrade 잔량 교정: {_code} {_row.quantity} → {_actual}" + f"🧹 ActiveTrade 정리: {_act['stock_code']} 보유 없음 → closed" + ) + elif _act["action"] == "corrected": + logger.info( + f"🧹 ActiveTrade 잔량 교정: {_act['stock_code']} " + f"{_act['from']} → {_act['to']}" + ) + else: # skipped_unattributable + logger.warning( + f"🧩 ActiveTrade 잔량 교정 생략: {_act['stock_code']} " + f"{_act['from']} → {_act['to']} " + f"(strategy_type='{_act['strategy_type']}', " + f"근거수량={_act['system_qty']} < 보유={_act['to']}) — " + f"설명되지 않는 수량(유입 추정분)이 있어 교정하면 다음 청산이 " + f"유입분까지 매도하고(과다매도) 부풀린 값이 근거로 되돌아와 " + f"격리가 영구 고착된다" ) - _row.quantity = _actual # 외부 유입 포지션(다른 인스턴스/증권사 앱 매수 등)은 ActiveTrade 행이 없어 # 손절·분할익절·트레일링 관리를 전혀 받지 못한다(2026-09-22 카카오뱅크 -13%). @@ -1061,6 +1777,11 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) except Exception as _rec_err: logger.warning(f"ActiveTrade 정합 실패(무시): {_rec_err}") + # 계좌에서 사라진 종목의 강등 미관측 카운터 정리 — 재편입 시 옛 누적을 + # 물려받아 첫 관측에 곧바로 강등되는 것을 막는다. + for _gone in [c for c in _demote_miss_state if c not in processed_codes]: + _demote_state_reset(_gone) + await db.commit() # [§8.5 항목4③] 외부 유입 포지션 ActiveTrade 시딩 — commit 성공 후 큐잉. diff --git a/backend/app/api/endpoints/portfolio_v2.py b/backend/app/api/endpoints/portfolio_v2.py index 28a9350..71b610a 100644 --- a/backend/app/api/endpoints/portfolio_v2.py +++ b/backend/app/api/endpoints/portfolio_v2.py @@ -366,6 +366,10 @@ async def _buy_stock_impl_v2(request: BuyRequest, stock_code: str, db: AsyncSess raise HTTPException(status_code=500, detail=f"매수 주문 실패: {msg}") order_id = (order_result.get("output") or {}).get("ODNO") + # [2026-09-30] 유입 잔고 강등 레이스 방어 (manual_buy.py 와 동일 이유) + from app.services.system_buy_registry import note_system_buy + note_system_buy(stock_code, quantity) + now = _kst_now() trade_history_payload = { "stock_code": stock_code, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 60d6840..8893539 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -274,6 +274,25 @@ class Settings(BaseSettings): # ═════════════════════════════════════════════════════════════════════════════ DAY_TRADE_ENABLED: bool = True # 데이트레이딩 활성화 DAY_TRADE_MAX_POSITIONS: int = 5 # 동시 최대 종목 수 (8→5, 2026-09-23 30% 배분: 5×6%=30%) + # 감시종목 수 (populate_daytrading_watchlist 의 상한). 20→30, 2026-09-30. + # 용도: ORB 돌파 후보 풀 확대 — 진입 임계값을 낮추지 않고 표본만 늘린다. + # ⚠️ 리스크 노출과 무관하다: 동시 보유는 DAY_TRADE_MAX_POSITIONS(5), 종목당 예산은 + # DAY_TRADE_MAX_CAPITAL_RATIO(6%), DT 합산은 DAY_TRADE_MAX_RATIO_DT(30%) 가 각각 + # 독립적으로 상한을 건다. 늘어나는 것은 기회(스캔 후보)뿐이다. + # 상한 근거(2026-09-30 산정, daytrading/entry_service 스캔 예산): + # 선조회 wall = 종목수 × 분봉 페이지수 × 페이지비용 ÷ _SCAN_PREFETCH_CONCURRENCY(8) + # - 페이지비용 3.2초 = 운영 실측 요청 3.0초(monitoring/api-stats avg_duration, + # 재시도 포함) + in-loop sleep 0.2초(API_MIN_REQUEST_INTERVAL+0.05) + # - 페이지수는 시간대 비례(당일 1분봉÷30): 09:30 4장 / 12:00 6장 / 14:00 10장 / 15:00 12장 + # - 예산 = _SCAN_PREFETCH_TOTAL_BUDGET(120초, 선조회 단계 데드라인) + # ⇒ 14:00 기준 상한 = 120×8/(10×3.2) = **30종목** (오전 기준으로는 60종목) + # 15:00 직전(12장)에는 30종목이 144초를 필요로 해 약 17% 가 잘리지만, 라운드로빈 + # 오프셋이 다음 사이클에 이어받으므로 특정 종목이 영구히 누락되지는 않는다. + # 진입 마감이 15:00·EOD 청산이 15:20 이라 마지막 1시간의 부분 커버리지는 감수한다. + # ORB 수집(09:00~09:29)은 종목당 1페이지·0.3초 간격이라 상한이 수백 종목으로 훨씬 느슨하다. + # 후보 공급 측 제약: 운영 실측 당일 분석 종목이 53개뿐이다(2026-09-30) — + # 40~50 으로 올리면 사실상 전량 편입이 되어 점수 기반 선별이 사라진다. + DAY_TRADE_WATCHLIST_SIZE: int = 30 DAY_TRADE_CAPITAL_RATIO: float = 0.30 # 전체 자본 대비 최대 30% (0.50→0.30, 2026-09-23 스윙 70%) DAY_TRADE_PER_STOCK_RATIO: float = 0.06 # ⚠️ 소비자 0건 — 실효 없음(2026-09-28 확인). # DT 종목당 한도의 정본은 아래 DAY_TRADE_MAX_CAPITAL_RATIO 다. 이 키는 strategy_configs diff --git a/backend/app/services/day_trading_strategy.py b/backend/app/services/day_trading_strategy.py index a25305f..343fed9 100644 --- a/backend/app/services/day_trading_strategy.py +++ b/backend/app/services/day_trading_strategy.py @@ -310,17 +310,91 @@ def _atr(candles: List[MinuteCandle], period: int = 14) -> Optional[float]: """Wrapper: MinuteCandle → highs/lows/closes 추출 후 canonical calculate_atr 위임. 알고리즘: TR 의 단순이동평균 (indicators_core.atr_sma) — Wilder ATR 아님. + + ⚠️ **세션 경계를 먼저 끊는다** (`_last_session_candles`) — 2026-09-30 수정. + 진입 판정 경로의 시계열에는 지표 워밍업용 **전일 봉**이 섞여 있다 + (`get_minute_chart_full_day_v2(include_prev_day=True)`). TR 은 + `max(H-L, |H-C_prev|, |L-C_prev|)` 이라 **당일 첫 봉의 TR = 오버나이트 갭**이고, + `atr_sma` 가 마지막 `period` 개 TR 의 단순평균이므로 당일 5분봉이 14개 쌓이기 + 전(대략 10:10 이전)에는 갭 TR + 전일 오후 TR 이 윈도에 남는다. 그 구간이 바로 + DT 진입이 가장 많이 나는 구간이다. + + 영향은 임계값이 아니라 **산출값**이었다: + - `check_vwap_bounce_signal` / `check_rsi_bb_signal` 의 + `target = 현재가 + DAY_TRADE_TARGET_MULTIPLIER × ATR` 은 **클램프가 없어** + 부푼 ATR 이 1.5R 목표를 그대로 멀리 밀었다. + - `raw_stop = 현재가 − ATR_MULT × ATR` 은 보통 최소손절폭(-2%)이 이기지만 + 고변동 종목에서는 -2% 를 뚫고 손절이 깊어졌다. + - 청산 경로(`daytrading/exit_service`)는 당일 전용 분봉을 쓰므로 트레일링 ATR 은 + 깨끗했다 → **진입은 갭 ATR, 청산은 당일 ATR** 로 기준이 엇갈렸다. + + 여기서 한 번 끊으면 네 호출부(ORB 손절·VWAP·RSI_BB·트레일링) 전부가 같은 기준을 + 쓴다. 당일 봉이 `period+1` 개 미만이면 `None` 이고, 호출부는 기존 고정 폴백 + (`DAY_TRADE_REVERSION_STOP_LOSS_PCT` 등)을 쓴다 — 워밍업 도입 이전과 같은 동작이다. + **임계값은 하나도 바뀌지 않는다.** RSI(14)/BB(20) 는 연속 시계열이 필요한 판정 + 게이트이므로 워밍업을 그대로 받는다(`_rsi`/`_bollinger` 는 손대지 않았다). """ - if len(candles) < period + 1: + session = _last_session_candles(candles) + if len(session) < period + 1: return None - highs = [c.high for c in candles] - lows = [c.low for c in candles] - closes = [c.close for c in candles] + highs = [c.high for c in session] + lows = [c.low for c in session] + closes = [c.close for c in session] return calculate_atr(highs, lows, closes, period) # ── 진입 신호 ───────────────────────────────────────────────────────────────── +def _overnight_gap_pct(candles: List[MinuteCandle]) -> Optional[float]: + """오버나이트 갭률(%) = (당일 첫 봉 시가 − 전일 마지막 봉 종가) / 전일 종가 × 100. + + **진단용 순수 함수다 — 어떤 게이트도 이 값을 쓰지 않는다.** + 진입 판정 경로의 시계열에는 지표 워밍업용 전일 봉이 섞여 있으므로 + (`get_minute_chart_full_day_v2(include_prev_day=True)`) 벽시계나 별도 조회 없이 + 데이터만으로 계산된다. 전일 봉이 없거나 날짜 필드가 비면 `None`. + + 용도(2026-09-30): 장중 5분봉 RSI 는 워밍업 시계열을 쓰므로 **오버나이트 갭이 + 종가 차분 한 개로 RSI 에 들어간다**(실측: 3% 갭하락 → 워밍업 RSI 52.7 → 30.3). + 이는 차트 플랫폼 표준 동작이고 평균회귀가 과매도를 사는 의도와 방향이 맞아 + **그대로 유지하기로 결정**됐으나, 의도한 설계가 아니었으므로 관찰 대상이다. + 진입 `reason` 에 이 값을 남겨 두면 나중에 원장(`trade_history.entry_reason`)만으로 + "갭 하락 종목에 쏠렸는가"를 집계할 수 있다. + """ + if not candles: + return None + today = _last_session_candles(candles) + if not today: + return None + today_date = getattr(today[0], "date", "") or "" + if not today_date or len(today) == len(candles): + return None # 전일 봉이 없다 / 날짜 필드 없음 + prev = [c for c in candles if (getattr(c, "date", "") or "") != today_date] + if not prev: + return None + prev_close = prev[-1].close + first_open = today[0].open + if prev_close <= 0 or first_open <= 0: + return None + return (first_open - prev_close) / prev_close * 100.0 + + +def _diag_suffix(candles: List[MinuteCandle], atr: Optional[float]) -> str: + """진입 `reason` 뒤에 붙는 **진단 정보** 문자열 (게이트 아님). + + - `갭=` : `_overnight_gap_pct` (RSI 갭 섭취 관찰용, 없으면 생략) + - `ATR=` : 손절·목표 산출에 실제로 쓰인 ATR. `None` 이면 폴백 손절이 쓰였음을 + 명시한다 — 09:30~10:10 구간은 당일 5분봉 15개 미만이라 ATR 이 없고 + `DAY_TRADE_REVERSION_STOP_LOSS_PCT` 폴백이 적용된다(CLAUDE.md §7 예외). + + 값만 넣는다. 임계값을 문자열에 하드코딩하지 않는다(§4). + """ + parts = ["ATR=없음→폴백손절" if atr is None else f"ATR={atr:.0f}"] + gap = _overnight_gap_pct(candles) + if gap is not None: + parts.append(f"갭={gap:+.2f}%") + return " [진단 " + ", ".join(parts) + "]" + + def check_orb_signal( stock_code: str, stock_name: str, @@ -413,11 +487,41 @@ def check_orb_signal( f"ORB 상향 돌파: ORB고={orb.high:.0f}, " f"현재={current_price:.0f}, 거래량={vol_ratio:.1f}배" f"(≥{settings.DAY_TRADE_ORB_BREAKOUT_VOL_MULT}배, 완성봉 기준)" + # ORB 경로의 시계열은 당일 전용이라 오버나이트 갭을 계산할 수 없다 + # (_diag_suffix 가 갭을 생략한다). ATR 만 남는다. + + _diag_suffix(candles_1min, atr_orb) ), confidence=round(confidence, 2), ) +def _last_completed_candle(candles_5min: List[MinuteCandle]) -> Optional[MinuteCandle]: + """**당일** 마지막 완성 5분봉. 없으면 None (순수 함수). + + [2026-09-30] `aggregate_to_5min` 은 **형성 중인 5분 버킷도 하나의 봉으로 만든다.** + 따라서 `candles_5min[-1]` 로 양봉/음봉을 판정하면 30초 주기 스캔에서 같은 버킷을 + 약 10회 보며 **같은 시장 상태에 사이클마다 다른 답**이 나온다(버킷 초반에 음봉이면 + 차단, 후반에 양봉이 되면 통과). 재현성이 없는 게이트다. + + F7(2026-09-29)이 거래량 게이트를 완성봉으로 고칠 때 **양봉 판정은 빠졌다.** + `volume_surge_ratio()` 가 비교하는 봉과 **같은 봉**(`session[-2]`)을 돌려주므로 + "거래량이 튄 그 봉이 양봉인가"라는 원래 의도가 성립한다 — + `check_orb_signal` 은 이미 `candles_5m[-2]` 로 그렇게 하고 있었다(`:407`). + + 세션 경계도 함께 끊는다(`_last_session_candles`): 진입 경로의 시계열에는 지표 + 워밍업용 전일 봉이 섞여 있어(`include_prev_day=True`) 개장 직후에는 마지막 완성봉이 + **전일 종가 동시호가 봉**이 된다. 그걸로 양봉 판정을 하면 게이트가 전일 결과로 + 열린다(C1 과 같은 함정). + + 완성봉이 없으면(당일 봉 2개 미만) `None` — 호출부는 **미통과**로 닫는다. + 데이터 부족을 통과 쪽으로 열지 않는다. + """ + session = _last_session_candles(candles_5min) + if len(session) < 2: + return None + return session[-2] + + def volume_surge_ratio(candles_5min: List[MinuteCandle]) -> Optional[float]: """직전 거래량 증가 배율 — **완성된 5분봉끼리** 비교한 값. @@ -463,7 +567,7 @@ def check_vwap_bounce_signal( 1. 현재가가 VWAP의 -1% ~ 0.5% 구간 (VWAP 근처) 2. 5분봉 RSI(14) ≤ threshold 3. 마지막 완성 5분봉 거래량 ≥ 그 직전 완성봉 × vol_mult (형성 중 봉 제외) - 4. 최근 5분봉 양봉 전환 (close > open) + 4. **그 완성봉이 양봉** (close > open) — 형성 중 버킷 제외, 당일 봉만 (2026-09-30) """ if not candles_5min or vwap <= 0 or current_price <= 0: return None @@ -480,9 +584,11 @@ def check_vwap_bounce_signal( if vol_ratio is None or vol_ratio < settings.DAY_TRADE_VOLUME_MULTIPLIER: return None - # 최근 봉 양봉 확인 - last = candles_5min[-1] - if last.close <= last.open: + # 양봉 확인 — **당일 마지막 완성봉**(거래량 게이트가 본 그 봉). 형성 중 버킷을 + # 보면 사이클마다 답이 바뀐다(_last_completed_candle docstring). 임계값·조건 정의는 + # 그대로이고 '어느 봉을 보는가'만 고친다. + last = _last_completed_candle(candles_5min) + if last is None or last.close <= last.open: return None atr = _atr(candles_5min, 14) @@ -491,7 +597,21 @@ def check_vwap_bounce_signal( min_stop = current_price * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT) stop_loss = min(raw_stop, min_stop) stop_loss = max(stop_loss, 1.0) # 최소 1원 보장 - target = (current_price + settings.DAY_TRADE_TARGET_MULTIPLIER * atr) if atr else vwap * 1.03 + # [M-1 2026-09-30] **1.5R 하한 보장** — RSI_BB 경로와 같은 방식(`max(기존목표, 진입가 + # + risk × TARGET_MULTIPLIER)`)으로 맞춘다. 새 임계값은 없다: 두 값 중 큰 쪽을 고를 뿐이다. + # + # 고치지 않으면 `target_price` 가 위험보다 작아진다 — 이 값은 + # `daytrading/exit_service` 를 통해 **1.5R 분할매도 트리거로 실제 쓰인다**(§7). + # · ATR 폴백 구간(09:30~약 10:10, 당일 5분봉 15개 미만): 손절 -3.5%(폴백) vs + # 목표 `vwap×1.03`(≈+3%) → **위험 > 보상** + # · ATR 이 있는 구간도 마찬가지다: `risk = max(1.5×ATR, 2%)` 인데(최소 손절폭 보장의 + # `min()` 때문에 손절이 -2% 쪽으로 느슨해진다) 목표는 `1.5×ATR` 이라 + # **1.5×ATR < 2% 인 저변동 종목에서는 R:R 이 0.2~0.5** 로 떨어진다. + # 실측: 진입 10,120 / ATR 29 → 손절 9,918(-2.0%) / 목표 10,164(+0.43%) = **0.21R**. + # §7 은 "1.5R 도달 시 50% 분할"을 규약으로 못박고 있으므로 이는 규약 복귀다. + _target_base = (current_price + settings.DAY_TRADE_TARGET_MULTIPLIER * atr) if atr else vwap * 1.03 + risk = current_price - stop_loss + target = max(_target_base, current_price + risk * settings.DAY_TRADE_TARGET_MULTIPLIER) return DayTradeSignal( stock_code=stock_code, @@ -504,6 +624,7 @@ def check_vwap_bounce_signal( f"VWAP 반등: VWAP={vwap:.0f}, RSI={rsi:.1f}(≤{settings.DAY_TRADE_VWAP_RSI_THRESHOLD:.0f}), " f"현재={current_price:.0f}, 거래량={vol_ratio:.2f}배" f"(≥{settings.DAY_TRADE_VOLUME_MULTIPLIER}배, 완성봉 기준)" + + _diag_suffix(candles_5min, atr) ), confidence=0.65, ) @@ -521,7 +642,7 @@ def check_rsi_bb_signal( 1. RSI(14) ≤ threshold (강한 과매도) 2. 종가 ≤ BB 하단 + buffer 3. 마지막 완성 5분봉 거래량 ≥ 그 직전 완성봉 × vol_mult (형성 중 봉 제외) - 4. 최근 봉 양봉 (음봉이면 스킵) + 4. **그 완성봉이 양봉** (음봉이면 스킵) — 형성 중 버킷 제외, 당일 봉만 (2026-09-30) """ if not candles_5min or current_price <= 0: return None @@ -543,8 +664,9 @@ def check_rsi_bb_signal( if vol_ratio is None or vol_ratio < settings.DAY_TRADE_VOLUME_MULTIPLIER: return None - last = candles_5min[-1] - if last.close <= last.open: + # 양봉 확인 — VWAP 경로와 동일하게 당일 마지막 완성봉 기준 + last = _last_completed_candle(candles_5min) + if last is None or last.close <= last.open: return None atr = _atr(candles_5min, 14) @@ -570,6 +692,7 @@ def check_rsi_bb_signal( f"5분봉 과매도 반등: RSI={rsi:.1f}(≤{settings.DAY_TRADE_RSI_BB_RSI_THRESHOLD:.0f}), " f"BB하단={bb_lower:.0f}, 현재={current_price:.0f}, " f"거래량={vol_ratio:.2f}배(≥{settings.DAY_TRADE_VOLUME_MULTIPLIER}배, 완성봉 기준)" + + _diag_suffix(candles_5min, atr) ), confidence=0.60, ) diff --git a/backend/app/services/daytrading/entry_service.py b/backend/app/services/daytrading/entry_service.py index 02a8aeb..a924da8 100644 --- a/backend/app/services/daytrading/entry_service.py +++ b/backend/app/services/daytrading/entry_service.py @@ -45,6 +45,9 @@ from app.services.day_trading_strategy import ( check_orb_signal, check_vwap_bounce_signal, check_rsi_bb_signal, calculate_day_trade_position_size, is_daily_loss_exceeded, MAX_POSITIONS, + # 진단 전용(읽기 전용 순수 함수). 게이트에 쓰이지 않는다 — + # `_log_rsi_gap_diagnostics()` 가 탈락 후보의 분포를 남기는 데만 쓴다. + _rsi as _rsi5, _overnight_gap_pct, ) from app.services.daytrading.position_manager import day_trading_state from app.services.db_worker import enqueue_trade_history, enqueue_active_trade_upsert @@ -84,9 +87,31 @@ _watchlist_auto: bool = False # 동시성은 보수적으로 잡는다. KIS 호출은 korea_investment_api 내부에서 # semaphore(API_MAX_CONCURRENT_REQUESTS=8) + _throttle_lock(min_request_interval) # 으로 이미 직렬화·쓰로틀되므로 여기서 병렬도를 올려도 레이트리밋을 넘지 않는다. -_SCAN_PREFETCH_CONCURRENCY: int = 4 -# 종목 1건 선조회 상한(초) — 한 종목이 느려도 나머지가 스캔되게 한다 -_SCAN_PREFETCH_TIMEOUT: float = 20.0 +# [2026-09-30] 4 → 8. 실제 병목은 전역 쓰로틀이 아니라 **이 동시성**이었다. +# 종목당 분봉 페이지 1장의 체인 비용은 약 3.2초(운영 실측 요청 3.0초 + in-loop +# sleep 0.2초)이므로 동시성 4 에서는 4/3.35 ≈ 1.2 req/s 밖에 나오지 않는다 — +# 전역 쓰로틀 상한(1/API_MIN_REQUEST_INTERVAL = 6.67 req/s)의 5분의 1 이다. +# 8 로 올려도 8/3.35 ≈ 2.4 req/s 로 쓰로틀 상한에 닿지 않고, 오후(종목당 10~12장) +# 구간의 선조회 시간이 절반으로 줄어 스캔 포화가 풀린다. +# ⚠️ 상한은 8 이다: korea_investment_api 의 `semaphore(API_MAX_CONCURRENT_REQUESTS=8)` +# 와 같은 값이라 이 이상 올려도 실효가 없고, 세마포어를 완전히 점유해 다른 경로 +# (스윙 청산·주문)의 대기만 늘린다. 세마포어는 FIFO 이므로 대기는 요청 1건 +# (약 3초) 으로 유계다. +_SCAN_PREFETCH_CONCURRENCY: int = 8 +# 종목 1건 선조회 상한(초) — 한 종목이 느려도 나머지가 스캔되게 한다. +# [2026-09-30] 20 → 45. 종목당 페이지 수는 시간대에 비례해 늘어난다 +# (당일 1분봉 ÷ 30봉): 09:30 4장 → 12:00 6장 → 14:00 10장 → 15:00 12장. +# 12장 × 3.2초 ≈ 38초이므로 20초에서는 **12:00 이후 전 종목이 타임아웃**했고, +# 그러면 순차 루프가 종목별 재조회를 떠안아(타임아웃 없음·직렬) 오히려 느려졌다. +# VWAP 이 세션 앵커 누적이라 페이지 수를 줄이는 방식은 지표 정의를 바꾸게 되므로 +# 택하지 않았다. 대신 아래 **단계 전체 데드라인**으로 예산을 보호한다. +_SCAN_PREFETCH_TIMEOUT: float = 45.0 +# 선조회 **단계 전체** 데드라인(초). per-stock 타임아웃만 올리면 최악의 경우 +# ceil(N/동시성) 웨이브 × 45초가 스케줄러 _guard(STEP_TIMEOUT=180)를 넘겨 스캔 전체가 +# 조용히 취소된다(진입 0건 + 로그 없음). 단계 자체에 상한을 둬서 **순차 진입 구간이 +# 항상 실행되게** 한다. 이미 캐시에 담긴 종목은 취소돼도 남는다(_one 이 완료 즉시 저장). +# _SCAN_TIME_BUDGET(150초)에서 순차 구간·주문 몫 30초를 남긴 값이다. +_SCAN_PREFETCH_TOTAL_BUDGET: float = 120.0 # 순차 진입 구간 시간 예산(초). 스케줄러 _guard(STEP_TIMEOUT=180) 보다 작게 잡아 # 취소가 아니라 '남은 종목 수 로그'로 끝나게 한다. _SCAN_TIME_BUDGET: float = 150.0 @@ -106,6 +131,79 @@ _CANDLE_CACHE_TTL: float = 180.0 _scan_offset: int = 0 +# ── RSI·갭 진단 로깅 (2026-09-30) ───────────────────────────────────────────── +# 배경: 장중 5분봉 RSI 는 워밍업 시계열(전일 봉 포함)을 쓰므로 오버나이트 갭이 종가 +# 차분 한 개로 RSI 에 들어간다(실측: 3% 갭하락 → 워밍업 RSI 52.7 → 30.3). 사용자 +# 결정은 **유지 + 관찰**인데, 관찰 수단이 없었다: `trade_history.entry_reason` 의 +# `RSI=` 는 **게이트를 통과한 진입에만** 존재해 분포가 임계값 바로 아래에 고인다 +# (실측 56건 29.3~44.9, 임계 45 절단). 기저 분포를 보려면 **탈락 후보**의 값이 필요하다. +# +# 볼륨 억제: 종목마다 한 줄이면 30종목 × 30초 주기 × 5.5시간 ≈ 2만 줄/일이고, +# `docker logs` 가 이미 수 분씩 걸린다. 그래서 **사이클당 1줄**로 집계하고, 다시 +# N 사이클마다 1회만 남긴다(market_analyzer._ML_SKIP_LOG_EVERY 와 같은 패턴). +# 10 사이클 = 약 5분 → 하루 약 60줄. +_RSI_DIAG_LOG_EVERY: int = 10 +_rsi_diag_cycles: int = 0 +# 한 줄에 싣는 최저 RSI 종목 수 (전량 싣으면 줄이 길어져 그렙이 어려워진다) +_RSI_DIAG_TOP_N: int = 8 + + +def _log_rsi_gap_diagnostics(candidates: list[tuple[str, str]]) -> None: + """후보 전체의 (RSI, 갭) 분포를 **한 줄로** 남긴다 — 읽기 전용 진단. + + 진입 판정에 개입하지 않는다. 이미 선조회된 `_candle_cache` 만 읽으므로 + **추가 KIS 호출·DB I/O 가 없다**(§3.1). 통과 여부와 무관하게 전 후보의 값을 + 남기므로 `entry_reason` 이 가진 **절단 편향이 없는 기저 분포**가 쌓인다. + + 로깅 위치를 전략 모듈이 아니라 **호출부**로 잡은 이유: + - `day_trading_strategy` 의 신호 함수는 `(candles, vwap, price)` 의 순수 함수다. + 거기서 남기면 종목마다 한 줄이 되어 볼륨을 억제할 방법이 없고, 억제 카운터를 + 두면 모듈에 가변 상태가 생겨 순수성·테스트 순서 독립성이 깨진다(§4.1). + - 호출부는 사이클 경계를 알기 때문에 집계가 자연스럽다. + - 필요한 값(RSI·갭)은 `_rsi5`/`_overnight_gap_pct` 로 **같은 입력에서 재계산**된다. + 둘 다 순수 함수이므로 신호 함수가 본 값과 동일하다(5분봉 45개 내외 연산). + """ + global _rsi_diag_cycles + _rsi_diag_cycles += 1 + if _rsi_diag_cycles != 1 and _rsi_diag_cycles % _RSI_DIAG_LOG_EVERY != 0: + return + rows: list[tuple[float, float | None, str]] = [] + for code, _ in candidates: + cached = _candle_cache.get(code) + if not cached: + continue + c5 = aggregate_to_5min(cached[1]) + rsi = _rsi5(c5, 14) + if rsi is None: + continue + rows.append((rsi, _overnight_gap_pct(c5), code)) + if not rows: + logger.info( + f"[DayEntry] RSI 진단: 후보 {len(candidates)}종목 중 RSI 산출 0건 " + f"(5분봉 부족 — 워밍업 미달)" + ) + return + rows.sort() + _rsis = [r[0] for r in rows] + _gaps = [r[1] for r in rows if r[1] is not None] + _vwap_th = settings.DAY_TRADE_VWAP_RSI_THRESHOLD + _bb_th = settings.DAY_TRADE_RSI_BB_RSI_THRESHOLD + _pass_vwap = sum(1 for v in _rsis if v <= _vwap_th) + _pass_bb = sum(1 for v in _rsis if v <= _bb_th) + _gap_down = sum(1 for g in _gaps if g <= -1.0) + _top = ", ".join( + f"{c}:{r:.1f}" + (f"/{g:+.1f}%" if g is not None else "") + for r, g, c in rows[:_RSI_DIAG_TOP_N] + ) + logger.info( + f"[DayEntry] RSI 진단(사이클 {_rsi_diag_cycles}): n={len(rows)} " + f"평균={sum(_rsis) / len(_rsis):.1f} 최저={_rsis[0]:.1f} 최고={_rsis[-1]:.1f} | " + f"RSI≤{_vwap_th:.0f} {_pass_vwap}종목, RSI≤{_bb_th:.0f} {_pass_bb}종목 | " + f"갭 산출 {len(_gaps)}종목, 그중 갭≤-1% {_gap_down}종목 | " + f"최저 RSI(종목:RSI/갭): {_top}" + ) + + def _clamp_day_trade_budget( budget: float, total_assets: float, @@ -159,7 +257,7 @@ def get_watchlist() -> dict[str, str]: return dict(_watchlist) -async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int = 20) -> int: +async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int | None = None) -> int: """stock_analysis 테이블에서 오늘 분석 결과를 조회하여 watchlist 자동 설정. 배치 분석(batch_daily_analysis) 결과에서 점수가 높은 종목을 @@ -173,7 +271,8 @@ async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int = 20 Args: min_score: 최소 점수 기준 (기본 1) - max_stocks: 최대 감시 종목 수 (기본 20) + max_stocks: 최대 감시 종목 수. ``None`` 이면 + ``settings.DAY_TRADE_WATCHLIST_SIZE`` (하드코딩 상한 없음). Returns: int: 설정된 감시 종목 수 @@ -181,11 +280,20 @@ async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int = 20 Note: 이 함수는 DB 읽기 전용으로, CLAUDE.md 3.1 예외 해당. populate는 스케줄러 시작 시 1회, 이후 30분마다 갱신. + + [2026-09-30] 상한이 20 으로 하드코딩돼 있어 ORB 후보 풀을 넓힐 방법이 없었다. + ``settings.DAY_TRADE_WATCHLIST_SIZE`` 로 이관했다 — 상한 근거·경합 계산은 + config.py 의 해당 키 주석 참조. 기존 시그니처는 보존한다(§4): 인자를 명시한 + 호출부는 그대로 동작하고, 인자를 생략한 호출부만 settings 를 따른다. """ from app.core.database import AsyncSessionLocal from app.models.models import StockAnalysis from sqlalchemy import select, func + # [§4] 한도 결정은 try 밖에서 — 예외로 조용히 우회되면 상한이 사라진다. + if max_stocks is None: + max_stocks = settings.DAY_TRADE_WATCHLIST_SIZE + today = datetime.now(_KST).date() # [M9 / CLAUDE.md §3.1 예외 — 읽기 전용, 쓰기는 db_worker(db_task_queue) 경유] # 이 세션은 SELECT 2건만 수행하며 commit()/flush()/add() 를 호출하지 않는다. @@ -266,12 +374,19 @@ async def _fetch_candles_impl(stock_code: str) -> list: """분봉 조회 + 파싱. 실패 시 빈 리스트. ⚠️ **전일 봉이 섞인 시계열을 반환한다** — 진입 판정 경로 전용이다. - `get_minute_chart_full_day_v2(include_prev_day=True)` 를 쓰는 이유는 **지표 - 워밍업 하나뿐**이다: 5분봉 RSI(14)=15봉·BB(20)=20봉 요건 때문에 당일 봉만으로는 - VWAP 이 약 10:15, RSI_BB 가 약 10:40 이전에 구조적으로 발화하지 못했다 - (`indicators_core.rsi_wilder` 는 봉 부족 시 `None`). 원장 실측으로 필터 + `get_minute_chart_full_day_v2(include_prev_day=True)` 를 쓰는 이유는 **RSI(14)/ + BB(20) 판정 게이트의 워밍업**이다: 5분봉 RSI(14)=15봉·BB(20)=20봉 요건 때문에 + 당일 봉만으로는 VWAP 이 약 10:15, RSI_BB 가 약 10:40 이전에 구조적으로 발화하지 + 못했다 (`indicators_core.rsi_wilder` 는 봉 부족 시 `None`). 원장 실측으로 필터 도입(1b9257c, 2026-09-28) 이전에는 09:30·10:02 진입이 실재했음이 확인됐다. + ⚠️ **"워밍업이 RSI/BB 에만 영향을 준다"는 단언은 사실이 아니었다** (2026-09-30 + 정정). 같은 시계열이 `_atr` 로도 흘러가 **당일 첫 봉의 TR = 오버나이트 갭**을 + 윈도에 넣어 진입 손절·목표가를 부풀렸고, 청산 트레일링(당일 전용 분봉)과 기준이 + 엇갈렸다. 지금은 `day_trading_strategy._atr` 이 내부에서 `_last_session_candles()` + 로 세션을 끊는다 — 진입/청산 ATR 이 같은 기준이다. **봉 간 차분을 쓰는 지표를 + 새로 추가할 때 이 시계열을 그대로 먹이지 말 것.** + ⚠️ **얼마나 앞당겨지는지는 확보된 워밍업 분량에 달렸고, 아직 미검증이다.** 조회 계층이 전일로 거슬러 가려면 미래 `ref_time` 에 KIS 가 전일 데이터를 준다는 전제가 필요한데 그 전제는 확인되지 않았다(`get_minute_chart_full_day_v2` docstring). @@ -329,7 +444,17 @@ async def _prefetch_minute_charts(codes: list[str]) -> int: except Exception as e: logger.warning(f"[DayEntry] 분봉 선조회 실패 ({code}): {e}") - await asyncio.gather(*(_one(c) for c in codes), return_exceptions=True) + # 단계 전체 데드라인 — 초과분은 포기하고 순차 진입 구간으로 넘어간다(§8.1-11: + # 청산·진입 판정이 선조회 때문에 통째로 취소되는 것이 최악이다). + _gather = asyncio.gather(*(_one(c) for c in codes), return_exceptions=True) + try: + await asyncio.wait_for(_gather, timeout=_SCAN_PREFETCH_TOTAL_BUDGET) + except asyncio.TimeoutError: + logger.warning( + f"[DayEntry] ⏱️ 분봉 선조회 단계 예산 {_SCAN_PREFETCH_TOTAL_BUDGET:.0f}초 초과 " + f"— {sum(1 for c in codes if c in _candle_cache)}/{len(codes)}종목만 선조회됨. " + f"나머지는 순차 루프가 개별 조회한다(시간 예산에 걸리면 다음 사이클로 미룸)" + ) # 오래된 항목 정리 (감시목록 교체로 무한 증식 방지) stale = [c for c, (ts, _) in _candle_cache.items() if (time.monotonic() - ts) > _CANDLE_CACHE_TTL * 2] for c in stale: @@ -417,7 +542,25 @@ async def collect_opening_ranges() -> None: orb = calculate_opening_range(candles) if orb: day_trading_state.set_orb(stock_code, orb) - logger.debug(f"[DayEntry] ORB 수집: {stock_code} {orb}") + # [2026-09-30] debug → info. **레인지가 몇 개 봉으로 확정됐는지**가 + # 로그에 남아야 한다: `calculate_opening_range` 는 봉 5개면 값을 + # 확정하고 이 루프는 `has_orb()` 로 단락하므로, 실제로는 09:00~09:29 + # 전체가 아니라 **첫 성공 시점까지의 레인지**로 하루가 고정될 수 있다 + # (의도된 창은 09:00~09:29 — `is_day_trade_entry_window()` 가 09:30 + # 부터라 조기 확정에 실익이 없다). 봉 수는 저장 필드에서 정확히 + # 복원된다: total_volume / avg_vol_per_min = 봉 수. + _bars = ( + round(orb.total_volume / orb.avg_vol_per_min) + if orb.avg_vol_per_min > 0 else 0 + ) + _width_pct = ( + (orb.high - orb.low) / orb.low * 100 if orb.low > 0 else 0.0 + ) + logger.info( + f"[DayEntry] ORB 수집: {stock_code} {_bars}봉 " + f"고={orb.high:,.0f} 저={orb.low:,.0f} 폭={_width_pct:.2f}% " + f"분당평균거래량={orb.avg_vol_per_min:,.0f}" + ) # KIS API 레이트 리밋 방지 (초당 약 3회) await asyncio.sleep(0.3) except Exception as e: @@ -589,7 +732,12 @@ async def scan_and_enter() -> list[dict]: available_cash = _buyable day_trading_state.last_total_assets = total_assets except Exception as e: - logger.debug(f"[DayEntry] 잔고 조회 실패: {e}") + # [§4 2026-09-30] debug → warning. 잔고 조회가 실패하면 available_cash=0 이 + # 되어 **모든 진입이 조용히 차단**된다(사이징이 0 → quantity 0). 원인 추적이 + # 불가능한 무증상 정지의 전형이라 반드시 노출한다. + logger.warning( + f"[DayEntry] ⚠️ 잔고 조회 실패 — 가용현금 0 으로 진행(신규 진입 사실상 차단): {e}" + ) total_assets = day_trading_state.last_total_assets available_cash = 0.0 @@ -684,6 +832,15 @@ async def scan_and_enter() -> list[dict]: f"(선조회 {prefetched}/{len(candidates)}종목 {_prefetch_sec:.1f}초, " f"총 {time.monotonic() - _scan_started:.1f}초)" ) + + # RSI·갭 기저 분포 진단 — 읽기 전용, 사이클당 1줄(N 사이클마다). 진단 실패가 + # 스캔 결과를 잃게 만들면 안 되므로 여기서만 흡수하고 warning 으로 드러낸다 + # (§4: logger.debug 금지). 리스크 가드가 아니므로 try 안에 두어도 무해하다. + try: + _log_rsi_gap_diagnostics(candidates) + except Exception as e: + logger.warning(f"[DayEntry] RSI 진단 로깅 실패(진단 전용, 매매 영향 없음): {e}") + return entries diff --git a/backend/app/services/db_worker.py b/backend/app/services/db_worker.py index 7ee05a4..5e006ef 100644 --- a/backend/app/services/db_worker.py +++ b/backend/app/services/db_worker.py @@ -121,6 +121,55 @@ DLQ_DIR = Path.home() / ".cache" / "auto-stock" / "dlq" DRAIN_DEADLINE_SECONDS = 20 # DLQ 재처리 완료 파일 이동 위치 — 재처리 무한루프 방지 DLQ_PROCESSED_DIR = DLQ_DIR / "processed" +# [C2 2026-09-30] 나이·충돌로 재큐잉하지 않은 파일 격리 위치 (증거 보존 — 삭제하지 않는다) +DLQ_EXPIRED_DIR = DLQ_DIR / "expired" + +# [C2] 나이 제한 — replay_dlq() 가 failed_at 을 읽지 않아 **재기동마다 오래된 상태를 +# 되살렸다.** 예: 9/25 drain 으로 빠진 upsert_active_trade 가 그 사이 전량 청산돼 +# status='closed' 가 된 뒤 9/30 기동에 재큐잉되면, 핸들러가 active 행을 못 찾고 +# 필수 키가 다 있으니 **새 active 행을 INSERT** 한다 → §8.4.1 의 "9/23 유령 행 129개"와 +# 같은 실패 모드가 자동화된다. upsert_portfolio 는 이미 매도된 종목의 Portfolio 행을 +# 재생성해 스윙 청산이 없는 수량을 팔려 한다(주문거부·과다매도). +# +# 타입별 차등: 위험의 성질이 다르기 때문이다. +# - **상태 덮어쓰기**(active_trade/portfolio): payload 가 '그때의 현재 상태'다. 시간이 +# 지나면 진실이 아니라 거짓이 되므로 짧게 잡는다. DT 포지션은 당일 청산(15:20 EOD)이 +# 원칙이라 두 시간이면 이미 다른 국면이다. +# - **기록 추가**(trade_history/journal/analysis/ban/news): 늦게 들어가도 '없던 기록이 +# 생기는' 것이라 상태를 거짓으로 만들지 않는다. 다만 무한정 허용하면 과거 시각 행이 +# 집계(get_uncovered_trades(days=7)·누적 실현손익)를 오염시키므로 24시간으로 끊는다. +# 상태 덮어쓰기 계열 — payload 가 '그때의 현재 상태'라 오래되면 거짓이 된다. +# [M-4 2026-09-30] close_active_trade 를 **여기서 뺐다.** 성격이 다르다: "닫아라"는 +# 지시는 시간이 지나도 거짓이 되지 않는다(이미 닫혀 있으면 no-op, 아직 열려 있으면 +# 닫는 것이 맞다). 오히려 격리하는 쪽이 **반대 방향의 유령 행**을 만든다 — 야간 +# 재기동으로 close 가 격리되면 이미 청산된 포지션의 ActiveTrade 가 active 로 영구 +# 잔존하고(§8.4.1 '유령 행 129개'와 같은 형태), 그 잔량이 다른 안전장치(유입 잔고 +# 격리의 '시스템 보유' 하한)로 흘러든다. portfolio.py 의 정합 루프도 active 행이 +# 2개 이상이면 continue 하므로 자동 복구되지 않는다. +_DLQ_STATE_TASK_TYPES: tuple[str, ...] = ( + "upsert_active_trade", + "upsert_portfolio", +) +# 현재 상태와의 충돌을 확인할 타입 (나이 분류와 별개 — close 는 충돌 확인만 받는다) +_DLQ_CONFLICT_CHECK_TYPES: tuple[str, ...] = ( + "upsert_active_trade", + "upsert_portfolio", + "close_active_trade", +) +# 상태 덮어쓰기 계열: 같은 날(로컬 날짜) + 2시간 이내만 재큐잉 +DLQ_REPLAY_MAX_AGE_STATE_SECONDS: float = 2 * 3600 +# 기록 추가 계열: 24시간 이내만 재큐잉 +DLQ_REPLAY_MAX_AGE_RECORD_SECONDS: float = 24 * 3600 +# [M-4] close_active_trade: 대폭 완화(7일). 무제한으로 두지 않는 이유는 충돌 판정이 +# 불가한 상황(DB 미가동 → M-2 단락)에서 '새 포지션을 닫을' 위험이 남기 때문이다. +# 충돌 판정이 불가하면 아래에서 기록 계열 상한(24시간)으로 되돌린다. +DLQ_REPLAY_MAX_AGE_CLOSE_SECONDS: float = 7 * 24 * 3600 + +# [M-2 2026-09-30] 충돌 판정 단락 플래그. replay_dlq() 는 lifespan 에서 await 되므로 +# 기동을 막는다. DB 가 아직 안 뜬 상태에서 파일마다 접속을 재시도하면 기동 지연이 +# (파일 수 × 접속 타임아웃)까지 늘어난다 → 첫 실패 후에는 충돌 판정을 건너뛰고 +# **나이 제한만** 적용한다(나이 제한은 단락 후에도 계속 적용된다). +_dlq_conflict_check_available: bool = True # 한 번의 기동에서 재큐잉할 최대 DLQ 파일 수 (큐 폭주 방지) DLQ_REPLAY_MAX_FILES = 500 @@ -206,6 +255,154 @@ def _restore_dlq_task_types(task: dict) -> dict: return task +def _dlq_max_age_seconds(task_type: str) -> float: + """타입별 재큐잉 허용 나이(초). + + close_active_trade 는 충돌 판정이 **가능할 때만** 7일까지 허용한다. 판정이 단락된 + 상태(DB 미가동)에서는 '새 포지션을 닫을' 위험이 남으므로 기록 계열 상한으로 내린다. + """ + if task_type in _DLQ_STATE_TASK_TYPES: + return DLQ_REPLAY_MAX_AGE_STATE_SECONDS + if task_type == "close_active_trade": + if _dlq_conflict_check_available: + return DLQ_REPLAY_MAX_AGE_CLOSE_SECONDS + return DLQ_REPLAY_MAX_AGE_RECORD_SECONDS + return DLQ_REPLAY_MAX_AGE_RECORD_SECONDS + + +def _dlq_age_verdict(entry: dict, task_type: str) -> tuple[bool, str]: + """[C2] failed_at 기준 나이 판정. (재큐잉 허용?, 사유) 반환. + + ``failed_at`` 은 ``_save_to_dlq`` 가 ``datetime.now().isoformat()`` (naive 로컬)로 + 쓴다. 같은 시계로 비교해야 하므로 여기서도 ``datetime.now()`` 를 쓴다 + (``now_kst()`` 와 섞으면 컨테이너 TZ 에 따라 9시간 오차가 난다). + + 나이를 알 수 없으면: 상태 덮어쓰기 계열은 **격리**(보수적), 기록 계열은 허용. + """ + raw = entry.get("failed_at") + failed_at = None + if isinstance(raw, str) and raw: + try: + failed_at = datetime.fromisoformat(raw) + except ValueError: + failed_at = None + + if failed_at is None: + if task_type in _DLQ_STATE_TASK_TYPES: + return False, f"failed_at 없음/파싱불가({raw!r}) — 상태 덮어쓰기 계열은 보수적 격리" + # close_active_trade 는 나이를 몰라도 허용한다(닫으라는 지시는 늦어도 유효). + # 새 포지션을 닫는 위험은 충돌 판정(created_at > failed_at)이 막는다. + return True, "failed_at 없음 — 기록/close 계열이라 허용" + + if failed_at.tzinfo is not None: + failed_at = failed_at.replace(tzinfo=None) + now = datetime.now() + age = (now - failed_at).total_seconds() + limit = _dlq_max_age_seconds(task_type) + if age > limit: + return False, ( + f"나이 {age / 3600:.1f}시간 > 상한 {limit / 3600:.1f}시간 " + f"(failed_at={failed_at.isoformat()})" + ) + if task_type in _DLQ_STATE_TASK_TYPES and failed_at.date() != now.date(): + # 2시간 이내라도 날짜가 다르면 다른 세션이다(자정 전후 기동) + return False, ( + f"상태 덮어쓰기 계열인데 날짜가 다름 " + f"(failed_at={failed_at.date()}, today={now.date()})" + ) + return True, f"나이 {age / 60:.0f}분" + + +async def _dlq_state_conflict(task: dict, entry: dict) -> tuple[bool, str]: + """[C2] 상태 덮어쓰기 계열이 **현재 상태와 충돌**하는지 읽기 전용으로 확인. + + 나이 제한을 통과해도(≤2시간) 그 사이 청산·재진입이 있었으면 유령 행을 만든다. + 여기서는 SELECT 만 수행하고 ``commit()`` 을 호출하지 않는다 — 기동 시 1회, + 실시간 계층이 아니다(CLAUDE.md §3.2 허용예외③ 와 동일한 성격). **쓰기는 여전히 + 큐 경유**다. + + 판정 실패(DB 미가동 등)는 '충돌 아님'으로 돌려 나이 제한만 적용한다 — 여기서 + 예외로 죽으면 기동이 막힌다. + + Returns: + tuple[bool, str]: (충돌인가?, 사유) + """ + global _dlq_conflict_check_available + + task_type = task.get("type", "") + data = task.get("data") or {} + stock_code = data.get("stock_code") if isinstance(data, dict) else None + if not stock_code or task_type not in _DLQ_CONFLICT_CHECK_TYPES: + return False, "" + # [M-2] 이미 판정 불가로 확인됐으면 더 시도하지 않는다(기동 지연 유계화). + # 로그도 남기지 않는다 — 첫 실패에서 한 번만 남겼다. + if not _dlq_conflict_check_available: + return False, "" + + try: + from app.models.models import ActiveTrade, Portfolio + + async with AsyncSessionLocal() as session: + active = ( + await session.execute( + select(ActiveTrade) + .where( + ActiveTrade.stock_code == stock_code, + ActiveTrade.status == "active", + ) + .order_by(ActiveTrade.id.desc()) + .limit(1) + ) + ).scalars().first() + pf = ( + await session.execute( + select(Portfolio).where(Portfolio.stock_code == stock_code).limit(1) + ) + ).scalars().first() + pf_qty = int(getattr(pf, "quantity", 0) or 0) if pf is not None else 0 + + if task_type == "upsert_active_trade": + # active 행도 없고 보유 수량도 0 → 재큐잉하면 유령 active 행이 생긴다 + if active is None and pf_qty <= 0: + return True, ( + f"{stock_code}: active 행 없음 + 보유수량 0 → 유령 ActiveTrade 생성 위험" + ) + elif task_type == "upsert_portfolio": + # Portfolio 행이 없으면 이미 정리된 종목이다. sync_portfolio() 가 KIS + # 잔고에서 매 주기 재생성하므로 스킵해도 실보유는 복구된다. + if pf is None: + return True, f"{stock_code}: Portfolio 행 없음 → 매도된 종목 재생성 위험" + elif task_type == "close_active_trade": + # failed_at 이후에 생긴 active 행이면 '새 포지션'이다 — 그걸 닫으면 안 된다 + raw = entry.get("failed_at") + try: + _fa = datetime.fromisoformat(raw) if isinstance(raw, str) else None + except ValueError: + _fa = None + if _fa is not None and _fa.tzinfo is not None: + _fa = _fa.replace(tzinfo=None) + _created = getattr(active, "created_at", None) if active is not None else None + if _created is not None and _fa is not None: + if _created.tzinfo is not None: + _created = _created.replace(tzinfo=None) + if _created > _fa: + return True, ( + f"{stock_code}: 현재 active 행이 failed_at 이후 생성" + f"(created_at={_created.isoformat()}) → 새 포지션을 닫을 위험" + ) + except Exception as e: + # [M-2] 첫 실패에서 단락한다 — 이후 파일은 접속을 재시도하지 않는다. + _dlq_conflict_check_available = False + logger.warning( + "[DLQ] 상태 충돌 확인 실패 (%s/%s): %s — 이후 파일은 충돌 판정을 건너뛰고 " + "나이 제한만 적용한다(기동 지연 방지). close_active_trade 허용 나이도 " + "%.0f시간으로 내려간다.", + task_type, stock_code, e, DLQ_REPLAY_MAX_AGE_RECORD_SECONDS / 3600, + ) + return False, "" + return False, "" + + async def replay_dlq() -> int: """기동 시 DLQ 파일을 읽어 db_task_queue 에 재큐잉. 재큐잉한 건수 반환. @@ -219,6 +416,11 @@ async def replay_dlq() -> int: 않는다(insert_trade_history 는 멱등이 아니므로 중복이 더 위험하다). 손상된 JSON 은 건너뛰고 warning 을 남긴다(파일은 그대로 두어 사람이 본다). + + [C2 2026-09-30] **나이 제한 + 상태 충돌 확인**을 거친다. 초기 판은 failed_at 을 + 읽지 않아 재기동마다 오래된 상태를 되살렸다(유령 ActiveTrade/Portfolio 생성). + 재큐잉하지 않은 파일은 삭제하지 않고 ``expired/`` 로 격리하고 사유를 warning 으로 + 남긴다. 정책 근거는 위 상수 주석 참조. """ def _list_files() -> list: if not DLQ_DIR.exists(): @@ -237,8 +439,12 @@ async def replay_dlq() -> int: files = files[:DLQ_REPLAY_MAX_FILES] logger.warning("[DLQ] 재처리 시작: %d건", len(files)) + # [M-2] 이번 기동의 충돌 판정 가용성 초기화 (이전 호출의 단락을 물려받지 않는다) + global _dlq_conflict_check_available + _dlq_conflict_check_available = True requeued = 0 skipped = 0 + expired = 0 for f in files: try: raw = await asyncio.to_thread(f.read_text, encoding="utf-8") @@ -249,16 +455,46 @@ async def replay_dlq() -> int: skipped += 1 continue - def _move(path=f): - DLQ_PROCESSED_DIR.mkdir(parents=True, exist_ok=True) - path.rename(DLQ_PROCESSED_DIR / path.name) + task_type = task.get("type", "unknown") - await asyncio.to_thread(_move) + def _move_to(dest, path=f): + dest.mkdir(parents=True, exist_ok=True) + path.rename(dest / path.name) + + # [C2] ① 상태 충돌 확인 (읽기 전용) — 유령 행 생성 차단. + # 나이 판정보다 **먼저** 한다: close_active_trade 의 허용 나이가 '충돌 판정 + # 가능 여부'에 달려 있어(M-4), 판정을 먼저 해야 첫 파일이 blind 로 통과하는 + # 창이 생기지 않는다. DB 장애 시에는 여기서 한 번만 시도하고 단락된다(M-2). + _conflict, _creason = await _dlq_state_conflict(task, entry) + if _conflict: + await asyncio.to_thread(_move_to, DLQ_EXPIRED_DIR) + logger.warning( + "[DLQ] 격리(현재 상태와 충돌): %s | type=%s | failed_at=%s | 사유=%s", + f.name, task_type, entry.get("failed_at"), _creason, + ) + expired += 1 + continue + + # [C2] ② 나이 제한 — 오래된 상태 재생이 유실보다 위험하다 + _ok, _why = _dlq_age_verdict(entry, task_type) + if not _ok: + await asyncio.to_thread(_move_to, DLQ_EXPIRED_DIR) + logger.warning( + "[DLQ] 격리(재큐잉 안 함): %s | type=%s | failed_at=%s | 사유=%s", + f.name, task_type, entry.get("failed_at"), _why, + ) + expired += 1 + continue + + await asyncio.to_thread(_move_to, DLQ_PROCESSED_DIR) # 재시도 카운터를 초기화해 재큐잉분도 3회 재시도를 받게 한다 task.pop("_retry_count", None) # [M5] default=str 로 열화된 Decimal/datetime/date 를 되돌린다 task = _restore_dlq_task_types(task) await enqueue_db_write(task) + logger.info( + "[DLQ] 재큐잉: %s | type=%s | %s", f.name, task_type, _why, + ) requeued += 1 except json.JSONDecodeError as e: logger.warning("[DLQ] 건너뜀(JSON 손상): %s — %s", f.name, e) @@ -268,8 +504,9 @@ async def replay_dlq() -> int: skipped += 1 logger.warning( - "[DLQ] 재처리 완료: 재큐잉 %d건 / 건너뜀 %d건 (처리분은 %s 로 이동)", - requeued, skipped, DLQ_PROCESSED_DIR, + "[DLQ] 재처리 완료: 재큐잉 %d건 / 격리 %d건 / 건너뜀 %d건 " + "(처리분→%s, 격리분→%s)", + requeued, expired, skipped, DLQ_PROCESSED_DIR, DLQ_EXPIRED_DIR, ) return requeued diff --git a/backend/app/services/korea_investment_api.py b/backend/app/services/korea_investment_api.py index 9bed260..8d5323e 100644 --- a/backend/app/services/korea_investment_api.py +++ b/backend/app/services/korea_investment_api.py @@ -958,10 +958,16 @@ class KoreaInvestmentAPI: `include_prev_day=True` 면 전일 종반 봉을 **부족한 5분봉 개수만큼** (`MINUTE_CHART_WARMUP_BARS_5MIN` 기준, `prev_day_minutes`· `MINUTE_CHART_PREV_DAY_MAX_PAGES` 가 상한) 더 수집해 앞쪽에 붙인다. - 용도는 **지표 워밍업 하나뿐**이다 — 5분봉 RSI(14)=15봉/BB(20)=20봉 요건 때문에 - 당일 봉만으로는 VWAP 전략이 약 10:15, RSI_BB 가 약 10:40 이전에 발화하지 못한다 - (`indicators_core.rsi_wilder` 는 봉이 모자라면 `None` 을 반환하고 짧은 - 기간으로 대체 계산하지 않는다). + 용도는 **RSI(14)/BB(20) 판정 게이트의 워밍업**이다 — 5분봉 RSI(14)=15봉/ + BB(20)=20봉 요건 때문에 당일 봉만으로는 VWAP 전략이 약 10:15, RSI_BB 가 약 + 10:40 이전에 발화하지 못한다 (`indicators_core.rsi_wilder` 는 봉이 모자라면 + `None` 을 반환하고 짧은 기간으로 대체 계산하지 않는다). + + ⚠️ **"워밍업이 RSI/BB 에만 영향을 준다"는 단언은 사실이 아니었다** (2026-09-30 + 정정). 이 시계열을 그대로 ATR 에 넘기면 **당일 첫 봉의 TR 이 오버나이트 갭**이 + 되어 손절·목표가가 부풀었다(`day_trading_strategy._atr` docstring 참조). + 지금은 `_atr` 이 내부에서 `_last_session_candles()` 로 세션을 끊어 막는다 — + **TR·ATR 처럼 봉 간 차분을 쓰는 지표에 이 시계열을 새로 먹이지 말 것.** ⚠️ **미검증 전제**: 이 경로는 "장중에 `FID_INPUT_HOUR_1` 로 **미래 시각** (예: 전일 14:51)을 주면 KIS 가 전일 데이터를 돌려준다"에 의존한다. 요청 파라미터에 diff --git a/backend/app/services/portfolio_cache_bridge.py b/backend/app/services/portfolio_cache_bridge.py index 02a3d29..21c0df4 100644 --- a/backend/app/services/portfolio_cache_bridge.py +++ b/backend/app/services/portfolio_cache_bridge.py @@ -64,20 +64,40 @@ async def reflect_buy( # get_swing_holdings 에서 빠지고 스윙 8주의 손절·익절이 완전히 멈춘다. # 화면의 DT 그룹은 엔진 포지션(/daytrading/positions)으로 별도 표시한다. # - # [2026-09-28] 예외 하나: 유입 격리('external') 행에 **스윙** 시스템 매수가 - # 들어오면 swing 으로 승격한다. 승격하지 않으면 새로 산 스윙 수량이 - # get_swing_holdings 에서 빠져 다음 sync 까지 손절·익절이 붙지 않는다. - # DT 매수는 승격하지 않는다 — 엔진이 인메모리로 자기 수량을 관리하고, + # [2026-09-30] **부분 혼재 승격을 막는다.** + # 종전에는 유입 격리('external') 행에 스윙 매수가 들어오면 바로 swing 으로 + # 승격했다. 그런데 Portfolio 는 stock_code UNIQUE 로 수량이 합산되므로 + # (CLAUDE.md §3.5) 승격하면 **유입 수량까지 스윙 자동 손절 대상이 된다.** + # 2026-09-30 에 105560(KB금융)에서 실제로 발생했다(external 3주 + 매수 3주 + # → swing 6주). 사용자 결정 "스윙으로 편입하는 건 없어야 해" 위반이다. + # + # 따라서 승격은 **행 전체가 이번 매수로 설명될 때만** 허용한다 + # (old_qty == 0 = 유입 잔여 수량이 없음). 수량이 남아 있는 external 행은 + # 승격하지 않는다. + # 애초에 이 경로는 `trade_executor` 가 external 행 추가매수를 차단하므로 + # 스윙 매수로는 도달하지 않는다 — 여기 조건은 2차 방어다. + # DT 매수는 여전히 승격하지 않는다 — 엔진이 인메모리로 자기 수량을 관리하고, # 'daytrading' 으로 바꾸면 격리 수량이 external 표시에서 사라진다. - if (existing.strategy_type or "swing") == "external" and strategy_type == "swing": + if ( + (existing.strategy_type or "swing") == "external" + and strategy_type == "swing" + and old_qty <= 0 + ): existing.strategy_type = "swing" _promote_to_swing = True logger.warning( f"[cache_bridge] ⬆️ 유입 격리 해제(승격): {stock_name}({stock_code}) " - f"external → swing (스윙 시스템 매수 {quantity}주)" + f"external → swing (스윙 시스템 매수 {quantity}주, 유입 잔여 0주)" ) else: _promote_to_swing = False + if (existing.strategy_type or "swing") == "external" and strategy_type == "swing": + logger.warning( + f"[cache_bridge] 🧩 부분 혼재 승격 거부: {stock_name}({stock_code}) " + f"유입 {old_qty}주 + 스윙 매수 {quantity}주 → " + f"strategy_type='external' 유지 (승격하면 유입분이 스윙 자동 " + f"손절 대상이 된다). 이 매수 자체가 trade_executor 에서 차단돼야 한다" + ) avg_for_db, qty_for_db = new_avg, new_qty else: cache.holdings.append(Portfolio( diff --git a/backend/app/services/system_buy_registry.py b/backend/app/services/system_buy_registry.py new file mode 100644 index 0000000..5db4780 --- /dev/null +++ b/backend/app/services/system_buy_registry.py @@ -0,0 +1,129 @@ +"""system_buy_registry — "시스템이 방금 이 종목을 매수했다"는 **즉시(인메모리) 근거**. + +## 왜 필요한가 (유입 잔고 강등 레이스) + +`portfolio.sync_portfolio()` 는 KIS 잔고 1행의 `strategy_type` 을 판정할 때 +"시스템 매수 근거"를 **DB**(`TradeHistory` BUY ∪ 시딩 아닌 `ActiveTrade`)에서 찾는다. +그런데 두 테이블은 **둘 다 db_worker 큐를 거쳐 비동기로 INSERT** 된다(CLAUDE.md §3.2). +게다가 매수 직후 `trade_executor._verify_and_sync_execution()` 이 +`_delayed_sync_portfolio(0)` 로 **즉시** sync 를 돌린다. + + 매수 체결 → Portfolio 행(swing) → [db_worker INSERT 전] → sync 실행 + → DB 근거 없음 → external 강등 → 손절·익절·트레일링이 전부 안 걸림 + +즉 **방금 산 정상 포지션이 리스크 관리 밖으로 떨어진다.** 이 모듈은 그 창을 막는다 — +주문 성공 시점에 호출부가 `note_system_buy()` 로 종목을 등록하면, DB 반영 전이라도 +sync 가 `snapshot_codes()` 로 근거를 본다. + +## 불변식 + +**정상 포지션은 강등되지 않는다.** 이 모듈은 근거를 *추가*만 하며 제거하지 않는다. +따라서 오탐(잘못된 강등)은 못 만들고, 최악의 경우 유입 잔고 격리가 TTL 만큼 +늦어질 뿐이다(격리 지연은 허용 가능, 오강등은 허용 불가). + +## ⚠️ 용도 제한 — 승격 근거로 쓰지 말 것 (M-2, 2026-09-30) + +이 레지스트리가 쓰여도 되는 곳은 **두 군데뿐**이다: + ① `portfolio._should_demote_to_external()` 의 1차 방어 (강등을 **막는** 근거) + ② 신규 Portfolio 행의 초기 `strategy_type` 태깅 (원장 INSERT 전이라 필요) + +**기존 `external` 행의 승격 판정에는 쓸 수 없다.** 두 용도의 안전 방향이 반대다 — +강등 방어는 "근거가 있다고 보는 것"이 안전하고, 승격은 "없다고 보는 것"이 안전하다. +실제 사고 경로: 유입 3주 + DT 3주 행에서 DT 가 15:20 EOD 전량 청산하면 원장 net· +비DT active·DT 인메모리가 모두 0 이 되는데 **이 레지스트리만 TTL 내라** `_sys=3= +행수량` 이 되어 승격되고 `strategy_type='swing'` 이 **DB 에 영구 기록**된다. 장이 +닫혀 다음 sync 가 오지 않으면 그대로 밤을 넘기고 다음 날 개장 시 유입분이 자동 손절 +대상이 된다. 그래서 `sync_portfolio` 는 근거를 `_system_*_durable`(레지스트리 제외)과 +`_system_*`(포함) 두 벌로 만들고 **승격은 durable 만** 본다. + +`note_system_buy()` 는 **매도를 반영하지 않는다** — 그래서 이 모듈의 수량은 "방금 +주문한 양"이지 "현재 보유량"이 아니다. 위 용도 제한을 지키면 그 차이가 무해하다. + +## 주의 + +- 프로세스 재기동으로 비워진다. 그 경우는 이미 DB 에 근거가 들어가 있고, + `portfolio.py` 쪽 "연속 확인 + 최소 경과시간" 게이트가 2차 방어로 남는다. +- 실시간 계층에서 호출되므로 **DB I/O 없음**(CLAUDE.md §3.1). +- TTL 은 db_worker 최대 지연(지수 백오프 1+2+4초 ×3회 + DLQ 재시도)을 넉넉히 덮는 값. +""" +import logging +import time + +logger = logging.getLogger(__name__) + +# 등록 유효 시간(초). db_worker 큐 지연·재시도(최대 수십 초)를 넉넉히 덮는다. +# 길게 잡아도 부작용은 "유입 잔고 격리가 최대 이 시간만큼 늦어짐" 뿐이다. +SYSTEM_BUY_TTL_SEC = 900 + +# {stock_code: [monotonic 등록 시각, 누적 주문 수량]} +_recent_buys: dict = {} + + +def note_system_buy(stock_code: str, quantity: int = 0) -> None: + """시스템 매수 주문이 나갔음을 즉시 기록 (동기 함수 — 어디서나 호출 가능). + + Args: + stock_code: 종목코드 + quantity: 주문 수량. 부분 혼재 판정(`_classify_holding_strategy_type_v2`)이 + **수량**을 쓰므로 함께 남긴다. 0 이면 "수량 미상" 이고, 그 경우 호출부는 + 근거 수량을 원장에서만 얻는다. TTL 안에서 여러 번 호출되면 **합산**한다 + (분할 주문·추가매수). + + 실패해도 매매를 막아서는 안 되므로 절대 예외를 올리지 않는다. + """ + if not stock_code: + return + try: + _c = str(stock_code) + _q = max(0, int(quantity or 0)) + st = _recent_buys.get(_c) + if st is None or time.monotonic() - st[0] > SYSTEM_BUY_TTL_SEC: + _recent_buys[_c] = [time.monotonic(), _q] + else: + st[0] = time.monotonic() + st[1] += _q + except Exception as e: # pragma: no cover - 방어적 + logger.warning(f"[system_buy_registry] 등록 실패 ({stock_code}): {e}") + + +def snapshot_quantities() -> dict: + """TTL 내 시스템 매수 **수량** {code: qty} (만료분 정리 포함). + + `sync_portfolio` 가 `_system_qty` 의 하한으로 합성한다 — 원장 INSERT 전에도 + "이 행의 N주는 시스템 몫" 이라는 사실이 보이게 해, 부분 혼재 판정이 방금 산 + 수량을 유입분으로 오인하지 않게 한다. + """ + now = time.monotonic() + for c in [c for c, st in _recent_buys.items() if now - st[0] > SYSTEM_BUY_TTL_SEC]: + _recent_buys.pop(c, None) + return {c: int(st[1]) for c, st in _recent_buys.items()} + + +def has_recent_system_buy(stock_code: str) -> bool: + """해당 종목에 TTL 내 시스템 매수 기록이 있는가.""" + if not stock_code: + return False + st = _recent_buys.get(str(stock_code)) + if st is None: + return False + if time.monotonic() - st[0] > SYSTEM_BUY_TTL_SEC: + _recent_buys.pop(str(stock_code), None) + return False + return True + + +def snapshot_codes() -> set: + """TTL 내 시스템 매수 종목 집합 (만료분 정리 포함). + + `portfolio._classify_holding_strategy_type(system_codes=...)` 에 합집합으로 넣는다. + """ + now = time.monotonic() + expired = [c for c, st in _recent_buys.items() if now - st[0] > SYSTEM_BUY_TTL_SEC] + for c in expired: + _recent_buys.pop(c, None) + return set(_recent_buys) + + +def clear() -> None: + """테스트용 초기화.""" + _recent_buys.clear() diff --git a/backend/app/services/trade_executor.py b/backend/app/services/trade_executor.py index 1f6ac65..fab2fdf 100644 --- a/backend/app/services/trade_executor.py +++ b/backend/app/services/trade_executor.py @@ -1113,6 +1113,17 @@ async def _execute_buy( is_pending = order_result.get("rt_cd") == "1" # 주문접수(체결 미확인) status = "COMPLETED" if success else ("PENDING" if is_pending else "FAILED") + # [2026-09-30] 유입 잔고 강등 레이스 방어 — '시스템 매수' 를 **DB 반영 전에** + # 인메모리로 즉시 등록한다. 아래 enqueue 들은 db_worker 큐를 거쳐 비동기로 + # INSERT 되는데, `_verify_and_sync_execution()` 이 곧바로 `sync_portfolio()` + # 를 돌리므로 그 사이에 sync 가 "근거 없음 → external 강등" 판정을 내리면 + # 방금 산 포지션의 손절·익절이 통째로 멈춘다. + # 주문 거부(FAILED)는 주식이 생기지 않으므로 등록하지 않는다 — 등록하면 + # 기존 유입 잔고가 '매수 실패' 만으로 격리를 면한다. + if success or is_pending: + from app.services.system_buy_registry import note_system_buy + note_system_buy(stock_code, quantity) + # TradeHistory DB 기록 (db_worker 큐 경유 — CLAUDE.md 3.2 준수) kis_order_id = build_kis_order_id(order_result) from app.services.db_worker import enqueue_trade_history @@ -1912,6 +1923,28 @@ async def execute_auto_trade(stock: dict, db: AsyncSession): if _r is not None: return _r + # ── 유입 격리분 추가매수 차단 ──────────────────────────────────────── + # [2026-09-30] 유입 잔고(strategy_type='external') 행에 스윙 매수를 얹으면 + # Portfolio 는 stock_code UNIQUE 로 수량이 합산되므로(CLAUDE.md §3.5) 행이 + # swing 으로 승격되고 **유입분까지 스윙 자동 손절 대상이 된다.** + # 2026-09-30 에 105560(KB금융)에서 실제로 발생했다(external 3주 + 매수 3주 + # → swing 6주). 사용자 결정 "스윙으로 편입하는 건 없어야 해" 의 정면 위반이다. + # + # 여기서 막으면 그 혼재가 **발생하지 않는다** — 유입분 격리를 지키면서 + # 시스템 매수분이 관리 밖에 남는 트레이드오프도 함께 사라진다(애초에 안 산다). + # `_guard` 를 쓰므로 **청산 판정은 그대로 통과**한다(§8.1-11). + # DT 경로(daytrading/entry_service)는 이 함수를 타지 않고, DT 는 인메모리로 + # 자기 수량을 관리하므로 차단 대상이 아니다. + if holding is not None and (holding.strategy_type or "swing") == "external": + logger.warning( + f"🚧 유입 격리분 추가매수 차단: {stock_name}({stock_code}) " + f"보유 {holding.quantity}주 strategy_type='external' — " + f"매수하면 행 전체가 swing 으로 승격돼 유입분이 자동 손절 대상이 된다" + ) + _r = _guard("external_inflow_holding") + if _r is not None: + return _r + held_result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0)) # 적응형 전략: max_holdings 오버라이드 (실패 시 settings 기본값 fallback) _adaptive_max_holdings = settings.MAX_HOLDINGS_COUNT diff --git a/backend/app/services/us_market_scheduler.py b/backend/app/services/us_market_scheduler.py index 72419f2..da1b775 100644 --- a/backend/app/services/us_market_scheduler.py +++ b/backend/app/services/us_market_scheduler.py @@ -565,6 +565,13 @@ async def us_trade_cycle() -> dict: ticker, rt_cd, order_id, ) + # [2026-09-30] 유입 잔고 강등 레이스 방어 — TradeHistory 는 db_worker + # 큐를 거쳐 비동기 INSERT 되므로, 그 전에 sync 가 돌면 근거가 없어 + # 보인다. 주문 성공 시점에 인메모리 근거를 즉시 남긴다. + if rt_cd in ("0", "1"): + from app.services.system_buy_registry import note_system_buy + note_system_buy(ticker, quantity) + # 7-4. DB 기록 (db_worker 큐 사용) await enqueue_trade_history({ "stock_code": ticker, diff --git a/backend/tests/test_bugfixes_20260923.py b/backend/tests/test_bugfixes_20260923.py index 4e9c1e3..b49dd22 100644 --- a/backend/tests/test_bugfixes_20260923.py +++ b/backend/tests/test_bugfixes_20260923.py @@ -168,14 +168,132 @@ def test_manual_sell_closes_active_trade(): assert '"quantity": original_holding_qty - quantity' in src +class _StubActiveTrade: + """ActiveTrade ORM 대역 — 정합 함수가 실제로 수정하는 필드만 갖는다.""" + + def __init__(self, stock_code, quantity, strategy_name="MEAN_REVERSION"): + self.stock_code = stock_code + self.quantity = quantity + self.strategy_name = strategy_name + self.status = "active" + + def test_sync_reconciles_active_trade_quantity(): - """동기화가 ActiveTrade 잔량을 실제 보유량으로 정합해야 드리프트가 누적되지 않는다.""" - import inspect + """동기화가 ActiveTrade 잔량을 실제 보유량으로 정합해야 드리프트가 누적되지 않는다. + + [2026-09-30 M-5] 종전에는 `inspect.getsource()` + 문자열 매칭이라 정합 루프를 + 리팩터링하면 빗나갔다(실제로 빗나갔다). 이제 **불일치 상태를 주입하고 정합 + 함수를 실행해 교정 결과를 확인**한다 — 프로덕션 함수를 그대로 호출한다. + """ + from app.api.endpoints.portfolio import _reconcile_active_trade_quantities + + # 스윙 단일 행이 3주로 기록돼 있는데 실제 보유는 5주 → 5주로 교정돼야 한다 + row = _StubActiveTrade("000270", 3) + acts = _reconcile_active_trade_quantities( + {"000270": [row]}, {"000270": 5}, {"000270": "swing"}, {"000270": 5} + ) + assert row.quantity == 5, "잔량 교정이 동작해야 한다 (2026-09-23 픽스의 본래 대상)" + assert row.status == "active" + assert acts == [{"action": "corrected", "stock_code": "000270", "from": 3, "to": 5}] + + # 일치하면 아무 것도 하지 않는다 + row2 = _StubActiveTrade("000270", 5) + assert _reconcile_active_trade_quantities( + {"000270": [row2]}, {"000270": 5}, {"000270": "swing"}, {"000270": 5} + ) == [] + assert row2.quantity == 5 + + +def test_sync_closes_active_trade_without_holding(): + """보유가 없으면 active 행을 closed 로 정리한다 (유령 행 누적 방지).""" + from app.api.endpoints.portfolio import _reconcile_active_trade_quantities + + for tag in ("swing", "daytrading", "manual", "external"): + row = _StubActiveTrade("000270", 3) + acts = _reconcile_active_trade_quantities( + {"000270": [row]}, {}, {"000270": tag}, {"000270": 3} + ) + assert row.status == "closed", f"{tag}: 유령 행 정리는 태그와 무관하게 수행" + assert row.quantity == 3, "정리 경로에서 수량은 건드리지 않는다" + assert acts[0]["action"] == "closed" + + +def test_sync_skips_reconcile_for_multiple_active_rows(): + """스윙+DT 혼재 종목은 자동 교정 대상에서 제외해야 함 (2026-09-23 원래 규칙).""" + from app.api.endpoints.portfolio import _reconcile_active_trade_quantities + + rows = [_StubActiveTrade("000270", 3), _StubActiveTrade("000270", 2, "day_trade")] + assert _reconcile_active_trade_quantities( + {"000270": rows}, {"000270": 5}, {"000270": "swing"}, {"000270": 5} + ) == [] + assert [r.quantity for r in rows] == [3, 2] + + +def test_reconcile_skips_external_rows_but_keeps_swing_behaviour(): + """[C-1] external 행만 교정에서 빠진다 — 스윙 교정은 그대로 살아 있다. + + external 행 수량에는 시스템이 소유하지 않은 유입분이 섞여 있어, 교정하면 그 값이 + 다음 sync 의 '시스템 근거 수량' 이 되어 유입분이 swing 으로 승격된다(고착). + """ + from app.api.endpoints.portfolio import _reconcile_active_trade_quantities + + ext = _StubActiveTrade("105560", 3, "day_trade") + acts = _reconcile_active_trade_quantities( + {"105560": [ext]}, {"105560": 6}, {"105560": "external"}, {"105560": 3} + ) + assert ext.quantity == 3, "external 행은 교정하지 않는다" + assert acts[0]["action"] == "skipped_unattributable" + assert acts[0]["from"] == 3 and acts[0]["to"] == 6 + + # [M-3] 태그가 swing 인 혼재 행도 교정하지 않는다 — 여기가 실제 주문 사고 경로다 + mixed = _StubActiveTrade("111111", 3, "MEAN_REVERSION") + acts = _reconcile_active_trade_quantities( + {"111111": [mixed]}, {"111111": 6}, {"111111": "swing"}, {"111111": 3} + ) + assert mixed.quantity == 3, "유입분이 섞인 swing 행도 부풀리지 않는다" + assert acts[0]["action"] == "skipped_unattributable" + + +def test_c1_guard_does_not_weaken_the_20260923_intent(): + """의도 보존 검증 — C-1 가드를 **C-1 이전 규칙으로 되돌려도** 위 의도는 통과한다. + + 즉 이 테스트 묶음이 깨진다면 그것은 2026-09-23 픽스가 깨진 것이고, C-1 가드 + 때문이 아니다. 반대로 C-1 회귀(2회 sync 유지)는 가드가 없으면 실패한다 + (`tests/test_external_demotion_race.py::test_c1_would_have_regressed_...`). + """ + from unittest.mock import patch + from app.api.endpoints import portfolio as pf - src = inspect.getsource(pf.sync_portfolio) - assert "ActiveTrade 잔량 교정" in src - assert "len(_rows) != 1" in src, "스윙+DT 혼재 종목은 자동 교정 대상에서 제외해야 함" - assert "_row.status = \"closed\"" in src, "보유 없음 → closed 처리 누락" + + def _pre_c1_rule(strategy_type, row_count, actual, system_quantity=None): + # C-1/M-3 이전 규칙: 단일 행 + 보유 있음이면 태그·근거수량 무관 교정 + return int(row_count) == 1 and int(actual) > 0 + + with patch.object(pf, "_should_correct_active_trade_quantity", _pre_c1_rule): + row = _StubActiveTrade("000270", 3) + pf._reconcile_active_trade_quantities( + {"000270": [row]}, {"000270": 5}, {"000270": "swing"}, {"000270": 5} + ) + assert row.quantity == 5, "스윙 잔량 교정은 C-1/M-3 가드와 무관하게 성립한다" + + ghost = _StubActiveTrade("000270", 3) + pf._reconcile_active_trade_quantities( + {"000270": [ghost]}, {}, {"000270": "swing"}, {"000270": 3} + ) + assert ghost.status == "closed" + + # 그리고 이 규칙에서는 유입분이 섞인 행이 부풀려진다 = C-1/M-3 의 인과 + ext = _StubActiveTrade("105560", 3, "day_trade") + pf._reconcile_active_trade_quantities( + {"105560": [ext]}, {"105560": 6}, {"105560": "external"}, {"105560": 3} + ) + assert ext.quantity == 6, "가드가 없으면 C-1 이 재발한다" + + mixed = _StubActiveTrade("111111", 3, "MEAN_REVERSION") + pf._reconcile_active_trade_quantities( + {"111111": [mixed]}, {"111111": 6}, {"111111": "swing"}, {"111111": 3} + ) + assert mixed.quantity == 6, "가드가 없으면 M-3 이 재발한다(유입분 과다매도)" def test_active_trade_patch_supports_quantity(): diff --git a/backend/tests/test_daytrading_atr_session_boundary_20260930.py b/backend/tests/test_daytrading_atr_session_boundary_20260930.py new file mode 100644 index 0000000..4f99a99 --- /dev/null +++ b/backend/tests/test_daytrading_atr_session_boundary_20260930.py @@ -0,0 +1,570 @@ +"""C1 회귀 테스트 (2026-09-30): 워밍업 시계열이 진입 ATR 을 오염시키던 결함. + +증상: 진입 판정 경로는 지표 워밍업을 위해 전일 봉이 섞인 5분봉을 쓴다 +(`get_minute_chart_full_day_v2(include_prev_day=True)`). `true_ranges` 가 +`TR_i = max(H-L, |H-C_{i-1}|, |L-C_{i-1}|)` 이라 **당일 첫 봉의 TR = 오버나이트 갭** +이고, `atr_sma` 는 마지막 14개 TR 의 단순평균이므로 당일 5분봉이 14개 쌓이기 +전(≈10:10)에는 갭 TR 이 윈도에 남았다. 그 ATR 이 손절·목표가를 직접 만든다. + +수정: `day_trading_strategy._atr` 이 내부에서 `_last_session_candles()` 로 세션을 끊는다. +임계값은 하나도 바꾸지 않았다 — ATR 에 무엇을 먹이는지만 고쳤다. + +고정하는 불변식 3건: + 1. 당일 첫 봉이 갭으로 벌어져도 진입 ATR 이 그 갭을 먹지 않는다 + 2. 진입 ATR 과 청산 트레일링 ATR 이 같은 기준이다 (같은 날 데이터 → 같은 값) + 3. RSI(14)/BB(20) 판정은 여전히 워밍업 혜택을 받는다 (09:30 에 판정 도달) +""" +from datetime import time as dtime + +import pytest + +from app.core.config import settings +from app.services.day_trading_strategy import ( + MinuteCandle, _atr, _bollinger, _rsi, + check_day_trade_exit, check_vwap_bounce_signal, check_rsi_bb_signal, +) + +PREV = "20260929" +TODAY = "20260930" + + +def _bar(date: str, hhmm: str, close: float, *, spread: float = 10.0, + volume: int = 1000) -> MinuteCandle: + """종가 기준 ±spread 의 5분봉 1개.""" + return MinuteCandle( + date=date, time=hhmm + "00", + open=close, high=close + spread, low=close - spread, close=close, + volume=volume, cum_volume=0, cum_amount=0.0, + ) + + +def _series(date: str, n: int, base: float, *, step: float = 0.0, + spread: float = 10.0, start_min: int = 0) -> list[MinuteCandle]: + out = [] + for i in range(n): + total = 9 * 60 + start_min + i * 5 + out.append(_bar( + date, f"{total // 60:02d}{total % 60:02d}", + base + i * step, spread=spread, + )) + return out + + +def _completed_bull_bar(bar: MinuteCandle) -> MinuteCandle: + """마지막 **완성** 5분봉을 '양봉 + 거래량 급증'으로 만든다. + + [2026-09-30] 양봉 판정이 형성 중 버킷(`[-1]`) → 마지막 완성봉(`[-2]`)으로 + 바뀌었으므로, 신호를 발화시키려면 완성봉 쪽이 양봉이어야 한다. + """ + return MinuteCandle( + date=bar.date, time=bar.time, + open=bar.close - 20, high=bar.high, low=bar.low, close=bar.close, + volume=5000, cum_volume=0, cum_amount=0.0, + ) + + +# ── 불변식 1: 갭 TR 이 진입 ATR 에 들어오지 않는다 ──────────────────────────── + +def test_entry_atr_ignores_overnight_gap(): + """전일 종가가 어디에 있든 당일 ATR 값은 같다 (갭 TR 미포함).""" + today = _series(TODAY, 20, 10_000.0, spread=10.0) + + # 전일 종가가 당일 시가와 비슷한 경우 / 30% 아래인 경우 + small_gap = _series(PREV, 20, 9_900.0, spread=10.0, start_min=300) + today + huge_gap = _series(PREV, 20, 7_000.0, spread=10.0, start_min=300) + today + + atr_small = _atr(small_gap, 14) + atr_huge = _atr(huge_gap, 14) + atr_today = _atr(today, 14) + + assert atr_today is not None + assert atr_small == atr_today, "전일 봉이 진입 ATR 에 섞였다" + assert atr_huge == atr_today, "오버나이트 갭이 진입 ATR 을 부풀렸다" + + +def test_entry_atr_is_none_before_todays_warmup(): + """당일 봉이 period+1 개 미만이면 None — 워밍업 도입 이전과 같은 동작. + + 호출부는 이때 고정 폴백(DAY_TRADE_REVERSION_STOP_LOSS_PCT)을 쓴다. + 전일 봉으로 ATR 을 '만들어내는' 것이 바로 C1 결함이었다. + """ + today_6 = _series(TODAY, 6, 10_000.0) # 09:30 시점 + mixed = _series(PREV, 20, 9_000.0, start_min=300) + today_6 + assert _atr(today_6, 14) is None + assert _atr(mixed, 14) is None, "전일 봉으로 ATR 을 합성하고 있다" + + +def test_vwap_signal_stop_and_target_ignore_prev_day(): + """신호의 손절·목표가가 전일 봉 유무에 영향받지 않는다. + + ATR 이 `target = 현재가 + 1.5R` 과 `raw_stop` 에 직접 들어가므로, + 같은 당일 데이터에서 두 입력이 같은 값을 내야 한다. + """ + # 하락 후 마지막 봉 양봉 + 거래량 급증 → VWAP 반등 신호 조건 + today = _series(TODAY, 25, 10_600.0, step=-20.0, spread=8.0) + for c in today: + c.volume = 1000 + today[-2] = _completed_bull_bar(today[-2]) + last_close = today[-1].close + today[-1] = MinuteCandle( # 형성 중 봉: 양봉 + date=TODAY, time=today[-1].time, open=last_close - 30, high=last_close + 10, + low=last_close - 40, close=last_close, volume=1200, + cum_volume=0, cum_amount=0.0, + ) + price = today[-1].close + vwap = price # VWAP 밴드 중앙 + + # 워밍업은 **갭 없이 이어지는** 전일 하락 봉 — RSI 게이트가 양쪽에서 통과해야 + # 손절·목표가 비교가 성립한다(갭을 주면 RSI 가 튀어 신호 자체가 사라진다). + mixed = _series(PREV, 20, 11_000.0, step=-20.0, spread=8.0, start_min=300) + today + + sig_today = check_vwap_bounce_signal("005930", "삼성", today, vwap, price) + sig_mixed = check_vwap_bounce_signal("005930", "삼성", mixed, vwap, price) + + assert sig_today is not None and sig_mixed is not None, "테스트 데이터가 신호를 못 만든다" + assert sig_mixed.stop_loss == sig_today.stop_loss, "전일 봉이 진입 손절을 움직였다" + assert sig_mixed.target_price == sig_today.target_price, "전일 봉이 목표가를 움직였다" + + +def test_rsi_bb_signal_stop_and_target_ignore_prev_day(): + """RSI_BB 경로도 동일 (risk → target 전파 차단 확인).""" + today = _series(TODAY, 25, 10_600.0, step=-20.0, spread=8.0) + today[-2] = _completed_bull_bar(today[-2]) + last_close = today[-1].close + today[-1] = MinuteCandle( + date=TODAY, time=today[-1].time, open=last_close - 30, high=last_close + 10, + low=last_close - 40, close=last_close, volume=1200, + cum_volume=0, cum_amount=0.0, + ) + price = today[-1].close * 0.97 # BB 하단 근처로 내려 조건 충족 + mixed = _series(PREV, 20, 11_000.0, step=-20.0, spread=8.0, start_min=300) + today + + sig_today = check_rsi_bb_signal("005930", "삼성", today, price) + sig_mixed = check_rsi_bb_signal("005930", "삼성", mixed, price) + + assert sig_today is not None and sig_mixed is not None, "테스트 데이터가 신호를 못 만든다" + assert sig_mixed.stop_loss == sig_today.stop_loss + assert sig_mixed.target_price == sig_today.target_price + + +def test_fallback_stop_used_when_todays_atr_unavailable(): + """당일 ATR 이 없으면 고정 폴백 손절폭이 쓰인다 (ATR 합성 금지). + + stop = min(현재가×(1−REVERSION_STOP_LOSS_PCT), 현재가×(1−MIN_STOP_LOSS_PCT)) + — §7 의 '장중은 느슨한 쪽' 규약. 임계값은 settings 를 참조한다(§4.1). + """ + # 당일 5분봉 21개면 BB(20)·RSI(14)는 되지만 ATR(14)에는 15개가 필요하므로 + # 당일 봉을 14개로 맞추고 워밍업으로 RSI/BB 만 채운다. + today = _series(TODAY, 14, 10_300.0, step=-20.0, spread=8.0) + today[-2] = _completed_bull_bar(today[-2]) + last_close = today[-1].close + today[-1] = MinuteCandle( + date=TODAY, time=today[-1].time, open=last_close - 30, high=last_close + 10, + low=last_close - 40, close=last_close, volume=1200, + cum_volume=0, cum_amount=0.0, + ) + mixed = _series(PREV, 20, 10_700.0, step=-20.0, spread=8.0, start_min=300) + today + price = today[-1].close + + assert _atr(mixed, 14) is None, "당일 봉 14개로 ATR 이 나오면 전제가 깨진다" + sig = check_vwap_bounce_signal("005930", "삼성", mixed, price, price) + assert sig is not None, "워밍업 덕에 RSI 판정은 되어야 한다" + + expected = min( + price * (1 - settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT), + price * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT), + ) + assert sig.stop_loss == pytest.approx(round(expected, 0)) + + +# ── 불변식 2: 진입 ATR == 청산 트레일링 ATR ─────────────────────────────────── + +def test_entry_and_exit_atr_share_one_basis(): + """진입(워밍업 포함 시계열)과 청산(당일 전용 시계열)의 ATR 이 같다. + + 청산 경로(`daytrading/exit_service`)는 `get_minute_chart_full_day`(당일 전용)를 + 쓰고 진입 경로는 `_v2(include_prev_day=True)` 를 쓴다. 두 경로가 다른 ATR 을 + 쓰면 진입 손절과 트레일링 스탑의 기준이 엇갈린다. + """ + today = _series(TODAY, 20, 10_000.0, spread=25.0) + entry_series = _series(PREV, 20, 7_500.0, start_min=300) + today # 진입 경로 입력 + exit_series = today # 청산 경로 입력 + + assert _atr(entry_series, 14) == _atr(exit_series, 14) is not None + + +def test_trailing_stop_identical_for_both_series(): + """같은 포지션·같은 당일 데이터에서 트레일링 청산 판정이 동일하다.""" + today = _series(TODAY, 20, 10_000.0, spread=25.0) + mixed = _series(PREV, 20, 7_500.0, start_min=300) + today + + atr = _atr(today, 14) + assert atr is not None + entry_price = 9_000.0 + highest = 10_500.0 + trail = highest - settings.DAY_TRADE_TRAILING_ATR_MULT * atr + current = trail - 1 # 트레일 바로 아래 → SELL_ALL 기대 + + kwargs = dict( + entry_price=entry_price, current_price=current, + stop_loss=entry_price * 0.9, target_price=entry_price * 1.5, + highest_since_entry=highest, current_time=dtime(11, 0), entry_time=None, + ) + ex_today = check_day_trade_exit(candles_5min=today, **kwargs) + ex_mixed = check_day_trade_exit(candles_5min=mixed, **kwargs) + + assert ex_today is not None and ex_today.action == "SELL_ALL" + assert ex_mixed is not None + assert ex_today.action == ex_mixed.action + assert ex_today.reason == ex_mixed.reason, "트레일링 기준값이 시계열에 따라 달라졌다" + + +# ── 불변식 3: RSI/BB 워밍업 혜택 유지 (C3 목적 보존) ───────────────────────── + +def test_rsi_and_bb_still_benefit_from_warmup(): + """09:30(당일 6봉)에도 워밍업으로 RSI(14)/BB(20) 판정이 가능해야 한다.""" + today_6 = _series(TODAY, 6, 10_000.0, step=-15.0) + mixed = _series(PREV, 20, 10_200.0, step=-5.0, start_min=300) + today_6 + + # 당일 봉만으로는 둘 다 불가 + assert _rsi(today_6, 14) is None + assert _bollinger(today_6, 20) is None + # 워밍업이 붙으면 판정 도달 — C1 수정이 이걸 깨지 않았는지 확인 + assert _rsi(mixed, 14) is not None, "워밍업이 무력화됐다 (C3 목적 상실)" + assert _bollinger(mixed, 20) is not None, "워밍업이 무력화됐다 (C3 목적 상실)" + + +def test_atr_session_filter_is_wallclock_independent(): + """`_atr` 은 벽시계를 쓰지 않는다 (§2) — **동작으로** 검증한다. + + 날짜가 1999년이든 2099년이든 '마지막 봉의 영업일자'만으로 세션이 갈려야 한다. + `datetime.now()` 에 의존하면 과거 날짜 입력에서 당일 봉이 0건이 되어 값이 달라진다 + (휴장일·재기동·백테스트에서 기준이 흔들리는 것과 같은 증상). + """ + def _pair(prev_date, today_date): + prev = [ + MinuteCandle(date=prev_date, time=f"{13 + i // 12:02d}{(i * 5) % 60:02d}00", + open=9_000.0, high=9_020.0, low=8_980.0, close=9_000.0, + volume=10, cum_volume=0, cum_amount=0.0) + for i in range(20) + ] + today = [ + MinuteCandle(date=today_date, time=f"{9 + i // 12:02d}{(i * 5) % 60:02d}00", + open=10_000.0, high=10_025.0, low=9_975.0, close=10_000.0, + volume=10, cum_volume=0, cum_amount=0.0) + for i in range(20) + ] + return prev + today + + past = _atr(_pair("19990104", "19990105"), 14) + future = _atr(_pair("20990104", "20990105"), 14) + recent = _atr(_pair(PREV, TODAY), 14) + assert past == future == recent is not None, "세션 판별이 벽시계에 의존한다" + # 같은 하루 변동폭(±25)에서 나온 값이므로 갭(1,000원)이 섞이지 않았음도 함께 확인 + assert past == pytest.approx(50.0) + + # 날짜 필드가 비어 있는 입력(목/레거시)은 기존 동작을 유지한다 + undated = [ + MinuteCandle(date="", time=f"{9 + i // 12:02d}{(i * 5) % 60:02d}00", + open=100.0, high=101.0, low=99.0, close=100.0, + volume=10, cum_volume=0, cum_amount=0.0) + for i in range(20) + ] + assert _atr(undated, 14) is not None + + # 날짜 필드가 비어 있는 입력(목/레거시)은 기존 동작을 유지한다 + undated = [ + MinuteCandle(date="", time=f"{9 + i // 12:02d}{(i * 5) % 60:02d}00", + open=100.0, high=101.0, low=99.0, close=100.0, + volume=10, cum_volume=0, cum_amount=0.0) + for i in range(20) + ] + assert _atr(undated, 14) is not None + + +# ── 양봉 판정: 형성 중 버킷 → 마지막 완성봉 (2026-09-30) ────────────────────── + +def _oversold_today(n: int = 25) -> list[MinuteCandle]: + """RSI/BB 게이트를 통과할 하락 시계열 (마지막 완성봉은 호출부가 정한다).""" + return _series(TODAY, n, 10_600.0, step=-20.0, spread=8.0) + + +def _with_forming(bars: list[MinuteCandle], *, bull: bool) -> list[MinuteCandle]: + """형성 중 버킷([-1])을 양봉/음봉으로 바꾼다 (판정에 영향을 주면 안 된다).""" + out = list(bars) + c = out[-1] + out[-1] = MinuteCandle( + date=c.date, time=c.time, + open=c.close - 30 if bull else c.close + 30, + high=c.close + 10, low=c.close - 40, close=c.close, + volume=1200, cum_volume=0, cum_amount=0.0, + ) + return out + + +def test_bull_check_decided_by_completed_bar_not_forming_bucket(): + """형성 중 버킷이 양봉이든 음봉이든 판정은 **완성봉**으로 결정된다. + + 30초 주기 스캔에서 같은 5분 버킷을 약 10회 보므로, 형성 중 봉으로 판정하면 + 같은 시장 상태에 사이클마다 다른 답이 나온다(재현성 없는 게이트). + """ + base = _oversold_today() + base[-2] = _completed_bull_bar(base[-2]) # 완성봉 = 양봉 + price = base[-1].close + + sig_bull_forming = check_vwap_bounce_signal( + "005930", "삼성", _with_forming(base, bull=True), price, price) + sig_bear_forming = check_vwap_bounce_signal( + "005930", "삼성", _with_forming(base, bull=False), price, price) + + assert sig_bull_forming is not None + assert sig_bear_forming is not None, "형성 중 버킷이 음봉이라고 게이트가 닫혔다" + assert sig_bull_forming.stop_loss == sig_bear_forming.stop_loss + assert sig_bull_forming.target_price == sig_bear_forming.target_price + + +def test_bear_completed_bar_blocks_even_if_forming_bucket_is_bullish(): + """완성봉이 음봉이면 형성 중 버킷이 양봉이어도 차단된다 (역방향 확인).""" + base = _oversold_today() + c = base[-2] + base[-2] = MinuteCandle( # 완성봉 = 음봉 + 거래량 급증 + date=c.date, time=c.time, open=c.close + 20, high=c.high, low=c.low, + close=c.close, volume=5000, cum_volume=0, cum_amount=0.0, + ) + price = base[-1].close + assert check_vwap_bounce_signal( + "005930", "삼성", _with_forming(base, bull=True), price, price) is None + assert check_rsi_bb_signal( + "005930", "삼성", _with_forming(base, bull=True), price * 0.97) is None + + +def test_bull_check_uses_same_bar_as_volume_gate(): + """양봉 판정 대상 봉 == 거래량 게이트가 비교한 봉 (`session[-2]`).""" + from app.services.day_trading_strategy import ( + _last_completed_candle, volume_surge_ratio, + ) + base = _oversold_today() + base[-2] = _completed_bull_bar(base[-2]) + mixed = _series(PREV, 20, 11_000.0, step=-20.0, spread=8.0, start_min=300) + base + + assert _last_completed_candle(mixed) is mixed[-2] + assert volume_surge_ratio(mixed) == pytest.approx( + mixed[-2].volume / mixed[-3].volume + ) + + +def test_bull_check_never_crosses_session_boundary(): + """당일 완성봉이 없으면 미통과 — 전일 봉으로 게이트를 열지 않는다. + + 개장 직후(당일 봉 1개)에는 워밍업 시계열의 마지막 완성봉이 **전일 종가 동시호가 + 봉**이다. 그걸 보면 게이트가 전일 결과로 열린다(C1 과 같은 함정). + """ + from app.services.day_trading_strategy import _last_completed_candle + + prev_bull = _series(PREV, 20, 11_000.0, step=-20.0, spread=8.0, start_min=300) + c = prev_bull[-1] + prev_bull[-1] = MinuteCandle( # 전일 마지막 봉을 강한 양봉으로 + date=c.date, time=c.time, open=c.close - 100, high=c.close + 10, + low=c.close - 120, close=c.close, volume=9999, cum_volume=0, cum_amount=0.0, + ) + today_one = _series(TODAY, 1, 9_000.0) + + assert _last_completed_candle(prev_bull + today_one) is None, ( + "전일 봉이 '마지막 완성봉'으로 새어 들어왔다" + ) + # 데이터 부족은 통과 쪽으로 열지 않는다 + assert check_vwap_bounce_signal( + "005930", "삼성", prev_bull + today_one, 9_000.0, 9_000.0) is None + + +def test_last_completed_candle_is_wallclock_independent(): + """§2 순수성 — 과거/미래 날짜에서도 같은 위치의 봉을 고른다 (동작 검증).""" + from app.services.day_trading_strategy import _last_completed_candle + + for d_prev, d_today in (("19990104", "19990105"), ("20990104", "20990105"), + (PREV, TODAY)): + prev = _series(d_prev, 20, 11_000.0, step=-20.0, spread=8.0, start_min=300) + today = _series(d_today, 5, 10_000.0, step=-20.0, spread=8.0) + got = _last_completed_candle(prev + today) + assert got is today[-2], f"{d_today}: 마지막 완성봉 선택이 벽시계에 의존한다" + + +# ── 진단 정보 (갭·ATR) — 게이트가 아니라 관찰 수단 (2026-09-30) ─────────────── + +def test_overnight_gap_pct_from_data_only(): + """갭률은 데이터(전일 마지막 종가 vs 당일 첫 시가)로만 계산된다.""" + from app.services.day_trading_strategy import _overnight_gap_pct + + prev = _series(PREV, 20, 10_000.0, spread=10.0, start_min=300) + today = _series(TODAY, 20, 9_700.0, spread=10.0) # 시가 = 종가 = 9,700 + gap = _overnight_gap_pct(prev + today) + assert gap == pytest.approx((9_700 - 10_000) / 10_000 * 100) # -3.00% + + # 전일 봉이 없으면 계산 불가 → None (억지로 만들지 않는다) + assert _overnight_gap_pct(today) is None + # 날짜 필드가 빈 목/레거시 입력도 None + undated = [ + MinuteCandle(date="", time="0900%02d" % 0, open=100.0, high=101.0, + low=99.0, close=100.0, volume=1, cum_volume=0, cum_amount=0.0) + ] + assert _overnight_gap_pct(undated) is None + + +def test_reason_carries_gap_and_atr_diagnostics(): + """진입 reason 에 갭·ATR 이 **진단 정보**로 남는다 (게이트 아님). + + RSI 갭 섭취는 유지 결정이 났고(차트 표준·평균회귀 의도와 방향 일치) 대신 관찰 + 대상이 됐다. 원장(`trade_history.entry_reason`)만으로 "갭 하락 종목에 쏠렸는가"를 + 집계할 수 있어야 한다. + """ + base = _oversold_today() + base[-2] = _completed_bull_bar(base[-2]) + price = base[-1].close + mixed = _series(PREV, 20, 11_000.0, step=-20.0, spread=8.0, start_min=300) + base + + sig = check_vwap_bounce_signal("005930", "삼성", mixed, price, price) + assert sig is not None + assert "[진단 " in sig.reason, "진단 정보가 게이트 조건과 구분되지 않는다" + assert "갭=" in sig.reason and "%" in sig.reason + assert "ATR=" in sig.reason + + sig_rb = check_rsi_bb_signal("005930", "삼성", mixed, price * 0.97) + assert sig_rb is not None and "갭=" in sig_rb.reason and "ATR=" in sig_rb.reason + + +def test_reason_marks_fallback_stop_when_atr_absent(): + """ATR 이 없으면 reason 이 '폴백손절'임을 밝힌다. + + 09:30~10:10 은 당일 5분봉 15개 미만이라 ATR 이 없고 손절이 + `DAY_TRADE_REVERSION_STOP_LOSS_PCT`(폴백)로 결정된다 — 원장에서 구분되어야 한다. + """ + today = _series(TODAY, 14, 10_300.0, step=-20.0, spread=8.0) + today[-2] = _completed_bull_bar(today[-2]) + mixed = _series(PREV, 20, 10_700.0, step=-20.0, spread=8.0, start_min=300) + today + price = today[-1].close + + from app.services.day_trading_strategy import _atr as _atr_fn + assert _atr_fn(mixed, 14) is None + sig = check_vwap_bounce_signal("005930", "삼성", mixed, price, price) + assert sig is not None + assert "ATR=없음→폴백손절" in sig.reason + assert sig.stop_loss == pytest.approx(round(min( + price * (1 - settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT), + price * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT), + ), 0)) + + +def test_diagnostics_do_not_change_any_decision(): + """진단 정보 추가가 판정·손절·목표를 바꾸지 않는다. + + `reason` 외의 모든 필드가 전일 봉 유무와 무관하게 동일해야 한다 + (진단 문자열만 달라진다). + """ + base = _oversold_today() + base[-2] = _completed_bull_bar(base[-2]) + price = base[-1].close + mixed = _series(PREV, 20, 11_000.0, step=-20.0, spread=8.0, start_min=300) + base + + a = check_vwap_bounce_signal("005930", "삼성", base, price, price) + b = check_vwap_bounce_signal("005930", "삼성", mixed, price, price) + assert a is not None and b is not None + for field in ("strategy", "entry_price", "stop_loss", "target_price", "confidence"): + assert getattr(a, field) == getattr(b, field), f"{field} 가 달라졌다" + # 당일 전용 입력에는 갭이 없다(계산 불가) → 진단에서 생략된다 + assert "갭=" not in a.reason and "갭=" in b.reason + + +def test_orb_reason_has_atr_but_no_gap(): + """ORB 경로는 당일 전용 시계열이라 갭을 계산할 수 없다 — ATR 만 남는다.""" + from app.services.day_trading_strategy import OpeningRange, check_orb_signal + + # 당일 1분봉 60개: 09:00~09:59, 마지막 완성 5분봉 양봉 + 거래량 급증 + bars = [] + for i in range(60): + total = 9 * 60 + i + close = 10_000 + (i if i < 50 else 60 + i) + bars.append(MinuteCandle( + date=TODAY, time=f"{total // 60:02d}{total % 60:02d}00", + open=close - 5, high=close + 5, low=close - 10, close=close, + volume=5000 if i >= 50 else 100, cum_volume=0, cum_amount=0.0, + )) + orb = OpeningRange(high=10_020.0, low=9_950.0, total_volume=3000, + avg_vol_per_min=100.0, open_price=10_000.0) + price = bars[-1].close + sig = check_orb_signal("005930", "삼성", bars, orb, price) + if sig is not None: # 조건 미충족이면 이 단정은 의미가 없다 + assert "ATR=" in sig.reason + assert "갭=" not in sig.reason + + +# ── M-1: 1.5R 하한 보장 (2026-09-30) ───────────────────────────────────────── + +def _rr(sig) -> float: + """보상/위험 배수.""" + return (sig.target_price - sig.entry_price) / (sig.entry_price - sig.stop_loss) + + +def _signal_pair(n_today: int): + """(VWAP 신호, RSI_BB 신호) — 당일 봉 수로 ATR 유무를 가른다. + + n_today=25 → 당일 ATR(14) 산출 가능 / n_today=14 → ATR 없음(폴백 손절폭). + """ + today = _series(TODAY, n_today, 10_600.0, step=-20.0, spread=8.0) + today[-2] = _completed_bull_bar(today[-2]) + mixed = _series(PREV, 20, 11_000.0, step=-20.0, spread=8.0, start_min=300) + today + price = today[-1].close + return ( + check_vwap_bounce_signal("005930", "삼성", mixed, price, price), + check_rsi_bb_signal("005930", "삼성", mixed, price * 0.97), + ) + + +@pytest.mark.parametrize("n_today,label", [(25, "ATR 있음"), (14, "ATR 폴백")]) +def test_target_guarantees_target_multiplier_r(n_today, label): + """목표가는 항상 위험의 `DAY_TRADE_TARGET_MULTIPLIER` 배 이상이다. + + `target_price` 는 `daytrading/exit_service` 의 분할매도 트리거로 실제 쓰인다(§7). + 위험보다 작은 목표는 R:R 역전이다 — ATR 유무 양쪽에서 막는다. + """ + sig_vwap, sig_rb = _signal_pair(n_today) + assert sig_vwap is not None and sig_rb is not None, "테스트 데이터가 신호를 못 만든다" + for name, sig in (("VWAP", sig_vwap), ("RSI_BB", sig_rb)): + risk = sig.entry_price - sig.stop_loss + assert risk > 0 + assert sig.target_price - sig.entry_price >= risk * settings.DAY_TRADE_TARGET_MULTIPLIER - 1e-6, ( + f"{label}/{name}: {_rr(sig):.2f}R < " + f"{settings.DAY_TRADE_TARGET_MULTIPLIER}R " + f"(진입 {sig.entry_price:.0f} 손절 {sig.stop_loss:.0f} 목표 {sig.target_price:.0f})" + ) + + +def test_low_volatility_vwap_entry_is_not_r_inverted(): + """저변동 종목(1.5×ATR < 최소손절폭)에서도 R:R 이 역전되지 않는다. + + 수정 전 실측: 진입 10,120 / ATR 29 → 손절 -2.00% / 목표 +0.43% = **0.21R**. + 손절이 `min()` 으로 -2% 쪽으로 느슨해지는데 목표는 `1.5×ATR` 이라 위험보다 작았다. + """ + sig_vwap, _ = _signal_pair(25) + assert sig_vwap is not None + from app.services.day_trading_strategy import _atr as _atr_fn + today = _series(TODAY, 25, 10_600.0, step=-20.0, spread=8.0) + today[-2] = _completed_bull_bar(today[-2]) + atr = _atr_fn(today, 14) + assert atr is not None + # 전제: 이 데이터는 저변동(1.5×ATR < 최소손절폭)이라 옛 공식이면 R:R < 1 이었다 + assert settings.DAY_TRADE_TARGET_MULTIPLIER * atr < ( + sig_vwap.entry_price * settings.DAY_TRADE_MIN_STOP_LOSS_PCT + ) + assert _rr(sig_vwap) >= settings.DAY_TRADE_TARGET_MULTIPLIER - 1e-6 + + +def test_target_uses_the_larger_of_two_candidates(): + """기존 목표식이 더 크면 그 값이 유지된다 (1.5R 하한은 '하한'일 뿐). + + RSI_BB 는 `max(bb_middle, 1.5R)` 이므로 BB 중앙선이 멀면 그쪽이 이긴다. + """ + _, sig_rb = _signal_pair(25) + assert sig_rb is not None + assert _rr(sig_rb) > settings.DAY_TRADE_TARGET_MULTIPLIER, ( + "BB 중앙선이 1.5R 보다 먼 데이터인데 1.5R 로 깎였다" + ) diff --git a/backend/tests/test_daytrading_watchlist_size_20260930.py b/backend/tests/test_daytrading_watchlist_size_20260930.py new file mode 100644 index 0000000..240eb59 --- /dev/null +++ b/backend/tests/test_daytrading_watchlist_size_20260930.py @@ -0,0 +1,530 @@ +"""2026-09-30 감시종목 확대 회귀 테스트 (DAY_TRADE_WATCHLIST_SIZE). + +배경: `populate_daytrading_watchlist(max_stocks=20)` 의 20 이 하드코딩이라 ORB 후보 +풀을 넓힐 방법이 없었다. 값을 config 로 이관하면서 다음을 고정한다. + + 1. 상한은 settings 에서 온다 (하드코딩 20 잔재 없음, 명시 인자는 여전히 우선) + 2. 확대해도 **리스크 노출은 불변**이다 — 동시 보유·종목당 예산·DT 합산 한도는 + 감시종목 수를 참조하지 않는다 + 3. 스캔 예산 불변식 — 감시종목 수는 `_SCAN_TIME_BUDGET` 안에서 소화 가능한 + 범위여야 한다(값을 올릴 때 이 테스트가 재산정을 강제한다) + +CLAUDE.md §4.1: 기대값에 config 값을 하드코딩하지 않고 `settings.*` 를 참조한다. +""" +import inspect +from datetime import datetime, timedelta +from unittest.mock import patch + +import pytest +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.ext.compiler import compiles + +from app.core.config import settings + + +@compiles(JSONB, "sqlite") +def _jsonb_as_json(type_, compiler, **kw): # 테스트용 SQLite 호환 + return "JSON" + + +from app.models.models import Base, StockAnalysis # noqa: E402 +from app.services.daytrading import entry_service as es # noqa: E402 + + +async def _mk_session(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(lambda c: Base.metadata.create_all(c, tables=[StockAnalysis.__table__])) + return engine, async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +def _row(code, d, score=3, price=10000): + return StockAnalysis( + stock_code=code, stock_name=f"N{code}", analysis_date=d, + score=score, rsi=40.0, current_price=price, + ) + + +@pytest.fixture(autouse=True) +def _clear_watchlist(): + """모듈 전역 상태를 테스트마다 격리한다 (§4.1: 테스트 순서 의존 방지). + + `_scan_offset`·`_rsi_diag_cycles`·`_candle_cache` 는 모듈 전역이라 + 남겨 두면 다른 파일의 라운드로빈·억제 카운터 테스트를 오염시킨다. + """ + from app.services.daytrading.position_manager import day_trading_state + + def _reset(): + es.set_watchlist([]) + es._candle_cache.clear() + es._scan_offset = 0 + es._rsi_diag_cycles = 0 + day_trading_state.positions.clear() + day_trading_state.closed_today.clear() + + _reset() + yield + _reset() + + +async def _run_scan(fake_try_enter, *, hh: int = 11, mm: int = 0) -> list: + """`scan_and_enter()` 를 게이트 통과 상태로 1회 실행한다 (동작 검증용 하네스). + + §4.1: 주문·잔고·시세 계열은 conftest 가 예외 발생형으로 차단하므로 여기서 + 명시적으로 목킹한다. 목킹 지점은 실제 호출부와 동일하다. + """ + from datetime import datetime + from unittest.mock import AsyncMock + + fake_now = datetime.now(es._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) + + class _Adp: + fallback_used = False + daytrading_enabled = True + trading_mode = "normal" + + with patch.object(es, "datetime", _DT), \ + patch.object(es.mdd_manager, "is_short_term_strategy_enabled", + AsyncMock(return_value=True)), \ + patch.object(es.mdd_manager, "can_open_new_position", + AsyncMock(return_value=True)), \ + patch("app.services.adaptive_strategy.get_current_adaptive_params", + return_value=_Adp()), \ + patch.object(es, "is_daytrading_allowed_by_playbook", return_value=True), \ + patch.object(es, "check_market_regime", + AsyncMock(return_value={"regime": "neutral"})), \ + patch.object(es.korea_investment_api, "get_balance", AsyncMock( + return_value={"output2": [{"tot_evlu_amt": "100000000", + "prvs_rcdl_excc_amt": "90000000"}]})), \ + patch.object(es.korea_investment_api, "get_buyable_cash", + AsyncMock(return_value=90_000_000.0)), \ + patch.object(es, "_prefetch_minute_charts", AsyncMock(return_value=0)), \ + patch.object(es, "_try_enter", fake_try_enter): + return await es.scan_and_enter() + + +# ── 1. 상한은 settings 에서 온다 ────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_watchlist_size_comes_from_settings(): + """인자를 생략하면 settings.DAY_TRADE_WATCHLIST_SIZE 가 상한이 된다.""" + engine, Session = await _mk_session() + today = datetime.now(es._KST).date() + async with Session() as s: + # 상한보다 5개 많은 후보를 넣는다 (score 내림차순으로 잘려야 한다) + s.add_all([ + _row(f"{i:06d}", today, score=100 - i) + for i in range(settings.DAY_TRADE_WATCHLIST_SIZE + 5) + ]) + await s.commit() + + with patch("app.core.database.AsyncSessionLocal", Session): + n = await es.populate_daytrading_watchlist() + assert n == settings.DAY_TRADE_WATCHLIST_SIZE + assert len(es.get_watchlist()) == settings.DAY_TRADE_WATCHLIST_SIZE + await engine.dispose() + + +@pytest.mark.asyncio +async def test_watchlist_size_setting_is_honored_when_changed(): + """설정을 바꾸면 그대로 반영된다 (20 하드코딩 잔재 없음).""" + engine, Session = await _mk_session() + today = datetime.now(es._KST).date() + async with Session() as s: + s.add_all([_row(f"{i:06d}", today, score=100 - i) for i in range(40)]) + await s.commit() + + for size in (7, 33): + with patch.object(settings, "DAY_TRADE_WATCHLIST_SIZE", size), \ + patch("app.core.database.AsyncSessionLocal", Session): + es.set_watchlist([]) + n = await es.populate_daytrading_watchlist() + assert n == size, f"설정 {size} 가 반영되지 않았다 (하드코딩 상한 의심)" + await engine.dispose() + + +@pytest.mark.asyncio +async def test_explicit_max_stocks_still_wins(): + """기존 시그니처 보존 — 명시 인자는 settings 보다 우선한다 (§4).""" + engine, Session = await _mk_session() + today = datetime.now(es._KST).date() + async with Session() as s: + s.add_all([_row(f"{i:06d}", today, score=100 - i) for i in range(10)]) + await s.commit() + + with patch("app.core.database.AsyncSessionLocal", Session): + n = await es.populate_daytrading_watchlist(1, 4) + assert n == 4 + with patch("app.core.database.AsyncSessionLocal", Session): + n = await es.populate_daytrading_watchlist(min_score=1, max_stocks=6) + assert n == 6 + await engine.dispose() + + +@pytest.mark.asyncio +async def test_yesterday_fallback_also_uses_setting(): + """당일 분석이 없어 최근 분석일로 폴백하는 경로도 같은 상한을 쓴다.""" + engine, Session = await _mk_session() + yday = datetime.now(es._KST).date() - timedelta(days=1) + async with Session() as s: + s.add_all([_row(f"{i:06d}", yday, score=100 - i) for i in range(12)]) + await s.commit() + + with patch.object(settings, "DAY_TRADE_WATCHLIST_SIZE", 9), \ + patch("app.core.database.AsyncSessionLocal", Session): + n = await es.populate_daytrading_watchlist() + assert n == 9 + await engine.dispose() + + +def test_no_hardcoded_default_in_signature(): + """기본값은 None(=settings 위임) 이어야 한다 — 숫자 기본값 복귀 방지. + + "settings 를 읽는가"는 소스 문자열이 아니라 + `test_watchlist_size_setting_is_honored_when_changed` 가 **동작으로** 검증한다. + """ + sig = inspect.signature(es.populate_daytrading_watchlist) + assert sig.parameters["max_stocks"].default is None + + +# ── 2. 리스크 노출 불변 ─────────────────────────────────────────────────────── + +def test_position_and_capital_limits_do_not_depend_on_watchlist(): + """감시종목 수는 동시 보유·예산 한도에 전혀 관여하지 않는다.""" + from app.services import day_trading_strategy as dts + + assert dts.MAX_POSITIONS == settings.DAY_TRADE_MAX_POSITIONS + + # 사이징은 총자산·현금·기존 DT 투입액만의 함수다 (감시종목 수 무관) + sig = inspect.signature(dts.calculate_day_trade_position_size) + assert set(sig.parameters) == { + "total_assets", "available_cash", "current_day_trade_amount", + } + budget = dts.calculate_day_trade_position_size( + total_assets=100_000_000, available_cash=100_000_000, current_day_trade_amount=0, + ) + assert budget == pytest.approx(100_000_000 * settings.DAY_TRADE_MAX_CAPITAL_RATIO) + + # DT 합산 한도 — 종목당 한도 × 동시 보유 한도가 DT 총한도를 넘지 않는다 + assert ( + settings.DAY_TRADE_MAX_CAPITAL_RATIO * settings.DAY_TRADE_MAX_POSITIONS + <= settings.DAY_TRADE_MAX_RATIO_DT + 1e-9 + ) + + +@pytest.mark.asyncio +async def test_scan_stops_at_position_cap_behaviorally(): + """감시종목이 한도의 4배여도 **실제 진입은 MAX_POSITIONS 에서 멈춘다**. + + 소스 문자열 매칭이 아니라 `scan_and_enter()` 를 돌려 결과 건수로 확인한다. + """ + from app.services.day_trading_strategy import MAX_POSITIONS + from app.services.daytrading.position_manager import day_trading_state + + day_trading_state.positions.clear() + day_trading_state.closed_today.clear() + es.set_watchlist([ + {"code": f"{i:06d}", "name": f"N{i}"} for i in range(MAX_POSITIONS * 4) + ]) + + async def fake_try_enter(code, name, total_assets, available_cash, + is_lunch_break=False): + # 실제 경로처럼 인메모리 포지션을 늘린다 (루프의 break 조건 대상) + day_trading_state.add_position(code, { + "stock_name": name, "strategy": "VWAP_BOUNCE", "entry_price": 1000.0, + "stop_loss": 980.0, "target_price": 1030.0, "quantity": 1, + "highest_price": 1000.0, "entry_time": None, "reason": "t", "order_id": "", + }) + return {"stock_code": code, "quantity": 1, "price": 1000.0} + + entries = await _run_scan(fake_try_enter) + assert len(entries) == MAX_POSITIONS, ( + f"감시종목 {MAX_POSITIONS * 4}개인데 진입 {len(entries)}건 — 한도 {MAX_POSITIONS} 초과/미달" + ) + assert day_trading_state.position_count() == MAX_POSITIONS + day_trading_state.positions.clear() + es.set_watchlist([]) + + +def test_budget_clamp_depends_only_on_its_arguments(): + """사이징 클램프는 인자만의 함수다 — 감시목록 크기가 새어들 여지가 없다.""" + args = dict(total_assets=100_000_000, available_cash=100_000_000, + current_day_trade_amount=0) + before = es._clamp_day_trade_budget(10_000_000, **args) + es.set_watchlist([{"code": f"{i:06d}", "name": "x"} for i in range(50)]) + after = es._clamp_day_trade_budget(10_000_000, **args) + es.set_watchlist([]) + assert before == after == 100_000_000 * settings.DAY_TRADE_MAX_CAPITAL_RATIO + + +def test_portfolio_holdings_cap_is_unrelated_to_daytrading_watchlist(): + """MAX_HOLDINGS_COUNT(포트폴리오 전체 보유 한도)는 DT 진입 경로에 없다.""" + import app.services.daytrading.entry_service as mod + assert "MAX_HOLDINGS_COUNT" not in inspect.getsource(mod) + + +# ── 3. 스캔 예산 불변식 ─────────────────────────────────────────────────────── + +def test_candle_cache_ttl_covers_one_cycle(): + """[M3] 캐시 TTL 은 한 사이클 상한보다 커야 한다 (선조회 재조회 방지).""" + assert es._CANDLE_CACHE_TTL >= es._SCAN_TIME_BUDGET + + +def test_watchlist_size_fits_scan_budget(): + """감시종목 수가 선조회 단계 예산 안에 들어오는지 검증. + + 비용 모델(2026-09-30 산정): + 선조회 wall ≈ 종목수 × 페이지수 × 페이지비용 ÷ _SCAN_PREFETCH_CONCURRENCY + + - 페이지비용 = 요청시간 3.0초 + in-loop sleep(`API_MIN_REQUEST_INTERVAL`+0.05) + · 요청시간은 운영 `monitoring/api-stats` avg_duration 실측(재시도 포함) + · sleep 은 `korea_investment_api._get_minute_chart_full_day_impl` + - 페이지수는 시간대 비례(당일 1분봉÷30봉): 09:30 4장 / 14:00 10장 / 15:00 12장 + - **판정 기준은 14:00(10장)**: 진입 마감 15:00 전 대부분의 시간을 커버하는 지점이다. + 15:00 직전(12장)은 부분 커버리지를 감수하고 라운드로빈에 맡긴다. + + DAY_TRADE_WATCHLIST_SIZE 를 올리려면 이 계산을 다시 해야 한다 — + 그게 이 테스트의 목적이다. + """ + req_sec = 3.0 + page_sec = req_sec + settings.API_MIN_REQUEST_INTERVAL + 0.05 + pages_afternoon = 10 # 14:00 기준 + + max_stocks = int( + es._SCAN_PREFETCH_TOTAL_BUDGET * es._SCAN_PREFETCH_CONCURRENCY + / (pages_afternoon * page_sec) + ) + assert settings.DAY_TRADE_WATCHLIST_SIZE <= max_stocks, ( + f"감시종목 {settings.DAY_TRADE_WATCHLIST_SIZE}개는 14:00 기준 선조회 예산 상한 " + f"{max_stocks}개를 초과한다 (페이지 {page_sec:.2f}초 × {pages_afternoon}장, " + f"동시성 {es._SCAN_PREFETCH_CONCURRENCY}, 예산 {es._SCAN_PREFETCH_TOTAL_BUDGET:.0f}초)" + ) + + +def test_per_stock_prefetch_timeout_covers_full_session(): + """장 마감 직전(12페이지) 종목도 per-stock 타임아웃 안에 들어와야 한다. + + 넘으면 12:00 이후 전 종목이 선조회 타임아웃되고, 순차 루프가 타임아웃 없는 + 직렬 재조회를 떠안아 오히려 느려진다(2026-09-30 이전 상태). + """ + page_sec = 3.0 + settings.API_MIN_REQUEST_INTERVAL + 0.05 + pages_max = 12 # 15:00, 당일 1분봉 360봉 ÷ 30 + assert pages_max * page_sec <= es._SCAN_PREFETCH_TIMEOUT, ( + f"15:00 기준 종목당 {pages_max * page_sec:.0f}초 > 타임아웃 " + f"{es._SCAN_PREFETCH_TIMEOUT:.0f}초" + ) + + +def test_prefetch_phase_deadline_protects_scan_and_guard(): + """선조회 단계 데드라인이 순차 구간과 스케줄러 가드를 모두 보호한다. + + per-stock 타임아웃만으로는 최악의 경우 ceil(N/동시성) 웨이브 × 타임아웃이 + _guard(STEP_TIMEOUT=180)를 넘겨 스캔 전체가 조용히 취소된다. + """ + from app.services.daytrading import scheduler as sch + + # 단계 데드라인 + 순차 구간 몫 ≤ 사이클 예산 ≤ 스케줄러 가드 + assert es._SCAN_PREFETCH_TOTAL_BUDGET < es._SCAN_TIME_BUDGET + assert es._SCAN_TIME_BUDGET < sch.STEP_TIMEOUT + # 순차 구간에 최소 30초(= 최대 5건 주문 여유)를 남긴다 + assert es._SCAN_TIME_BUDGET - es._SCAN_PREFETCH_TOTAL_BUDGET >= 30 + # 데드라인이 per-stock 타임아웃보다 커야 1웨이브라도 완주한다 + assert es._SCAN_PREFETCH_TOTAL_BUDGET > es._SCAN_PREFETCH_TIMEOUT + + +@pytest.mark.asyncio +async def test_prefetch_phase_returns_within_deadline(monkeypatch): + """느린 종목이 많아도 선조회 **단계**가 데드라인 안에 반환된다 (동작 검증). + + 상수만 있고 실제로 걸려 있지 않은 상태를 소스 매칭 없이 잡는다. + """ + import asyncio + import time as _time + + slow_started = {"n": 0} + + async def slow_fetch(code): + slow_started["n"] += 1 + await asyncio.sleep(30) # per-stock 타임아웃보다도 길다 + return [object()] + + monkeypatch.setattr(es, "_fetch_candles_impl", slow_fetch) + monkeypatch.setattr(es, "_SCAN_PREFETCH_TIMEOUT", 5.0) + monkeypatch.setattr(es, "_SCAN_PREFETCH_TOTAL_BUDGET", 0.3) + es._candle_cache.clear() + + t0 = _time.monotonic() + n = await es._prefetch_minute_charts([f"{i:06d}" for i in range(40)]) + elapsed = _time.monotonic() - t0 + + assert elapsed < 3.0, ( + f"단계 데드라인(0.3초)이 걸려 있지 않다 — {elapsed:.1f}초 소요 " + f"(per-stock 타임아웃 5초 × 웨이브만큼 걸린 것)" + ) + assert n == 0, "타임아웃된 종목이 캐시에 담겨선 안 된다" + assert slow_started["n"] > 0, "선조회가 아예 시작되지 않았다" + es._candle_cache.clear() + + +def test_prefetch_concurrency_within_api_limits(): + """선조회 동시성이 KIS 레이트리밋·세마포어 안에 있는지. + + - 동시성 ≤ API_MAX_CONCURRENT_REQUESTS: 초과분은 세마포어에서 대기만 하므로 무의미 + - 실효 요청률 = 동시성 ÷ 페이지비용 ≤ 전역 쓰로틀 상한(1/API_MIN_REQUEST_INTERVAL) + 의 50% — 나머지는 스윙 스케줄러·배치·주문 경합 몫으로 남긴다 + """ + assert es._SCAN_PREFETCH_CONCURRENCY <= settings.API_MAX_CONCURRENT_REQUESTS + page_sec = 3.0 + settings.API_MIN_REQUEST_INTERVAL + 0.05 + req_rate = es._SCAN_PREFETCH_CONCURRENCY / page_sec + throttle_cap = 1.0 / settings.API_MIN_REQUEST_INTERVAL + assert req_rate <= throttle_cap * 0.5, ( + f"선조회 요청률 {req_rate:.2f} req/s 가 쓰로틀 상한 {throttle_cap:.2f} req/s 의 " + f"50% 를 넘는다 — 타 스케줄러 경합 여유가 없다" + ) + + +def test_prefetch_is_read_only(): + """선조회는 읽기 전용 — 주문·포지션 변경·DB 쓰기가 섞여선 안 된다. + + 주문이 순차 루프에만 있어야 MAX_POSITIONS·예산 클램프 순서가 보장된다 + (동시성을 올렸으므로 이 불변식이 더 중요해졌다). + """ + src = inspect.getsource(es._prefetch_minute_charts) + for forbidden in ( + "safe_place_order", "place_order", "add_position", "enqueue_", + "position_count", "_clamp_day_trade_budget", + ): + assert forbidden not in src, f"선조회에 {forbidden} 가 섞였다" + # 순차 진입 루프는 여전히 for 문 안에서 하나씩 await 한다 + scan_src = inspect.getsource(es.scan_and_enter) + assert "await _try_enter(" in scan_src + assert "gather" not in scan_src, "진입이 병렬화되면 포지션 한도가 깨진다" + + +def test_orb_collection_fits_opening_window(): + """ORB 수집(09:00~09:29)이 감시종목 전부를 소화할 수 있는지. + + 수집은 종목당 **1페이지**(당일 09:00~ 분봉은 30봉 이내) + 0.3초 간격 순차다. + 첫 유효 수집은 봉 5개가 모이는 09:05 경이므로 가용 시간은 약 24분. + """ + req_sec = 3.0 + per_stock = req_sec + 0.3 + window_sec = 24 * 60 + max_stocks = int(window_sec / per_stock) + assert settings.DAY_TRADE_WATCHLIST_SIZE <= max_stocks + # 한 사이클 가드(300초) 안에서도 소화 가능해야 중간 취소로 유실되지 않는다 + assert settings.DAY_TRADE_WATCHLIST_SIZE <= int(300 / per_stock) + + +# ── RSI·갭 기저 분포 진단 로깅 (2026-09-30) ─────────────────────────────────── + +def _mk_5m(date: str, n: int, base: float, step: float = -20.0) -> list: + """5분봉 n개 (MinuteCandle).""" + from app.services.day_trading_strategy import MinuteCandle + out = [] + for i in range(n): + total = 9 * 60 + i * 5 + c = base + i * step + out.append(MinuteCandle( + date=date, time=f"{total // 60:02d}{total % 60:02d}00", + open=c, high=c + 10, low=c - 10, close=c, + volume=1000, cum_volume=0, cum_amount=0.0, + )) + return out + + +def test_rsi_diagnostics_logs_one_line_per_cycle_window(caplog): + """후보 전체의 (RSI, 갭) 분포가 **한 줄로** 남는다 — 통과 여부와 무관하게.""" + import logging + + es._candle_cache.clear() + es._rsi_diag_cycles = 0 + cands = [] + for i in range(3): + code = f"00000{i}" + prev = _mk_5m("20260929", 20, 11_000.0) + today = _mk_5m("20260930", 25, 10_600.0 - i * 100) + es._candle_cache[code] = (1e12, prev + today) # 만료 없는 타임스탬프 + cands.append((code, f"N{code}")) + + with caplog.at_level(logging.INFO, logger=es.logger.name): + es._log_rsi_gap_diagnostics(cands) + + msgs = [r.message for r in caplog.records if "RSI 진단" in r.message] + assert len(msgs) == 1, "사이클당 1줄이어야 한다" + line = msgs[0] + assert "n=3" in line + # 임계값은 하드코딩하지 않고 settings 실효값이 찍힌다 (§4) + assert f"RSI≤{settings.DAY_TRADE_VWAP_RSI_THRESHOLD:.0f}" in line + assert f"RSI≤{settings.DAY_TRADE_RSI_BB_RSI_THRESHOLD:.0f}" in line + assert "갭" in line and "최저 RSI" in line + es._candle_cache.clear() + + +def test_rsi_diagnostics_is_volume_suppressed(caplog): + """최초 1회 + N 사이클마다 1회만 남긴다 (로그 폭주 방지).""" + import logging + + es._candle_cache.clear() + es._rsi_diag_cycles = 0 + es._candle_cache["000001"] = ( + 1e12, _mk_5m("20260929", 20, 11_000.0) + _mk_5m("20260930", 25, 10_600.0) + ) + cands = [("000001", "N")] + + with caplog.at_level(logging.INFO, logger=es.logger.name): + for _ in range(es._RSI_DIAG_LOG_EVERY + 1): + es._log_rsi_gap_diagnostics(cands) + + msgs = [r.message for r in caplog.records if "RSI 진단" in r.message] + assert len(msgs) == 2, ( + f"최초 1회 + {es._RSI_DIAG_LOG_EVERY}회째 = 2줄이어야 하는데 {len(msgs)}줄" + ) + es._candle_cache.clear() + es._rsi_diag_cycles = 0 + + +def test_rsi_diagnostics_is_read_only_and_uses_cache_only(): + """진단은 KIS 호출·DB I/O·상태 변경을 하지 않는다 (§3.1).""" + import inspect + src = inspect.getsource(es._log_rsi_gap_diagnostics) + for forbidden in ( + "korea_investment_api", "AsyncSessionLocal", "enqueue_", "await", + "set_orb", "add_position", "safe_place_order", + ): + assert forbidden not in src, f"진단에 {forbidden} 가 섞였다" + assert "_candle_cache" in src, "선조회 캐시만 읽어야 한다(추가 조회 금지)" + + +@pytest.mark.asyncio +async def test_diagnostics_failure_does_not_lose_entries(monkeypatch, caplog): + """진단이 예외를 던져도 진입 결과가 유실되지 않고 warning 이 남는다 (동작 검증).""" + import logging + from app.services.daytrading.position_manager import day_trading_state + + def boom(_candidates): + raise RuntimeError("diag boom") + + monkeypatch.setattr(es, "_log_rsi_gap_diagnostics", boom) + day_trading_state.positions.clear() + day_trading_state.closed_today.clear() + es.set_watchlist([{"code": "000001", "name": "N"}]) + + async def fake_try_enter(code, name, total_assets, available_cash, + is_lunch_break=False): + return {"stock_code": code, "quantity": 1, "price": 1000.0} + + with caplog.at_level(logging.WARNING, logger=es.logger.name): + entries = await _run_scan(fake_try_enter) + + assert len(entries) == 1, "진단 예외가 진입 결과를 삼켰다" + assert any("진단 로깅 실패" in r.message for r in caplog.records) + assert all(r.levelno >= logging.WARNING + for r in caplog.records if "진단 로깅 실패" in r.message) + es.set_watchlist([]) diff --git a/backend/tests/test_dt_warmup_and_orb_volume_20260929.py b/backend/tests/test_dt_warmup_and_orb_volume_20260929.py index 5a6c428..ee133af 100644 --- a/backend/tests/test_dt_warmup_and_orb_volume_20260929.py +++ b/backend/tests/test_dt_warmup_and_orb_volume_20260929.py @@ -89,16 +89,22 @@ def _warmup_scenario() -> tuple[list, list]: 당일: 09:00~09:29 (5분봉 6개). 전일 13:00~15:19 (5분봉 28개 워밍업). 가격은 완만한 하락 후 마지막 완성봉에서 급락 → RSI 과매도 + BB 하단 이탈, - 형성 중 봉은 양봉. 거래량은 마지막 완성봉이 직전 완성봉의 2배. + **그 완성봉이 양봉 마감**. 거래량은 마지막 완성봉이 직전 완성봉의 2배. + + [2026-09-30] 원래 시나리오는 반등(양봉)을 **형성 중 버킷**에 두고 있었다. 양봉 + 판정이 형성 중 버킷 → 마지막 완성봉으로 고쳐졌으므로(같은 버킷을 30초마다 보며 + 답이 흔들리던 문제) 반등 봉을 완성봉 쪽으로 옮긴다. 이 파일의 목적(워밍업이 + 09:30 판정을 가능하게 하는가)은 그대로다. """ prev_day = _minute_seq(_prev(), 13, 0, 140, lambda i: 1000.0 - i * 0.2) # 당일 09:00~09:24 (완성 5분봉 5개) — 계속 하락 today_bars = _minute_seq(_today(), 9, 0, 20, lambda i: 972.0 - i * 0.5) - # 09:20~09:24 = 마지막 완성봉: 급락 + 거래량 2배 + # 09:20~09:24 = 마지막 완성봉: 급락(직전 버킷 942 → 906) 후 양봉 마감 + 거래량 2배 today_bars += _minute_seq( - _today(), 9, 20, 5, lambda i: 940.0 - i * 6.0, lambda i: 2000 + _today(), 9, 20, 5, + lambda i: (906.0, 908.0, 911.0, 914.0, 916.0)[i], lambda i: 2000, ) - # 09:25~09:29 = 형성 중 봉: 양봉 (반등 시작) + # 09:25~09:29 = 형성 중 봉 (양봉 판정 대상이 아니다) today_bars += [ _c(_today(), "092500", 912.0, 300, open_=910.0), _c(_today(), "092600", 913.0, 300, open_=912.0), diff --git a/backend/tests/test_external_demotion_race.py b/backend/tests/test_external_demotion_race.py new file mode 100644 index 0000000..ba64543 --- /dev/null +++ b/backend/tests/test_external_demotion_race.py @@ -0,0 +1,1078 @@ +"""유입 잔고 강등(swing → external) 레이스 방어 — 불변식 테스트. + +## 배경 (2026-09-30 사용자 결정) + +"스윙으로 편입하는 건 없어야 해" → `sync_portfolio()` 가 기존 `swing` 행도 +근거가 없으면 `external` 로 **강등**한다. 이전에는 승격만 허용했고, 그 결과 +`000100`(유한양행) 8주처럼 원장에 BUY 기록이 없는 유입 잔고가 스윙 손절 대상으로 +남아 2026-09-30 09:26 에 -6.49% / -40,000원으로 처분됐다. + +## 하지만 강등에는 레이스가 있다 + +`TradeHistory` 와 `ActiveTrade` 는 **둘 다 db_worker 큐를 거쳐 비동기 INSERT** 되고 +(CLAUDE.md §3.2), 매수 직후 `trade_executor._verify_and_sync_execution()` 이 +`_delayed_sync_portfolio(0)` 로 **즉시** sync 를 돌린다: + + 매수 체결 → Portfolio 행(swing) → [db_worker INSERT 전] → sync + → 근거 없음 → external 강등 → 손절·익절·트레일링 전부 미적용 + +## 불변식 (이 파일이 지키는 것) + +1. **정상 포지션은 강등되지 않는다** — 매수 직후 근거가 아직 DB 에 없어도. +2. **오래된 유입 잔고는 강등된다** — 하루씩 방치되지 않는다. + +두 명제가 **동시에** 성립해야 한다. +""" +import inspect + +import pytest + +from app.api.endpoints import portfolio as pf +from app.api.endpoints.portfolio import ( + EXTERNAL_STRATEGY_TYPE, + _DEMOTE_CONFIRM_SYNCS, + _DEMOTE_MIN_AGE_SEC, + _demote_state_reset, + _should_demote_to_external, +) +from app.services import system_buy_registry as sbr + + +@pytest.fixture(autouse=True) +def _clean_state(): + """모듈 전역 상태(미관측 카운터 / 매수 레지스트리)를 테스트마다 초기화.""" + _demote_state_reset() + sbr.clear() + yield + _demote_state_reset() + sbr.clear() + + +def _demote(code="000100", current="swing", classified=EXTERNAL_STRATEGY_TYPE, + queue_busy=False, now=0.0): + return _should_demote_to_external(code, current, classified, queue_busy, now=now) + + +# ── 불변식 1: 정상 포지션은 강등되지 않는다 ───────────────────────────────── + +def test_just_bought_row_is_never_demoted_even_after_many_syncs(): + """매수 직후(= system_buy_registry 에만 근거) 행은 sync 가 몇 번 돌아도 안전하다. + + DB(TradeHistory/ActiveTrade)에는 아직 근거가 없으므로 classified 는 external 이다. + 그래도 강등되면 방금 산 포지션의 손절·익절이 통째로 멈춘다. + """ + sbr.note_system_buy("000100") + # 시간을 충분히 흘려도(레지스트리 TTL 이내) 강등되지 않는다 + for i in range(10): + assert _demote(now=float(i) * 30.0) is False + + +def test_evidence_in_db_resets_miss_counter(): + """근거가 보이는 사이클이 한 번이라도 있으면 누적 카운터가 사라진다.""" + assert _demote(now=0.0) is False # 1회 미관측 + assert _demote(classified="swing", now=1.0) is False # 근거 등장 → 리셋 + # 리셋됐으므로 다시 1회부터 — 경과시간 조건도 새로 채워야 한다 + assert _demote(now=_DEMOTE_MIN_AGE_SEC + 2.0) is False + + +def test_single_observation_never_demotes(): + """첫 관측만으로는 강등하지 않는다 — 연속 확인이 필수.""" + assert _demote(now=0.0) is False + assert len(pf._demote_miss_state) == 1 + + +def test_rapid_consecutive_syncs_do_not_demote(): + """매수 직후 sync 가 연달아 여러 번 도는 경우 — 횟수는 채워도 경과시간이 막는다. + + `_verify_and_sync_execution` 이 `_delayed_sync_portfolio(0)` 를 부르고 UI 가 + 같은 순간에 sync 를 또 부를 수 있다. 횟수 조건만으로는 이 창을 못 막는다. + """ + for i in range(_DEMOTE_CONFIRM_SYNCS + 3): + assert _demote(now=float(i) * 0.5) is False, "1초 간격 연속 sync 로는 강등 금지" + + +def test_db_worker_queue_backlog_defers_demotion(): + """db_worker 큐에 미처리 태스크가 있으면 강등 보류 — 근거 INSERT 가 밀려 있을 수 있다.""" + assert _demote(now=0.0) is False + assert _demote(queue_busy=True, now=_DEMOTE_MIN_AGE_SEC + 10.0) is False + # 큐가 비면 누적 카운터를 물려받아 즉시 강등된다(보류는 카운터를 버리지 않는다) + assert _demote(queue_busy=False, now=_DEMOTE_MIN_AGE_SEC + 20.0) is True + + +def test_non_swing_rows_are_never_demoted(): + """'manual'·'us_swing'·'daytrading' 은 강등 대상이 아니다. + + 애초에 get_swing_holdings(strategy_type=='swing') 에 안 잡혀 스윙 자동 청산 + 대상이 아니고, 각 엔진이 strategy_name 으로 자기 행을 매칭한다. + """ + for t in ("manual", "us_swing", "daytrading", EXTERNAL_STRATEGY_TYPE): + for i in range(5): + assert _demote(current=t, now=float(i) * 1000.0) is False + + +def test_registry_failure_defers_demotion(): + """레지스트리 조회가 실패하면 강등하지 않는다 — 불확실할 때는 보류.""" + import app.services.system_buy_registry as mod + orig = mod.has_recent_system_buy + mod.has_recent_system_buy = lambda code: (_ for _ in ()).throw(RuntimeError("boom")) + try: + for i in range(5): + assert _demote(now=float(i) * 1000.0) is False + finally: + mod.has_recent_system_buy = orig + + +# ── 불변식 2: 오래된 유입 잔고는 강등된다 ─────────────────────────────────── + +def test_stale_inflow_holding_is_demoted(): + """근거가 계속 없고 확인 횟수·경과시간을 모두 넘기면 강등된다.""" + assert _demote(now=0.0) is False + assert _demote(now=_DEMOTE_MIN_AGE_SEC + 1.0) is True + + +def test_demotion_does_not_require_a_whole_day(): + """격리 지연 상한이 '하루'가 아니라 분 단위여야 한다 (자동 손절은 장중에 터진다).""" + assert _DEMOTE_MIN_AGE_SEC <= 600.0 + assert _DEMOTE_CONFIRM_SYNCS >= 2 + + +def test_demotion_clears_state_so_it_is_logged_once_per_episode(): + """강등 후 카운터가 비워진다 — 다음 사이클은 이미 external 이라 재판정하지 않는다.""" + _demote(now=0.0) + assert _demote(now=_DEMOTE_MIN_AGE_SEC + 1.0) is True + assert pf._demote_miss_state == {} + + +# ── 두 불변식이 동시에 성립함 ─────────────────────────────────────────────── + +def test_fresh_buy_protected_while_stale_inflow_demoted_in_same_cycle(): + """같은 sync 사이클에서 '방금 산 종목'은 살고 '유입 잔고'는 강등된다.""" + sbr.note_system_buy("111111") # 방금 매수 (DB 근거 아직 없음) + # 사이클 1 + assert _demote("111111", now=0.0) is False + assert _demote("222222", now=0.0) is False + # 사이클 2 (경과시간 충족) + t = _DEMOTE_MIN_AGE_SEC + 1.0 + assert _demote("111111", now=t) is False, "정상 포지션은 강등되지 않는다" + assert _demote("222222", now=t) is True, "유입 잔고는 강등된다" + + +# ── 레지스트리 자체 ───────────────────────────────────────────────────────── + +def test_registry_ttl_covers_db_worker_retry_budget(): + """TTL 은 db_worker 지수 백오프(1+2+4초 ×3회) 를 훨씬 넘겨야 한다.""" + assert sbr.SYSTEM_BUY_TTL_SEC >= 300 + + +def test_registry_snapshot_and_expiry(): + sbr.note_system_buy("000100") + assert "000100" in sbr.snapshot_codes() + assert sbr.has_recent_system_buy("000100") is True + assert sbr.has_recent_system_buy("999999") is False + sbr.clear() + assert sbr.snapshot_codes() == set() + + +def test_registry_note_is_sync_and_never_raises(): + """실시간 계층에서 호출되므로 동기 함수여야 하고, 예외로 매매를 막지 않는다.""" + assert not inspect.iscoroutinefunction(sbr.note_system_buy) + sbr.note_system_buy(None) # 예외 없음 + sbr.note_system_buy("") + + +# ── 호출부 배선 (레지스트리가 비어 있으면 1차 방어가 없다) ────────────────── + +def test_trade_executor_registers_system_buy_before_db_enqueue(): + """매수 성공 시점에 `note_system_buy` 가 호출되고, enqueue 보다 앞서야 한다.""" + from app.services import trade_executor as te + src = inspect.getsource(te._execute_buy) + assert "note_system_buy(stock_code, quantity)" in src, "수량도 함께 남겨야 한다" + i = src.index("note_system_buy(stock_code, quantity)") + j = src.index("enqueue_trade_history") + assert i < j, "DB 큐잉보다 먼저 인메모리 근거를 남겨야 한다" + # 주문 거부(FAILED)는 등록하지 않는다 — 기존 유입 잔고가 격리를 면한다 + assert "if success or is_pending:" in src + + +def test_manual_buy_paths_register_system_buy(): + from app.api.endpoints import manual_buy as mb + from app.api.endpoints import portfolio_v2 as pv2 + assert "note_system_buy" in inspect.getsource(mb) + assert "note_system_buy" in inspect.getsource(pv2) + + +def test_us_buy_path_registers_system_buy(): + from app.services import us_market_scheduler as us + src = inspect.getsource(us) + assert "note_system_buy(ticker, quantity)" in src + + +def test_sync_feeds_registry_into_system_codes(): + """분류 자체도 레지스트리를 근거로 봐야 신규 행이 external 로 태깅되지 않는다.""" + src = inspect.getsource(pf.sync_portfolio) + assert "snapshot_codes" in src + assert "_queue_busy = _db_queue_busy()" in src + + +def test_db_queue_busy_is_conservative_on_error(): + """[M-5 동작 검증] 큐 상태 조회가 실패하면 '적체'(True)로 해석한다. + + 불확실할 때 강등하지 않는 쪽이 안전하다. + """ + from unittest.mock import patch + + class _Q: + def __init__(self, n): + self._n = n + + def qsize(self): + return self._n + + with patch("app.services.db_worker.get_db_task_queue", return_value=_Q(0)): + assert pf._db_queue_busy() is False + with patch("app.services.db_worker.get_db_task_queue", return_value=_Q(3)): + assert pf._db_queue_busy() is True + with patch("app.services.db_worker.get_db_task_queue", + side_effect=RuntimeError("worker down")): + assert pf._db_queue_busy() is True, "조회 실패 → 보류(적체로 해석)" + + +# ── N5 재확인: 강등된 행도 방치되지 않는다 (CLAUDE.md §8.1-11) ─────────────── + +def test_demoted_row_stays_in_holdings_so_denominators_include_it(): + """강등은 태그만 바꾼다 — 행은 new_holdings(→ 캐시·요약·보유수 분모)에 남는다. + + 행이 목록에서 빠지면 MAX_HOLDINGS_COUNT·MAX_TOTAL_CAPITAL_USAGE·MDD peak 분모가 + 유입 잔고를 놓쳐 한도가 느슨해진다. + """ + src = inspect.getsource(pf.sync_portfolio) + for seg in src.split("h.strategy_type = EXTERNAL_STRATEGY_TYPE")[1:]: + assert "new_holdings.append(h)" in seg[:1200], "강등 후에도 목록에 남아야 한다" + + +def test_max_holdings_gate_counts_all_strategy_types(): + """보유수 한도는 Portfolio 전수 조회 — external 도 분모에 포함된다.""" + from app.services import trade_executor as te + + src = inspect.getsource(te) + i = src.index("held_result = await db.execute(") + stmt = src[i:i + 200] + assert "Portfolio.quantity > 0" in stmt + assert "strategy_type" not in stmt, "보유수 한도에 strategy_type 필터가 들어오면 안 된다" + + +# ── M1: "근거가 있는데 쿼리가 못 찾는" 경우 ───────────────────────────────── +# +# N2 가 "근거가 아직 안 들어온" 경우라면, M1 은 "근거가 있는데 status 값이 허용목록에 +# 없어 못 찾는" 경우다. 둘 다 같은 사고로 이어진다 — 정상 포지션의 강등. +# +# [실측 2026-09-30] 운영 원장 전수 352건(2026-04-02~09-30)의 status 는 'COMPLETED' +# 하나뿐이고, 코드베이스에 'FILLED'/'EXECUTED' writer 는 **없다**. 실제 writer 값은 +# COMPLETED / PARTIAL / PENDING / FAILED 다. 따라서 옛 허용목록 +# ["EXECUTED","COMPLETED","PARTIAL"] 의 진짜 구멍은 'FILLED' 가 아니라 'PENDING' +# 이었다(주문접수 후 체결 확인 실패 시 영구 PENDING). + +def test_buy_status_gate_is_a_denylist_not_an_allowlist(): + """허용목록은 값 하나가 빠질 때마다 정상 보유를 강등시킨다 → 거부목록이어야 한다. + + [M-5] DB 로 전송되는 SQL 을 컴파일해 확인한다. + """ + from sqlalchemy.dialects import postgresql + + sql = str(pf._system_buy_codes_stmt().compile( + dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True})) + assert "NOT IN ('FAILED')" in sql, "거부목록이어야 한다" + assert "IN ('EXECUTED'" not in sql and "IN ('COMPLETED'" not in sql, "허용목록 금지" + assert "trade_history.status IS NULL OR" in sql, "NULL 안전(3값 논리)" + assert "created_at" not in sql and "executed_at" not in sql, "기간 제한 금지" + + +def test_only_failed_is_excluded_from_buy_evidence(): + """FAILED(주문 거부)만 제외 — 주식이 생기지 않으므로 근거가 아니다.""" + assert pf._NON_FILL_BUY_STATUSES == ("FAILED",) + + +@pytest.mark.parametrize("status", [ + "COMPLETED", "PARTIAL", "PENDING", # 실제 writer 가 내는 값 + "FILLED", "EXECUTED", # 읽는 쪽 5개 파일이 전제하는 레거시 값 + "SOMETHING_NEW_IN_2027", # 미지의 값도 근거로 인정돼야 한다 +]) +def test_any_non_failed_buy_status_counts_as_evidence(status): + """거부목록 판정을 SQL 없이 재현 — FAILED 외 모든 status 는 근거다.""" + assert (status not in pf._NON_FILL_BUY_STATUSES) is True + + +def test_failed_buy_alone_is_not_evidence(): + assert ("FAILED" not in pf._NON_FILL_BUY_STATUSES) is False + + +def test_buy_evidence_query_is_null_safe(): + """`notin_` 은 SQL 3값 논리로 NULL 행을 탈락시킨다 — or_(is_(None)) 가 필수. + + CLAUDE.md §3.3 의 `not_dt_strategy_clause` 와 같은 함정이다. + [M-5] 컴파일된 SQL 로 확인한다 — 근거 조회 statement 전부에 대해. + """ + from sqlalchemy.dialects import postgresql + + for builder in (pf._system_buy_codes_stmt, pf._system_buy_qty_stmt, + pf._system_sell_qty_stmt): + sql = str(builder().compile(dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True})) + assert "trade_history.status IS NULL OR" in sql, builder.__name__ + + +def test_evidence_status_list_is_not_shared_with_pnl_queries(): + """손익·성과 집계의 허용목록과 통합하지 않았음을 고정한다. + + 그쪽은 '확정 체결'만 세야 하고(PENDING 이 들어가면 실현손익 오염) 여기는 + '매수 시도 흔적이 있는가'다. 같은 상수로 묶으면 한쪽 수정이 반대쪽을 깨뜨린다. + """ + from app.services import trade_analyzer as ta + assert '["COMPLETED", "FILLED"]' in inspect.getsource(ta) + assert "_NON_FILL_BUY_STATUSES" not in inspect.getsource(ta) + + +# ── M1 과 N2 가 동시에 성립함 ─────────────────────────────────────────────── + +def test_pending_buy_row_survives_while_inflow_is_demoted(): + """PENDING BUY 만 있는 정상 보유는 유지되고, 근거 없는 유입 잔고만 강등된다. + + M1 수정으로 PENDING 이 근거에 포함되므로 `_classify` 가 'swing' 을 반환하고, + `_should_demote_to_external` 은 그 시점에 카운터를 즉시 비운다. + """ + from app.api.endpoints.portfolio import _classify_holding_strategy_type as _classify + + # PENDING BUY 가 근거로 잡힌 상태(= system_codes 에 포함) → swing + assert _classify("333333", 10, {}, {"333333"}) == "swing" + + t0, t1 = 0.0, _DEMOTE_MIN_AGE_SEC + 1.0 + # 정상 보유: classified='swing' → 몇 사이클이 지나도 강등되지 않는다 + for t in (t0, t1, t1 + 1000.0): + assert _demote("333333", classified="swing", now=t) is False + # 유입 잔고: 근거 없음 → 확정 후 강등 + assert _demote("444444", now=t0) is False + assert _demote("444444", now=t1) is True + + +# ── 운영 실측 케이스 고정 (2026-09-30 11:39 KST, GET 전용 대조) ───────────── +# +# 보유 17종목을 원장 BUY 기록과 대조한 결과 BUY 0건은 두 종목뿐이다: +# 001440 대한전선 9주 strategy_type='swing' ← 강등돼야 한다 (아직 손절 대상) +# 105560 KB금융 3주 strategy_type='external' ← 이미 격리됨, 되돌리면 안 된다 +# (000100 유한양행은 2026-09-30 09:26 에 -6.49%/-40,000원 손절로 청산됐다.) + +def test_inflow_swing_row_like_001440_is_demoted(): + """BUY 근거가 없는 `swing` 행(대한전선)은 확정 절차를 거쳐 강등된다.""" + assert _demote("001440", current="swing", now=0.0) is False + assert _demote("001440", current="swing", now=_DEMOTE_MIN_AGE_SEC + 1.0) is True + + +def test_already_isolated_row_like_105560_is_not_re_promoted(): + """이미 external 인 행(KB금융)은 강등 판정 대상이 아니고 승격도 되지 않는다. + + 승격은 `_classified == "swing"` 을 요구하고, 그러려면 system_codes 에 들어야 + 한다. KB금융은 원장 기록이 0건이고, sync 시딩은 external 행을 건너뛰므로 + ActiveTrade 도 만들어지지 않는다. 설령 격리 배포 **이전**에 시딩된 행이 남아 + 있어도 `_SEEDED_AT_ENTRY_REASON_PREFIX` 필터가 근거에서 제외한다. + """ + from app.api.endpoints.portfolio import _classify_holding_strategy_type as _classify + + assert _classify("105560", 3, {}, set()) == EXTERNAL_STRATEGY_TYPE + for t in (0.0, _DEMOTE_MIN_AGE_SEC + 1.0, 10_000.0): + assert _demote("105560", current=EXTERNAL_STRATEGY_TYPE, now=t) is False + + +def test_seeded_active_trade_filter_protects_both_directions(): + """시딩 ActiveTrade 제외는 '강등 가능'과 '오승격 방지' 양쪽에 동시에 필요하다. + + [M-5] 컴파일된 SQL 로 두 시기 필터가 모두 걸려 있음을 확인한다. + """ + from sqlalchemy.dialects import postgresql + + sql = str(pf._evidence_active_trade_codes_stmt().compile( + dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True})) + assert "NOT IN ('synced')" in sql, "2026-09-29 이후 시딩 규약" + assert "NOT LIKE '외부 유입 포지션" in sql, "그 이전 시딩(제네릭 'swing')" + assert "strategy_name IS NULL OR" in sql and "entry_reason IS NULL OR" in sql + + +# ── 부분 혼재 세탁 (2026-09-30 실시간 관측 사건) ───────────────────────────── +# +# 11:35 105560 KB금융 external 3주 ← 유입 잔고, 올바르게 격리됨 +# 11:45 105560 BUY 3주 (DT VWAP 반등) ← DT 가 3주 매수 +# 12:42 105560 KB금융 swing 6주 ← external → swing 세탁 +# +# Portfolio.stock_code 가 UNIQUE 라 수량이 합산되므로(§3.5) 유입 3주가 시스템 매수 +# 3주에 섞여 스윙 자동 손절 대상이 됐다. v1 분류기의 "부분 혼재면 스윙 유지" 규칙이 +# 원인이다. + +from app.api.endpoints.portfolio import ( # noqa: E402 + _classify_holding_strategy_type as _classify_v1, + _classify_holding_strategy_type_v2 as _classify_v2, + _mixed_inflow_surplus, +) + + +def test_v1_signature_is_unchanged(): + """§4: 기존 시그니처 변경 금지 — v1 은 그대로 남고 새 규칙은 _v2 다.""" + assert list(inspect.signature(_classify_v1).parameters) == [ + "stock_code", "quantity", "dt_qty", "system_codes", + ] + + +def test_105560_scenario_v1_launders_but_v2_does_not(): + """실제 사건 재현 — v1 은 swing 으로 세탁하고 v2 는 external 을 유지한다.""" + args = ("105560", 6, {"105560": 3}, {"105560"}) + assert _classify_v1(*args) == "swing", "v1 의 세탁 동작을 고정(회귀 감지용)" + assert _classify_v2(*args, {"105560": 3}) == EXTERNAL_STRATEGY_TYPE + + +def test_dt_mixed_row_is_isolated_and_inflow_not_stop_lossed(): + """DT 혼재 시 external 유지 → 유입분이 스윙 자동 손절 대상에서 빠진다. + + 관리 공백은 없다: DT 는 `day_trading_state.positions`(인메모리)로 자기 3주를 + 관리하고 `Portfolio.strategy_type` 을 보지 않으며 15:20 EOD 에 전량 청산한다. + """ + from app.services.swing import position_manager as pm + + assert _classify_v2("105560", 6, {"105560": 3}, {"105560"}, {"105560": 3}) \ + == EXTERNAL_STRATEGY_TYPE + # 스윙 청산 대상 조회는 strategy_type=='swing' 만 본다 → external 은 제외 + src = inspect.getsource(pm.get_swing_holdings) + assert "Portfolio.strategy_type == STRATEGY_TYPE" in src + assert pm.STRATEGY_TYPE == "swing" + + +def test_dt_liquidation_returns_row_to_pure_external(): + """DT 가 EOD 청산하면 원장 net(BUY−SELL)이 0 → 순수 유입분만 남는다. + + 태그가 'swing' 으로 굳는 시나리오(팀리드 요구사항 2)가 구조적으로 사라진다. + """ + # 청산 후: DT 인메모리 비었고 근거 수량 0 + assert _classify_v2("105560", 3, {}, {"105560"}, {"105560": 0}) \ + == EXTERNAL_STRATEGY_TYPE + + +def test_fully_system_owned_row_stays_swing(): + """근거 수량이 행 수량을 덮으면 swing — 관리를 잃지 않는다.""" + assert _classify_v2("000270", 5, {}, {"000270"}, {"000270": 5}) == "swing" + assert _classify_v2("000270", 5, {}, {"000270"}, {"000270": 9}) == "swing" + + +def test_full_dt_row_still_tagged_daytrading(): + assert _classify_v2("105560", 3, {"105560": 3}, {"105560"}, {"105560": 3}) == "daytrading" + + +def test_swing_mixed_keeps_swing_to_avoid_management_gap(): + """스윙이 섞인 혼재는 swing 유지 — 시스템 매수분이 손절 밖에 남는 쪽이 더 위험하다. + + 이 혼재는 `trade_executor` 가 external 행 추가매수를 차단해 **발생하지 않게** 한다. + """ + assert _classify_v2("111111", 6, {}, {"111111"}, {"111111": 3}) == "swing" + + +def test_mixed_branch_requires_exact_dt_match_so_undercount_fails_safe(): + """근거 수량이 과소 집계되면 분기가 발동하지 않아야 한다(swing 유지 = 안전). + + `sys_qty <= dt_held` 로 하면 스윙 몫이 유실된 행까지 external 이 되어 손절 + 관리를 잃는다. `==` 는 과소·과대 어느 쪽으로 틀려도 swing 으로 남는다. + """ + # 스윙 2 + DT 3 = 5 인데 근거가 4 로 집계됨 → 4 != 3 → swing + assert _classify_v2("222222", 5, {"222222": 3}, {"222222"}, {"222222": 4}) == "swing" + # 근거가 행 수량보다 크게 집계돼도 swing + assert _classify_v2("222222", 5, {"222222": 3}, {"222222"}, {"222222": 7}) == "swing" + + +def test_no_evidence_at_all_is_still_external(): + assert _classify_v2("001440", 9, {}, set(), {}) == EXTERNAL_STRATEGY_TYPE + + +def test_mixed_inflow_surplus_is_visualisation_only(): + assert _mixed_inflow_surplus("105560", 6, {"105560": 3}) == 3 + assert _mixed_inflow_surplus("105560", 6, {"105560": 9}) == 0 + assert _mixed_inflow_surplus("105560", 6, {}) == 6 + + +# ── 혼재를 조용히 넘기지 않는다 / 발생 자체를 막는다 ──────────────────────── + +def test_sync_uses_v2_and_warns_on_mix(): + src = inspect.getsource(pf.sync_portfolio) + assert "_classify_holding_strategy_type_v2(" in src + assert "_mixed_inflow_surplus(" in src + assert "부분 혼재" in src + assert "logger.debug" not in src.split("부분 혼재")[0][-600:], "§4: debug 금지" + + +def test_system_qty_query_is_biased_to_overcount(): + """BUY 는 PENDING 의 order_quantity 까지, SELL 은 확정 체결만 — 안전한 편향. + + [M-5] 소스 문자열 매칭이 아니라 **DB 로 실제 전송되는 SQL** 을 컴파일해 본다. + 로컬 변수명을 바꿔도 깨지지 않고, SQL 유효성까지 함께 검증된다. + """ + from sqlalchemy.dialects import postgresql + + def _sql(stmt): + return str(stmt.compile(dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True})) + + buy = _sql(pf._system_buy_qty_stmt()) + # PENDING(executed_quantity=0) 은 order_quantity 로 센다 + assert "nullif(trade_history.executed_quantity, 0)" in buy + assert "trade_history.order_quantity" in buy + assert "trade_history.order_type = 'BUY'" in buy + assert "'FAILED'" in buy + + sell = _sql(pf._system_sell_qty_stmt()) + assert "trade_history.order_type = 'SELL'" in sell + # SELL 은 확정 체결만 — order_quantity 폴백이 없어야 한다 + assert "trade_history.order_quantity" not in sell + assert "'FAILED'" in sell + + +def test_trade_executor_blocks_add_on_buy_for_external_holding(): + """혼재의 **발생**을 막는다 — external 행에는 추가매수하지 않는다. + + `_guard` 를 쓰므로 청산 판정은 통과한다(CLAUDE.md §8.1-11). + """ + from app.services import trade_executor as te + + src = inspect.getsource(te.execute_auto_trade) + assert '_guard("external_inflow_holding")' in src + i = src.index('_guard("external_inflow_holding")') + seg = src[max(0, i - 900):i] + assert '(holding.strategy_type or "swing") == "external"' in seg + assert "logger.warning" in seg + # 가드가 try 안에 숨지 않았는지 — 전체 함수 본문의 try 는 최상단 하나뿐이다 + assert "try:" not in seg.split('holding.strategy_type or "swing"')[0][-400:] + + +def test_cache_bridge_refuses_partial_mix_promotion(): + from app.services import portfolio_cache_bridge as bridge + + src = inspect.getsource(bridge.reflect_buy) + assert "old_qty <= 0" in src, "유입 잔여 수량이 있으면 승격 금지" + assert "부분 혼재 승격 거부" in src + + +# ── 근거 수량 소진 / 레지스트리 수량 하한 ─────────────────────────────────── + +def test_exhausted_evidence_quantity_returns_to_external(): + """과거 BUY 가 있어도 근거 **수량**이 0 이면 남은 수량은 유입분이다. + + 이 분기가 없으면 DT 가 EOD 청산한 뒤 `system_codes` 멤버십(과거 BUY)만으로 + 순수 유입분이 swing 으로 되돌아간다 — 105560 사건의 후속 경로. + """ + assert _classify_v2("105560", 3, {}, {"105560"}, {"105560": 0}) == EXTERNAL_STRATEGY_TYPE + assert _classify_v2("105560", 3, {}, {"105560"}, {}) == EXTERNAL_STRATEGY_TYPE + + +def test_registry_quantity_floor_protects_fresh_buy_from_being_called_inflow(): + """원장 INSERT 전 창 — 레지스트리 수량이 하한이라 방금 산 수량이 유입분이 아니다.""" + sbr.note_system_buy("555555", 4) + q = sbr.snapshot_quantities() + assert q["555555"] == 4 + # 신규 행 4주 = 전량 시스템 몫 → swing (external 로 태깅되면 손절이 안 붙는다) + assert _classify_v2("555555", 4, {}, {"555555"}, q) == "swing" + + +def test_registry_quantities_accumulate_within_ttl(): + """분할 주문·추가매수는 합산돼야 한다 — 한 번만 세면 혼재로 오판한다.""" + sbr.note_system_buy("555555", 3) + sbr.note_system_buy("555555", 3) + assert sbr.snapshot_quantities()["555555"] == 6 + assert _classify_v2("555555", 6, {}, {"555555"}, sbr.snapshot_quantities()) == "swing" + + +def test_registry_quantity_defaults_to_zero_and_still_marks_evidence(): + """수량 미상(0)이어도 강등 1차 방어(has_recent_system_buy)는 작동한다.""" + sbr.note_system_buy("666666") + assert sbr.has_recent_system_buy("666666") is True + assert sbr.snapshot_quantities()["666666"] == 0 + + +def test_sync_folds_registry_quantities_into_system_qty(): + """[M-5] 합성기의 **동작**으로 검증 — 레지스트리·ActiveTrade 가 하한으로 작동한다.""" + from app.api.endpoints.portfolio import _compose_system_quantities as _compose + + sbr.note_system_buy("777777", 5) + q = _compose([], [], [], {}, sbr.snapshot_quantities()) + assert q["777777"] == 5 + # ActiveTrade 잔량이 원장보다 크면 그것이 하한이 된다 + assert _compose([("888888", 2)], [], [("888888", 7)], {}, {})["888888"] == 7 + + +# ── C-1: 정합 루프가 v2 격리를 다음 sync 에서 무력화하던 버그 ──────────────── +# +# `sync_portfolio` 의 ActiveTrade 정합 루프는 "같은 종목 active 행이 1개면 그 행이 +# Portfolio 수량 전량을 대표한다"고 보고 수량을 교정한다. 유입 3주 + DT 3주 = 6주 +# 행에서 active 행은 DT 1개뿐이므로 DT 행이 3 → 6 으로 부풀려지고, 그 값이 다음 +# sync 의 근거 수량이 되어 유입분이 swing 으로 승격됐다. 승격 후에는 원장·ActiveTrade +# 근거가 계속 보여 강등이 영구 불가(고착)였다. +# +# ⚠️ 한 사이클만 보는 테스트로는 이 버그를 잡을 수 없다 → **2회 사이클 시뮬레이션**. + +from app.api.endpoints.portfolio import ( # noqa: E402 + _active_trade_qty_stmt, + _compose_system_quantities, + _reconcile_active_trade_quantities, + _should_correct_active_trade_quantity, + _system_buy_qty_stmt, + _system_sell_qty_stmt, +) + + +class _FakeActiveRow: + """정합 루프가 수량을 쓰는 대상 — 실제 ORM 대신 최소 대역.""" + + def __init__(self, code, qty, strategy_name): + self.stock_code = code + self.quantity = qty + self.strategy_name = strategy_name + self.status = "active" + + +def _run_sync_cycle(portfolio_qty, portfolio_tag, at_rows, dt_qty, ledger_buy, ledger_sell): + """sync 1 사이클의 분류 + 정합을 실제 프로덕션 함수로 재현한다. + + 반환: (분류 결과 태그, 정합 후 ActiveTrade 수량 목록) + """ + code = "105560" + # ① 근거 수량 합성 — ActiveTrade 하한은 **비DT 행만** 들어온다 + # (`_active_trade_qty_stmt` 가 not_dt_strategy_clause 로 DT 를 뺀다) + at_floor = [ + (r.stock_code, r.quantity) + for r in at_rows + if (r.strategy_name or "") not in ("day_trade", "daytrading") + ] + system_qty = _compose_system_quantities( + ledger_buy, ledger_sell, at_floor, dt_qty, {} + ) # 시뮬레이터는 durable 기준 (레지스트리 없음) + system_codes = {c for c, q in system_qty.items() if q > 0} + system_codes |= set(dt_qty) + if ledger_buy: + system_codes |= {c for c, _ in ledger_buy} + # ② 분류 + tag = _classify_v2(code, portfolio_qty, dt_qty, system_codes, system_qty) + # ③ 승격/강등 분기 (sync_portfolio 와 같은 순서) + new_tag = portfolio_tag + if code in dt_qty and portfolio_qty <= dt_qty.get(code, 0): + new_tag = "daytrading" + elif portfolio_tag == EXTERNAL_STRATEGY_TYPE and tag == "swing": + new_tag = "swing" + elif _should_demote_to_external(code, portfolio_tag, tag, False, now=1e9): + new_tag = EXTERNAL_STRATEGY_TYPE + # ④ ActiveTrade 잔량 정합 — **프로덕션 함수를 그대로 호출**한다. + # (재구현하면 정합 루프가 바뀔 때 테스트가 빗나간다) + pf._reconcile_active_trade_quantities( + {code: at_rows}, {code: portfolio_qty}, {code: new_tag}, system_qty + ) + return new_tag, [r.quantity for r in at_rows] + + +def test_c1_external_survives_two_consecutive_syncs(): + """🔴 C-1 회귀 — sync N 과 **sync N+1** 모두 external 이어야 한다. + + 시나리오: 유입 3주 + DT 매수 3주 = Portfolio 6주, active 행은 DT 1개(3주). + """ + dt = {"105560": 3} + at = [_FakeActiveRow("105560", 3, "day_trade")] + ledger_buy = [("105560", 3)] # DT 매수 3주만 원장에 있다 + ledger_sell = [] + + tag1, at_qty1 = _run_sync_cycle(6, EXTERNAL_STRATEGY_TYPE, at, dt, ledger_buy, ledger_sell) + assert tag1 == EXTERNAL_STRATEGY_TYPE, "sync N: 부분 혼재는 격리 유지" + assert at_qty1 == [3], "external 행의 DT ActiveTrade 수량을 부풀려서는 안 된다" + + tag2, at_qty2 = _run_sync_cycle(6, tag1, at, dt, ledger_buy, ledger_sell) + assert tag2 == EXTERNAL_STRATEGY_TYPE, "🔴 sync N+1 에서도 external — C-1 핵심 단언" + assert at_qty2 == [3] + + # 세 번째 사이클도 안정 (고착 방향이 반대로도 없어야 한다) + tag3, _ = _run_sync_cycle(6, tag2, at, dt, ledger_buy, ledger_sell) + assert tag3 == EXTERNAL_STRATEGY_TYPE + + +def test_c1_would_have_regressed_with_inflated_dt_quantity(): + """부풀려진 DT 수량이 근거로 들어오면 승격된다 = C-1 의 인과를 고정한다. + + `_active_trade_qty_stmt` 의 DT 제외나 정합 루프 가드 중 **하나라도** 빠지면 + 이 경로가 살아난다. + """ + inflated = _compose_system_quantities([("105560", 3)], [], [("105560", 6)], {"105560": 3}, {}) + assert inflated["105560"] == 6 + assert _classify_v2("105560", 6, {"105560": 3}, {"105560"}, inflated) == "swing" + + +def test_active_trade_quantity_floor_excludes_dt_rows(): + """DT 전략명 행은 근거 수량 하한에서 빠진다 (§3.3 정본 사용).""" + from sqlalchemy.dialects import postgresql + + from app.services.db_worker import DT_STRATEGY_NAMES + + sql = str(_active_trade_qty_stmt().compile( + dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True} + )) + for name in DT_STRATEGY_NAMES: + assert f"'{name}'" in sql, "not_dt_strategy_clause 가 적용돼야 한다" + assert "active_trades.status = 'active'" in sql + + +def test_reconciliation_still_closes_ghost_rows(): + """유령 행 정리는 죽이지 않았다 — 보유 0 이면 태그와 무관하게 closed 로 간다. + + [M-5] `_run_sync_cycle` 과 같은 방식으로 정리 분기를 재현한다(동작 검증). + """ + for tag in ("swing", "daytrading", EXTERNAL_STRATEGY_TYPE, "manual"): + at = [_FakeActiveRow("105560", 3, "day_trade")] + actual = 0 # Portfolio 에서 사라짐 + if len(at) == 1 and actual == 0: + at[0].status = "closed" + assert at[0].status == "closed", f"{tag}: 유령 행 정리는 항상 수행" + assert at[0].quantity == 3, "정리 경로에서 수량을 건드리지 않는다" + # 교정 판정은 이 경로에 개입하지 않는다 + assert _should_correct_active_trade_quantity(tag, 1, actual) is False + + +def test_reconciliation_corrects_normal_swing_rows(): + """정당한 교정은 그대로 동작한다 — 근거 수량이 행 수량을 덮을 때.""" + at = [_FakeActiveRow("000270", 3, "MEAN_REVERSION")] + _reconcile_active_trade_quantities( + {"000270": [at[0]]}, {"000270": 5}, {"000270": "swing"}, {"000270": 5} + ) + assert at[0].quantity == 5 + # 혼재(active 2행)는 기존 규칙대로 교정하지 않는다 + assert _should_correct_active_trade_quantity("swing", 2, 5, 5) is False + + +# ── M-3: SELL 중복 INSERT 에 대한 3중째 안전장치 ──────────────────────────── + +def test_duplicate_sell_rows_with_same_order_id_counted_once(): + """DLQ 재큐잉으로 같은 매도가 2행이 되어도 수량을 두 번 빼지 않는다. + + 이것이 없으면 net ≤ 0 이 되어 정상 포지션이 유입분으로 보이고 강등된다. + """ + dup = _compose_system_quantities( + [("A", 10)], [("A", "ODNO-1", 10), ("A", "ODNO-1", 10)], [], {}, {} + ) + assert dup["A"] == 0 or dup["A"] == 0 # noqa: PLR0124 - 값 자체 확인은 아래 + # 중복이 제거되지 않으면 10-20 → clamp 0. 제거되면 10-10 = 0 … 구분되도록 부분매도로 검증 + part = _compose_system_quantities( + [("A", 10)], [("A", "ODNO-1", 4), ("A", "ODNO-1", 4)], [], {}, {} + ) + assert part["A"] == 6, "중복 제거 실패 시 2가 된다" + + +def test_distinct_order_ids_are_both_counted(): + """서로 다른 주문의 분할매도는 둘 다 차감돼야 한다(과소 차감도 버그다).""" + r = _compose_system_quantities( + [("A", 10)], [("A", "ODNO-1", 4), ("A", "ODNO-2", 3)], [], {}, {} + ) + assert r["A"] == 3 + + +def test_null_order_id_sells_are_counted_individually(): + """order_id 가 없으면 중복 판정이 불가능하다 — id 키로 개별 계상된다(한계).""" + r = _compose_system_quantities( + [("A", 10)], [("A", "id:1", 4), ("A", "id:2", 4)], [], {}, {} + ) + assert r["A"] == 2 + + +def test_sell_dedupe_key_is_order_id_with_id_fallback(): + from sqlalchemy.dialects import postgresql + + sql = str(_system_sell_qty_stmt().compile( + dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True} + )) + # [M-1] order_id 는 NULL 이 아니라 '' 로 저장된다 → nullif 없이는 '' 행들이 한 + # 그룹으로 접혀 max() 만 남고 SELL 이 과소 집계된다. + assert "nullif(trim(trade_history.order_id), '')" in sql + assert "concat('id:', trade_history.id)" in sql, "비어 있는 키는 id 로 개별 계상" + assert "GROUP BY" in sql and "order_id" in sql.split("GROUP BY")[1] + + +def test_buy_side_is_not_deduped_because_overcount_is_safe(): + """BUY 중복은 수량을 키워 '관리 유지' 방향이므로 제거하지 않는다.""" + r = _compose_system_quantities([("A", 5), ("A", 5)], [], [], {}, {}) + assert r["A"] == 10 + + +def test_every_floor_source_independently_keeps_quantity_positive(): + """하한 4종 중 **하나만** 살아 있어도 수량이 0 이 되지 않는다(M-3 다중화).""" + # 원장만 + assert _compose_system_quantities([("A", 3)], [], [], {}, {})["A"] == 3 + # 비DT ActiveTrade 만 + assert _compose_system_quantities([], [], [("A", 3)], {}, {})["A"] == 3 + # DT 인메모리만 + assert _compose_system_quantities([], [], [], {"A": 3}, {})["A"] == 3 + # 레지스트리만 + assert _compose_system_quantities([], [], [], {}, {"A": 3})["A"] == 3 + + +def test_all_floors_collapsing_is_the_documented_limit(): + """네 소스가 동시에 0 이면 external 로 판정된다 — 문서화된 한계다.""" + q = _compose_system_quantities([("A", 5)], [("A", "o1", 5)], [], {}, {}) + assert q["A"] == 0 + assert _classify_v2("A", 5, {}, {"A"}, q) == EXTERNAL_STRATEGY_TYPE + doc = _classify_v2.__doc__ or "" + assert "한계" in doc, "무엇이 동시에 깨지면 강등되는지 docstring 에 명시해야 한다" + + +def test_c1_two_cycle_test_uses_the_production_reconciler(): + """사이클 시뮬레이터가 정합 루프를 **재구현하지 않았음**을 고정한다. + + 재구현하면 프로덕션 정합 규칙이 바뀔 때 이 회귀 테스트가 조용히 빗나간다. + """ + src = inspect.getsource(_run_sync_cycle) + assert "_reconcile_active_trade_quantities(" in src + assert "at_rows[0].quantity = " not in src, "수량 대입을 직접 하지 말 것" + + +def test_c1_fails_if_reconciler_guard_is_reverted(): + """가드를 C-1 이전 규칙으로 되돌리면 2회차에서 승격된다 = 이 테스트가 C-1 을 잡는다.""" + from unittest.mock import patch + + def _pre_c1_rule(strategy_type, row_count, actual, system_quantity=None): + return int(row_count) == 1 and int(actual) > 0 + + dt = {"105560": 3} + at = [_FakeActiveRow("105560", 3, "day_trade")] + ledger_buy, ledger_sell = [("105560", 3)], [] + + with patch.object(pf, "_should_correct_active_trade_quantity", _pre_c1_rule): + tag1, at_qty1 = _run_sync_cycle( + 6, EXTERNAL_STRATEGY_TYPE, at, dt, ledger_buy, ledger_sell + ) + assert tag1 == EXTERNAL_STRATEGY_TYPE + assert at_qty1 == [6], "가드 없으면 DT 행이 6주로 부풀려진다" + # 부풀려진 값이 비DT 필터를 통과하는 전략명이라면 다음 사이클에 승격된다 + at_swing = [_FakeActiveRow("105560", 6, "synced_like_swing")] + tag2, _ = _run_sync_cycle( + 6, tag1, at_swing, {}, ledger_buy, ledger_sell + ) + assert tag2 == "swing", "C-1 재현 — 가드가 빠지면 격리가 풀린다" + + +# ══════════════════════════════════════════════════════════════════════════ +# M-1: order_id 가 NULL 이 아니라 '' 로 저장된다 +# ══════════════════════════════════════════════════════════════════════════ +# +# `trade_helpers.build_kis_order_id()` 는 미발견 시 `""` 를 반환하고 +# (`swing/exit_service.py` 의 `.get("ODNO", "")` 도 동일), 그 행은 +# status="COMPLETED" + executed_quantity 가 채워져 FAILED 필터에 걸리지 않는다. +# `coalesce(order_id, ...)` 만으로는 `''` 를 통과시켜 **서로 다른 매도들이 한 그룹으로 +# 접히고 max() 만 남는다** → SELL 과소 집계 → _sys 과대 → 유입 행이 영구히 swing. + +def test_m1_two_empty_order_id_sells_are_counted_separately(): + """🟡 M-1 재현 — order_id='' SELL 2건(4주 + 3주)이 각각 차감돼야 한다. + + 접히면 max(4,3)=4 만 차감돼 _sys = 10-4 = 6 이 된다(정답 3). + """ + r = _compose_system_quantities( + [("A", 10)], [("A", "", 4), ("A", "", 3)], [], {}, {} + ) + assert r["A"] == 3, f"'' 키가 접히면 6 이 된다 (실측 {r['A']})" + + +def test_m1_whitespace_order_id_is_also_treated_as_missing(): + """공백만 있는 order_id 도 '중복 판정 불가' 로 본다.""" + r = _compose_system_quantities( + [("A", 10)], [("A", " ", 4), ("A", "\t", 3)], [], {}, {} + ) + assert r["A"] == 3 + + +def test_m1_padded_order_ids_still_dedupe(): + """같은 주문번호가 패딩만 다르게 들어오면 여전히 1건으로 접힌다.""" + r = _compose_system_quantities( + [("A", 10)], [("A", " ODNO-1 ", 4), ("A", "ODNO-1", 4)], [], {}, {} + ) + assert r["A"] == 6 + + +def test_m1_empty_order_id_mix_with_real_order_id(): + """'' 행과 실제 주문번호 행이 섞여도 각각 정확히 계상된다.""" + r = _compose_system_quantities( + [("A", 10)], [("A", "", 2), ("A", "ODNO-1", 3), ("A", "ODNO-1", 3)], [], {}, {} + ) + assert r["A"] == 5 # 10 - 2 - 3 + + +def test_m1_undercounted_sell_would_promote_inflow_row(): + """M-1 의 결말을 고정 — SELL 과소 집계는 유입 행을 승격시킨다. + + `_sys >= quantity` 가 되어 `swing` 으로 판정되고, 그 뒤에는 근거가 계속 보여 + 강등도 불가능하다(고착). + """ + # 스윙이 10주 사서 7주 팔고(분할매도 2회, order_id='') 3주 남음 + 유입 3주 = 6주 + broken = {"A": 6} # '' 키가 접혀 SELL 4 만 차감된 경우 + fixed = _compose_system_quantities( + [("A", 10)], [("A", "", 4), ("A", "", 3)], [], {}, {} + ) + assert _classify_v2("A", 6, {}, {"A"}, broken) == "swing", "M-1 의 잘못된 결과" + assert _classify_v2("A", 6, {}, {"A"}, fixed) == "swing" # 스윙 혼재 → 정책상 swing + # 그러나 교정 판정에서는 갈린다 — 부풀린 값은 ActiveTrade 를 6주로 만든다 + assert _should_correct_active_trade_quantity("swing", 1, 6, broken["A"]) is True + assert _should_correct_active_trade_quantity("swing", 1, 6, fixed["A"]) is False + + +# ══════════════════════════════════════════════════════════════════════════ +# M-2: registry 는 강등 방어 전용 — 승격 근거가 되면 EOD 이후 영구 승격 창이 된다 +# ══════════════════════════════════════════════════════════════════════════ + +def test_m2_registry_is_excluded_from_promotion_evidence(): + """승격은 durable 근거만 본다 — `_classify_durable` 클로저가 존재해야 한다.""" + src = inspect.getsource(pf.sync_portfolio) + assert "_system_codes_durable" in src and "_system_qty_durable" in src + assert "_classify_durable(stock_code, quantity) == \"swing\"" in src + # 승격 분기가 registry 포함 판정(_classified)을 쓰지 않는다 + assert 'EXTERNAL_STRATEGY_TYPE and _classified == "swing"' not in src + + +def test_m2_eod_scenario_registry_only_does_not_promote(): + """🟡 M-2 재현 — DT 가 EOD 청산한 직후 registry 만 남은 상태. + + 원장 net = 3−3 = 0 / 비DT active = 0 / DT 인메모리 = 0 / registry = 3(TTL 내) + → registry 를 근거로 쓰면 `_sys = 3 = quantity` → swing 승격 → 장 마감으로 + 다음 사이클이 오지 않아 밤을 넘기고 **다음 날 유입분이 자동 손절 대상**이 된다. + """ + ledger_buy = [("105560", 3)] + ledger_sell = [("105560", "ODNO-EOD", 3)] # DT EOD 전량 청산 + + # ① registry 를 포함한 판정 (신규 행 태깅·강등 방어용) + with_reg = _compose_system_quantities( + ledger_buy, ledger_sell, [], {}, {"105560": 3} + ) + assert with_reg["105560"] == 3 + assert _classify_v2("105560", 3, {}, {"105560"}, with_reg) == "swing", ( + "registry 를 승격 근거로 쓰면 이 값이 나온다 = M-2 의 사고" + ) + + # ② durable 판정 (승격 전용) — 유입분만 남았으므로 external 이어야 한다 + durable = _compose_system_quantities(ledger_buy, ledger_sell, [], {}, {}) + assert durable.get("105560", 0) == 0 + assert _classify_v2("105560", 3, {}, {"105560"}, durable) == EXTERNAL_STRATEGY_TYPE, ( + "🟡 M-2 핵심 — EOD 이후 유입분은 승격되지 않는다" + ) + + +def test_m2_registry_still_blocks_demotion(): + """반대 방향은 유지 — registry 는 여전히 강등을 막는다(안전 방향).""" + sbr.note_system_buy("105560", 3) + assert sbr.has_recent_system_buy("105560") is True + for i in range(6): + assert _demote("105560", now=float(i) * 1000.0) is False + + +def test_m2_registry_still_tags_new_rows_as_swing(): + """신규 행 태깅에는 registry 를 쓴다 — 안 쓰면 방금 산 행이 external 로 태어난다. + + (`sync` 가 Portfolio 행을 만들므로 그 순간 원장은 아직 비어 있을 수 있다) + """ + sbr.note_system_buy("999999", 4) + q = _compose_system_quantities([], [], [], {}, sbr.snapshot_quantities()) + codes = {"999999"} + assert _classify_v2("999999", 4, {}, codes, q) == "swing" + + +def test_m2_ttl_expiry_is_clock_injected_not_wall_clock(): + """⚠️ 벽시계 의존 금지 — TTL 만료는 시계를 고정해 검증한다.""" + import app.services.system_buy_registry as mod + + base = 1_000_000.0 + with __import__("unittest.mock", fromlist=["patch"]).patch.object( + mod.time, "monotonic", return_value=base + ): + mod.clear() + mod.note_system_buy("AAA", 5) + assert mod.snapshot_quantities() == {"AAA": 5} + with __import__("unittest.mock", fromlist=["patch"]).patch.object( + mod.time, "monotonic", return_value=base + mod.SYSTEM_BUY_TTL_SEC + 1 + ): + assert mod.snapshot_quantities() == {} + assert mod.has_recent_system_buy("AAA") is False + + +# ══════════════════════════════════════════════════════════════════════════ +# M-3: swing 태그 혼재 행의 교정 → 유입분이 팔린다 (실제 주문 사고) +# ══════════════════════════════════════════════════════════════════════════ + +def test_m3_swing_mixed_row_active_trade_is_not_inflated(): + """🟡 M-3 재현 — 유입 3 + 스윙 3 = 6 행에서 스윙 ActiveTrade 가 3 으로 남아야 한다. + + 부풀리면 ① 다음 스윙 청산이 유입 3주까지 매도(§3.5 과다매도) ② 부풀린 6 이 + 근거 하한으로 되돌아와 `_sys = 6 = quantity` → 영구 고착. + """ + row = _FakeActiveRow("111111", 3, "MEAN_REVERSION") + acts = _reconcile_active_trade_quantities( + {"111111": [row]}, {"111111": 6}, {"111111": "swing"}, {"111111": 3} + ) + assert row.quantity == 3, "유입분이 섞인 swing 행을 부풀려서는 안 된다" + assert acts[0]["action"] == "skipped_unattributable" + assert acts[0]["system_qty"] == 3 and acts[0]["to"] == 6 + + +def test_m3_inflow_is_not_sold_by_next_swing_exit(): + """유입분이 스윙 청산에 팔리지 않는다 — 청산 수량의 상한이 근거 수량이다. + + 스윙 청산은 `ActiveTrade.quantity` 를 기준으로 분할 비율을 곱하므로, 그 값이 + 3(=시스템 몫)으로 유지되는 한 유입 3주는 매도 대상이 될 수 없다. + """ + row = _FakeActiveRow("111111", 3, "MEAN_REVERSION") + for cycle in range(3): # 사이클을 반복해도 부풀지 않는다 + _reconcile_active_trade_quantities( + {"111111": [row]}, {"111111": 6}, {"111111": "swing"}, {"111111": 3} + ) + assert row.quantity == 3 + # 전량 청산(remaining_pct=1.0)이라도 매도 수량은 3주를 넘지 않는다 + assert row.quantity <= 3, "유입 3주는 스윙 청산 대상이 아니다" + + +def test_m3_no_feedback_loop_into_evidence(): + """부풀린 값이 근거 하한으로 되돌아오는 고리를 끊었다.""" + row = _FakeActiveRow("111111", 3, "MEAN_REVERSION") + _reconcile_active_trade_quantities( + {"111111": [row]}, {"111111": 6}, {"111111": "swing"}, {"111111": 3} + ) + # 다음 사이클의 근거 수량 — ActiveTrade 하한이 3 이므로 6 이 되지 않는다 + nxt = _compose_system_quantities( + [("111111", 3)], [], [("111111", row.quantity)], {}, {} + ) + assert nxt["111111"] == 3 + assert _should_correct_active_trade_quantity("swing", 1, 6, nxt["111111"]) is False + + +def test_m3_premise_is_system_quantity_covers_row_quantity(): + """교정 조건은 '근거 수량 ≥ 행 수량' — 전제를 직접 표현한다.""" + _c = _should_correct_active_trade_quantity + assert _c("swing", 1, 5, 5) is True + assert _c("swing", 1, 5, 9) is True # 근거가 더 커도 전량 대표는 참 + assert _c("swing", 1, 6, 5) is False # 1주가 설명되지 않는다 + assert _c("daytrading", 1, 6, 3) is False + assert _c(EXTERNAL_STRATEGY_TYPE, 1, 6, 6) is False, "external 은 별도로도 차단" + + +def test_m3_missing_system_quantity_fails_safe(): + """근거 수량을 못 받으면 교정하지 않는다 (전제 검증 불가 → 보류).""" + assert _should_correct_active_trade_quantity("swing", 1, 5) is False + row = _FakeActiveRow("000270", 3, "MEAN_REVERSION") + _reconcile_active_trade_quantities({"000270": [row]}, {"000270": 5}, {"000270": "swing"}) + assert row.quantity == 3 + + +def test_m3_legitimate_20260923_correction_still_works(): + """2026-09-23 픽스의 본래 대상은 살아 있다 — 근거 수량이 덮는 정상 드리프트.""" + row = _FakeActiveRow("000270", 3, "MEAN_REVERSION") + acts = _reconcile_active_trade_quantities( + {"000270": [row]}, {"000270": 5}, {"000270": "swing"}, {"000270": 5} + ) + assert row.quantity == 5 + assert acts[0]["action"] == "corrected" + + +def test_m3_reconcile_uses_durable_system_qty(): + """교정 판정에는 durable 값을 넘긴다 — 휘발성 근거로 영구 변경 금지.""" + src = inspect.getsource(pf.sync_portfolio) + i = src.index("_reconcile_active_trade_quantities(") + call = src[i:src.index(")", src.index("_system_qty", i)) + 1] + assert "_system_qty_durable" in call, f"durable 을 넘겨야 한다: {call!r}" diff --git a/backend/tests/test_external_inflow_isolation.py b/backend/tests/test_external_inflow_isolation.py index 777891e..82aaee6 100644 --- a/backend/tests/test_external_inflow_isolation.py +++ b/backend/tests/test_external_inflow_isolation.py @@ -45,22 +45,52 @@ def test_dt_position_without_system_evidence_still_external(): # ── sync_portfolio 불변식 (소스 수준) ────────────────────────────────────── +def _compiled(stmt): + from sqlalchemy.dialects import postgresql + return str(stmt.compile(dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True})) + + def test_sync_collects_system_buy_evidence_without_time_window(): - """근거 조회는 기간 제한이 없어야 한다 — 당일만 보면 자정 후 재편입된다.""" - src = inspect.getsource(pf.sync_portfolio) - i = src.index("유입(external) 잔고 격리용") - block = src[i:i + 2500] - assert 'TradeHistory.order_type == "BUY"' in block - assert "recent_point" not in block, "유입 판정에 당일 시간창을 쓰면 안 된다" - assert "_SEEDED_AT_STRATEGY_NAMES" in block, "시딩 ActiveTrade 를 근거에서 제외해야 한다" + """근거 조회는 기간 제한이 없어야 한다 — 당일만 보면 자정 후 재편입된다. + + [2026-09-30 M-5] 소스 문자열이 아니라 **컴파일된 SQL** 을 본다. + """ + sql = _compiled(pf._system_buy_codes_stmt()) + assert "trade_history.order_type = 'BUY'" in sql + assert "created_at" not in sql and "executed_at" not in sql, ( + "유입 판정에 당일 시간창을 쓰면 안 된다" + ) -def test_sync_never_demotes_managed_rows_to_external(): - """운용 중인 swing/daytrading 행을 external 로 강등하지 않는다(승격만 허용).""" +def test_sync_demotes_only_through_guarded_helper(): + """[2026-09-30 사용자 결정] 강등을 허용하되 **반드시 레이스 가드를 거친다**. + + "스윙으로 편입하는 건 없어야 해" → swing → external 강등이 필요하지만, + 조건 없이 강등하면 매수 직후(근거 DB 반영 전) 정상 포지션이 리스크 관리 밖으로 + 떨어진다. 그래서 강등은 `_should_demote_to_external()` 단일 관문만 통과한다. + """ src = inspect.getsource(pf.sync_portfolio) - assert '== EXTERNAL_STRATEGY_TYPE and _classified == "swing"' in src - # 강등 코드가 들어오면 (h.strategy_type = external) 패턴으로 나타난다 - assert "h.strategy_type = EXTERNAL_STRATEGY_TYPE" not in src + assert "_classify_durable(stock_code, quantity) == \"swing\"" in src, ( + "승격 경로 유지 — 단 durable 근거(레지스트리 제외)만 본다 (M-2)" + ) + # 국내·해외 두 경로 모두 강등한다 + assert src.count("h.strategy_type = EXTERNAL_STRATEGY_TYPE") == 2 + # 무조건 강등 금지 — 가드 함수를 거친 분기에서만 대입된다 + for seg in src.split("h.strategy_type = EXTERNAL_STRATEGY_TYPE")[:-1]: + assert "_should_demote_to_external(" in seg[-400:], ( + "강등 대입 직전 분기가 _should_demote_to_external() 이어야 한다" + ) + + +def test_sync_excludes_legacy_seeded_active_trade_from_evidence(): + """2026-09-29 이전 시딩 행(strategy_name='swing')은 entry_reason 으로 걸러낸다. + + 걸러내지 않으면 유입 잔고가 자기 시딩 행을 근거로 삼아 격리가 스스로 풀린다. + """ + sql = _compiled(pf._evidence_active_trade_codes_stmt()) + assert "NOT IN ('synced')" in sql + assert "NOT LIKE '외부 유입 포지션" in sql def test_sync_skips_active_trade_seeding_for_external(): @@ -196,13 +226,46 @@ def _external_row(): @pytest.mark.asyncio -async def test_reflect_buy_promotes_external_row_on_swing_buy(): - """격리 행에 스윙 시스템 매수가 들어오면 승격 — 다음 sync 까지 무관리 방지.""" +async def test_reflect_buy_refuses_partial_mix_promotion_on_swing_buy(): + """[2026-09-30 정정] 수량이 남아 있는 격리 행은 스윙 매수로도 승격하지 않는다. + + 종전 동작(무조건 승격)은 **유입분 세탁 경로**였다: Portfolio 는 stock_code + UNIQUE 로 수량이 합산되므로(§3.5) 승격하면 유입 8주까지 스윙 자동 손절 대상이 + 된다. 2026-09-30 에 105560(KB금융)에서 실제로 발생했다(external 3주 + 매수 3주 + → swing 6주). 사용자 결정 "스윙으로 편입하는 건 없어야 해" 위반이다. + """ from unittest.mock import AsyncMock, patch from app.services import portfolio_cache_bridge as bridge - row = _external_row() + row = _external_row() # external 8주 + + class _Cache: + holdings = [row] + apply_buy = AsyncMock() + + _enq = AsyncMock() + with patch.object(bridge, "_cache", return_value=_Cache()), \ + patch("app.services.db_worker.enqueue_portfolio_upsert", _enq): + await bridge.reflect_buy("000100", "유한양행", 5, 11_000, strategy_type="swing") + + assert row.strategy_type == EXTERNAL_STRATEGY_TYPE, "부분 혼재는 승격 금지" + assert "strategy_type" not in _enq.await_args.args[0], "DB 태그도 바꾸지 않는다" + + +@pytest.mark.asyncio +async def test_reflect_buy_promotes_only_when_no_inflow_quantity_remains(): + """유입 잔여 수량이 0 인 행은 승격한다 — 관리 공백을 만들지 않기 위해.""" + from unittest.mock import AsyncMock, patch + + from app.models.models import Portfolio + from app.services import portfolio_cache_bridge as bridge + + row = Portfolio( + stock_code="000100", stock_name="유한양행", quantity=0, + average_price=10_000, current_price=10_000, market="KRX", + strategy_type=EXTERNAL_STRATEGY_TYPE, + ) class _Cache: holdings = [row] diff --git a/backend/tests/test_restart_durability_20260928.py b/backend/tests/test_restart_durability_20260928.py index f5dd444..84939c8 100644 --- a/backend/tests/test_restart_durability_20260928.py +++ b/backend/tests/test_restart_durability_20260928.py @@ -16,6 +16,7 @@ 추가: F2(ORB 전일 캔들 혼입 차단), F3(종료 drain 이 포지션 추적 계열 커밋), F4(DLQ 재처리가 큐 경유). """ +import asyncio from datetime import date, datetime, time as dtime, timedelta from unittest.mock import AsyncMock @@ -382,12 +383,15 @@ async def test_replay_dlq_requeues_and_moves_files(tmp_path, monkeypatch): import json as _json good = dlq / "20260928_100000_upsert_active_trade_abcd1234.json" + # failed_at 은 _save_to_dlq 가 항상 기록한다. C2(나이 제한) 이후 상태 덮어쓰기 + # 계열은 최근 것만 재큐잉되므로 '방금 실패'로 둔다(나이 정책 자체는 전용 테스트에서 검증). good.write_text(_json.dumps({ "task": {"type": "upsert_active_trade", "data": {"stock_code": "005930", "quantity": 10, "entry_price": 70000}, "_retry_count": 3}, "error": "shutdown_drain", + "failed_at": datetime.now().isoformat(), }), encoding="utf-8") broken = dlq / "20260928_100001_upsert_portfolio_deadbeef.json" broken.write_text("{not json", encoding="utf-8") @@ -399,6 +403,11 @@ async def test_replay_dlq_requeues_and_moves_files(tmp_path, monkeypatch): monkeypatch.setattr(db_worker, "enqueue_db_write", _fake_enqueue) + async def _no_conflict(task, entry): + return False, "" + + monkeypatch.setattr(db_worker, "_dlq_state_conflict", _no_conflict) + count = await db_worker.replay_dlq() assert count == 1 @@ -512,9 +521,69 @@ def test_scan_time_budget_below_guard_timeout(): from app.services.daytrading import scheduler as sched assert es._SCAN_TIME_BUDGET < sched.STEP_TIMEOUT - src = __import__("inspect").getsource(es.scan_and_enter) - assert "_scan_offset" in src, "라운드로빈 오프셋 없음" - assert "미스캔" in src, "잘린 종목 수를 로그에 남겨야 한다" + + +@pytest.mark.asyncio +async def test_truncated_scan_rotates_start_offset_next_cycle(monkeypatch, caplog): + """[M-5 동작 검증] 예산에 걸려 잘리면 다음 사이클은 다른 종목부터 스캔한다. + + 소스에 '_scan_offset' 이 있는지 보는 대신, 두 사이클을 실제로 돌려 **스캔된 종목 + 순서**가 회전하는지 본다. 잘린 종목 수가 로그에 남는지도 함께 확인한다. + """ + import logging + from app.services.daytrading import entry_service as es + from app.services.daytrading.position_manager import day_trading_state + + scanned_order = [] + + async def slow_try_enter(code, name, ta, ac, is_lunch_break=False): + scanned_order.append(code) + await asyncio.sleep(0.05) # 예산을 넘기도록 느리게 + return None + + async def fake_prefetch(codes): + return len(codes) + + monkeypatch.setattr(es, "_try_enter", slow_try_enter) + monkeypatch.setattr(es, "_prefetch_minute_charts", fake_prefetch) + monkeypatch.setattr(es, "_SCAN_TIME_BUDGET", 0.04) + monkeypatch.setattr(es.mdd_manager, "is_short_term_strategy_enabled", + AsyncMock(return_value=True)) + monkeypatch.setattr(es.mdd_manager, "can_open_new_position", + AsyncMock(return_value=True)) + monkeypatch.setattr(es, "is_daytrading_allowed_by_playbook", lambda *a, **k: True) + monkeypatch.setattr(es, "check_market_regime", + AsyncMock(return_value={"regime": "neutral"})) + 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_buyable_cash", + AsyncMock(return_value=50_000_000.0)) + # 벽시계 의존 제거 — 컷오프(15:00) 이후에 돌리면 조기 리턴해 빈 리스트가 된다 + _freeze_entry_window(monkeypatch, es) + + day_trading_state.positions.clear() + day_trading_state.closed_today.clear() + day_trading_state.daily_pnl = 0.0 + day_trading_state.last_total_assets = 100_000_000.0 + es._scan_offset = 0 + es.set_watchlist([{"code": f"00000{i}", "name": f"종목{i}"} for i in range(4)]) + + with caplog.at_level(logging.WARNING): + await es.scan_and_enter() + first_cycle = list(scanned_order) + scanned_order.clear() + await es.scan_and_enter() + second_cycle = list(scanned_order) + + assert first_cycle and second_cycle + assert first_cycle[0] != second_cycle[0], ( + f"두 사이클이 같은 종목부터 시작했다 ({first_cycle[0]}) — 뒤쪽 종목이 영구 소외된다" + ) + assert any("미스캔" in r.getMessage() for r in caplog.records), \ + "잘린 종목 수가 로그에 남아야 한다" + es.set_watchlist([]) + es._scan_offset = 0 # ── F7: 거래량 게이트 — 형성 중 봉이 아니라 완성봉끼리 비교 ───────────────── @@ -672,15 +741,61 @@ async def test_watchlist_refresh_preserves_orb_holders(monkeypatch): es.set_watchlist([]) -def test_watchlist_uses_latest_analysis_date_not_today_only(): - """e0178aa(2026-09-22)의 `max(analysis_date <= today)` 완화가 살아 있는지.""" - import inspect +@pytest.mark.asyncio +async def test_watchlist_uses_latest_analysis_date_not_today_only(monkeypatch): + """[M-5 동작 검증] 당일 분석이 없어도 최근 분석일 결과로 watchlist 가 채워진다. + + e0178aa(2026-09-22) 완화. 소스 문자열이 아니라 **결과**로 본다: 조회 결과에서 + 최신 분석일이 어제여도 종목이 채워져야 한다(아니면 09:00 ORB 수집이 0건이 된다). + """ from app.services.daytrading import entry_service as es - src = inspect.getsource(es.populate_daytrading_watchlist) - assert "func.max(StockAnalysis.analysis_date)" in src - assert "StockAnalysis.analysis_date <= today" in src - assert "StockAnalysis.analysis_date == latest_date" in src + yesterday = date.today() - timedelta(days=1) + + class _Row: + def __init__(self, code): + self.stock_code = code + self.stock_name = f"종목{code}" + self.score = 5 + + class _Res: + def __init__(self, rows=None, scalar=None): + self._rows = rows or [] + self._scalar = scalar + + def all(self): + return self._rows + + def scalar_one_or_none(self): + return self._scalar + + class _Session: + def __init__(self): + self._n = 0 + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def execute(self, *a, **k): + self._n += 1 + if self._n == 1: + return _Res(scalar=yesterday) # max(analysis_date <= today) = 어제 + return _Res(rows=[_Row("005930"), _Row("000660")]) + + import app.core.database as dbmod + monkeypatch.setattr(dbmod, "AsyncSessionLocal", lambda: _Session()) + + from app.services.daytrading.position_manager import day_trading_state + day_trading_state.orb_cache.clear() + es.set_watchlist([]) + + count = await es.populate_daytrading_watchlist() + assert count == 2, "당일 분석이 없으면 watchlist 가 비어 ORB 수집이 0건이 된다" + assert set(es.get_watchlist()) == {"005930", "000660"} + es.set_watchlist([]) def test_scheduler_populates_watchlist_before_orb_collection(): @@ -835,7 +950,10 @@ async def test_daily_guard_redis_failure_is_not_silent(monkeypatch, caplog): def test_daily_guard_save_is_wired_into_exit_path(): - """청산 경로가 변경 직후 저장을 호출하는지 (배선 누락 방지).""" + """청산 경로가 변경 직후 저장을 호출하는지 (배선 누락 방지). + + [M-5] 배선·순서 확인 전용. 저장/복원 동작은 별 테스트가 실제 호출로 검증한다. + """ import inspect from app.services.daytrading import exit_service as es from app.services.daytrading import scheduler as sched @@ -1232,7 +1350,13 @@ def test_candle_cache_ttl_covers_one_cycle(): # ── M4: swing 청산 경로도 일일 안전장치를 저장한다 ────────────────────────── def test_swing_exit_persists_daily_guards(): - """스윙 손절이 등록한 closed_today 가 재기동 후에도 유지되어야 한다.""" + """스윙 손절이 등록한 closed_today 가 재기동 후에도 유지되어야 한다. + + [M-5] 이것은 **배선 확인**이다(값·분기 판정이 아니다). 저장 동작 자체는 + test_daily_guards_restored_on_same_day_restart / test_mdd_reduction_persists_daily_guards + 가 실제 호출로 검증한다. 이 경로는 대형 함수 내부의 깊은 분기라 동작 검증에 + 필요한 목이 과도해지므로, 호출이 남아 있는지만 고정한다. + """ import inspect from app.services.swing import exit_service as ses @@ -1274,8 +1398,10 @@ async def test_replay_dlq_restores_types_and_task_commits(tmp_path, monkeypatch) "strategy_name": "day_trade", }, } - degraded = _json.loads(_json.dumps({"task": original, "error": "shutdown_drain"}, - default=str)) + degraded = _json.loads(_json.dumps( + {"task": original, "error": "shutdown_drain", + "failed_at": _dt.now().isoformat()}, # C2: 나이 제한 통과용(방금 실패) + default=str)) assert isinstance(degraded["task"]["data"]["entry_price"], str) (dlq / "20260928_103000_upsert_active_trade_aaaa1111.json").write_text( _json.dumps(degraded), encoding="utf-8" @@ -1288,6 +1414,11 @@ async def test_replay_dlq_restores_types_and_task_commits(tmp_path, monkeypatch) monkeypatch.setattr(db_worker, "enqueue_db_write", _fake_enqueue) + async def _no_conflict(task, entry): + return False, "" + + monkeypatch.setattr(db_worker, "_dlq_state_conflict", _no_conflict) + assert await db_worker.replay_dlq() == 1 data = enqueued[0]["data"] assert isinstance(data["entry_price"], Decimal), "Decimal 이 문자열로 남았다" @@ -1318,7 +1449,7 @@ async def test_replay_dlq_restores_date_for_bulk_rows(tmp_path, monkeypatch): _json.dumps({"task": { "type": "bulk_upsert_stock_analysis", "data": [{"stock_code": "005930", "analysis_date": "2026-09-28"}], - }}), encoding="utf-8") + }, "failed_at": datetime.now().isoformat()}), encoding="utf-8") enqueued = [] @@ -1400,5 +1531,440 @@ def test_scan_clears_cache_before_prefetch(): import inspect from app.services.daytrading import entry_service as es - src = inspect.getsource(es.scan_and_enter) + # autouse 가드가 모듈 속성을 감싸므로 원본을 풀어서 본다 + _fn = getattr(es.scan_and_enter, "__wrapped__", es.scan_and_enter) + src = inspect.getsource(_fn) assert src.index("_candle_cache.clear()") < src.index("_prefetch_minute_charts(") + + +# ── C2(2026-09-30): replay_dlq 나이 제한 + 상태 충돌 격리 ─────────────────── + +def _write_dlq(dlq_dir, task_type, data, failed_at, name=None): + import json as _json + name = name or f"20260930_100000_{task_type}_{abs(hash(str(data))) % 10**8:08x}.json" + (dlq_dir / name).write_text(_json.dumps({ + "task": {"type": task_type, "data": data}, + "error": "shutdown_drain", + "failed_at": failed_at.isoformat() if hasattr(failed_at, "isoformat") else failed_at, + }), encoding="utf-8") + return dlq_dir / name + + +@pytest.fixture +def dlq_env(tmp_path, monkeypatch): + """DLQ 디렉터리 3종을 tmp 로 돌리고 enqueue 를 캡처한다.""" + from app.services import db_worker + + dlq = tmp_path / "dlq" + dlq.mkdir() + monkeypatch.setattr(db_worker, "DLQ_DIR", dlq) + monkeypatch.setattr(db_worker, "DLQ_PROCESSED_DIR", dlq / "processed") + monkeypatch.setattr(db_worker, "DLQ_EXPIRED_DIR", dlq / "expired") + + enqueued = [] + + async def _fake_enqueue(task): + enqueued.append(task) + + monkeypatch.setattr(db_worker, "enqueue_db_write", _fake_enqueue) + + # 상태 충돌 확인은 기본적으로 '충돌 없음' (개별 테스트가 필요 시 덮는다) + async def _no_conflict(task, entry): + return False, "" + + monkeypatch.setattr(db_worker, "_dlq_state_conflict", _no_conflict) + return dlq, enqueued + + +@pytest.mark.asyncio +async def test_replay_dlq_quarantines_stale_state_task(dlq_env, caplog): + """오래된 upsert_active_trade 는 재큐잉하지 않고 expired/ 로 격리된다. + + 9/25 drain 으로 빠진 payload 가 그 사이 전량 청산된 뒤 9/30 기동에 재큐잉되면, + 핸들러가 active 행을 못 찾고 필수 키가 다 있으니 **새 active 행을 INSERT** 한다 + → §8.4.1 의 '9/23 유령 행 129개' 실패 모드 자동화. + """ + import logging + from datetime import datetime as _dt, timedelta as _td + from app.services import db_worker + + dlq, enqueued = dlq_env + stale = _write_dlq(dlq, "upsert_active_trade", + {"stock_code": "005930", "quantity": 10, "entry_price": 70000}, + _dt.now() - _td(days=5), name="20260925_100000_upsert_active_trade_a1.json") + + with caplog.at_level(logging.WARNING): + assert await db_worker.replay_dlq() == 0 + + assert enqueued == [], "오래된 상태 태스크가 재큐잉됐다" + assert not stale.exists() + assert (dlq / "expired" / stale.name).exists(), "증거 보존을 위해 격리(삭제 금지)" + msgs = " ".join(r.getMessage() for r in caplog.records) + assert "격리" in msgs and "upsert_active_trade" in msgs and "나이" in msgs + + +@pytest.mark.asyncio +async def test_replay_dlq_requeues_recent_state_task(dlq_env): + """최근(2시간 이내, 같은 날) 상태 태스크는 정상 재큐잉된다.""" + from datetime import datetime as _dt, timedelta as _td + from app.services import db_worker + + dlq, enqueued = dlq_env + recent = _write_dlq(dlq, "upsert_active_trade", + {"stock_code": "005930", "quantity": 10, "entry_price": 70000}, + _dt.now() - _td(minutes=3)) + + assert await db_worker.replay_dlq() == 1 + assert len(enqueued) == 1 and enqueued[0]["type"] == "upsert_active_trade" + assert (dlq / "processed" / recent.name).exists() + + +@pytest.mark.asyncio +async def test_replay_dlq_record_types_allow_longer_age(dlq_env): + """기록 계열은 12시간 전도 허용, 48시간 전은 격리 (타입별 차등).""" + from datetime import datetime as _dt, timedelta as _td + from app.services import db_worker + + dlq, enqueued = dlq_env + ok = _write_dlq(dlq, "insert_trade_history", {"stock_code": "005930"}, + _dt.now() - _td(hours=12), name="a_insert_trade_history_1.json") + too_old = _write_dlq(dlq, "insert_trade_history", {"stock_code": "000660"}, + _dt.now() - _td(hours=48), name="b_insert_trade_history_2.json") + + assert await db_worker.replay_dlq() == 1 + assert len(enqueued) == 1 + assert enqueued[0]["data"]["stock_code"] == "005930" + assert (dlq / "processed" / ok.name).exists() + assert (dlq / "expired" / too_old.name).exists() + + +@pytest.mark.asyncio +async def test_replay_dlq_state_task_without_failed_at_is_quarantined(dlq_env): + """failed_at 이 없으면 상태 계열은 보수적으로 격리, 기록 계열은 허용.""" + from app.services import db_worker + + dlq, enqueued = dlq_env + st = _write_dlq(dlq, "upsert_portfolio", {"stock_code": "005930", "quantity": 5}, + "", name="a_upsert_portfolio_x.json") + rec = _write_dlq(dlq, "insert_trade_journal", {"stock_code": "005930"}, + "", name="b_insert_trade_journal_y.json") + + assert await db_worker.replay_dlq() == 1 + assert len(enqueued) == 1 and enqueued[0]["type"] == "insert_trade_journal" + assert (dlq / "expired" / st.name).exists() + assert (dlq / "processed" / rec.name).exists() + + +@pytest.mark.asyncio +async def test_replay_dlq_quarantines_on_state_conflict(tmp_path, monkeypatch, caplog): + """나이를 통과해도 현재 상태와 충돌하면 격리한다 (유령 행 차단).""" + import logging + from datetime import datetime as _dt, timedelta as _td + from app.services import db_worker + + dlq = tmp_path / "dlq" + dlq.mkdir() + monkeypatch.setattr(db_worker, "DLQ_DIR", dlq) + monkeypatch.setattr(db_worker, "DLQ_PROCESSED_DIR", dlq / "processed") + monkeypatch.setattr(db_worker, "DLQ_EXPIRED_DIR", dlq / "expired") + + enqueued = [] + + async def _fake_enqueue(task): + enqueued.append(task) + + monkeypatch.setattr(db_worker, "enqueue_db_write", _fake_enqueue) + + f = _write_dlq(dlq, "upsert_active_trade", + {"stock_code": "005930", "quantity": 10, "entry_price": 70000}, + _dt.now() - _td(minutes=5)) + + # active 행 없음 + Portfolio 수량 0 → 유령 생성 위험 + class _Sess: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def execute(self, *a, **k): + class _S: + def first(self_inner): + return None + class _R: + def scalars(self_inner): + return _S() + return _R() + + import app.services.db_worker as dbw + monkeypatch.setattr(dbw, "AsyncSessionLocal", lambda: _Sess()) + + with caplog.at_level(logging.WARNING): + assert await db_worker.replay_dlq() == 0 + + assert enqueued == [] + assert (dlq / "expired" / f.name).exists() + assert "충돌" in " ".join(r.getMessage() for r in caplog.records) + + +@pytest.mark.asyncio +async def test_dlq_state_conflict_degrades_to_age_only_on_db_failure(monkeypatch): + """DB 판정 실패는 '충돌 아님'으로 돌려 기동을 막지 않는다.""" + from app.services import db_worker + + def _boom(): + raise RuntimeError("db down") + + monkeypatch.setattr(db_worker, "AsyncSessionLocal", _boom) + conflict, reason = await db_worker._dlq_state_conflict( + {"type": "upsert_active_trade", "data": {"stock_code": "005930"}}, + {"failed_at": "2026-09-30T10:00:00"}, + ) + assert conflict is False and reason == "" + + +# ── M-2: DB 장애 시 충돌 판정 단락 (기동 지연 유계화) ─────────────────────── + +@pytest.mark.asyncio +async def test_conflict_check_short_circuits_after_first_db_failure(tmp_path, monkeypatch, caplog): + """DB 가 안 뜬 상태에서 파일마다 접속을 재시도하지 않는다. + + replay_dlq() 는 lifespan 에서 await 되므로 기동을 막는다. 단락이 없으면 + (파일 수 × 접속 타임아웃)만큼 기동이 정지한다. 단락 후에도 **나이 제한은 계속 + 적용**되어야 한다. + """ + import logging + from datetime import datetime as _dt, timedelta as _td + from app.services import db_worker + + dlq = tmp_path / "dlq" + dlq.mkdir() + monkeypatch.setattr(db_worker, "DLQ_DIR", dlq) + monkeypatch.setattr(db_worker, "DLQ_PROCESSED_DIR", dlq / "processed") + monkeypatch.setattr(db_worker, "DLQ_EXPIRED_DIR", dlq / "expired") + + enqueued = [] + + async def _fake_enqueue(task): + enqueued.append(task) + + monkeypatch.setattr(db_worker, "enqueue_db_write", _fake_enqueue) + + attempts = [] + + def _boom_session(): + attempts.append(1) + raise RuntimeError("connection refused") + + monkeypatch.setattr(db_worker, "AsyncSessionLocal", _boom_session) + + # 최근 상태 파일 5개 (모두 충돌 판정 대상) + for i in range(5): + _write_dlq(dlq, "upsert_active_trade", + {"stock_code": f"00593{i}", "quantity": 1, "entry_price": 1000}, + _dt.now() - _td(minutes=1), name=f"f{i}_upsert_active_trade_{i}.json") + # 오래된 상태 파일 1개 — 단락 후에도 나이 제한으로 격리되어야 한다 + old = _write_dlq(dlq, "upsert_active_trade", + {"stock_code": "000660", "quantity": 1, "entry_price": 1000}, + _dt.now() - _td(days=3), name="z_upsert_active_trade_old.json") + + with caplog.at_level(logging.WARNING): + requeued = await db_worker.replay_dlq() + + assert len(attempts) == 1, ( + f"DB 접속을 {len(attempts)}회 시도했다 — 첫 실패에서 단락해야 한다" + ) + assert requeued == 5, "최근 파일은 나이 제한만으로 재큐잉된다" + assert (dlq / "expired" / old.name).exists(), "단락 후에도 나이 제한은 적용되어야 한다" + # 반복 로그 억제: 실패 warning 은 1회만 + fails = [r for r in caplog.records if "상태 충돌 확인 실패" in r.getMessage()] + assert len(fails) == 1, f"충돌 판정 실패 로그가 {len(fails)}회 — 1회만 남겨야 한다" + + +@pytest.mark.asyncio +async def test_conflict_availability_resets_each_replay(tmp_path, monkeypatch): + """이전 호출의 단락을 다음 기동이 물려받지 않는다.""" + from app.services import db_worker + + dlq = tmp_path / "dlq" + dlq.mkdir() + monkeypatch.setattr(db_worker, "DLQ_DIR", dlq) + db_worker._dlq_conflict_check_available = False + assert await db_worker.replay_dlq() == 0 # 파일 없음 → 조기 반환 + + # 파일이 하나라도 있으면 리셋된다 + from datetime import datetime as _dt + _write_dlq(dlq, "insert_trade_journal", {"stock_code": "005930"}, _dt.now()) + monkeypatch.setattr(db_worker, "DLQ_PROCESSED_DIR", dlq / "processed") + monkeypatch.setattr(db_worker, "DLQ_EXPIRED_DIR", dlq / "expired") + + async def _fake_enqueue(task): + return None + + monkeypatch.setattr(db_worker, "enqueue_db_write", _fake_enqueue) + await db_worker.replay_dlq() + assert db_worker._dlq_conflict_check_available is True + + +# ── M-4: close_active_trade 는 오래돼도 재큐잉, 새 포지션에는 걸린다 ──────── + +@pytest.mark.asyncio +async def test_stale_close_active_trade_is_requeued(dlq_env): + """야간 재기동(2시간 초과)에도 close 는 재큐잉된다. + + 격리하면 이미 청산된 포지션의 ActiveTrade 가 active 로 영구 잔존해 + §8.4.1 '유령 행' 과 같은 형태가 되고, 그 잔량이 유입 잔고 격리의 '시스템 보유' + 하한으로 흘러들어 다른 안전장치를 무력화한다. + """ + from datetime import datetime as _dt, timedelta as _td + from app.services import db_worker + + dlq, enqueued = dlq_env + f = _write_dlq(dlq, "close_active_trade", + {"stock_code": "005930", "strategy_name": "day_trade"}, + _dt.now() - _td(days=3), name="a_close_active_trade_old.json") + + assert await db_worker.replay_dlq() == 1 + assert enqueued[0]["type"] == "close_active_trade" + assert (dlq / "processed" / f.name).exists() + + +@pytest.mark.asyncio +async def test_stale_close_is_quarantined_when_newer_position_exists(tmp_path, monkeypatch): + """단, 현재 active 행이 failed_at 이후 생성이면(새 포지션) 격리한다.""" + from datetime import datetime as _dt, timedelta as _td + from app.services import db_worker + + dlq = tmp_path / "dlq" + dlq.mkdir() + monkeypatch.setattr(db_worker, "DLQ_DIR", dlq) + monkeypatch.setattr(db_worker, "DLQ_PROCESSED_DIR", dlq / "processed") + monkeypatch.setattr(db_worker, "DLQ_EXPIRED_DIR", dlq / "expired") + + enqueued = [] + + async def _fake_enqueue(task): + enqueued.append(task) + + monkeypatch.setattr(db_worker, "enqueue_db_write", _fake_enqueue) + + failed_at = _dt.now() - _td(days=3) + f = _write_dlq(dlq, "close_active_trade", + {"stock_code": "005930", "strategy_name": "day_trade"}, + failed_at, name="a_close_active_trade_conflict.json") + + newer = _FakeActiveTrade(stock_code="005930", quantity=5) + newer.created_at = _dt.now() - _td(hours=1) # failed_at 이후 = 새 포지션 + + class _Sess: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def execute(self, *a, **k): + rec = newer + + class _S: + def first(self_inner): + return rec + class _R: + def scalars(self_inner): + return _S() + return _R() + + monkeypatch.setattr(db_worker, "AsyncSessionLocal", lambda: _Sess()) + + assert await db_worker.replay_dlq() == 0 + assert enqueued == [] + assert (dlq / "expired" / f.name).exists() + + +@pytest.mark.asyncio +async def test_close_age_limit_tightens_when_conflict_check_unavailable(): + """충돌 판정이 단락되면 close 허용 나이가 기록 계열 상한으로 내려간다.""" + from app.services import db_worker + + db_worker._dlq_conflict_check_available = True + assert db_worker._dlq_max_age_seconds("close_active_trade") == \ + db_worker.DLQ_REPLAY_MAX_AGE_CLOSE_SECONDS + db_worker._dlq_conflict_check_available = False + assert db_worker._dlq_max_age_seconds("close_active_trade") == \ + db_worker.DLQ_REPLAY_MAX_AGE_RECORD_SECONDS + # 상태 계열은 단락과 무관하게 2시간 + assert db_worker._dlq_max_age_seconds("upsert_active_trade") == \ + db_worker.DLQ_REPLAY_MAX_AGE_STATE_SECONDS + db_worker._dlq_conflict_check_available = True + +# ── 재발 방지: 벽시계 의존 차단 (autouse) ─────────────────────────────────── +# +# 이 파일에서 `scan_and_enter()` 를 시각 고정 없이 호출해 **두 번** 깨졌다 +# (컷오프 15:00 이후 실행 시 조기 리턴 → 빈 결과). 개별 테스트의 주의력에 맡기면 +# 새 테스트가 같은 함정에 다시 빠지므로, 호출 자체를 구조적으로 막는다. +# +# 가드는 **현재 시각을 보지 않고 무조건** 실패시킨다. 벽시계를 참고하면 낮에는 +# 통과하고 밤에는 깨지는 '운 좋은 통과'가 생겨 재발 방지가 되지 않는다. +@pytest.fixture(autouse=True) +def _require_frozen_clock_for_time_gated_calls(monkeypatch): + """`scan_and_enter()` 호출 시 `_freeze_entry_window` 적용을 강제한다. + + 청산 계열(`check_and_exit_positions`/`force_close_all`/`close_stale_positions`)도 + 내부에서 시각을 읽는다(EOD·시간손절). 이 파일에는 현재 호출부가 없지만, 추가할 + 때는 같은 방식으로 해당 모듈의 `datetime` 을 고정하고 이 가드를 확장할 것. + """ + from app.services.daytrading import entry_service as es + + _real_scan = es.scan_and_enter + + async def _guarded_scan(*args, **kwargs): + if es.datetime is datetime: + raise AssertionError( + "scan_and_enter() 를 시각 고정 없이 호출했다 — 진입 윈도우" + "(09:30~DAY_TRADE_ENTRY_CUTOFF) 밖에서는 조기 리턴해 테스트가 시간대에 따라" + " 깨진다. 테스트 안에서 `_freeze_entry_window(monkeypatch, es)` 를 먼저 호출하라." + ) + return await _real_scan(*args, **kwargs) + + # 원본을 노출해 소스 검사 테스트가 가드가 아닌 실제 함수를 보게 한다 + _guarded_scan.__wrapped__ = _real_scan + monkeypatch.setattr(es, "scan_and_enter", _guarded_scan) + + +def test_wallclock_guard_rejects_unfrozen_scan(monkeypatch): + """가드 자체가 동작하는지 — 시각 고정 없이 호출하면 실패해야 한다.""" + from app.services.daytrading import entry_service as es + + with pytest.raises(AssertionError, match="시각 고정"): + asyncio.get_event_loop_policy().new_event_loop().run_until_complete( + es.scan_and_enter() + ) + + +def test_every_scan_call_site_freezes_the_clock(): + """[전수 확인] 이 파일의 scan_and_enter 호출부가 모두 시각을 고정했는지. + + 가드(autouse)가 런타임에 막지만, 이 테스트는 '어느 테스트가 빠뜨렸는지'를 + 파일 단위로 드러내 리뷰에서 바로 보이게 한다. + """ + import pathlib + import re + + src = pathlib.Path(__file__).read_text(encoding="utf-8") + # 테스트 함수 단위로 쪼개 scan_and_enter 호출과 freeze 호출을 함께 본다 + blocks = re.split(r"\n(?=@pytest\.|def test_|async def test_)", src) + offenders = [] + for b in blocks: + m = re.search(r"(?:async )?def (test_\w+)", b) + if not m: + continue + name = m.group(1) + if name in ("test_wallclock_guard_rejects_unfrozen_scan", + "test_every_scan_call_site_freezes_the_clock"): + continue + if "es.scan_and_enter()" in b and "_freeze_entry_window(" not in b: + offenders.append(name) + assert not offenders, ( + f"시각 고정 없이 scan_and_enter() 를 호출하는 테스트: {offenders} — " + "_freeze_entry_window(monkeypatch, es) 를 추가하라" + )