fix: 리스크 결함 일괄 수정 — 누적 4% 상한·스윙 소유권·일일한도 분리·DT 순손익/체결가·시간손절 밴 제외·체결조회 경로
trade_executor - 종목당 4% 상한을 기존 보유분 포함 누적으로 (sizing_core.remaining_position_room) - 추천 경로는 스윙 소유 행만 매수·매도 (external/manual/daytrading/us_swing skip, 대칭), 고아 DT 행 warning - 일일 매수한도는 스윙 소유 매수만 집계, 집계 실패 시 fail-closed - 부분매도 반복 차단에서만 DT 매도 제외 (재매수 쿨다운은 유지) - 체결 확인: 부분체결 잔량 자동 재주문 제거(가드 전부 우회·취소/거부 시 발동), PARTIAL→COMPLETED+체결수량, 초과체결 캡 korea_investment_api - get_order_execution 이 존재하지 않는 /trading/inquire-ccnl 을 호출하던 결함 → inquire-daily-ccld (TTTC0081R 우선, 구 TR 폴백, 페이지네이션, ODNO 매칭, EXCG_ID_DVSN_CD=KRX), 실패 로그 warning daytrading - 시간손절은 loss_tracker 중립 (42일 밴이 스윙까지 막던 문제) - 실현손익을 수수료·거래세 차감 순손익으로 (trade_cost_core), 체결가 백그라운드 보정 (fill_reconciler) - legacy _record_trade strategy_type=daytrading 명시 db_worker - update_trade_history_by_order_id 를 당일(KST)+최신 1행으로 한정 (일별 재사용 ODNO 로 과거 행 덮어쓰기 방지) alembic 0013: trade_journals strategy_name='unknown' 을 trade_history.strategy_type 으로 보정 (백업 테이블 포함) CLAUDE.md: 변경 동작 반영, MT 예약 리비전 0013→0014 998 passed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
338
backend/tests/test_dt_cost_fill_timestop_20261001.py
Normal file
338
backend/tests/test_dt_cost_fill_timestop_20261001.py
Normal file
@@ -0,0 +1,338 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user