Files
kis_bot/kis_trader/execution/order_manager.py
2026-07-30 18:05:07 +09:00

2191 lines
95 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
kis_trader/execution/order_manager.py — Master Executor
========================================================
두 전략(스캘핑/꼬리잡기)이 공유하는 "단 하나의 주문 실행자".
설계 목적:
* 전략은 **시그널만 생성**한다. 주문 실행은 전부 여기서 직렬화 처리.
* 매수: ``active_trades (code, strategy)`` 복합키로 전략별 qty 관리 — 타 전략/실계좌 보유와 무관하게 추가 매수 가능.
* 매도 전 실계좌 잔고 재확인 → 요청 qty 만큼만 매도 (``min(요청, 실보유)``).
(DB·메모리 정리까지 한 번에).
* 주문번호(ODNO) 기준 UNIQUE 제약으로 **서버단 중복 차단**.
정책 (env_config 로 제어):
* ``STRATEGY_SAME_CODE_POLICY``
- ``allow`` (기본): 전략별 active_trades·ODNO 분리 — 다른 전략 보유와 무관하게 매수 허용.
- ``block`` : 한 종목은 한 전략만 (다른 전략 active_trades 보유 시 차단).
* ``REAL_BALANCE_VERIFY_BEFORE_SELL`` (기본 True): 매도 전 실잔고 조회.
* ``BROKER_HOLDINGS_CACHE_TTL_SEC`` (기본 5): 잔고 캐시 TTL — 매도 루프 내 N종목 공유.
* ``GHOST_POSITION_COOLDOWN_SEC`` (기본 300): 유령잔고 정리 후 동일 (전략,종목) 재시도 쿨다운.
* ``REAL_BALANCE_VERIFY_BEFORE_BUY`` (기본 False): 매수 전 검증 (기본 OFF — 전략별 복합키가 주 관리).
* ``REAL_BALANCE_VERIFY_BEFORE_BUY_MODE`` — ``strategy``(기본): 동일 전략 DB 보유 시만 차단 /
``global``(레거시): 실계좌에 1주라도 있으면 차단.
* ``STRICT_FILL_VERIFY`` (기본 False): 모의에서 true 시 실전과 동일하게 fill 미확인 시
active_trades 미반영·heartbeat 재조회. **실전은 항상 엄격**.
* ``PENDING_FILL_POLL_INTERVAL_SEC`` / ``PENDING_BUY_MAX_AGE_SEC`` /
``PENDING_SELL_MAX_AGE_SEC`` / ``PENDING_SELL_STOP_MAX_AGE_SEC`` — 미체결 재조회·만료.
* ``PENDING_POLL_BATCH_FETCH`` (기본 True): heartbeat ``poll_pending_fills`` 에서
당일 체결을 1 REST 로 일괄 조회 (실매 전용 · 백테 무관).
* ``DUPLICATE_ORDER_FILL_RECOVERY_ENABLED`` (기본 True): insert 실패(주문DB중복) 시
``inquire-daily-ccld`` 1회로 체결 복구 → active_trades 반영.
* ``DUPLICATE_ORDER_RECOVERY_WAIT_SEC`` (기본 ORDER_FILL_WAIT_SEC): 중복복구 체결 대기.
* ``SELL_PENDING_REORDER_ON_EXPIRE`` — 손절 등 긴급 매도 만료 시 즉시 시장가 재주문.
* ``SELL_LIMIT_CANCEL_BEFORE_MARKET_RETRY`` (기본 True): 익절 지정가 미확인/부분체결 시
**시장가 보강 전에 지정가 취소 → ODNO 재조회**. 취소 실패·체결 미확인이면 시장가 금지
(PENDING). 지정가+시장가 이중체결로 타전략 몫까지 파는 사고 방지 (전 전략 공통).
* ``SELL_LIMIT_RECHECK_WAIT_SEC`` (기본 1): 취소 후 지정가 ODNO 재조회 대기.
* ``SELL_LIMIT_CANCEL_FAIL_BALANCE_CONFIRM`` (기본 True): 취소 실패 후 ODNO 미확인이어도
브로커 잔고가 0(또는 요청수량만큼 감소)이면 **지정가 매도 체결로 finalize** (유령 금지).
* ``GHOST_PURGE_BLOCK_WHILE_PENDING_SELL`` (기본 True): PENDING 매도 ODNO 있으면
ghost_purge 대신 체결 재조회·잔고확정 청산.
* ``GHOST_PURGE_RECORD_HISTORY`` (기본 True): 유령잔고 삭제 시 trade_history 에
``ghost_purge`` 기록 (미기록 유령 방지).
* ``AccountCashLedger`` — kv_store+메모리 예수금. **기존 qty 우선**, 부족할 때만
``ORDER_CASH_PCT`` 로 수량 축소 (매수체크 루프에서는 REST 미호출).
"""
from __future__ import annotations
import datetime
import json
import threading
import time
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import datetime as dt
from typing import Callable, Dict, Optional
from ..database.db_manager import TradeDBExt
from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int
from ..utils.stock_name import resolve_stock_display_name
from ..utils.logger import (
LOG_CYAN,
LOG_GREEN,
LOG_RED,
LOG_RESET,
LOG_YELLOW,
get_logger,
msg_mm_strategy,
)
from .account_cash import AccountCashLedger
from .kis_client import KISClient
from .orderbook_sell import (
evaluate_take_profit_limit_sell,
is_profit_take_sell_reason,
is_urgent_market_sell_reason,
resolve_orderbook_raw,
)
logger = get_logger("kis_trader.order_mgr")
@dataclass
class OrderRequest:
"""전략이 OrderManager 에 넘기는 주문 요청."""
strategy_id: str
code: str
name: str
side: str # 'BUY' | 'SELL'
qty: int
price_ref: float = 0.0 # 참고가(로그/DB용, 시장가 주문이라도 체결 추정치)
reason: str = "" # 매도 사유
# 매수 시 포지션 관리용 부가 정보
stop_price: float = 0.0
target_price: float = 0.0
atr_entry: float = 0.0
size_class: Optional[str] = None
entry_features: Optional[Dict] = None
use_limit_buy: bool = False
# 매도 시 계산 결과 전달 (로그용)
buy_price: float = 0.0
profit_pct: float = 0.0
# 해외(US) — market="US" 또는 exchange 있으면 해외 주문 경로
market: str = "" # "" | "US" | "KR"
exchange: str = "" # NASD / NYSE / AMEX …
currency: str = "" # "" | "USD"
@dataclass
class OrderResult:
success: bool
ord_no: Optional[str] = None
filled_qty: int = 0
filled_avg_price: float = 0.0
reason: str = "" # 실패 사유 (차단/거부/오류)
request: Optional[OrderRequest] = None
extra: Dict = field(default_factory=dict)
class OrderManager:
"""
단일 주문 실행자 (싱글톤처럼 운용).
- 종목별 Lock → 같은 종목에 대한 요청 직렬화.
- 실잔고 캐시 (짧은 TTL) → API 호출 횟수 절감.
"""
def __init__(
self,
*,
client: KISClient,
db: TradeDBExt,
cash_ledger: Optional[AccountCashLedger] = None,
):
self.client = client
self.db = db
self.cash_ledger: Optional[AccountCashLedger] = cash_ledger
# 종목별 Lock: 한 종목에 대한 주문 요청은 순차 처리
self._code_locks: Dict[str, threading.Lock] = defaultdict(threading.Lock)
# 전역 Lock: _code_locks 인스턴스 생성 시 경합 방지
self._global_lock = threading.Lock()
# 실잔고 맵 캐시
self._holdings_cache: Optional[Dict[str, Dict]] = None
self._holdings_cache_ts: float = 0.0
self._holdings_lock = threading.Lock()
self._holdings_last_fetch_ok: bool = True
# 유령잔고 정리 쿨다운 — (strategy_id, code) → epoch
self._ghost_purged_at: Dict[tuple, float] = {}
# 매도 실패 백오프 (영업일 아님 등)
self._sell_backoff: Dict[str, float] = {}
# 체결 알림에 덧붙일 자산 요약 라인 생성기(주입식).
# TradingOrchestrator.start() 가 self._asset_line_for_notify 를 세팅.
# signature: (side: str, extra: Optional[Dict]) -> str (여러 줄 가능)
self.asset_line_provider: Optional[Callable[..., str]] = None
# TradingOrchestrator.start() 가 self._strategy_daily_pnl_for_notify 를 세팅.
# signature: (strategy_id: str) -> (당일 실현손익, 청산건수) | None
self.strategy_pnl_provider: Optional[Callable[..., tuple]] = None
# 호가 조회 — WSManager.get_orderbook 등 (키움 0D 캐시 우선)
self.orderbook_provider: Optional[Callable[..., Optional[dict]]] = None
# ------------------------------------------------------------------
# Lock 헬퍼
# ------------------------------------------------------------------
def _lock_for(self, code: str) -> threading.Lock:
with self._global_lock:
return self._code_locks[code]
# ------------------------------------------------------------------
# 실잔고 조회 (캐시)
# ------------------------------------------------------------------
def _holdings_cache_ttl(self) -> float:
return float(get_env_int("BROKER_HOLDINGS_CACHE_TTL_SEC", 5))
def get_broker_holdings(self, force: bool = False) -> Dict[str, Dict]:
"""
실계좌 잔고 맵 {code: {qty, avg_price, ...}}.
TTL 캐시로 API 폭주 방지. API 실패 시 stale 캐시가 있으면 그걸 반환.
``_holdings_last_fetch_ok`` 로 최신 조회 성공 여부 확인.
"""
now = time.time()
ttl = self._holdings_cache_ttl()
with self._holdings_lock:
if (
not force
and self._holdings_cache is not None
and (now - self._holdings_cache_ts) < ttl
):
self._holdings_last_fetch_ok = True
return dict(self._holdings_cache)
m = self.client.get_broker_holdings_map()
if m is None:
self._holdings_last_fetch_ok = False
if self._holdings_cache is not None:
logger.debug(
"⏸ [잔고캐시] API 실패 → stale 캐시 사용 (age=%.1fs)",
now - self._holdings_cache_ts,
)
return dict(self._holdings_cache)
return {}
self._holdings_last_fetch_ok = True
self._holdings_cache = m
self._holdings_cache_ts = now
return dict(m)
def prefetch_broker_holdings(self) -> bool:
"""
전략 매도 루프 시작 전 1회 호출.
동일 루프에서 N종목 매도 시 inquire-balance 중복 호출을 줄인다.
"""
self.get_broker_holdings(force=True)
return self._holdings_last_fetch_ok
def invalidate_holdings_cache(self) -> None:
with self._holdings_lock:
self._holdings_cache = None
self._holdings_cache_ts = 0.0
def _resolve_order_display_name(self, req: OrderRequest) -> str:
"""MM·DB·로그용 종목명 — code=이름이면 DB/잔고에서 보완."""
fb = str(req.name or req.code or "").strip()
if fb and fb != req.code:
return fb
try:
holdings = self.get_broker_holdings(force=False)
except Exception:
holdings = None
resolved = resolve_stock_display_name(
self.db,
req.code,
fb,
holdings_map=holdings,
)
if resolved and resolved != req.code:
req.name = resolved
return req.name or req.code
# ------------------------------------------------------------------
# 체결 검증 (실전 항상 엄격 / 모의는 STRICT_FILL_VERIFY)
# ------------------------------------------------------------------
def _strict_fill_required(self) -> bool:
"""실전은 fill 미확인 시 가정 체결 금지. 모의는 env 로 훈련 모드 전환."""
if not self.client.mock:
return True
return get_env_bool("STRICT_FILL_VERIFY", False)
def _buy_pending_meta(self, req: OrderRequest) -> str:
"""재시작·heartbeat 재조회용 매수 메타 (orders.raw_json)."""
return json.dumps({
"strategy_id": req.strategy_id,
"code": req.code,
"name": req.name,
"qty": req.qty,
"price_ref": req.price_ref,
"stop_price": req.stop_price,
"target_price": req.target_price,
"atr_entry": req.atr_entry,
"size_class": req.size_class,
"entry_features": req.entry_features or {},
"use_limit_buy": req.use_limit_buy,
}, ensure_ascii=False)
def _sell_pending_meta(self, req: OrderRequest) -> str:
return json.dumps({
"strategy_id": req.strategy_id,
"code": req.code,
"name": req.name,
"qty": req.qty,
"price_ref": req.price_ref,
"reason": req.reason,
"buy_price": req.buy_price,
"profit_pct": req.profit_pct,
}, ensure_ascii=False)
def _req_from_order_row(self, row: Dict) -> Optional[OrderRequest]:
"""orders 행 → OrderRequest (raw_json 우선)."""
side = str(row.get("side") or "").upper()
raw = row.get("raw_json")
if raw:
try:
d = json.loads(raw)
if side == "SELL":
return OrderRequest(
strategy_id=d.get("strategy_id") or row.get("strategy_id", ""),
code=d.get("code") or row.get("code", ""),
name=d.get("name") or row.get("name", ""),
side="SELL",
qty=int(d.get("qty") or row.get("qty") or 0),
price_ref=float(d.get("price_ref") or row.get("price") or 0),
reason=str(d.get("reason") or ""),
buy_price=float(d.get("buy_price") or 0),
profit_pct=float(d.get("profit_pct") or 0),
)
return OrderRequest(
strategy_id=d.get("strategy_id") or row.get("strategy_id", ""),
code=d.get("code") or row.get("code", ""),
name=d.get("name") or row.get("name", ""),
side="BUY",
qty=int(d.get("qty") or row.get("qty") or 0),
price_ref=float(d.get("price_ref") or row.get("price") or 0),
stop_price=float(d.get("stop_price") or 0),
target_price=float(d.get("target_price") or 0),
atr_entry=float(d.get("atr_entry") or 0),
size_class=d.get("size_class"),
entry_features=d.get("entry_features") or {},
use_limit_buy=bool(d.get("use_limit_buy")),
)
except Exception as e:
logger.debug("raw_json 파싱 실패 ord_no=%s: %s", row.get("ord_no"), e)
qty = int(row.get("qty") or 0)
if qty <= 0:
return None
if side == "SELL":
return OrderRequest(
strategy_id=str(row.get("strategy_id") or ""),
code=str(row.get("code") or ""),
name=str(row.get("name") or ""),
side="SELL",
qty=qty,
price_ref=float(row.get("price") or 0),
)
return OrderRequest(
strategy_id=str(row.get("strategy_id") or ""),
code=str(row.get("code") or ""),
name=str(row.get("name") or ""),
side="BUY",
qty=qty,
price_ref=float(row.get("price") or 0),
)
def _order_age_sec(self, row: Dict) -> float:
"""submitted_at 기준 경과 초."""
submitted = str(row.get("submitted_at") or "").strip()
if not submitted:
return 0.0
try:
ts = dt.strptime(submitted, "%Y-%m-%d %H:%M:%S")
return max(0.0, time.time() - ts.timestamp())
except Exception:
return 0.0
def _pending_buy_max_age_sec(self) -> float:
legacy = get_env_int("PENDING_FILL_MAX_AGE_SEC", 120)
return float(get_env_int("PENDING_BUY_MAX_AGE_SEC", legacy))
def _pending_sell_max_age_sec(self, reason: str) -> float:
if is_urgent_market_sell_reason(reason):
return float(get_env_int("PENDING_SELL_STOP_MAX_AGE_SEC", 15))
return float(get_env_int("PENDING_SELL_MAX_AGE_SEC", 60))
def _submit_market_sell_and_confirm(
self, req: OrderRequest, sell_qty: int
) -> OrderResult:
"""시장가 매도 접수 + 체결 확인 (손절·만료 재주문용)."""
ord_no = self.client.sell_market_order(req.code, sell_qty)
if not ord_no:
cd = self.client._last_sell_msg_cd or ""
m1 = self.client._last_sell_msg1 or ""
return OrderResult(
False, reason=f"sell_reject:{cd or m1}", request=req,
)
self.db.insert_order(
ord_no=ord_no, strategy_id=req.strategy_id,
code=req.code, name=req.name,
side="SELL", qty=sell_qty,
price=req.price_ref,
status="SUBMITTED",
raw_json=self._sell_pending_meta(req),
)
wait_sec = float(get_env_int("ORDER_FILL_WAIT_SEC", 2))
fill = self.client.get_execution_by_odno(
ord_no, code=req.code, wait_sec=wait_sec,
)
if fill and int(fill.get("filled_qty", 0) or 0) > 0:
return self._finalize_sell_fill(
req, ord_no,
int(fill["filled_qty"]), float(fill["avg_price"]),
sell_qty,
)
if self._strict_fill_required():
self.db.update_order_status(
ord_no=ord_no, strategy_id=req.strategy_id, code=req.code,
status="PENDING_FILL",
)
return OrderResult(
False, ord_no=ord_no, reason="sell_fill_pending", request=req,
)
sell_price = float(req.price_ref or req.buy_price or 0)
if sell_price <= 0:
return OrderResult(
False, ord_no=ord_no, reason="zero_sell_fill", request=req,
)
return self._finalize_sell_fill(
req, ord_no, sell_qty, sell_price, sell_qty,
)
def _escalate_urgent_sell_market(
self, req: OrderRequest, sell_qty: int, *, tag: str,
) -> None:
"""손절·장마감 등 — 만료 취소 직후 시장가 재매도."""
if sell_qty <= 0:
return
if not get_env_bool("SELL_PENDING_REORDER_ON_EXPIRE", True):
return
if not is_urgent_market_sell_reason(req.reason or ""):
return
logger.warning(
"%s🔁 [%s] [%s] %s %s%d주 시장가 재매도%s",
LOG_YELLOW, tag, req.strategy_id, req.name, req.code,
sell_qty, LOG_RESET,
)
with self._lock_for(req.code):
if self.db.get_pending_sell_order(req.strategy_id, req.code):
logger.debug(
"escalate skip — pending sell exists %s/%s",
req.strategy_id, req.code,
)
return
self._submit_market_sell_and_confirm(req, sell_qty)
def _try_cancel_buy_remainder(
self,
ord_no: str,
code: str,
remain_qty: int,
*,
use_limit: bool,
) -> None:
"""부분체결 잔량 취소 (IOC 시장가는 브로커가 자동 취소)."""
if remain_qty <= 0:
return
if not get_env_bool("AUTO_CANCEL_PARTIAL_BUY_REMAINDER", True):
return
if not use_limit and get_env_bool("USE_MARKET_IOC", True):
return
try:
ok = self.client.cancel_order(ord_no, qty=remain_qty)
if ok:
logger.info(
"🛑 [매수잔량취소] %s ODNO=%s 잔량 %d",
code, ord_no, remain_qty,
)
except Exception as e:
logger.warning("매수 잔량 취소 실패 %s ord_no=%s: %s", code, ord_no, e)
def _try_recover_duplicate_buy_fill(
self,
req: OrderRequest,
ord_no: str,
buy_qty: int,
) -> Optional[OrderResult]:
"""
insert_order 실패(주문DB중복) 시 브로커 체결 1회 조회 → active_trades 복구.
DUPLICATE_ORDER_FILL_RECOVERY_ENABLED=false 이면 None (호출부에서 실패 반환).
"""
if not get_env_bool("DUPLICATE_ORDER_FILL_RECOVERY_ENABLED", True):
return None
from ..utils.strategy_ids import canonical_strategy_id
sid = canonical_strategy_id(req.strategy_id)
existing = self.db.get_order_by_odno(
ord_no, strategy_id=req.strategy_id, code=req.code,
)
if existing:
ex_filled = int(existing.get("filled_qty") or 0)
ex_status = str(existing.get("status") or "").upper()
if ex_filled > 0 and ex_status in ("FILLED", "PARTIAL", "SUBMITTED"):
at_qty = 0
try:
row = self.db.conn.execute(
"SELECT current_qty FROM active_trades "
"WHERE code=%s AND strategy=%s",
(req.code, sid),
).fetchone()
if row:
at_qty = int(float(
row.get("current_qty")
if isinstance(row, dict)
else row[0]
) or 0)
except Exception as e:
logger.debug("체결복구 active_trades 조회 실패 %s: %s", req.code, e)
if at_qty > 0:
logger.info(
"🔄 [체결복구스킵] %s %s — orders/active_trades 이미 반영 (qty=%d)",
req.code, ord_no, at_qty,
)
return OrderResult(
True,
ord_no=ord_no,
filled_qty=ex_filled,
filled_avg_price=float(
existing.get("filled_avg_price") or req.price_ref
),
reason="duplicate_already_finalized",
request=req,
)
if req.use_limit_buy:
wait_sec = float(get_env_int("LIMIT_ORDER_FILL_WAIT_SEC", 1))
else:
wait_sec = float(get_env_int(
"DUPLICATE_ORDER_RECOVERY_WAIT_SEC",
get_env_int("ORDER_FILL_WAIT_SEC", 2),
))
fill = self.client.get_execution_by_odno(
ord_no, code=req.code, wait_sec=wait_sec,
)
if not fill or int(fill.get("filled_qty", 0) or 0) <= 0:
logger.warning(
"%s⚠️ [체결복구실패] insert 실패 + 브로커 미체결 %s %s ODNO=%s%s",
LOG_YELLOW, req.name, req.code, ord_no, LOG_RESET,
)
return None
filled_qty = int(fill["filled_qty"])
filled_price = float(fill["avg_price"])
if 0 < filled_qty < buy_qty:
miss = buy_qty - filled_qty
logger.warning(
"%s⚠️ [체결복구·부분체결] [%s] %s %s: %d/%d%s",
LOG_YELLOW, req.strategy_id, req.name, req.code,
filled_qty, buy_qty, LOG_RESET,
)
self._try_cancel_buy_remainder(
ord_no, req.code, miss, use_limit=req.use_limit_buy,
)
logger.warning(
"%s🔄 [체결복구] insert 실패했으나 브로커 체결 확인 — "
"[%s] %s %s × %d%s",
LOG_CYAN, req.strategy_id, req.name, req.code, filled_qty, LOG_RESET,
)
return self._finalize_buy_fill(
req, ord_no, filled_qty, filled_price, buy_qty,
log_tag="체결복구",
)
def _finalize_buy_fill(
self,
req: OrderRequest,
ord_no: str,
filled_qty: int,
filled_price: float,
order_qty: int,
*,
log_tag: str = "매수체결",
) -> OrderResult:
"""체결 확정 후 active_trades·알림 반영."""
if filled_qty <= 0 or filled_price <= 0:
return OrderResult(False, ord_no=ord_no, reason="zero_fill", request=req)
self._resolve_order_display_name(req)
status = "FILLED" if filled_qty >= order_qty else "PARTIAL"
self.db.update_order_fill(
ord_no=ord_no,
strategy_id=req.strategy_id,
code=req.code,
filled_qty=filled_qty,
filled_avg_price=filled_price,
status=status,
)
now_str = dt.now().strftime("%Y-%m-%d %H:%M:%S")
from ..utils.strategy_ids import canonical_strategy_id
self.db.upsert_trade({
"code": req.code,
"name": req.name,
"strategy": canonical_strategy_id(req.strategy_id),
"avg_buy_price": filled_price,
"current_price": filled_price,
"stop_price": req.stop_price,
"target_price": req.target_price,
"max_price": filled_price,
"atr_entry": req.atr_entry,
"target_qty": filled_qty,
"current_qty": filled_qty,
"total_invested": filled_price * filled_qty,
"status": "HOLDING",
"buy_date": now_str,
"size_class": req.size_class or "",
"entry_features": req.entry_features or {},
})
self.invalidate_holdings_cache()
if self.cash_ledger is not None:
fee_buf = max(1.0, get_env_float("ORDER_CASH_FEE_BUFFER", 1.01))
self.cash_ledger.apply_trade_delta(
-filled_qty * filled_price * fee_buf,
source="trade_delta",
)
logger.info(
"%s✅ [%s] [%s] %s %s @ %d× %d주 (ODNO=%s)%s",
LOG_GREEN, log_tag, req.strategy_id, req.name, req.code,
int(filled_price), filled_qty, ord_no, LOG_RESET,
)
try:
disp = _strategy_display(req.strategy_id)
header = (
f"🔷 **[{log_tag}:{disp}]** {req.name}({req.code})\n"
f"{filled_price:,.0f}× {filled_qty}주 = {filled_price*filled_qty:,.0f}\n"
f"손절 {req.stop_price:,.0f} / 목표 {req.target_price:,.0f}\n"
f"ODNO={ord_no}"
)
tail = ""
if self.asset_line_provider is not None:
try:
tail = self.asset_line_provider("BUY", None) or ""
except Exception as _e:
logger.debug("asset_line_provider(BUY) 실패: %s", _e)
msg = header + ("\n\n" + tail if tail else "")
msg_mm_strategy(
msg,
_strategy_mm_channel(req.strategy_id),
jitter=False,
)
except Exception:
pass
return OrderResult(
True, ord_no=ord_no,
filled_qty=filled_qty, filled_avg_price=filled_price,
request=req,
)
def _finalize_sell_fill(
self,
req: OrderRequest,
ord_no: str,
filled_qty: int,
sell_price: float,
order_qty: int,
) -> OrderResult:
"""매도 체결 확정 후 close_trade·알림."""
if filled_qty <= 0 or sell_price <= 0:
return OrderResult(False, ord_no=ord_no, reason="zero_sell_fill", request=req)
self._resolve_order_display_name(req)
status = "FILLED" if filled_qty >= order_qty else "PARTIAL"
self.db.update_order_fill(
ord_no=ord_no,
strategy_id=req.strategy_id,
code=req.code,
filled_qty=filled_qty,
filled_avg_price=sell_price,
status=status,
)
fee_rate = float(get_env_from_db("FEE_RATE_PCT", "0.015")) / 100.0
tax_rate = float(get_env_from_db("SELL_TAX_RATE_PCT", "0.18")) / 100.0
buy_price = req.buy_price or 0
if buy_price > 0:
fees = (
buy_price * filled_qty * fee_rate
+ sell_price * filled_qty * (fee_rate + tax_rate)
)
realized_pnl = (sell_price - buy_price) * filled_qty - fees
else:
realized_pnl = None
if req.name and req.name != req.code:
try:
from ..utils.strategy_ids import canonical_strategy_id
sid = canonical_strategy_id(req.strategy_id)
with self.db.conn:
self.db.conn.execute(
"UPDATE active_trades SET name=%s WHERE code=%s AND strategy=%s",
(req.name, req.code, sid),
)
except Exception as exc:
logger.debug("active_trades name 보정 실패(%s): %s", req.code, exc)
# close_trade 전에 매수시각 확보 (보유구간 봉 백필용)
buy_date_for_bf = None
try:
from ..utils.strategy_ids import canonical_strategy_id
_sid_bf = canonical_strategy_id(req.strategy_id)
_ar = self.db.conn.execute(
"SELECT buy_date FROM active_trades WHERE code=%s AND strategy=%s LIMIT 1",
(req.code, _sid_bf),
).fetchone()
if _ar:
buy_date_for_bf = dict(_ar).get("buy_date")
except Exception:
buy_date_for_bf = None
self.db.close_trade(
code=req.code,
sell_price=sell_price,
sell_reason=req.reason or "",
strategy=req.strategy_id,
realized_pnl_override=realized_pnl,
)
# 매수~매도 구간 1분봉 REST 백필 (백테 봉구멍·슬롯 좀비 방지) — 비동기 1회
if buy_date_for_bf:
try:
from kis_trader.engine.post_sell_candle_backfill import (
schedule_post_sell_backfill,
)
schedule_post_sell_backfill(
code=req.code,
buy_date=buy_date_for_bf,
sell_date=None,
strategy=str(req.strategy_id or ""),
)
except Exception as _bf_e:
logger.debug("post-sell candle backfill schedule 스킵: %s", _bf_e)
self.invalidate_holdings_cache()
if self.cash_ledger is not None:
gross = filled_qty * sell_price
net = gross * (1.0 - fee_rate - tax_rate)
self.cash_ledger.apply_trade_delta(net, source="trade_delta")
color = LOG_GREEN if (realized_pnl is None or realized_pnl >= 0) else LOG_RED
logger.info(
"%s💸 [매도체결] [%s] %s %s × %d주 @ %d원 | 사유=%s (ODNO=%s)%s",
color, req.strategy_id, req.name, req.code,
filled_qty, int(sell_price), req.reason, ord_no, LOG_RESET,
)
try:
emoji = "🟢" if (realized_pnl is None or realized_pnl >= 0) else "🔴"
pnl_str = f"{realized_pnl:+,.0f}" if realized_pnl is not None else "-"
disp = _strategy_display(req.strategy_id)
header = (
f"{emoji} **[매도체결:{disp}]** {req.name}({req.code})\n"
f"{sell_price:,.0f}× {filled_qty}\n"
f"{req.reason} · 수익률 {req.profit_pct*100:+.2f}%\n"
f"실현 {pnl_str} · ODNO={ord_no}"
)
if self.strategy_pnl_provider is not None:
try:
_sp = self.strategy_pnl_provider(req.strategy_id)
if _sp is not None:
_spnl, _scnt = _sp
header += (
f"\n📊 {disp} 당일 {_spnl:+,.0f}원 · 청산 {_scnt}"
)
except Exception as _e:
logger.debug("strategy_pnl_provider(SELL) 실패: %s", _e)
tail = ""
if self.asset_line_provider is not None:
try:
tail = self.asset_line_provider(
"SELL",
{"realized_pnl": realized_pnl},
) or ""
except Exception as _e:
logger.debug("asset_line_provider(SELL) 실패: %s", _e)
msg = header + ("\n\n" + tail if tail else "")
msg_mm_strategy(
msg,
_strategy_mm_channel(req.strategy_id),
jitter=False,
)
except Exception:
pass
return OrderResult(
True, ord_no=ord_no,
filled_qty=filled_qty, filled_avg_price=sell_price,
request=req,
)
def _lookup_fill(
self,
ord_no: str,
code: str,
*,
fill_map: Optional[Dict[str, Dict]] = None,
wait_sec: float = 0.0,
) -> Optional[Dict]:
"""체결 맵 우선, 없으면 건별 inquire-daily-ccld."""
key = str(ord_no or "").strip()
if fill_map is not None:
hit = fill_map.get(key)
if hit:
row_code = str(hit.get("code") or "").strip()
if code and row_code and row_code != str(code).strip():
return None
return {
"filled_qty": int(hit["filled_qty"]),
"avg_price": float(hit["avg_price"]),
}
return None
return self.client.get_execution_by_odno(
key, code=code, wait_sec=wait_sec,
)
def poll_pending_fills(self) -> int:
"""
heartbeat — 미확인·부분체결 주문 재조회.
처리(체결 반영 또는 만료 취소) 건수 반환.
"""
rows = self.db.get_pending_fill_orders()
if not rows:
return 0
wait_sec = float(get_env_int("PENDING_FILL_POLL_SEC", 1))
handled = 0
fill_map: Optional[Dict[str, Dict]] = None
batch_ok = False
if get_env_bool("PENDING_POLL_BATCH_FETCH", True):
fill_map = self.client.get_today_execution_map(wait_sec=wait_sec)
batch_ok = fill_map is not None
if batch_ok:
logger.debug(
"poll_pending_fills 일괄체결조회 %d건 pending / map %d ODNO",
len(rows), len(fill_map or {}),
)
else:
logger.debug(
"poll_pending_fills 일괄체결조회 실패 → 건별 폴백 (%d건)",
len(rows),
)
fill_map = None
for row in rows:
ord_no = str(row.get("ord_no") or "").strip()
if not ord_no:
continue
req = self._req_from_order_row(row)
if req is None:
continue
order_qty = int(row.get("qty") or req.qty or 0)
prev_filled = int(row.get("filled_qty") or 0)
side = str(row.get("side") or req.side or "").upper()
age = self._order_age_sec(row)
if side == "BUY":
max_age = self._pending_buy_max_age_sec()
else:
max_age = self._pending_sell_max_age_sec(req.reason or "")
row_wait = 0.0 if batch_ok else wait_sec
fill = self._lookup_fill(
ord_no, req.code,
fill_map=fill_map if batch_ok else None,
wait_sec=row_wait,
)
if fill and int(fill.get("filled_qty", 0) or 0) > 0:
filled_qty = int(fill["filled_qty"])
avg_price = float(fill["avg_price"])
if side == "BUY":
if 0 < filled_qty < order_qty:
miss = order_qty - filled_qty
logger.warning(
"%s⚠️ [매수 부분체결·폴링] [%s] %s %s: "
"주문 %d주 → 체결 %d주 (미체결 %d주)%s",
LOG_YELLOW, req.strategy_id, req.name, req.code,
order_qty, filled_qty, miss, LOG_RESET,
)
self._try_cancel_buy_remainder(
ord_no, req.code, miss,
use_limit=bool(getattr(req, "use_limit_buy", False)),
)
self._finalize_buy_fill(
req, ord_no, filled_qty, avg_price, order_qty,
log_tag="매수체결(재조회)",
)
else:
self._finalize_sell_fill(
req, ord_no, filled_qty, avg_price, order_qty,
)
handled += 1
continue
if age < max_age:
continue
# 만료 — 미체결 또는 부분체결 잔량 정리
remain = max(0, order_qty - prev_filled)
if remain > 0:
try:
self.client.cancel_order(ord_no, qty=remain)
except Exception as e:
logger.debug("만료 주문 취소 실패 ord_no=%s: %s", ord_no, e)
if prev_filled <= 0:
self.db.update_order_status(
ord_no=ord_no, strategy_id=req.strategy_id, code=req.code,
status="CANCELLED",
)
logger.warning(
"%s⏱ [체결만료] %s %s ODNO=%s — 미체결 취소 (%.0fs)%s",
LOG_YELLOW, req.name, req.code, ord_no, age, LOG_RESET,
)
handled += 1
if side == "SELL":
self._escalate_urgent_sell_market(
req, order_qty, tag="만료재손절",
)
elif side == "BUY" and prev_filled > 0:
self.db.update_order_status(
ord_no=ord_no, strategy_id=req.strategy_id, code=req.code,
status="PARTIAL",
)
logger.warning(
"%s⏱ [부분체결만료] %s %s ODNO=%s — 잔량 %d주 취소%s",
LOG_YELLOW, req.name, req.code, ord_no, remain, LOG_RESET,
)
handled += 1
return handled
def _record_ghost_purge_history(
self,
code: str,
strategy_id: str,
*,
sell_reason: str = "ghost_purge",
) -> bool:
"""
유령(브로커 0주) 정리 시 trade_history 기록.
실제 매도 체결이 아니므로 realized_pnl=0 (사유=ghost_purge/broker_zero).
"""
if not get_env_bool("GHOST_PURGE_RECORD_HISTORY", True):
self.db.delete_active_trade(code=code, strategy=strategy_id)
return False
try:
from ..utils.strategy_ids import canonical_strategy_id
sid = canonical_strategy_id(strategy_id)
row = None
matched_sid = strategy_id
for try_sid in (sid, strategy_id):
row = self.db.conn.execute(
"SELECT avg_buy_price, current_price FROM active_trades "
"WHERE code=%s AND strategy=%s LIMIT 1",
(code, try_sid),
).fetchone()
if row:
matched_sid = try_sid
break
buy_px = 0.0
mark = 0.0
if row:
d = dict(row)
buy_px = float(d.get("avg_buy_price") or 0)
cur = float(d.get("current_price") or 0)
# 장부용 매도가: 마지막 시세 있으면 사용, 없으면 매수가 (PnL은 0 고정)
mark = cur if cur > 0 else buy_px
if mark <= 0:
mark = buy_px if buy_px > 0 else 1.0
ok = self.db.close_trade(
code=code,
sell_price=mark,
sell_reason=sell_reason,
strategy=matched_sid,
realized_pnl_override=0.0,
)
if not ok:
self.db.delete_active_trade(code=code, strategy=matched_sid)
return bool(ok)
except Exception as e:
logger.warning(
"유령 trade_history 기록 실패 %s [%s]: %s → delete만",
code, strategy_id, e,
)
try:
self.db.delete_active_trade(code=code, strategy=strategy_id)
except Exception:
pass
return False
def _purge_ghost_position(self, req: OrderRequest, log_tag: str) -> OrderResult:
"""
브로커 0주인데 로컬만 남은 포지션 정리.
DB 삭제(+ 선택적 trade_history) + 전략 holdings.pop 트리거(extra.purge_holdings).
동일 (전략, 종목) 은 GHOST_POSITION_COOLDOWN_SEC 동안 재로그·재API 방지.
PENDING 매도 ODNO 가 있으면 유령이 아니라 **미확정 매도** 로 본다.
→ 체결 재조회 / 잔고확정 finalize (PnL 기록). ghost_purge(0원) 금지.
"""
# ── B: PENDING 매도 있으면 유령정리 금지 → 매도 확정 시도 ──
if get_env_bool("GHOST_PURGE_BLOCK_WHILE_PENDING_SELL", True):
try:
pending = self.db.get_pending_sell_order(req.strategy_id, req.code)
except Exception:
pending = None
if pending:
odno = str(pending.get("ord_no") or "").strip()
order_qty = int(pending.get("qty") or req.qty or 0)
wait_sec = float(get_env_int("SELL_LIMIT_RECHECK_WAIT_SEC", 1))
fill = None
if odno:
try:
fill = self.client.get_execution_by_odno(
odno, code=req.code, wait_sec=wait_sec,
)
except Exception as e:
logger.debug(
"유령경로 체결재조회 실패 %s ODNO=%s: %s",
req.code, odno, e,
)
if fill and int(fill.get("filled_qty", 0) or 0) > 0:
fq = int(fill["filled_qty"])
fp = float(fill["avg_price"])
logger.info(
"%s✅ [PENDING매도→체결확정] [%s] %s %s ODNO=%s × %d @ %.0f "
"(유령정리 대신 정상 매도마감)%s",
LOG_GREEN, req.strategy_id, req.name, req.code,
odno, fq, fp, LOG_RESET,
)
return self._finalize_sell_fill(
req, odno, fq, fp, max(order_qty, fq),
)
# 체결 API 빈손 + 잔고 0 = 이미 팔림 (지정가 체결·취소잔량없음 패턴)
px = float(
(fill or {}).get("avg_price")
or req.price_ref
or req.buy_price
or 0
)
qty = order_qty if order_qty > 0 else int(req.qty or 0)
if px > 0 and qty > 0 and odno:
logger.warning(
"%s✅ [PENDING매도→잔고확정청산] [%s] %s %s ODNO=%s × %d @ %.0f "
"(브로커0·체결API미확인 → 유령금지·정상마감)%s",
LOG_GREEN, req.strategy_id, req.name, req.code,
odno, qty, px, LOG_RESET,
)
return self._finalize_sell_fill(req, odno, qty, px, qty)
logger.warning(
"%s⏸ [유령보류] [%s] %s %s — PENDING 매도 ODNO=%s 존재, "
"체결가 미확보 → ghost_purge 스킵%s",
LOG_YELLOW, req.strategy_id, req.name, req.code,
odno or "-", LOG_RESET,
)
return OrderResult(
False,
reason="pending_sell_block_ghost",
request=req,
ord_no=odno or None,
)
key = (req.strategy_id, req.code)
cooldown_sec = get_env_int("GHOST_POSITION_COOLDOWN_SEC", 300)
now = time.time()
last = self._ghost_purged_at.get(key, 0.0)
if last > 0 and (now - last) < cooldown_sec:
remain = int(cooldown_sec - (now - last))
logger.debug(
"⏸ [유령쿨다운] [%s] %s %s%d초 내 재처리 스킵 (%s)",
req.strategy_id, req.name, req.code, remain, log_tag,
)
return OrderResult(
False,
reason="ghost_cooldown",
request=req,
)
self._ghost_purged_at[key] = now
reason_tag = (
"ghost_purge:broker_response"
if log_tag == "broker_response"
else "ghost_purge:broker_zero"
)
if log_tag == "broker_response":
logger.warning(
"%s⚠️ [유령잔고응답] [%s] %s %s: 로컬 정리 (%s)%s",
LOG_YELLOW, req.strategy_id, req.name, req.code, reason_tag, LOG_RESET,
)
else:
logger.warning(
"%s⚠️ [유령잔고정리] [%s] %s %s: 브로커 0주 → 로컬 정리 (%s)%s",
LOG_YELLOW, req.strategy_id, req.name, req.code, reason_tag, LOG_RESET,
)
self._record_ghost_purge_history(
req.code, req.strategy_id, sell_reason=reason_tag,
)
self.invalidate_holdings_cache()
return OrderResult(
False,
reason="broker_no_position",
request=req,
extra={"purge_holdings": True},
)
def _after_limit_sell_need_market(
self,
*,
req: OrderRequest,
ord_no: str,
sell_qty: int,
filled_so_far: int,
wait_sec: float,
limit_price: float = 0.0,
) -> tuple:
"""
익절 지정가 미확인·부분체결 후 시장가 보강.
Returns:
(fill_dict|None, market_ord_no|None, pending:bool)
fill_dict: {"filled_qty", "avg_price"} — 지정가(+시장가) 합산 체결
pending=True 이면 호출부가 PENDING_FILL 로 두고 시장가 금지
"""
remain = max(0, int(sell_qty) - int(filled_so_far))
if remain <= 0:
return None, None, False
use_cancel = get_env_bool("SELL_LIMIT_CANCEL_BEFORE_MARKET_RETRY", True)
recheck_sec = float(get_env_int("SELL_LIMIT_RECHECK_WAIT_SEC", 1))
if not use_cancel:
# 레거시: 즉시 시장가 (이중체결 위험 — 기본 OFF 경로)
logger.warning(
"%s⚠️ [익절지정가→시장가] %s 잔여 %d주 (취소가드 OFF)%s",
LOG_YELLOW, req.code, remain, LOG_RESET,
)
mkt_no = self.client.sell_market_order(req.code, remain)
if not mkt_no:
return None, None, False
mfill = self.client.get_execution_by_odno(
mkt_no, code=req.code, wait_sec=wait_sec,
)
return mfill, mkt_no, False
# ── 근본: 잔여 지정가 취소 → ODNO 재조회 → 필요할 때만 시장가 ──
try:
cancel_ok = bool(
self.client.cancel_order(str(ord_no), qty=remain)
)
except Exception as e:
cancel_ok = False
logger.warning(
"익절지정가 취소 예외 %s ODNO=%s: %s", req.code, ord_no, e,
)
logger.info(
"%s🛑 [익절지정가취소] %s ODNO=%s 잔여=%d 결과=%s%s",
LOG_CYAN, req.code, ord_no, remain,
"OK" if cancel_ok else "FAIL", LOG_RESET,
)
# 취소 직전·직후 체결됐을 수 있음 → 같은 ODNO 재조회
refill = self.client.get_execution_by_odno(
ord_no, code=req.code, wait_sec=recheck_sec,
)
refill_qty = int((refill or {}).get("filled_qty", 0) or 0)
refill_px = float((refill or {}).get("avg_price", 0) or 0)
if refill_qty >= sell_qty and refill_px > 0:
logger.info(
"%s✅ [익절지정가 재확인체결] %s ODNO=%s × %d주 @ %.0f — 시장가 생략%s",
LOG_GREEN, req.code, ord_no, refill_qty, refill_px, LOG_RESET,
)
return (
{"filled_qty": refill_qty, "avg_price": refill_px},
None,
False,
)
still_need = max(0, sell_qty - max(filled_so_far, refill_qty))
if still_need <= 0 and refill_qty > 0 and refill_px > 0:
return (
{"filled_qty": refill_qty, "avg_price": refill_px},
None,
False,
)
if not cancel_ok:
# 취소 실패 = 이미 체결·처리 중 가능 → 시장가 금지 (이중매도 방지)
# A: ODNO 미확인이어도 잔고가 이미 0이면 지정가 체결로 finalize
if get_env_bool("SELL_LIMIT_CANCEL_FAIL_BALANCE_CONFIRM", True):
try:
self.invalidate_holdings_cache()
real_map = self.get_broker_holdings(force=True)
real_qty = int(
(real_map.get(req.code) or {}).get("qty", 0) or 0
)
except Exception as e:
real_qty = -1
logger.debug("취소실패 잔고확인 예외 %s: %s", req.code, e)
if real_qty == 0:
px = refill_px if refill_px > 0 else float(
limit_price or req.price_ref or req.buy_price or 0
)
if px > 0:
logger.info(
"%s✅ [익절지정가 잔고확정체결] %s ODNO=%s × %d주 @ %.0f "
"(취소실패·브로커0 → 시장가생략·정상마감)%s",
LOG_GREEN, req.code, ord_no, sell_qty, px, LOG_RESET,
)
return (
{"filled_qty": int(sell_qty), "avg_price": px},
None,
False,
)
logger.warning(
"%s⏸ [익절시장가보류] %s ODNO=%s — 취소실패·체결미확정 → PENDING "
"(시장가 재시도 금지)%s",
LOG_YELLOW, req.code, ord_no, LOG_RESET,
)
if refill_qty > 0 and refill_px > 0:
return (
{"filled_qty": refill_qty, "avg_price": refill_px},
None,
refill_qty < sell_qty, # 부분만 보이면 pending
)
return None, None, True
# 취소 성공 + 아직 잔여 → 시장가
logger.warning(
"%s⚠️ [익절지정가 미체결→시장가] %s 잔여 %d%s",
LOG_YELLOW, req.code, still_need, LOG_RESET,
)
mkt_no = self.client.sell_market_order(req.code, still_need)
if not mkt_no:
if refill_qty > 0 and refill_px > 0:
return (
{"filled_qty": refill_qty, "avg_price": refill_px},
None,
True,
)
return None, None, True
mfill = self.client.get_execution_by_odno(
mkt_no, code=req.code, wait_sec=wait_sec,
)
if not mfill or int(mfill.get("filled_qty", 0) or 0) <= 0:
if refill_qty > 0 and refill_px > 0:
return (
{"filled_qty": refill_qty, "avg_price": refill_px},
mkt_no,
True,
)
return None, mkt_no, True
add_q = int(mfill["filled_qty"])
add_p = float(mfill["avg_price"])
prev_q = max(int(filled_so_far), int(refill_qty))
prev_p = refill_px if refill_qty > 0 and refill_px > 0 else 0.0
if prev_q > 0 and prev_p > 0:
tot_q = prev_q + add_q
tot_p = (prev_p * prev_q + add_p * add_q) / tot_q if tot_q > 0 else add_p
else:
tot_q = add_q
tot_p = add_p
return {"filled_qty": tot_q, "avg_price": tot_p}, mkt_no, False
# ------------------------------------------------------------------
# 공개 API
# ------------------------------------------------------------------
def place(self, req: OrderRequest) -> OrderResult:
"""전략이 호출하는 유일한 진입점. BUY / SELL 모두 처리."""
side = (req.side or "").upper()
if self._is_overseas_request(req):
if side == "BUY":
return self._place_overseas_buy(req)
if side == "SELL":
return self._place_overseas_sell(req)
return OrderResult(success=False, reason=f"invalid side={req.side}", request=req)
if side == "BUY":
return self._place_buy(req)
if side == "SELL":
return self._place_sell(req)
return OrderResult(success=False, reason=f"invalid side={req.side}", request=req)
@staticmethod
def _is_overseas_request(req: OrderRequest) -> bool:
mkt = str(req.market or "").strip().upper()
if mkt in ("US", "OVERSEAS", "OVRS"):
return True
if str(req.currency or "").strip().upper() == "USD":
return True
if str(req.exchange or "").strip():
return True
sid = str(req.strategy_id or "").upper()
return sid.startswith("US_")
def _overseas_exchange(self, req: OrderRequest) -> str:
ex = str(req.exchange or "").strip().upper()
if ex:
return ex
return str(
get_env_from_db("KIS_OVRS_DEFAULT_EXCG", "NASD") or "NASD"
).strip().upper() or "NASD"
def _place_overseas_buy(self, req: OrderRequest) -> OrderResult:
"""해외 지정가 매수 → orders/active_trades + MM (접수=FILLED 추적)."""
if req.qty <= 0:
return OrderResult(False, reason="qty<=0", request=req)
px = float(req.price_ref or 0)
if px <= 0:
return OrderResult(False, reason="price<=0", request=req)
code = str(req.code or "").strip().upper()
if not code:
return OrderResult(False, reason="empty_code", request=req)
exchange = self._overseas_exchange(req)
slip = abs(float(get_env_float("KIS_OVRS_BUY_LIMIT_SLIPPAGE_PCT", 0.3) or 0))
limit_px = px * (1.0 + slip / 100.0) if slip > 0 else px
with self._lock_for(code):
from kis_trader.utils.api_reject_log import (
mark_order_cooldown,
order_cooldown_remaining,
record_api_reject,
)
rem = order_cooldown_remaining(
side="BUY", code=code, strategy_id=str(req.strategy_id or ""),
)
if rem > 0:
# journal/JSONL 도배 방지 — remain 값은 fingerprint에 넣지 않음
logger.debug(
"⏳ [해외매수] 쿨다운 스킵 [%s] %s remain=%.0fs",
req.strategy_id, code, rem,
)
record_api_reject(
kind="overseas_buy_cooldown_skip",
side="BUY",
code=code,
strategy_id=str(req.strategy_id or ""),
msg_cd="COOLDOWN",
msg1="order_api_skipped",
path="order_manager",
extra={"remain_sec": int(rem)},
)
return OrderResult(
False,
reason="overseas_buy_fail:cooldown",
request=req,
extra={"cooldown_remain_sec": int(rem)},
)
if not hasattr(self.client, "buy_overseas_limit"):
return OrderResult(False, reason="no_overseas_buy", request=req)
ord_no = self.client.buy_overseas_limit(
code, int(req.qty), float(limit_px), exchange=exchange,
)
if not ord_no:
msg_cd = str(getattr(self.client, "_last_order_msg_cd", "") or "")
msg1 = str(getattr(self.client, "_last_order_msg1", "") or "")
logger.warning(
"⚠️ [해외매수실패] [%s] %s qty=%s msg_cd=%s | %s",
req.strategy_id, code, req.qty, msg_cd or "-", (msg1 or "")[:100],
)
until = mark_order_cooldown(
side="BUY",
code=code,
strategy_id=str(req.strategy_id or ""),
msg_cd=msg_cd,
msg1=msg1,
http=500 if str(msg_cd).startswith("HTTP_5") else None,
)
if until:
logger.warning(
"🧊 [해외매수] 영구형 거절 → %.0f초 쿨다운 [%s] %s msg_cd=%s",
max(0.0, until - time.time()),
req.strategy_id, code, msg_cd or "-",
)
return OrderResult(
False,
reason=f"overseas_buy_fail:{msg_cd or 'reject'}",
request=req,
extra={"msg_cd": msg_cd, "msg1": msg1},
)
feats = dict(req.entry_features or {})
feats["exchange"] = exchange
feats["overseas"] = True
req.entry_features = feats
req.market = "US"
req.currency = "USD"
req.exchange = exchange
self.db.insert_order(
ord_no=str(ord_no),
strategy_id=req.strategy_id,
code=code,
name=req.name or code,
side="BUY",
qty=int(req.qty),
price=float(px),
status="SUBMITTED",
msg1="overseas_limit_accept",
)
return self._finalize_overseas_buy_fill(
req, str(ord_no), int(req.qty), float(px), int(req.qty),
)
def _place_overseas_sell(self, req: OrderRequest) -> OrderResult:
"""해외 지정가 매도 → orders + close_trade + MM."""
if req.qty <= 0:
return OrderResult(False, reason="qty<=0", request=req)
px = float(req.price_ref or 0)
if px <= 0:
return OrderResult(False, reason="price<=0", request=req)
code = str(req.code or "").strip().upper()
if not code:
return OrderResult(False, reason="empty_code", request=req)
exchange = self._overseas_exchange(req)
slip = abs(float(get_env_float("KIS_OVRS_SELL_LIMIT_SLIPPAGE_PCT", 0.3) or 0))
limit_px = px * (1.0 - slip / 100.0) if slip > 0 else px
if limit_px <= 0:
limit_px = px
with self._lock_for(code):
from kis_trader.utils.api_reject_log import (
mark_order_cooldown,
order_cooldown_remaining,
record_api_reject,
)
rem = order_cooldown_remaining(
side="SELL", code=code, strategy_id=str(req.strategy_id or ""),
)
if rem > 0:
# journal/JSONL 도배 방지 — remain 값은 fingerprint에 넣지 않음
logger.debug(
"⏳ [해외매도] 쿨다운 스킵 [%s] %s remain=%.0fs",
req.strategy_id, code, rem,
)
record_api_reject(
kind="overseas_sell_cooldown_skip",
side="SELL",
code=code,
strategy_id=str(req.strategy_id or ""),
msg_cd="COOLDOWN",
msg1="order_api_skipped",
path="order_manager",
extra={"remain_sec": int(rem)},
)
return OrderResult(
False,
reason="overseas_sell_fail:cooldown",
request=req,
extra={"cooldown_remain_sec": int(rem)},
)
if not hasattr(self.client, "sell_overseas_limit"):
return OrderResult(False, reason="no_overseas_sell", request=req)
ord_no = self.client.sell_overseas_limit(
code, int(req.qty), float(limit_px), exchange=exchange,
)
if not ord_no:
# 매도는 client 가 _last_sell_msg_* 에 기록 (매수 _last_order_msg_* 와 분리)
msg_cd = str(getattr(self.client, "_last_sell_msg_cd", "") or "")
msg1 = str(getattr(self.client, "_last_sell_msg1", "") or "")
if not msg_cd and not msg1:
msg_cd = str(getattr(self.client, "_last_order_msg_cd", "") or "")
msg1 = str(getattr(self.client, "_last_order_msg1", "") or "")
logger.warning(
"⚠️ [해외매도실패] [%s] %s qty=%s msg_cd=%s | %s",
req.strategy_id, code, req.qty, msg_cd or "-", (msg1 or "")[:100],
)
until = mark_order_cooldown(
side="SELL",
code=code,
strategy_id=str(req.strategy_id or ""),
msg_cd=msg_cd,
msg1=msg1,
http=500 if str(msg_cd).startswith("HTTP_5") else None,
)
if until:
logger.warning(
"🧊 [해외매도] 영구형 거절 → %.0f초 쿨다운 [%s] %s msg_cd=%s",
max(0.0, until - time.time()),
req.strategy_id, code, msg_cd or "-",
)
return OrderResult(
False,
reason=f"overseas_sell_fail:{msg_cd or 'reject'}",
request=req,
extra={"msg_cd": msg_cd, "msg1": msg1},
)
req.market = "US"
req.currency = "USD"
req.exchange = exchange
self.db.insert_order(
ord_no=str(ord_no),
strategy_id=req.strategy_id,
code=code,
name=req.name or code,
side="SELL",
qty=int(req.qty),
price=float(px),
status="SUBMITTED",
msg1="overseas_limit_accept",
)
return self._finalize_overseas_sell_fill(
req, str(ord_no), int(req.qty), float(px), int(req.qty),
)
def _finalize_overseas_buy_fill(
self,
req: OrderRequest,
ord_no: str,
filled_qty: int,
filled_price: float,
order_qty: int,
*,
log_tag: str = "해외매수",
) -> OrderResult:
"""해외 매수 접수=체결 추적 → active_trades + MM($)."""
if filled_qty <= 0 or filled_price <= 0:
return OrderResult(False, ord_no=ord_no, reason="zero_fill", request=req)
self._resolve_order_display_name(req)
status = "FILLED" if filled_qty >= order_qty else "PARTIAL"
self.db.update_order_fill(
ord_no=ord_no,
strategy_id=req.strategy_id,
code=req.code,
filled_qty=filled_qty,
filled_avg_price=filled_price,
status=status,
)
now_str = dt.now().strftime("%Y-%m-%d %H:%M:%S")
from ..utils.strategy_ids import canonical_strategy_id
feats = dict(req.entry_features or {})
feats.setdefault("overseas", True)
if req.exchange:
feats["exchange"] = req.exchange
self.db.upsert_trade({
"code": req.code,
"name": req.name,
"strategy": canonical_strategy_id(req.strategy_id),
"avg_buy_price": filled_price,
"current_price": filled_price,
"stop_price": req.stop_price,
"target_price": req.target_price,
"max_price": filled_price,
"atr_entry": req.atr_entry,
"target_qty": filled_qty,
"current_qty": filled_qty,
"total_invested": filled_price * filled_qty,
"status": "HOLDING",
"buy_date": now_str,
"size_class": req.size_class or "",
"entry_features": feats,
})
self.invalidate_holdings_cache()
logger.info(
"%s✅ [%s] [%s] %s %s @ $%.4f × %d (ODNO=%s excg=%s)%s",
LOG_GREEN, log_tag, req.strategy_id, req.name, req.code,
filled_price, filled_qty, ord_no, req.exchange or "-", LOG_RESET,
)
try:
disp = _strategy_display(req.strategy_id)
header = (
f"🔷 **[{log_tag}:{disp}]** {req.name}({req.code})\n"
f"${filled_price:.4f} × {filled_qty}주 = ${filled_price * filled_qty:.2f}\n"
f"손절 ${req.stop_price:.4f} / 목표 ${req.target_price:.4f}\n"
f"거래소 {req.exchange or '-'} · ODNO={ord_no}"
)
tail = ""
if self.asset_line_provider is not None:
try:
tail = self.asset_line_provider("BUY", {"currency": "USD"}) or ""
except Exception as _e:
logger.debug("asset_line_provider(BUY overseas) 실패: %s", _e)
msg = header + ("\n\n" + tail if tail else "")
msg_mm_strategy(
msg,
_strategy_mm_channel(req.strategy_id),
jitter=False,
)
except Exception:
pass
return OrderResult(
True, ord_no=ord_no,
filled_qty=filled_qty, filled_avg_price=filled_price,
request=req,
)
def _finalize_overseas_sell_fill(
self,
req: OrderRequest,
ord_no: str,
filled_qty: int,
sell_price: float,
order_qty: int,
) -> OrderResult:
"""해외 매도 접수=체결 → close_trade + MM($)."""
if filled_qty <= 0 or sell_price <= 0:
return OrderResult(False, ord_no=ord_no, reason="zero_sell_fill", request=req)
self._resolve_order_display_name(req)
status = "FILLED" if filled_qty >= order_qty else "PARTIAL"
self.db.update_order_fill(
ord_no=ord_no,
strategy_id=req.strategy_id,
code=req.code,
filled_qty=filled_qty,
filled_avg_price=sell_price,
status=status,
)
# US_MOMENTUM_* 우선 (수수료·SEC·환전). 없으면 한투 미국 온라인 기본.
try:
from kis_trader.engine.us_momentum_env_keys import us_momentum_trading_cost_rates
_c = us_momentum_trading_cost_rates()
fee_rate = float(_c["fee_rate"])
tax_rate = float(_c["sell_tax"])
fx_rate = float(_c["fx_fee_rate"])
except Exception:
fee_rate = float(get_env_float("US_MOMENTUM_FEE_RATE", 0.0025) or 0.0025)
tax_rate = float(get_env_float("US_MOMENTUM_SELL_TAX", 0.0000206) or 0.0000206)
fx_rate = float(get_env_float("US_MOMENTUM_FX_FEE_RATE", 0.0005) or 0.0005)
if fee_rate > 1.0:
fee_rate = fee_rate / 100.0
if tax_rate > 1.0:
tax_rate = tax_rate / 100.0
if fx_rate > 1.0:
fx_rate = fx_rate / 100.0
buy_price = float(req.buy_price or 0)
realized_pnl = None
if buy_price > 0:
buy_amt = buy_price * filled_qty
sell_amt = sell_price * filled_qty
fees = (
buy_amt * fee_rate
+ sell_amt * (fee_rate + tax_rate)
+ (buy_amt + sell_amt) * fx_rate
)
realized_pnl = (sell_amt - buy_amt) - fees
from ..utils.strategy_ids import canonical_strategy_id
sid = canonical_strategy_id(req.strategy_id)
ok_close = False
try:
ok_close = bool(self.db.close_trade(
code=req.code,
sell_price=float(sell_price),
sell_reason=str(req.reason or "overseas"),
strategy=sid,
realized_pnl_override=realized_pnl,
))
except Exception as e:
logger.error("overseas close_trade 실패 %s: %s", req.code, e)
self.invalidate_holdings_cache()
color = LOG_GREEN if (realized_pnl is None or realized_pnl >= 0) else LOG_RED
logger.info(
"%s💸 [해외매도] [%s] %s %s × %d @ $%.4f | 사유=%s (ODNO=%s close=%s)%s",
color, req.strategy_id, req.name, req.code,
filled_qty, sell_price, req.reason, ord_no, ok_close, LOG_RESET,
)
try:
emoji = "🟢" if (realized_pnl is None or realized_pnl >= 0) else "🔴"
pnl_str = f"{realized_pnl:+.2f}$" if realized_pnl is not None else "-"
disp = _strategy_display(req.strategy_id)
header = (
f"{emoji} **[해외매도:{disp}]** {req.name}({req.code})\n"
f"${sell_price:.4f} × {filled_qty}\n"
f"{req.reason} · 수익률 {req.profit_pct * 100:+.2f}%\n"
f"실현 {pnl_str} · ODNO={ord_no}"
)
if self.strategy_pnl_provider is not None:
try:
_sp = self.strategy_pnl_provider(req.strategy_id)
if _sp is not None:
_spnl, _scnt = _sp
header += f"\n📊 {disp} 당일 {_spnl:+.2f} · 청산 {_scnt}"
except Exception as _e:
logger.debug("strategy_pnl_provider(SELL overseas) 실패: %s", _e)
tail = ""
if self.asset_line_provider is not None:
try:
tail = self.asset_line_provider(
"SELL",
{"realized_pnl": realized_pnl, "currency": "USD"},
) or ""
except Exception as _e:
logger.debug("asset_line_provider(SELL overseas) 실패: %s", _e)
msg = header + ("\n\n" + tail if tail else "")
msg_mm_strategy(
msg,
_strategy_mm_channel(req.strategy_id),
jitter=False,
)
except Exception:
pass
return OrderResult(
True, ord_no=ord_no,
filled_qty=filled_qty, filled_avg_price=sell_price,
request=req,
extra={"close_ok": ok_close, "realized_pnl": realized_pnl},
)
# ------------------------------------------------------------------
# 매수
# ------------------------------------------------------------------
def _place_buy(self, req: OrderRequest) -> OrderResult:
if req.qty <= 0:
return OrderResult(False, reason="qty<=0", request=req)
with self._lock_for(req.code):
# ── [1] 같은 종목 다른 전략 보유 여부 체크 ─────────────────
policy = str(get_env_from_db("STRATEGY_SAME_CODE_POLICY", "allow")).lower()
if policy == "block":
# 다른 전략이 이미 active_trades 에 들고 있으면 차단
blocked = self._is_code_held_by_other_strategy(
req.code, req.strategy_id
)
if blocked:
logger.warning(
"%s🚫 [중복종목차단] %s %s → 다른 전략(%s) 보유 중 → 매수 스킵%s",
LOG_YELLOW, req.name, req.code, blocked, LOG_RESET,
)
return OrderResult(False, reason=f"held_by_{blocked}", request=req)
# ── [2] 매수 전 검증 (선택) ─────────────────────────────────
# ※ 계좌 잔고는 종목당 통짜이지만, 봇은 (code, strategy) + ODNO 로 전략별 qty 를 관리.
# 타 전략·수동(MANUAL) 보유가 있어도 추가 매수 가능. 매도는 요청 qty 만 청산.
if get_env_bool("REAL_BALANCE_VERIFY_BEFORE_BUY", False):
mode = str(
get_env_from_db("REAL_BALANCE_VERIFY_BEFORE_BUY_MODE", "strategy")
).lower()
if mode == "global":
real_map = self.get_broker_holdings()
if req.code in real_map and real_map[req.code]["qty"] > 0:
logger.warning(
"%s🚫 [이미실보유-global] %s %s × %d주 → 매수 스킵%s",
LOG_YELLOW, req.name, req.code,
real_map[req.code]["qty"], LOG_RESET,
)
return OrderResult(
False, reason="already_held_broker", request=req,
)
elif mode == "strategy":
try:
row = self.db.conn.execute(
"SELECT current_qty FROM active_trades "
"WHERE code=%s AND strategy=%s",
(req.code, req.strategy_id),
).fetchone()
db_qty = 0
if row:
db_qty = int(float(
row.get("current_qty")
if isinstance(row, dict)
else row[0]
) or 0)
if db_qty > 0:
logger.warning(
"%s🚫 [이미전략보유] [%s] %s %s DB %d주 → 매수 스킵%s",
LOG_YELLOW, req.strategy_id, req.name, req.code,
db_qty, LOG_RESET,
)
return OrderResult(
False, reason="already_held_strategy", request=req,
)
except Exception as e:
logger.debug("매수 전 전략 보유 조회 실패(%s): %s", req.code, e)
# ── [2a] 동일 종목 미체결 매수 있으면 중복 주문 방지 ───────
# 체결확인 API(inquire-daily-ccld) 장애로 PENDING_FILL 이 쌓이면,
# 전략 루프가 같은 종목을 매 턴(10초) 재주문하는 폭주가 발생한다.
# → 미체결 매수 1건이라도 있으면 스킵. 만료 시 poll 이 CANCELLED 처리해 자동 해제.
if get_env_bool("BUY_DEDUP_PENDING", True):
pending_buy = self.db.get_pending_buy_order(req.strategy_id, req.code)
if pending_buy:
pend_no = str(pending_buy.get("ord_no") or "")
logger.info(
"%s⏸ [매수대기중] [%s] %s %s — 기존 ODNO=%s 체결확인 대기 (재주문 스킵)%s",
LOG_YELLOW, req.strategy_id, req.name, req.code,
pend_no, LOG_RESET,
)
return OrderResult(
False,
ord_no=pend_no or None,
reason="buy_pending_dup",
request=req,
)
# ── [2b] 예수금 부족 시에만 qty 축소 (기존 전략 산식 우선) ─────
buy_qty = req.qty
if self.cash_ledger is not None:
buy_qty, cash_reason = self.cash_ledger.clamp_buy_qty_if_insufficient(
req.qty, req.price_ref, req.code, req.name, req.strategy_id,
)
if buy_qty <= 0:
return OrderResult(
False,
reason=cash_reason or "insufficient_cash",
request=req,
)
# ── [3] 주문 전송 (지정가 / 시장가) ─────────────────────
if req.use_limit_buy and req.price_ref > 0:
limit_px = int(req.price_ref)
ord_no = self.client.buy_limit_order(req.code, buy_qty, limit_px)
if ord_no:
logger.info(
"📤 [지정가매수] %s %s × %d주 @ %s",
req.name, req.code, buy_qty, f"{limit_px:,}",
)
else:
ord_no = self.client.buy_market_order(req.code, buy_qty)
if not ord_no:
cd = self.client._last_order_msg_cd or ""
m1 = self.client._last_order_msg1 or ""
reason = f"order_reject:{cd or m1}"
logger.error(
"%s❌ [매수주문거부] %s %s: %s%s",
LOG_RED, req.name, req.code, m1 or cd, LOG_RESET,
)
return OrderResult(False, reason=reason, request=req)
# ── [4] DB에 주문 기록 (ODNO PK, 전략단위 UNIQUE) ──────────
inserted = self.db.insert_order(
ord_no=ord_no,
strategy_id=req.strategy_id,
code=req.code, name=req.name,
side="BUY", qty=buy_qty,
price=req.price_ref,
status="SUBMITTED",
raw_json=self._buy_pending_meta(req),
)
if not inserted:
# ord_no PK 중복(동일 ODNO 재전송) — 브로커 주문은 이미 나갔을 수 있음
logger.error(
"%s❌ [주문DB중복] strategy=%s %s ord_no=%s"
"브로커 체결 여부 확인 필요 (active_trades 미반영 가능)%s",
LOG_RED, req.strategy_id, req.code, ord_no, LOG_RESET,
)
recovered = self._try_recover_duplicate_buy_fill(
req, ord_no, buy_qty,
)
if recovered is not None:
return recovered
return OrderResult(
False, ord_no=ord_no, reason="duplicate_order_record", request=req,
)
# ── [5] 체결 확인 ────────────────────────────────────────
if req.use_limit_buy:
wait_sec = float(get_env_int("LIMIT_ORDER_FILL_WAIT_SEC", 1))
else:
wait_sec = float(get_env_int("ORDER_FILL_WAIT_SEC", 2))
fill = self.client.get_execution_by_odno(ord_no, code=req.code, wait_sec=wait_sec)
filled_qty = 0
filled_price = 0.0
if fill and int(fill.get("filled_qty", 0) or 0) > 0:
filled_qty = int(fill["filled_qty"])
filled_price = float(fill["avg_price"])
if 0 < filled_qty < buy_qty:
miss = buy_qty - filled_qty
logger.warning(
"%s⚠️ [매수 부분체결] [%s] %s %s: 주문 %d주 → 체결 %d주 (미체결 %d주, ODNO=%s)%s",
LOG_YELLOW, req.strategy_id, req.name, req.code,
buy_qty, filled_qty, miss, ord_no, LOG_RESET,
)
self._try_cancel_buy_remainder(
ord_no, req.code, miss, use_limit=req.use_limit_buy,
)
elif req.use_limit_buy:
return OrderResult(
success=True,
ord_no=ord_no,
filled_qty=0,
filled_avg_price=0.0,
reason="limit_pending",
request=req,
)
elif self._strict_fill_required():
self.db.update_order_status(
ord_no=ord_no, strategy_id=req.strategy_id, code=req.code,
status="PENDING_FILL",
)
logger.warning(
"%s⏳ [매수체결대기] [%s] %s %s ODNO=%s — fill 미확인, heartbeat 재조회%s",
LOG_YELLOW, req.strategy_id, req.name, req.code, ord_no, LOG_RESET,
)
return OrderResult(
False,
ord_no=ord_no,
reason="fill_pending",
request=req,
)
else:
# 레거시 모의: 체결 미확인 시 참고가로 가정 체결
filled_qty = buy_qty
filled_price = req.price_ref
self.db.update_order_fill(
ord_no=ord_no,
strategy_id=req.strategy_id,
code=req.code,
filled_qty=filled_qty,
filled_avg_price=filled_price,
status="SUBMITTED",
)
if filled_qty <= 0:
return OrderResult(
False, ord_no=ord_no, reason="zero_fill", request=req,
)
# ── [6] active_trades upsert (전략별 독립 row) ────────────
return self._finalize_buy_fill(
req, ord_no, filled_qty, filled_price, buy_qty,
)
def try_finalize_limit_buy(self, req: OrderRequest, ord_no: str) -> OrderResult:
"""미체결 지정가 — 체결됐으면 active_trades 반영."""
odno = str(ord_no or "").strip()
if not odno or req.qty <= 0:
return OrderResult(False, reason="invalid_limit_finalize", request=req)
wait_sec = float(get_env_int("LIMIT_ORDER_FILL_POLL_SEC", 1))
fill = self.client.get_execution_by_odno(odno, code=req.code, wait_sec=wait_sec)
if not fill or int(fill.get("filled_qty", 0) or 0) <= 0:
return OrderResult(False, reason="not_filled_yet", request=req)
filled_qty = int(fill["filled_qty"])
filled_price = float(fill["avg_price"])
if 0 < filled_qty < req.qty:
miss = req.qty - filled_qty
logger.warning(
"%s⚠️ [지정가 부분체결] [%s] %s %s: %d/%d%s",
LOG_YELLOW, req.strategy_id, req.name, req.code,
filled_qty, req.qty, LOG_RESET,
)
self._try_cancel_buy_remainder(
odno, req.code, miss, use_limit=True,
)
return self._finalize_buy_fill(
req, odno, filled_qty, filled_price, req.qty,
log_tag="지정가체결",
)
# ------------------------------------------------------------------
# 매도
# ------------------------------------------------------------------
def _place_sell(self, req: OrderRequest) -> OrderResult:
if req.qty <= 0:
return OrderResult(False, reason="qty<=0", request=req)
with self._lock_for(req.code):
# ── [1] 매도 실패 백오프 ───────────────────────────────────
backoff_until = self._sell_backoff.get(req.code, 0.0)
if time.time() < backoff_until:
remain = int(backoff_until - time.time())
logger.debug(
"⏸ [매도백오프] %s(%s) — %d초 남음",
req.name, req.code, remain,
)
return OrderResult(False, reason="sell_backoff", request=req)
# ── [2] 실계좌 잔고 재검증 ─────────────────────────────────
# force=True 는 전략 루프 시작 시 prefetch_broker_holdings() 1회만.
# 여기서는 TTL 캐시 공유 → N종목 매도 시 inquire-balance 폭주 방지.
real_qty = None
if get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True):
real_map = self.get_broker_holdings(force=False)
if not self._holdings_last_fetch_ok:
logger.warning(
"%s⏸ [매도보류] [%s] %s %s — 잔고 API 불가 (유령정리 스킵)%s",
LOG_YELLOW, req.strategy_id, req.name, req.code, LOG_RESET,
)
return OrderResult(False, reason="balance_unavailable", request=req)
real_row = real_map.get(req.code)
real_qty = int((real_row or {}).get("qty", 0))
if real_qty <= 0:
return self._purge_ghost_position(req, "broker_zero")
# 실제 보유수량 > 요청수량이면 요청수량만 매도 (다른 전략 몫 보호)
sell_qty = min(req.qty, real_qty)
if sell_qty < req.qty:
# 원인: (1) 모의서버 매수 부분체결 (2) 잔고 반영 지연
# → 실 브로커 수량 기준으로만 매도 (안전)
logger.warning(
"%s⚠️ [매도수량조정] [%s] %s %s: 요청 %d주 → 실보유 %d"
"(미체결 매수 %d주 추정 — 모의서버 부분체결 가능)%s",
LOG_YELLOW, req.strategy_id, req.name, req.code,
req.qty, sell_qty, req.qty - sell_qty, LOG_RESET,
)
else:
sell_qty = req.qty
# ── [2b] 동일 종목 미체결 매도 있으면 중복 주문 방지 ───────
pending_sell = self.db.get_pending_sell_order(req.strategy_id, req.code)
if pending_sell:
pend_no = str(pending_sell.get("ord_no") or "")
logger.info(
"%s⏸ [매도대기중] [%s] %s %s — 기존 ODNO=%s (poll 재조회)%s",
LOG_YELLOW, req.strategy_id, req.name, req.code,
pend_no, LOG_RESET,
)
return OrderResult(
False,
ord_no=pend_no or None,
reason="sell_order_pending",
request=req,
)
# ── [3] 주문 전송 (익절·수익청산만 호가 지정가, 손절·긴급은 시장가) ──
limit_price = 0
use_limit = False
if (
get_env_bool("SELL_USE_ORDERBOOK_ON_PROFIT", True)
and is_profit_take_sell_reason(req.reason or "")
):
ob_raw = None
ob_src = "none"
if self.orderbook_provider:
try:
ob_raw, ob_src = resolve_orderbook_raw(
req.code,
ws_get=self.orderbook_provider,
)
except Exception:
ob_raw = None
if not ob_raw:
ob_raw, ob_src = resolve_orderbook_raw(
req.code,
rest_client=self.client,
)
ok_ob, limit_price, ob_msg = evaluate_take_profit_limit_sell(
sell_qty, ob_raw,
)
if ok_ob and limit_price > 0:
use_limit = True
elif ob_msg == "orderbook_thin":
logger.info(
"%s⏸ [익절대기] %s %s — 매수호가 잔량 부족(%s), 다음 틱 재시도%s",
LOG_YELLOW, req.name, req.code, ob_src, LOG_RESET,
)
return OrderResult(False, reason="orderbook_thin", request=req)
if use_limit:
ord_no = self.client.sell_limit_order(req.code, sell_qty, limit_price)
logger.info(
"📤 [익절지정가] %s %s × %d주 @ %s원 (매수1호가)",
req.name, req.code, sell_qty, f"{limit_price:,}",
)
else:
ord_no = self.client.sell_market_order(req.code, sell_qty)
if not ord_no:
cd = self.client._last_sell_msg_cd or ""
m1 = self.client._last_sell_msg1 or ""
non_biz = {"40100000", "40200000", "APBK0013", "APBK0962", "40910000"}
if cd in non_biz or any(k in m1 for k in ("영업일", "장외", "시장")):
backoff_sec = get_env_int("SELL_FAILURE_BACKOFF_SEC", 1800)
self._sell_backoff[req.code] = time.time() + backoff_sec
logger.warning(
"⏸ [%s(%s)] 매도 실패(%s) → %d초 백오프",
req.name, req.code, m1 or cd, backoff_sec,
)
elif "잔고" in m1 or "보유" in m1 or "APBK3020" in cd:
return self._purge_ghost_position(req, "broker_response")
return OrderResult(False, reason=f"sell_reject:{cd or m1}", request=req)
# ── [4] 주문 기록 ────────────────────────────────────────
self.db.insert_order(
ord_no=ord_no, strategy_id=req.strategy_id,
code=req.code, name=req.name,
side="SELL", qty=sell_qty,
price=req.price_ref,
status="SUBMITTED",
raw_json=self._sell_pending_meta(req),
)
# ── [5] 체결 확인 ─────────────────────────────────────────
wait_sec = float(get_env_int("ORDER_FILL_WAIT_SEC", 2))
fill = self.client.get_execution_by_odno(ord_no, code=req.code, wait_sec=wait_sec)
sell_price = 0.0
filled_qty = 0
if fill and int(fill.get("filled_qty", 0) or 0) > 0:
filled_qty = int(fill["filled_qty"])
sell_price = float(fill["avg_price"])
elif self._strict_fill_required():
self.db.update_order_status(
ord_no=ord_no, strategy_id=req.strategy_id, code=req.code,
status="PENDING_FILL",
)
logger.warning(
"%s⏳ [매도체결대기] [%s] %s %s ODNO=%s — fill 미확인%s",
LOG_YELLOW, req.strategy_id, req.name, req.code, ord_no, LOG_RESET,
)
return OrderResult(
False, ord_no=ord_no, reason="sell_fill_pending", request=req,
)
else:
# 레거시 모의: 참고가로 가정 체결
sell_price = req.price_ref or req.buy_price
filled_qty = sell_qty
self.db.update_order_fill(
ord_no=ord_no, strategy_id=req.strategy_id, code=req.code,
filled_qty=filled_qty,
filled_avg_price=sell_price, status="SUBMITTED",
)
# 익절 지정가 미확인·부분체결 → 취소 후 재조회, 필요할 때만 시장가
# (지정가 체결 중 시장가 재시도 → 타전략 몫까지 이중매도 방지)
if use_limit and (
not fill
or int(fill.get("filled_qty", 0) or 0) < sell_qty
):
filled_so_far = int((fill or {}).get("filled_qty", 0) or 0)
merged, _mkt_no, pending = self._after_limit_sell_need_market(
req=req,
ord_no=ord_no,
sell_qty=sell_qty,
filled_so_far=filled_so_far,
wait_sec=wait_sec,
limit_price=float(limit_price or 0),
)
if merged and int(merged.get("filled_qty", 0) or 0) > 0:
filled_qty = int(merged["filled_qty"])
sell_price = float(merged["avg_price"])
fill = {"filled_qty": filled_qty, "avg_price": sell_price}
self.db.update_order_fill(
ord_no=ord_no,
strategy_id=req.strategy_id,
code=req.code,
filled_qty=filled_qty,
filled_avg_price=sell_price,
status="FILLED" if filled_qty >= sell_qty else "PARTIAL",
)
if pending or filled_qty <= 0 or sell_price <= 0:
self.db.update_order_status(
ord_no=ord_no, strategy_id=req.strategy_id, code=req.code,
status="PENDING_FILL",
)
logger.warning(
"%s⏳ [매도체결대기] [%s] %s %s ODNO=%s"
"지정가 미확정·시장가보류, heartbeat 재조회%s",
LOG_YELLOW, req.strategy_id, req.name, req.code,
ord_no, LOG_RESET,
)
return OrderResult(
False, ord_no=ord_no, reason="sell_fill_pending", request=req,
)
if filled_qty <= 0 or sell_price <= 0:
if self._strict_fill_required():
self.db.update_order_status(
ord_no=ord_no, strategy_id=req.strategy_id, code=req.code,
status="PENDING_FILL",
)
return OrderResult(
False, ord_no=ord_no, reason="sell_fill_pending", request=req,
)
return OrderResult(False, ord_no=ord_no, reason="zero_sell_fill", request=req)
# ── [6] 손익 계산 + active_trades→trade_history 이동 ───────
return self._finalize_sell_fill(
req, ord_no, filled_qty, sell_price, sell_qty,
)
# ------------------------------------------------------------------
# 내부 유틸
# ------------------------------------------------------------------
def _is_code_held_by_other_strategy(
self, code: str, strategy_id: str
) -> Optional[str]:
"""
active_trades 에서 code 를 strategy_id 가 아닌 다른 전략이 들고 있는지 확인.
반환: 다른 전략 ID 문자열 (없으면 None).
"""
try:
cursor = self.db.conn.execute(
"SELECT strategy FROM active_trades WHERE code=%s",
(code,),
)
rows = cursor.fetchall() or []
for r in rows:
strat = r.get("strategy") if isinstance(r, dict) else r[0]
if strat and strat != strategy_id:
return strat
return None
except Exception as e:
logger.debug("active_trades 교차 조회 실패(%s): %s", code, e)
return None
def _strategy_mm_channel(strategy_id: str) -> str:
"""전략별 MM 채널 alias (config_*). 없으면 MATTERMOST_CHANNEL(통합) 폴백."""
sid = (strategy_id or "").upper()
# US_MOMENTUM 은 MOMENTUM prefix 보다 먼저 (startswith("MOMENTUM")에 안 걸리지만 명시)
if sid.startswith("US_MOMENTUM") or sid == "US_MOMENTUM":
return str(
get_env_from_db("KIS_US_MOMENTUM_MM_CHANNEL", "stock") or "stock"
)
if sid.startswith("SCALP"):
return str(get_env_from_db("KIS_SCALP_MM_CHANNEL", "scalping") or "scalping")
if sid.startswith("SHORT"):
return str(get_env_from_db("KIS_SHORT_MM_CHANNEL", "stock") or "stock")
if sid.startswith("MOMENTUM"):
return str(get_env_from_db("KIS_MOMENTUM_MM_CHANNEL", "stock") or "stock")
if sid.startswith("BREAKOUT"):
return str(get_env_from_db("KIS_BREAKOUT_MM_CHANNEL", "stock") or "stock")
if sid.startswith("UPDOW"):
return str(get_env_from_db("KIS_UPDOW_MM_CHANNEL", "kis_updown") or "kis_updown")
return str(get_env_from_db("MATTERMOST_CHANNEL", "stock") or "stock")
def _strategy_display(strategy_id: str) -> str:
"""
알림 문구에 노출되는 전략 라벨.
내부 ID('SHORT' = 꼬리잡기)는 사용자 직관 맞춰 'TAIL' 로 표기.
- SCALP → 'SCALP'
- SHORT → 'TAIL'
- SHORT_ANT_* → 'TAIL_ANT_*' (prefix 교체)
- 그 외 → 그대로 반환
"""
sid = strategy_id or ""
if sid == "SHORT":
return "TAIL"
if sid.startswith("SHORT"):
return "TAIL" + sid[len("SHORT"):]
return sid