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:
@@ -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]:
|
||||
"""진입일이 오늘이 아닌(전일 미청산) 데이트레이딩 포지션을 시장가 전량 청산.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user