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:
2026-10-01 17:37:29 +09:00
parent 599de3b52e
commit 45677c8fbb
19 changed files with 2873 additions and 166 deletions

View 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"