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