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

@@ -31,10 +31,13 @@ from app.services.korea_investment_api import korea_investment_api
from app.services.day_trading_strategy import (
parse_minute_candles, aggregate_to_5min,
check_day_trade_exit, is_daily_loss_exceeded,
is_time_stop_exit_reason,
)
from app.services.daytrading.position_manager import day_trading_state
from app.services.daytrading import fill_reconciler
from app.services.db_worker import enqueue_trade_history, enqueue_close_active_trade
from app.services.loss_tracker import loss_tracker
from app.services.trade_cost_core import net_realized_pnl
logger = logging.getLogger(__name__)
@@ -51,6 +54,26 @@ def get_stale_realized_pnl() -> float:
return _stale_realized_pnl
def _dt_sell_tax_rate(pos: dict) -> float:
"""DT 매도 거래세율 — 포지션에 시장 구분이 없으면 DAY_TRADE_DEFAULT_TAX_MARKET(스윙 기본과 동일)."""
market = str((pos or {}).get("market") or settings.DAY_TRADE_DEFAULT_TAX_MARKET or "KOSPI").upper()
if market == "KOSDAQ":
return float(settings.KR_SELL_TAX_RATE_KOSDAQ)
return float(settings.KR_SELL_TAX_RATE_KOSPI)
def dt_net_pnl(entry_price: float, exit_price: float, quantity: int, pos: dict) -> dict:
"""[2026-10-01] DT 실현손익(비용 차감). 스윙 집행 경로와 같은 비용 정의(trade_cost_core).
매수·매도 수수료(KR_COMMISSION_RATE) + 매도 거래세(KR_SELL_TAX_RATE_*)를 뺀다.
반환 키: gross_profit_loss / total_cost / net_profit_loss / net_profit_loss_rate
"""
return net_realized_pnl(
entry_price, exit_price, quantity,
float(settings.KR_COMMISSION_RATE), _dt_sell_tax_rate(pos),
)
def _fallback_stop_pct() -> float:
"""[M7] stop_loss 가 없을 때 사용할 폴백 손절 비율.
@@ -528,8 +551,15 @@ async def _finalize_exit_impl(
entry_price = pos.get("entry_price", 0)
stock_name = pos.get("stock_name", stock_code)
order_id = order_resp.get("output", {}).get("ODNO", "")
profit_loss = (price - entry_price) * quantity
profit_loss_rate = ((price - entry_price) / entry_price * 100) if entry_price > 0 else 0.0
# [2026-10-01] 순손익(수수료·거래세 차감)으로 기록·집계한다.
# 일일 손실한도(DAY_TRADE_DAILY_LOSS_LIMIT)·당일 연패·loss_tracker 가 모두 이 값을 본다
# → 비용만큼 한도가 **약간 더 일찍** 발동한다(보수적 방향, 의도). 손익분기(gross≈0)
# 청산도 순손익으로는 손실이다.
_pnl = dt_net_pnl(entry_price, price, quantity, pos)
gross_profit_loss = _pnl["gross_profit_loss"]
trade_cost = _pnl["total_cost"]
profit_loss = _pnl["net_profit_loss"]
profit_loss_rate = _pnl["net_profit_loss_rate"] if entry_price > 0 else 0.0
# 인메모리 상태 업데이트 (DB 직접 접근 금지)
async with day_trading_state.trade_lock:
@@ -561,10 +591,22 @@ async def _finalize_exit_impl(
await day_trading_state.save_daily_state()
# [P0] loss_tracker 연동 — 전량 매도 시 연속 손실 추적
# [2026-10-01] 시간손절은 **중립**(손실로도 이익으로도 세지 않음 → 기록 자체를 생략).
# 기회비용 청산까지 3연패 → CONSECUTIVE_LOSS_BAN_DAYS 진입금지에 넣으면 그 밴이
# 스윙 진입(swing/entry_service)까지 막는다. 하드/폴백 손절·일일한도·EOD·트레일링 등
# 나머지 청산은 기존대로 기록한다. (당일 연패 쿨다운 loss_streak_today 는 위 update_pnl 이
# 그대로 센다 — DT 전용·당일 한정 스로틀이라 스윙에 번지지 않는다.)
if is_full_exit:
is_loss = profit_loss < 0
await loss_tracker.record_trade_result_safe(stock_code, is_loss)
await loss_tracker.save_to_db(stock_code=stock_code)
if is_time_stop_exit_reason(reason):
logger.info(
f"[DayExit] 시간손절은 연속손실 집계 제외(중립): {stock_code} "
f"순손익={profit_loss:+.0f}원 — {settings.CONSECUTIVE_LOSS_BAN_COUNT}연패/"
f"{settings.CONSECUTIVE_LOSS_BAN_DAYS}일 밴 카운트 변화 없음"
)
else:
is_loss = profit_loss < 0
await loss_tracker.record_trade_result_safe(stock_code, is_loss)
await loss_tracker.save_to_db(stock_code=stock_code)
# DB 기록 (큐 경유)
await enqueue_trade_history({
@@ -579,10 +621,17 @@ async def _finalize_exit_impl(
"status": "COMPLETED",
"order_id": order_id,
"exit_reason": reason,
"commission": trade_cost,
"net_profit_loss": profit_loss,
"profit_loss_rate": profit_loss_rate,
"strategy_type": "daytrading",
})
# [2026-10-01] 실제 체결가 확인 — 백그라운드(루프 비차단). 확인되면 원장·당일 손익 보정.
_schedule_sell_fill_reconcile(
stock_code=stock_code, pos=dict(pos), order_id=order_id, signal_price=price,
quantity=quantity, entry_price=entry_price, recorded_net=profit_loss,
count_daily_pnl=count_daily_pnl,
)
if is_full_exit:
await enqueue_close_active_trade(stock_code, strategy_name="day_trade")
else:
@@ -601,7 +650,8 @@ async def _finalize_exit_impl(
sell_label = f"{int(sell_pct * 100)}%" if sell_pct < 1.0 else "전량"
logger.info(
f"[DayExit] ✅ 청산({sell_label}): {stock_code} {stock_name} "
f"수량={quantity}/{total_qty} 수익률={profit_loss_rate:.1f}% 이유={reason}"
f"수량={quantity}/{total_qty} 순수익률={profit_loss_rate:.2f}% "
f"(총손익={gross_profit_loss:+.0f} 비용={trade_cost:.0f} 순손익={profit_loss:+.0f}원) 이유={reason}"
)
return {
@@ -609,13 +659,72 @@ async def _finalize_exit_impl(
"stock_name": stock_name,
"price": price,
"quantity": quantity,
"profit_loss": profit_loss,
"profit_loss_rate": profit_loss_rate,
"profit_loss": profit_loss, # 순손익(비용 차감)
"profit_loss_rate": profit_loss_rate, # 순수익률(진입 원금 대비 %)
"gross_profit_loss": gross_profit_loss,
"trade_cost": trade_cost,
"reason": reason,
"sell_pct": sell_pct,
"order_id": order_id,
}
def _schedule_sell_fill_reconcile(
stock_code: str, pos: dict, order_id: str, signal_price: float, quantity: int,
entry_price: float, recorded_net: float, count_daily_pnl: bool,
):
"""매도 실제 체결가 확인을 백그라운드로 예약 (실시간 루프 비차단).
확인되면:
- TradeHistory(order_id) 의 체결가·비용·순손익·수익률·슬리피지(%)를 db_worker 로 갱신
- 기록했던 순손익과의 차이를 당일 daily_pnl 에 보정(전일 stale 청산은 _stale_realized_pnl —
§7 당일 미반영 규칙 유지). 당일 연패·loss_tracker 판정은 청산 시점 값 그대로 둔다.
확인 실패면 신호가 기록이 그대로 남는다(fill_reconciler 가 warning).
"""
from app.core.timeutil import now_kst as _now_kst
_order_date = _now_kst().date().isoformat() # 주문일(KST) — 확인이 늦어져도 이 날짜로 조회
async def _on_fill(filled_qty: int, avg_price: float) -> None:
global _stale_realized_pnl
if filled_qty < quantity:
logger.warning(
f"[DayExit] 매도 부분체결 확인: {stock_code} {filled_qty}/{quantity}주 "
f"order_id={order_id} — 인메모리 수량은 주문 수량 기준으로 반영돼 있다(수동 확인 필요)"
)
new = dt_net_pnl(entry_price, avg_price, quantity, pos)
delta = new["net_profit_loss"] - recorded_net
slippage_pct = ((avg_price - signal_price) / signal_price * 100) if signal_price > 0 else 0.0
from decimal import Decimal
from app.services.db_worker import enqueue_update_trade_history_by_order_id
await enqueue_update_trade_history_by_order_id(order_id, {
# 식별 키(db_worker 가 setattr 하지 않음): KIS ODNO 는 일별 순번이라 날짜·구분으로 좁힌다
"order_date": _order_date,
"order_type": "SELL",
"strategy_type": "daytrading",
"executed_price": Decimal(str(round(avg_price, 2))),
"total_amount": Decimal(str(round(avg_price * quantity, 2))),
"commission": Decimal(str(new["total_cost"])),
"net_profit_loss": Decimal(str(round(new["net_profit_loss"], 2))),
"profit_loss_rate": Decimal(str(round(new["net_profit_loss_rate"], 4))) if entry_price > 0 else None,
"slippage": Decimal(str(round(slippage_pct, 4))),
})
if delta:
if count_daily_pnl:
async with day_trading_state.trade_lock:
day_trading_state.adjust_realized_pnl(delta)
await day_trading_state.save_daily_state()
else:
_stale_realized_pnl += delta
logger.info(
f"[DayExit] 매도 체결가 확인: {stock_code} 신호가={signal_price:,.0f} "
f"체결가={avg_price:,.2f} (슬리피지 {slippage_pct:+.3f}%) "
f"순손익 {recorded_net:+.0f}→{new['net_profit_loss']:+.0f}원 "
f"({'당일' if count_daily_pnl else '전일'} 손익 보정 {delta:+.0f}원)"
)
return fill_reconciler.schedule(stock_code, order_id, "SELL", _on_fill)
async def close_stale_positions() -> list[dict]:
"""진입일이 오늘이 아닌(전일 미청산) 데이트레이딩 포지션을 시장가 전량 청산.