Files
kis_bot/kis_trader/execution/order_manager.py
Your Name bc2b1b642c feat(execution): AccountOrderWorker로 매수·매도 주문 직렬화
전략별 tick/scan 매도 락 대신 계좌 단일 PriorityQueue로 place를 B-full 직렬화한다.
틱매도 only_code 필터와 inflight 중복 enqueue 방지로 REST 폭주를 줄인다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 16:45:26 +09:00

2681 lines
120 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): 매도 전 실잔고 조회.
보유(`hldg_qty`)와 매도가능(`ord_psbl_qty`)을 분리. 매도가능 0 ≠ 유령.
``orders`` 미체결(pending_sell) 가드와 **역할이 다름** — 중복주문 vs 증권사 진실.
* ``BROKER_HOLDINGS_CACHE_TTL_SEC`` (기본 5): 잔고 캐시 TTL — 매도 **루프** 내 N종목 공유.
틱매도는 ``prefetch_broker_holdings(force=True)`` 라 TTL 을 안 탐.
* ``INQUIRE_PSBL_RVSECNCL_BEFORE_GHOST`` (기본 True): 유령정리 전 정정취소가능주문조회.
시장에 남은 매도가 있으면 ghost_purge 금지.
* ``PSBL_RVSECNCL_CACHE_TTL_SEC`` (기본 5): 정정취소가능 조회 캐시 (유량 보호).
* ``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`` — 손절 등 긴급 매도 만료 시 즉시 시장가 재주문.
* ``USE_MARKET_IOC`` — 실전 매수 시장가 IOC(13). 모의는 01 고정.
* ``USE_MARKET_IOC_SELL`` — 실전 매도 시장가 IOC(13). 모의는 01 고정.
부분체결 시 잔량은 브로커 자동취소·포지션 축소 후 긴급이면 즉시 재매도.
* ``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,
broker_row_hldg_qty,
broker_row_sellable_qty,
broker_row_still_held,
cancelable_remainder_qty,
)
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: 한 종목에 대한 주문 요청은 순차 처리
# RLock: place_buy 가 락을 잡은 채 _finalize_* 를 호출해도 재진입 가능
self._code_locks: Dict[str, threading.RLock] = defaultdict(threading.RLock)
# 전역 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
# 정정취소가능(미체결 매도) 맵 캐시 — 유령정리 오판 방지, 잔고와 별도 REST
self._psbl_rvsecncl_cache: Optional[Dict[str, Dict]] = None
self._psbl_rvsecncl_cache_ts: float = 0.0
self._psbl_rvsecncl_last_ok: bool = False
# 유령잔고 정리 쿨다운 — (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
# 시세 조회 — WSManager.get_price (알림 직전 2초 체인 갱신용)
self.price_quote_provider: Optional[Callable[..., Optional[dict]]] = None
# 체결 알림 시세 표기 — WSManager.get_tick_feed_label(code) -> str
self.tick_feed_label_provider: Optional[Callable[[str], str]] = None
self.ob_feed_label_provider: Optional[Callable[[str], str]] = None
def _tick_feed_mm_line(self, code: str) -> str:
"""매수/매도 MM 한 줄: 시세: kiwoom(1차) | 호가: ls(3차)."""
from kis_trader.engine.feed_fallback import format_mm_feed_line
# 알림 직전 읽기 체인을 한 번 돌려 실제로 쓴 시세·호가 소스를 찍는다.
# REST 없음 (get_price/get_orderbook = 1·2·3차 RAM).
try:
if self.price_quote_provider is not None:
self.price_quote_provider(code)
except Exception as e:
logger.debug("price_quote_provider(알림) 실패: %s", e)
try:
if self.orderbook_provider is not None:
self.orderbook_provider(code)
except Exception as e:
logger.debug("orderbook_provider(알림) 실패: %s", e)
tick_lab = ""
ob_lab = ""
try:
if self.tick_feed_label_provider is not None:
tick_lab = (self.tick_feed_label_provider(code) or "").strip()
except Exception as e:
logger.debug("tick_feed_label_provider 실패: %s", e)
try:
if self.ob_feed_label_provider is not None:
ob_lab = (self.ob_feed_label_provider(code) or "").strip()
except Exception as e:
logger.debug("ob_feed_label_provider 실패: %s", e)
if not tick_lab:
try:
tick_lab = (get_env_from_db("LIVE_TICK_PROVIDER", "kiwoom") or "kiwoom").strip().lower()
except Exception:
tick_lab = "?"
if not ob_lab:
try:
ob_lab = (get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom").strip().lower()
except Exception:
ob_lab = "?"
return format_mm_feed_line(tick_lab, ob_lab)
# ------------------------------------------------------------------
# Lock 헬퍼
# ------------------------------------------------------------------
def _lock_for(self, code: str) -> threading.RLock:
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회 호출 (force=True → TTL 무시).
틱매도 콜백에서도 동일 함수를 써서, 틱마다 잔고 REST 가 날아갈 수 있다.
"""
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
self._psbl_rvsecncl_cache = None
self._psbl_rvsecncl_cache_ts = 0.0
def get_cancelable_sells(self, force: bool = False) -> Optional[Dict[str, Dict]]:
"""
정정취소가능 매도 맵 {code: {psbl_qty, odno, ...}}.
None = API 실패 (유령정리 하면 안 됨). 빈 dict = 미체결 매도 없음.
"""
if not get_env_bool("INQUIRE_PSBL_RVSECNCL_BEFORE_GHOST", True):
return {}
now = time.time()
ttl = float(get_env_int("PSBL_RVSECNCL_CACHE_TTL_SEC", 5))
with self._holdings_lock:
if (
not force
and self._psbl_rvsecncl_cache is not None
and self._psbl_rvsecncl_last_ok
and (now - self._psbl_rvsecncl_cache_ts) < ttl
):
return dict(self._psbl_rvsecncl_cache)
m = self.client.get_cancelable_sell_map()
if m is None:
self._psbl_rvsecncl_last_ok = False
if self._psbl_rvsecncl_cache is not None:
logger.debug(
"⏸ [정정취소캐시] API 실패 → stale 캐시 (age=%.1fs)",
now - self._psbl_rvsecncl_cache_ts,
)
return dict(self._psbl_rvsecncl_cache)
return None
self._psbl_rvsecncl_last_ok = True
self._psbl_rvsecncl_cache = m
self._psbl_rvsecncl_cache_ts = now
return dict(m)
def _ghost_purge_block_reason(
self,
code: str,
strategy_id: str,
real_row: Optional[dict],
) -> str:
"""
유령정리하면 안 되는 이유. 빈 문자열이면 정리 후보.
매도가능 0 / 맵 없음 만으로 지우지 않는다.
"""
if broker_row_still_held(real_row):
return "broker_held"
try:
pending = self.db.get_pending_sell_order(strategy_id, code)
except Exception:
pending = None
if pending:
return "pending_sell_db"
cmap = self.get_cancelable_sells(force=False)
if cmap is None:
return "cancelable_api_fail"
if cancelable_remainder_qty(cmap, code) > 0:
return "cancelable_open"
return ""
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·알림 반영.
본매수 경로와 ``poll_pending_fills`` 가 동시에 올 수 있어,
DB 체결수량이 이미 같거나 더 크면 알림·예수금 델타를 건너뛴다.
(동일 ODNO 중복 MM 방지. 부분→증가 47→61 은 증가분만 반영·재알림.)
"""
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)
with self._lock_for(req.code):
existing = self.db.get_order_by_odno(
ord_no, strategy_id=req.strategy_id, code=req.code,
)
prev_filled = int((existing or {}).get("filled_qty") or 0)
if filled_qty <= prev_filled:
logger.info(
"%s⏭ [%s] 이미 반영된 매수체결 스킵 ODNO=%s "
"filled=%d <= db=%d (중복알림 방지)%s",
LOG_CYAN, log_tag, ord_no, filled_qty, prev_filled, LOG_RESET,
)
prev_px = float(
(existing or {}).get("filled_avg_price") or filled_price or 0
)
return OrderResult(
True,
ord_no=ord_no,
filled_qty=prev_filled,
filled_avg_price=prev_px,
reason="buy_fill_already_applied",
request=req,
)
delta_qty = filled_qty - prev_filled
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
# entry_features 안에 임시로 숨겨온 _env_snapshot 분리
feats = dict(req.entry_features or {})
snap_str = feats.pop("_env_snapshot", None)
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,
"env_snapshot": snap_str,
})
self.invalidate_holdings_cache()
if self.cash_ledger is not None and delta_qty > 0:
fee_buf = max(1.0, get_env_float("ORDER_CASH_FEE_BUFFER", 1.01))
self.cash_ledger.apply_trade_delta(
-delta_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"{self._tick_feed_mm_line(req.code)}\n"
f"ODNO={ord_no}"
)
if prev_filled > 0:
header += f"\n(추가체결 +{delta_qty}주 · 누적 {filled_qty}주)"
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(BUY) 실패: %s", _e)
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)
with self._lock_for(req.code):
existing = self.db.get_order_by_odno(
ord_no, strategy_id=req.strategy_id, code=req.code,
)
prev_filled = int((existing or {}).get("filled_qty") or 0)
# orders.filled 만 먼저 찍히고 close_trade 가 빠진 경우
# (모의 가정체결·지정가 merge 선반영) → active 남으면 장부 보완.
# 진짜 중복 poll 은 active 도 0 이라 여기서 스킵.
repair_only = False
if filled_qty <= prev_filled:
remain = self._active_qty(req.strategy_id, req.code)
if remain <= 0:
logger.info(
"%s⏭ [매도체결] 이미 반영 스킵 ODNO=%s filled=%d <= db=%d%s",
LOG_CYAN, ord_no, filled_qty, prev_filled, LOG_RESET,
)
prev_px = float(
(existing or {}).get("filled_avg_price") or sell_price or 0
)
return OrderResult(
True,
ord_no=ord_no,
filled_qty=prev_filled,
filled_avg_price=prev_px,
reason="sell_fill_already_applied",
request=req,
)
repair_only = True
delta_qty = remain
if sell_price <= 0:
sell_price = float(
(existing or {}).get("filled_avg_price") or 0
)
logger.warning(
"%s⚠️ [매도체결·장부보완] [%s] %s %s ODNO=%s "
"orders.filled=%d 이미 있으나 active=%d → close_trade%s",
LOG_YELLOW, req.strategy_id, req.name, req.code, ord_no,
prev_filled, remain, LOG_RESET,
)
else:
delta_qty = filled_qty - prev_filled
if not repair_only:
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,
)
# 시장가 부분체결: IOC면 잔량 이미 취소, 아니면 잔량 취소 후 pending 해제
if 0 < filled_qty < order_qty:
miss = order_qty - filled_qty
if not getattr(self.client, "uses_market_sell_ioc", lambda: False)():
try:
self.client.cancel_order(ord_no, qty=miss)
except Exception as e:
logger.debug(
"매도 잔량 취소 스킵/실패 %s ODNO=%s: %s",
req.code, ord_no, e,
)
self._seal_partial_sell_order(ord_no, req, filled_qty)
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 * delta_qty * fee_rate
+ sell_price * delta_qty * (fee_rate + tax_rate)
)
realized_pnl = (sell_price - buy_price) * delta_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,
sell_qty=int(delta_qty),
)
try:
from kis_trader.utils.today_trades_cache import invalidate_today_trades_cache
invalidate_today_trades_cache()
except Exception:
pass
# 매수~매도 구간 1분봉 REST 백필 (백테 봉구멍·슬롯 좀비 방지) — 비동기 1회
# 부분매도면 포지션 잔존 → 전량 청산 시에만 백필
remain_after = self._active_qty(req.strategy_id, req.code)
if buy_date_for_bf and remain_after <= 0:
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 and delta_qty > 0:
gross = delta_qty * sell_price
net = gross * (1.0 - fee_rate - tax_rate)
self.cash_ledger.apply_trade_delta(net, source="trade_delta")
# realized_pnl 또는 profit_pct 중 하나라도 손실이면 빨강
_pct_loss = req.profit_pct < 0
color = LOG_GREEN if (not _pct_loss and (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,
delta_qty, int(sell_price), req.reason, ord_no, LOG_RESET,
)
if remain_after > 0:
logger.warning(
"%s⚠️ [매도부분체결] [%s] %s %s: 누적체결 %d/%d주 · 잔량 %d%s",
LOG_YELLOW, req.strategy_id, req.name, req.code,
filled_qty, order_qty, remain_after, LOG_RESET,
)
try:
# realized_pnl 또는 profit_pct 중 하나라도 손실이면 빨강
emoji = "🟢" if (not _pct_loss and (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}× {delta_qty}\n"
f"{req.reason} · 수익률 {req.profit_pct*100:+.2f}%\n"
f"실현 {pnl_str} · ODNO={ord_no}\n"
f"{self._tick_feed_mm_line(req.code)}"
)
if remain_after > 0:
header += f"\n⚠️ 부분체결 잔량 {remain_after}"
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
# 손절 등 긴급: 잔량 있으면 즉시 시장가 재매도 (IOC면 pending 이미 seal)
# MAX_URGENT_ESCALATE_COUNT 초과 시 포기 — 모의서버 부분체결 무한루프·락 점유 방지
if remain_after > 0 and is_urgent_market_sell_reason(req.reason or ""):
_esc = int(getattr(req, "_escalate_count", 0) or 0)
_esc_max = int(get_env_int("MAX_URGENT_ESCALATE_COUNT", 3) or 3)
if _esc < _esc_max:
req._escalate_count = _esc + 1
self._escalate_urgent_sell_market(
req, remain_after, tag="부분체결재매도",
)
else:
logger.warning(
"%s⚠️ [재매도상한] [%s] %s %s — 긴급재매도 %d회 초과"
" (MAX_URGENT_ESCALATE_COUNT=%d), 잔량 %d주 포기%s",
LOG_YELLOW, req.strategy_id, req.name, req.code,
_esc, _esc_max, remain_after, LOG_RESET,
)
return OrderResult(
True, ord_no=ord_no,
filled_qty=filled_qty, filled_avg_price=sell_price,
request=req,
)
def _active_qty(self, strategy_id: str, code: str) -> int:
"""전략·종목 active_trades 잔량 (없으면 0)."""
try:
from ..utils.strategy_ids import canonical_strategy_id
sid = canonical_strategy_id(strategy_id)
for try_sid in (sid, strategy_id):
row = self.db.conn.execute(
"SELECT current_qty FROM active_trades "
"WHERE code=%s AND strategy=%s LIMIT 1",
(code, try_sid),
).fetchone()
if row:
return int(dict(row).get("current_qty") or 0)
except Exception as e:
logger.debug("active_qty 조회 실패 %s/%s: %s", strategy_id, code, e)
return 0
def _seal_partial_sell_order(
self, ord_no: str, req: OrderRequest, filled_qty: int,
) -> None:
"""
부분매도 후 주문행을 pending 에서 제외 (qty=체결분 고정).
get_pending_sell_order 가 잔량 재주문을 막지 않게 한다.
"""
if filled_qty <= 0 or not ord_no:
return
od = datetime.datetime.now().strftime("%Y-%m-%d")
try:
mock_flag = self.db._resolve_is_mock(None)
with self.db.conn:
self.db.conn.execute(
"""
UPDATE orders
SET qty=%s, filled_qty=%s, status='PARTIAL'
WHERE ord_no=%s AND strategy_id=%s AND code=%s AND ord_date=%s
AND is_mock=%s
""",
(
int(filled_qty),
int(filled_qty),
ord_no,
req.strategy_id,
req.code,
od,
mock_flag,
),
)
except Exception as e:
logger.debug(
"seal_partial_sell_order 실패 ODNO=%s: %s", ord_no, e,
)
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"])
# 본매수/본매도 경로와 heartbeat 레이스·동일수량 재폴링 시
# finalize(알림·예수금 델타) 중복 방지 — 수량이 늘었을 때만 확정.
# (부분체결 20→26 처럼 증가분은 그대로 반영)
fresh = self.db.get_order_by_odno(
ord_no, strategy_id=req.strategy_id, code=req.code,
)
if fresh is not None:
prev_filled = int(fresh.get("filled_qty") or 0)
fresh_st = str(fresh.get("status") or "").upper()
if fresh_st == "FILLED" and prev_filled >= order_qty:
continue
if filled_qty <= prev_filled:
continue
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:
# 매도 IOC면 잔량 주문은 이미 브로커 취소 — cancel REST 생략
skip_cancel = (
side == "SELL"
and getattr(self.client, "uses_market_sell_ioc", lambda: False)()
)
if not skip_cancel:
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
elif side == "SELL" and prev_filled > 0:
# 부분매도 만료: pending 해제 후 긴급이면 잔량 재매도
self._seal_partial_sell_order(ord_no, req, prev_filled)
logger.warning(
"%s⏱ [매도부분만료] %s %s ODNO=%s — 체결 %d · 미체결잔량 %d%s",
LOG_YELLOW, req.name, req.code, ord_no,
prev_filled, remain, LOG_RESET,
)
handled += 1
pos_left = self._active_qty(req.strategy_id, req.code)
re_qty = pos_left if pos_left > 0 else remain
if re_qty > 0:
self._escalate_urgent_sell_market(
req, re_qty, tag="만료재손절(부분)",
)
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 가 있으면 유령이 아니라 **미확정 매도** 로 본다.
→ 체결 재조회. 체결 API 빈손이면 정정취소가능 잔량이 없을 때만 대기 (가정체결 금지).
매도가능 0 · 잔고맵 없음만으로 ghost_purge(0원) 하지 않는다.
"""
# ── 잔고 칸 / 시장 미체결 매도가 있으면 유령 아님 ──
try:
from database import TradeDB
cur_mock = int(TradeDB.resolve_kis_is_mock(None))
at = self.db.conn.execute(
"SELECT is_mock FROM active_trades WHERE code=%s AND strategy=%s LIMIT 1",
(req.code, req.strategy_id),
).fetchone()
if at is not None:
row_m = dict(at).get("is_mock")
if row_m is None or int(row_m) != cur_mock:
logger.warning(
"%s⏸ [유령보류] [%s] %s %s — is_mock=%s ≠ 현재=%s → ghost_purge 스킵%s",
LOG_YELLOW, req.strategy_id, req.name, req.code,
row_m, cur_mock, LOG_RESET,
)
return OrderResult(
False,
reason="ghost_blocked:other_account",
request=req,
)
except Exception as e:
logger.debug("ghost is_mock 가드 스킵: %s", e)
try:
real_row = (self.get_broker_holdings(force=False) or {}).get(req.code)
except Exception:
real_row = None
block = self._ghost_purge_block_reason(req.code, req.strategy_id, real_row)
if block in ("broker_held", "cancelable_api_fail", "cancelable_open"):
hldg = broker_row_hldg_qty(real_row)
psbl = broker_row_sellable_qty(real_row)
logger.warning(
"%s⏸ [유령보류] [%s] %s %s%s (보유=%d 매도가능=%d) → ghost_purge 스킵%s",
LOG_YELLOW, req.strategy_id, req.name, req.code, block,
hldg, psbl, LOG_RESET,
)
return OrderResult(
False,
reason="ghost_blocked:%s" % block,
request=req,
)
# ── 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이면 전량 체결로 처리 → 매도가능 0 착시와 겹쳐 장부만 지움)
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_row = real_map.get(req.code) or {}
real_qty = broker_row_hldg_qty(real_row)
psbl_qty = broker_row_sellable_qty(real_row)
except Exception as e:
real_qty = -1
psbl_qty = -1
logger.debug("취소실패 잔고확인 예외 %s: %s", req.code, e)
# 보유·매도가능이 남아 있거나, 시장에 정정취소가능 매도가 있으면 체결 아님
open_book = 0
try:
cmap = self.get_cancelable_sells(force=True)
if cmap is None:
open_book = -1
else:
open_book = cancelable_remainder_qty(cmap, req.code)
except Exception:
open_book = -1
if real_qty == 0 and psbl_qty == 0 and open_book == 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}\n"
f"{self._tick_feed_mm_line(req.code)}"
)
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(BUY overseas) 실패: %s", _e)
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()
# realized_pnl 또는 profit_pct 중 하나라도 손실이면 빨강
_pct_loss_us = req.profit_pct < 0
color = LOG_GREEN if (not _pct_loss_us and (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:
# realized_pnl 또는 profit_pct 중 하나라도 손실이면 빨강
emoji = "🟢" if (not _pct_loss_us and (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}\n"
f"{self._tick_feed_mm_line(req.code)}"
)
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,
)
try:
from kis_trader.utils.ops_alert import note_counter, ops_alert
streak = note_counter("buy_reject")
need = max(1, get_env_int("OPS_ALERT_ORDER_REJECT_STREAK", 3))
if streak >= need:
ops_alert(
"order_buy_reject",
f"매수주문 연속거부 {streak}",
detail=f"{req.strategy_id} {req.name}({req.code}) {m1 or cd}",
level="critical",
)
note_counter("buy_reject", reset=True)
except Exception:
pass
return OrderResult(False, reason=reason, request=req)
try:
from kis_trader.utils.ops_alert import note_counter
note_counter("buy_reject", reset=True)
except Exception:
pass
# ── [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] 실계좌 잔고 재검증 (보유 vs 매도가능 분리) ─────────
# 루프: 시작 시 prefetch(force=True) 1회 → 여기선 TTL 캐시.
# 틱매도: 콜백에서 이미 prefetch(force=True) 해서 TTL 무효.
# 13(IOC) 이어도 잔고 REST·ORDER_FILL_WAIT_SEC sleep 은 그대로.
real_qty = None
sell_qty = req.qty
real_row = None
# ── [2a] 동일 종목 미체결 매도(우리 DB) 있으면 중복·유령 금지 ─
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,
)
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)
hldg = broker_row_hldg_qty(real_row)
psbl = broker_row_sellable_qty(real_row)
real_qty = hldg
if hldg != psbl:
logger.info(
"📊 [잔고칸] [%s] %s %s 보유=%d 매도가능=%d 금일매도=%s",
req.strategy_id, req.name, req.code, hldg, psbl,
(real_row or {}).get("thdt_sll_qty"),
)
block = self._ghost_purge_block_reason(
req.code, req.strategy_id, real_row,
)
if not broker_row_still_held(real_row):
if block:
logger.warning(
"%s⏸ [매도보류] [%s] %s %s — 잔고맵 0이지만 %s "
"(시장 미체결/API실패) → 유령정리 안 함%s",
LOG_YELLOW, req.strategy_id, req.name, req.code,
block, LOG_RESET,
)
return OrderResult(
False,
reason="sell_locked:%s" % block,
request=req,
)
return self._purge_ghost_position(req, "broker_zero")
# 새 매도는 매도가능 수량만. 보유>0·매도가능=0 = 이미 주문이 잠금
if psbl <= 0:
logger.info(
"%s⏸ [매도가능0] [%s] %s %s — 보유 %d주, 매도가능 0 "
"(미체결 매도가 잠금) → 추가매도/유령정리 안 함%s",
LOG_YELLOW, req.strategy_id, req.name, req.code,
hldg, LOG_RESET,
)
return OrderResult(
False, reason="sellable_zero", request=req,
)
# 실제 매도가능 > 요청이면 요청만, 적으면 매도가능만 (타전략 몫 보호)
sell_qty = min(req.qty, psbl)
if sell_qty < req.qty:
logger.warning(
"%s⚠️ [매도수량조정] [%s] %s %s: 요청 %d주 → 매도가능 %d"
"(보유 %d · 미체결 잠금/부분체결 가능)%s",
LOG_YELLOW, req.strategy_id, req.name, req.code,
req.qty, sell_qty, hldg, LOG_RESET,
)
else:
sell_qty = req.qty
# ── [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,
ob_feed_label=self.ob_feed_label_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호가·호가=%s)",
req.name, req.code, sell_qty, f"{limit_price:,}", ob_src,
)
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:
# 잔고검증이 이미 보유>0 이면 모의 문구 착시 — 유령삭제 금지
# 매도가능 0 거절도 미체결 잠금일 수 있음
if real_qty is not None and int(real_qty) > 0:
logger.warning(
"%s⚠️ [매도거절] [%s] %s %s: %s 이지만 실잔고 %d주 → 유령정리 안 함%s",
LOG_YELLOW, req.strategy_id, req.name, req.code,
m1 or cd, real_qty, LOG_RESET,
)
return OrderResult(False, reason=f"sell_reject:{cd or m1}", request=req)
block = self._ghost_purge_block_reason(
req.code, req.strategy_id, real_row,
)
if block:
logger.warning(
"%s⚠️ [매도거절] [%s] %s %s: %s%s → 유령정리 안 함%s",
LOG_YELLOW, req.strategy_id, req.name, req.code,
m1 or cd, block, LOG_RESET,
)
return OrderResult(False, reason=f"sell_reject:{cd or m1}", request=req)
return self._purge_ghost_position(req, "broker_response")
else:
try:
from kis_trader.utils.ops_alert import note_counter, ops_alert
streak = note_counter("sell_reject")
need = max(1, get_env_int("OPS_ALERT_ORDER_REJECT_STREAK", 3))
if streak >= need:
ops_alert(
"order_sell_reject",
f"매도주문 연속실패 {streak}",
detail=f"{req.strategy_id} {req.name}({req.code}) {m1 or cd}",
level="critical",
)
note_counter("sell_reject", reset=True)
except Exception:
pass
return OrderResult(False, reason=f"sell_reject:{cd or m1}", request=req)
try:
from kis_trader.utils.ops_alert import note_counter
note_counter("sell_reject", reset=True)
except Exception:
pass
# ── [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:
# 레거시 모의: 참고가로 가정 체결.
# orders.filled 는 _finalize_sell_fill 에서만 기록
# (여기서 선반영하면 filled<=db 스킵 → close_trade 누락 → ghost).
sell_price = req.price_ref or req.buy_price
filled_qty = sell_qty
# 익절 지정가 미확인·부분체결 → 취소 후 재조회, 필요할 때만 시장가
# (지정가 체결 중 시장가 재시도 → 타전략 몫까지 이중매도 방지)
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}
# filled 선반영 금지 — finalize 가 update+close 원자적으로 처리
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