- _feed_fallback 미러 OFF, LS cap/grace/hold RAM을 KIS·키움 spill과 정합 - LS 접근토큰 .ls_token_cache_*.json (재시작 재사용, revoke 루프 없음) - 호가 RAM을 틱과 동일 LIVE_FEED_FALLBACK(snap_time)로 컷, 필터 max_age=0은 유지 - 익절 지정가 로그에 실제 호가 벤더(kis/kiwoom/ls 1·2·3차) 표기 Co-authored-by: Cursor <cursoragent@cursor.com>
2656 lines
119 KiB
Python
2656 lines
119 KiB
Python
"""
|
||
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:
|
||
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
|
||
""",
|
||
(
|
||
int(filled_qty),
|
||
int(filled_qty),
|
||
ord_no,
|
||
req.strategy_id,
|
||
req.code,
|
||
od,
|
||
),
|
||
)
|
||
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:
|
||
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
|