"""DT 결함 3건 회귀 테스트 (2026-10-01). 1. 시간손절은 loss_tracker(3연패 → CONSECUTIVE_LOSS_BAN_DAYS 진입금지) 집계에서 제외(중립). 하드 손절·폴백 손절·일일한도 청산 등은 기존대로 기록 + save_to_db. 2. 실현손익은 수수료·거래세 차감 순손익. 실제 체결가는 백그라운드로 확인해 원장·당일 손익 보정. → 일일 손실한도가 비용만큼 **더 일찍** 발동(보수적, 의도). 3. 레거시 `_record_trade` 가 strategy_type="daytrading" 을 명시. 목킹 원칙: 주문 경로는 예외 발생형(BaseException) — 목 누락이 거짓 통과가 되지 않게. 기대값은 settings.* 에서 계산(하드코딩 금지). """ import asyncio from datetime import datetime, time, timedelta 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 entry_service as ens from app.services.daytrading import fill_reconciler as fr from app.services.daytrading.position_manager import day_trading_state from app.services.trade_cost_core import net_realized_pnl, round_trip_costs class MustNotBeCalled(BaseException): """호출되면 안 되는 경로 도달 (except Exception 에 삼켜지지 않게 BaseException).""" def _boom(name): async def _f(*a, **k): raise MustNotBeCalled(f"호출 금지 경로 도달: {name}") return _f def _expected_net(entry, exit_, qty, tax_rate=None): """settings 비용률로 독립 계산한 기대 순손익 (trade_helpers.calculate_trade_costs 와 같은 반올림).""" tax = settings.KR_SELL_TAX_RATE_KOSPI if tax_rate is None else tax_rate c = settings.KR_COMMISSION_RATE cost = round(entry * qty * c + exit_ * qty * c + exit_ * qty * tax, 0) return (exit_ - entry) * qty - cost, cost @pytest.fixture def dt_env(): """_execute_exit → _execute_exit_impl → _finalize_exit_impl 을 실제로 타게 하되 외부 경계(주문·DB 큐·캐시·Redis)만 목으로 덮는다.""" import app.services.trade_executor as te import app.services.portfolio_cache_bridge as pcb captured = {"history": [], "updates": [], "upserts": []} async def _hist(payload): captured["history"].append(payload) async def _upd(order_id, fields): captured["updates"].append((order_id, fields)) async def _ups(payload): captured["upserts"].append(payload) saved_positions = dict(day_trading_state.positions) saved_pnl = day_trading_state.daily_pnl saved_streak = day_trading_state.loss_streak_today saved_closed = set(day_trading_state.closed_today) saved_stale = es._stale_realized_pnl day_trading_state.positions.clear() day_trading_state.daily_pnl = 0.0 day_trading_state.loss_streak_today = 0 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", _ups), \ patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", _upd), \ patch.object(ens, "enqueue_active_trade_upsert", _ups), \ patch.object(pcb, "reflect_sell", AsyncMock()), \ patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)): yield captured day_trading_state.positions.clear() day_trading_state.positions.update(saved_positions) day_trading_state.daily_pnl = saved_pnl day_trading_state.loss_streak_today = saved_streak day_trading_state.closed_today.clear() day_trading_state.closed_today.update(saved_closed) es._stale_realized_pnl = saved_stale def _pos(entry=10_000.0, qty=10, order_id="0000999"): return {"stock_name": "테스트", "entry_price": entry, "quantity": qty, "highest_price": entry, "order_id": order_id} # ── 1. 시간손절 중립 처리 ─────────────────────────────────────────────────── def test_time_stop_reason_from_strategy_is_classified(): """check_day_trade_exit 가 실제로 만드는 시간손절 사유가 판별 함수와 일치해야 한다.""" from app.core.timeutil import now_kst entry_time = now_kst() - timedelta(minutes=settings.DAY_TRADE_TIME_STOP_MINUTES + 1) entry = 10_000.0 price = entry * (1 + settings.DAY_TRADE_TIME_STOP_MIN_GAIN * 2) # 임계 미달 sig = dts.check_day_trade_exit( entry_price=entry, current_price=price, stop_loss=entry * 0.5, target_price=entry * 2, highest_since_entry=entry, candles_5min=[], current_time=time(10, 0), entry_time=entry_time, ) assert sig is not None and dts.is_time_stop_exit_reason(sig.reason), sig # 손절·EOD·폴백·일일한도 사유는 시간손절이 아니다 for r in ("손절 -3.0% (현재=9700 ≤ 손절가=9700)", "EOD 강제 청산 (15:20 ≥ 15:20)", "폴백 손절(분봉 미확보) -2.1%", "일일 손실 한도 초과", "", None): assert not dts.is_time_stop_exit_reason(r) @pytest.mark.asyncio async def test_time_stop_exit_not_recorded_in_loss_tracker(dt_env): day_trading_state.positions["111111"] = _pos() with patch.object(es.loss_tracker, "record_trade_result_safe", _boom("record_trade_result_safe")), \ patch.object(es.loss_tracker, "save_to_db", _boom("save_to_db")): res = await es._execute_exit( "111111", day_trading_state.positions["111111"], 9_960.0, f"{dts.TIME_STOP_REASON_PREFIX}: 31분 경과, 수익=-0.4% < -0.3%", ) assert res is not None and res["profit_loss"] < 0 # 당일 연패 쿨다운(DT 전용·당일 한정)은 그대로 센다 assert day_trading_state.loss_streak_today == 1 @pytest.mark.asyncio @pytest.mark.parametrize("reason", [ "손절 -3.0% (현재=9700 ≤ 손절가=9700)", "폴백 손절(분봉 미확보) -2.1%", "일일 손실 한도 초과", ]) async def test_real_stop_exits_still_recorded_and_saved(dt_env, reason): day_trading_state.positions["222222"] = _pos() rec, save = AsyncMock(), AsyncMock() with patch.object(es.loss_tracker, "record_trade_result_safe", rec), \ patch.object(es.loss_tracker, "save_to_db", save): await es._execute_exit("222222", day_trading_state.positions["222222"], 9_700.0, reason) rec.assert_awaited_once_with("222222", True) save.assert_awaited_once_with(stock_code="222222") # ── 2. 순손익 ────────────────────────────────────────────────────────────── def test_cost_core_matches_swing_definition(): """trade_cost_core 는 스윙 집행 경로(trade_helpers.calculate_trade_costs)와 같은 정의.""" from app.services.trade_helpers import calculate_trade_costs for market, tax in (("KOSPI", settings.KR_SELL_TAX_RATE_KOSPI), ("KOSDAQ", settings.KR_SELL_TAX_RATE_KOSDAQ)): for e, x, q in ((10_000, 10_100, 10), (53_400, 52_100, 7), (1_234, 1_250, 333)): assert round_trip_costs(e, x, q, settings.KR_COMMISSION_RATE, tax) == \ calculate_trade_costs(e, x, q, True, market) @pytest.mark.asyncio async def test_exit_records_net_pnl_and_feeds_daily_pnl(dt_env): entry, exit_, qty = 10_000.0, 10_100.0, 10 day_trading_state.positions["333333"] = _pos(entry, qty) with patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()), \ patch.object(es.loss_tracker, "save_to_db", AsyncMock()): res = await es._execute_exit("333333", day_trading_state.positions["333333"], exit_, "트레일링 스탑") net, cost = _expected_net(entry, exit_, qty) rec = dt_env["history"][-1] assert rec["net_profit_loss"] == pytest.approx(net) assert rec["commission"] == pytest.approx(cost) assert rec["profit_loss_rate"] == pytest.approx(net / (entry * qty) * 100) assert rec["strategy_type"] == "daytrading" assert day_trading_state.daily_pnl == pytest.approx(net) assert res["gross_profit_loss"] == pytest.approx((exit_ - entry) * qty) @pytest.mark.asyncio async def test_breakeven_exit_counts_as_loss_after_costs(dt_env): """총손익 0 이어도 순손익은 비용만큼 음수 → loss_tracker 손실, 당일 연패 +1 (의도된 보수화).""" day_trading_state.positions["444444"] = _pos() rec = AsyncMock() with patch.object(es.loss_tracker, "record_trade_result_safe", rec), \ patch.object(es.loss_tracker, "save_to_db", AsyncMock()): await es._execute_exit("444444", day_trading_state.positions["444444"], 10_000.0, "EOD 강제 청산") rec.assert_awaited_once_with("444444", True) assert day_trading_state.daily_pnl < 0 def test_daily_loss_limit_fires_earlier_with_net_pnl(): """[의도 고정] 총손익으로는 한도 직전이던 손실이 순손익으로는 한도를 넘는다.""" total_assets = 10_000_000.0 limit_amt = abs(dts._get_daily_loss_limit()) * total_assets entry, qty = 10_000.0, 100 # 총손실이 한도보다 1원 작게: (exit-entry)*qty = -(limit_amt-1) exit_ = entry - (limit_amt - 1) / qty gross = (exit_ - entry) * qty net = net_realized_pnl(entry, exit_, qty, settings.KR_COMMISSION_RATE, settings.KR_SELL_TAX_RATE_KOSPI)["net_profit_loss"] assert not dts.is_daily_loss_exceeded(total_assets, gross) assert dts.is_daily_loss_exceeded(total_assets, net) @pytest.mark.asyncio async def test_stale_exit_net_pnl_excluded_from_daily(dt_env): entry, exit_, qty = 10_000.0, 9_500.0, 10 pos = _pos(entry, qty) before = es.get_stale_realized_pnl() with patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()), \ patch.object(es.loss_tracker, "save_to_db", AsyncMock()): await es._execute_exit_stale("555555", pos, exit_, "전일 미청산 포지션 강제 청산") net, _ = _expected_net(entry, exit_, qty) assert day_trading_state.daily_pnl == 0.0 assert es.get_stale_realized_pnl() - before == pytest.approx(net) # ── 2b. 체결가 확인 ──────────────────────────────────────────────────────── def test_parse_fill_row_matches_zero_padded_odno(): rows = [{"odno": "0000000777", "tot_ccld_qty": "5", "avg_prvs": "10050"}, {"odno": "0000123", "tot_ccld_qty": "10", "avg_prvs": "0", "tot_ccld_amt": "100700"}] assert fr.parse_fill_row(rows, "0000123") == (10, 10070.0) assert fr.parse_fill_row(rows, "777") == (5, 10050.0) assert fr.parse_fill_row(rows, "888") is None assert fr.parse_fill_row([{"odno": "1", "tot_ccld_qty": "0", "avg_prvs": "0"}], "1") is None @pytest.mark.asyncio async def test_fetch_fill_timeout_returns_none(monkeypatch, caplog): monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0) monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_TIMEOUT_SEC", 0.05) async def _slow(*a, **k): await asyncio.sleep(5) # rate-limit sleep 모사 return [] with patch.object(fr.korea_investment_api, "get_todays_orders", _slow): assert await fr.fetch_fill("000000", "1", "SELL") is None assert any("시간초과" in r.message for r in caplog.records) @pytest.mark.asyncio async def test_schedule_disabled_is_noop(): # conftest 가 유닛 테스트 기본값으로 OFF assert settings.DAY_TRADE_FILL_CHECK_ENABLED is False assert fr.schedule("000000", "1", "BUY", _boom("on_fill")) is None @pytest.fixture def fill_on(monkeypatch): monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_ENABLED", True) monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0) monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_INTERVAL_SEC", 0.0) @pytest.mark.asyncio async def test_sell_fill_reconcile_adjusts_daily_pnl_and_history(dt_env, fill_on): entry, signal, fill, qty = 10_000.0, 10_100.0, 10_080.0, 10 day_trading_state.positions["666666"] = _pos(entry, qty) rows = [{"odno": "123", "tot_ccld_qty": str(qty), "avg_prvs": str(fill)}] with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)), \ patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()), \ patch.object(es.loss_tracker, "save_to_db", AsyncMock()): await es._execute_exit("666666", day_trading_state.positions["666666"], signal, "트레일링 스탑") await asyncio.gather(*list(fr._pending_tasks)) net_fill, cost_fill = _expected_net(entry, fill, qty) assert day_trading_state.daily_pnl == pytest.approx(net_fill) oid, upd = dt_env["updates"][-1] assert oid == "0000123" assert float(upd["executed_price"]) == pytest.approx(fill) assert float(upd["net_profit_loss"]) == pytest.approx(net_fill) assert float(upd["commission"]) == pytest.approx(cost_fill) assert float(upd["slippage"]) == pytest.approx(round((fill - signal) / signal * 100, 4)) @pytest.mark.asyncio async def test_sell_fill_reconcile_stale_goes_to_stale_bucket(dt_env, fill_on): entry, signal, fill, qty = 10_000.0, 9_500.0, 9_450.0, 10 rows = [{"odno": "123", "tot_ccld_qty": str(qty), "avg_prvs": str(fill)}] before = es.get_stale_realized_pnl() with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)), \ patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()), \ patch.object(es.loss_tracker, "save_to_db", AsyncMock()): await es._execute_exit_stale("777777", _pos(entry, qty), signal, "전일 미청산 포지션 강제 청산") await asyncio.gather(*list(fr._pending_tasks)) net_fill, _ = _expected_net(entry, fill, qty) assert day_trading_state.daily_pnl == 0.0 assert es.get_stale_realized_pnl() - before == pytest.approx(net_fill) @pytest.mark.asyncio async def test_buy_fill_reconcile_updates_entry_price(dt_env, fill_on): day_trading_state.positions["888888"] = _pos(10_000.0, 10, order_id="0000456") rows = [{"odno": "456", "tot_ccld_qty": "10", "avg_prvs": "10030"}] with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)): ens._schedule_buy_fill_reconcile("888888", "0000456", 10_000.0, 10) await asyncio.gather(*list(fr._pending_tasks)) assert day_trading_state.positions["888888"]["entry_price"] == pytest.approx(10_030.0) assert dt_env["upserts"][-1] == {"stock_code": "888888", "strategy_name": "day_trade", "entry_price": 10_030.0} oid, upd = dt_env["updates"][-1] assert oid == "0000456" and float(upd["executed_price"]) == pytest.approx(10_030.0) @pytest.mark.asyncio async def test_buy_fill_reconcile_skips_replaced_position(dt_env, fill_on): """다른 주문으로 교체된 포지션의 진입가는 건드리지 않는다(원장만 보정).""" day_trading_state.positions["999999"] = _pos(10_000.0, 10, order_id="OTHER") rows = [{"odno": "456", "tot_ccld_qty": "10", "avg_prvs": "10030"}] with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)): ens._schedule_buy_fill_reconcile("999999", "0000456", 10_000.0, 10) await asyncio.gather(*list(fr._pending_tasks)) assert day_trading_state.positions["999999"]["entry_price"] == 10_000.0 assert dt_env["upserts"] == [] assert len(dt_env["updates"]) == 1 @pytest.mark.asyncio async def test_fill_check_failure_keeps_signal_price(dt_env, fill_on): """체결 미확인이면 원장·당일 손익을 건드리지 않는다(신호가 유지).""" day_trading_state.positions["121212"] = _pos() with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=[])), \ patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()), \ patch.object(es.loss_tracker, "save_to_db", AsyncMock()): await es._execute_exit("121212", day_trading_state.positions["121212"], 10_100.0, "트레일링 스탑") pnl_after_exit = day_trading_state.daily_pnl await asyncio.gather(*list(fr._pending_tasks)) assert day_trading_state.daily_pnl == pnl_after_exit assert dt_env["updates"] == [] # ── 3. strategy_type ─────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_legacy_record_trade_sets_daytrading_strategy_type(): from app.api.endpoints import day_trading as dt_api cap = AsyncMock() with patch("app.services.db_worker.enqueue_trade_history", cap): await dt_api._record_trade(None, "005930", "삼성전자", "SELL", 70_000.0, 1, "EOD_flat", -100.0) assert cap.await_args.args[0]["strategy_type"] == "daytrading"