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:
@@ -33,7 +33,7 @@ from app.services.day_trading_strategy import (
|
||||
parse_minute_candles, aggregate_to_5min,
|
||||
calculate_vwap, calculate_opening_range,
|
||||
check_orb_signal, check_vwap_bounce_signal, check_rsi_bb_signal,
|
||||
check_day_trade_exit,
|
||||
check_day_trade_exit, is_time_stop_exit_reason,
|
||||
calculate_day_trade_position_size, is_daily_loss_exceeded,
|
||||
ORB_END, ENTRY_CUTOFF, EOD_FLAT, MAX_POSITIONS,
|
||||
)
|
||||
@@ -271,6 +271,8 @@ async def _record_trade(
|
||||
"status": "COMPLETED",
|
||||
"entry_reason": reason if order_type == "BUY" else None,
|
||||
"exit_reason": reason if order_type == "SELL" else None,
|
||||
# [2026-10-01] 명시 — 빠지면 모델 기본값 'swing' 으로 기록돼 DT 거래가 스윙 통계에 섞인다.
|
||||
"strategy_type": "daytrading",
|
||||
"executed_at": _kst_now(),
|
||||
})
|
||||
except Exception as e:
|
||||
@@ -480,9 +482,11 @@ async def _check_exits(
|
||||
await enqueue_close_active_trade(code, strategy_name="day_trade")
|
||||
|
||||
# loss_tracker 기록 — CLAUDE.md 7.3 규칙 준수
|
||||
_profit_rate = (current_price - pos["entry_price"]) / pos["entry_price"] if pos["entry_price"] > 0 else 0
|
||||
await loss_tracker.record_trade_result_safe(code, _profit_rate < 0)
|
||||
await loss_tracker.save_to_db(db=None, stock_code=code)
|
||||
# [2026-10-01] 시간손절은 연속손실 집계 제외(중립) — 엔진 경로(exit_service)와 동일
|
||||
if not is_time_stop_exit_reason(exit_signal.reason):
|
||||
_profit_rate = (current_price - pos["entry_price"]) / pos["entry_price"] if pos["entry_price"] > 0 else 0
|
||||
await loss_tracker.record_trade_result_safe(code, _profit_rate < 0)
|
||||
await loss_tracker.save_to_db(db=None, stock_code=code)
|
||||
|
||||
exits.append({
|
||||
"stock_code": code,
|
||||
|
||||
@@ -312,6 +312,15 @@ class Settings(BaseSettings):
|
||||
DAY_TRADE_TRAILING_ATR_MULT: float = 1.5 # 트레일링 ATR 배수 (1.0→1.5 whipsaw 방지)
|
||||
DAY_TRADE_ORB_BREAKOUT_MARGIN: float = 0.002 # ORB 돌파 최소 여유 +0.2%
|
||||
DAY_TRADE_COST_PCT: float = 0.005 # 수수료+슬리피지 합산 (매수+매도)
|
||||
# [2026-10-01] DT 실제 체결가 확인 (주문 후 백그라운드, 실시간 루프 비차단).
|
||||
# 실패/시간초과 시 신호가 기록 유지 + warning. 판정 임계값이 아니라 기록 정확도용.
|
||||
DAY_TRADE_FILL_CHECK_ENABLED: bool = True
|
||||
DAY_TRADE_FILL_CHECK_DELAY_SEC: float = 2.0 # 주문 직후 첫 조회까지 대기
|
||||
DAY_TRADE_FILL_CHECK_ATTEMPTS: int = 3 # 체결 조회 최대 횟수
|
||||
DAY_TRADE_FILL_CHECK_INTERVAL_SEC: float = 2.0 # 조회 간격
|
||||
DAY_TRADE_FILL_CHECK_TIMEOUT_SEC: float = 15.0 # 전체 상한 (토큰 rate-limit sleep 차단)
|
||||
# DT 종목의 시장 구분을 모를 때 거래세 적용 시장 (스윙 trade_executor 기본값과 동일)
|
||||
DAY_TRADE_DEFAULT_TAX_MARKET: str = "KOSPI"
|
||||
DAY_TRADE_ENTRY_CUTOFF_HOUR: int = 15 # 신규 진입 마감 시간 (14:30→15:00, 2026-09-22 시간대 제한 완화)
|
||||
DAY_TRADE_ENTRY_CUTOFF_MIN: int = 0 # 신규 진입 마감 분
|
||||
DAY_TRADE_EOD_HOUR: int = 15 # EOD 강제 청산 시작 시간
|
||||
|
||||
@@ -164,6 +164,19 @@ class DayTradeExit:
|
||||
sell_pct: float = 1.0
|
||||
|
||||
|
||||
# [2026-10-01] 시간손절 사유 접두어 — 사유 문자열 생성과 판별이 이 상수 하나를 공유한다.
|
||||
# 시간손절은 "진입 후 N분간 목표 방향 진행 미미"를 정리하는 **기회비용 청산**이지
|
||||
# 손절선 이탈이 아니다. 이것까지 loss_tracker(3연패 → CONSECUTIVE_LOSS_BAN_DAYS 진입금지)에
|
||||
# 집계하면 소폭 손실 시간손절 3회만으로 그 종목의 스윙 진입까지 42일간 막힌다.
|
||||
# 판별은 exit_service 가 청산 사유로 한다(_execute_exit 시그니처 불변 유지).
|
||||
TIME_STOP_REASON_PREFIX = "시간 손절"
|
||||
|
||||
|
||||
def is_time_stop_exit_reason(reason: Optional[str]) -> bool:
|
||||
"""청산 사유가 DT 시간손절인지 판별 (check_day_trade_exit 가 만든 사유 기준)."""
|
||||
return bool(reason) and str(reason).startswith(TIME_STOP_REASON_PREFIX)
|
||||
|
||||
|
||||
# ── 분봉 파싱 헬퍼 ────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_minute_candles(raw_output2: list) -> List[MinuteCandle]:
|
||||
@@ -771,7 +784,7 @@ def check_day_trade_exit(
|
||||
if gain_pct < settings.DAY_TRADE_TIME_STOP_MIN_GAIN: # 최소 수익 미달 (수수료 커버 못함)
|
||||
return DayTradeExit(
|
||||
action="SELL_ALL",
|
||||
reason=f"시간 손절: {elapsed_min:.0f}분 경과, 수익={gain_pct*100:.1f}% < {settings.DAY_TRADE_TIME_STOP_MIN_GAIN*100:.1f}%",
|
||||
reason=f"{TIME_STOP_REASON_PREFIX}: {elapsed_min:.0f}분 경과, 수익={gain_pct*100:.1f}% < {settings.DAY_TRADE_TIME_STOP_MIN_GAIN*100:.1f}%",
|
||||
)
|
||||
|
||||
# 4. 목표가 달성 → 50% 분할 매도 (CLAUDE.md 5.2: +2R 시 50% 분할 매도)
|
||||
|
||||
@@ -1035,6 +1035,11 @@ async def _try_enter(
|
||||
"take_profit": signal.target_price,
|
||||
})
|
||||
|
||||
# [2026-10-01] 실제 체결가 확인 — 백그라운드(루프 비차단). 위 기록의 executed_price 는
|
||||
# 신호 시점 1분봉 종가다. 확인되면 원장·인메모리 진입가·ActiveTrade 를 체결가로 보정하고,
|
||||
# 실패하면 신호가 기록이 그대로 남는다(warning).
|
||||
_schedule_buy_fill_reconcile(stock_code, order_id, current_price, quantity)
|
||||
|
||||
logger.info(
|
||||
f"[DayEntry] ✅ 진입: {stock_code} {stock_name} "
|
||||
f"수량={quantity} 가격={current_price:,.0f} 신호={signal.strategy}"
|
||||
@@ -1052,3 +1057,59 @@ async def _try_enter(
|
||||
except Exception as e:
|
||||
logger.error(f"[DayEntry] _try_enter 오류 ({stock_code}): {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _schedule_buy_fill_reconcile(
|
||||
stock_code: str, order_id: str, signal_price: float, quantity: int,
|
||||
):
|
||||
"""[2026-10-01] 매수 실제 체결가 확인을 백그라운드로 예약 (실시간 루프 비차단).
|
||||
|
||||
확인되면:
|
||||
- TradeHistory(order_id) executed_price·total_amount·slippage(%) 갱신 (db_worker 큐)
|
||||
- 인메모리 포지션 entry_price 를 체결가로 교체(같은 주문번호의 포지션이 남아 있을 때만).
|
||||
손절가·목표가는 신호 시점 절대가격 그대로 둔다(전략 파라미터 불변).
|
||||
- ActiveTrade(day_trade) entry_price 갱신 — 재기동 복원도 체결가 기준이 된다.
|
||||
이미 일부/전량 청산된 뒤에 확인되면 그 청산 손익은 신호가 기준으로 남는다(보고된 잔여 위험).
|
||||
"""
|
||||
from app.services.daytrading import fill_reconciler
|
||||
|
||||
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:
|
||||
if filled_qty < quantity:
|
||||
logger.warning(
|
||||
f"[DayEntry] 매수 부분체결 확인: {stock_code} {filled_qty}/{quantity}주 "
|
||||
f"order_id={order_id} — 인메모리 수량은 주문 수량 기준(수동 확인 필요)"
|
||||
)
|
||||
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": "BUY",
|
||||
"strategy_type": "daytrading",
|
||||
"executed_price": Decimal(str(round(avg_price, 2))),
|
||||
"total_amount": Decimal(str(round(avg_price * quantity, 2))),
|
||||
"slippage": Decimal(str(round(slippage_pct, 4))),
|
||||
})
|
||||
updated = False
|
||||
async with day_trading_state.trade_lock:
|
||||
pos = day_trading_state.positions.get(stock_code)
|
||||
if pos is not None and str(pos.get("order_id", "")) == str(order_id):
|
||||
pos["entry_price"] = float(avg_price)
|
||||
updated = True
|
||||
if updated:
|
||||
await enqueue_active_trade_upsert({
|
||||
"stock_code": stock_code,
|
||||
"strategy_name": "day_trade",
|
||||
"entry_price": float(avg_price),
|
||||
})
|
||||
logger.info(
|
||||
f"[DayEntry] 매수 체결가 확인: {stock_code} 신호가={signal_price:,.0f} "
|
||||
f"체결가={avg_price:,.2f} (슬리피지 {slippage_pct:+.3f}%)"
|
||||
+ ("" if updated else " — 포지션 이미 청산/교체됨, 원장만 보정")
|
||||
)
|
||||
|
||||
return fill_reconciler.schedule(stock_code, order_id, "BUY", _on_fill)
|
||||
|
||||
@@ -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]:
|
||||
"""진입일이 오늘이 아닌(전일 미청산) 데이트레이딩 포지션을 시장가 전량 청산.
|
||||
|
||||
|
||||
143
backend/app/services/daytrading/fill_reconciler.py
Normal file
143
backend/app/services/daytrading/fill_reconciler.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""데이트레이딩 실제 체결가 확인 (백그라운드).
|
||||
|
||||
[2026-10-01] DT 는 시장가 주문 후 신호 시점 가격(1분봉 종가)을 체결가로 기록했다.
|
||||
실제 체결가와의 차이(슬리피지)가 원장·일일 손익·성과 통계에서 사라진다.
|
||||
|
||||
설계:
|
||||
- 주문 응답(KIS order-cash)에는 체결가가 없다 → 당일 주문체결 조회
|
||||
(`korea_investment_api.get_todays_orders`, inquire-daily-ccld)에서 주문번호(ODNO)로 찾는다.
|
||||
`get_order_execution()` 은 쓰지 않는다 — 국내 경로가 같은 TR(TTTC8001R)을
|
||||
`/trading/inquire-ccnl` 로 호출해(같은 TR 의 다른 메서드 `get_todays_orders` 는
|
||||
`/trading/inquire-daily-ccld`) 경로가 어긋나 있을 가능성이 높고, 실패를 debug 로 삼켜
|
||||
체결 0 과 구분되지 않는다(운영 확인 필요 — 보고 사항).
|
||||
- **실시간 루프를 막지 않는다**: `schedule()` 이 asyncio 태스크로 띄우고 즉시 반환.
|
||||
전체를 `DAY_TRADE_FILL_CHECK_TIMEOUT_SEC` 로 감싼다 — KIS 토큰 rate-limit 의
|
||||
`asyncio.sleep(≈70초)` 에 걸려도 상한에서 끊긴다.
|
||||
- DB I/O 없음. 반영은 호출부 콜백이 인메모리 + db_worker 큐로 한다(§3).
|
||||
- 확인 실패 시 None → 호출부는 신호가 기록을 유지하고 warning 을 남긴다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Awaitable, Callable, Optional
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.korea_investment_api import korea_investment_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# create_task 결과의 강한 참조 — 참조가 없으면 GC 로 태스크가 중간에 사라질 수 있다.
|
||||
_pending_tasks: "set[asyncio.Task]" = set()
|
||||
|
||||
|
||||
def _norm_odno(value) -> str:
|
||||
"""KIS 주문번호 정규화 — 응답(ODNO)은 0 패딩, 조회(odno)는 경우에 따라 다르다."""
|
||||
s = str(value or "").strip()
|
||||
return s.lstrip("0") or ("0" if s else "")
|
||||
|
||||
|
||||
def parse_fill_row(rows: list, order_id: str) -> Optional[tuple[int, float]]:
|
||||
"""inquire-daily-ccld output1 에서 주문번호가 일치하는 행의 (체결수량, 평균체결가).
|
||||
|
||||
체결수량 0 이거나 평균가를 구할 수 없으면 None.
|
||||
"""
|
||||
target = _norm_odno(order_id)
|
||||
if not target:
|
||||
return None
|
||||
for row in rows or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
if _norm_odno(row.get("odno")) != target:
|
||||
continue
|
||||
try:
|
||||
qty = int(float(row.get("tot_ccld_qty", 0) or 0))
|
||||
avg = float(row.get("avg_prvs", 0) or 0)
|
||||
if avg <= 0 and qty > 0:
|
||||
amt = float(row.get("tot_ccld_amt", 0) or 0)
|
||||
avg = amt / qty if amt > 0 else 0.0
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if qty > 0 and avg > 0:
|
||||
return qty, avg
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
async def _poll_fill(stock_code: str, order_id: str) -> Optional[tuple[int, float]]:
|
||||
"""체결 조회 폴링 (타임아웃은 호출부 wait_for 가 건다)."""
|
||||
attempts = max(1, int(settings.DAY_TRADE_FILL_CHECK_ATTEMPTS))
|
||||
interval = max(0.0, float(settings.DAY_TRADE_FILL_CHECK_INTERVAL_SEC))
|
||||
await asyncio.sleep(max(0.0, float(settings.DAY_TRADE_FILL_CHECK_DELAY_SEC)))
|
||||
for i in range(attempts):
|
||||
rows = await korea_investment_api.get_todays_orders(stock_code)
|
||||
fill = parse_fill_row(rows, order_id)
|
||||
if fill is not None:
|
||||
return fill
|
||||
if i < attempts - 1:
|
||||
await asyncio.sleep(interval)
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_fill(stock_code: str, order_id: str, side: str) -> Optional[tuple[int, float]]:
|
||||
"""주문번호의 실제 (체결수량, 평균체결가). 실패·시간초과·미체결이면 None + warning."""
|
||||
if not order_id:
|
||||
logger.warning(f"[DTFill] {stock_code} {side}: 주문번호 없음 — 신호가 기록 유지")
|
||||
return None
|
||||
timeout = float(settings.DAY_TRADE_FILL_CHECK_TIMEOUT_SEC)
|
||||
try:
|
||||
fill = await asyncio.wait_for(_poll_fill(stock_code, order_id), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
f"[DTFill] {stock_code} {side} 체결 조회 시간초과({timeout:.0f}초) "
|
||||
f"order_id={order_id} — 신호가 기록 유지"
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[DTFill] {stock_code} {side} 체결 조회 실패 order_id={order_id}: "
|
||||
f"{type(e).__name__}: {e} — 신호가 기록 유지"
|
||||
)
|
||||
return None
|
||||
if fill is None:
|
||||
logger.warning(
|
||||
f"[DTFill] {stock_code} {side} 체결 미확인 "
|
||||
f"({int(settings.DAY_TRADE_FILL_CHECK_ATTEMPTS)}회 조회) order_id={order_id} — 신호가 기록 유지"
|
||||
)
|
||||
return fill
|
||||
|
||||
|
||||
def schedule(
|
||||
stock_code: str,
|
||||
order_id: str,
|
||||
side: str,
|
||||
on_fill: Callable[[int, float], Awaitable[None]],
|
||||
) -> Optional[asyncio.Task]:
|
||||
"""체결 확인을 백그라운드로 띄우고 즉시 반환. 확인되면 `on_fill(qty, avg_price)` 호출.
|
||||
|
||||
`DAY_TRADE_FILL_CHECK_ENABLED=False` 면 아무것도 하지 않는다(None).
|
||||
"""
|
||||
if not settings.DAY_TRADE_FILL_CHECK_ENABLED:
|
||||
return None
|
||||
|
||||
async def _run() -> None:
|
||||
fill = await fetch_fill(stock_code, order_id, side)
|
||||
if fill is None:
|
||||
return
|
||||
try:
|
||||
await on_fill(*fill)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[DTFill] {stock_code} {side} 체결가 반영 실패 order_id={order_id}: "
|
||||
f"{type(e).__name__}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
try:
|
||||
task = asyncio.get_running_loop().create_task(_run())
|
||||
except RuntimeError as e:
|
||||
logger.warning(f"[DTFill] {stock_code} {side} 체결 확인 예약 실패: {e}")
|
||||
return None
|
||||
_pending_tasks.add(task)
|
||||
task.add_done_callback(_pending_tasks.discard)
|
||||
return task
|
||||
@@ -200,6 +200,15 @@ class DayTradingState:
|
||||
# 이익 발생 시 연속 손실 카운트 리셋 (손익분기 realized==0은 중립 — 리셋하지 않음)
|
||||
self.loss_streak_today = 0
|
||||
|
||||
def adjust_realized_pnl(self, delta: float) -> None:
|
||||
"""[2026-10-01] 실현손익 사후 보정 (체결가 확인으로 기록 손익이 바뀐 경우).
|
||||
|
||||
반드시 trade_lock 획득 후 호출해야 함.
|
||||
daily_pnl 만 보정하고 loss_streak_today 는 건드리지 않는다 — 연패 판정은 청산
|
||||
시점에 한 번만 한다(같은 청산을 두 번 세지 않기 위함).
|
||||
"""
|
||||
self.daily_pnl += float(delta)
|
||||
|
||||
def get_position(self, stock_code: str) -> dict | None:
|
||||
"""특정 종목의 활성 포지션 정보 반환.
|
||||
|
||||
|
||||
@@ -883,6 +883,117 @@ async def _handle_upsert_portfolio(session, data: dict) -> None:
|
||||
)
|
||||
|
||||
|
||||
# [2026-10-01] update_trade_history_by_order_id 의 식별·필터 전용 키 — setattr 대상이 아니다.
|
||||
# strategy_type/order_type 은 중복 행을 좁히는 데만 쓴다(좁히기에 실패해 다른 전략 행이
|
||||
# 선택됐을 때 그 행의 전략 구분을 덮어쓰지 않도록).
|
||||
_ORDER_ID_UPDATE_META_KEYS: frozenset = frozenset(
|
||||
{"order_id", "order_date", "strategy_type", "order_type"}
|
||||
)
|
||||
|
||||
|
||||
def _norm_kis_order_id(value) -> str:
|
||||
"""KIS 주문번호 정규화(앞자리 0 제거). 저장 형식은 응답 ODNO 원문(0 패딩)이다."""
|
||||
s = str(value or "").strip()
|
||||
return s.lstrip("0")
|
||||
|
||||
|
||||
def _order_id_ref_date(data: dict) -> date:
|
||||
"""조회 기준일(KST). 우선순위: order_date(메타) > created_at > executed_at > 오늘.
|
||||
|
||||
executed_at 은 체결 확인 시각(주문 후 수 초)이라 주문일과 같다. DLQ 재처리로 다음 날
|
||||
실행돼도 payload 의 원래 날짜를 쓰므로 당일 필터가 행을 놓치지 않는다.
|
||||
"""
|
||||
for key in ("order_date", "created_at", "executed_at"):
|
||||
v = data.get(key)
|
||||
if v is None or v == "":
|
||||
continue
|
||||
try:
|
||||
if isinstance(v, datetime):
|
||||
return v.date()
|
||||
if isinstance(v, date):
|
||||
return v
|
||||
if isinstance(v, str):
|
||||
return datetime.fromisoformat(v).date() if "T" in v or " " in v else date.fromisoformat(v)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("⚠️ [Worker] order_id 갱신 기준일 파싱 실패 %s=%r — 다음 후보 사용", key, v)
|
||||
return now_kst().date()
|
||||
|
||||
|
||||
async def _handle_update_trade_history_by_order_id(session, data: dict) -> None:
|
||||
"""TradeHistory UPDATE by KIS 주문번호 — 정상 경로 단일 구현.
|
||||
|
||||
[2026-10-01] KIS ODNO 는 **일별 순번**이라 날짜가 바뀌면 같은 번호가 반복된다. 예전 구현
|
||||
(`order_id == X` + `scalar_one_or_none()`)은 과거 행과 겹치면 MultipleResultsFound →
|
||||
단일 워커가 1+2+4초 재시도로 큐 전체를 멈추고 DLQ 로 보냈고, 당일 INSERT 가 유실된
|
||||
상태면 **과거 행을 덮어써** 원장을 오염시켰다.
|
||||
- 기준일(KST 00:00~다음날 00:00) created_at 범위로 한정한다(_order_id_ref_date).
|
||||
- order_id 는 원문 일치 OR 앞자리 0 제거 일치.
|
||||
- 그래도 여러 행이면 payload 의 strategy_type·order_type 으로 좁히고 created_at desc
|
||||
첫 행만 갱신 + warning (예외 없음 → 큐 정체 없음).
|
||||
- 0건이면 warning 후 종료(재시도·DLQ 없음 — 기존 동작 유지).
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from sqlalchemy import func
|
||||
|
||||
order_id = data.get("order_id")
|
||||
if not order_id:
|
||||
return
|
||||
user_id = data.get("user_id")
|
||||
ref = _order_id_ref_date(data)
|
||||
start = datetime.combine(ref, datetime.min.time())
|
||||
end = start + timedelta(days=1)
|
||||
|
||||
raw = str(order_id).strip()
|
||||
norm = _norm_kis_order_id(raw)
|
||||
id_cond = TradeHistory.order_id == raw
|
||||
if norm:
|
||||
id_cond = or_(id_cond, func.ltrim(TradeHistory.order_id, "0") == norm)
|
||||
|
||||
query = select(TradeHistory).where(
|
||||
id_cond,
|
||||
TradeHistory.created_at >= start,
|
||||
TradeHistory.created_at < end,
|
||||
)
|
||||
if user_id is not None:
|
||||
query = query.where(TradeHistory.user_id == user_id)
|
||||
query = query.order_by(TradeHistory.created_at.desc(), TradeHistory.id.desc())
|
||||
rows = list((await session.execute(query)).scalars().all())
|
||||
|
||||
if not rows:
|
||||
logger.warning(
|
||||
"⚠️ [Worker] TradeHistory not found for order_id=%s (기준일 %s KST) — 갱신 생략",
|
||||
order_id, ref.isoformat(),
|
||||
)
|
||||
return
|
||||
|
||||
if len(rows) > 1:
|
||||
narrowed = rows
|
||||
for key in ("strategy_type", "order_type"):
|
||||
want = data.get(key)
|
||||
if want is None:
|
||||
continue
|
||||
cand = [r for r in narrowed if getattr(r, key, None) == want]
|
||||
if cand:
|
||||
narrowed = cand
|
||||
logger.warning(
|
||||
"⚠️ [Worker] order_id=%s 기준일 %s 에 TradeHistory %d건 중복 — "
|
||||
"strategy_type/order_type 으로 좁힌 %d건 중 최신 1건(id=%s)만 갱신",
|
||||
order_id, ref.isoformat(), len(rows), len(narrowed), narrowed[0].id,
|
||||
)
|
||||
rows = narrowed
|
||||
|
||||
trade = rows[0]
|
||||
for key, val in data.items():
|
||||
if key in _ORDER_ID_UPDATE_META_KEYS or val is None or not hasattr(trade, key):
|
||||
continue
|
||||
setattr(trade, key, val)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"✅ [Worker] TradeHistory updated by order_id=%s (id=%s) status=%s",
|
||||
order_id, trade.id, data.get("status", "?"),
|
||||
)
|
||||
|
||||
|
||||
async def _handle_upsert_active_trade(session, data: dict) -> None:
|
||||
"""ActiveTrade UPSERT — 정상 경로와 종료 drain 이 공유하는 단일 구현."""
|
||||
from app.models.models import ActiveTrade
|
||||
@@ -1054,29 +1165,7 @@ async def db_writer_worker() -> None:
|
||||
|
||||
# ── 2b. TradeHistory UPDATE by order_id (KIS 주문번호 기준) ──
|
||||
elif task_type == "update_trade_history_by_order_id":
|
||||
order_id = data.get("order_id")
|
||||
user_id = data.get("user_id")
|
||||
if order_id:
|
||||
query = select(TradeHistory).where(TradeHistory.order_id == order_id)
|
||||
if user_id is not None:
|
||||
query = query.where(TradeHistory.user_id == user_id)
|
||||
row = await session.execute(query)
|
||||
trade = row.scalar_one_or_none()
|
||||
if trade:
|
||||
for key, val in data.items():
|
||||
if key != "order_id" and val is not None and hasattr(trade, key):
|
||||
setattr(trade, key, val)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"✅ [Worker] TradeHistory updated by order_id=%s status=%s",
|
||||
order_id,
|
||||
data.get("status", "?"),
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"⚠️ [Worker] TradeHistory not found for order_id=%s",
|
||||
order_id,
|
||||
)
|
||||
await _handle_update_trade_history_by_order_id(session, data)
|
||||
|
||||
# ── 3. 단건 StockAnalysis UPSERT ──────────────────────────
|
||||
elif task_type == "upsert_stock_analysis":
|
||||
|
||||
@@ -165,6 +165,79 @@ async def _cache_set(key: str, ttl: int, data: Any) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# ── 국내 주문체결 조회 (inquire-daily-ccld) ────────────────────────────────
|
||||
# KIS 공식 "주식일별주문체결조회"(3개월 이내): GET /uapi/domestic-stock/v1/trading/inquire-daily-ccld
|
||||
# TR — 현행 공식 샘플(koreainvestment/open-trading-api examples_llm/domestic_stock/inquire_daily_ccld):
|
||||
# 실전 TTTC0081R / 모의 VTTC0081R. 구 TR(legacy/ 샘플·Postman v2.6): 실전 TTTC8001R / 모의 VTTC8001R.
|
||||
# 구 TR 의 현재 지원 여부는 레포 근거로 확정 불가 → 현행 TR 우선, rt_cd != "0" 이면 구 TR 1회 폴백
|
||||
# (성공한 쪽을 인스턴스에 기억). 응답 output1[] = 주문 단위 행
|
||||
# (odno, pdno, sll_buy_dvsn_cd, ord_qty, tot_ccld_qty, avg_prvs, tot_ccld_amt, rmn_qty, cncl_yn ...).
|
||||
# 연속조회: 응답 헤더 tr_cont F/M = 다음 있음 → 요청 헤더 tr_cont=N + CTX_AREA_FK100/NK100.
|
||||
DAILY_CCLD_ENDPOINT = "/uapi/domestic-stock/v1/trading/inquire-daily-ccld"
|
||||
DAILY_CCLD_TR_IDS = { # (실전, 모의)
|
||||
"current": ("TTTC0081R", "VTTC0081R"),
|
||||
"legacy": ("TTTC8001R", "VTTC8001R"),
|
||||
}
|
||||
ORDER_EXEC_MAX_PAGES = 5 # 단건 체결조회 연속조회 상한 (ODNO 필터가 무시돼도 무한루프 방지)
|
||||
|
||||
|
||||
def _norm_kis_odno(value) -> str:
|
||||
"""KIS 주문번호 정규화 — 주문 응답(ODNO)과 조회 응답(odno)의 0 패딩 차이를 흡수."""
|
||||
s = str(value or "").strip()
|
||||
return s.lstrip("0") or ("0" if s else "")
|
||||
|
||||
|
||||
def _to_int_or_none(value) -> Optional[int]:
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
return None
|
||||
try:
|
||||
return int(float(str(value).replace(",", "")))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_order_execution_rows(rows, order_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""inquire-daily-ccld output1 에서 주문번호가 일치하는 행을 체결 dict 로 변환 (순수 함수).
|
||||
|
||||
반환 (일치 행 없으면 None):
|
||||
filled_qty : 총체결수량(tot_ccld_qty)
|
||||
filled_price : 평균체결가(avg_prvs). 0 이면 tot_ccld_amt / tot_ccld_qty 로 보정
|
||||
ordered_qty : 주문수량(ord_qty) — 모르면 None
|
||||
remaining_qty : 잔여수량(rmn_qty) — **필드가 없으면 None**(0 과 구분: 0 은 '잔량 소멸')
|
||||
cancelled : cncl_yn == "Y" (취소는 별도 행(orgn_odno)이라 원주문은 N 일 수 있음 — 로그용)
|
||||
cancel_confirmed_qty : 취소확인수량(cnc_cfrm_qty) — 없으면 None (로그용)
|
||||
rejected_qty : 거부수량(rjct_qty) — 없으면 None (로그용)
|
||||
"""
|
||||
target = _norm_kis_odno(order_id)
|
||||
if not target:
|
||||
return None
|
||||
if isinstance(rows, dict):
|
||||
rows = [rows]
|
||||
for row in rows or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
if _norm_kis_odno(row.get("odno")) != target:
|
||||
continue
|
||||
filled_qty = _to_int_or_none(row.get("tot_ccld_qty")) or 0
|
||||
try:
|
||||
filled_price = float(str(row.get("avg_prvs") or 0).replace(",", ""))
|
||||
except (TypeError, ValueError):
|
||||
filled_price = 0.0
|
||||
if filled_price <= 0 and filled_qty > 0:
|
||||
amt = _to_int_or_none(row.get("tot_ccld_amt")) or 0
|
||||
filled_price = (amt / filled_qty) if amt > 0 else 0.0
|
||||
return {
|
||||
"filled_qty": max(0, filled_qty),
|
||||
"filled_price": float(filled_price) if filled_qty > 0 else 0.0,
|
||||
"ordered_qty": _to_int_or_none(row.get("ord_qty")),
|
||||
"remaining_qty": _to_int_or_none(row.get("rmn_qty")),
|
||||
"cancelled": str(row.get("cncl_yn") or "").upper() == "Y",
|
||||
"cancel_confirmed_qty": _to_int_or_none(row.get("cnc_cfrm_qty")),
|
||||
"rejected_qty": _to_int_or_none(row.get("rjct_qty")),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _round_to_krx_tick(price: int) -> int:
|
||||
"""KRX 호가단위에 맞춰 가격을 올림(ceil) 처리. 모의투자 호가단위 오류 방지."""
|
||||
if price < 1_000:
|
||||
@@ -1278,45 +1351,105 @@ class KoreaInvestmentAPI:
|
||||
raise
|
||||
|
||||
async def get_todays_orders(self, stock_code: str = "") -> list[dict]:
|
||||
"""당일 주문체결 내역 조회 (inquire-daily-ccld).
|
||||
"""당일 주문체결 내역 조회 (inquire-daily-ccld) — 첫 페이지만(실전 100건 / 모의 15건, 역순).
|
||||
|
||||
주문 전송 후 응답을 못 받았을 때 '실제로 접수됐는지' 확인해 중복 주문을 막는 용도.
|
||||
실전: TTTC8001R / 모의: VTTC8001R. 실패 시 빈 리스트.
|
||||
TR: 현행 TTTC0081R/VTTC0081R 우선, 실패 시 구 TTTC8001R/VTTC8001R. 실패 시 빈 리스트.
|
||||
"""
|
||||
try:
|
||||
account_number_prefix = self.account_number[:8]
|
||||
account_number_suffix = self.account_number[8:]
|
||||
is_virtual = "vts" in self.base_url.lower()
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
result, _ = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/trading/inquire-daily-ccld",
|
||||
"VTTC8001R" if is_virtual else "TTTC8001R",
|
||||
params={
|
||||
"CANO": account_number_prefix,
|
||||
"ACNT_PRDT_CD": account_number_suffix,
|
||||
"INQR_STRT_DT": today,
|
||||
"INQR_END_DT": today,
|
||||
"SLL_BUY_DVSN_CD": "00",
|
||||
"INQR_DVSN": "00",
|
||||
"PDNO": stock_code or "",
|
||||
"CCLD_DVSN": "00",
|
||||
"ORD_GNO_BRNO": "",
|
||||
"ODNO": "",
|
||||
"INQR_DVSN_3": "00",
|
||||
"INQR_DVSN_1": "",
|
||||
"CTX_AREA_FK100": "",
|
||||
"CTX_AREA_NK100": "",
|
||||
},
|
||||
)
|
||||
result, _ = await self._daily_ccld_fetch_v2(stock_code=stock_code)
|
||||
if result.get("rt_cd") != "0":
|
||||
# [2026-10-01] 이전에는 조용히 [] — 조회 실패와 '주문 없음' 이 구분되지 않았다.
|
||||
logger.warning(
|
||||
f"[get_todays_orders] {stock_code or '*'} 조회 실패 "
|
||||
f"rt_cd={result.get('rt_cd')} msg_cd={result.get('msg_cd')} msg1={result.get('msg1')}"
|
||||
)
|
||||
return []
|
||||
out = result.get("output1") or []
|
||||
return out if isinstance(out, list) else [out]
|
||||
except Exception as e:
|
||||
logger.debug(f"[get_todays_orders] 조회 실패: {e}")
|
||||
# CLAUDE.md §4: 예외 로그 debug 금지 (중복주문 방지·DT 체결가 확인의 근거 조회)
|
||||
logger.warning(f"[get_todays_orders] {stock_code or '*'} 조회 예외: {type(e).__name__}: {e}")
|
||||
return []
|
||||
|
||||
def _daily_ccld_request_v2(
|
||||
self,
|
||||
stock_code: str = "",
|
||||
order_id: str = "",
|
||||
ctx_fk100: str = "",
|
||||
ctx_nk100: str = "",
|
||||
tr_generation: str = "current",
|
||||
) -> tuple:
|
||||
"""inquire-daily-ccld 요청 (tr_id, params) 구성 — 당일 조회. 모의/실전 TR 분기 포함.
|
||||
|
||||
`get_todays_orders` 와 `get_order_execution` 이 같은 요청 구성을 공유한다
|
||||
(2026-10-01 이전에는 후자가 존재하지 않는 `/trading/inquire-ccnl` 경로로 따로 조립했다).
|
||||
tr_generation: "current"(TTTC0081R/VTTC0081R) | "legacy"(TTTC8001R/VTTC8001R)
|
||||
"""
|
||||
is_virtual = "vts" in self.base_url.lower()
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
real_tr, virtual_tr = DAILY_CCLD_TR_IDS[tr_generation]
|
||||
tr_id = virtual_tr if is_virtual else real_tr
|
||||
params = {
|
||||
"CANO": self.account_number[:8],
|
||||
"ACNT_PRDT_CD": self.account_number[8:],
|
||||
"INQR_STRT_DT": today,
|
||||
"INQR_END_DT": today,
|
||||
"SLL_BUY_DVSN_CD": "00", # 00 전체 / 01 매도 / 02 매수
|
||||
"INQR_DVSN": "00", # 00 역순(최신 먼저)
|
||||
"PDNO": stock_code or "",
|
||||
"CCLD_DVSN": "00", # 00 전체 / 01 체결 / 02 미체결
|
||||
"ORD_GNO_BRNO": "",
|
||||
"ODNO": order_id or "",
|
||||
"INQR_DVSN_3": "00",
|
||||
"INQR_DVSN_1": "",
|
||||
"CTX_AREA_FK100": ctx_fk100 or "",
|
||||
"CTX_AREA_NK100": ctx_nk100 or "",
|
||||
"EXCG_ID_DVSN_CD": "KRX", # 거래소ID구분 (공식 샘플 기본값 KRX / NXT / SOR / ALL)
|
||||
}
|
||||
return tr_id, params
|
||||
|
||||
async def _daily_ccld_fetch_v2(
|
||||
self,
|
||||
stock_code: str = "",
|
||||
order_id: str = "",
|
||||
ctx_fk100: str = "",
|
||||
ctx_nk100: str = "",
|
||||
next_page: bool = False,
|
||||
) -> tuple:
|
||||
"""inquire-daily-ccld 1페이지 호출 → (result, headers). 예외는 그대로 올린다.
|
||||
|
||||
현행 TR 이 rt_cd != "0" 이면 구 TR 로 1회 재시도하고, 구 TR 이 성공하면 이후 구 TR 을
|
||||
우선 쓴다(인스턴스 상태). 둘 다 실패하면 마지막 결과를 그대로 반환(호출부가 warning).
|
||||
연속조회는 요청 헤더 tr_cont=N (공식 샘플 규약).
|
||||
"""
|
||||
preferred = getattr(self, "_daily_ccld_tr_generation", "current")
|
||||
order = [preferred] + [g for g in ("current", "legacy") if g != preferred]
|
||||
extra = {"tr_cont": "N"} if next_page else None
|
||||
result, headers = {}, None
|
||||
for i, gen in enumerate(order):
|
||||
tr_id, params = self._daily_ccld_request_v2(
|
||||
stock_code=stock_code, order_id=order_id,
|
||||
ctx_fk100=ctx_fk100, ctx_nk100=ctx_nk100, tr_generation=gen,
|
||||
)
|
||||
result, headers = await self._make_request(
|
||||
"GET", DAILY_CCLD_ENDPOINT, tr_id, extra_headers=extra, params=params,
|
||||
)
|
||||
if isinstance(result, dict) and result.get("rt_cd") == "0":
|
||||
if gen != preferred:
|
||||
logger.warning(
|
||||
f"[daily-ccld] TR {gen} 로 전환 (선호 TR {preferred} 실패) — tr_id={tr_id}"
|
||||
)
|
||||
self._daily_ccld_tr_generation = gen
|
||||
return result, headers
|
||||
if i == 0:
|
||||
_r = result if isinstance(result, dict) else {}
|
||||
logger.warning(
|
||||
f"[daily-ccld] tr_id={tr_id} 실패 rt_cd={_r.get('rt_cd')} "
|
||||
f"msg_cd={_r.get('msg_cd')} msg1={_r.get('msg1')} — 대체 TR 재시도"
|
||||
)
|
||||
return result, headers
|
||||
|
||||
async def get_buyable_cash(self, stock_code: str = "005930") -> float:
|
||||
"""주문가능현금(ord_psbl_cash) 조회 — 매수가능조회 API.
|
||||
|
||||
@@ -1448,51 +1581,63 @@ class KoreaInvestmentAPI:
|
||||
async def get_order_execution(self, order_id: str, is_domestic: bool = True) -> Dict[str, Any]:
|
||||
"""주문 체결 내역 단건 조회.
|
||||
|
||||
국내: TTTC8001R (실전) / VTTC8001R (모의)
|
||||
국내: GET /uapi/domestic-stock/v1/trading/inquire-daily-ccld,
|
||||
TTTC0081R/VTTC0081R (현행) → 실패 시 TTTC8001R/VTTC8001R (구)
|
||||
Returns:
|
||||
{"filled_qty": int, "filled_price": float}
|
||||
체결 미확인 시 filled_qty=0 반환.
|
||||
"""
|
||||
try:
|
||||
account_number_prefix = self.account_number[:8]
|
||||
account_number_suffix = self.account_number[8:]
|
||||
is_virtual = "vts" in self.base_url.lower()
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
{"filled_qty": int, "filled_price": float} ← 기존 계약(불변)
|
||||
+ 추가 키(국내, 하위호환): "found"(주문행 발견), "ok"(조회 성공),
|
||||
"ordered_qty", "remaining_qty"(None=모름), "cancelled",
|
||||
"cancel_confirmed_qty", "rejected_qty"
|
||||
체결 미확인·조회 실패 시 filled_qty=0 반환.
|
||||
|
||||
if is_domestic:
|
||||
tr_id = "VTTC8001R" if is_virtual else "TTTC8001R"
|
||||
result, _ = await self._make_request(
|
||||
"GET",
|
||||
"/uapi/domestic-stock/v1/trading/inquire-ccnl",
|
||||
tr_id,
|
||||
params={
|
||||
"CANO": account_number_prefix,
|
||||
"ACNT_PRDT_CD": account_number_suffix,
|
||||
"INQR_STRT_DT": today,
|
||||
"INQR_END_DT": today,
|
||||
"SLL_BUY_DVSN_CD": "00",
|
||||
"INQR_DVSN": "00",
|
||||
"PDNO": "",
|
||||
"CCLD_DVSN": "00",
|
||||
"ORD_GNO_BRNO": "",
|
||||
"ODNO": order_id,
|
||||
"INQR_DVSN_3": "00",
|
||||
"INQR_DVSN_1": "",
|
||||
"CTX_AREA_FK100": "",
|
||||
"CTX_AREA_NK100": "",
|
||||
},
|
||||
[2026-10-01] 이전 구현은 `/domestic-stock/v1/trading/inquire-ccnl` 을 호출했다 — KIS 에
|
||||
그런 경로는 없다(국내 `inquire-ccnl` 은 `/quotations/` 아래 시세 체결 FHKST01010300,
|
||||
`/trading/inquire-ccnl` 은 해외 `/overseas-stock/` 의 체결조회 TTTS3035R). 그리고 실패를 debug 로
|
||||
삼키거나(예외) 아예 로그 없이(4xx 응답) filled_qty=0 을 돌려줬다 → 체결 확인이 항상 실패.
|
||||
"""
|
||||
if not is_domestic:
|
||||
# 해외 체결 조회(TTTS3035R)는 미구현 — 포트폴리오 동기화로 대체
|
||||
return {"filled_qty": 0, "filled_price": 0.0}
|
||||
return await self._get_order_execution_domestic_impl(order_id)
|
||||
|
||||
async def _get_order_execution_domestic_impl(self, order_id: str) -> Dict[str, Any]:
|
||||
"""국내 단건 체결조회 본체 — inquire-daily-ccld 를 ODNO 로 조회 + 클라이언트측 ODNO 매칭.
|
||||
|
||||
서버측 ODNO 필터가 기대대로 동작하지 않아도(전체 반환) 응답에서 주문번호로 다시 고르고,
|
||||
연속조회(tr_cont F/M → N)로 최대 ORDER_EXEC_MAX_PAGES 페이지까지 찾는다.
|
||||
"""
|
||||
empty = {"filled_qty": 0, "filled_price": 0.0, "found": False, "ok": False,
|
||||
"ordered_qty": None, "remaining_qty": None, "cancelled": False,
|
||||
"cancel_confirmed_qty": None, "rejected_qty": None}
|
||||
if not order_id:
|
||||
return empty
|
||||
try:
|
||||
ctx_fk, ctx_nk = "", ""
|
||||
for page in range(ORDER_EXEC_MAX_PAGES):
|
||||
result, headers = await self._daily_ccld_fetch_v2(
|
||||
order_id=order_id, ctx_fk100=ctx_fk, ctx_nk100=ctx_nk, next_page=page > 0,
|
||||
)
|
||||
output = result.get("output1", [])
|
||||
if output:
|
||||
row = output[0] if isinstance(output, list) else output
|
||||
filled_qty = int(row.get("tot_ccld_qty", 0) or 0)
|
||||
filled_price = float(row.get("avg_prvs", 0) or 0)
|
||||
return {"filled_qty": filled_qty, "filled_price": filled_price}
|
||||
# 해외 체결 조회는 별도 TR_ID 필요 — 현재 미지원 (포트폴리오 동기화로 대체)
|
||||
return {"filled_qty": 0, "filled_price": 0.0}
|
||||
if not isinstance(result, dict) or result.get("rt_cd") != "0":
|
||||
_r = result if isinstance(result, dict) else {}
|
||||
logger.warning(
|
||||
f"[get_order_execution] order_id={order_id} 체결조회 실패 "
|
||||
f"rt_cd={_r.get('rt_cd')} msg_cd={_r.get('msg_cd')} msg1={_r.get('msg1')}"
|
||||
)
|
||||
return empty
|
||||
parsed = parse_order_execution_rows(result.get("output1") or [], order_id)
|
||||
if parsed is not None:
|
||||
return {**parsed, "found": True, "ok": True}
|
||||
tr_cont = (headers.get("tr_cont") if headers is not None else None) or ""
|
||||
ctx_fk = str(result.get("ctx_area_fk100") or "").strip()
|
||||
ctx_nk = str(result.get("ctx_area_nk100") or "").strip()
|
||||
if tr_cont not in ("F", "M") or not ctx_nk:
|
||||
break
|
||||
return {**empty, "ok": True} # 조회는 성공했으나 주문행 없음(접수 전/다른 날)
|
||||
except Exception as e:
|
||||
logger.debug(f"[get_order_execution] order_id={order_id} 조회 오류: {e}")
|
||||
return {"filled_qty": 0, "filled_price": 0.0}
|
||||
logger.warning(
|
||||
f"[get_order_execution] order_id={order_id} 체결조회 예외: {type(e).__name__}: {e}"
|
||||
)
|
||||
return empty
|
||||
|
||||
async def cancel_order(self, order_id: str, quantity: int) -> Dict[str, Any]:
|
||||
"""주문 취소"""
|
||||
|
||||
@@ -43,6 +43,7 @@ __all__ = [
|
||||
"amount_to_quantity",
|
||||
"half_kelly_fraction",
|
||||
"effective_min_buy_amount",
|
||||
"remaining_position_room",
|
||||
]
|
||||
|
||||
|
||||
@@ -198,3 +199,27 @@ def effective_min_buy_amount(
|
||||
if small_account_cap <= 0:
|
||||
return config_min_amount
|
||||
return min(config_min_amount, small_account_cap)
|
||||
|
||||
|
||||
# ── 종목당 누적 한도 ─────────────────────────────────────────────────────────
|
||||
|
||||
def remaining_position_room(
|
||||
total_assets: float,
|
||||
position_cap_ratio: float,
|
||||
existing_position_value: float,
|
||||
) -> float:
|
||||
"""종목당 **누적** 잔여 한도 = `max(0, 총자산 × 종목당상한 − 해당 종목 기존 보유 평가액)`.
|
||||
|
||||
`clamp_by_ratios(..., [MAX_POSITION_SIZE], ...)` 는 **주문 1건**을 상한으로 묶을 뿐이라
|
||||
같은 종목이 매일 추천되면 추가매수가 쌓여 종목 비중이 상한을 넘을 수 있다
|
||||
(CLAUDE.md §5.1 "종목당 4% 절대 상한"). 이 함수는 기존 보유분을 차감한 잔여 한도를
|
||||
돌려준다.
|
||||
|
||||
**floor 있음** — 이미 한도를 초과했으면 0. `total_assets <= 0` 또는
|
||||
`position_cap_ratio <= 0` 이면 한도를 계산할 수 없으므로 0 을 반환한다
|
||||
(호출부가 "검증 불가"를 별도로 판단한다). 음수 보유 평가액은 0 으로 본다.
|
||||
"""
|
||||
if total_assets <= 0 or position_cap_ratio <= 0:
|
||||
return 0.0
|
||||
existing = max(0.0, float(existing_position_value or 0.0))
|
||||
return max(0.0, total_assets * position_cap_ratio - existing)
|
||||
|
||||
72
backend/app/services/trade_cost_core.py
Normal file
72
backend/app/services/trade_cost_core.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""매매 비용·순손익 순수 계산 모듈.
|
||||
|
||||
settings·DB·네트워크를 참조하지 않는다(indicators_core / sizing_core 와 같은 원칙).
|
||||
**비용률 숫자는 이 모듈에 없다** — 호출부가 settings(KR_COMMISSION_RATE,
|
||||
KR_SELL_TAX_RATE_KOSPI/KOSDAQ)에서 꺼내 넘긴다.
|
||||
|
||||
비용 정의는 스윙 집행 경로(`trade_helpers.calculate_trade_costs` →
|
||||
`trade_executor` 매도 기록)와 동일하다:
|
||||
- 매수 수수료 = 진입가 × 수량 × 수수료율
|
||||
- 매도 수수료 = 청산가 × 수량 × 수수료율
|
||||
- 매도 거래세 = 청산가 × 수량 × 거래세율
|
||||
- 각 항목을 원 단위로 반올림한 뒤 합산
|
||||
슬리피지는 비용률에 넣지 않는다 — 실제 체결가를 쓰면 이미 손익에 반영되어 있고,
|
||||
`DAY_TRADE_COST_PCT`(수수료+슬리피지 추정치)는 진입/청산 **판정 게이트**용이라 기록에 쓰지 않는다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def round_trip_costs(
|
||||
entry_price: float,
|
||||
exit_price: float,
|
||||
quantity: int,
|
||||
commission_rate: float,
|
||||
sell_tax_rate: float,
|
||||
) -> dict:
|
||||
"""왕복 매매 비용(원). `trade_helpers.calculate_trade_costs` 와 같은 반올림 규칙.
|
||||
|
||||
Returns:
|
||||
{"buy_commission", "sell_commission", "sell_tax", "total_cost"}
|
||||
"""
|
||||
qty = max(0, int(quantity))
|
||||
buy_commission = float(entry_price) * qty * float(commission_rate)
|
||||
sell_commission = float(exit_price) * qty * float(commission_rate)
|
||||
sell_tax = float(exit_price) * qty * float(sell_tax_rate)
|
||||
total_cost = buy_commission + sell_commission + sell_tax
|
||||
return {
|
||||
"buy_commission": round(buy_commission, 0),
|
||||
"sell_commission": round(sell_commission, 0),
|
||||
"sell_tax": round(sell_tax, 0),
|
||||
"total_cost": round(total_cost, 0),
|
||||
}
|
||||
|
||||
|
||||
def net_realized_pnl(
|
||||
entry_price: float,
|
||||
exit_price: float,
|
||||
quantity: int,
|
||||
commission_rate: float,
|
||||
sell_tax_rate: float,
|
||||
) -> dict:
|
||||
"""비용 차감 실현손익.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"gross_profit_loss": (청산가 − 진입가) × 수량,
|
||||
"total_cost": 왕복 비용(원),
|
||||
"net_profit_loss": gross − total_cost,
|
||||
"net_profit_loss_rate": net / (진입가 × 수량) × 100 (진입 원금 대비 %, 진입가<=0 이면 0.0),
|
||||
}
|
||||
"""
|
||||
qty = max(0, int(quantity))
|
||||
gross = (float(exit_price) - float(entry_price)) * qty
|
||||
costs = round_trip_costs(entry_price, exit_price, qty, commission_rate, sell_tax_rate)
|
||||
net = gross - costs["total_cost"]
|
||||
invested = float(entry_price) * qty
|
||||
rate = (net / invested * 100.0) if invested > 0 else 0.0
|
||||
return {
|
||||
"gross_profit_loss": gross,
|
||||
"total_cost": costs["total_cost"],
|
||||
"net_profit_loss": net,
|
||||
"net_profit_loss_rate": rate,
|
||||
}
|
||||
@@ -32,6 +32,7 @@ from app.services.technical_indicators import (
|
||||
# §8.5-2 포지션 사이징 단일 출처 (순수 계산 모듈 — settings/DB/네트워크 없음)
|
||||
from app.services.sizing_core import (
|
||||
amount_to_quantity, clamp_by_ratios, effective_min_buy_amount, half_kelly_fraction,
|
||||
remaining_position_room,
|
||||
)
|
||||
from app.services.signal_types import BUY_KEYWORDS, SELL_KEYWORDS, STRONG_BUY_KEYWORDS, BLOCK_KEYWORDS
|
||||
from app.services.correlation_filter import validate_correlation_filter
|
||||
@@ -531,16 +532,33 @@ async def _verify_and_sync_execution(
|
||||
db_trade_id: int,
|
||||
delay: int = 3,
|
||||
) -> None:
|
||||
"""주문 후 KIS 체결 내역 조회 → DB 업데이트 + 슬리피지 기록 + 부분 체결 재주문.
|
||||
"""주문 후 KIS 체결 내역 조회 → DB 업데이트 + 슬리피지 기록 (부분 체결 자동 재주문 없음).
|
||||
|
||||
[동작 흐름]
|
||||
1. delay초 대기 (체결 확인 전 KIS 서버 처리 시간 확보)
|
||||
2. KIS 체결 조회 API 호출 → filled_qty, filled_price 추출
|
||||
3. 슬리피지 계산: (체결가 - 주문가) / 주문가 × 100
|
||||
4. db_worker 큐를 통해 TradeHistory UPDATE (CLAUDE.md 3.2 준수)
|
||||
- 완전 체결(filled_qty == ordered_quantity) → status = "COMPLETED"
|
||||
- 부분 체결(filled_qty < ordered_quantity) → status = "PARTIAL" + 잔여 재주문
|
||||
- 완전 체결(filled_qty >= ordered_quantity) → status = "COMPLETED"
|
||||
- 부분 체결 + KIS 잔량 소멸(rmn_qty == 0: 취소·거부·만료) → 체결분만 기록(COMPLETED)
|
||||
+ warning("수동 확인"). **자동 재주문하지 않는다** (아래 ③)
|
||||
- 부분 체결 + 잔량이 KIS 에 살아 있음/모름 → DB 미갱신 + warning (재주문 금지)
|
||||
5. 포트폴리오 동기화 트리거
|
||||
|
||||
[2026-10-01] `get_order_execution` 이 존재하지 않는 경로를 호출해 이 함수의 체결 반영
|
||||
분기는 **한 번도 실행된 적이 없다**. 경로를 고치면서 처음 살아나는 분기의 결함을 함께 고쳤다:
|
||||
① 첫 부분체결 관측에서 곧바로 break → 잔량이 아직 KIS 에 지정가 주문으로 살아 있는데
|
||||
같은 수량을 재주문 = **중복 주문(과다 매수 / 과다 매도 시도)**. 이제 전량 체결 또는
|
||||
잔량 소멸까지 폴링을 계속한다.
|
||||
② status "PARTIAL" 은 매수 재진입 가드(당일 중복매수·일일횟수·쿨다운: EXECUTED/COMPLETED
|
||||
(/PENDING) 만 셈)와 손익 집계(COMPLETED/FILLED 만 셈)에서 빠진다 → 부분체결 BUY 가
|
||||
당일 재매수를 허용하고 부분체결 SELL 이 성과에서 사라진다. 체결분만 수량에 반영하고
|
||||
status 는 COMPLETED 를 유지한다.
|
||||
③ 잔량 소멸 시 재주문도 제거했다. 잔량이 사라지는 경우는 사실상 사용자 HTS 취소·KIS 거부·
|
||||
만료뿐이고(취소는 별도 행(orgn_odno)으로 기록돼 원주문 cncl_yn 이 N 일 수 있다),
|
||||
`_reorder_unfilled` 는 MDD fail-safe·밴·종목당 4%·현금·주문 세마포어/Redis 락·
|
||||
TradeHistory·체결 검증을 전부 우회한다. 취소 의도를 덮어쓰거나 가드 없는 주문을 내느니
|
||||
warning 으로 수동 확인을 요청한다. `_reorder_unfilled` 는 시그니처 보존용으로만 남긴다.
|
||||
"""
|
||||
await asyncio.sleep(delay) # KIS 서버 체결 처리 대기
|
||||
|
||||
@@ -550,25 +568,29 @@ async def _verify_and_sync_execution(
|
||||
return
|
||||
|
||||
try:
|
||||
# KIS 체결 내역 조회 (TR: TTTC8001R / VTTC8001R)
|
||||
# KIS 체결 내역 조회 (inquire-daily-ccld, TR: TTTC0081R→폴백 TTTC8001R / 모의 V*)
|
||||
# [실전 안정화] 최대 5회 폴링 (3초 간격, 최대 15초) — 체결 지연 대응
|
||||
filled_qty = 0
|
||||
filled_price = 0.0
|
||||
remaining_qty = None
|
||||
exec_result = {}
|
||||
max_polls = 5
|
||||
poll_interval = 3
|
||||
|
||||
for poll in range(max_polls):
|
||||
exec_result = await korea_investment_api.get_order_execution(order_id, is_domestic)
|
||||
filled_qty = int(exec_result.get("filled_qty", 0))
|
||||
filled_qty = int(exec_result.get("filled_qty", 0) or 0)
|
||||
filled_price = float(exec_result.get("filled_price", order_price) or order_price)
|
||||
remaining_qty = exec_result.get("remaining_qty")
|
||||
|
||||
if filled_qty > 0:
|
||||
break # 체결 확인됨
|
||||
if filled_qty >= ordered_quantity:
|
||||
break # 전량 체결
|
||||
if filled_qty > 0 and remaining_qty == 0:
|
||||
break # 부분 체결 후 잔량 소멸(취소·거부) — 더 기다려도 변하지 않음
|
||||
|
||||
if poll < max_polls - 1:
|
||||
logger.debug(
|
||||
f"[VERIFY_EXEC] {stock_code} {order_type} 체결 미확인 "
|
||||
f"[VERIFY_EXEC] {stock_code} {order_type} 체결 {filled_qty}/{ordered_quantity} "
|
||||
f"({poll + 1}/{max_polls}), {poll_interval}초 후 재시도"
|
||||
)
|
||||
await asyncio.sleep(poll_interval)
|
||||
@@ -576,12 +598,21 @@ async def _verify_and_sync_execution(
|
||||
if filled_qty == 0:
|
||||
logger.warning(
|
||||
f"[VERIFY_EXEC] {stock_code} {order_type} "
|
||||
f"{max_polls}회 폴링 후에도 체결 미확인 — order_id={order_id}"
|
||||
f"{max_polls}회 폴링 후에도 체결 미확인 — order_id={order_id} "
|
||||
f"(조회성공={exec_result.get('ok')}, 주문행발견={exec_result.get('found')})"
|
||||
)
|
||||
|
||||
if filled_qty > ordered_quantity:
|
||||
# 다른 주문 행과 혼동된 경우 등 — 주문 수량을 넘겨 기록하지 않는다
|
||||
logger.warning(
|
||||
f"[VERIFY_EXEC] {stock_code} {order_type} 체결수량 {filled_qty} > 주문수량 "
|
||||
f"{ordered_quantity} — 주문수량으로 클램프 (order_id={order_id})"
|
||||
)
|
||||
filled_qty = ordered_quantity
|
||||
|
||||
# ── 슬리피지 계산: (체결가 - 주문가) / 주문가 × 100 ─────────────────
|
||||
slippage_pct = 0.0
|
||||
if order_price > 0 and filled_price > 0:
|
||||
if filled_qty > 0 and order_price > 0 and filled_price > 0:
|
||||
slippage_pct = ((filled_price - order_price) / order_price) * 100
|
||||
logger.info(
|
||||
f"[SLIPPAGE] {stock_code} {order_type} "
|
||||
@@ -589,13 +620,24 @@ async def _verify_and_sync_execution(
|
||||
f"슬리피지={slippage_pct:+.3f}%"
|
||||
)
|
||||
|
||||
partial = 0 < filled_qty < ordered_quantity
|
||||
remainder_dead = partial and remaining_qty == 0
|
||||
if partial and not remainder_dead:
|
||||
# 잔량이 KIS 에 미체결 지정가로 살아 있다(또는 모른다) → 재주문하면 중복 주문.
|
||||
# 최종 체결 수량이 아직 정해지지 않았으므로 원장도 건드리지 않는다(포트폴리오 sync 가 진실).
|
||||
logger.warning(
|
||||
f"[PARTIAL_FILL] {stock_code} {order_type} {filled_qty}/{ordered_quantity}주 체결, "
|
||||
f"잔량 {remaining_qty if remaining_qty is not None else '미상'}주 KIS 미체결 주문 유지 "
|
||||
f"— 재주문·원장 갱신 안 함 (order_id={order_id})"
|
||||
)
|
||||
|
||||
# ── DB 업데이트 (CLAUDE.md 3.2: db_worker 큐 경유, 직접 세션 금지) ───
|
||||
if filled_qty > 0:
|
||||
partial = filled_qty < ordered_quantity # 부분 체결 여부
|
||||
if filled_qty > 0 and (not partial or remainder_dead):
|
||||
update_fields = {
|
||||
"executed_quantity": filled_qty,
|
||||
"executed_price": Decimal(str(filled_price)),
|
||||
"status": "PARTIAL" if partial else "COMPLETED",
|
||||
# PARTIAL 금지 — 위 docstring ② (가드·집계 누락)
|
||||
"status": "COMPLETED",
|
||||
"executed_at": _get_kst_now(),
|
||||
"slippage": Decimal(str(round(slippage_pct, 4))),
|
||||
}
|
||||
@@ -608,24 +650,14 @@ async def _verify_and_sync_execution(
|
||||
from app.services.db_worker import enqueue_update_trade_history_by_order_id
|
||||
await enqueue_update_trade_history_by_order_id(order_id, update_fields)
|
||||
|
||||
# ── 부분 체결 → 미체결 수량 재주문 ──────────────────────
|
||||
if partial:
|
||||
# ── 부분 체결 + 잔량 소멸 → 자동 재주문 금지, 수동 확인 요청 (docstring ③) ──
|
||||
if remainder_dead:
|
||||
remain = ordered_quantity - filled_qty
|
||||
logger.warning(
|
||||
f"[PARTIAL_FILL] {stock_code} {order_type} "
|
||||
f"{filled_qty}/{ordered_quantity}주 체결 → 잔여 {remain}주 재주문"
|
||||
)
|
||||
# 재주문은 백그라운드 태스크로 분리 (현재 흐름 블록 방지)
|
||||
asyncio.create_task(
|
||||
_reorder_unfilled(
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
order_type=order_type,
|
||||
quantity=remain,
|
||||
price=order_price,
|
||||
is_domestic=is_domestic,
|
||||
exchange_code=exchange_code,
|
||||
)
|
||||
f"[PARTIAL_FILL] {stock_code}/{stock_name} {order_type} order_id={order_id} "
|
||||
f"체결 {filled_qty}/{ordered_quantity}주, 잔량 {remaining_qty}주(미체결 {remain}주 소멸) "
|
||||
f"취소확인={exec_result.get('cancel_confirmed_qty')} 거부={exec_result.get('rejected_qty')} "
|
||||
f"cncl_yn={'Y' if exec_result.get('cancelled') else 'N'} — 자동 재주문 안 함, 수동 확인 필요"
|
||||
)
|
||||
|
||||
await _delayed_sync_portfolio(0) # KIS 잔고 기준으로 포트폴리오 재동기화
|
||||
@@ -667,6 +699,83 @@ async def _reorder_unfilled(
|
||||
logger.error(f"[REORDER_ERROR] {stock_code} {order_type}: {type(e).__name__}: {e}")
|
||||
|
||||
|
||||
def _existing_position_qty_v2(holding, stock_code: str) -> int:
|
||||
"""해당 종목의 기존 보유 수량 (종목당 누적 상한 산정용).
|
||||
|
||||
Portfolio 행은 stock_code UNIQUE 라 스윙+DT 수량이 합산돼 있다(CLAUDE.md §3.5).
|
||||
§5.1 의 종목당 상한은 '계좌 내 그 종목 비중'이므로 **전략 구분 없이 전체**를 센다
|
||||
(보수적). 단 DT 가 sync 이전에 산 수량은 행에 아직 없을 수 있으므로
|
||||
`max(행 수량, DT 인메모리 수량)` 으로 잡는다 — 더하면 sync 이후 이중 계상된다.
|
||||
"""
|
||||
_row_qty = int(getattr(holding, "quantity", 0) or 0) if holding is not None else 0
|
||||
try:
|
||||
from app.services.daytrading.position_manager import day_trading_state as _dts
|
||||
_dt_qty = int((_dts.positions.get(stock_code) or {}).get("quantity", 0) or 0)
|
||||
except Exception as _e:
|
||||
logger.warning(
|
||||
f"[POSITION_CAP] {stock_code}: DT 인메모리 수량 조회 실패 — Portfolio 행 수량만 반영: {_e}"
|
||||
)
|
||||
_dt_qty = 0
|
||||
return max(_row_qty, _dt_qty)
|
||||
|
||||
|
||||
def _apply_position_cap_room_v2(
|
||||
invest_amount: float,
|
||||
holding,
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
current_price: float,
|
||||
total_assets: float,
|
||||
position_cap_ratio: float,
|
||||
) -> tuple[float, str | None]:
|
||||
"""종목당 누적 상한 적용. 반환 = (클램프된 금액, SKIP 사유 또는 None).
|
||||
|
||||
매수 예산 = min(기존 예산, 총자산 × position_cap_ratio − 기존 보유 평가액).
|
||||
잔여 한도가 실효 최소주문금액(effective_min_buy_amount) 미만이면
|
||||
'position_cap_reached' 로 건너뛴다. 보유가 있는데 총자산을 모르면(잔고 조회 실패)
|
||||
한도를 검증할 수 없으므로 'position_cap_unverifiable' 로 추가매수를 막는다.
|
||||
|
||||
리스크 가드이므로 이 함수 자체에 try 로 감싼 판정이 없다(§4, §8.1-10).
|
||||
신규 진입(보유 0)은 잔여 한도 = 총자산 × cap 이라 기존 1건 클램프와 동일하게 동작한다.
|
||||
"""
|
||||
_existing_qty = _existing_position_qty_v2(holding, stock_code)
|
||||
if _existing_qty <= 0:
|
||||
return invest_amount, None
|
||||
|
||||
_existing_value = _existing_qty * float(current_price)
|
||||
if total_assets <= 0:
|
||||
logger.warning(
|
||||
f"[POSITION_CAP] {stock_code}/{stock_name}: 기존 보유 {_existing_qty}주 "
|
||||
f"(평가 {_existing_value:,.0f}원)인데 총자산 미확인 — 종목당 상한 "
|
||||
f"{position_cap_ratio:.1%} 검증 불가로 추가매수 차단"
|
||||
)
|
||||
return 0.0, "position_cap_unverifiable"
|
||||
|
||||
_room = remaining_position_room(total_assets, position_cap_ratio, _existing_value)
|
||||
_min_buy = effective_min_buy_amount(
|
||||
config_min_amount=settings.MIN_BUY_AMOUNT_KRW,
|
||||
total_assets=total_assets,
|
||||
position_cap_ratio=position_cap_ratio,
|
||||
factor=0.9,
|
||||
)
|
||||
if _room < _min_buy:
|
||||
logger.info(
|
||||
f"[POSITION_CAP] {stock_code}/{stock_name}: 종목당 상한 도달 — 기존 "
|
||||
f"{_existing_qty}주 평가 {_existing_value:,.0f}원 = 총자산의 "
|
||||
f"{_existing_value / total_assets:.2%} (상한 {position_cap_ratio:.1%}), "
|
||||
f"잔여 {_room:,.0f}원 < 최소주문 {_min_buy:,.0f}원 — 추가매수 건너뜀"
|
||||
)
|
||||
return 0.0, "position_cap_reached"
|
||||
|
||||
if invest_amount > _room:
|
||||
logger.info(
|
||||
f"[POSITION_CAP] {stock_code}/{stock_name}: 기존 보유 {_existing_value:,.0f}원 반영 — "
|
||||
f"매수 예산 {invest_amount:,.0f} → {_room:,.0f}원 (종목당 상한 {position_cap_ratio:.1%})"
|
||||
)
|
||||
return _room, None
|
||||
return invest_amount, None
|
||||
|
||||
|
||||
async def _execute_buy(
|
||||
stock: dict,
|
||||
db: AsyncSession,
|
||||
@@ -985,6 +1094,23 @@ async def _execute_buy(
|
||||
else:
|
||||
invest_amount = min(invest_amount, available_cash)
|
||||
|
||||
# ── 7.6. 종목당 **누적** 상한 (CLAUDE.md §5.1) ─────────────────────────
|
||||
# [2026-10-01] 위 클램프는 '주문 1건' 기준이라, 같은 종목이 매일 추천되면
|
||||
# 추가매수가 쌓여 종목 비중이 MAX_POSITION_SIZE 를 넘었다. 기존 보유 평가액을
|
||||
# 차감한 잔여 한도로 다시 묶는다. 리스크 가드이므로 try 밖에 둔다(§4, §8.1-10).
|
||||
_pos_cap_result = _apply_position_cap_room_v2(
|
||||
invest_amount=invest_amount,
|
||||
holding=holding,
|
||||
stock_code=stock_code,
|
||||
stock_name=stock_name,
|
||||
current_price=current_price,
|
||||
total_assets=total_assets,
|
||||
position_cap_ratio=_adaptive_position_cap,
|
||||
)
|
||||
if _pos_cap_result[1] is not None:
|
||||
return {"action": "SKIP", "reason": _pos_cap_result[1], "stock": stock_name}
|
||||
invest_amount = _pos_cap_result[0]
|
||||
|
||||
# [BUG FIX] 국면별 배율(_position_multiplier)과 뉴스 배율(_news_position_scale)을
|
||||
# invest_amount에 반영 — 기존에는 stock dict에 주입만 되고 실제로 사용되지 않았음.
|
||||
try:
|
||||
@@ -1241,6 +1367,64 @@ async def _execute_buy(
|
||||
await _release_order_lock(stock_code, "BUY")
|
||||
|
||||
|
||||
def _dt_inmemory_qty_v2(stock_code: str) -> int | None:
|
||||
"""DT 인메모리 포지션 수량. 조회 실패 시 None(호출부가 '확인 불가'로 취급)."""
|
||||
try:
|
||||
from app.services.daytrading.position_manager import day_trading_state as _dts
|
||||
return int((_dts.positions.get(stock_code) or {}).get("quantity", 0) or 0)
|
||||
except Exception as e:
|
||||
logger.warning(f"[SELL] {stock_code}: DT 인메모리 수량 조회 실패: {type(e).__name__}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# 자동 매도 대상이 아닌 Portfolio.strategy_type — 사용자 수동 판단 원칙.
|
||||
# external : sync 가 시스템 매수 근거 없이 발견한 유입 잔고(portfolio.EXTERNAL_STRATEGY_TYPE)
|
||||
# manual : manual_buy / portfolio_v2 수동 매수
|
||||
_MANUAL_JUDGMENT_STRATEGY_TYPES = ("external", "manual")
|
||||
|
||||
# 스윙 엔진이 '자기 것'으로 간주하는 strategy_type (NULL 레거시 행은 별도로 포함).
|
||||
# 매도 소유권(_sell_ownership_block_reason_v2)과 일일 매수 한도 집계
|
||||
# (_count_swing_buys_today_v2)가 같은 기준을 쓴다 — 한쪽만 바꾸지 말 것.
|
||||
# swing/position_manager.STRATEGY_TYPE("swing") 과 동일.
|
||||
_SWING_OWNER_STRATEGY_TYPES = ("swing",)
|
||||
|
||||
|
||||
def _swing_owned_clause_v2(column):
|
||||
"""스윙 소유 행 SQL 필터 = `column IS NULL OR column IN (_SWING_OWNER_STRATEGY_TYPES)`.
|
||||
|
||||
NULL 을 명시 포함한다(SQL 3값 논리 — `IN` 만 쓰면 legacy NULL 행이 빠진다).
|
||||
"""
|
||||
return or_(column.is_(None), column.in_(_SWING_OWNER_STRATEGY_TYPES))
|
||||
|
||||
|
||||
def _sell_ownership_block_reason_v2(holding, stock: dict) -> str | None:
|
||||
"""추천 경로 매도(_execute_sell)가 이 보유 행을 팔아도 되는지 판정.
|
||||
|
||||
swing/exit_service 가 `get_swing_holdings()`(strategy_type=='swing') 만 청산하는 것과
|
||||
같은 기준이다. 반환이 None 이면 허용, 문자열이면 SKIP 사유.
|
||||
|
||||
- external / manual → 'manual_judgment_holding' (warning)
|
||||
- DT 계열(DT_STRATEGY_NAMES) → 'owned_by_daytrading' — DT 엔진 소유
|
||||
(단 호출자 태그도 DT 계열이면 자기 보유분이므로 허용)
|
||||
- 그 외 비-swing(us_swing 등) → 'owned_by_other_strategy' — 호출자 태그와 같을 때만 허용
|
||||
|
||||
strategy_type 이 비어 있으면 모델 기본값과 같이 'swing' 으로 본다.
|
||||
MDD 강제축소(mdd_manager)·DT 청산·swing/exit_service 는 이 함수를 거치지 않는다
|
||||
(각자 주문 경로를 가진다) — 리스크 청산은 영향받지 않는다(§8.1-11).
|
||||
"""
|
||||
from app.services.db_worker import DT_STRATEGY_NAMES
|
||||
|
||||
_holding_type = (getattr(holding, "strategy_type", None) or "swing")
|
||||
_caller_tag = stock.get("strategy_type") or "swing"
|
||||
if _holding_type in _SWING_OWNER_STRATEGY_TYPES:
|
||||
return None
|
||||
if _holding_type in _MANUAL_JUDGMENT_STRATEGY_TYPES:
|
||||
return "manual_judgment_holding"
|
||||
if _holding_type in DT_STRATEGY_NAMES:
|
||||
return None if _caller_tag in DT_STRATEGY_NAMES else "owned_by_daytrading"
|
||||
return None if _caller_tag == _holding_type else "owned_by_other_strategy"
|
||||
|
||||
|
||||
async def _execute_sell(
|
||||
stock: dict,
|
||||
db: AsyncSession,
|
||||
@@ -1269,6 +1453,39 @@ async def _execute_sell(
|
||||
- 기본 손절 -6% (CLAUDE.md 5.2), 익절 2R (ATR 기반)
|
||||
"""
|
||||
|
||||
# ── 보유 행 소유권 가드 (2026-10-01) ─────────────────────────────────────
|
||||
# 추천 경로는 strategy_type 검사 없이 보유 행을 스윙 이름으로 팔았다
|
||||
# (external·manual 유입 잔고, strategy_type='daytrading' 행 포함).
|
||||
# 이 함수의 호출자는 execute_auto_trade 하나뿐이며 MDD 강제축소·DT 청산·
|
||||
# swing/exit_service 는 별도 주문 경로라 영향이 없다. try 밖 가드(§4).
|
||||
_ownership_block = _sell_ownership_block_reason_v2(holding, stock)
|
||||
if _ownership_block is not None:
|
||||
_h_type = getattr(holding, "strategy_type", None)
|
||||
_msg = (
|
||||
f"[SELL] {stock_code}/{stock_name}: 보유 {getattr(holding, 'quantity', '?')}주 "
|
||||
f"strategy_type='{_h_type}' — 추천 경로 자동매도 스킵({_ownership_block}, "
|
||||
f"요청 sell_pct={sell_pct*100:.0f}%, 호출 태그='{stock.get('strategy_type', 'swing')}')"
|
||||
)
|
||||
if _ownership_block == "owned_by_daytrading":
|
||||
# [2026-10-01] DT 인메모리에 포지션이 없는데 daytrading 행이 남아 있으면
|
||||
# 고아 행일 수 있다(재시작·stale 청산 실패 등) — 아무 엔진도 팔지 않으므로
|
||||
# info 에 묻히지 않게 warning 으로 올린다.
|
||||
_dt_mem_qty = _dt_inmemory_qty_v2(stock_code)
|
||||
if _dt_mem_qty is None or _dt_mem_qty <= 0:
|
||||
logger.warning(
|
||||
_msg + f" — DT 인메모리 포지션 "
|
||||
f"{'조회 실패' if _dt_mem_qty is None else '0주'}인데 daytrading 행 "
|
||||
f"{getattr(holding, 'quantity', '?')}주가 남아 있음: 고아 행 가능성, "
|
||||
f"수동 확인 필요 ({stock_name}/{stock_code})"
|
||||
)
|
||||
else:
|
||||
logger.info(_msg + f" — DT 엔진 소유(인메모리 {_dt_mem_qty}주)")
|
||||
elif _ownership_block == "manual_judgment_holding":
|
||||
logger.warning(_msg + " — 유입/수동 보유분은 수동 판단 대상")
|
||||
else:
|
||||
logger.warning(_msg + " — 다른 전략 엔진 소유")
|
||||
return {"action": "SKIP", "reason": _ownership_block, "stock": stock_name}
|
||||
|
||||
# [CLAUDE.md 5.2] 분할매도: sell_pct에 따라 매도 수량 결정
|
||||
# [2026-09-23] Portfolio 행은 stock_code UNIQUE 라 같은 종목의 스윙+데이트레이딩
|
||||
# 수량이 합산돼 있다. 행 전체를 대상으로 잡으면 다른 전략 보유분까지 매도된다
|
||||
@@ -1632,6 +1849,29 @@ async def _execute_sell(
|
||||
_auto_trade_bg_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def _count_swing_buys_today_v2(db: AsyncSession) -> int | None:
|
||||
"""당일 스윙 소유 BUY 종목 수 (distinct stock_code). 조회 실패 시 None.
|
||||
|
||||
None 은 '모름'이다 — 호출부가 fail-closed(신규 매수 차단)로 처리한다.
|
||||
여기서 0 으로 폴백하면 한도가 조용히 풀린다(이전 동작).
|
||||
"""
|
||||
try:
|
||||
_res = await db.execute(
|
||||
select(TradeHistory.stock_code)
|
||||
.where(
|
||||
TradeHistory.order_type == "BUY",
|
||||
TradeHistory.status.in_(["COMPLETED", "PENDING"]),
|
||||
TradeHistory.created_at >= get_daily_cutoff_utc(),
|
||||
_swing_owned_clause_v2(TradeHistory.strategy_type),
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
return len(_res.scalars().all())
|
||||
except Exception as e:
|
||||
logger.error(f"[AUTO_TRADE_BG] 당일 스윙 매수 건수 조회 예외: {type(e).__name__}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def execute_auto_trade_background(recommendations_response: dict, db: AsyncSession):
|
||||
"""recommendations_response dict 에서 추천 종목을 꺼내 자동매매 실행.
|
||||
|
||||
@@ -1670,24 +1910,23 @@ async def _execute_auto_trade_background_locked(recommendations_response: dict,
|
||||
|
||||
# 일일 한도는 "오늘 이미 나간 BUY 종목 수"를 차감한 전역 기준으로 적용
|
||||
# (호출 단위 슬라이스만으로는 여러 사이클에 걸쳐 한도 초과됨)
|
||||
try:
|
||||
_today_buys = await db.execute(
|
||||
select(TradeHistory.stock_code)
|
||||
.where(
|
||||
TradeHistory.order_type == "BUY",
|
||||
TradeHistory.status.in_(["COMPLETED", "PENDING"]),
|
||||
TradeHistory.created_at >= get_daily_cutoff_utc(),
|
||||
)
|
||||
.distinct()
|
||||
# [2026-10-01] MAX_DAILY_TRADES 는 스윙 한도다 → 스윙 소유 매수(swing + NULL
|
||||
# 레거시)만 센다. DT·manual·external·us_swing 매수는 제외(매도 소유권과 같은 기준).
|
||||
# [2026-10-01] 조회 실패 시 fail-closed — 한도를 검증할 수 없으면 신규 매수를
|
||||
# 막는다. 판정은 try 밖(§4). 스윙 청산은 swing/scheduler step 2
|
||||
# (check_and_execute_exit_v2)가 이 함수와 독립적으로 수행하므로 영향이 없다.
|
||||
_bought_today = await _count_swing_buys_today_v2(db)
|
||||
if _bought_today is None:
|
||||
logger.error(
|
||||
f"[AUTO_TRADE_BG] 🚨 당일 스윙 매수 건수 조회 실패 — 일일 한도 "
|
||||
f"{min(settings.MAX_DAILY_TRADES, regime_max_trades)}종목 검증 불가로 "
|
||||
f"이번 사이클 신규 진입 차단(fail-closed). 스윙 청산 경로는 영향 없음"
|
||||
)
|
||||
_bought_today = len(_today_buys.scalars().all())
|
||||
except Exception as _e:
|
||||
logger.warning(f"[AUTO_TRADE_BG] 당일 매수 건수 조회 실패 (한도 미차감): {_e}")
|
||||
_bought_today = 0
|
||||
return []
|
||||
max_trades = max(0, max_trades - _bought_today)
|
||||
if max_trades == 0:
|
||||
logger.info(
|
||||
f"[AUTO_TRADE_BG] 일일 매수 한도 도달 (오늘 {_bought_today}종목 / 한도 "
|
||||
f"[AUTO_TRADE_BG] 일일 매수 한도 도달 (오늘 스윙 {_bought_today}종목 / 한도 "
|
||||
f"{min(settings.MAX_DAILY_TRADES, regime_max_trades)}) — 신규 진입 스킵"
|
||||
)
|
||||
return []
|
||||
@@ -1831,6 +2070,47 @@ def _ml_gate_score_v2(rule_score: float, ml_score: float) -> float:
|
||||
return (rule_score / rule_max) * 100.0
|
||||
|
||||
|
||||
def _today_sell_conditions_v2(stock_code: str, cutoff_time) -> list:
|
||||
"""당일 SELL 판정 조건 (COMPLETED/PARTIAL 은 executed_at, PENDING 은 created_at 기준)."""
|
||||
return [
|
||||
TradeHistory.stock_code == stock_code,
|
||||
TradeHistory.order_type == "SELL",
|
||||
or_(
|
||||
and_(
|
||||
TradeHistory.status.in_(["COMPLETED", "PARTIAL"]),
|
||||
TradeHistory.executed_at >= cutoff_time,
|
||||
),
|
||||
and_(
|
||||
TradeHistory.status == "PENDING",
|
||||
TradeHistory.created_at >= cutoff_time,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def _non_dt_sold_today_v2(db: AsyncSession, stock_code: str, cutoff_time) -> bool:
|
||||
"""당일 비-DT SELL 존재 여부 — 스윙 보유분 '부분매도 반복 차단' 판정 전용.
|
||||
|
||||
DT 판정은 db_worker.not_dt_strategy_clause 정본 표현(CLAUDE.md §3.3).
|
||||
조회 실패 시 True(보수적: 부분매도만 막고 전량 청산은 허용 — 기존 동작과 동일).
|
||||
"""
|
||||
from app.services.db_worker import not_dt_strategy_clause
|
||||
try:
|
||||
_res = await db.execute(
|
||||
select(TradeHistory.id).where(
|
||||
*_today_sell_conditions_v2(stock_code, cutoff_time),
|
||||
not_dt_strategy_clause(TradeHistory.strategy_type),
|
||||
).limit(1)
|
||||
)
|
||||
return _res.first() is not None
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[TRADE] {stock_code}: 당일 비-DT 매도 조회 실패 — 부분매도 차단으로 간주 "
|
||||
f"(전량 청산은 허용): {type(e).__name__}: {e}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def execute_auto_trade(stock: dict, db: AsyncSession):
|
||||
"""종목 단건 매매 판단 및 실행.
|
||||
|
||||
@@ -1944,6 +2224,22 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
|
||||
_r = _guard("external_inflow_holding")
|
||||
if _r is not None:
|
||||
return _r
|
||||
elif holding is not None:
|
||||
# [2026-10-01] "팔 수 없는 행에는 사지 않는다" — 매도 소유권과 대칭.
|
||||
# manual(수동매수)·daytrading(EOD 미청산 등)·us_swing 행에 스윙이 추가매수하면
|
||||
# 그 수량은 swing/exit_service(=='swing' 만)도 _execute_sell 도 팔지 않아
|
||||
# SL/TP 없는 보유가 된다. `_guard` 경유라 청산 판정은 그대로 통과(§8.1-11).
|
||||
_own_block = _sell_ownership_block_reason_v2(holding, stock)
|
||||
if _own_block is not None:
|
||||
logger.warning(
|
||||
f"🚧 비-스윙 보유 행 추가매수 차단: {stock_name}({stock_code}) "
|
||||
f"보유 {holding.quantity}주 strategy_type='{holding.strategy_type}' "
|
||||
f"({_own_block}) — 스윙 청산 경로가 이 행을 팔지 않으므로 "
|
||||
f"추가매수분이 SL/TP 없이 남는다"
|
||||
)
|
||||
_r = _guard(f"non_swing_holding:{_own_block}")
|
||||
if _r is not None:
|
||||
return _r
|
||||
|
||||
held_result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0))
|
||||
# 적응형 전략: max_holdings 오버라이드 (실패 시 settings 기본값 fallback)
|
||||
@@ -1974,26 +2270,16 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
|
||||
# 쿨다운을 영구히 걸지 않는다.
|
||||
cutoff_time = _get_kst_now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
recent_sell = await db.execute(
|
||||
select(TradeHistory).where(
|
||||
TradeHistory.stock_code == stock_code,
|
||||
TradeHistory.order_type == "SELL",
|
||||
or_(
|
||||
and_(
|
||||
TradeHistory.status.in_(["COMPLETED", "PARTIAL"]),
|
||||
TradeHistory.executed_at >= cutoff_time,
|
||||
),
|
||||
and_(
|
||||
TradeHistory.status == "PENDING",
|
||||
TradeHistory.created_at >= cutoff_time,
|
||||
),
|
||||
),
|
||||
)
|
||||
select(TradeHistory).where(*_today_sell_conditions_v2(stock_code, cutoff_time))
|
||||
)
|
||||
if recent_sell.first():
|
||||
_sold_today = True
|
||||
# 당일 재매수 차단은 DT 매도까지 포함한다(같은 종목 왕복 방지 — 기존 동작).
|
||||
_r = _guard("cooldown_sell_today")
|
||||
if _r is not None:
|
||||
return _r
|
||||
# [2026-10-01] 단 '부분매도 반복 차단'(_sold_today) 은 스윙 자신의 매도만 본다.
|
||||
# DT 가 같은 종목을 팔았다고 스윙 보유분의 부분익절이 당일 막히면 안 된다.
|
||||
_sold_today = await _non_dt_sold_today_v2(db, stock_code, cutoff_time)
|
||||
|
||||
# [BUG FIX] ActiveTrade.remaining_pct 조회 — 미주입 시 1차 익절 후에도
|
||||
# remaining_pct가 항상 1.0으로 간주되어 SELL_PARTIAL이 반복 발생하는 문제 방지.
|
||||
|
||||
Reference in New Issue
Block a user