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:
456
backend/tests/test_dt_orphan_timestop_20261008.py
Normal file
456
backend/tests/test_dt_orphan_timestop_20261008.py
Normal 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"}
|
||||
Reference in New Issue
Block a user