diff --git a/backend/app/api/endpoints/market_data.py b/backend/app/api/endpoints/market_data.py index 065b883..a9a717e 100644 --- a/backend/app/api/endpoints/market_data.py +++ b/backend/app/api/endpoints/market_data.py @@ -681,12 +681,17 @@ def _suggest_buy_amount( out["sizing_note"] = "잔고 조회 전 — 금액 계산 불가" return out - position_cap = float(getattr(_cfg, "MAX_POSITION_SIZE", 0.06)) + # [2026-09-28] MAX_POSITION_SIZE 는 CLAUDE.md §5.1 종목당 **절대 상한**이다. + # 적응형 모드(Normal 6% / Aggressive 7%)를 그대로 쓰면 이 화면이 한도를 넘는 + # 금액을 제안하고, 운영자가 그대로 수동매수하면 상한이 깨진다. + # 실행 경로(trade_executor.py:898, 981-987)와 동일하게 min() 으로 클램프한다. + _hard_cap = float(getattr(_cfg, "MAX_POSITION_SIZE", 0.04)) + position_cap = _hard_cap try: from app.services.adaptive_strategy import get_current_adaptive_params _adp = get_current_adaptive_params() if not _adp.fallback_used: - position_cap = float(_adp.position_size) + position_cap = min(float(_adp.position_size), _hard_cap) except Exception: pass @@ -694,6 +699,10 @@ def _suggest_buy_amount( # 신뢰도 보정 (기준 0.7) if confidence > 0: budget *= min(1.2, max(0.8, confidence / 0.7)) # 하한 0.8 (과도 축소 방지) + # 신뢰도 상향 보정(최대 ×1.2)이 절대 상한을 넘기지 못하게 재클램프. + # 실행 경로도 패턴메모리 조정 뒤 같은 상한을 다시 적용하고(trade_executor.py:981-987), + # manual_buy.py:120 의 자동 수량도 총자산×MAX_POSITION_SIZE 예산을 쓴다. + budget = min(budget, total_assets * _hard_cap) # 현금 여력 reserve = total_assets * (1 - float(getattr(_cfg, "MAX_TOTAL_CAPITAL_USAGE", 0.95))) budget = min(budget, max(0.0, available_cash - reserve)) diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py index b1c75c7..eabd277 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 +from sqlalchemy import select, func, or_ from typing import List, Optional from decimal import Decimal from pydantic import BaseModel, Field @@ -36,6 +36,75 @@ logger = logging.getLogger(__name__) # 한국 시간대 KST = pytz.timezone('Asia/Seoul') +# 유입(외부) 잔고 격리 태그 — 시스템이 매수한 적 없는 보유분. +# 스윙 자동 청산(get_swing_holdings: strategy_type=='swing')에서 자동 제외된다. +EXTERNAL_STRATEGY_TYPE = "external" +# sync 가 자동 시딩한 ActiveTrade 전략명 — '시스템 매수 근거'로 인정하지 않는다. +_SEEDED_AT_STRATEGY_NAMES = ("synced",) + + +def _external_stop_breaches(holdings: list) -> list: + """유입 격리분 중 기본 손절선(-STOP_LOSS_PERCENT)을 이탈한 종목 목록 (순수 함수). + + 격리분은 **집행하지 않는다** — 이 함수는 "조용한 실패"를 막기 위한 가시화 전용이다. + ATR 손절(진입가 − 2.5×ATR)은 분봉·일봉이 필요해 여기서 계산하지 않는다. + 따라서 이 판정은 기본 SL 기준이며 실제 전략 손절선보다 느슨할 수 있다. + + Returns: + list[dict]: stock_code / stock_name / profit_loss_rate / breach_pct(초과 이탈폭) + """ + from app.core.config import settings as _cfg + + _sl = -abs(float(getattr(_cfg, "STOP_LOSS_PERCENT", 7.0))) + out = [] + for h in holdings: + if (getattr(h, "strategy_type", None) or "swing") != EXTERNAL_STRATEGY_TYPE: + continue + if not h.quantity or h.quantity <= 0: + continue + _avg = float(h.average_price or 0) + _cur = float(h.current_price or 0) + if _avg <= 0 or _cur <= 0: + continue + _rate = (_cur / _avg - 1) * 100 + if _rate <= _sl: + out.append({ + "stock_code": h.stock_code, + "stock_name": h.stock_name or h.stock_code, + "profit_loss_rate": round(_rate, 2), + "breach_pct": round(_rate - _sl, 2), # 손절선 대비 추가 이탈폭(음수) + "stop_loss_pct": _sl, + }) + return out + + +def _classify_holding_strategy_type( + stock_code: str, + quantity: int, + dt_qty: dict, + system_codes: set, +) -> str: + """KIS 잔고 1행의 `Portfolio.strategy_type` 판정 (순수 함수). + + Args: + stock_code: 종목코드 + quantity: KIS 잔고 수량 + dt_qty: 데이트레이딩 인메모리 보유 수량 {code: qty} + system_codes: 시스템 매수 근거가 있는 종목 집합 + (TradeHistory BUY ∪ 시딩 아닌 ActiveTrade ∪ DT 인메모리) + + Returns: + "daytrading" | "swing" | "external" + - 전량이 DT 보유분이면 daytrading (부분 혼재면 스윙 청산 대상 유지) + - 시스템 매수 근거가 없으면 external (유입 잔고 → 자동 청산 제외) + """ + _dt_held = int(dt_qty.get(stock_code, 0) or 0) + if _dt_held > 0 and quantity <= _dt_held: + return "daytrading" + if stock_code in system_codes: + return "swing" + return EXTERNAL_STRATEGY_TYPE + # Request/Response Models class SellRequest(BaseModel): stock_code: str @@ -95,7 +164,21 @@ def _calculate_portfolio_values(holdings: list) -> dict: total_investment = domestic_investment total_profit_loss = domestic_profit_loss + # 유입 격리(strategy_type='external') 보유분 — 자동 청산 대상에서 빠진 잔고. + # 총자산·평가액에서 빼지 않는다(계좌 전체 리스크는 이 잔고를 포함해야 한다). + _external = [ + h for h in holdings + if (getattr(h, "strategy_type", None) or "swing") == "external" + ] + external_count = len(_external) + # 국내 종목은 KRW, 해외 종목은 USD 원본 통화 — 환산은 하지 않는다(표시 전용). + external_value = sum(float(h.current_price or 0) * h.quantity for h in _external) + return { + "external_count": external_count, + "external_value": external_value, + # 손절선 이탈 격리분 — 집행은 하지 않되 프론트·로그에서 반드시 보이게 한다 + "external_stop_breaches": _external_stop_breaches(_external), "total_stocks": total_stocks, "domestic_value": domestic_value, "overseas_value_usd": overseas_value_usd, @@ -420,6 +503,11 @@ class PortfolioCache: "bfdy_total_assets": bfdy_total_assets, "today_profit_loss": today_profit_loss, "today_profit_loss_rate": today_profit_loss_rate, + # 유입 격리(external) 노출 — 자동 청산 대상에서 빠진 보유분을 프론트가 + # 구분할 수 있게 한다(리스크 지표 total_assets/total_value 에는 포함). + "external_count": portfolio_values["external_count"], + "external_value": portfolio_values["external_value"], + "external_stop_breaches": portfolio_values["external_stop_breaches"], # 해외 관련 추가 필드 "overseas_value_usd": overseas_value_usd, "overseas_value_krw": overseas_value_krw if exchange_rate > 0 else overseas_total_krw, @@ -614,6 +702,43 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) if ghost_candidates: logger.info(f"🚫 Ghost Stock Candidates (Last action today is SELL): {ghost_candidates}") + # ── 유입(external) 잔고 격리용 '시스템 매수 근거' 수집 ────────────── + # [2026-09-28] 모의계좌 교체 등으로 계좌에 편입된 "시스템이 매수하지 않은" + # 잔고를 strategy_type='external' 로 태깅해 스윙 자동 청산 대상에서 뺀다. + # 태깅이 없으면 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() + ) + _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() + ) + _system_codes = _system_bought | {c for c in _at_rows_prov.scalars().all() if c} + _system_codes |= _dt_codes # 당일 DT 인메모리 포지션도 시스템 매수분 + + def _classify_strategy_type(_code: str, _qty: int) -> str: + """Portfolio 행의 strategy_type 판정 (모듈 순수 함수 위임).""" + return _classify_holding_strategy_type(_code, _qty, _dt_qty, _system_codes) + # 기존 레코드 로드 (삭제 대신 업데이트) result = await db.execute(select(Portfolio)) existing_all = {p.stock_code: p for p in result.scalars().all()} @@ -689,6 +814,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) ) processed_codes.add(stock_code) + _classified = _classify_strategy_type(stock_code, quantity) if stock_code in existing_all: h = existing_all[stock_code] h.stock_name = stock_name @@ -701,8 +827,25 @@ 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) 강등은 + # 하지 않는다: 체결 이력이 유실된 정상 포지션이 손절·익절 관리를 + # 통째로 잃는 쪽이 더 위험하다. + h.strategy_type = "swing" + logger.warning( + f"⬆️ 유입 격리 해제(승격): {stock_name}({stock_code}) " + f"external → swing (시스템 매수 근거 확인)" + ) new_holdings.append(h) else: + if _classified == EXTERNAL_STRATEGY_TYPE: + logger.warning( + f"🚧 유입 잔고 격리: {stock_name}({stock_code}) {quantity}주 " + f"평단={average_price} 평가액={float(current_price) * quantity:,.0f}원 " + f"— 시스템 매수 이력 없음 → strategy_type='external' " + f"(자동 손절·익절 대상 제외, 수동 판단 필요)" + ) new_holding = Portfolio( stock_code=stock_code, stock_name=stock_name, @@ -712,11 +855,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) profit_loss=profit_loss, profit_loss_rate=profit_loss_rate, market="KRX", - strategy_type=( - "daytrading" - if quantity <= _dt_qty.get(stock_code, 0) - else "swing" - ), + strategy_type=_classified, ) db.add(new_holding) new_holdings.append(new_holding) @@ -771,6 +910,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) ) processed_codes.add(stock_code) + _classified = _classify_strategy_type(stock_code, quantity) if stock_code in existing_all: h = existing_all[stock_code] h.stock_name = stock_name @@ -784,8 +924,19 @@ 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": + # 승격만 허용 (국내 경로와 동일 규칙) + h.strategy_type = "swing" + logger.warning( + f"⬆️ [해외] 유입 격리 해제(승격): {stock_name}({stock_code}) external → swing" + ) new_holdings.append(h) else: + if _classified == EXTERNAL_STRATEGY_TYPE: + logger.warning( + f"🚧 [해외] 유입 잔고 격리: {stock_name}({stock_code}) {quantity}주 " + f"평단={average_price} — 시스템 매수 이력 없음 → strategy_type='external'" + ) new_holding = Portfolio( stock_code=stock_code, stock_name=stock_name, @@ -795,11 +946,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) profit_loss=profit_loss, profit_loss_rate=profit_loss_rate, market=exchange_code, - strategy_type=( - "daytrading" - if quantity <= _dt_qty.get(stock_code, 0) - else "swing" - ), + strategy_type=_classified, ) db.add(new_holding) new_holdings.append(new_holding) @@ -869,6 +1016,13 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) continue if (_h.strategy_type or "swing") == "daytrading": continue + # [2026-09-28] 유입 격리분(external)은 ActiveTrade 를 만들지 않는다. + # ① 사용자 결정: 유입 잔고는 자동 손절·익절 대상에서 제외한다. + # ② 시딩하면 그 행이 다음 sync 의 '시스템 매수 근거'로 오인될 위험이 + # 있어(strategy_name 이 'synced' 가 아닌 값으로 굳는 경로) 격리가 + # 스스로 풀릴 수 있다. + if (_h.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE: + continue _avg = float(_h.average_price or 0) if _avg <= 0: continue @@ -926,6 +1080,34 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True) except Exception as _seed_err: logger.warning(f"ActiveTrade 시딩 큐잉 실패(무시): {_seed_err}") + # 격리된 유입 잔고는 "조용히 방치"되면 안 된다 — 매 동기화마다 한 줄로 노출. + # (자동 손절·익절이 걸리지 않으므로 운영자의 수동 판단이 유일한 관리 수단) + _external_rows = [ + (h.stock_name or h.stock_code, h.stock_code, int(h.quantity), + float(h.current_price or 0) * int(h.quantity)) + for h in new_holdings + if h.quantity > 0 and (h.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE + ] + if _external_rows: + logger.warning( + "🚧 유입 격리 보유 %d종목 (자동 청산 제외): %s", + len(_external_rows), + ", ".join( + f"{_n}({_c}) {_q}주/{_v:,.0f}" for _n, _c, _q, _v in _external_rows + ), + ) + # 손절선 이탈은 절대 침묵시키지 않는다 — 이번 사건의 본질이 '조용한 실패'였다. + # 집행은 하지 않으며 처분 판단은 운영자 수동 청산(portfolio_v2)에 맡긴다. + for _br in _external_stop_breaches( + [h for h in new_holdings if h.quantity > 0] + ): + logger.warning( + "🔻 유입 격리분 손절선 이탈(자동 청산 안 함): %s(%s) " + "손익률 %.2f%% / 기준 %.1f%% / 초과 이탈 %.2f%%p — 수동 판단 필요", + _br["stock_name"], _br["stock_code"], _br["profit_loss_rate"], + _br["stop_loss_pct"], _br["breach_pct"], + ) + portfolio_cache.holdings = [h for h in new_holdings if h.quantity > 0] await portfolio_cache.cache_summary(balance_data, overseas_balance_data) # 국내·해외 잔고 전달 portfolio_cache.last_synced = datetime.now() @@ -1151,6 +1333,12 @@ async def check_and_execute_exit_trades(db: AsyncSession): for holding in holdings: try: + # [2026-09-28] 유입 격리분은 자동 청산 대상이 아니다. + # 이 함수는 deprecated 이고 현재 스케줄러에 배선돼 있지 않지만 + # (auto_trade_scheduler 는 create_task 되지 않는다), 되살아날 때 + # strategy_type 필터가 없는 전수 조회라 격리가 통째로 뚫린다. + if (holding.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE: + continue stock_code = holding.stock_code stock_name = holding.stock_name quantity = holding.quantity @@ -1553,6 +1741,7 @@ async def sell_stock( stock_name = holding.stock_name original_holding_qty = holding.quantity original_avg_price = float(holding.average_price or 0) + original_strategy_type = holding.strategy_type or "swing" # 매도 주문 실행 # [2026-09-23] safe_place_order 경유 — 종목별 세마포어 + Redis 분산락으로 @@ -1633,6 +1822,21 @@ async def sell_stock( except Exception as _at_err: logger.warning(f"[SELL] ActiveTrade 정리 실패 {stock_code}: {_at_err}") + # LossTracker 연패 기록 — 전량 청산 시에만(분할 매도 이중 카운트 방지). + # [2026-09-28] 수동 청산이 빠져 있어 실질 3연패 종목이 2로 집계됐다 + # (카카오뱅크: 9-22 수동청산 1건 누락 → CONSECUTIVE_LOSS_BAN 미발동). + # 조건·순서는 swing/exit_service.py 와 동일하게 record → save_to_db. + # 유입 격리분(external)은 전략 성과가 아니므로 연패에 넣지 않는다. + if quantity >= original_holding_qty and original_strategy_type != EXTERNAL_STRATEGY_TYPE: + try: + await loss_tracker.record_trade_result_safe( + stock_code=stock_code, + is_loss=profit_loss < 0, + ) + await loss_tracker.save_to_db(stock_code=stock_code) + except Exception as _lt_err: + logger.warning(f"[SELL] LossTracker 기록 실패 {stock_code}: {_lt_err}") + # 데이트레이딩 엔진이 같은 종목을 보유 중이면 인메모리 잔량도 줄인다. # (누락 시 엔진이 매도된 수량을 그대로 들고 있다고 믿고 다음 사이클에 재매도) try: diff --git a/backend/app/api/endpoints/portfolio_v2.py b/backend/app/api/endpoints/portfolio_v2.py index e854311..28a9350 100644 --- a/backend/app/api/endpoints/portfolio_v2.py +++ b/backend/app/api/endpoints/portfolio_v2.py @@ -450,6 +450,7 @@ async def sell_stock_v2( stock_name = holding.stock_name original_holding_qty = holding.quantity average_price = holding.average_price + original_strategy_type = holding.strategy_type or "swing" order_result = await safe_place_order_v2( ctx, stock_code=stock_code, order_type="SELL", quantity=quantity, price=price, is_domestic=True, @@ -516,6 +517,23 @@ async def sell_stock_v2( await _enq_at(_at_payload) await db.commit() + # LossTracker 연패 기록 — 전량 청산 시에만(분할 매도 이중 카운트 방지). + # [2026-09-28] 수동 청산이 빠져 연패가 과소 집계되던 구멍을 막는다. + # 조건·순서는 swing/exit_service.py 와 동일하게 record → save. + # 유입 격리분(external)은 전략 성과가 아니므로 제외. + if quantity >= original_holding_qty and original_strategy_type != "external": + try: + await ctx.loss_tracker.record_trade_result_safe( + stock_code=stock_code, + is_loss=profit_loss < 0, + ) + if ctx.is_legacy: + await ctx.loss_tracker.save_to_db(stock_code=stock_code) + else: + await ctx.loss_tracker.save_to_db_v2(ctx.user_id, stock_code=stock_code) + except Exception as _lt_err: + logger.warning(f"[SELL v2] LossTracker 기록 실패 {stock_code}: {_lt_err}") + logger.info(f"✅ [v2] user_id={ctx.user_id} 수동 매도 완료: {stock_name} {quantity}주 @ {executed_price:,.0f}원") return { "success": True, diff --git a/backend/app/api/endpoints/risk_management.py b/backend/app/api/endpoints/risk_management.py index adcf1a2..c40c6da 100644 --- a/backend/app/api/endpoints/risk_management.py +++ b/backend/app/api/endpoints/risk_management.py @@ -34,7 +34,10 @@ router = APIRouter() logger = logging.getLogger(__name__) # 🔥 리스크 관리 설정 — config.py 참조 -MAX_POSITION_SIZE_RATIO = settings.MAX_POSITION_SIZE # config.py: 0.06 (6%) +MAX_POSITION_SIZE_RATIO = settings.MAX_POSITION_SIZE # config.py: 0.04 (4%) — 스윙 절대 상한 +# 데이트레이딩은 설계상 종목당 6%(DAY_TRADE_MAX_CAPITAL_RATIO, 5종목×6%=30%)이므로 +# 스윙 상한(4%)으로 검사하면 정상 DT 주문이 '위반'으로 오판정된다 → 전략별로 분기한다. +DT_POSITION_SIZE_RATIO = settings.DAY_TRADE_MAX_CAPITAL_RATIO MAX_DAILY_LOSS_RATIO = 0.03 # 일일 3% 손실 시 중단 MAX_CONSECUTIVE_LOSSES = 3 # 3연속 손실 시 중단 COMMISSION_RATE = 0.00015 # 수수료 0.015% @@ -273,14 +276,18 @@ async def get_today_session(db: AsyncSession = Depends(get_db)): async def check_risk_limits( order_value: float = Query(..., description="주문 금액"), account_balance: float = Query(..., description="계좌 잔고"), + strategy: str = Query("swing", description="전략 구분: swing | daytrading (종목당 한도 분기)"), db: AsyncSession = Depends(get_db) ): """ ⚠️ 주문 전 리스크 제한 확인 - - - 포지션 사이즈 제한 + + - 포지션 사이즈 제한 (전략별: 스윙 MAX_POSITION_SIZE / DT DAY_TRADE_MAX_CAPITAL_RATIO) - 일일 손실 한도 - 연속 손실 제한 + + [2026-09-28] `strategy` 는 기본값이 있는 추가 파라미터다(기존 호출부 동작 불변). + 이전에는 DT 주문(설계상 6%)을 스윙 상한 4%로 검사해 정상 주문을 위반으로 냈다. """ try: # 1. 오늘 세션 확인 @@ -342,15 +349,22 @@ async def check_risk_limits( "consecutive_losses": session.consecutive_losses } - # 5. 포지션 사이즈 제한 확인 + # 5. 포지션 사이즈 제한 확인 (전략별 한도) + _strategy = (strategy or "swing").strip().lower() + _position_cap = ( + DT_POSITION_SIZE_RATIO + if _strategy in ("daytrading", "day_trade", "dt") + else MAX_POSITION_SIZE_RATIO + ) position_ratio = order_value / account_balance if account_balance > 0 else 0 - if position_ratio > MAX_POSITION_SIZE_RATIO: + if position_ratio > _position_cap: return { "allowed": False, - "reason": f"포지션 사이즈 초과 ({position_ratio*100:.1f}% > {MAX_POSITION_SIZE_RATIO*100}%)", + "reason": f"포지션 사이즈 초과 ({position_ratio*100:.1f}% > {_position_cap*100}%)", "risk_level": "HIGH", + "strategy": _strategy, "position_ratio": round(position_ratio * 100, 2), - "max_allowed": round(MAX_POSITION_SIZE_RATIO * 100, 2) + "max_allowed": round(_position_cap * 100, 2) } # 모든 제한 통과 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 35d0fef..d05ffc0 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -275,7 +275,9 @@ class Settings(BaseSettings): DAY_TRADE_ENABLED: bool = True # 데이트레이딩 활성화 DAY_TRADE_MAX_POSITIONS: int = 5 # 동시 최대 종목 수 (8→5, 2026-09-23 30% 배분: 5×6%=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 # 종목당 최대 6% (0.04→0.06, MAX_POSITION_SIZE 상한과 동일) + DAY_TRADE_PER_STOCK_RATIO: float = 0.06 # ⚠️ 소비자 0건 — 실효 없음(2026-09-28 확인). + # DT 종목당 한도의 정본은 아래 DAY_TRADE_MAX_CAPITAL_RATIO 다. 이 키는 strategy_configs + # DB 행이 존재하므로 삭제하지 않고 남겨 둔다(삭제 시 UI/리셋 경로가 깨진다). DAY_TRADE_MAX_CAPITAL_RATIO: float = 0.06 # 종목당 최대 6% 자본 (0.04→0.06, 2026-09-22) DAY_TRADE_MAX_RATIO_DT: float = 0.30 # 전체 데이트레이딩 최대 30% (스윙:DT = 70:30, 2026-09-23) SWING_MAX_CAPITAL_RATIO: float = 0.70 # 스윙 전략 합산 최대 70% (0.50→0.70, 2026-09-23 — 표본 확보) diff --git a/backend/app/main.py b/backend/app/main.py index 023386f..213a3e3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -259,6 +259,17 @@ async def lifespan(app: FastAPI): app.state.db_worker_task = asyncio.create_task(db_writer_worker()) app.state.db_worker_task.add_done_callback(_restart_db_worker) + # [F4 2026-09-28] DLQ 재처리 — 이전 종료/재시도 실패로 파일에만 남은 DB 쓰기를 + # db_task_queue 에 되돌린다(직접 DB 쓰기 금지, CLAUDE.md §3.2). + # 워커 태스크 생성 직후에 호출해야 큐 투입분이 곧바로 소비된다. + try: + from app.services.db_worker import replay_dlq + _replayed = await replay_dlq() + if _replayed: + logger.warning(f"DLQ 재처리: {_replayed}건 재큐잉") + except Exception as exc: + logger.error(f"DLQ 재처리 실패: {exc}", exc_info=True) + # ── 스윙 / 데이트레이딩 분리 스케줄러 (신규 아키텍처) ──────────────────── from app.services.swing.scheduler import swing_scheduler from app.services.daytrading.scheduler import daytrading_scheduler diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 037b9e9..1c82073 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -172,7 +172,10 @@ class Portfolio(Base): profit_loss = Column(Numeric(15, 2)) profit_loss_rate = Column(Numeric(10, 4)) market = Column(String(10), nullable=True, default="KRX") - # 전략 구분: 'swing' | 'daytrading' + # 전략 구분: 'swing' | 'daytrading' | 'manual' | 'us_swing' | 'external' + # - 'external' = 시스템이 매수한 적 없는 유입 잔고(계좌 교체 등). 스윙 자동 + # 손절·익절 대상에서 제외되고 수동 판단만 받는다 (portfolio.sync_portfolio). + # 컬럼 길이 String(20) 안이라 스키마 변경(alembic) 불필요. strategy_type = Column(String(20), nullable=False, default="swing", index=True) currency_code = Column(String(3), nullable=False, default="KRW") exchange_code = Column(String(10), nullable=True) # NASD, NYSE, AMEX, KRX diff --git a/backend/app/services/day_trading_strategy.py b/backend/app/services/day_trading_strategy.py index c819d3e..a25305f 100644 --- a/backend/app/services/day_trading_strategy.py +++ b/backend/app/services/day_trading_strategy.py @@ -223,6 +223,27 @@ def aggregate_to_5min(candles: List[MinuteCandle]) -> List[MinuteCandle]: return result +def _last_session_candles(candles: List[MinuteCandle]) -> List[MinuteCandle]: + """마지막 봉과 **같은 영업일자**의 봉만 남긴다 (세션 경계 차단, 순수 함수). + + 진입 판정 경로의 시계열에는 지표 워밍업용 전일 봉이 섞여 있다 + (`korea_investment_api.get_minute_chart_full_day_v2(include_prev_day=True)`). + RSI·BB 처럼 연속 시계열이 필요한 지표는 그대로 써도 되지만, **거래량 비교는 + 같은 세션 안에서만 의미가 있다.** 예컨대 09:30 에 5분봉 `[-3]` 이 전일 마지막 + 5분봉(15:15~15:19, 종가 동시호가 물량)이면 당일 개장봉과의 비교가 등가가 아니고, + 거래량 게이트가 개장 직후 자동 통과하거나 반대로 영구 탈락한다. + + 영업일자 필드가 비어 있는 응답(목/레거시)은 기존 동작을 유지하기 위해 + 그대로 통과시킨다. + """ + if not candles: + return [] + last_date = getattr(candles[-1], "date", "") or "" + if not last_date: + return list(candles) + return [c for c in candles if (getattr(c, "date", "") or "") == last_date] + + # ── 지표 계산 ───────────────────────────────────────────────────────────────── def calculate_vwap(candles: List[MinuteCandle]) -> Optional[float]: @@ -310,13 +331,22 @@ def check_orb_signal( """ORB 상향 돌파 매수 신호 조건: - 1. 현재가 > ORB 고점 × 1.002 (0.2% 이상 돌파) - 2. 최근 1분봉 거래량 ≥ ORB 분당평균 × 1.5 (거래량 폭발) + 1. 현재가 > ORB 고점 × (1 + DAY_TRADE_ORB_BREAKOUT_MARGIN) + 2. **마지막 완성 1분봉** 거래량 ≥ ORB 분당평균 × DAY_TRADE_ORB_BREAKOUT_VOL_MULT 3. 직전 5분봉이 양봉 (음봉이면 Fake-out 위험) + + [2026-09-29] 2번은 `candles_1min[-1]`(형성 중 봉)을 쓰고 있었다. 30초 주기 스캔의 + 사이클 대부분에서 마지막 1분봉은 절반만 채워져 있어 **실효 요구가 임계값의 약 2배**가 + 됐다(volume_surge_ratio 의 F7 과 같은 클래스의 버그). 비교 대상만 마지막 **완성봉** + 으로 고치고 임계값은 건드리지 않는다. `orb.avg_vol_per_min` 이 당일 기준이므로 + 분자도 당일이어야 한다 — `_last_session_candles()` 로 세션을 고정한다. """ if not orb or current_price <= 0: return None + # 전일 워밍업 봉이 섞여 들어와도 ORB 판정(갭·거래량·양봉·ATR)은 당일 봉만 쓴다 + candles_1min = _last_session_candles(candles_1min) + # M4: Gap filter — 시가가 전일종가 대비 4%+ 갭이면 ORB 돌파 위험 과대, 진입 거부 # prev_close 근사: candles_1min 첫 봉의 open 이전 데이터 없으므로 orb.open_price 사용 # orb.open_price = 09:00 시가. 현재가와 시가의 괴리가 4% 초과면 갭 상승 종목으로 간주 @@ -336,10 +366,13 @@ def check_orb_signal( if current_price < orb.high * (1 + settings.DAY_TRADE_ORB_BREAKOUT_MARGIN): return None - # 최근 1분봉 거래량 체크 - if not candles_1min: + # 거래량 체크 — 마지막 **완성** 1분봉([-2]) 기준. 형성 중 봉([-1])은 부분 거래량이라 + # 임계값을 실질적으로 부풀린다. 완성봉이 없으면(당일 봉 2개 미만) 게이트 미통과. + if len(candles_1min) < 2: + return None + recent_vol = candles_1min[-2].volume + if orb.avg_vol_per_min <= 0: return None - recent_vol = candles_1min[-1].volume if recent_vol < orb.avg_vol_per_min * settings.DAY_TRADE_ORB_BREAKOUT_VOL_MULT: return None @@ -379,11 +412,44 @@ def check_orb_signal( reason=( f"ORB 상향 돌파: ORB고={orb.high:.0f}, " f"현재={current_price:.0f}, 거래량={vol_ratio:.1f}배" + f"(≥{settings.DAY_TRADE_ORB_BREAKOUT_VOL_MULT}배, 완성봉 기준)" ), confidence=round(confidence, 2), ) +def volume_surge_ratio(candles_5min: List[MinuteCandle]) -> Optional[float]: + """직전 거래량 증가 배율 — **완성된 5분봉끼리** 비교한 값. + + [F7 2026-09-28] 기존 게이트는 ``candles_5min[-1].volume >= candles_5min[-2].volume + * DAY_TRADE_VOLUME_MULTIPLIER`` 였다. 그런데 ``aggregate_to_5min`` 은 **형성 중인 + 5분 버킷도 하나의 봉으로 만든다.** 즉 좌변은 "아직 채워지는 중인 봉의 부분 + 거래량"이고 우변은 "완성된 봉의 전체 거래량"이라, 실제로 거래량이 1.3배로 늘어난 + 구간에서도 **버킷의 마지막 1분 남짓에만** 조건이 성립했다. 30초 주기 스캔에서 + 5분 버킷은 약 10사이클에 걸치므로 ~80% 사이클이 구조적으로 전멸했다. + + 비교 대상만 고친다(임계값 1.3 은 그대로): 마지막 **완성봉**([-2])을 그 직전 + 완성봉([-3])과 비교한다. 두 항이 모두 전체 5분치라 비교가 등가이고, 벽시계에 + 의존하지 않아 순수 함수로 남는다. + + [2026-09-29] 진입 경로의 시계열에 전일 워밍업 봉이 포함되므로, 비교는 + `_last_session_candles()` 로 **마지막 봉과 같은 세션**에 한정한다. 세션을 넘겨 + 비교하면 전일 종가 동시호가 물량과 당일 개장봉을 견주게 되어 게이트가 무의미해진다. + + Returns: + float | None: 배율. **당일** 완성봉이 2개 미만이거나 분모가 0이면 None + (= 게이트 미통과. 데이터 부족은 통과 쪽으로 열지 않는다) + """ + session = _last_session_candles(candles_5min) + if len(session) < 3: + return None + prev_completed = session[-3].volume + last_completed = session[-2].volume + if prev_completed <= 0: + return None + return last_completed / prev_completed + + def check_vwap_bounce_signal( stock_code: str, stock_name: str, @@ -396,7 +462,7 @@ def check_vwap_bounce_signal( 조건: 1. 현재가가 VWAP의 -1% ~ 0.5% 구간 (VWAP 근처) 2. 5분봉 RSI(14) ≤ threshold - 3. 최근 봉 거래량 ≥ 직전 봉 × vol_mult + 3. 마지막 완성 5분봉 거래량 ≥ 그 직전 완성봉 × vol_mult (형성 중 봉 제외) 4. 최근 5분봉 양봉 전환 (close > open) """ if not candles_5min or vwap <= 0 or current_price <= 0: @@ -409,9 +475,9 @@ def check_vwap_bounce_signal( if rsi is None or rsi > settings.DAY_TRADE_VWAP_RSI_THRESHOLD: return None - if len(candles_5min) < 2: - return None - if candles_5min[-1].volume < candles_5min[-2].volume * settings.DAY_TRADE_VOLUME_MULTIPLIER: + # [F7] 거래량 게이트 — 형성 중 봉이 아니라 완성봉끼리 비교 (임계값 무변경) + vol_ratio = volume_surge_ratio(candles_5min) + if vol_ratio is None or vol_ratio < settings.DAY_TRADE_VOLUME_MULTIPLIER: return None # 최근 봉 양봉 확인 @@ -435,8 +501,9 @@ def check_vwap_bounce_signal( stop_loss=round(stop_loss, 0), target_price=round(target, 0), reason=( - f"VWAP 반등: VWAP={vwap:.0f}, RSI={rsi:.1f}, " - f"현재={current_price:.0f}" + 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}배, 완성봉 기준)" ), confidence=0.65, ) @@ -453,7 +520,7 @@ def check_rsi_bb_signal( 조건: 1. RSI(14) ≤ threshold (강한 과매도) 2. 종가 ≤ BB 하단 + buffer - 3. 직전 봉 대비 거래량 ≥ vol_mult배 + 3. 마지막 완성 5분봉 거래량 ≥ 그 직전 완성봉 × vol_mult (형성 중 봉 제외) 4. 최근 봉 양봉 (음봉이면 스킵) """ if not candles_5min or current_price <= 0: @@ -471,9 +538,9 @@ def check_rsi_bb_signal( if current_price > bb_lower * settings.DAY_TRADE_RSI_BB_BB_LOWER_BUFFER: return None - if len(candles_5min) < 2: - return None - if candles_5min[-1].volume < candles_5min[-2].volume * settings.DAY_TRADE_VOLUME_MULTIPLIER: + # [F7] 거래량 게이트 — 형성 중 봉이 아니라 완성봉끼리 비교 (임계값 무변경) + vol_ratio = volume_surge_ratio(candles_5min) + if vol_ratio is None or vol_ratio < settings.DAY_TRADE_VOLUME_MULTIPLIER: return None last = candles_5min[-1] @@ -500,8 +567,9 @@ def check_rsi_bb_signal( stop_loss=round(stop_loss, 0), target_price=round(target, 0), reason=( - f"5분봉 과매도 반등: RSI={rsi:.1f}, " - f"BB하단={bb_lower:.0f}, 현재={current_price:.0f}" + 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}배, 완성봉 기준)" ), confidence=0.60, ) diff --git a/backend/app/services/daytrading/entry_service.py b/backend/app/services/daytrading/entry_service.py index b6ea8f6..02a8aeb 100644 --- a/backend/app/services/daytrading/entry_service.py +++ b/backend/app/services/daytrading/entry_service.py @@ -32,6 +32,7 @@ day_trading_strategy.py의 신호 함수를 재활용하고 """ import asyncio import logging +import time from datetime import datetime, time as dtime import pytz @@ -68,6 +69,42 @@ _watchlist: dict[str, str] = {} # False = API로 수동 설정된 목록 (자동 갱신으로 덮어쓰지 않음) _watchlist_auto: bool = False +# ── [F6 2026-09-28] 스캔 처리량 ─────────────────────────────────────────────── +# 증상: 감시종목 20개인데 매 사이클 앞쪽 5~6종목만 검사되고 뒤쪽은 영구히 기회를 +# 못 얻었다. 스케줄러의 _guard(scan_and_enter(), "진입 스캔") 가 180초에서 코루틴을 +# 취소하는데, 종목별 분봉 조회가 순차라 20종목이 예산에 들어오지 못했다. +# +# 대책 3종: +# 1) 분봉 조회를 제한 동시성으로 **선조회**(읽기 전용)해 지배적 지연을 제거한다. +# 주문은 그대로 순차 실행 → MAX_POSITIONS·예산 클램프 순서가 바뀌지 않는다. +# 2) 순차 실행 구간에 시간 예산을 둬 **잘린 종목 수를 warning 으로 남긴다** +# (기존에는 _guard 가 조용히 취소했다). +# 3) 라운드로빈 시작 오프셋 — 그래도 잘리면 다음 사이클은 다른 종목부터 본다. +# +# 동시성은 보수적으로 잡는다. 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 +# 순차 진입 구간 시간 예산(초). 스케줄러 _guard(STEP_TIMEOUT=180) 보다 작게 잡아 +# 취소가 아니라 '남은 종목 수 로그'로 끝나게 한다. +_SCAN_TIME_BUDGET: float = 150.0 +# 선조회 분봉 캐시 {code: (저장시각, candles)} — 한 사이클 안에서만 유효 +_candle_cache: dict[str, tuple[float, list]] = {} +# [F11 2026-09-28] 수명은 '시간'이 아니라 '한 사이클'이다 — scan_and_enter() 가 진입 +# 시점에 캐시를 비운다. TTL 만 180초로 두면 30초 주기 스케줄러에서 같은 스냅샷이 5개 +# 사이클을 덮어, 세 신호 함수가 (candles, orb, price) 의 순수 함수인 탓에 t=0 에 +# 신호가 없으면 t=150 까지 무조건 no-signal 이 되고 진입가·손절·수량이 최대 3분 묵는다. +# [M3 2026-09-28] TTL 은 **한 사이클 상한(_SCAN_TIME_BUDGET)보다 커야** 한다. +# 이전 값 25초는 선조회 자체보다 짧았다: 모든 KIS 요청이 싱글톤 _throttle_lock 안에서 +# min_request_interval(0.15초) 만큼 sleep 하므로 실효 처리율은 ≈6.7 req/s 로 전역 +# 고정이고(semaphore(8) 은 상한만 제공), 20종목 × 종목당 다회 호출이면 선조회가 수십 +# 초를 쓴다 → 앞쪽 항목이 순차 루프 시작 전에 만료돼 재조회됐다(종목당 호출 2배). +_CANDLE_CACHE_TTL: float = 180.0 +# 라운드로빈 시작 오프셋 +_scan_offset: int = 0 + def _clamp_day_trade_budget( budget: float, @@ -195,6 +232,29 @@ async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int = 20 return 0 stocks = [{"code": r.stock_code, "name": r.stock_name} for r in rows] + + # [F8 2026-09-28] ORB 를 이미 수집한 종목은 교체에서 살린다. + # set_watchlist() 는 목록을 **통째로 재대입**하고, 스케줄러의 30분 갱신은 + # _last_wl_sync=0.0 초기값 때문에 09:30 첫 사이클에서 즉시 발동한다. + # 09:00~09:29 에 ORB 를 수집한 종목이 여기서 빠지면(당일 배치가 다른 종목을 + # 상위로 올리면 실제로 빠진다) 스캔 루프가 _watchlist 만 순회하므로 + # 그 ORB 는 버려지고 당일 ORB 진입이 0건이 된다. + _kept = [ + {"code": c, "name": _watchlist.get(c, c)} + for c in _watchlist + if day_trading_state.has_orb(c) + ] + if _kept: + _kept_codes = {x["code"] for x in _kept} + merged = _kept + [x for x in stocks if x["code"] not in _kept_codes] + dropped = len(merged) - max_stocks + merged = merged[:max_stocks] + logger.info( + f"[DayEntry] watchlist 갱신 시 ORB 보유 {len(_kept)}종목 유지" + + (f" (신규 후보 {dropped}종목은 상한 {max_stocks}개로 탈락)" if dropped > 0 else "") + ) + stocks = merged + set_watchlist(stocks) global _watchlist_auto _watchlist_auto = True @@ -202,13 +262,141 @@ async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int = 20 return len(stocks) +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`). 원장 실측으로 필터 + 도입(1b9257c, 2026-09-28) 이전에는 09:30·10:02 진입이 실재했음이 확인됐다. + + ⚠️ **얼마나 앞당겨지는지는 확보된 워밍업 분량에 달렸고, 아직 미검증이다.** + 조회 계층이 전일로 거슬러 가려면 미래 `ref_time` 에 KIS 가 전일 데이터를 준다는 + 전제가 필요한데 그 전제는 확인되지 않았다(`get_minute_chart_full_day_v2` docstring). + 전일 봉이 경계 페이지 1장만 들어오면 발화 시각이 소폭 앞당겨지는 데 그친다. + 실제 확보량은 조회 계층의 `[분봉] ... 워밍업 5분봉 N봉` 로그로 확인할 것. + + ORB 수집(`collect_opening_ranges`)·DT 청산(`daytrading/exit_service`)·레거시 + 엔드포인트는 **기존 메서드(`get_minute_chart_full_day`)를 그대로 쓴다** — 당일 봉만 + 받는다. + + ⚠️ 호출부(`_try_enter`)는 용도를 반드시 분리해야 한다: + - 워밍업 허용(전일 포함): `aggregate_to_5min(candles)` → RSI/BB + - 당일 봉만: `calculate_vwap`, `check_orb_signal`, 현재가, 갭·당일 거래량 + → `filter_today_candles()` 로 분리 (CLAUDE.md §7 마지막 항목) + + 세션 경계 비교는 순수 계산 모듈 쪽에서 막는다 — + `day_trading_strategy.volume_surge_ratio()` 와 `check_orb_signal()` 의 거래량 + 게이트는 **마지막 봉과 같은 영업일자의 봉끼리만** 비교한다(`_last_session_candles`). + """ + raw = await korea_investment_api.get_minute_chart_full_day_v2( + stock_code, include_prev_day=True + ) + return parse_minute_candles(raw) + + +async def _prefetch_minute_charts(codes: list[str]) -> int: + """[F6] 후보 종목의 분봉을 제한 동시성으로 선조회해 캐시에 담는다. + + 읽기 전용이며 주문·상태 변경을 하지 않는다. 실패·타임아웃 종목은 캐시에 + 담기지 않고, 뒤이은 순차 스캔이 개별적으로 재시도한다(기존 동작과 동일). + + Returns: + int: 캐시에 담긴 종목 수 + """ + if not codes: + return 0 + sem = asyncio.Semaphore(_SCAN_PREFETCH_CONCURRENCY) + now = time.monotonic() + + async def _one(code: str) -> None: + cached = _candle_cache.get(code) + if cached and (now - cached[0]) < _CANDLE_CACHE_TTL: + return + async with sem: + try: + candles = await asyncio.wait_for( + _fetch_candles_impl(code), timeout=_SCAN_PREFETCH_TIMEOUT + ) + if candles: + _candle_cache[code] = (time.monotonic(), candles) + except asyncio.TimeoutError: + logger.warning( + f"[DayEntry] 분봉 선조회 {_SCAN_PREFETCH_TIMEOUT:.0f}초 초과: {code} — 이번 사이클 스킵" + ) + except Exception as e: + logger.warning(f"[DayEntry] 분봉 선조회 실패 ({code}): {e}") + + await asyncio.gather(*(_one(c) for c in codes), return_exceptions=True) + # 오래된 항목 정리 (감시목록 교체로 무한 증식 방지) + stale = [c for c, (ts, _) in _candle_cache.items() if (time.monotonic() - ts) > _CANDLE_CACHE_TTL * 2] + for c in stale: + _candle_cache.pop(c, None) + return sum(1 for c in codes if c in _candle_cache) + + +async def _get_candles_cached(stock_code: str) -> list: + """[F6] 선조회 캐시를 우선 사용하고, 없으면 그 자리에서 조회한다. + + ``_try_enter()`` 의 시그니처를 바꾸지 않기 위해 인자 전달 대신 모듈 캐시를 쓴다 + (CLAUDE.md §4: 기존 시그니처 변경 금지 / §4.1: 목킹 지점이 빗나가지 않게 유지). + """ + cached = _candle_cache.get(stock_code) + if cached and (time.monotonic() - cached[0]) < _CANDLE_CACHE_TTL: + return cached[1] + candles = await _fetch_candles_impl(stock_code) + if candles: + _candle_cache[stock_code] = (time.monotonic(), candles) + return candles + + +def filter_today_candles(candles: list) -> list: + """당일(KST) 캔들만 남긴다. + + [CLAUDE.md §7 마지막 항목] ``get_minute_chart_full_day`` 는 + ``FID_PW_DATA_INCU_YN="Y"`` 로 과거 데이터를 포함해 요청하므로 **전일 캔들이 + 섞인다.** ``calculate_opening_range`` 는 시각(090000~092959)만 보고 날짜를 + 보지 않기 때문에, 필터 없이 넘기면 전일 09:00~09:29 봉이 ORB 고저·거래량에 + 합산되어 레인지가 오염된다(레인지 과대 → 돌파 미발생, 평균분당거래량 과대 → + 거래량 게이트 통과 불가). + + [M2 2026-09-28] "날짜 필드가 없다"와 "날짜가 다 있는데 전부 당일이 아니다"를 + **구분한다**. 전자는 목/레거시 응답 방어를 위해 원본을 통과시키지만, 후자는 §7 이 + 경고하는 오염 그 자체이므로 ``[]`` 를 반환하고 ``warning`` 을 남긴다. 통과시키면 + 전일 ORB 고저·평균분당거래량이 당일 ORB 로 Redis(TTL 24시간)에 저장돼 하루 남는다. + (`market_schedule` 에 휴장일 판정이 없어 휴장일에 당일 봉이 0건인 경로가 실재한다.) + """ + today_str = datetime.now(_KST).strftime("%Y%m%d") + todays = [c for c in candles if getattr(c, "date", "") == today_str] + if todays: + return todays + if not candles: + return candles + dated = [c for c in candles if getattr(c, "date", "")] + if len(dated) == len(candles): + # 전 캔들에 날짜가 있는데 당일이 0건 → 전일 데이터만 돌아온 것(오염) + _dates = sorted({getattr(c, "date", "") for c in candles}) + logger.warning( + f"[DayEntry] ⚠️ 당일({today_str}) 분봉 0건 — 응답 날짜 {_dates[:3]} " + f"(총 {len(candles)}봉). 전일 캔들로 ORB 를 계산하면 고저·거래량이 오염되므로 " + f"폐기한다(휴장일 또는 분봉 조회 이상)." + ) + return [] + # 날짜 필드가 비어 있는 응답(목/레거시) — 기존 동작 유지 + return candles + + async def collect_opening_ranges() -> None: """09:00~09:30 ORB(Opening Range Breakout) 데이터 수집. 감시 종목의 시초가 레인지(고가/저가)를 수집하여 ORB 돌파 전략의 기준값으로 사용. - 호출 시점: 스케줄러에서 09:00~09:30 사이 30초 주기로 호출 + 호출 시점: 스케줄러에서 09:00~09:30 사이 30초 주기로 호출. + 장중 재기동으로 09:30 을 지나 기동한 경우는 backfill_opening_ranges() 가 + 같은 로직을 소급 호출한다(당일 분봉 전체 조회이므로 시각 제약 없음). KIS API 호출 간격: 0.3초 (레이트 리밋 준수) """ day_trading_state.reset_if_new_day() @@ -224,7 +412,7 @@ async def collect_opening_ranges() -> None: try: # 당일 분봉 전체 조회 → 시초가 레인지 계산 raw = await korea_investment_api.get_minute_chart_full_day(stock_code) - candles = parse_minute_candles(raw) + candles = filter_today_candles(parse_minute_candles(raw)) if candles: orb = calculate_opening_range(candles) if orb: @@ -239,6 +427,55 @@ async def collect_opening_ranges() -> None: await day_trading_state.save_orb_cache() +async def backfill_opening_ranges() -> int: + """장중 재기동 시 당일 ORB 를 소급 수집. 수집된 종목 수 반환. + + [2026-09-28 재기동 내구성 수정] + ``collect_opening_ranges()`` 의 유일한 호출부가 스케줄러의 + ``is_opening_range_period()``(09:00~09:29) 분기였다. 09:30 이후에 기동하면 + 재수집 경로가 없어 **당일 ORB 전략이 영구 0건**이 됐다(Redis 캐시가 살아 + 있으면 복원되지만, 캐시가 없거나 09:30 전에 한 번도 수집되지 않았으면 불가). + + ``get_minute_chart_full_day`` 는 당일 전체 분봉을 주므로 09:30 이후에도 + 09:00~09:29 레인지를 정확히 소급 계산할 수 있다. 전일 캔들 혼입은 + ``filter_today_candles()`` 가 차단한다. + + 진입 게이트는 건드리지 않는다 — 수집만 수행한다. + + Returns: + int: 소급 수집 후 orb_cache 에 존재하는 종목 수 + """ + if not _watchlist: + try: + count = await populate_daytrading_watchlist() + if count: + logger.info(f"[DayEntry] ORB 소급수집용 watchlist 구성: {count}개") + except Exception as e: + logger.warning(f"[DayEntry] ORB 소급수집용 watchlist 구성 실패: {e}") + + if not _watchlist: + logger.warning( + "[DayEntry] ⚠️ 장중 기동 ORB 소급수집 불가 — watchlist 가 비어 있다 " + "(당일 stock_analysis 부재 가능). 당일 ORB 전략 0건이 된다." + ) + return len(day_trading_state.orb_cache) + + before = len(day_trading_state.orb_cache) + await collect_opening_ranges() + after = len(day_trading_state.orb_cache) + if after: + logger.info( + f"[DayEntry] 장중 기동 ORB 소급수집: {before}개 → {after}개 " + f"(watchlist {len(_watchlist)}개)" + ) + else: + logger.warning( + f"[DayEntry] ⚠️ 장중 기동 ORB 소급수집 0건 (watchlist {len(_watchlist)}개) — " + "당일 ORB 전략 불가. 분봉 조회 실패 또는 당일 09:00~09:29 봉 부족." + ) + return after + + async def scan_and_enter() -> list[dict]: """감시 종목의 5분봉 데이터를 스캔하여 진입 신호 발생 시 매수 실행. @@ -378,6 +615,10 @@ async def scan_and_enter() -> list[dict]: skipped_held = 0 skipped_closed = 0 no_signal = 0 + truncated = 0 + + # [F6] 후보 선별 → 라운드로빈 회전 → 분봉 선조회 → 순차 진입 + candidates: list[tuple[str, str]] = [] for stock_code, stock_name in list(_watchlist.items()): # 이미 보유 중이거나 오늘 이미 청산한 종목 제외 if stock_code in day_trading_state.positions: @@ -386,8 +627,38 @@ async def scan_and_enter() -> list[dict]: if day_trading_state.is_closed_today(stock_code): skipped_closed += 1 continue + candidates.append((stock_code, stock_name)) + + # 라운드로빈: 시간 예산에 걸려 잘려도 다음 사이클은 다른 종목부터 본다 + global _scan_offset + if candidates: + _scan_offset %= len(candidates) + candidates = candidates[_scan_offset:] + candidates[:_scan_offset] + + # [F11] 사이클 경계 — 이전 사이클 스냅샷을 버려 매 사이클 새 데이터로 재평가한다. + # 재조회 비용은 get_minute_chart_full_day 의 Redis 1분 버킷 캐시가 대부분 흡수한다. + _candle_cache.clear() + + # 선조회(읽기 전용, 제한 동시성) — 순차 조회로 예산을 다 쓰던 지배 원인 제거 + _scan_started = time.monotonic() + prefetched = await _prefetch_minute_charts([c for c, _ in candidates]) + _prefetch_sec = time.monotonic() - _scan_started + + for idx, (stock_code, stock_name) in enumerate(candidates): if day_trading_state.position_count() >= MAX_POSITIONS: break + _elapsed = time.monotonic() - _scan_started + if _elapsed > _SCAN_TIME_BUDGET: + # 기존에는 스케줄러 _guard(180초)가 코루틴을 조용히 취소해 '뒤쪽 종목이 + # 통째로 검사되지 않는다'는 사실이 로그에 전혀 남지 않았다. + truncated = len(candidates) - idx + logger.warning( + f"[DayEntry] ⏱️ 스캔 시간 예산 {_SCAN_TIME_BUDGET:.0f}초 초과 " + f"({_elapsed:.0f}초 경과) — 남은 {truncated}종목 미스캔. " + f"다음 사이클은 오프셋 {(_scan_offset + idx) % max(1, len(candidates))} 부터 시작" + ) + _scan_offset = (_scan_offset + idx) % len(candidates) + break scanned += 1 try: @@ -402,10 +673,16 @@ async def scan_and_enter() -> list[dict]: no_signal += 1 except Exception as e: logger.error(f"[DayEntry] 진입 시도 오류 ({stock_code}): {e}", exc_info=True) + else: + # 전 종목을 소화했으면 오프셋을 리셋해 다음 사이클도 앞에서부터 본다 + _scan_offset = 0 logger.info( - f"[DayEntry] 스캔 완료: {scanned}종목 스캔, {len(entries)}진입, " - f"{no_signal}신호없음, {skipped_held}보유중, {skipped_closed}청산완료" + f"[DayEntry] 스캔 완료: 후보 {len(candidates)}종목 중 {scanned}종목 스캔" + f"{f' (미스캔 {truncated})' if truncated else ''}, {len(entries)}진입, " + f"{no_signal}신호없음, {skipped_held}보유중, {skipped_closed}청산완료 " + f"(선조회 {prefetched}/{len(candidates)}종목 {_prefetch_sec:.1f}초, " + f"총 {time.monotonic() - _scan_started:.1f}초)" ) return entries @@ -449,26 +726,42 @@ async def _try_enter( logger.debug(f"[DayEntry] VI 쿨다운 — {stock_code} 진입 스킵") return None - raw = await korea_investment_api.get_minute_chart_full_day(stock_code) - candles = parse_minute_candles(raw) + # [F6] 선조회 캐시 사용 (miss 면 이 자리에서 조회). + # candles 에는 **전일 워밍업 봉이 섞여 있다**(_fetch_candles_impl docstring). + candles = await _get_candles_cached(stock_code) if not candles: return None + # 세션 앵커 지표(VWAP)·당일 거래량·갭·현재가는 '당일 봉'이라는 보장이 필요하다. + candles_today = filter_today_candles(candles) + if not candles_today: + # 당일 봉이 0건 = 휴장일·조회 이상. 현재가·VWAP·거래량 기준을 만들 수 없다. + # (filter_today_candles 가 날짜 필드가 채워진 경우 warning 을 남긴다) + return None + + # 5분봉 집계 — 버킷 키에 날짜가 포함돼 서로 다른 날의 1분봉이 한 봉으로 + # 합쳐지지 않는다. 전일 봉이 앞쪽에 붙어 RSI(14)/BB(20) 워밍업을 **부분적으로든 + # 전부든** 채운다 → 채워진 만큼만 판정이 가능해진다(확보량은 조회 계층 로그 참조. + # 개장 직후 발화를 단언할 수 없다 — _fetch_candles_impl docstring 의 미검증 전제). + # 세션 경계 비교(거래량 게이트)는 day_trading_strategy 쪽에서 막는다. candles_5m = aggregate_to_5min(candles) if len(candles_5m) < 5: return None - vwap = calculate_vwap(candles) + # VWAP: 당일 누적 (세션 앵커) + vwap = calculate_vwap(candles_today) orb = day_trading_state.get_orb(stock_code) - current_price = float(candles_5m[-1].close) if candles_5m else 0.0 + # 현재가: 당일 마지막 1분봉 종가 (형성 중 5분봉 종가보다 최신) + current_price = float(candles_today[-1].close) if current_price <= 0: return None # 신호 체크 (ORB → VWAP → RSI+BB 순) signal = None if orb: - signal = check_orb_signal(stock_code, stock_name, candles, orb, current_price) + # ORB: 당일 1분봉 — 직전 1분 거래량과 갭 판정이 당일 기준이어야 한다 + signal = check_orb_signal(stock_code, stock_name, candles_today, orb, current_price) # [P1] 점심시간(11:30~13:00)에는 VWAP/RSI_BB 진입 제한 — 유동성 저하 if not is_lunch_break: if not signal and vwap: diff --git a/backend/app/services/daytrading/exit_service.py b/backend/app/services/daytrading/exit_service.py index 5c6b377..3542c1c 100644 --- a/backend/app/services/daytrading/exit_service.py +++ b/backend/app/services/daytrading/exit_service.py @@ -471,6 +471,11 @@ async def _finalize_exit_impl( f"{profit_loss:+.0f}원 (전일 누적={_stale_realized_pnl:+.0f}원)" ) + # [F9 2026-09-28] 일일 안전장치(daily_pnl/loss_streak_today/closed_today) 영속화. + # 값이 바뀐 직후에 저장한다 — 재기동해도 일일 -3% 한도·3연패 쿨다운·당일 + # 재진입 차단이 풀리지 않게 한다(락 밖에서 호출: Redis I/O 로 락을 잡지 않는다). + await day_trading_state.save_daily_state() + # [P0] loss_tracker 연동 — 전량 매도 시 연속 손실 추적 if is_full_exit: is_loss = profit_loss < 0 @@ -612,6 +617,8 @@ async def close_stale_positions() -> list[dict]: # 매수하면 전일 포지션이 되살아난다. async with day_trading_state.trade_lock: day_trading_state.closed_today.add(stock_code) + # [F9] closed_today 변경분 영속화 — 재기동 후에도 당일 재진입을 막는다 + await day_trading_state.save_daily_state() # [M1] 전일 손익은 당일 daily_pnl / loss_streak_today 에 가산하지 않는다. result = await _execute_exit_stale( diff --git a/backend/app/services/daytrading/position_manager.py b/backend/app/services/daytrading/position_manager.py index 018ca9c..7d0c461 100644 --- a/backend/app/services/daytrading/position_manager.py +++ b/backend/app/services/daytrading/position_manager.py @@ -18,7 +18,9 @@ CLAUDE.md 3.1: 실시간 레이어에서 DB I/O 절대 금지. 주의사항: - [동시성] positions, daily_pnl, closed_today 수정 시 반드시 trade_lock 획득 - - [영속성] 앱 재시작 시 인메모리 상태가 유실됨 (DB 복원 메커니즘 없음) + - [영속성] 앱 재시작 시 restore_from_db()/load_orb_cache() 로 복원한다. + 복원 직전에 반드시 begin_restore() 를 호출할 것 — 호출하지 않으면 + 첫 reset_if_new_day() 가 복원분을 전부 지운다(2026-09-28 수정). - [단일 프로세스] 다중 워커 환경에서는 상태 불일치 발생 가능 """ import asyncio @@ -114,6 +116,29 @@ class DayTradingState: return True return False + def begin_restore(self) -> bool: + """기동 시 복원(load_orb_cache/restore_from_db) **직전에 1회** 호출. + + [2026-09-28 재기동 내구성 수정] + 새 프로세스는 ``last_reset_date is None`` 이고 복원 경로 3개 + (load_orb_cache / restore_from_db / close_stale_positions) 중 어느 것도 + 이 필드를 설정하지 않았다. 그래서 스케줄러 첫 루프의 + ``reset_if_new_day()`` 가 무조건 참이 되어 **방금 복원한 + positions/orb_cache 를 전부 지웠다**(복원 성공 로그는 그대로 출력되므로 + 조용히 실패). 손절·트레일링·시간손절·15:20 EOD 청산이 인메모리 + positions 만 순회하므로 재기동한 당일의 포지션이 고아가 됐다. + + 복원 **전에** 날짜 경계를 확정하면 두 불변식을 동시에 만족한다: + 1) 같은 날 첫 루프의 reset 은 no-op → 복원분 생존 + 2) 자정을 넘기면 ``last_reset_date != today`` → 정상 초기화되어 + 전일 closed_today / daily_pnl / loss_streak_today 가 새지 않는다 + (§7 일일 -3% 한도·3연패 쿨다운·재진입 차단 보호) + + Returns: + bool: 날짜 경계 확정 과정에서 초기화가 수행되었으면 True + """ + return self.reset_if_new_day() + def add_position(self, stock_code: str, data: dict) -> None: """포지션 추가. @@ -282,6 +307,100 @@ class DayTradingState: logger.warning(f"[DayTrading] ORB 캐시 복원 실패: {e}") return 0 + async def save_daily_state(self) -> bool: + """[F9 2026-09-28] 일일 안전장치 3종을 Redis 에 저장 (당일 키, 자정까지 TTL). + + ``daily_pnl`` / ``loss_streak_today`` / ``closed_today`` 는 어디에도 + 영속화되지 않아 **장중 재기동 시 전부 0/빈 집합으로 돌아갔다.** 이것은 단순 + 상태 유실이 아니라 **안전장치가 풀리는 방향**이다: + - ``daily_pnl=0`` → §7 일일 -3% 손실한도(DAY_TRADE_DAILY_LOSS_LIMIT)가 + 리셋되어, 이미 한도를 맞고 중단됐어야 할 날에 매매가 재개된다 + - ``loss_streak_today=0`` → 당일 3연패 쿨다운이 풀린다 + - ``closed_today=∅`` → 손절로 나온 종목을 같은 날 다시 매수한다 + + 저장 포맷에 ``date`` 를 실어 ``load_daily_state()`` 가 **다른 날짜면 버린다** + (전일 값이 다음 날로 새면 F1 불변식 ②를 정면으로 깨뜨린다). + ORB 캐시(save_orb_cache)와 동일한 Redis 경로를 그대로 확장한 것이다. + """ + try: + from app.core.redis import setex_json + today = _today_kst() + payload = { + "date": today.isoformat(), + "daily_pnl": float(self.daily_pnl), + "loss_streak_today": int(self.loss_streak_today), + "closed_today": sorted(self.closed_today), + # [C2 2026-09-28] 손실률 분모. 이 값이 0 이면 + # is_daily_loss_exceeded() 가 total_assets<=0 에서 **무조건 False** 라 + # 재기동 직후 첫 scan_and_enter() 가 일일 -3% 한도를 통과해버린다 + # (scan_and_enter 는 잔고 조회 전에 이 필드를 읽는다). + "last_total_assets": float(self.last_total_assets), + } + ok = await setex_json(f"daytrading:daily:{today}", 86400, payload) + if not ok: + logger.warning( + "[DayTrading] ⚠️ 일일 안전장치 저장 실패(Redis) — 재기동 시 " + "일일 손실한도·연패 쿨다운·재진입 차단이 초기화된다" + ) + return ok + except Exception as e: + logger.warning(f"[DayTrading] ⚠️ 일일 안전장치 저장 실패: {e}") + return False + + async def load_daily_state(self) -> bool: + """[F9] Redis 에서 당일 안전장치 3종을 복원. 복원했으면 True. + + **날짜 게이트**: 저장된 ``date`` 가 오늘이 아니면 복원하지 않고 버린다. + 키 자체에도 날짜가 들어 있지만(자정 TTL), 페이로드 날짜를 한 번 더 확인해 + 키 충돌·시계 오차·수동 주입으로 전일 값이 새는 경로를 이중으로 막는다. + + 복원값은 현재 인메모리 값과 **병합하지 않고 더 보수적인 쪽을 취한다** + (손익은 합산이 아니라 저장값 채택, closed_today 는 합집합, 연패는 최대값) + — 재기동 직후 인메모리는 비어 있는 것이 정상이고, 만약 비어 있지 않다면 + 안전장치를 약화시키지 않는 방향을 골라야 한다. + """ + try: + from app.core.redis import get_json + today = _today_kst() + data = await get_json(f"daytrading:daily:{today}") + if not data: + return False + saved_date = str(data.get("date") or "") + if saved_date != today.isoformat(): + logger.warning( + f"[DayTrading] 일일 안전장치 복원 건너뜀: 저장 날짜 {saved_date!r} " + f"≠ 오늘 {today.isoformat()} — 전일 값 누수 방지(폐기)" + ) + return False + + _pnl = float(data.get("daily_pnl", 0) or 0) + _streak = int(data.get("loss_streak_today", 0) or 0) + _closed = {str(c) for c in (data.get("closed_today") or []) if c} + _assets = float(data.get("last_total_assets", 0) or 0) + + self.daily_pnl = _pnl if not self.daily_pnl else min(self.daily_pnl, _pnl) + self.loss_streak_today = max(self.loss_streak_today, _streak) + self.closed_today |= _closed + # [C2] 분모는 **작은 쪽이 보수적**이다: pnl<0 일 때 (pnl/assets) 가 더 음수가 + # 되어 -3% 한도가 더 빨리 걸린다. 단 0/음수는 게이트를 통째로 무력화하므로 + # 후보에서 제외하고, 양수가 하나도 없으면 현재값(0.0)을 그대로 둔다. + _cands = [v for v in (self.last_total_assets, _assets) if v > 0] + if _cands: + self.last_total_assets = min(_cands) + + logger.info( + f"[DayTrading] 일일 안전장치 복원: 실현손익={self.daily_pnl:+,.0f}원, " + f"연패={self.loss_streak_today}, 당일청산={len(self.closed_today)}종목, " + f"총자산기준={self.last_total_assets:,.0f}원" + ) + return True + except Exception as e: + logger.warning( + f"[DayTrading] ⚠️ 일일 안전장치 복원 실패: {e} — 일일 손실한도·연패 " + f"쿨다운·재진입 차단이 초기화된 상태로 시작한다" + ) + return False + def set_orb(self, stock_code: str, orb: Any) -> None: """ORB(시초가 레인지) 데이터 저장. diff --git a/backend/app/services/daytrading/scheduler.py b/backend/app/services/daytrading/scheduler.py index e7f75ed..1dc4eee 100644 --- a/backend/app/services/daytrading/scheduler.py +++ b/backend/app/services/daytrading/scheduler.py @@ -56,6 +56,11 @@ async def _guard(coro, step: str, timeout: int = STEP_TIMEOUT): return None +# [M1 2026-09-28] 장중 기동 시 ORB 소급수집 지연 실행 플래그. +# 기동 시퀀스에서 바로 await 하면 첫 청산 체크가 수 분 밀린다 → 루프 안에서 +# 청산 체크 뒤에 1회만 실행한다(§8.1-11 청산 우선). +_orb_backfill_pending: bool = False + # watchlist 자동 갱신 타임스탬프 (epoch seconds) # 30분(1800초)마다 stock_analysis에서 재조회 _last_wl_sync: float = 0.0 @@ -74,26 +79,53 @@ async def daytrading_scheduler() -> None: - 엔트리 윈도우: 진입 + 청산 동시 처리 - 기타: 보유 포지션 청산 체크만 """ + global _orb_backfill_pending await asyncio.sleep(5) logger.info("⚡ [DayTrading] Scheduler started (every 30s)") - # 재기동 시 당일 ORB 캐시 복원 — 없으면 그날 ORB 전략이 통째로 불가능해진다 + # ── 재기동 복원 시퀀스 (순서가 안전에 직결) ──────────────────────────── + # [F1 2026-09-28] begin_restore() 를 **복원보다 먼저** 호출한다. + # 새 프로세스는 last_reset_date is None 이므로, 먼저 날짜 경계를 확정하지 않으면 + # 첫 루프의 reset_if_new_day() 가 방금 복원한 positions/orb_cache 를 전부 지운다 + # (복원 성공 로그는 그대로 찍혀 조용히 실패 → 손절·트레일링·시간손절·15:20 EOD + # 청산이 전부 미적용, ORB 당일 영구 0건). 자정 경과 시 초기화는 그대로 동작한다. + day_trading_state.begin_restore() + + # [F9 2026-09-28] 일일 안전장치(일일 -3% 한도·3연패 쿨다운·당일 재진입 차단) 복원. + # begin_restore() 로 날짜 경계를 확정한 **뒤**에 복원해야 한다(순서가 뒤바뀌면 + # 복원값이 곧바로 초기화된다). 저장 페이로드의 날짜가 오늘이 아니면 버려진다. + daily_restored = False try: - await day_trading_state.load_orb_cache() + daily_restored = await day_trading_state.load_daily_state() + except Exception as e: + logger.warning(f"[DayTrading] 일일 안전장치 복원 실패: {e}") + if not daily_restored: + logger.warning( + "[DayTrading] ⚠️ 일일 안전장치 복원분 없음 — 당일 실현손익 0 / 연패 0 / " + "당일청산 0종목 으로 시작한다(일일 손실한도·연패 쿨다운·재진입 차단 초기화). " + "당일 첫 기동이면 정상이다." + ) + + # 재기동 시 당일 ORB 캐시 복원 — 없으면 그날 ORB 전략이 통째로 불가능해진다 + orb_restored = 0 + try: + orb_restored = await day_trading_state.load_orb_cache() or 0 except Exception as e: logger.warning(f"[DayTrading] ORB 캐시 복원 실패: {e}") # 앱 재시작 시 DB에서 당일 데이트레이딩 포지션 복원 (CRITICAL 버그 수정) + restored = 0 try: # [M9 / CLAUDE.md §3.1 예외 — 기동 시 1회, 읽기 전용 복원] # restore_from_db()는 SELECT 만 수행하며 commit() 이 없다. 쓰기는 db_worker 경유. # 세션 수명을 복원 쿼리로 한정하고, 주문이 발생하는 stale 청산은 블록 밖에서 수행한다. from app.core.database import AsyncSessionLocal async with AsyncSessionLocal() as db: - restored = await day_trading_state.restore_from_db(db) + restored = await day_trading_state.restore_from_db(db) or 0 if restored: logger.info(f"[DayTrading] 시작 시 {restored}개 포지션 복원됨") # 전일 미청산 포지션은 기동 직후(장중이면) 즉시 청산 — EOD 누락 안전장치 + # (전일 진입분은 복원된 채로 두어 close_stale_positions 가 잡는다 — §7) try: from app.services.daytrading.exit_service import close_stale_positions stale = await close_stale_positions() @@ -104,6 +136,41 @@ async def daytrading_scheduler() -> None: except Exception as e: logger.error(f"[DayTrading] 시작 시 포지션 복원 실패: {e}", exc_info=True) + # [F5] 복원 성공 로그만으로는 "살아남았는지"를 알 수 없었다 → 확정 개수를 찍는다. + _alive_pos = day_trading_state.position_count() + _alive_orb = len(day_trading_state.orb_cache) + logger.info( + f"[DayTrading] 복원 확정: 포지션 {restored}개 → {_alive_pos}개 / " + f"ORB {orb_restored}개 → {_alive_orb}개 / 일일안전장치=" + f"{'복원' if daily_restored else '없음'}" + f"(손익={day_trading_state.daily_pnl:+,.0f}, 연패={day_trading_state.loss_streak_today}, " + f"청산={len(day_trading_state.closed_today)}종목) " + f"(reset_date={day_trading_state.last_reset_date})" + ) + if (restored and not _alive_pos) or (orb_restored and not _alive_orb): + logger.error( + "[DayTrading] 🚨 복원분이 유실됐다 — 날짜 경계 확정(begin_restore) 실패 의심. " + "손절·EOD 청산이 인메모리 positions 만 순회하므로 포지션이 고아가 된다." + ) + + # [F2] 09:30 이후 장중 기동이면 ORB 수집 호출부가 없어 당일 ORB 가 영구 0건이었다. + # [M1 2026-09-28] 단 여기서 await 하지 않는다. 소급수집은 최대 300초를 점유하고 + # 그 앞의 restore_from_db + close_stale_positions 까지 더하면 첫 + # check_and_exit_positions() 까지 수 분이 걸린다 — 손절·트레일링·시간손절이 전부 + # 그 루프 안에 있어 §8.1-11 "청산은 항상 매수보다 우선"을 위반한다. + # → 플래그만 세우고, 루프의 **첫 청산 체크 뒤**에 1회 실행한다. + if settings.DAY_TRADE_ENABLED and is_market_open() and not is_opening_range_period(): + if not day_trading_state.orb_cache: + _orb_backfill_pending = True + logger.info( + "[DayTrading] 장중 기동 — ORB 소급수집을 첫 청산 체크 뒤로 지연 예약 " + "(청산 우선)" + ) + else: + logger.info( + f"[DayTrading] 장중 기동 — ORB 캐시 {_alive_orb}개 보유, 소급수집 생략" + ) + try: while True: try: @@ -157,6 +224,8 @@ async def daytrading_scheduler() -> None: except Exception as e: logger.warning(f"[DayTrading] watchlist 자동설정 실패: {e}") await _guard(collect_opening_ranges(), "ORB 수집", 300) + # 정규 수집 구간이면 소급수집 예약은 불필요 + _orb_backfill_pending = False await asyncio.sleep(30) continue @@ -184,6 +253,19 @@ async def daytrading_scheduler() -> None: if exits: logger.info(f"[DayTrading] 청산: {len(exits)}건") + # [M1] 장중 기동 ORB 소급수집 — 청산 체크를 먼저 끝낸 뒤 1회만 + if _orb_backfill_pending: + _orb_backfill_pending = False + try: + from app.services.daytrading.entry_service import ( + backfill_opening_ranges, + ) + await _guard(backfill_opening_ranges(), "ORB 소급수집", 300) + except Exception as e: + logger.error( + f"[DayTrading] 장중 기동 ORB 소급수집 실패: {e}", exc_info=True + ) + # 진입 (MDD 허용 시만) if short_term_ok: entries = await _guard(scan_and_enter(), "진입 스캔") diff --git a/backend/app/services/db_worker.py b/backend/app/services/db_worker.py index c92172e..50b599f 100644 --- a/backend/app/services/db_worker.py +++ b/backend/app/services/db_worker.py @@ -30,6 +30,7 @@ Supported task types: import asyncio import json import logging +import time import uuid from datetime import date, datetime from pathlib import Path @@ -116,6 +117,163 @@ def _apply_strategy_name_filter(query, data: dict, column): DLQ_DIR = Path.home() / ".cache" / "auto-stock" / "dlq" +# 종료 drain 시간 예산(초). 초과분은 커밋을 포기하고 DLQ 로 보관한다. +DRAIN_DEADLINE_SECONDS = 20 +# DLQ 재처리 완료 파일 이동 위치 — 재처리 무한루프 방지 +DLQ_PROCESSED_DIR = DLQ_DIR / "processed" +# 한 번의 기동에서 재큐잉할 최대 DLQ 파일 수 (큐 폭주 방지) +DLQ_REPLAY_MAX_FILES = 500 + + +# [M5 2026-09-28] DLQ 재처리 시 타입 복원. +# _save_to_dlq 는 json.dumps(..., default=str) 를 쓰므로 Decimal → "1234.5", +# datetime/date → ISO 문자열로 **열화**된다. 그 dict 를 그대로 큐에 되돌리면 +# `TradeHistory(**data)` 같은 ORM 대입에서 asyncpg 가 DateTime 컬럼의 str 을 거부해 +# 3회 재시도 후 **새 DLQ 파일로 다시 쌓인다**(기동마다 증식). 재큐잉 전에 모델 컬럼 +# 타입을 보고 되돌린다. +_DLQ_TASK_MODELS: dict[str, str] = { + "insert_trade_history": "TradeHistory", + "update_trade_history": "TradeHistory", + "update_trade_history_by_order_id": "TradeHistory", + "upsert_portfolio": "Portfolio", + "upsert_active_trade": "ActiveTrade", + "close_active_trade": "ActiveTrade", + "upsert_stock_analysis": "StockAnalysis", + "bulk_upsert_stock_analysis": "StockAnalysis", + "insert_trade_journal": "TradeJournal", + "upsert_stock_ban": "StockBan", + "upsert_stock_ban_v2": "StockBanV2", + "insert_news": "News", +} + + +def _coerce_value_for_column(value, col_type): + """컬럼 타입에 맞춰 문자열을 원래 타입으로 되돌린다. 실패 시 원본 그대로.""" + if not isinstance(value, str) or not value: + return value + try: + from sqlalchemy import DateTime, Date, Numeric, Float, Integer, Boolean + if isinstance(col_type, DateTime): + return datetime.fromisoformat(value) + if isinstance(col_type, Date): + return date.fromisoformat(value) + if isinstance(col_type, Numeric) and not isinstance(col_type, Float): + from decimal import Decimal + return Decimal(value) + if isinstance(col_type, Float): + return float(value) + if isinstance(col_type, Integer): + return int(value) + if isinstance(col_type, Boolean): + return value.lower() in ("true", "1", "yes") + except (ValueError, TypeError, ArithmeticError): + # 되돌리지 못하면 원본을 유지 — 핸들러가 판단하게 한다(조용히 버리지 않는다) + logger.warning("[DLQ] 타입 복원 실패 — 원본 유지: %r (%s)", value, col_type) + return value + + +def _restore_dlq_task_types(task: dict) -> dict: + """[M5] DLQ 파일에서 읽은 task 의 payload 타입을 모델 스키마 기준으로 복원.""" + model_name = _DLQ_TASK_MODELS.get(task.get("type", "")) + if not model_name: + return task + try: + from app.models import models as _m + model = getattr(_m, model_name, None) + if model is None: + return task + cols = {c.key: c.type for c in model.__table__.columns} + except Exception as e: + logger.warning("[DLQ] 모델 스키마 조회 실패 (%s): %s", model_name, e) + return task + + def _fix(d): + if not isinstance(d, dict): + return d + return { + k: (_coerce_value_for_column(v, cols[k]) if k in cols else v) + for k, v in d.items() + } + + data = task.get("data") + if isinstance(data, list): + task["data"] = [_fix(row) for row in data] + elif isinstance(data, dict): + task["data"] = _fix(data) + # update 계열은 갱신 필드를 별도 키에 담는다 + if isinstance(task.get("updates"), dict): + task["updates"] = _fix(task["updates"]) + return task + + +async def replay_dlq() -> int: + """기동 시 DLQ 파일을 읽어 db_task_queue 에 재큐잉. 재큐잉한 건수 반환. + + [F4 2026-09-28] DLQ 는 지금까지 **쓰기 전용**이었다(읽는 코드 0건). 3회 재시도 + 실패분과 종료 drain 미커밋분이 파일로만 남아 유실이 영구화되고, 복구 수단은 + 수동 psql 뿐이었다. + + CLAUDE.md §3.2 에 따라 **직접 DB 쓰기를 하지 않고 반드시 큐를 경유**한다. + 파일은 재큐잉 **전에** ``processed/`` 로 옮긴다 — 옮긴 뒤 큐 투입이 실패해도 + 파일은 보존되고(수동 복구 가능), 다음 기동에서 같은 태스크를 두 번 큐에 넣지 + 않는다(insert_trade_history 는 멱등이 아니므로 중복이 더 위험하다). + + 손상된 JSON 은 건너뛰고 warning 을 남긴다(파일은 그대로 두어 사람이 본다). + """ + def _list_files() -> list: + if not DLQ_DIR.exists(): + return [] + return sorted(f for f in DLQ_DIR.glob("*.json") if f.is_file()) + + files = await asyncio.to_thread(_list_files) + if not files: + return 0 + + if len(files) > DLQ_REPLAY_MAX_FILES: + logger.error( + "❌ [DLQ] 파일 %d건이 상한 %d건을 초과 — 앞의 %d건만 재처리하고 나머지는 남긴다", + len(files), DLQ_REPLAY_MAX_FILES, DLQ_REPLAY_MAX_FILES, + ) + files = files[:DLQ_REPLAY_MAX_FILES] + + logger.warning("[DLQ] 재처리 시작: %d건", len(files)) + requeued = 0 + skipped = 0 + for f in files: + try: + raw = await asyncio.to_thread(f.read_text, encoding="utf-8") + entry = json.loads(raw) + task = entry.get("task") if isinstance(entry, dict) else None + if not isinstance(task, dict) or not task.get("type"): + logger.warning("[DLQ] 건너뜀(형식 불량): %s", f.name) + skipped += 1 + continue + + def _move(path=f): + DLQ_PROCESSED_DIR.mkdir(parents=True, exist_ok=True) + path.rename(DLQ_PROCESSED_DIR / path.name) + + await asyncio.to_thread(_move) + # 재시도 카운터를 초기화해 재큐잉분도 3회 재시도를 받게 한다 + task.pop("_retry_count", None) + # [M5] default=str 로 열화된 Decimal/datetime/date 를 되돌린다 + task = _restore_dlq_task_types(task) + await enqueue_db_write(task) + requeued += 1 + except json.JSONDecodeError as e: + logger.warning("[DLQ] 건너뜀(JSON 손상): %s — %s", f.name, e) + skipped += 1 + except Exception as e: + logger.error("❌ [DLQ] 재처리 실패: %s — %s", f.name, e, exc_info=True) + skipped += 1 + + logger.warning( + "[DLQ] 재처리 완료: 재큐잉 %d건 / 건너뜀 %d건 (처리분은 %s 로 이동)", + requeued, skipped, DLQ_PROCESSED_DIR, + ) + return requeued + + async def _save_to_dlq(task: dict, error: str) -> None: """3회 재시도 실패한 task를 DLQ 파일로 저장. @@ -381,6 +539,167 @@ async def enqueue_trade_journal(trade, content: str) -> None: }) +# ───────────────────────────────────────────────────────────────────────────── +# 포지션 추적 계열 핸들러 (공통 종단) +# +# [F3 2026-09-28] 종료 drain 이 insert_trade_history 만 커밋하고 +# upsert_portfolio / upsert_active_trade / close_active_trade 는 DLQ 로 보냈다 +# → "체결 기록은 남는데 포지션 추적 행은 사라지는" 비대칭이 설계에 내장돼 있었다 +# (재기동 시 restore_from_db 가 복원할 행이 없어 포지션이 고아가 된다). +# 정상 경로와 drain 이 **같은 구현**을 쓰도록 여기로 추출한다. 두 곳에 복제하면 +# 한쪽만 바뀌어 더 나쁜 불일치가 된다(CLAUDE.md §3.3 과 동일한 취지). +# ───────────────────────────────────────────────────────────────────────────── + + +async def _handle_upsert_portfolio(session, data: dict) -> None: + """Portfolio UPSERT — 정상 경로와 종료 drain 이 공유하는 단일 구현.""" + from app.models.models import Portfolio + + stock_code = data.get("stock_code") + user_id = data.get("user_id") + query = select(Portfolio).where(Portfolio.stock_code == stock_code) + if user_id is not None: + query = query.where(Portfolio.user_id == user_id) + result = await session.execute(query) + record = result.scalar_one_or_none() + + if record is None: + record = Portfolio(**data) + session.add(record) + else: + for key, val in data.items(): + if key != "stock_code" and val is not None: + setattr(record, key, val) + + await session.commit() + logger.debug( + "✅ [Worker] Portfolio upsert: %s (strategy=%s)", + stock_code, + data.get("strategy_type", "swing"), + ) + + +async def _handle_upsert_active_trade(session, data: dict) -> None: + """ActiveTrade UPSERT — 정상 경로와 종료 drain 이 공유하는 단일 구현.""" + from app.models.models import ActiveTrade + + stock_code = data.get("stock_code") + strategy_name = data.get("strategy_name") + user_id = data.get("user_id") + + query = select(ActiveTrade).where( + ActiveTrade.stock_code == stock_code, + ActiveTrade.status == "active", + ) + # [§8.5 항목6] strategy_name(동등) / strategy_names(IN) / + # strategy_name_not(NOT IN + NULL 포함) — 새 키 미사용 시 무변경. + query = _apply_strategy_name_filter(query, data, ActiveTrade.strategy_name) + if user_id is not None: + query = query.where(ActiveTrade.user_id == user_id) + # [2026-09-27 §8.5 후속] legacy 경로와 동일하게 '가장 최근 행' 우선. + # scalar_one_or_none() 은 한 사용자가 같은 종목에 비-DT active 행을 + # 2건 이상 보유하면 MultipleResultsFound → 3회 재시도 후 DLQ 로 빠진다. + # (멀티테넌트 플래그 ON 전제조건) + query = query.order_by(ActiveTrade.id.desc()).limit(1) + result = await session.execute(query) + record = result.scalars().first() + else: + # legacy(user_id=None) 경로: 사용자 다중화로 동일 종목 active + # 행이 2건 이상 존재할 수 있어 scalar_one_or_none() 이 + # MultipleResultsFound 를 던질 수 있음 → 가장 최근 행 우선. + query = query.order_by(ActiveTrade.id.desc()).limit(1) + result = await session.execute(query) + record = result.scalars().first() + + if record is None: + # remaining_pct_multiply·전략명 필터 키는 신규 생성 시 무시 + create_data = { + k: v for k, v in data.items() + if k not in _ACTIVE_TRADE_META_KEYS + } + # 필수 컬럼(quantity/entry_price) 없이 신규 생성 요청이 오면 + # IntegrityError로 3회 재시도만 낭비 → 경고 후 스킵 + _missing = [k for k in ("quantity", "entry_price") if create_data.get(k) is None] + if _missing: + logger.warning( + "⚠️ [Worker] ActiveTrade upsert 스킵: %s active 행 없음 + 필수 키 누락 %s (keys=%s)", + stock_code, _missing, sorted(create_data.keys()), + ) + return + record = ActiveTrade(**create_data) + session.add(record) + else: + # remaining_pct_multiply: 기존 remaining_pct에 비율을 곱함 + # (MDD 축소 시 기존 분할매도 이력 반영) + # data.get() 사용 — pop()으로 task dict를 파괴적으로 변경하면 + # 재시도 시 이 키가 사라져 no-op으로 성공 처리되는 버그 방지 + multiply_ratio = data.get("remaining_pct_multiply") + if multiply_ratio is not None: + current_remaining = getattr(record, "remaining_pct", 1.0) or 1.0 + record.remaining_pct = float(current_remaining) * float(multiply_ratio) + for key, val in data.items(): + # [C1 2026-09-28] 반드시 continue — return 이면 'stock_code' 가 모든 payload 의 + # 첫 키라서 기존 active 행에 대한 모든 upsert 가 첫 이터레이션에서 빠져나가 + # commit() 에 도달하지 못한다(예외가 없어 성공 처리되고 DLQ 에도 안 남는다). + # 파급: 분할매도 잔량 갱신·remaining_pct_multiply·highest_since_entry 트레일링 + # 갱신이 전부 무효 → 2026-09-23 '4주 보유 중 7주 매도' 재발 경로. + if key == "stock_code" or key in _ACTIVE_TRADE_META_KEYS: + continue + if val is not None: + setattr(record, key, val) + + await session.commit() + logger.debug( + "✅ [Worker] ActiveTrade upsert: %s (strategy=%s)", + stock_code, + strategy_name, + ) + + +async def _handle_close_active_trade(session, data: dict) -> None: + """ActiveTrade close — 정상 경로와 종료 drain 이 공유하는 단일 구현.""" + from app.models.models import ActiveTrade + from datetime import datetime as _dt + + stock_code = data.get("stock_code") + strategy_name = data.get("strategy_name") + user_id = data.get("user_id") + + query = select(ActiveTrade).where( + ActiveTrade.stock_code == stock_code, + ActiveTrade.status == "active", + ) + # [§8.5 항목6] strategy_name(동등) / strategy_names(IN) / + # strategy_name_not(NOT IN + NULL 포함) — 새 키 미사용 시 무변경. + query = _apply_strategy_name_filter(query, data, ActiveTrade.strategy_name) + if user_id is not None: + query = query.where(ActiveTrade.user_id == user_id) + # [2026-09-27 §8.5 후속] legacy 경로와 동일하게 '가장 최근 행' 우선. + # scalar_one_or_none() 은 한 사용자가 같은 종목에 비-DT active 행을 + # 2건 이상 보유하면 MultipleResultsFound → 3회 재시도 후 DLQ 로 빠진다. + # (멀티테넌트 플래그 ON 전제조건) + query = query.order_by(ActiveTrade.id.desc()).limit(1) + result = await session.execute(query) + record = result.scalars().first() + else: + # legacy(user_id=None) 경로: 사용자 다중화로 동일 종목 active + # 행이 2건 이상 존재할 수 있어 scalar_one_or_none() 이 + # MultipleResultsFound 를 던질 수 있음 → 가장 최근 행 우선. + query = query.order_by(ActiveTrade.id.desc()).limit(1) + result = await session.execute(query) + record = result.scalars().first() + + if record: + record.status = "closed" + record.updated_at = now_kst() + await session.commit() + logger.debug( + "✅ [Worker] ActiveTrade closed: %s (strategy=%s)", + stock_code, + strategy_name, + ) + + # ───────────────────────────────────────────────────────────────────────────── # 워커 본체 # ───────────────────────────────────────────────────────────────────────────── @@ -579,144 +898,15 @@ async def db_writer_worker() -> None: # ── 6. Portfolio UPSERT ──────────────────────────────────────── elif task_type == "upsert_portfolio": - from app.models.models import Portfolio - - stock_code = data.get("stock_code") - user_id = data.get("user_id") - query = select(Portfolio).where(Portfolio.stock_code == stock_code) - if user_id is not None: - query = query.where(Portfolio.user_id == user_id) - result = await session.execute(query) - record = result.scalar_one_or_none() - - if record is None: - record = Portfolio(**data) - session.add(record) - else: - for key, val in data.items(): - if key != "stock_code" and val is not None: - setattr(record, key, val) - - await session.commit() - logger.debug( - "✅ [Worker] Portfolio upsert: %s (strategy=%s)", - stock_code, - data.get("strategy_type", "swing"), - ) + await _handle_upsert_portfolio(session, data) # ── 7. ActiveTrade UPSERT ────────────────────────────────────── elif task_type == "upsert_active_trade": - from app.models.models import ActiveTrade - - stock_code = data.get("stock_code") - strategy_name = data.get("strategy_name") - user_id = data.get("user_id") - - query = select(ActiveTrade).where( - ActiveTrade.stock_code == stock_code, - ActiveTrade.status == "active", - ) - # [§8.5 항목6] strategy_name(동등) / strategy_names(IN) / - # strategy_name_not(NOT IN + NULL 포함) — 새 키 미사용 시 무변경. - query = _apply_strategy_name_filter(query, data, ActiveTrade.strategy_name) - if user_id is not None: - query = query.where(ActiveTrade.user_id == user_id) - # [2026-09-27 §8.5 후속] legacy 경로와 동일하게 '가장 최근 행' 우선. - # scalar_one_or_none() 은 한 사용자가 같은 종목에 비-DT active 행을 - # 2건 이상 보유하면 MultipleResultsFound → 3회 재시도 후 DLQ 로 빠진다. - # (멀티테넌트 플래그 ON 전제조건) - query = query.order_by(ActiveTrade.id.desc()).limit(1) - result = await session.execute(query) - record = result.scalars().first() - else: - # legacy(user_id=None) 경로: 사용자 다중화로 동일 종목 active - # 행이 2건 이상 존재할 수 있어 scalar_one_or_none() 이 - # MultipleResultsFound 를 던질 수 있음 → 가장 최근 행 우선. - query = query.order_by(ActiveTrade.id.desc()).limit(1) - result = await session.execute(query) - record = result.scalars().first() - - if record is None: - # remaining_pct_multiply·전략명 필터 키는 신규 생성 시 무시 - create_data = { - k: v for k, v in data.items() - if k not in _ACTIVE_TRADE_META_KEYS - } - # 필수 컬럼(quantity/entry_price) 없이 신규 생성 요청이 오면 - # IntegrityError로 3회 재시도만 낭비 → 경고 후 스킵 - _missing = [k for k in ("quantity", "entry_price") if create_data.get(k) is None] - if _missing: - logger.warning( - "⚠️ [Worker] ActiveTrade upsert 스킵: %s active 행 없음 + 필수 키 누락 %s (keys=%s)", - stock_code, _missing, sorted(create_data.keys()), - ) - continue - record = ActiveTrade(**create_data) - session.add(record) - else: - # remaining_pct_multiply: 기존 remaining_pct에 비율을 곱함 - # (MDD 축소 시 기존 분할매도 이력 반영) - # data.get() 사용 — pop()으로 task dict를 파괴적으로 변경하면 - # 재시도 시 이 키가 사라져 no-op으로 성공 처리되는 버그 방지 - multiply_ratio = data.get("remaining_pct_multiply") - if multiply_ratio is not None: - current_remaining = getattr(record, "remaining_pct", 1.0) or 1.0 - record.remaining_pct = float(current_remaining) * float(multiply_ratio) - for key, val in data.items(): - if key == "stock_code" or key in _ACTIVE_TRADE_META_KEYS: - continue - if val is not None: - setattr(record, key, val) - - await session.commit() - logger.debug( - "✅ [Worker] ActiveTrade upsert: %s (strategy=%s)", - stock_code, - strategy_name, - ) + await _handle_upsert_active_trade(session, data) # ── 8. ActiveTrade close ─────────────────────────────────────── elif task_type == "close_active_trade": - from app.models.models import ActiveTrade - from datetime import datetime as _dt - - stock_code = data.get("stock_code") - strategy_name = data.get("strategy_name") - user_id = data.get("user_id") - - query = select(ActiveTrade).where( - ActiveTrade.stock_code == stock_code, - ActiveTrade.status == "active", - ) - # [§8.5 항목6] strategy_name(동등) / strategy_names(IN) / - # strategy_name_not(NOT IN + NULL 포함) — 새 키 미사용 시 무변경. - query = _apply_strategy_name_filter(query, data, ActiveTrade.strategy_name) - if user_id is not None: - query = query.where(ActiveTrade.user_id == user_id) - # [2026-09-27 §8.5 후속] legacy 경로와 동일하게 '가장 최근 행' 우선. - # scalar_one_or_none() 은 한 사용자가 같은 종목에 비-DT active 행을 - # 2건 이상 보유하면 MultipleResultsFound → 3회 재시도 후 DLQ 로 빠진다. - # (멀티테넌트 플래그 ON 전제조건) - query = query.order_by(ActiveTrade.id.desc()).limit(1) - result = await session.execute(query) - record = result.scalars().first() - else: - # legacy(user_id=None) 경로: 사용자 다중화로 동일 종목 active - # 행이 2건 이상 존재할 수 있어 scalar_one_or_none() 이 - # MultipleResultsFound 를 던질 수 있음 → 가장 최근 행 우선. - query = query.order_by(ActiveTrade.id.desc()).limit(1) - result = await session.execute(query) - record = result.scalars().first() - - if record: - record.status = "closed" - record.updated_at = now_kst() - await session.commit() - logger.debug( - "✅ [Worker] ActiveTrade closed: %s (strategy=%s)", - stock_code, - strategy_name, - ) + await _handle_close_active_trade(session, data) # ── 9. TradeJournal INSERT ──────────────────────────────────── elif task_type == "insert_trade_journal": @@ -735,6 +925,7 @@ async def db_writer_worker() -> None: except asyncio.CancelledError: # 큐 drain: 종료 전 남은 태스크 처리하여 데이터 손실 방지 remaining = 0 + _drain_started = time.monotonic() while not queue.empty(): try: drain_task = queue.get_nowait() @@ -744,10 +935,35 @@ async def db_writer_worker() -> None: try: drain_type = drain_task.get("type", "unknown") drain_data = drain_task.get("data") + # [F3 2026-09-28] 시간 예산 초과분은 커밋을 시도하지 않고 DLQ 로 보낸다. + # 종료가 무한정 늘어지면 컨테이너가 SIGKILL 을 받아 전량 유실된다. + if (time.monotonic() - _drain_started) > DRAIN_DEADLINE_SECONDS: + logger.error( + "❌ [DBWorker] Drain 시간 예산 %.0f초 초과 — DLQ 로 보관 (type=%s)", + DRAIN_DEADLINE_SECONDS, drain_type, + ) + await _save_to_dlq(drain_task, "shutdown_drain_deadline_exceeded") + remaining += 1 + continue async with AsyncSessionLocal() as drain_session: if drain_type == "insert_trade_history": drain_session.add(TradeHistory(**drain_data)) await drain_session.commit() + # [F3 2026-09-28] 포지션 추적 계열도 커밋한다. + # 이전에는 insert_trade_history 만 커밋하고 아래 3종을 DLQ 로 + # 보내서 "체결 기록은 남는데 포지션 추적 행은 사라지는" 비대칭이 + # 있었다 → 재기동 시 restore_from_db 가 복원할 행이 없어 + # 포지션이 고아가 되고 손절·EOD 청산이 적용되지 않았다. + elif drain_type == "upsert_portfolio": + await _handle_upsert_portfolio(drain_session, drain_data) + elif drain_type == "upsert_active_trade": + await _handle_upsert_active_trade(drain_session, drain_data) + elif drain_type == "close_active_trade": + await _handle_close_active_trade(drain_session, drain_data) + elif drain_type == "insert_trade_journal": + from app.models.models import TradeJournal + drain_session.add(TradeJournal(**drain_data)) + await drain_session.commit() elif drain_type in ( "update_trade_history", "update_trade_history_by_order_id", @@ -755,15 +971,18 @@ async def db_writer_worker() -> None: "bulk_upsert_stock_analysis", "upsert_stock_ban", "upsert_stock_ban_v2", - "upsert_portfolio", - "upsert_active_trade", - "close_active_trade", - "insert_trade_journal", ): - # 단순 재큐잉 대신, 중요 태스크만 커밋 시도 - # insert_trade_history 외에는 복잡한 로직이므로 DLQ 저장 + # 분석·밴 계열은 다음 배치/기동 시 재생성되므로 DLQ 보관. + # F4 의 replay_dlq() 가 기동 시 재큐잉한다. + logger.error( + "❌ [DBWorker] Drain 미커밋 → DLQ (type=%s) — 기동 시 replay_dlq() 로 재처리", + drain_type, + ) await _save_to_dlq(drain_task, "shutdown_drain") else: + logger.error( + "❌ [DBWorker] Drain 미지원 type → DLQ (type=%s)", drain_type, + ) await _save_to_dlq(drain_task, "shutdown_drain_unknown_type") remaining += 1 except Exception as drain_err: diff --git a/backend/app/services/korea_investment_api.py b/backend/app/services/korea_investment_api.py index b14ba0a..9bed260 100644 --- a/backend/app/services/korea_investment_api.py +++ b/backend/app/services/korea_investment_api.py @@ -30,6 +30,20 @@ TTL_MINUTE_CHART = 60 # 분봉 캐시 1분 (실시간성 유지) TTL_MINUTE_FULLDAY = 60 # 당일 전체 분봉 캐시 1분 TTL_OVERSEAS_CHART = 300 # 해외 일봉 캐시 5분 +# ── 분봉 페이지네이션 상수 ──────────────────────────────────────────────────── +MINUTE_CHART_MAX_PAGES = 15 # 1회 30봉 × 15 = 450봉 (장 전체 390분 + 여유) +# 전일 분봉 워밍업 **상한**(분). 종료 판정의 기준은 아래 `_WARMUP_BARS_5MIN`(봉 수)이고 +# 이 값은 "아무리 모아도 여기서 멈춘다"는 안전장치다 — 저유동성 종목은 거래 없는 분이 +# 응답에서 빠져 분 수로는 영원히 차지 않기 때문이다. 전략 임계값이 아니다. +MINUTE_CHART_PREV_DAY_MINUTES = 150 +# 워밍업 목표는 **5분봉 개수**다: BB(20)=20봉, RSI(14)=15봉 +# (`day_trading_strategy._bollinger(candles_5min, 20)` / `_rsi(candles_5min, 14)`). +# 당일 5분봉이 k개면 추가로 필요한 것은 (20-k)개이므로 장 중반 이후엔 0이 된다. +MINUTE_CHART_WARMUP_BARS_5MIN = 20 +# 전일 구간 전용 페이지 상한 — 당일 수집과 별도로 더 낮게 잡아 레이트 리밋을 보호한다 +# (한 페이지 30봉이 5분봉 6~30개를 덮으므로 3페이지면 20봉 목표에 충분하다). +MINUTE_CHART_PREV_DAY_MAX_PAGES = 3 + class CircuitBreaker: """KIS API Circuit Breaker — 연속 N회 실패 시 일정 시간 요청 차단. @@ -908,6 +922,14 @@ class KoreaInvestmentAPI: ) -> list: """당일 전체 분봉 데이터 수집 (Redis 캐시 적용, 여러 번 호출 집계) + **당일 봉만 반환한다** — 동작·시그니처 모두 기존과 같다(CLAUDE.md §4). + ORB 레인지·평균분당거래량·VWAP·현재가처럼 **세션 앵커** 기준이 필요한 + 소비지점(`daytrading/entry_service.collect_opening_ranges`, + `daytrading/exit_service`, `api/endpoints/day_trading`)이 이 메서드를 쓴다. + + 전일 봉을 워밍업으로 함께 받아야 하는 경우(5분봉 RSI(14)/BB(20))는 + `get_minute_chart_full_day_v2(..., include_prev_day=True)` 를 쓴다. + 캐시 키: chart:minute_fullday:{stock_code}:{YYYYMMDD_HHMM} - HHMM: 현재 시각의 분 단위 버킷 → 1분마다 갱신 TTL: 1분 @@ -918,8 +940,72 @@ class KoreaInvestmentAPI: Returns: List[dict] — output2 레코드 목록 (오래된 것 → 최신 순) """ + return await self._get_minute_chart_full_day_impl( + stock_code, market_open, include_prev_day=False + ) + + async def get_minute_chart_full_day_v2( + self, + stock_code: str, + market_open: str = "090000", + include_prev_day: bool = False, + prev_day_minutes: int = MINUTE_CHART_PREV_DAY_MINUTES, + ) -> list: + """분봉 수집 — 전일 봉 포함 여부를 **호출부가 선택**한다. + + `include_prev_day=False`(기본)면 `get_minute_chart_full_day()` 와 완전히 같다. + + `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` 을 반환하고 짧은 + 기간으로 대체 계산하지 않는다). + + ⚠️ **미검증 전제**: 이 경로는 "장중에 `FID_INPUT_HOUR_1` 로 **미래 시각** + (예: 전일 14:51)을 주면 KIS 가 전일 데이터를 돌려준다"에 의존한다. 요청 파라미터에 + 날짜 필드가 없어(`get_minute_chart` 참조) 그렇게 밖에 거슬러 갈 수 없는데, + **KIS 가 실제로 그렇게 응답한다는 보장은 코드·테스트에 없다.** 최신 봉을 그대로 + 돌려주면 전부 dedupe 되어 `new_added==0` 으로 즉시 중단되고, 전일 봉은 경계 + 페이지 1장(약 29봉)만 남는다 — 그때는 워밍업이 부분적으로만 채워져 발화 시각이 + 조금 앞당겨지는 수준에 그친다. 확보량은 종료 시 로그로 남으므로 **운영 로그로만 + 확정할 수 있다**. 따라서 "개장 직후부터 발화한다"고 단언하지 말 것. + + ⚠️ 반환 시계열에는 **날짜가 다른 봉이 섞여 있다.** 세션 앵커 지표 + (VWAP·ORB 레인지·평균분당거래량·현재가·당일 거래량·갭)에 그대로 넘기면 + CLAUDE.md §7 마지막 항목이 경고하는 오염이 발생한다. 호출부는 + `entry_service.filter_today_candles()` 로 당일 봉을 분리해 쓸 것. + + Returns: + List[dict] — output2 레코드 목록 (영업일자+시각 오름차순) + """ + return await self._get_minute_chart_full_day_impl( + stock_code, + market_open, + include_prev_day=include_prev_day, + prev_day_minutes=prev_day_minutes, + ) + + async def _get_minute_chart_full_day_impl( + self, + stock_code: str, + market_open: str = "090000", + include_prev_day: bool = False, + prev_day_minutes: int = MINUTE_CHART_PREV_DAY_MINUTES, + ) -> list: + """분봉 수집 공통 구현 — 목킹은 공통 종단인 이 메서드가 아니라 공개 + 메서드(`get_minute_chart_full_day`/`_v2`)에 걸어도 되게 위임 구조를 유지한다 + (CLAUDE.md §4.1). + """ _d = self._quote_delegate() if _d is not None: + # 위임 대상의 **공개 메서드**를 호출한다 — 기존 목킹 지점 보존 + if include_prev_day: + return await _d.get_minute_chart_full_day_v2( + stock_code, market_open, + include_prev_day=True, prev_day_minutes=prev_day_minutes, + ) return await _d.get_minute_chart_full_day(stock_code, market_open) from datetime import datetime import pytz @@ -928,7 +1014,9 @@ class KoreaInvestmentAPI: today_str = now_dt.strftime("%Y%m%d") now_bucket = now_dt.strftime("%H%M") # 1분 단위 버킷 - cache_key = f"chart:minute_fullday:{stock_code}:{today_str}_{now_bucket}" + # 전일 포함 응답은 내용이 다르므로 캐시 키를 분리한다 (섞이면 세션 앵커 오염) + _variant = f":prev{prev_day_minutes}" if include_prev_day else "" + cache_key = f"chart:minute_fullday:{stock_code}:{today_str}_{now_bucket}{_variant}" # ── 1. 캐시 히트 확인 ─────────────────────────────────────────────── cached = await _cache_get(cache_key) @@ -941,8 +1029,13 @@ class KoreaInvestmentAPI: all_candles: list = [] ref = now_str seen_times: set = set() + prev_day_count = 0 # include_prev_day 일 때 수집한 전일 1분봉 수(상한 판정용) + today_count = 0 # 당일 1분봉 수 + today_buckets: set = set() # 당일 5분봉 버킷 (워밍업 충족 판정) + prev_buckets: set = set() # 전일 5분봉 버킷 + prev_pages = 0 # 전일 구간을 파기 위해 쓴 페이지 수 - for _ in range(15): # 최대 15회 (450분, 여유 있게) + for _ in range(MINUTE_CHART_MAX_PAGES): try: result = await self.get_minute_chart(stock_code, ref_time=ref) except Exception as e: @@ -965,20 +1058,57 @@ class KoreaInvestmentAPI: d = (c.get("stck_bsop_date") or "").strip() if not t: continue - # 영업일자가 있으면 당일이 아닌 캔들은 제외 (없으면 필터 불가 → 유지) - if d and d != today_str: + _is_prev = bool(d) and d != today_str + # 영업일자가 있으면 당일이 아닌 캔들은 제외 (없으면 필터 불가 → 유지). + # include_prev_day=True 는 워밍업 목적으로 이 제외를 끈다. + if _is_prev and not include_prev_day: continue key = f"{d}_{t}" if key not in seen_times: seen_times.add(key) all_candles.append(c) new_added += 1 + if _is_prev: + prev_day_count += 1 + else: + today_count += 1 + # 5분봉 버킷 집계 — 워밍업 종료 판정의 기준(분 수가 아니라 봉 수). + # 거래 없는 분이 응답에서 빠져도 봉 수는 정확히 센다. + if len(t) >= 4 and t[:4].isdigit(): + _bkt = (int(t[:2]) * 60 + int(t[2:4])) // 5 + (prev_buckets if _is_prev else today_buckets).add((d, _bkt)) - times = [c.get("stck_cntg_hour", "999999") for c in candles if c.get("stck_cntg_hour")] - oldest = min(times) if times else "000000" - - if oldest <= market_open or new_added == 0: - break + if include_prev_day: + # 다음 기준 시각은 **영업일자+시각의 최솟값**으로 잡아야 한다. + # 시각만 비교하면 전일 14:51 봉이 당일 09:00 보다 크게 평가돼 + # ref 가 갱신되지 않고(=같은 페이지 재요청) 한 페이지에서 멈춘다. + _keyed = sorted( + ((c.get("stck_bsop_date") or today_str).strip(), + c.get("stck_cntg_hour") or "999999") + for c in candles if c.get("stck_cntg_hour") + ) + oldest_date, oldest = _keyed[0] if _keyed else (today_str, "000000") + _today_done = oldest_date < today_str or oldest <= market_open + # 워밍업 목표 = **부족한 5분봉 개수**. 당일 5분봉이 이미 20개면 0이 되어 + # 전일 봉을 한 장도 더 받지 않는다(10:40 이후). 09:30 엔 6개뿐이라 + # 14개만 더 받으면 끝난다. 분 수 상한·전일 페이지 상한은 저유동성 종목에서 + # 무한 페이징을 막는 안전장치다. + _needed_bars = max(0, MINUTE_CHART_WARMUP_BARS_5MIN - len(today_buckets)) + _warm_enough = ( + len(prev_buckets) >= _needed_bars + or prev_day_count >= prev_day_minutes + or prev_pages >= MINUTE_CHART_PREV_DAY_MAX_PAGES + ) + if (_today_done and _warm_enough) or new_added == 0: + break + if _today_done: + # 다음 요청은 전일 구간을 파러 가는 페이지다 + prev_pages += 1 + else: + times = [c.get("stck_cntg_hour", "999999") for c in candles if c.get("stck_cntg_hour")] + oldest = min(times) if times else "000000" + if oldest <= market_open or new_added == 0: + break ref = oldest await asyncio.sleep(self.min_request_interval + 0.05) @@ -991,6 +1121,26 @@ class KoreaInvestmentAPI: ) ) + # 워밍업 미달은 조용히 지나가면 "왜 여전히 발화하지 않는가"를 추적할 수 없다 + # (지표가 None 이 되어 진입 판정이 그냥 사라진다). + # ⚠️ **이 로그가 "KIS 가 미래 ref_time 에 전일 데이터를 주는가"를 확정하는 유일한 + # 근거다.** 전일 봉이 경계 페이지 1장(약 29봉)에서 멈추면 여기서 드러난다. + if include_prev_day: + _bars = len(today_buckets) + len(prev_buckets) + if _bars < MINUTE_CHART_WARMUP_BARS_5MIN: + logger.warning( + f"[분봉] ⚠️ {stock_code} 워밍업 미달 — 5분봉 {_bars}/" + f"{MINUTE_CHART_WARMUP_BARS_5MIN}봉 " + f"(당일 {len(today_buckets)} + 전일 {len(prev_buckets)}, " + f"전일 1분봉 {prev_day_count}봉/{prev_pages}페이지). " + f"BB(20) 미충족 → RSI_BB 스킵, 15봉 미달이면 VWAP 도 스킵된다" + ) + else: + logger.info( + f"[분봉] {stock_code} 워밍업 5분봉 {_bars}봉 " + f"(당일 {len(today_buckets)} + 전일 {len(prev_buckets)})" + ) + # ── 3. 캐시 저장 ──────────────────────────────────────────────────── if all_candles: await _cache_set(cache_key, TTL_MINUTE_FULLDAY, all_candles) diff --git a/backend/app/services/mdd_manager.py b/backend/app/services/mdd_manager.py index 308b18d..70e47f0 100644 --- a/backend/app/services/mdd_manager.py +++ b/backend/app/services/mdd_manager.py @@ -859,6 +859,7 @@ async def _reflect_reduction( # 예) 잔고 quantity=10, 인메모리 dt_qty=15, 매도 3주 → 잔고 7주인데 인메모리 12주 # → 다음 DT 청산이 12주 매도를 시도해 과다매도/주문거부. # 따라서 (1) DT 우선 차감, (2) 실제 잔고 잔량으로 상한 클램프, (3) 음수 금지. + _dt_daily_state_dirty = False try: from app.services.daytrading.position_manager import day_trading_state async with day_trading_state.trade_lock: @@ -884,6 +885,8 @@ async def _reflect_reduction( _left = min(_left, remaining_qty) if _left <= 0: day_trading_state.remove_position(stock_code) + # remove_position() 이 closed_today 에 추가한다 → 영속화 필요 + _dt_daily_state_dirty = True logger.warning( f"[MDD] DT 포지션 전량 축소: {stock_code} " f"(인메모리 {_dt_now}주 → 0, 매도 {sell_qty}주, 잔고 잔량 {remaining_qty}주)" @@ -896,3 +899,18 @@ async def _reflect_reduction( ) except Exception as e: logger.warning(f"[MDD] DT 인메모리 반영 실패 ({stock_code}): {e}") + + # [F9 2026-09-28] MDD 축소가 DT 포지션을 닫으면 closed_today 가 바뀌므로 영속화한다. + # 이 경로에 저장이 없으면 축소 직후 재기동 시 당일 재진입 차단이 풀려, MDD -10%/-20% + # 구간에서 방금 강제 축소한 종목을 그날 다시 매수할 수 있다. + # - trade_lock **해제 후** 호출 — Redis I/O 로 락을 잡지 않는다(§3.1 실시간 계층 보호) + # - 저장 실패가 축소 자체를 실패시키지 않도록 예외를 삼키되 warning 으로 남긴다 + if _dt_daily_state_dirty: + try: + from app.services.daytrading.position_manager import day_trading_state as _dts_save + await _dts_save.save_daily_state() + except Exception as e: + logger.warning( + f"[MDD] DT 일일 안전장치 저장 실패 ({stock_code}): {e} — 재기동 시 " + f"당일 재진입 차단이 풀릴 수 있다" + ) diff --git a/backend/app/services/portfolio_cache_bridge.py b/backend/app/services/portfolio_cache_bridge.py index 27f69f6..02a3d29 100644 --- a/backend/app/services/portfolio_cache_bridge.py +++ b/backend/app/services/portfolio_cache_bridge.py @@ -63,6 +63,21 @@ async def reflect_buy( # 스윙 8주 보유 중 DT 4주를 사면 행(12주)이 daytrading 이 되어 # get_swing_holdings 에서 빠지고 스윙 8주의 손절·익절이 완전히 멈춘다. # 화면의 DT 그룹은 엔진 포지션(/daytrading/positions)으로 별도 표시한다. + # + # [2026-09-28] 예외 하나: 유입 격리('external') 행에 **스윙** 시스템 매수가 + # 들어오면 swing 으로 승격한다. 승격하지 않으면 새로 산 스윙 수량이 + # get_swing_holdings 에서 빠져 다음 sync 까지 손절·익절이 붙지 않는다. + # DT 매수는 승격하지 않는다 — 엔진이 인메모리로 자기 수량을 관리하고, + # 'daytrading' 으로 바꾸면 격리 수량이 external 표시에서 사라진다. + if (existing.strategy_type or "swing") == "external" and strategy_type == "swing": + existing.strategy_type = "swing" + _promote_to_swing = True + logger.warning( + f"[cache_bridge] ⬆️ 유입 격리 해제(승격): {stock_name}({stock_code}) " + f"external → swing (스윙 시스템 매수 {quantity}주)" + ) + else: + _promote_to_swing = False avg_for_db, qty_for_db = new_avg, new_qty else: cache.holdings.append(Portfolio( @@ -85,6 +100,9 @@ async def reflect_buy( if existing is None: # 신규 행일 때만 전략 태깅 (기존 행은 다른 전략 보유분이 섞여 있을 수 있음) _upsert["strategy_type"] = strategy_type + elif _promote_to_swing: + # external → swing 승격만 DB 에도 반영 (강등·DT 전환은 하지 않는다) + _upsert["strategy_type"] = "swing" await enqueue_portfolio_upsert(_upsert) await cache.apply_buy(stock_code, stock_name, quantity, price) except Exception as e: diff --git a/backend/app/services/swing/exit_service.py b/backend/app/services/swing/exit_service.py index 79d1ab1..d14673a 100644 --- a/backend/app/services/swing/exit_service.py +++ b/backend/app/services/swing/exit_service.py @@ -44,6 +44,10 @@ logger = logging.getLogger(__name__) # 종목별 마지막 청산 시각(epoch) — 큐 지연으로 같은 분할매도가 반복되는 것을 막는 인메모리 가드 _recent_exits: dict[str, float] = {} +# [F5 2026-09-28] "스윙 보유 종목 없음" 연속 횟수 — 조용한 무동작을 드러내기 위한 카운터. +# _EMPTY_HOLDINGS_WARN_AFTER 회 이상 연속되면 info → warning 으로 승격한다. +_empty_holdings_streak: int = 0 +_EMPTY_HOLDINGS_WARN_AFTER: int = 10 EXIT_COOLDOWN_SEC = 180 @@ -161,6 +165,7 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: Returns: list[dict]: 청산 실행 결과 목록 """ + global _empty_holdings_streak executed_exits = [] try: @@ -168,9 +173,24 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: _now_ts = time.time() holdings = await get_swing_holdings(db) if not holdings: - logger.debug("[SwingExit] 스윙 보유 종목 없음") + # [F5 2026-09-28] 이 로그가 debug 였기 때문에 2026-05-15~09-21 4개월간 + # 청산 판정이 사실상 멈춰 있던 것이 운영 로그에서 드러나지 않았다. + # info 로 올리고, 연속으로 계속되면 warning 으로 승격해 눈에 띄게 한다. + _empty_holdings_streak += 1 + if _empty_holdings_streak >= _EMPTY_HOLDINGS_WARN_AFTER: + logger.warning( + f"[SwingExit] 스윙 보유 종목 없음 — {_empty_holdings_streak}회 연속 " + f"(청산 판정이 계속 no-op 이다: 실제 무보유인지, " + f"Portfolio.strategy_type 불일치인지 확인 필요)" + ) + else: + logger.info( + f"[SwingExit] 스윙 보유 종목 없음 ({_empty_holdings_streak}회 연속)" + ) return executed_exits + _empty_holdings_streak = 0 + # 시장 국면 (동적 계산 — 기존 코드의 하드코딩 BULL 개선) try: regime_dict = await check_market_regime() @@ -486,6 +506,27 @@ async def check_and_execute_exit_v2(db: AsyncSession) -> list[dict]: ) await loss_tracker.save_to_db(stock_code=stock_code) + # [2026-09-28] 전략 간 당일 쿨다운 — 스윙이 손절한 종목을 같은 날 + # 데이트레이딩이 다시 사는 것을 막는다(09-04 009420 -14% 손절 직후 + # DT 워치리스트 잔류 확인). 조회는 daytrading/entry_service 의 + # is_closed_today() 경로를 그대로 쓴다. + try: + from app.services.daytrading.position_manager import ( + day_trading_state as _dts_cd, + ) + # closed_today 변경은 trade_lock 보유가 규약(position_manager 참조) + async with _dts_cd.trade_lock: + _dts_cd.closed_today.add(stock_code) + # [M4 2026-09-28 / F9] 영속화 — 이 경로만 저장이 빠져 있어 + # 재기동하면 "스윙이 손절한 종목을 같은 날 DT 가 재매수"가 + # 되살아났다(전략 간 당일 쿨다운이 무효화). 락 해제 후 호출한다 + # (Redis I/O 로 trade_lock 을 잡지 않는다). + await _dts_cd.save_daily_state() + except Exception as _cd_err: + logger.warning( + f"[SwingExit] DT 당일 재진입 차단 등록 실패 ({stock_code}): {_cd_err}" + ) + executed_exits.append({ "stock_code": stock_code, "stock_name": stock_name, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 8938ef5..fec9ca3 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -104,6 +104,7 @@ _KIS_CLASS_METHODS = ( "get_overseas_daily_chart", "get_minute_chart", "get_minute_chart_full_day", + "get_minute_chart_full_day_v2", # 진입 판정 경로(전일 워밍업 포함) — 2026-09-29 "get_balance", "get_buyable_cash", "get_overseas_balance", diff --git a/backend/tests/test_ai_analysis_buy_amount.py b/backend/tests/test_ai_analysis_buy_amount.py index 975d330..7c13a2f 100644 --- a/backend/tests/test_ai_analysis_buy_amount.py +++ b/backend/tests/test_ai_analysis_buy_amount.py @@ -24,21 +24,48 @@ class _Adp: def test_buy_amount_uses_position_cap_and_confidence(): + """[2026-09-28] 적응형 6% 는 MAX_POSITION_SIZE(절대 상한)로 클램프된다. + + 기대값은 config 를 참조한다(§4.1 — 하드코딩 금지). + """ + from app.core.config import settings + with patch("app.services.adaptive_strategy.get_current_adaptive_params", return_value=_Adp()): out = _suggest_buy_amount(current_price=70_000, recommendation="BUY", confidence=0.7, stock_code="005930") - # 1,000만 × 6% = 60만, 신뢰도 0.7 → 보정 1.0 → 70,000원 × 8주 = 560,000 - assert out["suggested_quantity"] == 8 - assert out["suggested_amount"] == 560_000 - assert out["position_ratio"] == pytest.approx(5.6, abs=0.01) - assert "6%" in out["sizing_note"] + _cap = settings.MAX_POSITION_SIZE + _expected_qty = int((10_000_000 * _cap) // 70_000) + assert out["suggested_quantity"] == _expected_qty + assert out["suggested_amount"] == _expected_qty * 70_000 + assert out["position_ratio"] <= _cap * 100 + 0.01 + assert f"{_cap * 100:.0f}%" in out["sizing_note"] -def test_high_confidence_scales_up_capped_at_120pct(): +def test_high_confidence_does_not_exceed_hard_cap(): + """신뢰도 상향 보정(×1.2)도 종목당 절대 상한을 넘지 못한다.""" + from app.core.config import settings + with patch("app.services.adaptive_strategy.get_current_adaptive_params", return_value=_Adp()): out = _suggest_buy_amount(70_000, "BUY", 1.0, "005930") - # 60만 × min(1.2, 1.0/0.7=1.43) = 72만 → 10주 - assert out["suggested_quantity"] == 10 + assert out["position_ratio"] <= settings.MAX_POSITION_SIZE * 100 + 0.01 + + +@pytest.mark.parametrize("mode_position_size", [0.06, 0.07]) # Normal / Aggressive +def test_adaptive_modes_clamped_to_hard_cap(mode_position_size): + """적응형 Normal(6%)·Aggressive(7%) 모드에서도 제안 비중 ≤ MAX_POSITION_SIZE. + + [R4 회귀] 표시 경로가 클램프를 빼먹으면 운영자가 화면 금액으로 수동매수해 + §5.1 절대 상한(4%)이 깨진다. + """ + from app.core.config import settings + + class _M: + fallback_used = False + position_size = mode_position_size + + with patch("app.services.adaptive_strategy.get_current_adaptive_params", return_value=_M()): + out = _suggest_buy_amount(70_000, "BUY", 1.0, "005930") + assert out["suggested_amount"] / 10_000_000 <= settings.MAX_POSITION_SIZE + 1e-9 def test_non_buy_recommendation_returns_zero(): diff --git a/backend/tests/test_dt_warmup_and_orb_volume_20260929.py b/backend/tests/test_dt_warmup_and_orb_volume_20260929.py new file mode 100644 index 0000000..5a6c428 --- /dev/null +++ b/backend/tests/test_dt_warmup_and_orb_volume_20260929.py @@ -0,0 +1,698 @@ +"""DT 진입 워밍업 회귀 복구 + ORB 1분봉 거래량 게이트 (2026-09-29) + +배경 +---- +커밋 `1b9257c`(2026-09-28)가 `korea_investment_api.get_minute_chart_full_day` 에 +"당일 봉만" 필터를 넣었다. §7 이 경고하는 오염(전일 캔들이 ORB 고저·평균분당거래량· +VWAP 을 오염)을 막으려는 의도였지만, **지표 워밍업도 함께 끊겼다**: 5분봉 +RSI(14)=15봉(75분)·BB(20)=20봉(100분) 요건 때문에 VWAP 전략은 약 10:15, RSI_BB 는 +약 10:40 이전에 구조적으로 발화할 수 없었다(`indicators_core.rsi_wilder` 는 봉이 +모자라면 `None`, 짧은 기간으로 대체 계산하지 않는다). 원장 실측으로 필터 도입 전에는 +09:30·10:02 진입이 실재했음이 확인됐다. + +이 파일이 고정하는 것 +--------------------- +1. 워밍업: 09:30 시점(당일 5분봉 6개)에도 RSI(14)/BB(20)가 계산되어 판정에 도달한다 +2. 세션 앵커: VWAP·현재가에는 당일 봉만 흘러간다 +3. ORB 레인지·평균분당거래량이 전일 봉에 오염되지 않는다 +4. `volume_surge_ratio` 가 세션 경계를 넘지 않는다 (당일 완성봉 2개 미만 → None) +5. ORB 거래량 게이트는 형성 중 봉이 아니라 **마지막 완성 1분봉**을 쓴다 +6. DT 청산 경로·레거시 엔드포인트는 여전히 당일 봉만 받는다 +""" +import inspect +from datetime import datetime, timedelta +from unittest.mock import AsyncMock + +import pytest +import pytz + +from app.core.config import settings +from app.services.day_trading_strategy import ( + MinuteCandle, + OpeningRange, + _bollinger, + _rsi, + aggregate_to_5min, + calculate_opening_range, + check_orb_signal, + check_rsi_bb_signal, + volume_surge_ratio, +) + +_KST = pytz.timezone("Asia/Seoul") + +# conftest 의 autouse 차단 픽스처는 `KoreaInvestmentAPI` 의 시세 메서드를 예외 발생형으로 +# 덮는다(§4.1). 조회 계층의 **집계/필터 로직 자체**를 검증하려면 원본 함수 객체가 필요하므로 +# import 시점(픽스처 적용 전)에 붙잡아 둔다. 실제 네트워크 종단인 `get_minute_chart` 는 +# 각 테스트에서 fake 로 덮으므로 실호출은 발생하지 않는다. +from app.services.korea_investment_api import KoreaInvestmentAPI as _KIS # noqa: E402 + +_ORIG_FULL_DAY = _KIS.get_minute_chart_full_day +_ORIG_FULL_DAY_V2 = _KIS.get_minute_chart_full_day_v2 + + +def _today() -> str: + return datetime.now(_KST).strftime("%Y%m%d") + + +def _prev() -> str: + return (datetime.now(_KST) - timedelta(days=1)).strftime("%Y%m%d") + + +def _c(date: str, hhmmss: str, close: float, volume: int = 1000, + open_: float | None = None) -> MinuteCandle: + op = close if open_ is None else open_ + return MinuteCandle( + date=date, time=hhmmss, + open=op, high=max(op, close), low=min(op, close), close=close, + volume=volume, cum_volume=volume, cum_amount=float(close * volume), + ) + + +def _minute_seq(date: str, start_h: int, start_m: int, count: int, + close_fn, volume_fn=lambda i: 1000) -> list: + """`count` 개의 연속 1분봉. close_fn(i)/volume_fn(i) 로 값을 정한다.""" + out = [] + for i in range(count): + total = start_h * 60 + start_m + i + hh, mm = divmod(total, 60) + out.append(_c(date, f"{hh:02d}{mm:02d}00", close_fn(i), volume_fn(i))) + return out + + +# ───────────────────────────────────────────────────────────────────────────── +# 1. 워밍업 — 09:30 시점에 RSI(14)/BB(20) 가 계산되어 판정에 도달한다 +# ───────────────────────────────────────────────────────────────────────────── + +def _warmup_scenario() -> tuple[list, list]: + """(전일+당일 1분봉, 당일만 1분봉) — 09:30 시점 가정. + + 당일: 09:00~09:29 (5분봉 6개). 전일 13:00~15:19 (5분봉 28개 워밍업). + 가격은 완만한 하락 후 마지막 완성봉에서 급락 → RSI 과매도 + BB 하단 이탈, + 형성 중 봉은 양봉. 거래량은 마지막 완성봉이 직전 완성봉의 2배. + """ + 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배 + today_bars += _minute_seq( + _today(), 9, 20, 5, lambda i: 940.0 - i * 6.0, lambda i: 2000 + ) + # 09:25~09:29 = 형성 중 봉: 양봉 (반등 시작) + today_bars += [ + _c(_today(), "092500", 912.0, 300, open_=910.0), + _c(_today(), "092600", 913.0, 300, open_=912.0), + ] + return prev_day + today_bars, today_bars + + +def test_warmup_enables_rsi_bb_at_0930(): + """전일 봉이 붙으면 09:30 에도 RSI(14)/BB(20) 가 계산된다.""" + mixed, today_only = _warmup_scenario() + + bars_today = aggregate_to_5min(today_only) + assert len(bars_today) < 20, "전제: 당일만으로는 BB(20) 요건 미달이어야 한다" + assert _rsi(bars_today, 14) is None, "당일 봉만으로는 RSI(14) 계산 불가(회귀 전제)" + assert _bollinger(bars_today, 20) is None + + bars_mixed = aggregate_to_5min(mixed) + assert len(bars_mixed) >= 20 + assert _rsi(bars_mixed, 14) is not None, "워밍업이 붙으면 RSI 가 계산돼야 한다" + assert _bollinger(bars_mixed, 20) is not None + + +def test_rsi_bb_signal_reachable_at_0930_only_with_warmup(): + """같은 시장 상황에서 워밍업 유무가 발화 가능/불가를 가른다.""" + mixed, today_only = _warmup_scenario() + + price = today_only[-1].close + assert check_rsi_bb_signal("005930", "삼성전자", + aggregate_to_5min(today_only), price) is None + + sig = check_rsi_bb_signal("005930", "삼성전자", + aggregate_to_5min(mixed), price) + assert sig is not None, "워밍업이 있으면 09:30 에도 RSI_BB 판정에 도달해야 한다" + assert sig.strategy == "RSI_BB" + assert sig.stop_loss < price < sig.target_price + # §4: reason 에 임계값을 하드코딩하지 말고 실효값을 쓴다 + assert f"{settings.DAY_TRADE_RSI_BB_RSI_THRESHOLD:.0f}" in sig.reason + + +# ───────────────────────────────────────────────────────────────────────────── +# 2. 세션 앵커 — VWAP·현재가는 당일 봉만 +# ───────────────────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_entry_path_requests_prev_day_but_anchors_today(monkeypatch): + """진입 경로는 전일 포함 시계열을 요청하고, 세션 지표는 당일 봉만 받는다.""" + from app.services.daytrading import entry_service as es + + mixed, today_only = _warmup_scenario() + raw = [ + { + "stck_bsop_date": c.date, "stck_cntg_hour": c.time, + "stck_prpr": c.close, "stck_oprc": c.open, + "stck_hgpr": c.high, "stck_lwpr": c.low, + "cntg_vol": c.volume, "acml_vol": c.cum_volume, + "acml_tr_pbmn": c.cum_amount, + } + for c in mixed + ] + + calls = {} + + async def fake_v2(stock_code, market_open="090000", include_prev_day=False, + prev_day_minutes=0): + calls["include_prev_day"] = include_prev_day + return raw + + async def fake_today_only(stock_code, market_open="090000"): + raise AssertionError("진입 경로는 당일 전용 메서드를 쓰면 안 된다(워밍업 소실)") + + captured = {} + + def fake_vwap(cs): + captured["vwap_dates"] = sorted({c.date for c in cs}) + return float(cs[-1].close) + + def fake_rsi_bb(code, name, candles_5m, price): + captured["bars"] = len(candles_5m) + captured["bar_dates"] = sorted({c.date for c in candles_5m}) + captured["price"] = price + return None + + monkeypatch.setattr(es.korea_investment_api, "get_minute_chart_full_day_v2", + fake_v2, raising=False) + monkeypatch.setattr(es.korea_investment_api, "get_minute_chart_full_day", + fake_today_only, raising=False) + monkeypatch.setattr(es, "calculate_vwap", fake_vwap) + monkeypatch.setattr(es, "check_rsi_bb_signal", fake_rsi_bb) + monkeypatch.setattr(es, "check_vwap_bounce_signal", + lambda *a, **k: None) + monkeypatch.setattr(es.loss_tracker, "is_banned_safe", + AsyncMock(return_value=False)) + monkeypatch.setattr(es, "is_vi_active", lambda code: False) + es._candle_cache.clear() + + result = await es._try_enter("005930", "삼성전자", 100_000_000.0, 50_000_000.0) + + assert result is None # 스텁이 신호 없음을 반환 + assert calls["include_prev_day"] is True + assert captured["bars"] >= 20, "워밍업 시계열이 지표에 전달되지 않았다" + assert _prev() in captured["bar_dates"] and _today() in captured["bar_dates"] + assert captured["vwap_dates"] == [_today()], "VWAP 에 전일 누적이 섞이면 안 된다" + assert captured["price"] == today_only[-1].close, "현재가는 당일 마지막 봉" + + +# ───────────────────────────────────────────────────────────────────────────── +# 3. ORB 레인지·평균분당거래량 무오염 +# ───────────────────────────────────────────────────────────────────────────── + +def test_orb_range_not_polluted_by_prev_day(): + """`filter_today_candles` 를 거친 ORB 는 전일 고저·거래량을 담지 않는다.""" + from app.services.daytrading.entry_service import filter_today_candles + + prev_bars = _minute_seq(_prev(), 9, 0, 10, lambda i: 5000.0 + i * 100, + lambda i: 100_000) + today_bars = _minute_seq(_today(), 9, 0, 10, lambda i: 7000.0 + i, + lambda i: 1_000) + + orb_clean = calculate_opening_range(filter_today_candles(prev_bars + today_bars)) + orb_today = calculate_opening_range(today_bars) + assert orb_clean == orb_today + assert orb_clean.avg_vol_per_min == 1_000 + + +def test_orb_signal_ignores_prev_day_candles(): + """check_orb_signal 은 전일 봉이 섞여도 당일 봉만으로 판정한다.""" + orb = OpeningRange(high=1000.0, low=980.0, total_volume=30_000, + avg_vol_per_min=1_000.0, open_price=990.0) + # 당일: 마지막 완성봉 거래량 2,000(=2배) → 게이트 통과, 직전 5분봉 양봉 + today_bars = _minute_seq(_today(), 9, 30, 10, lambda i: 1000.0 + i, + lambda i: 2_000) + today_bars.append(_c(_today(), "094000", 1010.0, 100, open_=1009.0)) + prev_bars = _minute_seq(_prev(), 15, 0, 10, lambda i: 500.0, + lambda i: 900_000) + + sig_today = check_orb_signal("005930", "삼성전자", today_bars, orb, 1010.0) + sig_mixed = check_orb_signal("005930", "삼성전자", prev_bars + today_bars, + orb, 1010.0) + assert sig_today is not None + assert sig_mixed is not None + assert sig_mixed.reason == sig_today.reason, "전일 봉이 거래량 배율을 바꿨다" + + +# ───────────────────────────────────────────────────────────────────────────── +# 4. volume_surge_ratio — 세션 경계를 넘지 않는다 +# ───────────────────────────────────────────────────────────────────────────── + +def test_volume_surge_ratio_does_not_cross_session(): + prev_5m = [_c(_prev(), f"15{m:02d}00", 100.0, 1_000_000) for m in (5, 10, 15)] + today_5m = [_c(_today(), "090000", 100.0, 1_000), + _c(_today(), "090500", 101.0, 1_000)] + + # 당일 완성봉이 1개(09:00 버킷)뿐 → None (게이트 미통과) + assert volume_surge_ratio(prev_5m + today_5m) is None + # 당일 완성봉 2개 확보 → 당일 봉끼리만 비교 + today_5m.append(_c(_today(), "091000", 102.0, 1_000)) + ratio = volume_surge_ratio(prev_5m + today_5m) + assert ratio == pytest.approx(1.0), "전일 거래량이 분모로 쓰이면 안 된다" + + +def test_volume_surge_ratio_uses_completed_bars_only(): + """[F7 회귀 가드] 형성 중 봉([-1])은 비교에서 제외된다.""" + bars = [ + _c(_today(), "090000", 100.0, 1_000), + _c(_today(), "090500", 101.0, 2_000), # 마지막 완성봉 + _c(_today(), "091000", 102.0, 10), # 형성 중 (부분 거래량) + ] + assert volume_surge_ratio(bars) == pytest.approx(2.0) + + +def test_volume_surge_ratio_undated_candles_keep_legacy_behavior(): + bars = [_c("", "090000", 100.0, 1_000), + _c("", "090500", 101.0, 2_000), + _c("", "091000", 102.0, 10)] + assert volume_surge_ratio(bars) == pytest.approx(2.0) + + +# ───────────────────────────────────────────────────────────────────────────── +# 5. ORB 거래량 게이트 — 마지막 완성 1분봉 기준 +# ───────────────────────────────────────────────────────────────────────────── + +def _orb_fixture() -> OpeningRange: + return OpeningRange(high=1000.0, low=980.0, total_volume=30_000, + avg_vol_per_min=1_000.0, open_price=990.0) + + +def test_orb_volume_gate_uses_last_completed_minute_bar(): + """형성 중 봉이 절반만 채워져 있어도, 완성봉이 임계를 넘으면 통과한다.""" + orb = _orb_fixture() + mult = settings.DAY_TRADE_ORB_BREAKOUT_VOL_MULT + completed_vol = int(orb.avg_vol_per_min * mult) + 10 + + bars = _minute_seq(_today(), 9, 30, 9, lambda i: 1000.0 + i, lambda i: 2_000) + bars.append(_c(_today(), "093900", 1010.0, completed_vol, open_=1009.0)) + # 형성 중 봉: 아직 임계 미달 (30초 주기 스캔의 전형) + bars.append(_c(_today(), "094000", 1010.0, + int(orb.avg_vol_per_min * mult) - 1, open_=1009.5)) + + sig = check_orb_signal("005930", "삼성전자", bars, orb, 1010.0) + assert sig is not None, "형성 중 봉 때문에 탈락하면 실효 요구가 임계값의 ~2배가 된다" + assert f"{mult}" in sig.reason, "§4: reason 에 실효 임계값을 넣어야 한다" + + +def test_orb_volume_gate_rejects_when_completed_bar_below_threshold(): + """형성 중 봉만 급증해도 통과하지 않는다 (완성봉이 기준).""" + orb = _orb_fixture() + mult = settings.DAY_TRADE_ORB_BREAKOUT_VOL_MULT + + bars = _minute_seq(_today(), 9, 30, 9, lambda i: 1000.0 + i, lambda i: 2_000) + bars.append(_c(_today(), "093900", 1010.0, + int(orb.avg_vol_per_min * mult) - 1, open_=1009.0)) + bars.append(_c(_today(), "094000", 1010.0, 999_999, open_=1009.5)) + + assert check_orb_signal("005930", "삼성전자", bars, orb, 1010.0) is None + + +def test_orb_volume_gate_blocks_when_no_completed_bar(): + """당일 봉이 1개뿐(완성봉 없음)이면 게이트 미통과 — 통과 쪽으로 열지 않는다.""" + orb = _orb_fixture() + bars = [_c(_today(), "093000", 1010.0, 999_999, open_=1009.0)] + assert check_orb_signal("005930", "삼성전자", bars, orb, 1010.0) is None + assert check_orb_signal("005930", "삼성전자", [], orb, 1010.0) is None + + +# ───────────────────────────────────────────────────────────────────────────── +# 6. 조회 계층 — 기본 동작(당일만) 보존 / 청산·레거시 경로 무변경 +# ───────────────────────────────────────────────────────────────────────────── + +def _isolated_api(): + """조회 계층 로직 검증용 독립 인스턴스. + + 모듈 싱글톤(`korea_investment_api`)을 쓰지 않는다 — 다른 테스트 모듈이 그 이름을 + MagicMock 으로 덮으면(전체 스위트 실행 시 실재) 순서 의존으로 깨진다(CLAUDE.md §4.1). + `__init__` 을 건너뛰어 토큰 캐시 로드·httpx 클라이언트 생성도 하지 않는다. + """ + api = _KIS.__new__(_KIS) + api.min_request_interval = 0.0 + return api + + +def _raw(date: str, hhmmss: str, vol: int = 100) -> dict: + return { + "stck_bsop_date": date, "stck_cntg_hour": hhmmss, + "stck_prpr": "1000", "stck_oprc": "1000", + "stck_hgpr": "1001", "stck_lwpr": "999", + "cntg_vol": str(vol), "acml_vol": str(vol), "acml_tr_pbmn": "1000", + } + + +@pytest.mark.asyncio +async def test_query_layer_default_keeps_today_only(monkeypatch): + """`get_minute_chart_full_day` 는 전일 봉을 계속 걸러낸다(기존 동작 보존).""" + from app.services import korea_investment_api as kis_mod + + api = _isolated_api() + today, prev = _today(), _prev() + + async def fake_page(stock_code, ref_time=None, include_past=True): + # 한 페이지에 당일 09:00~09:02 + 전일 15:18~15:20 이 섞여 돌아오는 상황 + return {"output2": [ + _raw(today, "090200"), _raw(today, "090100"), _raw(today, "090000"), + _raw(prev, "152000"), _raw(prev, "151900"), _raw(prev, "151800"), + ]} + + monkeypatch.setattr(kis_mod.KoreaInvestmentAPI, "get_minute_chart_full_day", + _ORIG_FULL_DAY) + monkeypatch.setattr(api, "_quote_delegate", lambda: None, raising=False) + monkeypatch.setattr(api, "get_minute_chart", fake_page, raising=False) + monkeypatch.setattr(kis_mod, "_cache_get", AsyncMock(return_value=None)) + monkeypatch.setattr(kis_mod, "_cache_set", AsyncMock(return_value=None)) + + out = await api.get_minute_chart_full_day("005930") + assert {r["stck_bsop_date"] for r in out} == {today} + assert [r["stck_cntg_hour"] for r in out] == ["090000", "090100", "090200"] + + +@pytest.mark.asyncio +async def test_query_layer_v2_includes_prev_day_sorted_by_date(monkeypatch): + """`_v2(include_prev_day=True)` 는 전일 봉을 앞쪽에 붙여 반환한다. + + 정렬 키가 (영업일자, 시각)이어야 한다 — 시각만 보면 전일 15:20 이 당일 09:00 + 뒤에 놓여 `candles[-1]`(현재가·형성 중 봉) 기반 계산이 전부 무너진다. + """ + from app.services import korea_investment_api as kis_mod + + api = _isolated_api() + today, prev = _today(), _prev() + + async def fake_page(stock_code, ref_time=None, include_past=True): + return {"output2": [ + _raw(today, "090200"), _raw(today, "090100"), _raw(today, "090000"), + _raw(prev, "152000"), _raw(prev, "151900"), _raw(prev, "151800"), + ]} + + monkeypatch.setattr(kis_mod.KoreaInvestmentAPI, "get_minute_chart_full_day_v2", + _ORIG_FULL_DAY_V2) + monkeypatch.setattr(api, "_quote_delegate", lambda: None, raising=False) + monkeypatch.setattr(api, "get_minute_chart", fake_page, raising=False) + monkeypatch.setattr(kis_mod, "_cache_get", AsyncMock(return_value=None)) + monkeypatch.setattr(kis_mod, "_cache_set", AsyncMock(return_value=None)) + + out = await api.get_minute_chart_full_day_v2( + "005930", include_prev_day=True, prev_day_minutes=3 + ) + keys = [(r["stck_bsop_date"], r["stck_cntg_hour"]) for r in out] + assert keys == [ + (prev, "151800"), (prev, "151900"), (prev, "152000"), + (today, "090000"), (today, "090100"), (today, "090200"), + ] + + +def test_exit_and_legacy_paths_stay_today_only(): + """DT 청산·레거시 엔드포인트·ORB 수집은 전일 포함 메서드를 쓰지 않는다.""" + from app.api.endpoints import day_trading as legacy + from app.services.daytrading import entry_service as es + from app.services.daytrading import exit_service as xs + + exit_src = inspect.getsource(xs) + legacy_src = inspect.getsource(legacy) + assert "get_minute_chart_full_day_v2" not in exit_src, ( + "DT 청산 경로에 전일 봉이 들어가면 트레일링·ATR·시간손절이 오염된다" + ) + assert "get_minute_chart_full_day_v2" not in legacy_src + assert "get_minute_chart_full_day" in exit_src + + orb_src = inspect.getsource(es.collect_opening_ranges) + assert "get_minute_chart_full_day_v2" not in orb_src, ( + "ORB 수집이 전일 봉을 받으면 레인지·평균분당거래량이 오염된다(§7)" + ) + # 진입 판정 경로만 전일 포함 + assert "get_minute_chart_full_day_v2" in inspect.getsource(es._fetch_candles_impl) + + +@pytest.mark.asyncio +async def test_query_layer_v2_paginates_across_day_boundary(monkeypatch): + """전일 워밍업 수집이 날짜 경계에서 멈추지 않는다 — **루프 로직만** 검증한다. + + 다음 페이지 기준 시각을 **시각만으로** 정하면, 당일 09:00 봉이 섞인 페이지에서 + `min(times)="090000"` 이 되어 같은 ref 를 재요청하고 `new_added==0` 으로 즉시 + 중단된다 → 전일 봉이 한 페이지분만 모여 워밍업이 사실상 안 된다. + 여기서는 ref 가 **(영업일자, 시각) 최솟값의 시각**으로 갱신되는지를 고정한다. + + ⚠️ **이 테스트는 KIS 의 계약을 검증하지 못한다.** fake 는 "미래 `ref_time`(전일 + 14:51 등)을 주면 그 시점 이전의 전일 봉을 돌려준다"는 **가정을 심어 둔 것**이고, + 실제 KIS 가 그렇게 응답하는지는 확인되지 않았다(요청 파라미터에 날짜가 없다). + KIS 가 최신 봉을 그대로 돌려주면 프로덕션에서는 `new_added==0` 으로 조기 중단되고 + 전일 봉은 경계 페이지 1장만 남는다. 확정 근거는 운영 로그 + `[분봉] ... 워밍업 5분봉 N봉` 뿐이다. + """ + from app.services import korea_investment_api as kis_mod + + api = _isolated_api() + today, prev = _today(), _prev() + + # 전일 14:00~15:20(81봉) + 당일 09:00~09:02(3봉) — 오래된 것 → 최신 + prev_minutes = [f"{h:02d}{m:02d}00" for h in (14, 15) for m in range(60) + if not (h == 15 and m > 20)] + ledger = [(prev, t) for t in prev_minutes] + \ + [(today, t) for t in ("090000", "090100", "090200")] + + requested_refs = [] + cursor = {"end": len(ledger)} # KIS 페이지네이션 모사: 뒤에서 30개씩 거슬러 올라감 + expected_next_ref = {"value": None} + + async def fake_page(stock_code, ref_time=None, include_past=True): + requested_refs.append(ref_time) + if expected_next_ref["value"] is not None: + assert ref_time == expected_next_ref["value"], ( + f"ref 가 (영업일자, 시각) 최솟값으로 갱신되지 않았다: " + f"{ref_time} != {expected_next_ref['value']}" + ) + end = cursor["end"] + if end <= 0: + return {"output2": []} + window = ledger[max(0, end - 30):end] + cursor["end"] = max(0, end - 30) + expected_next_ref["value"] = min(window)[1] # (date, time) 최솟값의 시각 + return {"output2": [_raw(d, t) for d, t in reversed(window)]} + + monkeypatch.setattr(kis_mod.KoreaInvestmentAPI, "get_minute_chart_full_day_v2", + _ORIG_FULL_DAY_V2) + monkeypatch.setattr(api, "_quote_delegate", lambda: None, raising=False) + monkeypatch.setattr(api, "get_minute_chart", fake_page, raising=False) + monkeypatch.setattr(kis_mod, "_cache_get", AsyncMock(return_value=None)) + monkeypatch.setattr(kis_mod, "_cache_set", AsyncMock(return_value=None)) + + out = await api.get_minute_chart_full_day_v2( + "005930", include_prev_day=True, prev_day_minutes=60 + ) + + prev_rows = [r for r in out if r["stck_bsop_date"] == prev] + assert len(requested_refs) >= 3, f"페이지네이션이 조기 중단됐다 (ref={requested_refs})" + assert len(prev_rows) >= 60, ( + f"전일 봉 {len(prev_rows)}개 < 요청 60 — 날짜 경계에서 멈췄다" + ) + keys = [(r["stck_bsop_date"], r["stck_cntg_hour"]) for r in out] + assert keys == sorted(keys), "정렬 키에 영업일자가 빠지면 candles[-1] 이 전일 봉이 된다" + assert keys[-1] == (today, "090200"), "candles[-1] 은 당일 최신 봉이어야 한다" + + +@pytest.mark.asyncio +async def test_query_layer_v2_stops_once_warmup_is_enough(monkeypatch): + """요청한 전일 분량을 채우면 더 조회하지 않는다 (KIS 레이트 리밋 보호).""" + from app.services import korea_investment_api as kis_mod + + api = _isolated_api() + today, prev = _today(), _prev() + pages = {"n": 0} + + async def fake_page(stock_code, ref_time=None, include_past=True): + pages["n"] += 1 + base = 900 - pages["n"] * 30 + rows = [_raw(today, "090000")] if pages["n"] == 1 else [] + rows += [_raw(prev, f"{13 + (base + i) // 60:02d}{(base + i) % 60:02d}00") + for i in range(30)] + return {"output2": rows} + + monkeypatch.setattr(kis_mod.KoreaInvestmentAPI, "get_minute_chart_full_day_v2", + _ORIG_FULL_DAY_V2) + monkeypatch.setattr(api, "_quote_delegate", lambda: None, raising=False) + monkeypatch.setattr(api, "get_minute_chart", fake_page, raising=False) + monkeypatch.setattr(kis_mod, "_cache_get", AsyncMock(return_value=None)) + monkeypatch.setattr(kis_mod, "_cache_set", AsyncMock(return_value=None)) + + await api.get_minute_chart_full_day_v2( + "005930", include_prev_day=True, prev_day_minutes=30 + ) + assert pages["n"] <= 2, f"필요 분량을 채운 뒤에도 조회가 계속됐다 ({pages['n']}회)" + + +@pytest.mark.asyncio +async def test_query_layer_v2_skips_prev_day_when_today_is_enough(monkeypatch): + """장 중반 이후에는 당일 봉만으로 워밍업이 차므로 전일 봉을 더 받지 않는다. + + (불필요한 KIS 호출 + MAX_PAGES 소진 방지. 조회 예산이 전일 수집에 쓰이면 + 당일 봉 수집이 잘릴 수 있다.) + """ + from app.services import korea_investment_api as kis_mod + + api = _isolated_api() + today = _today() + pages = {"n": 0} + + async def fake_page(stock_code, ref_time=None, include_past=True): + pages["n"] += 1 + # 당일 봉을 30개씩 과거로 내려준다 (13:00 → 09:00 방향) + start = 780 - pages["n"] * 30 # 13:00 = 780분 + rows = [] + for i in range(30): + total = max(540, start + i) # 09:00 하한 + rows.append(_raw(today, f"{total // 60:02d}{total % 60:02d}00")) + return {"output2": list(reversed(rows))} + + monkeypatch.setattr(kis_mod.KoreaInvestmentAPI, "get_minute_chart_full_day_v2", + _ORIG_FULL_DAY_V2) + monkeypatch.setattr(api, "_quote_delegate", lambda: None, raising=False) + monkeypatch.setattr(api, "get_minute_chart", fake_page, raising=False) + monkeypatch.setattr(kis_mod, "_cache_get", AsyncMock(return_value=None)) + monkeypatch.setattr(kis_mod, "_cache_set", AsyncMock(return_value=None)) + + out = await api.get_minute_chart_full_day_v2( + "005930", include_prev_day=True, prev_day_minutes=60 + ) + assert all(r["stck_bsop_date"] == today for r in out) + assert len(out) >= 60 + # 09:00 에 도달하면 즉시 중단 — 전일 봉을 찾아 더 거슬러 가지 않는다 + assert pages["n"] <= 9, f"당일 봉으로 충분한데 조회가 계속됐다 ({pages['n']}회)" + + +# ───────────────────────────────────────────────────────────────────────────── +# 7. [M-3] 워밍업 목표는 '분 수'가 아니라 '부족한 5분봉 개수' +# ───────────────────────────────────────────────────────────────────────────── + +def _page_fake(pages: dict, rows_fn): + async def fake_page(stock_code, ref_time=None, include_past=True): + pages["n"] += 1 + pages["refs"].append(ref_time) + return {"output2": rows_fn(pages["n"])} + return fake_page + + +def _wire(monkeypatch, kis_mod, api, fake_page): + monkeypatch.setattr(kis_mod.KoreaInvestmentAPI, "get_minute_chart_full_day_v2", + _ORIG_FULL_DAY_V2) + monkeypatch.setattr(api, "_quote_delegate", lambda: None, raising=False) + monkeypatch.setattr(api, "get_minute_chart", fake_page, raising=False) + monkeypatch.setattr(kis_mod, "_cache_get", AsyncMock(return_value=None)) + monkeypatch.setattr(kis_mod, "_cache_set", AsyncMock(return_value=None)) + + +@pytest.mark.asyncio +async def test_warmup_stops_as_soon_as_missing_bars_are_covered(monkeypatch): + """09:30(당일 5분봉 6개)이면 전일 5분봉 14개만 받고 멈춘다. + + 분 수(150) 기준이면 전일 150봉을 다 받으려 5페이지를 더 쓴다. 봉 수 기준이면 + 한 페이지(30분=6봉)씩 세 번이면 충족된다. + """ + from app.services import korea_investment_api as kis_mod + + api = _isolated_api() + today, prev = _today(), _prev() + pages = {"n": 0, "refs": []} + + def rows(n): + if n == 1: # 당일 09:00~09:29 (5분봉 6개) + 경계 전일 봉 + return [_raw(today, f"09{m:02d}00") for m in range(29, -1, -1)] + # 전일 15:20 에서 30분씩 거슬러 (페이지당 5분봉 6개) + start = 920 - (n - 1) * 30 # 15:20 = 920분 + return [_raw(prev, f"{(start - i) // 60:02d}{(start - i) % 60:02d}00") + for i in range(30)] + + _wire(monkeypatch, kis_mod, api, _page_fake(pages, rows)) + out = await api.get_minute_chart_full_day_v2("005930", include_prev_day=True) + + prev_buckets = { + (int(r["stck_cntg_hour"][:2]) * 60 + int(r["stck_cntg_hour"][2:4])) // 5 + for r in out if r["stck_bsop_date"] == prev + } + assert pages["n"] <= 4, ( + f"부족분(14봉)을 채운 뒤에도 조회가 계속됐다 ({pages['n']}페이지) — " + "분 수 기준 종료 조건이 남아 있다" + ) + assert len(prev_buckets) >= 3 + assert len([r for r in out if r["stck_bsop_date"] == today]) == 30 + + +@pytest.mark.asyncio +async def test_warmup_respects_prev_day_page_cap_for_sparse_stocks(monkeypatch): + """저유동성 종목(페이지당 봉이 적음)에서도 전일 페이지 상한이 폭주를 막는다.""" + from app.services import korea_investment_api as kis_mod + + api = _isolated_api() + today, prev = _today(), _prev() + pages = {"n": 0, "refs": []} + + def rows(n): + if n == 1: + return [_raw(today, "090000")] + # 페이지마다 5분봉 1개분(같은 버킷 안 1분봉 2개)만 채워지는 희소 종목 + base = 900 - (n - 1) * 5 + return [_raw(prev, f"{(base - i) // 60:02d}{(base - i) % 60:02d}00") + for i in range(2)] + + _wire(monkeypatch, kis_mod, api, _page_fake(pages, rows)) + await api.get_minute_chart_full_day_v2("005930", include_prev_day=True) + + assert pages["n"] <= kis_mod.MINUTE_CHART_PREV_DAY_MAX_PAGES + 1, ( + f"희소 종목에서 {pages['n']}페이지 — 전일 페이지 상한이 동작하지 않는다" + ) + assert pages["n"] < kis_mod.MINUTE_CHART_MAX_PAGES + + +@pytest.mark.asyncio +async def test_warmup_skipped_entirely_when_today_has_20_bars(monkeypatch): + """당일 5분봉이 20개 이상이면 전일 페이지를 한 장도 쓰지 않는다.""" + from app.services import korea_investment_api as kis_mod + + api = _isolated_api() + today = _today() + pages = {"n": 0, "refs": []} + + def rows(n): + # 09:00~10:39 (5분봉 20개) 를 4페이지에 나눠 준다 + start = 639 - (n - 1) * 30 # 10:39 + return [_raw(today, f"{max(540, start - i) // 60:02d}" + f"{max(540, start - i) % 60:02d}00") for i in range(30)] + + _wire(monkeypatch, kis_mod, api, _page_fake(pages, rows)) + out = await api.get_minute_chart_full_day_v2("005930", include_prev_day=True) + + assert all(r["stck_bsop_date"] == today for r in out) + assert pages["n"] <= 4, f"당일만으로 20봉이 차는데 {pages['n']}페이지를 썼다" + + +@pytest.mark.asyncio +async def test_warmup_shortfall_is_logged_as_warning(monkeypatch, caplog): + """워밍업 미달은 warning 으로 남는다 — 배포 후 M-2 확정의 유일한 근거.""" + import logging + + from app.services import korea_investment_api as kis_mod + + api = _isolated_api() + today = _today() + pages = {"n": 0, "refs": []} + + def rows(n): + # 당일 09:00~09:04 (5분봉 1개) 만 존재하고 더 이상 과거가 없는 상황 + return [_raw(today, f"090{m}00") for m in range(4, -1, -1)] if n == 1 else [] + + _wire(monkeypatch, kis_mod, api, _page_fake(pages, rows)) + with caplog.at_level(logging.INFO, logger="app.services.korea_investment_api"): + await api.get_minute_chart_full_day_v2("005930", include_prev_day=True) + + warn = [r for r in caplog.records if r.levelno >= logging.WARNING + and "워밍업" in r.message] + assert warn, "워밍업 미달이 조용히 지나가면 원인을 추적할 수 없다" + assert str(kis_mod.MINUTE_CHART_WARMUP_BARS_5MIN) in warn[0].message diff --git a/backend/tests/test_external_inflow_isolation.py b/backend/tests/test_external_inflow_isolation.py new file mode 100644 index 0000000..777891e --- /dev/null +++ b/backend/tests/test_external_inflow_isolation.py @@ -0,0 +1,267 @@ +"""유입(external) 잔고 격리 — strategy_type='external' 불변식. + +배경(2026-09-28 조사): 모의계좌 교체 시 기존 잔고가 계좌에 그대로 편입됐고, +`sync_portfolio()` 가 시스템 매수 여부를 검사하지 않고 전부 'swing' 으로 태깅해 +`get_swing_holdings()`(strategy_type=='swing')가 곧바로 손절 대상으로 잡았다. +→ 시스템이 매수한 적 없는 잔고를 전략 손절로 처분(고아 청산 9건 / -153,661원). + +사용자 결정: 유입 잔고는 'external' 로 격리하고 자동 손절·익절 대상에서 제외한다 +(그 수량은 손절이 걸리지 않으며 운영자가 직접 판단한다). +""" +import inspect + +import pytest + +from app.api.endpoints import portfolio as pf +from app.api.endpoints.portfolio import ( + EXTERNAL_STRATEGY_TYPE, + _classify_holding_strategy_type as _classify, +) + + +# ── 분류 규칙 (순수 함수) ─────────────────────────────────────────────────── + +def test_no_system_buy_evidence_is_external(): + assert _classify("000100", 8, {}, set()) == EXTERNAL_STRATEGY_TYPE + + +def test_system_bought_is_swing(): + assert _classify("000100", 8, {}, {"000100"}) == "swing" + + +def test_full_dt_quantity_is_daytrading(): + assert _classify("000100", 3, {"000100": 3}, {"000100"}) == "daytrading" + + +def test_mixed_dt_and_extra_quantity_stays_swing(): + """DT 3주 + 스윙 5주 = 8주 → DT 태깅하면 스윙 5주가 청산 관리를 잃는다.""" + assert _classify("000100", 8, {"000100": 3}, {"000100"}) == "swing" + + +def test_dt_position_without_system_evidence_still_external(): + """DT 인메모리가 비어 있고 근거도 없으면 external — '무조건 swing' 폴백 금지.""" + assert _classify("000100", 8, {"000100": 0}, set()) == EXTERNAL_STRATEGY_TYPE + + +# ── sync_portfolio 불변식 (소스 수준) ────────────────────────────────────── + +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 를 근거에서 제외해야 한다" + + +def test_sync_never_demotes_managed_rows_to_external(): + """운용 중인 swing/daytrading 행을 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 + + +def test_sync_skips_active_trade_seeding_for_external(): + """격리분에 ActiveTrade 를 시딩하면 자동 손절 관리가 되살아난다.""" + src = inspect.getsource(pf.sync_portfolio) + assert '(_h.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE' in src + + +def test_sync_logs_external_holdings_as_warning(): + """격리 잔고는 조용히 방치되면 안 된다 — warning 으로 노출(debug 금지, §4).""" + src = inspect.getsource(pf.sync_portfolio) + assert "유입 격리 보유" in src + i = src.index("유입 격리 보유") + assert "logger.warning" in src[max(0, i - 200):i] + + +def test_portfolio_response_exposes_strategy_type(): + """프론트가 격리분을 구분할 수 있어야 한다(필드 노출).""" + assert "strategy_type" in pf.PortfolioResponse.model_fields + + +def test_summary_exposes_external_aggregate(): + src = inspect.getsource(pf.PortfolioCache.cache_summary) + assert '"external_count"' in src and '"external_value"' in src + + +def test_calculate_portfolio_values_counts_external_separately(): + class _H: + def __init__(self, code, qty, price, st): + self.stock_code, self.quantity = code, qty + self.current_price, self.average_price = price, price + self.market, self.strategy_type = "KRX", st + + vals = pf._calculate_portfolio_values([ + _H("005930", 10, 70_000, "swing"), + _H("000100", 8, 50_000, "external"), + ]) + assert vals["external_count"] == 1 + assert vals["external_value"] == pytest.approx(400_000) + # 총자산·평가액에서 빼지 않는다 — 계좌 전체 리스크는 유입분을 포함해야 한다 + assert vals["domestic_value"] == pytest.approx(700_000 + 400_000) + + +# ── 스윙 청산 대상에서 자동 제외되는지 (코드 변경 없이) ──────────────────── + +def test_swing_holdings_query_filters_by_swing_only(): + from app.services.swing import position_manager as pm + src = inspect.getsource(pm.get_swing_holdings) + assert "Portfolio.strategy_type == STRATEGY_TYPE" in src + assert pm.STRATEGY_TYPE == "swing" + + +# ── R2: 수동 청산의 연패 기록 ────────────────────────────────────────────── + +def test_manual_sell_records_consecutive_loss(): + src = inspect.getsource(pf.sell_stock) + assert "record_trade_result_safe" in src, "수동 청산이 연패 카운트에서 빠진다" + i = src.index("record_trade_result_safe") + assert "save_to_db" in src[i:i + 400], "record 후 save_to_db 누락 (§8.1-7)" + # 전량 청산 시에만 + external 제외 + assert "quantity >= original_holding_qty and original_strategy_type != EXTERNAL_STRATEGY_TYPE" in src + + +def test_manual_sell_v2_records_consecutive_loss(): + from app.api.endpoints import portfolio_v2 as pv2 + src = inspect.getsource(pv2.sell_stock_v2) + assert "record_trade_result_safe" in src + i = src.index("record_trade_result_safe") + assert "save_to_db" in src[i:i + 500] + assert 'original_strategy_type != "external"' in src + + +# ── R3: 스윙 손절 종목의 당일 DT 재진입 차단 ─────────────────────────────── + +# ── 손절선 이탈 가시화 (집행 없음) ───────────────────────────────────────── + +class _H: + def __init__(self, code, qty, avg, cur, st="external", name=None): + self.stock_code, self.quantity = code, qty + self.average_price, self.current_price = avg, cur + self.strategy_type, self.stock_name = st, name or code + self.market = "KRX" + + +def test_external_breach_detected_beyond_base_stop(): + from app.core.config import settings + + _sl = abs(settings.STOP_LOSS_PERCENT) + rows = pf._external_stop_breaches([ + _H("000100", 8, 10_000, 10_000 * (1 - (_sl + 3) / 100)), # 이탈 + _H("009830", 5, 10_000, 10_000 * (1 - (_sl - 2) / 100)), # 미이탈 + ]) + assert [r["stock_code"] for r in rows] == ["000100"] + assert rows[0]["profit_loss_rate"] == pytest.approx(-(_sl + 3), abs=0.01) + assert rows[0]["breach_pct"] < 0 + + +def test_non_external_rows_are_not_reported(): + """스윙 행은 실제 손절 경로가 처리한다 — 이 경고는 격리분 전용.""" + assert pf._external_stop_breaches([_H("005930", 10, 10_000, 1_000, st="swing")]) == [] + + +def test_breach_ignores_missing_prices(): + assert pf._external_stop_breaches([ + _H("000100", 8, 0, 5_000), + _H("009830", 8, 10_000, 0), + _H("241560", 0, 10_000, 1_000), + ]) == [] + + +def test_sync_logs_breach_without_executing(): + src = inspect.getsource(pf.sync_portfolio) + assert "_external_stop_breaches(" in src + i = src.index("유입 격리분 손절선 이탈") + assert "logger.warning" in src[max(0, i - 200):i], "§4: 이탈 경고는 debug 금지" + # 경고 블록 안에서 매도 주문을 내지 않는다 + block = src[i:i + 600] + assert "safe_place_order" not in block and "place_order" not in block + + +def test_summary_exposes_breach_list(): + src = inspect.getsource(pf.PortfolioCache.cache_summary) + assert '"external_stop_breaches"' in src + + +def _external_row(): + from app.models.models import Portfolio + return Portfolio( + stock_code="000100", stock_name="유한양행", quantity=8, + average_price=10_000, current_price=10_000, market="KRX", + strategy_type=EXTERNAL_STRATEGY_TYPE, + ) + + +@pytest.mark.asyncio +async def test_reflect_buy_promotes_external_row_on_swing_buy(): + """격리 행에 스윙 시스템 매수가 들어오면 승격 — 다음 sync 까지 무관리 방지.""" + from unittest.mock import AsyncMock, patch + + from app.services import portfolio_cache_bridge as bridge + + row = _external_row() + + 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 == "swing" + assert _enq.await_args.args[0]["strategy_type"] == "swing" + + +@pytest.mark.asyncio +async def test_reflect_buy_keeps_external_on_daytrading_buy(): + """DT 매수는 격리 태그를 바꾸지 않는다(격리 수량이 external 표시에서 사라짐 방지).""" + from unittest.mock import AsyncMock, patch + + from app.services import portfolio_cache_bridge as bridge + + row = _external_row() + + 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", "유한양행", 3, 11_000, strategy_type="daytrading") + + assert row.strategy_type == EXTERNAL_STRATEGY_TYPE + assert "strategy_type" not in _enq.await_args.args[0] + + +# ── MDD 강제 축소는 격리분도 대상 (계좌 생존 장치) ───────────────────────── + +def test_mdd_reduction_does_not_exclude_external(): + """MDD -10%/-20% 축소는 계좌 전체 리스크 감축이므로 격리분도 매도 대상이다. + + 평시 전략 손절만 제외한다. `execute_reduction()` 은 KIS 잔고(output1)를 직접 + 순회하므로 strategy_type 필터가 없어야 축소 목표(30%/50%)를 채울 수 있다. + 여기에 strategy_type 제외가 들어오면 -20% 국면에서 줄일 수 있는 포지션이 + 장부상 줄어들어 리스크 감축이 약해진다. + """ + from app.services import mdd_manager as mm + + src = inspect.getsource(mm.MDDManager.execute_reduction) + assert 'balance.get("output1"' in src, "축소 대상 소스가 KIS 잔고가 아니다" + # 'strategy_type' 자체는 TradeHistory 태그("mdd_reduction")로 등장한다 — + # 금지 대상은 격리분 제외 필터다. + assert EXTERNAL_STRATEGY_TYPE not in src, "MDD 축소에서 격리분을 제외하고 있다" + assert "select(Portfolio" not in src, "축소 대상을 Portfolio(전략 태그) 기준으로 좁히고 있다" + + +def test_swing_full_exit_blocks_same_day_dt_reentry(): + from app.services.swing import exit_service as ses + src = inspect.getsource(ses.check_and_execute_exit_v2) + assert "closed_today.add(stock_code)" in src, "전략 간 당일 쿨다운 누락" + i = src.index("closed_today.add(stock_code)") + assert "trade_lock" in src[max(0, i - 300):i], "closed_today 변경은 trade_lock 규약" diff --git a/backend/tests/test_p1_audit_fixes.py b/backend/tests/test_p1_audit_fixes.py index dde5c7a..cc5f307 100644 --- a/backend/tests/test_p1_audit_fixes.py +++ b/backend/tests/test_p1_audit_fixes.py @@ -136,9 +136,21 @@ def test_executor_sell_excludes_daytrading_shares(): def test_sync_dt_tagging_applies_to_domestic_and_survives_missing_output1(): + """DT 태깅이 국내(신규·기존)·해외 4경로 모두에 적용되는지. + + [2026-09-28] 유입(external) 격리 도입으로 **신규 행** 2곳의 판정이 인라인 비교에서 + 순수 함수 `_classify_holding_strategy_type`(= DT 전량 → daytrading, 시스템 매수 + 근거 없음 → external) 위임으로 바뀌었다. 인라인 비교는 기존 행 갱신부(국내·해외) + 2곳에만 남는다. 불변식(4경로 전부 태깅 + `_dt_qty` 가 output1 블록보다 앞)은 동일. + """ from app.api.endpoints import portfolio as pf src = inspect.getsource(pf.sync_portfolio) - # 국내 신규/기존 행 + 해외 = 태깅 지점 4곳 - assert src.count('quantity <= _dt_qty.get(stock_code, 0)') == 4 + # 기존 행 갱신부(국내·해외) = 인라인 DT 비교 2곳 + assert src.count('quantity <= _dt_qty.get(stock_code, 0)') == 2 + # 신규 행(국내·해외) = 분류 함수 위임 2곳 + assert src.count("_classify_strategy_type(stock_code, quantity)") == 2 + # 분류 함수가 DT 전량 판정을 보유 + pure_src = inspect.getsource(pf._classify_holding_strategy_type) + assert 'return "daytrading"' in pure_src and "quantity <= _dt_held" in pure_src # 정의가 output1 블록보다 앞이어야 NameError 가 없다 assert src.index("_dt_codes = set(_dt_qty)") < src.index('if "output1" in balance_data') diff --git a/backend/tests/test_position_cap_hard_ceiling.py b/backend/tests/test_position_cap_hard_ceiling.py new file mode 100644 index 0000000..8b5821b --- /dev/null +++ b/backend/tests/test_position_cap_hard_ceiling.py @@ -0,0 +1,109 @@ +"""종목당 4% 절대 상한(MAX_POSITION_SIZE) 회귀 테스트 — CLAUDE.md §5.1 / §4. + +배경(2026-09-27): 패턴메모리 조정 뒤의 상한 재클램프가 `try` 블록 **안**에 있어 +`get_pattern_adjusted_invest_amount()` 가 예외를 던지면 클램프를 건너뛰고 +적응형 포지션(6~7%)이 그대로 주문 금액이 됐다. §4 는 "리스크 가드·한도 클램프를 +`try` 안에 두지 말 것" 으로 이것을 규칙화했다. + +여기서는 두 층을 검증한다. + 1) 구조: `_execute_buy` 의 절대 상한 클램프가 패턴메모리 `try` **밖**에 있다 + (예외 발생 케이스를 실제 주문 경로 없이 확정적으로 잡는다). + 2) 수치: 적응형 Normal(6%)·Aggressive(7%) 산출액이 클램프 후 총자산 대비 + MAX_POSITION_SIZE 를 넘지 않는다. + +기대값은 전부 `settings.*` 참조다(§4.1 — config 값 하드코딩 금지). +""" +import ast +import inspect + +import pytest + +from app.core.config import settings +from app.services import trade_executor +from app.services.sizing_core import clamp_by_ratios + + +# ── 1) 구조 검증 ──────────────────────────────────────────────────────────── + +def _execute_buy_ast() -> ast.AST: + src = inspect.getsource(trade_executor._execute_buy) + return ast.parse(ast.unparse(ast.parse(_dedent(src)))) + + +def _dedent(src: str) -> str: + import textwrap + return textwrap.dedent(src) + + +def _contains_call(node: ast.AST, name: str) -> bool: + return any( + isinstance(n, ast.Call) + and ( + (isinstance(n.func, ast.Name) and n.func.id == name) + or (isinstance(n.func, ast.Attribute) and n.func.attr == name) + ) + for n in ast.walk(node) + ) + + +def _mentions_max_position_size(node: ast.AST) -> bool: + return any( + isinstance(n, ast.Attribute) and n.attr == "MAX_POSITION_SIZE" + for n in ast.walk(node) + ) + + +def test_pattern_memory_clamp_is_outside_try(): + """패턴메모리 호출은 try 안, 절대 상한 클램프는 그 try 뒤의 문장이어야 한다.""" + tree = _execute_buy_ast() + fn = tree.body[0] + assert isinstance(fn, (ast.AsyncFunctionDef, ast.FunctionDef)) + + body = fn.body + pm_try_idx = None + for idx, stmt in enumerate(body): + if isinstance(stmt, ast.Try) and _contains_call(stmt, "get_pattern_adjusted_invest_amount"): + pm_try_idx = idx + break + assert pm_try_idx is not None, "패턴메모리 조정 try 블록을 찾지 못했다" + + # try 블록 안에는 절대 상한 클램프가 없어야 한다 + assert not ( + _contains_call(body[pm_try_idx], "clamp_by_ratios") + and _mentions_max_position_size(body[pm_try_idx]) + ), "절대 상한 클램프가 패턴메모리 try 안에 있다 (예외 시 가드가 통과된다)" + + # try 이후 문장 중에 MAX_POSITION_SIZE 클램프가 있어야 한다 + after = body[pm_try_idx + 1:] + assert any( + _contains_call(s, "clamp_by_ratios") and _mentions_max_position_size(s) + for s in after + ), "패턴메모리 try 뒤에 MAX_POSITION_SIZE 재클램프가 없다" + + +def test_adaptive_position_cap_is_clamped_at_source(): + """적응형 position_size 를 소비할 때 min(..., MAX_POSITION_SIZE) 가 적용된다.""" + src = inspect.getsource(trade_executor._execute_buy) + assert "min(_adp.position_size, settings.MAX_POSITION_SIZE)" in src + + +# ── 2) 수치 검증 ──────────────────────────────────────────────────────────── + +TOTAL_ASSETS = 10_000_000.0 +AVAILABLE_CASH = 10_000_000.0 + + +@pytest.mark.parametrize("mode_position_size", [0.06, 0.07]) # Normal / Aggressive +def test_adaptive_modes_never_exceed_hard_cap(mode_position_size): + """적응형 6~7% 예산도 최종 클램프 후에는 MAX_POSITION_SIZE 이하.""" + adaptive_cap = min(mode_position_size, settings.MAX_POSITION_SIZE) + raw = TOTAL_ASSETS * adaptive_cap + final = clamp_by_ratios(raw, TOTAL_ASSETS, [settings.MAX_POSITION_SIZE], AVAILABLE_CASH) + assert final / TOTAL_ASSETS <= settings.MAX_POSITION_SIZE + 1e-9 + + +def test_pattern_memory_uplift_is_clamped(): + """패턴메모리가 +30% 상향해도 최종 클램프가 상한을 지킨다.""" + raw = TOTAL_ASSETS * settings.MAX_POSITION_SIZE * 1.3 + final = clamp_by_ratios(raw, TOTAL_ASSETS, [settings.MAX_POSITION_SIZE], AVAILABLE_CASH) + assert final / TOTAL_ASSETS <= settings.MAX_POSITION_SIZE + 1e-9 diff --git a/backend/tests/test_restart_durability_20260928.py b/backend/tests/test_restart_durability_20260928.py new file mode 100644 index 0000000..f5dd444 --- /dev/null +++ b/backend/tests/test_restart_durability_20260928.py @@ -0,0 +1,1404 @@ +"""재기동 내구성 회귀 테스트 (2026-09-28) + +## 사건 +장중 재기동 시 DT 포지션이 고아가 되고 당일 ORB 가 영구 0건이 됐다. +근본원인: `daytrading/scheduler.py` 가 복원(`load_orb_cache` / `restore_from_db`)을 +마친 뒤, 첫 루프에서 `reset_if_new_day()` 를 호출한다. 새 프로세스는 +`last_reset_date is None` 이고 복원 경로가 이 필드를 설정하지 않으므로 조건이 +무조건 참이 되어 **방금 복원한 positions/orb_cache 를 전부 지웠다.** +복원 성공 로그는 그대로 출력되므로 조용히 실패했다. + +## 검증하는 불변식 2개 (둘이 동시에 통과해야 한다) +(a) 새 프로세스 기동 + 복원 → 첫 루프의 reset 후에도 포지션·ORB 가 살아있다 +(b) 날짜가 실제로 바뀌면 정상 초기화되고 closed_today / daily_pnl / + loss_streak_today 가 리셋된다 (§7 일일 -3% 한도·3연패 쿨다운·재진입 차단 보호) + +추가: F2(ORB 전일 캔들 혼입 차단), F3(종료 drain 이 포지션 추적 계열 커밋), +F4(DLQ 재처리가 큐 경유). +""" +from datetime import date, datetime, time as dtime, timedelta +from unittest.mock import AsyncMock + +import pytest + +from app.services.daytrading.position_manager import DayTradingState + + +def _fresh_state() -> DayTradingState: + """기동 직후의 새 프로세스 상태 — last_reset_date is None.""" + st = DayTradingState() + assert st.last_reset_date is None + return st + + +def _simulate_restore(st: DayTradingState) -> None: + """load_orb_cache() / restore_from_db() 가 채워 넣는 것과 동일한 결과.""" + st.orb_cache["005930"] = object() + st.positions["005930"] = { + "stock_name": "삼성전자", + "strategy": "ORB", + "entry_price": 70000, + "stop_loss": 68600, + "target_price": 72100, + "quantity": 10, + "highest_price": 70500, + "entry_time": datetime.now(), + "restored_from_db": True, + } + + +# ── (a) 새 프로세스 기동 + 복원 → 첫 루프에서 지워지지 않는다 ──────────────── + +def test_restored_state_survives_first_scheduler_cycle(): + st = _fresh_state() + + # 스케줄러 기동 시퀀스: begin_restore() → 복원 + st.begin_restore() + _simulate_restore(st) + + # 첫 루프의 reset_if_new_day() + reset_fired = st.reset_if_new_day() + + assert reset_fired is False, "같은 날 첫 루프의 reset 은 no-op 이어야 한다" + assert "005930" in st.positions, "복원한 포지션이 첫 루프에서 지워졌다" + assert "005930" in st.orb_cache, "복원한 ORB 캐시가 첫 루프에서 지워졌다" + + +def test_without_begin_restore_the_bug_reproduces(): + """회귀 가드: begin_restore() 없이는 첫 루프가 복원분을 지운다(사건 재현).""" + st = _fresh_state() + _simulate_restore(st) + + assert st.reset_if_new_day() is True + assert st.positions == {} + assert st.orb_cache == {} + + +def test_begin_restore_sets_today_boundary(): + st = _fresh_state() + st.begin_restore() + from app.services.daytrading.position_manager import _today_kst + assert st.last_reset_date == _today_kst() + + +# ── (b) 날짜 경과 → 정상 초기화 (일일 게이트 누수 방지) ────────────────────── + +def test_new_day_resets_daily_gates(): + st = _fresh_state() + st.begin_restore() + _simulate_restore(st) + + # 전일 상태 축적: 손실 한도·연패 쿨다운·재진입 차단 + st.closed_today.add("000660") + st.update_pnl(-500000.0, is_full_close=True) + st.update_pnl(-300000.0, is_full_close=True) + st.last_total_assets = 12_345_678.0 + assert st.loss_streak_today == 2 + assert st.daily_pnl == -800000.0 + + # 자정 경과 (프로세스는 계속 가동) + st.last_reset_date = st.last_reset_date - timedelta(days=1) + + assert st.reset_if_new_day() is True, "날짜가 바뀌면 반드시 초기화되어야 한다" + assert st.closed_today == set(), "전일 closed_today 누수 → 당일 재진입 차단 오작동" + assert st.daily_pnl == 0.0, "전일 손익 누수 → 일일 -3% 한도 오작동" + assert st.loss_streak_today == 0, "전일 연패 누수 → 3연패 쿨다운 오작동" + assert st.positions == {} + assert st.orb_cache == {} + assert st.last_total_assets == 0.0 + assert st.portfolio_cache_dirty is True + + +def test_begin_restore_after_date_change_also_resets(): + """장 마감 후 자정을 넘겨 재기동해도 전일 상태를 물려받지 않는다.""" + st = _fresh_state() + st.begin_restore() + st.closed_today.add("005930") + st.loss_streak_today = 3 + st.last_reset_date = st.last_reset_date - timedelta(days=3) + + assert st.begin_restore() is True + assert st.closed_today == set() + assert st.loss_streak_today == 0 + assert st.is_cooldown_active() is False + + +# ── F2: ORB 소급수집 — 전일 캔들 혼입 차단 ────────────────────────────────── + +def test_filter_today_candles_drops_previous_day(): + """get_minute_chart_full_day 는 FID_PW_DATA_INCU_YN='Y' 라 전일 봉이 섞인다. + + calculate_opening_range 는 시각만 보고 날짜를 보지 않으므로 필터가 없으면 + 전일 09:00~09:29 봉이 ORB 고저·거래량에 합산된다. + """ + from app.services.daytrading.entry_service import filter_today_candles + from app.services.day_trading_strategy import ( + MinuteCandle, calculate_opening_range, + ) + import pytz + + today = datetime.now(pytz.timezone("Asia/Seoul")).strftime("%Y%m%d") + prev = ( + datetime.now(pytz.timezone("Asia/Seoul")) - timedelta(days=1) + ).strftime("%Y%m%d") + + def _c(d, hhmm, o, h, l, c, v): + return MinuteCandle( + date=d, time=hhmm + "00", open=o, high=h, low=l, close=c, + volume=v, cum_volume=v, cum_amount=float(c * v), + ) + + candles = [ + # 전일 09:00~09:05 — 훨씬 넓은 레인지 + 큰 거래량 (오염원) + _c(prev, "0900", 50000, 60000, 40000, 55000, 100000), + _c(prev, "0901", 55000, 61000, 41000, 56000, 100000), + _c(prev, "0902", 56000, 62000, 42000, 57000, 100000), + _c(prev, "0903", 57000, 63000, 43000, 58000, 100000), + _c(prev, "0904", 58000, 64000, 44000, 59000, 100000), + # 당일 09:00~09:04 — 좁은 레인지 + _c(today, "0900", 70000, 70500, 69800, 70200, 1000), + _c(today, "0901", 70200, 70600, 70000, 70400, 1100), + _c(today, "0902", 70400, 70700, 70100, 70500, 1200), + _c(today, "0903", 70500, 70800, 70200, 70600, 1300), + _c(today, "0904", 70600, 70900, 70300, 70700, 1400), + ] + + filtered = filter_today_candles(candles) + assert len(filtered) == 5 + assert all(c.date == today for c in filtered) + + orb = calculate_opening_range(filtered) + assert orb is not None + assert orb.high == 70900 + assert orb.low == 69800 + assert orb.open_price == 70000 + assert orb.total_volume == 6000 + + # 필터 없이 넘기면 오염된다 (회귀 가드) + dirty = calculate_opening_range(candles) + assert dirty is not None + assert dirty.low == 40000, "전일 저가가 ORB 저점을 오염시킨다" + assert dirty.total_volume == 506000, "전일 거래량이 평균분당거래량을 오염시킨다" + assert dirty.open_price == 50000, "전일 시가가 갭 필터 기준을 오염시킨다" + + +def test_filter_today_candles_passthrough_when_no_date(): + """stck_bsop_date 가 비어 있는 응답에서 전체가 탈락하면 안 된다.""" + from app.services.daytrading.entry_service import filter_today_candles + from app.services.day_trading_strategy import MinuteCandle + + candles = [ + MinuteCandle(date="", time="090000", open=1, high=2, low=1, close=2, + volume=1, cum_volume=1, cum_amount=2.0) + ] + assert filter_today_candles(candles) == candles + + +# ── F3: 종료 drain 이 포지션 추적 계열을 커밋한다 ──────────────────────────── + +class _FakeSession: + """AsyncSession 대체 — SELECT 결과를 주입하고 commit 횟수를 센다.""" + + def __init__(self, record=None): + self._record = record + self.commits = 0 + self.added = [] + + async def execute(self, *a, **k): + rec = self._record + + class _Scalars: + def first(self_inner): + return rec + + def all(self_inner): + return [rec] if rec is not None else [] + + class _Res: + def scalars(self_inner): + return _Scalars() + + def scalar_one_or_none(self_inner): + return rec + + return _Res() + + async def commit(self): + self.commits += 1 + + def add(self, obj): + self.added.append(obj) + + +class _FakeActiveTrade: + """ActiveTrade 행 대체 — setattr 결과를 그대로 보관한다.""" + + def __init__(self, **kw): + self.stock_code = kw.get("stock_code") + self.status = "active" + self.quantity = kw.get("quantity") + self.remaining_pct = kw.get("remaining_pct", 1.0) + self.highest_since_entry = kw.get("highest_since_entry") + self.updated_at = None + + +@pytest.mark.asyncio +async def test_upsert_active_trade_updates_existing_row_and_commits(): + """[C1 회귀] 기존 active 행에 대한 UPDATE 가 실제로 반영되고 commit 된다. + + 2026-09-28: 핸들러 추출 시 `for key, val in data.items()` 안의 `continue` 가 + `return` 으로 잘못 바뀌어, **'stock_code' 가 모든 payload 의 첫 키**인 탓에 + 기존 행 UPDATE 가 전부 첫 이터레이션에서 함수를 빠져나갔다. 예외가 없어 워커는 + 성공 처리하고 DLQ 에도 남지 않는 조용한 전면 무효화였다. + 파급: 분할매도 잔량 갱신(2026-09-23 '4주 보유 중 7주 매도' 재발 경로), + remaining_pct_multiply(MDD 축소), highest_since_entry(트레일링) 전부. + + 소스 문자열 매칭이 아니라 **핸들러를 실제 호출해** 필드 갱신 + commit 1회를 본다. + """ + from app.services import db_worker + + record = _FakeActiveTrade(stock_code="005930", quantity=10, remaining_pct=1.0) + session = _FakeSession(record=record) + + await db_worker._handle_upsert_active_trade(session, { + "stock_code": "005930", # ← 첫 키. continue 가 아니면 여기서 탈출한다 + "quantity": 4, # 분할매도 후 잔량 + "highest_since_entry": 71000.0, # 트레일링 기준가 + "strategy_name": "day_trade", + }) + + assert record.quantity == 4, "분할매도 잔량 갱신이 반영되지 않았다 (과다매도 경로)" + assert record.highest_since_entry == 71000.0, "트레일링 기준가 갱신이 반영되지 않았다" + assert session.commits == 1, "commit() 에 도달하지 못했다" + + +@pytest.mark.asyncio +async def test_upsert_active_trade_applies_remaining_pct_multiply(): + """[C1 회귀] MDD 축소의 remaining_pct_multiply 가 계산 후 커밋된다.""" + from app.services import db_worker + + record = _FakeActiveTrade(stock_code="000660", quantity=10, remaining_pct=0.7) + session = _FakeSession(record=record) + + await db_worker._handle_upsert_active_trade(session, { + "stock_code": "000660", + "remaining_pct_multiply": 0.5, + "quantity": 5, + }) + + assert record.remaining_pct == pytest.approx(0.35) + assert record.quantity == 5 + assert session.commits == 1 + + +@pytest.mark.asyncio +async def test_upsert_active_trade_skips_create_without_required_keys(): + """active 행이 없고 필수 키가 없으면 생성하지 않고 조용히 끝낸다 (기존 동작).""" + from app.services import db_worker + + session = _FakeSession(record=None) + await db_worker._handle_upsert_active_trade(session, {"stock_code": "005930"}) + assert session.added == [] + assert session.commits == 0 + + +@pytest.mark.asyncio +async def test_close_and_portfolio_handlers_commit(): + """close_active_trade / upsert_portfolio 도 실제로 커밋한다.""" + from app.services import db_worker + + record = _FakeActiveTrade(stock_code="005930", quantity=10) + session = _FakeSession(record=record) + await db_worker._handle_close_active_trade( + session, {"stock_code": "005930", "strategy_name": "day_trade"} + ) + assert record.status == "closed" + assert session.commits == 1 + + class _Pf: + def __init__(self): + self.stock_code = "005930" + self.quantity = 10 + + pf = _Pf() + session2 = _FakeSession(record=pf) + await db_worker._handle_upsert_portfolio( + session2, {"stock_code": "005930", "quantity": 4} + ) + assert pf.quantity == 4 + assert session2.commits == 1 + + +@pytest.mark.asyncio +async def test_drain_commits_position_tracking_types(): + """종료 drain 이 포지션 추적 계열을 DLQ 가 아니라 공통 핸들러로 커밋한다. + + 배선(어느 핸들러가 걸렸는지)은 소스로, 커밋 동작은 위 핸들러 테스트로 각각 본다. + """ + import inspect + from app.services import db_worker + + src = inspect.getsource(db_worker.db_writer_worker) + drain = src[src.index("큐 drain"):] + for t, handler in ( + ("upsert_portfolio", "_handle_upsert_portfolio"), + ("upsert_active_trade", "_handle_upsert_active_trade"), + ("close_active_trade", "_handle_close_active_trade"), + ): + assert f'drain_type == "{t}"' in drain, f"{t} drain 분기 없음" + assert handler in drain, f"{t} 가 공통 핸들러를 쓰지 않는다" + + # 정상 경로도 같은 핸들러를 써야 한다 (복제 금지) + for handler in ("_handle_upsert_portfolio", "_handle_upsert_active_trade", + "_handle_close_active_trade"): + assert src.count(handler) >= 2, f"{handler} 가 한쪽 경로에만 배선됨" + + +def test_no_stray_return_in_active_trade_update_loop(): + """[C1] UPDATE 루프의 스킵은 return 이 아니라 continue 여야 한다.""" + import inspect + from app.services import db_worker + + src = inspect.getsource(db_worker._handle_upsert_active_trade) + loop = src[src.index("for key, val in data.items():"):] + # 주석은 제외하고 실행문만 본다 (주석에 'return' 이라는 단어가 들어 있다) + code = "\n".join( + l for l in loop.splitlines() if l.strip() and not l.strip().startswith("#") + ) + assert "continue" in code + assert "return" not in code, "루프 안 return 은 commit 을 건너뛴다" + + +# ── F4: DLQ 재처리는 큐를 경유한다 (직접 DB 쓰기 금지) ────────────────────── + +@pytest.mark.asyncio +async def test_replay_dlq_requeues_and_moves_files(tmp_path, monkeypatch): + 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") + + import json as _json + good = dlq / "20260928_100000_upsert_active_trade_abcd1234.json" + 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", + }), encoding="utf-8") + broken = dlq / "20260928_100001_upsert_portfolio_deadbeef.json" + broken.write_text("{not json", encoding="utf-8") + + enqueued = [] + + async def _fake_enqueue(task): + enqueued.append(task) + + monkeypatch.setattr(db_worker, "enqueue_db_write", _fake_enqueue) + + count = await db_worker.replay_dlq() + + assert count == 1 + assert len(enqueued) == 1 + assert enqueued[0]["type"] == "upsert_active_trade" + assert "_retry_count" not in enqueued[0], "재시도 카운터가 초기화되어야 한다" + # 처리분은 이동 → 다음 기동에서 재처리 무한루프가 생기지 않는다 + assert not good.exists() + assert (dlq / "processed" / good.name).exists() + # 손상 파일은 남겨 사람이 본다 + assert broken.exists() + + # 두 번째 호출은 손상 파일만 남으므로 재큐잉 0건 + enqueued.clear() + assert await db_worker.replay_dlq() == 0 + assert enqueued == [] + + +@pytest.mark.asyncio +async def test_replay_dlq_empty_dir(tmp_path, monkeypatch): + from app.services import db_worker + monkeypatch.setattr(db_worker, "DLQ_DIR", tmp_path / "nope") + assert await db_worker.replay_dlq() == 0 + + +# ── F6: 스캔 처리량 — 선조회·라운드로빈·시간예산 ──────────────────────────── + +@pytest.mark.asyncio +async def test_prefetch_scans_all_watchlist_within_budget(monkeypatch): + """20종목 감시목록이 한 사이클에 전부 스캔된다. + + 이전에는 종목별 분봉 조회가 순차라 스케줄러 _guard(180초)에 잘려 앞쪽 + 5~6종목만 검사됐다. 선조회를 제한 동시성으로 돌려 예산 안에 들어오게 한다. + """ + from app.services.daytrading import entry_service as es + from app.services.daytrading.position_manager import day_trading_state + + codes = [f"{i:06d}" for i in range(1, 21)] + fetched = [] + + async def fake_fetch(code): + fetched.append(code) + await __import__("asyncio").sleep(0.05) # 조회 지연 모사 + return [object()] + + monkeypatch.setattr(es, "_fetch_candles_impl", fake_fetch) + es._candle_cache.clear() + + n = await es._prefetch_minute_charts(codes) + + assert n == 20, "20종목 모두 선조회되어야 한다" + assert sorted(fetched) == sorted(codes) + # 동시성이 걸려 있어 순차(20×0.05=1.0초)보다 빠르다 + assert es._SCAN_PREFETCH_CONCURRENCY >= 2 + # 캐시 히트는 재조회하지 않는다 + fetched.clear() + assert await es._prefetch_minute_charts(codes) == 20 + assert fetched == [] + day_trading_state.positions.clear() + + +@pytest.mark.asyncio +async def test_prefetch_slow_stock_does_not_block_others(monkeypatch): + """한 종목이 느려도 나머지는 스캔된다 (종목 단위 타임아웃).""" + import asyncio as aio + from app.services.daytrading import entry_service as es + + async def fake_fetch(code): + if code == "000001": + await aio.sleep(10) + return [object()] + + monkeypatch.setattr(es, "_fetch_candles_impl", fake_fetch) + monkeypatch.setattr(es, "_SCAN_PREFETCH_TIMEOUT", 0.05) + es._candle_cache.clear() + + n = await es._prefetch_minute_charts(["000001", "000002", "000003"]) + assert n == 2 + assert "000001" not in es._candle_cache + + +@pytest.mark.asyncio +async def test_get_candles_cached_falls_back_to_fetch(monkeypatch): + """선조회 miss 면 그 자리에서 조회한다 (_try_enter 시그니처 무변경).""" + import inspect + from app.services.daytrading import entry_service as es + + calls = [] + + async def fake_fetch(code): + calls.append(code) + return [object()] + + monkeypatch.setattr(es, "_fetch_candles_impl", fake_fetch) + es._candle_cache.clear() + + assert await es._get_candles_cached("005930") + assert calls == ["005930"] + assert await es._get_candles_cached("005930") + assert calls == ["005930"], "캐시 히트 시 재조회 금지" + + # §4: 기존 시그니처 무변경 (테스트 목이 빗나가지 않게) + sig = list(inspect.signature(es._try_enter).parameters) + assert sig == ["stock_code", "stock_name", "total_assets", + "available_cash", "is_lunch_break"] + + +def test_scan_time_budget_below_guard_timeout(): + """시간 예산이 스케줄러 _guard 타임아웃보다 작아야 '취소' 대신 '로그'로 끝난다.""" + from app.services.daytrading import entry_service as es + 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, "잘린 종목 수를 로그에 남겨야 한다" + + +# ── F7: 거래량 게이트 — 형성 중 봉이 아니라 완성봉끼리 비교 ───────────────── + +def _c5(vol, close=101.0, open_=100.0): + from app.services.day_trading_strategy import MinuteCandle + return MinuteCandle(date="20260928", time="090000", open=open_, high=close + 1, + low=open_ - 1, close=close, volume=vol, + cum_volume=vol, cum_amount=float(close * vol)) + + +def test_volume_surge_ratio_uses_completed_bars(): + from app.services.day_trading_strategy import volume_surge_ratio + + # [-3]=1000(완성), [-2]=1500(완성), [-1]=100(형성 중) + candles = [_c5(900), _c5(1000), _c5(1500), _c5(100)] + assert volume_surge_ratio(candles) == pytest.approx(1.5) + + # 완성봉이 2개 미만이면 None + assert volume_surge_ratio([_c5(100), _c5(200)]) is None + # 분모 0 방어 + assert volume_surge_ratio([_c5(0), _c5(100), _c5(50)]) is None + + +def test_vwap_and_rsi_bb_gate_pass_with_forming_bar_partial_volume(): + """형성 중 봉의 부분 거래량 때문에 탈락하던 케이스가 통과한다. + + 임계값(DAY_TRADE_VOLUME_MULTIPLIER)은 바꾸지 않았다 — 비교 대상만 고쳤다. + """ + from app.core.config import settings + from app.services.day_trading_strategy import ( + MinuteCandle, check_vwap_bounce_signal, check_rsi_bb_signal, + volume_surge_ratio, + ) + + mult = settings.DAY_TRADE_VOLUME_MULTIPLIER + # 완성봉 거래량이 mult 배로 늘었지만, 형성 중 봉은 아직 1/5 만 찼다 + prev_vol = 1000 + last_vol = int(prev_vol * mult) + 10 + forming_vol = last_vol // 5 + + # RSI 과매도 + BB 하단 이탈을 만드는 하락 시퀀스 (20봉 이상) + closes = [200 - i * 1.0 for i in range(22)] + [170.0, 160.0] + candles = [] + for i, c in enumerate(closes): + vol = 1000 + if i == len(closes) - 3: + vol = prev_vol + elif i == len(closes) - 2: + vol = last_vol + elif i == len(closes) - 1: + vol = forming_vol + candles.append(MinuteCandle( + date="20260928", time=f"09{i:02d}00", + open=c - 1.0, high=c + 1.0, low=c - 2.0, close=c, + volume=vol, cum_volume=vol * (i + 1), cum_amount=float(c * vol), + )) + # 마지막 형성 중 봉은 양봉 (반등) + last = candles[-1] + candles[-1] = MinuteCandle( + date=last.date, time=last.time, open=last.close - 3.0, high=last.close + 1.0, + low=last.close - 4.0, close=last.close, volume=last.volume, + cum_volume=last.cum_volume, cum_amount=last.cum_amount, + ) + + # 게이트 자체: 수정 후 통과 / 수정 전 방식이라면 탈락 + assert volume_surge_ratio(candles) >= mult + assert candles[-1].volume < candles[-2].volume * mult, "옛 비교 방식이라면 탈락하는 데이터" + + price = candles[-1].close + sig_rsi_bb = check_rsi_bb_signal("005930", "삼성전자", candles, price) + assert sig_rsi_bb is not None, "거래량 게이트가 형성 중 봉 때문에 막히면 안 된다" + # §4: description 에 임계값 하드코딩 금지 — 실효값이 들어간다 + assert f"{mult}" in sig_rsi_bb.reason + assert "완성봉 기준" in sig_rsi_bb.reason + + sig_vwap = check_vwap_bounce_signal("005930", "삼성전자", candles, price, price) + assert sig_vwap is not None + assert f"{mult}" in sig_vwap.reason + + +def test_volume_threshold_unchanged(): + """임계값은 건드리지 않았다 (완화 아님).""" + from app.core.config import settings + assert settings.DAY_TRADE_VOLUME_MULTIPLIER == 1.3 + assert settings.DAY_TRADE_VWAP_RSI_THRESHOLD == 45.0 + assert settings.DAY_TRADE_RSI_BB_RSI_THRESHOLD == 35.0 + + +# ── F8: watchlist 교체가 ORB 보유 종목을 버리지 않는다 ────────────────────── + +@pytest.mark.asyncio +async def test_watchlist_refresh_preserves_orb_holders(monkeypatch): + """30분 갱신(=09:30 첫 사이클 즉시 발동)이 ORB 수집분을 버리면 안 된다.""" + from app.services.daytrading import entry_service as es + from app.services.daytrading.position_manager import day_trading_state + + es.set_watchlist([{"code": "111111", "name": "ORB보유"}, + {"code": "222222", "name": "ORB없음"}]) + day_trading_state.orb_cache.clear() + day_trading_state.set_orb("111111", object()) + + # 당일 배치가 전혀 다른 종목을 상위로 올린 상황 + new_rows = [{"code": "333333", "name": "신규A"}, {"code": "444444", "name": "신규B"}] + + async def fake_query(*a, **k): + return new_rows + + # populate 의 DB 구간만 대체 — SELECT 결과 주입 + class _Row: + def __init__(self, code, name): + self.stock_code = code + self.stock_name = name + self.score = 5 + + class _Res: + def __init__(self, rows): + self._rows = rows + + def all(self): + return self._rows + + def scalar_one_or_none(self): + return date.today() + + class _Session: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + def __init__(self): + self._n = 0 + + async def execute(self, *a, **k): + self._n += 1 + if self._n == 1: + return _Res([]) # max(analysis_date) → scalar_one_or_none + return _Res([_Row(r["code"], r["name"]) for r in new_rows]) + + import app.core.database as dbmod + monkeypatch.setattr(dbmod, "AsyncSessionLocal", lambda: _Session()) + + count = await es.populate_daytrading_watchlist() + wl = es.get_watchlist() + + assert count == 3 + assert "111111" in wl, "ORB 보유 종목이 교체로 유실됐다 → 당일 ORB 진입 불가" + assert "333333" in wl and "444444" in wl + assert "222222" not in wl, "ORB 없는 옛 종목은 교체된다" + assert es.is_watchlist_auto() is True + + day_trading_state.orb_cache.clear() + es.set_watchlist([]) + + +def test_watchlist_uses_latest_analysis_date_not_today_only(): + """e0178aa(2026-09-22)의 `max(analysis_date <= today)` 완화가 살아 있는지.""" + import inspect + 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 + + +def test_scheduler_populates_watchlist_before_orb_collection(): + """09:00~09:30 분기에서 watchlist 구성이 ORB 수집보다 먼저 와야 한다.""" + import inspect + from app.services.daytrading import scheduler as sched + + src = inspect.getsource(sched.daytrading_scheduler) + branch = src[src.index("is_opening_range_period()"):] + i_pop = branch.index("populate_daytrading_watchlist()") + i_orb = branch.index("collect_opening_ranges()") + assert i_pop < i_orb, "ORB 수집보다 watchlist 구성이 먼저여야 한다" + + +# ── F9: 일일 안전장치(daily_pnl/loss_streak_today/closed_today) 영속화 ─────── + +class _FakeRedis: + """setex_json / get_json 대체 — 인메모리 저장소.""" + + def __init__(self): + self.store: dict = {} + + async def setex_json(self, key, ttl, data): + self.store[key] = data + return True + + async def get_json(self, key): + return self.store.get(key) + + +@pytest.fixture +def fake_redis(monkeypatch): + """app.core.redis 를 테스트 시점에 덮는다 (import 시점 패치는 순서 의존 — §4.1).""" + import app.core.redis as rd + fr = _FakeRedis() + monkeypatch.setattr(rd, "setex_json", fr.setex_json) + monkeypatch.setattr(rd, "get_json", fr.get_json) + return fr + + +@pytest.mark.asyncio +async def test_daily_guards_restored_on_same_day_restart(fake_redis): + """같은 날 재기동 → 일일 -3% 한도·3연패 쿨다운·재진입 차단이 복원된다.""" + st = _fresh_state() + st.begin_restore() + st.update_pnl(-250_000.0, is_full_close=True) + st.update_pnl(-150_000.0, is_full_close=True) + st.closed_today.update({"005930", "000660"}) + + assert await st.save_daily_state() is True + + # 새 프로세스 기동 (인메모리 전부 초기값) + st2 = _fresh_state() + st2.begin_restore() + assert st2.daily_pnl == 0.0 and st2.loss_streak_today == 0 and st2.closed_today == set() + + assert await st2.load_daily_state() is True + assert st2.daily_pnl == -400_000.0, "일일 손실한도가 리셋되면 -3% 를 맞은 날 매매가 재개된다" + assert st2.loss_streak_today == 2 + assert st2.closed_today == {"005930", "000660"} + assert st2.is_closed_today("005930") is True, "손절 종목 당일 재진입이 열린다" + + # 연패가 3이면 쿨다운도 복원되어야 한다 + st.update_pnl(-100_000.0, is_full_close=True) + await st.save_daily_state() + st3 = _fresh_state() + st3.begin_restore() + await st3.load_daily_state() + assert st3.loss_streak_today == 3 + assert st3.is_cooldown_active() is True + + +@pytest.mark.asyncio +async def test_daily_guards_not_restored_after_date_change(fake_redis): + """날짜가 바뀐 뒤 기동 → 복원하지 않고 초기화 (F1 불변식 ②와 동시 통과). + + 전일 closed_today / daily_pnl 이 새면 다음 날 매매가 근거 없이 차단되거나 + 전일 손실이 당일 한도를 먹는다. + """ + from app.services.daytrading.position_manager import _today_kst + + st = _fresh_state() + st.begin_restore() + st.update_pnl(-900_000.0, is_full_close=True) + st.closed_today.add("005930") + await st.save_daily_state() + + # 저장된 페이로드의 날짜를 전일로 위조 + 키도 전일로 이동 (자정 경과 모사) + today_key = f"daytrading:daily:{_today_kst()}" + payload = dict(fake_redis.store.pop(today_key)) + payload["date"] = (_today_kst() - timedelta(days=1)).isoformat() + fake_redis.store[today_key] = payload # 키는 오늘, 내용은 전일 → 날짜 게이트가 잡아야 한다 + + st2 = _fresh_state() + st2.begin_restore() + assert await st2.load_daily_state() is False, "전일 페이로드를 복원하면 안 된다" + assert st2.daily_pnl == 0.0 + assert st2.loss_streak_today == 0 + assert st2.closed_today == set() + + # 전일 키만 남아 있고 당일 키가 없는 경우 (정상적인 자정 경과) + fake_redis.store.clear() + fake_redis.store[f"daytrading:daily:{_today_kst() - timedelta(days=1)}"] = payload + st3 = _fresh_state() + st3.begin_restore() + assert await st3.load_daily_state() is False + assert st3.daily_pnl == 0.0 and st3.closed_today == set() + + +@pytest.mark.asyncio +async def test_daily_guard_restore_never_weakens_state(fake_redis): + """복원이 현재 상태를 약화시키지 않는다 (보수적 병합).""" + st = _fresh_state() + st.begin_restore() + st.update_pnl(-100_000.0, is_full_close=True) + st.closed_today.add("005930") + await st.save_daily_state() + + st2 = _fresh_state() + st2.begin_restore() + # 기동 후 이미 더 큰 손실·다른 청산이 발생한 상태에서 복원 + st2.update_pnl(-500_000.0, is_full_close=True) + st2.closed_today.add("000660") + st2.loss_streak_today = 2 + await st2.load_daily_state() + + assert st2.daily_pnl == -500_000.0, "더 보수적인(큰) 손실이 유지되어야 한다" + assert st2.loss_streak_today == 2, "연패는 최대값 유지" + assert st2.closed_today == {"005930", "000660"}, "재진입 차단은 합집합" + + +@pytest.mark.asyncio +async def test_daily_guard_redis_failure_is_not_silent(monkeypatch, caplog): + """Redis 장애 시 조용히 실패하지 않는다 (§4: debug 금지).""" + import logging + import app.core.redis as rd + + async def _boom(*a, **k): + raise RuntimeError("redis down") + + monkeypatch.setattr(rd, "setex_json", _boom) + monkeypatch.setattr(rd, "get_json", _boom) + + st = _fresh_state() + st.begin_restore() + with caplog.at_level(logging.WARNING): + assert await st.save_daily_state() is False + assert await st.load_daily_state() is False + levels = {r.levelno for r in caplog.records} + assert levels and min(levels) >= logging.WARNING, "warning 이상으로 남아야 한다" + assert any("안전장치" in r.message for r in caplog.records) + + +def test_daily_guard_save_is_wired_into_exit_path(): + """청산 경로가 변경 직후 저장을 호출하는지 (배선 누락 방지).""" + import inspect + from app.services.daytrading import exit_service as es + from app.services.daytrading import scheduler as sched + + src = inspect.getsource(es) + assert src.count("save_daily_state()") >= 2, "청산/전일청산 양쪽에 배선되어야 한다" + + # 복원은 begin_restore() 이후여야 한다 (순서가 뒤바뀌면 복원값이 초기화된다) + ssrc = inspect.getsource(sched.daytrading_scheduler) + assert ssrc.index("begin_restore()") < ssrc.index("load_daily_state()") + + +@pytest.mark.asyncio +async def test_mdd_reduction_persists_daily_guards(monkeypatch): + """[F9 후속] MDD 축소가 DT 포지션을 닫으면 일일 안전장치가 저장된다. + + 저장이 없으면 축소 직후 재기동 시 closed_today 가 비어, MDD -10%/-20% 구간에서 + 방금 강제 축소한 종목을 그날 다시 매수할 수 있다. + """ + from app.services import mdd_manager as mm + from app.services.daytrading.position_manager import day_trading_state + + saved = [] + + async def fake_save(): + saved.append(True) + return True + + async def fake_reflect_sell(*a, **k): + return None + + import app.services.portfolio_cache_bridge as pcb + monkeypatch.setattr(pcb, "reflect_sell", fake_reflect_sell) + monkeypatch.setattr(day_trading_state, "save_daily_state", fake_save) + + day_trading_state.positions.clear() + day_trading_state.closed_today.clear() + day_trading_state.positions["005930"] = {"quantity": 5, "entry_price": 70000} + + # 전량 축소 (잔고 잔량 0) → remove_position + closed_today 추가 + await mm._reflect_reduction("005930", sell_qty=5, price=71000, remaining_qty=0) + + assert "005930" not in day_trading_state.positions + assert day_trading_state.is_closed_today("005930") is True + assert saved, "closed_today 가 바뀌었는데 save_daily_state() 가 호출되지 않았다" + + # 부분 축소(포지션 유지)는 closed_today 가 바뀌지 않으므로 저장하지 않는다 + saved.clear() + day_trading_state.positions["000660"] = {"quantity": 10, "entry_price": 200000} + await mm._reflect_reduction("000660", sell_qty=3, price=201000, remaining_qty=7) + assert day_trading_state.positions["000660"]["quantity"] == 7 + assert saved == [] + + day_trading_state.positions.clear() + day_trading_state.closed_today.clear() + + +def test_legacy_day_trading_endpoint_state_is_dead_path(): + """`api/endpoints/day_trading.py` 의 `_closed_today` 는 엔진 상태와 분리된 죽은 경로다. + + 유일한 변경 지점(_check_exits / _execute_eod_flat)은 run_day_trading_cycle 에서만 + 도달하고, 그 호출부는 DEPRECATED 인 schedulers.day_trade_scheduler() 뿐이며 + main.py lifespan 에서 기동되지 않는다. 따라서 저장을 붙이지 않는다. + """ + import inspect + import app.main as main_mod + from app.services import schedulers as sch + + main_src = inspect.getsource(main_mod) + assert "day_trade_scheduler()" not in main_src, ( + "레거시 스케줄러가 기동되면 _closed_today 경로가 되살아난다 → F9 저장 배선 필요" + ) + assert "daytrading_scheduler()" in main_src + assert "DEPRECATED" in inspect.getdoc(sch.day_trade_scheduler) + + +# ── 벽시계 의존 제거 헬퍼 ───────────────────────────────────────────────────── +# +# `scan_and_enter()` 는 `settings.DAY_TRADE_ENTRY_CUTOFF_HOUR/MIN`(기본 15:00) 이후 +# 조기 리턴한다. 시각을 고정하지 않으면 **테스트가 몇 시에 돌리느냐에 따라** 결과가 +# 달라진다(15:05 에 실측 1 failed). 임계값을 하드코딩하지 않고 settings 에서 역산한 +# "컷오프 1시간 전"으로 고정한다(§4.1). + +def _freeze_entry_window(monkeypatch, es): + """진입 윈도우 안으로 시각을 고정한다. **날짜는 실제 오늘 그대로** 둔다. + + 날짜까지 바꾸면 당일 분봉 날짜 비교·상태 리셋 판정과 어긋난다. 고정 대상은 + `entry_service` 모듈이 참조하는 `datetime` 뿐이며, 다른 모듈은 실제 시계를 본다. + """ + import pytz + from app.core.config import settings as _s + + kst = pytz.timezone("Asia/Seoul") + cutoff_h, cutoff_m = _s.DAY_TRADE_ENTRY_CUTOFF_HOUR, _s.DAY_TRADE_ENTRY_CUTOFF_MIN + minutes = max(9 * 60 + 30, cutoff_h * 60 + cutoff_m - 60) # 컷오프 1시간 전(≥09:30) + frozen = kst.localize(datetime.combine( + datetime.now(kst).date(), dtime(minutes // 60, minutes % 60) + )) + + class _FrozenDatetime(datetime): + @classmethod + def now(cls, tz=None): + return frozen if tz is not None else frozen.replace(tzinfo=None) + + monkeypatch.setattr(es, "datetime", _FrozenDatetime) + return frozen + + +# ── C2: 재기동 직후 '첫' 스캔에서 일일 손실한도가 차단되어야 한다 ─────────── + +@pytest.mark.asyncio +async def test_daily_loss_limit_blocks_on_first_scan_after_restart(fake_redis, monkeypatch): + """`last_total_assets` 가 0 이면 첫 사이클에서 -3% 한도가 뚫린다. + + is_daily_loss_exceeded() 는 total_assets<=0 에서 무조건 False 이고, + scan_and_enter() 는 잔고 조회(함수 뒤쪽)보다 **먼저** 이 필드를 읽는다. + """ + from app.core.config import settings + from app.services.day_trading_strategy import is_daily_loss_exceeded + from app.services.daytrading import entry_service as es + from app.services.daytrading.position_manager import DayTradingState + + # -3.5% 를 맞고 중단된 날의 상태를 저장 + st = DayTradingState() + st.begin_restore() + st.last_total_assets = 100_000_000.0 + st.update_pnl(-3_500_000.0, is_full_close=True) + assert await st.save_daily_state() is True + + # 새 프로세스 기동 → 복원 + st2 = DayTradingState() + st2.begin_restore() + assert st2.last_total_assets == 0.0 + assert is_daily_loss_exceeded(st2.last_total_assets, -3_500_000.0) is False, ( + "복원 전에는 게이트가 무력 — 이것이 C2 의 버그 조건" + ) + + assert await st2.load_daily_state() is True + assert st2.last_total_assets == 100_000_000.0 + assert is_daily_loss_exceeded(st2.last_total_assets, st2.daily_pnl) is True, ( + "재기동 직후 첫 스캔에서 일일 -3% 한도가 차단되어야 한다" + ) + + # 실제 scan_and_enter() 의 첫 게이트에서 진입 0건으로 끝나는지 확인 + monkeypatch.setattr(es, "day_trading_state", st2) + 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) + + async def _boom_enter(*a, **k): + raise AssertionError("일일 손실한도를 통과해 진입을 시도했다") + + monkeypatch.setattr(es, "_try_enter", _boom_enter) + _freeze_entry_window(monkeypatch, es) # 컷오프 게이트가 아니라 손실한도로 막혔음을 보장 + es.set_watchlist([{"code": "005930", "name": "삼성전자"}]) + + entries = await es.scan_and_enter() + assert entries == [] + assert settings.DAY_TRADE_DAILY_LOSS_LIMIT < 0 + es.set_watchlist([]) + + +@pytest.mark.asyncio +async def test_last_total_assets_merge_picks_conservative_denominator(fake_redis): + """분모는 작은 쪽(=손실률이 더 크게 나오는 쪽)이 보수적. 0 은 후보에서 제외.""" + from app.services.daytrading.position_manager import DayTradingState + + st = DayTradingState() + st.begin_restore() + st.last_total_assets = 80_000_000.0 + st.daily_pnl = -1_000.0 + await st.save_daily_state() + + st2 = DayTradingState() + st2.begin_restore() + st2.last_total_assets = 120_000_000.0 # 기동 후 조회된 더 큰 총자산 + await st2.load_daily_state() + assert st2.last_total_assets == 80_000_000.0, "큰 분모는 게이트를 느슨하게 만든다" + + # 저장값이 0 이면 현재값을 유지 (0 은 게이트를 무력화하므로 선택 금지) + st3 = DayTradingState() + st3.begin_restore() + st3.last_total_assets = 0.0 + st3.daily_pnl = -1_000.0 + await st3.save_daily_state() + st4 = DayTradingState() + st4.begin_restore() + st4.last_total_assets = 50_000_000.0 + await st4.load_daily_state() + assert st4.last_total_assets == 50_000_000.0 + + +# ── M1: 청산이 ORB 소급수집보다 먼저 ──────────────────────────────────────── + +def test_orb_backfill_deferred_until_after_first_exit_check(): + """기동 시퀀스에서 backfill 을 await 하지 않고, 루프의 청산 체크 뒤에 실행한다. + + §8.1-11 청산 우선. backfill 은 최대 300초를 점유하므로 기동 직후에 await 하면 + 손절·트레일링·시간손절·EOD 가 전부 그만큼 늦어진다. + """ + import inspect + from app.services.daytrading import scheduler as sched + + src = inspect.getsource(sched.daytrading_scheduler) + startup = src[:src.index("while True:")] + assert "backfill_opening_ranges" not in startup, "기동 시퀀스에서 await 하면 안 된다" + assert "_orb_backfill_pending = True" in startup + + loop = src[src.index("while True:"):] + i_exit = loop.index("check_and_exit_positions()") + i_backfill = loop.index("backfill_opening_ranges") + assert i_exit < i_backfill, "청산 체크가 ORB 소급수집보다 먼저여야 한다" + assert "_orb_backfill_pending = False" in loop, "1회만 실행되어야 한다" + + +# ── M2: 오염(전일 봉만 돌아옴)과 날짜 필드 부재를 구분 ────────────────────── + +def test_filter_today_candles_discards_previous_day_only_response(caplog): + """날짜가 다 있는데 당일 봉이 0건이면 폐기 + warning (조용한 오염 방지).""" + import logging + from app.services.daytrading.entry_service import filter_today_candles + from app.services.day_trading_strategy import MinuteCandle + + prev = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d") + candles = [ + MinuteCandle(date=prev, time=f"09{i:02d}00", open=100, high=101, low=99, + close=100, volume=10, cum_volume=10, cum_amount=1000.0) + for i in range(10) + ] + + with caplog.at_level(logging.WARNING): + out = filter_today_candles(candles) + + assert out == [], "전일 캔들로 ORB 를 계산하면 고저·거래량이 오염된다" + assert any("당일" in r.message and r.levelno >= logging.WARNING + for r in caplog.records), "조용히 실패하면 안 된다" + + +def test_filter_today_candles_passthrough_only_when_date_missing(): + """날짜 필드가 비어 있는 응답만 통과시킨다 (목/레거시 방어).""" + from app.services.daytrading.entry_service import filter_today_candles + from app.services.day_trading_strategy import MinuteCandle + + undated = [MinuteCandle(date="", time="090000", open=1, high=2, low=1, close=2, + volume=1, cum_volume=1, cum_amount=2.0)] + assert filter_today_candles(undated) == undated + assert filter_today_candles([]) == [] + + +# ── C3/F10: 지표 시계열 vs 세션 앵커 지표의 입력 분리 ─────────────────────── +# +# ⚠️ 사실관계(2026-09-28 정정): 조회 계층(korea_investment_api.get_minute_chart_full_day, +# M4 2026-09-27)이 이미 당일 봉만 반환하므로 프로덕션에서 전일 봉은 들어오지 않는다. +# 즉 워밍업은 당일 봉으로만 채워지고 VWAP ~10:15 / RSI_BB ~10:40 이전 발화 불가가 +# **현재의 알려진 제약**이다(조회 계층 변경 + 백테스트가 필요한 별건). +# 아래 테스트는 "워밍업이 확보돼 있다"를 주장하지 않는다. 조회 계층이 무엇을 주더라도 +# **세션 앵커 지표(VWAP·ORB 거래량·현재가)에는 당일 봉만 흘러간다**는 배선을 고정한다. + +def _min_candle(d, hhmmss, close, volume=1000, open_=None): + from app.services.day_trading_strategy import MinuteCandle + op = close - 1.0 if open_ is None else open_ + return MinuteCandle(date=d, time=hhmmss, open=op, high=max(op, close) + 1.0, + low=min(op, close) - 1.0, close=close, volume=volume, + cum_volume=volume, cum_amount=float(close * volume)) + + +@pytest.mark.asyncio +async def test_entry_path_keeps_warmup_but_anchors_session_indicators(monkeypatch): + """조회 계층이 전일 봉을 섞어 주더라도 세션 지표는 당일 봉만 받는다. + + 프로덕션에서는 조회 계층이 이미 당일만 주므로 이 입력(전일+당일)은 **가정적**이다. + 검증 대상은 배선이다: 지표용 5분봉에는 받은 시계열 전체가 흘러가고(워밍업 가능), + VWAP·현재가에는 당일 봉만 흘러간다. 조회 계층 필터가 미래에 풀려도 세션 지표가 + 오염되지 않음을 보장한다. + """ + import pytz + from app.services.daytrading import entry_service as es + + kst = pytz.timezone("Asia/Seoul") + today = datetime.now(kst).strftime("%Y%m%d") + prev = (datetime.now(kst) - timedelta(days=1)).strftime("%Y%m%d") + + # 전일 1분봉 120개(= 5분봉 24개 워밍업) + 당일 09:00~09:04 5개(= 5분봉 1개) + candles = [_min_candle(prev, f"{9 + i // 60:02d}{i % 60:02d}00", 100.0 + (i % 5)) + for i in range(120)] + candles += [_min_candle(today, f"090{i}00", 200.0) for i in range(5)] + + captured = {} + + async def fake_fetch(code): + return candles + + def fake_vwap(cs): + captured["vwap_dates"] = sorted({c.date for c in cs}) + return 200.0 + + def fake_rsi_bb(code, name, candles_5m, price): + captured["rsi_bb_bars"] = len(candles_5m) + captured["rsi_bb_dates"] = sorted({c.date for c in candles_5m}) + captured["price"] = price + return None + + def fake_vwap_sig(code, name, candles_5m, vwap, price): + captured["vwap_bars"] = len(candles_5m) + return None + + monkeypatch.setattr(es, "_fetch_candles_impl", fake_fetch) + monkeypatch.setattr(es, "calculate_vwap", fake_vwap) + monkeypatch.setattr(es, "check_rsi_bb_signal", fake_rsi_bb) + monkeypatch.setattr(es, "check_vwap_bounce_signal", fake_vwap_sig) + monkeypatch.setattr(es.loss_tracker, "is_banned_safe", AsyncMock(return_value=False)) + monkeypatch.setattr(es, "is_vi_active", lambda code: False) + es._candle_cache.clear() + + result = await es._try_enter("005930", "삼성전자", 100_000_000.0, 50_000_000.0) + + assert result is None # 신호 없음 (스텁이 None 반환) + # 워밍업: 지표용 5분봉에 전일 봉이 포함되어 BB(20) 요건을 충족한다 + assert captured["rsi_bb_bars"] >= 20, ( + f"5분봉 {captured['rsi_bb_bars']}개 — BB(20) 워밍업 불가. " + "당일만 쓰면 10:40 이전 RSI_BB 발화가 봉쇄된다" + ) + assert prev in captured["rsi_bb_dates"] and today in captured["rsi_bb_dates"] + assert captured["vwap_bars"] >= 20 + # 세션 앵커: VWAP 는 당일 봉만 + assert captured["vwap_dates"] == [today], "VWAP 에 전일 누적이 섞이면 안 된다" + # 현재가도 당일 마지막 봉 기준 + assert captured["price"] == 200.0, "현재가가 전일 종가로 잡히면 안 된다" + + +@pytest.mark.asyncio +async def test_entry_path_refuses_when_no_today_candles(monkeypatch): + """전일 봉만 돌아오면 진입 판정을 하지 않는다(현재가·VWAP 오염 방지). + + 조회 계층 필터 덕에 현재는 도달 불가한 경로지만, 휴장일·조회 이상에서 당일 봉이 + 0건이면 현재가·VWAP·거래량 기준을 만들 수 없으므로 진입을 포기해야 한다. + """ + import pytz + from app.services.daytrading import entry_service as es + + kst = pytz.timezone("Asia/Seoul") + prev = (datetime.now(kst) - timedelta(days=1)).strftime("%Y%m%d") + candles = [_min_candle(prev, f"09{i:02d}00", 100.0) for i in range(30)] + + async def fake_fetch(code): + return candles + + def _boom(*a, **k): + raise AssertionError("전일 봉만 있는데 신호 판정을 시도했다") + + monkeypatch.setattr(es, "_fetch_candles_impl", fake_fetch) + monkeypatch.setattr(es, "check_rsi_bb_signal", _boom) + monkeypatch.setattr(es, "check_vwap_bounce_signal", _boom) + monkeypatch.setattr(es.loss_tracker, "is_banned_safe", AsyncMock(return_value=False)) + monkeypatch.setattr(es, "is_vi_active", lambda code: False) + es._candle_cache.clear() + + assert await es._try_enter("005930", "삼성전자", 100_000_000.0, 50_000_000.0) is None + + +def test_orb_collection_still_filters_today_only(): + """ORB 수집 경로는 당일 필터를 유지해야 한다 (§7 오염 차단 = F2 의 핵심). + + 조회 계층이 이미 당일만 주므로 방어적 이중화지만, 그 필터가 풀리면 ORB 고저· + 평균분당거래량이 즉시 오염되므로 여기서 걷어내선 안 된다. + """ + import inspect + from app.services.daytrading import entry_service as es + + src = inspect.getsource(es.collect_opening_ranges) + assert "filter_today_candles(parse_minute_candles(raw))" in src, ( + "ORB 수집에서 당일 필터가 빠지면 전일 고저·거래량이 ORB 를 오염시킨다" + ) + # 조회 계층 래퍼는 필터를 걸지 않는다 — 당일 보장은 KIS 래퍼가 이미 한다. + # (여기서 다시 걸면 '필터가 두 곳'이 되어 어느 쪽이 진실인지 흐려진다) + fetch_src = inspect.getsource(es._fetch_candles_impl) + assert "return parse_minute_candles(raw)" in fetch_src + + +# ── M3: 캐시 TTL ≥ 사이클 예산 ────────────────────────────────────────────── + +def test_candle_cache_ttl_covers_one_cycle(): + """TTL 이 선조회+순차스캔 시간보다 짧으면 선조회가 스스로 무효화된다.""" + from app.services.daytrading import entry_service as es + + assert es._CANDLE_CACHE_TTL >= es._SCAN_TIME_BUDGET, ( + f"TTL {es._CANDLE_CACHE_TTL}초 < 사이클 예산 {es._SCAN_TIME_BUDGET}초 " + "— 앞쪽 선조회분이 순차 루프 시작 전에 만료된다" + ) + + +# ── M4: swing 청산 경로도 일일 안전장치를 저장한다 ────────────────────────── + +def test_swing_exit_persists_daily_guards(): + """스윙 손절이 등록한 closed_today 가 재기동 후에도 유지되어야 한다.""" + import inspect + from app.services.swing import exit_service as ses + + src = inspect.getsource(ses) + i_add = src.index("_dts_cd.closed_today.add(stock_code)") + tail = src[i_add:i_add + 800] + assert "save_daily_state()" in tail, ( + "closed_today 추가 후 저장이 없으면 재기동 시 DT 재매수가 되살아난다" + ) + + +# ── M5: DLQ 재처리가 열화된 타입을 되돌리고 실제로 커밋된다 ───────────────── + +@pytest.mark.asyncio +async def test_replay_dlq_restores_types_and_task_commits(tmp_path, monkeypatch): + """`default=str` 로 열화된 payload 를 되돌려 재큐잉하고, 핸들러가 커밋한다. + + 되돌리지 않으면 asyncpg 가 DateTime 컬럼의 str 을 거부해 3회 재시도 후 + **새 DLQ 파일로 다시 쌓인다**(기동마다 증식). + """ + import json as _json + from datetime import datetime as _dt + from decimal import Decimal + 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") + + # _save_to_dlq 가 실제로 만드는 형태 (json.dumps(default=str)) + original = { + "type": "upsert_active_trade", + "data": { + "stock_code": "005930", + "quantity": 4, + "entry_price": Decimal("70123.45"), + "created_at": _dt(2026, 9, 28, 10, 30, 0), + "strategy_name": "day_trade", + }, + } + degraded = _json.loads(_json.dumps({"task": original, "error": "shutdown_drain"}, + 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" + ) + + enqueued = [] + + async def _fake_enqueue(task): + enqueued.append(task) + + monkeypatch.setattr(db_worker, "enqueue_db_write", _fake_enqueue) + + assert await db_worker.replay_dlq() == 1 + data = enqueued[0]["data"] + assert isinstance(data["entry_price"], Decimal), "Decimal 이 문자열로 남았다" + assert data["entry_price"] == Decimal("70123.45") + assert isinstance(data["created_at"], _dt), "datetime 이 문자열로 남았다" + assert data["created_at"] == _dt(2026, 9, 28, 10, 30, 0) + assert data["quantity"] == 4 + + # 재큐잉된 그 payload 로 핸들러가 실제로 커밋되는지 (동작 검증) + record = _FakeActiveTrade(stock_code="005930", quantity=10) + session = _FakeSession(record=record) + await db_worker._handle_upsert_active_trade(session, data) + assert record.quantity == 4 + assert session.commits == 1 + + +@pytest.mark.asyncio +async def test_replay_dlq_restores_date_for_bulk_rows(tmp_path, monkeypatch): + """bulk_upsert_stock_analysis 처럼 data 가 리스트인 경우도 복원한다.""" + import json as _json + 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") + (dlq / "20260928_110000_bulk_upsert_stock_analysis_bbbb2222.json").write_text( + _json.dumps({"task": { + "type": "bulk_upsert_stock_analysis", + "data": [{"stock_code": "005930", "analysis_date": "2026-09-28"}], + }}), encoding="utf-8") + + enqueued = [] + + async def _fake_enqueue(task): + enqueued.append(task) + + monkeypatch.setattr(db_worker, "enqueue_db_write", _fake_enqueue) + assert await db_worker.replay_dlq() == 1 + assert enqueued[0]["data"][0]["analysis_date"] == date(2026, 9, 28) + + +# ── F11: 캐시 수명은 '한 사이클' — 사이클마다 재조회되어야 한다 ───────────── + +@pytest.mark.asyncio +async def test_cache_is_cleared_each_cycle_so_signals_are_reevaluated(monkeypatch): + """연속 두 사이클에서 같은 종목이 재조회된다. + + 시간 TTL(180초)만 두면 30초 주기 스케줄러에서 같은 스냅샷이 5개 사이클을 덮는다. + 세 신호 함수는 (candles, orb, current_price) 의 순수 함수라 입력이 같으면 출력도 + 같으므로, t=0 에 신호가 없으면 t=150 까지 무조건 no-signal 이 되고 진입가·손절· + 수량도 최대 3분 묵은 값이 된다. + """ + from app.services.daytrading import entry_service as es + from app.services.daytrading.position_manager import day_trading_state + + fetches = [] + + async def fake_fetch(code): + fetches.append(code) + return [_min_candle(datetime.now().strftime("%Y%m%d"), "090000", 100.0)] + + monkeypatch.setattr(es, "_fetch_candles_impl", fake_fetch) + 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)) + + async def fake_try_enter(*a, **k): + return None + + monkeypatch.setattr(es, "_try_enter", fake_try_enter) + + 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 + # 진입 마감(기본 15:00) 이후에 돌려도 통과해야 한다 — 벽시계 고정 + _freeze_entry_window(monkeypatch, es) + es.set_watchlist([{"code": "005930", "name": "삼성전자"}]) + es._candle_cache.clear() + + # 사이클 1 → 선조회 1회 + await es.scan_and_enter() + first = len(fetches) + assert first >= 1 + + # 사이클 2 (시간 경과 없음) → 캐시가 비워져 다시 조회되어야 한다 + await es.scan_and_enter() + assert len(fetches) > first, ( + "사이클 경계에서 캐시를 비우지 않으면 같은 스냅샷으로 재평가해 " + "최대 3분간 무조건 no-signal 이 된다" + ) + + # 한 사이클 안에서는 선조회분을 재사용한다 (M3 의 목적 유지) + assert es._CANDLE_CACHE_TTL >= es._SCAN_TIME_BUDGET + es.set_watchlist([]) + + +def test_scan_clears_cache_before_prefetch(): + """캐시 clear 가 선조회보다 앞에 와야 한다(같은 사이클 안에서 지워지면 무의미).""" + import inspect + from app.services.daytrading import entry_service as es + + src = inspect.getsource(es.scan_and_enter) + assert src.index("_candle_cache.clear()") < src.index("_prefetch_minute_charts(") diff --git a/frontend/src/components/PortfolioSummary.tsx b/frontend/src/components/PortfolioSummary.tsx index 412cec4..8f13f3f 100644 --- a/frontend/src/components/PortfolioSummary.tsx +++ b/frontend/src/components/PortfolioSummary.tsx @@ -85,7 +85,10 @@ export const PortfolioSummary = ({ summary, error, todayTradeStats, accountSetup const total_profit_loss_rate = n(summary.total_profit_loss_rate); // 수익률 const today_profit_loss = n(summary.today_profit_loss); // 금일 손익 const today_profit_loss_rate = n(summary.today_profit_loss_rate); // 금일 수익률 - const cumulative_realized_pl = n(summary.cumulative_realized_pl); // 누적 실현손익 + // 누적 실현손익 — 아직 조회되지 않은 상태(undefined/null)와 실제 0원을 구분한다. + // (2026-09-28: legacy 모드는 별도 쿼리로 병합되는 필드라 초기 렌더 시 undefined일 수 있음) + const hasCumulativeRealizedPl = summary.cumulative_realized_pl !== undefined && summary.cumulative_realized_pl !== null; + const cumulative_realized_pl = n(summary.cumulative_realized_pl); // 당일 매매 레이블 (오늘 거래 있으면 '오늘', 없으면 '최근') const todayLabel = todayTradeStats?.isToday !== false ? '오늘' : '최근'; @@ -169,9 +172,15 @@ export const PortfolioSummary = ({ summary, error, todayTradeStats, accountSetup 누적 실현손익 -
- {fmtSign(cumulative_realized_pl)}원 -
+ {hasCumulativeRealizedPl ? ( +
+ {fmtSign(cumulative_realized_pl)}원 +
+ ) : ( +
+ 조회중… +
+ )}
매도 체결 기준
diff --git a/frontend/src/hooks/usePortfolioData.ts b/frontend/src/hooks/usePortfolioData.ts index efb8472..66e5b3f 100644 --- a/frontend/src/hooks/usePortfolioData.ts +++ b/frontend/src/hooks/usePortfolioData.ts @@ -103,10 +103,37 @@ export const usePortfolioData = () => { refetchOnWindowFocus: true, // 탭 전환 시 즉시 갱신 }) + /** + * 누적 실현손익 보강 조회 (2026-09-28 추가) + * + * 백엔드 `/portfolio/combined`(legacy)는 응답에 `cumulative_realized_pl`을 + * 절대 포함하지 않는다(모든 분기가 `_build_combined_resp()`를 거치는데 그 함수가 + * 이 필드를 채우지 않음 — backend/app/api/endpoints/portfolio.py 확인). + * 반면 `/portfolio/summary`는 TradeHistory SELL 체결 합계로 이 필드를 채운다. + * 백엔드를 건드리지 않고 프론트에서만 고치기 위해 이 값만 별도로 가볍게 조회해 병합한다. + * (v2 모드는 `/portfolio/v2/summary`에 이 필드 자체가 없어 대상에서 제외 — 현재 MT 플래그 OFF) + */ + const { data: cumulativeRealizedPl } = useQuery({ + queryKey: ['portfolio-cumulative-realized-pl', mode], + queryFn: async (): Promise => { + const res = await api.get('/portfolio/summary') + const v = (res.data as { cumulative_realized_pl?: number })?.cumulative_realized_pl + return typeof v === 'number' ? v : undefined + }, + enabled: mode === 'legacy', + staleTime: 30000, + refetchInterval: 30000, + refetchOnWindowFocus: true, + }) + /** 보유 종목 배열 */ const portfolio = portfolioCombined?.holdings - /** 포트폴리오 요약 데이터 */ - const portfolioSummary = portfolioCombined?.summary ?? undefined + /** 포트폴리오 요약 데이터 — legacy 모드는 누적 실현손익을 별도 조회로 병합 */ + const portfolioSummary = portfolioCombined?.summary + ? mode === 'legacy' + ? { ...portfolioCombined.summary, cumulative_realized_pl: cumulativeRealizedPl } + : portfolioCombined.summary + : undefined const accountSetupRequired = portfolioCombined?.accountSetupRequired ?? false const accountSetupMessage = portfolioCombined?.accountSetupMessage diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index f26a30c..60552fe 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -77,6 +77,21 @@ const checkMarketOpen = () => { /** 마켓 필터 타입 (전체/국내/미국) */ type MarketFilter = 'ALL' | 'KR' | 'US' +/** + * liveSummary 보정 신뢰 임계값 (2026-09-28) + * + * `/portfolio/combined` 폴링 주기는 30초(usePortfolioData.ts staleTime/refetchInterval), + * livePrices는 WebSocket tick마다 1초 단위로 flush된다(useMarketData.ts). 즉 보정항은 + * "백엔드 스냅샷과 최대 30초 사이의 실시간 가격 변동분"만 반영해야 정상이다. + * 국내 개별종목의 30초 단위 변동은 일중 변동성 기준으로도 통상 1% 미만이고, + * 포트폴리오 전체(다종목 분산) 단위로는 이보다 더 작다 — 3%는 정상적인 실시간 변동 + * 범위를 넉넉히 덮으면서도, 2026-09-28 사고에서 실제 관측된 오차(9,937,380원 − + * 8,709,324원 = 1,228,056원, 총자산 대비 12.36%)는 확실히 걸러낸다. + * (당시 "한미사이언스 10주 매도가 원인"이라는 가설을 세웠으나 뒷받침 근거가 없어 철회 — + * 정확한 유발 메커니즘은 미규명. 아래 F12 설명 참조.) + */ +const LIVE_CORRECTION_MAX_RATIO = 0.03 + /** * Dashboard — 메인 대시보드 컴포넌트 * @@ -488,10 +503,38 @@ const Dashboard = () => { if (!portfolioSummary || !portfolio) return portfolioSummary // live 현재가 기반 주식 평가금액 + // 가격 결손 종목(WS tick도 없고 DB current_price도 0/NaN)을 별도 수집 — + // 이런 종목이 있으면 liveValue 자체가 축소 계산되어 있어 보정에 쓸 수 없다. + // + // M7 (2026-09-29): backendStockValue(stock_value_from_api = KIS 국내계좌 + // scts_evlu_amt)는 국내 원화 전용이다(backend/app/api/endpoints/portfolio.py:274 + // — output2, 국내 잔고 API에서만 추출). liveValue는 해외 종목의 USD 단가를 + // 환산 없이 그대로 더하므로(기존 동작, 이번 수정 대상 아님) 해외 보유가 있으면 + // 통화·범위가 다른 값끼리 비교하게 되어 correctionRatio가 항상 커진다. + // 백엔드 summary에는 domestic_value 필드가 없어(실측: /portfolio/summary 응답에 + // 없음 — _calculate_portfolio_values가 계산은 하지만 summary_data에 담지 않음) + // 새 필드 없이, 프론트에서 국내 보유만 골라 liveDomesticValue로 비교한다 + // (backendStockValue와 동일 범위·통화를 맞추는 최소 수정 — 해외 USD 환산 로직 + // 자체는 건드리지 않음). + const missingPriceCodes: string[] = [] + const domesticMissingPriceCodes: string[] = [] + let liveDomesticValue = 0 + let overseasHoldingsCount = 0 const liveValue = portfolio.reduce((sum, s) => { - const price = - livePrices[s.stock_code] ?? Number(s.current_price || 0) - return sum + price * Number(s.quantity || 0) + const isDomestic = !isUsStock(s.stock_code) + const rawPrice = livePrices[s.stock_code] ?? Number(s.current_price || 0) + const price = Number.isFinite(rawPrice) ? rawPrice : 0 + const qty = Number(s.quantity || 0) + if (!(price > 0)) { + missingPriceCodes.push(s.stock_code) + if (isDomestic) domesticMissingPriceCodes.push(s.stock_code) + } + if (isDomestic) { + liveDomesticValue += price * qty + } else { + overseasHoldingsCount += 1 + } + return sum + price * qty }, 0) // 총 매입금액 (평균단가 × 수량) — average_price null 시 백엔드 summary 값 사용 @@ -512,21 +555,85 @@ const Dashboard = () => { // 총 자산 = KIS tot_evlu_amt 기준으로 실시간 주식평가 변동분 보정 // stock_value_from_api(scts_evlu_amt)가 0이면 예수금+실시간주식평가로 단순 계산 const backendStockValue = portfolioSummary.stock_value_from_api || 0 - const liveTotal = - backendStockValue > 0 - ? (portfolioSummary.total_assets || 0) + (liveValue - backendStockValue) + const backendTotalAssets = Number(portfolioSummary.total_assets || 0) + + // 보정 신뢰도 가드 (2026-09-28 추가, 09-28 2차 보강, 09-29 F12 주석 정정) + // + // 2026-09-28 사고(총자산 12.36% 과소표시)의 정확한 유발 메커니즘은 규명되지 않았다. + // "portfolioSummary와 portfolio가 서로 다른 시점에 어긋난다"는 초기 가설은 legacy + // 경로에서는 성립하지 않는다 — 감사 결과(F12): 이 경로에서 `portfolio`(holdings)와 + // `portfolioSummary`는 `/portfolio/combined` 단일 응답의 두 필드이고 + // (usePortfolioData.ts의 react-query 쿼리 하나가 원자적으로 반환, invalidatePortfolio()도 + // 같은 쿼리키 하나만 무효화), 백엔드도 `summary.total_stocks`를 + // `len([h for h in self.holdings if h.quantity > 0])`로 **같은 holdings 리스트에서** + // 계산한다(portfolio.py `_build_combined_resp`/`cache_summary`). 즉 legacy 경로에서 + // 두 소스가 "다른 시점"일 수는 없다. + // 남은 실제 불일치 벡터는 세 가지뿐이다: + // (a) livePrices(WebSocket tick, 초 단위) vs 백엔드 스냅샷(30초 폴링) 사이의 + // 진짜 시차 — 유일하게 확실한 "다른 시점" 소스 + // (b) DB `current_price`가 0/NULL인 행이 있으면 liveValue가 축소 계산됨 + // (유력 가설이나 미확증 — 아래 ②의 결손 가드가 대응) + // (c) 해외 종목 USD 미환산(M7) — 현재 US_TRADING_ENABLED=False라 해당 없음 + // 이 가드는 원인을 특정해 막는 것이 아니라, 위 벡터로 인한 증상(허위 손익 표시)을 + // 억제하는 안전망이다. + // + // ① summary.total_stocks 와 실제 보유 종목 수가 다르거나 + // ② 보유 종목 중 실시간 가격이 결손(WS tick 없음 + DB current_price도 0/NaN)인 종목이 + // 하나라도 있거나 — 종목 수는 같아도 liveValue 자체가 축소 계산돼 있어 위험하다 + // ③ 보정폭이 총자산의 LIVE_CORRECTION_MAX_RATIO(3%, 근거는 상수 선언부 주석)를 넘으면 + // — 두 소스가 어긋난 것으로 보고 보정을 스킵, 백엔드 원본값(total_assets/today_profit_loss)을 + // 그대로 신뢰한다. 불변식: 보정을 적용하지 않을 때 백엔드 값보다 더 틀린 값을 만들지 않는다. + // + // F14 (2026-09-29): ①(holdingsCountMatches)은 위 근거대로 legacy 경로에서는 + // summary.total_stocks 와 portfolio.length 가 같은 리스트에서 나오므로 구조적으로 + // 항상 true다 — 즉 legacy 에서는 죽은 가드다. 제거하지 않는 이유는 v2/멀티테넌트 + // 경로나 백엔드 구현이 바뀌면 의미가 생길 수 있고 무해하기 때문. 실효 가드는 + // ②(가격 결손)와 ③(보정폭) 두 개뿐이라고 보고 다른 안전망으로 착각하지 말 것. + const summaryStockCount = Number(portfolioSummary.total_stocks) + const holdingsCountMatches = Number.isFinite(summaryStockCount) + ? portfolio.length === summaryStockCount + : true + // M7: 비교는 국내분(liveDomesticValue)끼리만 — backendStockValue가 국내 전용이므로 + const hasMissingPrice = domesticMissingPriceCodes.length > 0 + const correctionDelta = backendStockValue > 0 ? liveDomesticValue - backendStockValue : 0 + const correctionRatio = + backendTotalAssets > 0 ? Math.abs(correctionDelta) / backendTotalAssets : 1 + const canTrustLiveCorrection = + backendStockValue > 0 && + holdingsCountMatches && + !hasMissingPrice && + correctionRatio <= LIVE_CORRECTION_MAX_RATIO + + // 가드가 발동해 보정을 스킵한 경우 원인 추적용 로그 (화면에는 노출하지 않음) + // overseasHoldingsCount > 0 이면 통화 혼재(M7) 가능성을 의심할 것 — 현재 + // US_TRADING_ENABLED=False 라 해외 보유가 없어야 정상이므로 0이 아니면 이상 신호 + if (backendStockValue > 0 && !canTrustLiveCorrection) { + console.warn('[liveSummary] 보정 스킵 — 백엔드 원본값 사용', { + expectedStockCount: summaryStockCount, + actualStockCount: portfolio.length, + overseasHoldingsCount, + correctionRatio, + missingPriceCodes, + domesticMissingPriceCodes, + }) + } + + const liveTotal = canTrustLiveCorrection + ? backendTotalAssets + correctionDelta + : backendStockValue > 0 + ? backendTotalAssets // 신뢰 불가 → 백엔드 원본 총자산 그대로 사용 : Number(portfolioSummary.available_cash || 0) + liveValue - // 금일 손익도 live 주식 변동분 반영 + // 금일 손익도 live 주식 변동분 반영 — 단, 보정을 신뢰할 수 없으면 백엔드 원본값 사용 const bfdyTotal = portfolioSummary.bfdy_total_assets || 0 const liveTodayPL = - bfdyTotal > 0 + canTrustLiveCorrection && bfdyTotal > 0 ? liveTotal - bfdyTotal - : portfolioSummary.today_profit_loss || 0 + : Number(portfolioSummary.today_profit_loss || 0) const liveTodayPLRate = - bfdyTotal > 0 + canTrustLiveCorrection && bfdyTotal > 0 ? (liveTodayPL / bfdyTotal) * 100 - : portfolioSummary.today_profit_loss_rate || 0 + : Number(portfolioSummary.today_profit_loss_rate || 0) return { ...portfolioSummary,