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

@@ -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,

View File

@@ -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 강제 청산 시작 시간

View File

@@ -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% 분할 매도)

View File

@@ -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)

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

View 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

View File

@@ -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:
"""특정 종목의 활성 포지션 정보 반환.

View File

@@ -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":

View File

@@ -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]:
"""주문 취소"""

View File

@@ -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)

View 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,
}

View File

@@ -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이 반복 발생하는 문제 방지.