fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정

- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
This commit is contained in:
2026-10-10 12:17:49 +09:00
parent 5c4803b339
commit a264561576
230 changed files with 41160 additions and 2334 deletions

View File

@@ -0,0 +1,456 @@
"""DT 운영 관측 3건 회귀 테스트 (2026-10-08).
1. 고아 DT 잔고 — Portfolio.strategy_type='daytrading' 수량은 있는데 인메모리·active DT 행이
없는 종목(10/06 KB금융: EOD 매도 접수 → 0체결 → ActiveTrade 만 closed)을
close_stale_positions 가 감지해 같은 stale 경로로 전량 청산한다.
+ 접수 후 0체결 종결이 확인되면 인메모리 포지션을 되살린다(재시도).
2. 시간손절 임계 -0.3% → -0.7% (비용·슬리피지 노이즈 위), reason 은 .2f.
3. 엔진 카드 '청산' 은 DT 청산만 센다(스윙 손절의 전략 간 쿨다운 제외).
목킹 원칙: 주문은 공통 종단 `_execute_exit_impl`(stale 경로)·`safe_place_order` 에 건다.
호출되면 안 되는 경로는 BaseException 발생형. 기대값은 settings.* 에서 계산한다.
"""
import asyncio
import inspect
from datetime import datetime, time, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from app.core.config import settings
from app.services import day_trading_strategy as dts
from app.services.daytrading import exit_service as es
from app.services.daytrading import fill_reconciler as fr
from app.services.daytrading.position_manager import day_trading_state
class MustNotBeCalled(BaseException):
"""호출되면 안 되는 경로 (except Exception 에 삼켜지지 않게 BaseException)."""
def _boom(name):
async def _f(*a, **k):
raise MustNotBeCalled(f"호출 금지 경로 도달: {name}")
return _f
class _SeqSession:
"""execute() 호출 순서대로 결과를 돌려주는 가짜 세션(SELECT 전용)."""
def __init__(self, results):
self._results = list(results)
self.calls = 0
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def execute(self, *a, **k):
self.calls += 1
rows = self._results.pop(0) if self._results else []
return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: rows))
async def commit(self): # 읽기 전용 경로 — 호출되면 실패
raise MustNotBeCalled("commit")
def _sess(*results):
return lambda: _SeqSession(results)
def _balance(rows):
return AsyncMock(return_value={
"rt_cd": "0",
"output1": [
{"pdno": c, "hldg_qty": str(h), "ord_psbl_qty": str(s)} for c, h, s in rows
],
"output2": [{}],
})
KB = "105560"
def _pf(code=KB, qty=3, st="daytrading"):
return SimpleNamespace(stock_code=code, stock_name="KB금융", quantity=qty,
average_price=80_000.0, strategy_type=st)
def _th(code=KB, buy=3, sell=3, sell_status="CANCELLED", sell_exec=0, days_ago=2):
"""[R1-1] DT 원장 — 기본값은 '매도 접수 후 0체결 종결'(순보유 = buy)."""
t = datetime.now().replace(microsecond=0) - timedelta(days=days_ago)
return [
SimpleNamespace(stock_code=code, order_type="BUY", status="COMPLETED",
executed_quantity=buy, order_quantity=buy, created_at=t),
SimpleNamespace(stock_code=code, order_type="SELL", status=sell_status,
executed_quantity=sell_exec, order_quantity=sell, created_at=t),
]
def _ev(code=KB, qty=3, days_ago=2, status="closed"):
return SimpleNamespace(
stock_code=code, stock_name="KB금융", quantity=qty, entry_price=80_000.0,
highest_since_entry=80_500.0, status=status, strategy_name="day_trade",
created_at=datetime.now().replace(microsecond=0) - timedelta(days=days_ago),
)
@pytest.fixture(autouse=True)
def _clean():
saved_pos = dict(day_trading_state.positions)
saved_closed = set(day_trading_state.closed_today)
saved_dt_exit = set(day_trading_state.dt_exited_today)
saved_pnl = day_trading_state.daily_pnl
day_trading_state.reset_if_new_day()
day_trading_state.positions.clear()
day_trading_state.closed_today.clear()
day_trading_state.dt_exited_today.clear()
day_trading_state.daily_pnl = 0.0
for coll in (es._stale_sell_attempted, es._stale_sell_failures, es._last_exit_order_outcome,
es._unresolved_exit_unknown, es._stale_cleaned, es._exit_tokens,
es._recent_dt_sell_finalized, es._manual_alerted):
coll.clear()
day_trading_state.exits_in_progress.clear()
from app.services.telegram_notifier import telegram_notifier
with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)), \
patch.object(telegram_notifier, "notify_system_warning", AsyncMock()):
yield
day_trading_state.positions.clear()
day_trading_state.positions.update(saved_pos)
day_trading_state.closed_today.clear()
day_trading_state.closed_today.update(saved_closed)
day_trading_state.dt_exited_today.clear()
day_trading_state.dt_exited_today.update(saved_dt_exit)
day_trading_state.daily_pnl = saved_pnl
es._recent_dt_sell_finalized.clear()
def _common(session_factory, balance, exec_mock):
return (
patch.object(es, "is_market_open", return_value=True),
patch("app.core.database.AsyncSessionLocal", session_factory),
patch.object(es.korea_investment_api, "get_balance", balance),
patch.object(es.korea_investment_api, "get_current_price",
AsyncMock(return_value={"output": {"stck_prpr": "77600"}})),
patch.object(es, "_execute_exit_impl", exec_mock),
)
async def _run(session_factory, balance, exec_mock):
p = _common(session_factory, balance, exec_mock)
with p[0], p[1], p[2], p[3], p[4]:
return await es.close_stale_positions()
# ═════════════════════════════════════════════════════════════════════════════
# 1. 고아 DT 잔고
# ═════════════════════════════════════════════════════════════════════════════
# 쿼리 순서: ①DT active ②(stale 있을 때만) 비-DT ③Portfolio DT ④DT 근거 행 ④b DT 원장(R1-1)
# ⑤추가 비-DT
@pytest.mark.asyncio
async def test_orphan_portfolio_dt_holding_is_sold_via_stale_path():
"""10/06 KB금융 재현: active 행은 closed, 인메모리 비어 있음, Portfolio 'daytrading' 3주."""
exec_mock = AsyncMock(return_value={"stock_code": KB, "quantity": 3})
out = await _run(_sess([], [_pf()], [_ev()], _th(), []), _balance([(KB, 3, 3)]), exec_mock)
assert out == [{"stock_code": KB, "quantity": 3}]
assert exec_mock.await_count == 1
code, pos, price, reason, sell_pct = exec_mock.await_args.args
assert exec_mock.await_args.kwargs.get("is_stale") is True
assert code == KB and pos["quantity"] == 3 and sell_pct == 1.0
assert "고아" in reason
# 당일 재진입 차단(stale 경로와 동일)
assert KB in day_trading_state.closed_today
@pytest.mark.asyncio
async def test_orphan_sell_qty_capped_by_swing_holding_and_evidence_qty():
"""§3.5 — 실보유 10, 비-DT active 7 → DT 가용 3. 근거 행 수량(3)·Portfolio(10) 중 최소."""
swing = SimpleNamespace(stock_code=KB, quantity=7)
exec_mock = AsyncMock(return_value={"stock_code": KB})
await _run(_sess([], [_pf(qty=10)], [_ev(qty=3)], _th(), [swing]),
_balance([(KB, 10, 10)]), exec_mock)
assert exec_mock.await_args.args[1]["quantity"] == 3
@pytest.mark.asyncio
async def test_orphan_all_swing_owned_is_not_sold():
swing = SimpleNamespace(stock_code=KB, quantity=3)
await _run(_sess([], [_pf()], [_ev()], _th(), [swing]), _balance([(KB, 3, 3)]),
_boom("_execute_exit_impl"))
@pytest.mark.asyncio
async def test_orphan_without_dt_evidence_is_not_sold():
"""'daytrading' 태그만 있고 최근 DT 행이 없으면 팔지 않는다(수동 매수 섞인 행 보호)."""
await _run(_sess([], [_pf()], []), _balance([(KB, 3, 3)]), _boom("_execute_exit_impl"))
@pytest.mark.asyncio
async def test_orphan_with_zero_kis_holding_does_nothing():
"""실보유 0 — 매도도, ActiveTrade close 도 하지 않는다(닫을 active 행이 없다)."""
with patch.object(es, "enqueue_close_active_trade", _boom("enqueue_close_active_trade")):
out = await _run(_sess([], [_pf()], [_ev()], _th(), []), _balance([]),
_boom("_execute_exit_impl"))
assert out == []
@pytest.mark.asyncio
async def test_orphan_kis_balance_failure_holds_sell():
bal = AsyncMock(side_effect=RuntimeError("KIS down"))
out = await _run(_sess([], [_pf()], [_ev()], _th(), []), bal, _boom("_execute_exit_impl"))
assert out == []
@pytest.mark.asyncio
async def test_orphan_grace_after_recent_dt_sell():
"""방금 DT 매도를 반영한 종목은 Portfolio/KIS 가 옛 수량일 수 있다 → 유예."""
import time as _t
es._recent_dt_sell_finalized[KB] = _t.monotonic()
out = await _run(_sess([], [_pf()], [_ev()], []), _balance([(KB, 3, 3)]),
_boom("_execute_exit_impl"))
assert out == []
@pytest.mark.asyncio
async def test_orphan_skipped_when_in_memory():
day_trading_state.positions[KB] = {"entry_price": 80_000.0, "quantity": 3,
"entry_time": datetime.now()}
with patch.object(es.korea_investment_api, "get_balance", _boom("get_balance")):
p = _common(_sess([], [_pf()], [_ev()], []), _balance([]), _boom("_execute_exit_impl"))
with p[0], p[1], p[3], p[4], \
patch.object(es.korea_investment_api, "get_balance", _boom("get_balance")):
assert await es.close_stale_positions() == []
@pytest.mark.asyncio
async def test_orphan_detection_can_be_disabled(monkeypatch):
monkeypatch.setattr(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", False)
out = await _run(_sess([], [_pf()], [_ev()], []), _balance([(KB, 3, 3)]),
_boom("_execute_exit_impl"))
assert out == []
# ── 당일 active 행 인메모리 유실 → 복원 ────────────────────────────────────
def _today_row(code="222222", qty=5):
return SimpleNamespace(
stock_code=code, stock_name="T", strategy_name="day_trade", status="active",
entry_price=10_000.0, stop_loss=9_700.0, take_profit=10_450.0, quantity=qty,
highest_since_entry=10_100.0, created_at=datetime.now().replace(microsecond=0),
entry_reason="", order_id="1", remaining_pct=None,
)
@pytest.mark.asyncio
async def test_today_active_row_missing_in_memory_is_restored_not_sold():
# ①DT active=[당일 행] ③Portfolio=[] ⑤추가 비-DT=[]
out = await _run(_sess([_today_row()], [], []), _balance([("222222", 5, 5)]),
_boom("_execute_exit_impl"))
assert out == []
pos = day_trading_state.positions["222222"]
assert pos["quantity"] == 5 and pos["stop_loss"] == 9_700.0
@pytest.mark.asyncio
async def test_today_active_row_not_restored_when_closed_today():
# [R2 하드닝 2026-10-09] 복원 차단 근거는 DT 전량 청산 기록(remove_position 의 dt_exited_today).
# closed_today 만 있는 경우(불명 매수 접수·0체결 복원)는 test_dt_restore_closed_today_r2h_20261009 참조.
day_trading_state.closed_today.add("222222")
day_trading_state.dt_exited_today.add("222222")
await _run(_sess([_today_row()], [], []), _balance([("222222", 5, 5)]),
_boom("_execute_exit_impl"))
assert "222222" not in day_trading_state.positions
@pytest.mark.asyncio
async def test_today_active_row_not_restored_when_holding_short():
await _run(_sess([_today_row(qty=5)], [], []), _balance([("222222", 2, 2)]),
_boom("_execute_exit_impl"))
assert "222222" not in day_trading_state.positions
# ── 접수 후 0체결 종결 → 인메모리 복원 ──────────────────────────────────────
@pytest.fixture
def exit_env():
import app.services.trade_executor as te
import app.services.portfolio_cache_bridge as pcb
cap = {"updates": [], "history": []}
async def _upd(order_id, fields):
cap["updates"].append((order_id, fields))
async def _hist(payload):
cap["history"].append(payload)
with patch.object(te, "safe_place_order",
AsyncMock(return_value={"rt_cd": "0", "output": {"ODNO": "0000123"}})), \
patch.object(es, "enqueue_trade_history", _hist), \
patch.object(es, "enqueue_close_active_trade", AsyncMock()), \
patch("app.services.db_worker.enqueue_active_trade_upsert", AsyncMock()), \
patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", _upd), \
patch.object(pcb, "reflect_sell", AsyncMock()), \
patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()), \
patch.object(es.loss_tracker, "save_to_db", AsyncMock()), \
patch.object(settings, "DAY_TRADE_FILL_CHECK_ENABLED", True), \
patch.object(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0), \
patch.object(settings, "DAY_TRADE_FILL_CHECK_INTERVAL_SEC", 0.0):
yield cap
def _pos(qty=10):
return {"stock_name": "테스트", "entry_price": 10_000.0, "quantity": qty,
"stop_loss": 9_700.0, "target_price": 10_450.0,
"highest_price": 10_000.0, "order_id": "0000999", "entry_time": datetime.now()}
@pytest.mark.asyncio
async def test_eod_sell_accepted_then_zero_fill_restores_position(exit_env):
day_trading_state.positions["666666"] = _pos(10)
rows = [{"odno": "123", "pdno": "666666", "ord_qty": "10", "tot_ccld_qty": "0",
"avg_prvs": "0", "rmn_qty": "0", "cncl_yn": "Y"}]
with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)):
res = await es._execute_exit("666666", day_trading_state.positions["666666"],
9_900.0, "EOD 강제 청산 (15:20)")
assert res is not None # 접수 시점엔 청산으로 반영됐다(기존 동작)
await asyncio.gather(*list(fr._pending_tasks))
# 인메모리 복원 → 다음 EOD 사이클 force_close_all 이 재시도
assert day_trading_state.positions["666666"]["quantity"] == 10
assert "666666" not in day_trading_state.dt_exited_today
# 당일 손익은 체결 0 이므로 원복
assert day_trading_state.daily_pnl == pytest.approx(0.0)
oid, upd = exit_env["updates"][-1]
assert oid == "0000123" and upd["status"] == "CANCELLED" and upd["executed_quantity"] == 0
# 그레이스 해제(고아 감지 즉시 가능)
assert "666666" not in es._recent_dt_sell_finalized
@pytest.mark.asyncio
async def test_stale_sell_zero_fill_clears_reorder_block(exit_env):
today = datetime.now().date().isoformat()
rows = [{"odno": "123", "pdno": "777777", "ord_qty": "10", "tot_ccld_qty": "0",
"avg_prvs": "0", "rmn_qty": "0", "rjct_qty": "10"}]
es._stale_sell_attempted.add((today, "777777"))
with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)):
await es._execute_exit_stale("777777", _pos(10), 9_500.0, "전일 미청산 포지션 강제 청산")
await asyncio.gather(*list(fr._pending_tasks))
assert (today, "777777") not in es._stale_sell_attempted
assert "777777" not in day_trading_state.positions # 전일분은 close_stale 가 다시 잡는다
assert day_trading_state.daily_pnl == 0.0
@pytest.mark.asyncio
async def test_full_fill_keeps_existing_reconcile_behavior(exit_env):
day_trading_state.positions["888888"] = _pos(10)
rows = [{"odno": "123", "tot_ccld_qty": "10", "avg_prvs": "10080"}]
with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)):
await es._execute_exit("888888", day_trading_state.positions["888888"], 10_100.0, "트레일링")
await asyncio.gather(*list(fr._pending_tasks))
assert "888888" not in day_trading_state.positions
_, upd = exit_env["updates"][-1]
assert float(upd["executed_price"]) == pytest.approx(10_080.0)
assert "status" not in upd
# ── 스케줄러 배선 ──────────────────────────────────────────────────────────
def test_scheduler_runs_stale_orphan_check_at_startup_without_restore():
"""복원 0건이어도 기동 시 전일·고아 점검을 한다 — 'if restored:' 블록 밖(복원 try 이후)."""
from app.services.daytrading import scheduler as sched
src = inspect.getsource(sched.daytrading_scheduler)
head = src[:src.index("while True:")]
restore_fail = head.index("시작 시 포지션 복원 실패")
assert head.index("_dt_exit.close_stale_positions()") > restore_fail
def test_scheduler_offhours_branch_checks_without_positions_guard():
from app.services.daytrading import scheduler as sched
src = inspect.getsource(sched.daytrading_scheduler)
tail = src[src.index("# 그 외 시간"):]
tail = tail[:tail.index("except Exception as e:")]
assert "check_and_exit_positions()" in tail
assert "if day_trading_state.positions:" not in tail
eod = src[src.index("if is_eod_flat_time():"):]
eod = eod[:eod.index("continue")]
assert "_dt_exit.close_stale_positions()" in eod
# ═════════════════════════════════════════════════════════════════════════════
# 2. 시간손절 임계
# ═════════════════════════════════════════════════════════════════════════════
def test_time_stop_threshold_is_negative():
"""임계는 손실 쪽이어야 한다(양수면 수익 중 포지션까지 시간손절)."""
assert settings.DAY_TRADE_TIME_STOP_MIN_GAIN < 0
def _exit_at(gain):
from app.core.timeutil import now_kst
entry = 10_000.0
return dts.check_day_trade_exit(
entry_price=entry, current_price=entry * (1 + gain), stop_loss=entry * 0.5,
target_price=entry * 2, highest_since_entry=entry, candles_5min=[],
current_time=time(10, 0),
entry_time=now_kst() - timedelta(minutes=settings.DAY_TRADE_TIME_STOP_MINUTES + 1),
)
def test_time_stop_not_triggered_by_cost_sized_loss():
# 임계의 절반 손실(비용·슬리피지 수준) — 시간손절 아님
sig = _exit_at(settings.DAY_TRADE_TIME_STOP_MIN_GAIN / 2)
assert sig is None or not dts.is_time_stop_exit_reason(sig.reason)
def test_time_stop_triggered_below_threshold_with_2dp_reason():
g = settings.DAY_TRADE_TIME_STOP_MIN_GAIN - 0.0001
sig = _exit_at(g)
assert sig is not None and dts.is_time_stop_exit_reason(sig.reason)
assert f"< {settings.DAY_TRADE_TIME_STOP_MIN_GAIN * 100:.2f}%" in sig.reason
# .1f 반올림이었다면 "-0.7% < -0.7%" 처럼 보였다 — 수익률도 .2f
assert f"수익={g * 100:.2f}%" in sig.reason
# ═════════════════════════════════════════════════════════════════════════════
# 3. 엔진 카드 '청산' = DT 청산만
# ═════════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
async def test_status_closed_count_excludes_swing_cooldown_codes():
from app.api.endpoints import day_trading as dt_api
day_trading_state.closed_today.update({"015760", "006260"}) # 스윙 손절 쿨다운(한국전력·LS)
async with day_trading_state.trade_lock:
for c in ("111111", "222222"):
day_trading_state.positions[c] = {"quantity": 1}
day_trading_state.remove_position(c)
out = await dt_api.get_status()
assert out["closed_today"] == 2
assert out["reentry_blocked_today"] == 4
@pytest.mark.asyncio
async def test_dt_exited_today_persisted_and_restored():
from app.services.daytrading.position_manager import DayTradingState
saved = {}
async def _setex(key, ttl, payload):
saved["p"] = payload
return True
async def _get(key):
return saved.get("p")
a = DayTradingState()
a.reset_if_new_day()
a.dt_exited_today.add("111111")
a.closed_today.update({"111111", "015760"})
with patch("app.core.redis.setex_json", _setex), patch("app.core.redis.get_json", _get):
assert await a.save_daily_state()
b = DayTradingState()
b.reset_if_new_day()
assert await b.load_daily_state()
assert b.dt_exited_today == {"111111"}
assert b.closed_today == {"111111", "015760"}