Files
kis_bot/kis_trader/execution/order_manager.py
Hwang 61c72a8a4c feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가
변경 사항
----
- _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가
- _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가
- _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가

영향
----
- 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임
- 기존 기능에 대한 영향 없음

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 01:27:00 +09:00

1273 lines
56 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
kis_trader/execution/order_manager.py — Master Executor
========================================================
두 전략(스캘핑/꼬리잡기)이 공유하는 "단 하나의 주문 실행자".
설계 목적:
* 전략은 **시그널만 생성**한다. 주문 실행은 전부 여기서 직렬화 처리.
* 매수: ``active_trades (code, strategy)`` 복합키로 전략별 qty 관리 — 타 전략/실계좌 보유와 무관하게 추가 매수 가능.
* 매도 전 실계좌 잔고 재확인 → 요청 qty 만큼만 매도 (``min(요청, 실보유)``).
(DB·메모리 정리까지 한 번에).
* 주문번호(ODNO) 기준 UNIQUE 제약으로 **서버단 중복 차단**.
정책 (env_config 로 제어):
* ``STRATEGY_SAME_CODE_POLICY``
- ``allow`` (기본): 전략별 active_trades·ODNO 분리 — 다른 전략 보유와 무관하게 매수 허용.
- ``block`` : 한 종목은 한 전략만 (다른 전략 active_trades 보유 시 차단).
* ``REAL_BALANCE_VERIFY_BEFORE_SELL`` (기본 True): 매도 전 실잔고 조회.
* ``BROKER_HOLDINGS_CACHE_TTL_SEC`` (기본 5): 잔고 캐시 TTL — 매도 루프 내 N종목 공유.
* ``GHOST_POSITION_COOLDOWN_SEC`` (기본 300): 유령잔고 정리 후 동일 (전략,종목) 재시도 쿨다운.
* ``REAL_BALANCE_VERIFY_BEFORE_BUY`` (기본 False): 매수 전 검증 (기본 OFF — 전략별 복합키가 주 관리).
* ``REAL_BALANCE_VERIFY_BEFORE_BUY_MODE`` — ``strategy``(기본): 동일 전략 DB 보유 시만 차단 /
``global``(레거시): 실계좌에 1주라도 있으면 차단.
* ``STRICT_FILL_VERIFY`` (기본 False): 모의에서 true 시 실전과 동일하게 fill 미확인 시
active_trades 미반영·heartbeat 재조회. **실전은 항상 엄격**.
* ``PENDING_FILL_POLL_INTERVAL_SEC`` / ``PENDING_BUY_MAX_AGE_SEC`` /
``PENDING_SELL_MAX_AGE_SEC`` / ``PENDING_SELL_STOP_MAX_AGE_SEC`` — 미체결 재조회·만료.
* ``PENDING_POLL_BATCH_FETCH`` (기본 True): heartbeat ``poll_pending_fills`` 에서
당일 체결을 1 REST 로 일괄 조회 (실매 전용 · 백테 무관).
* ``SELL_PENDING_REORDER_ON_EXPIRE`` — 손절 등 긴급 매도 만료 시 즉시 시장가 재주문.
* ``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.logger import (
LOG_CYAN,
LOG_GREEN,
LOG_RED,
LOG_RESET,
LOG_YELLOW,
get_logger,
msg_mm_strategy,
)
from .account_cash import AccountCashLedger
from .kis_client import KISClient
from .orderbook_sell import (
evaluate_take_profit_limit_sell,
is_profit_take_sell_reason,
is_urgent_market_sell_reason,
resolve_orderbook_raw,
)
logger = get_logger("kis_trader.order_mgr")
@dataclass
class OrderRequest:
"""전략이 OrderManager 에 넘기는 주문 요청."""
strategy_id: str
code: str
name: str
side: str # 'BUY' | 'SELL'
qty: int
price_ref: float = 0.0 # 참고가(로그/DB용, 시장가 주문이라도 체결 추정치)
reason: str = "" # 매도 사유
# 매수 시 포지션 관리용 부가 정보
stop_price: float = 0.0
target_price: float = 0.0
atr_entry: float = 0.0
size_class: Optional[str] = None
entry_features: Optional[Dict] = None
use_limit_buy: bool = False
# 매도 시 계산 결과 전달 (로그용)
buy_price: float = 0.0
profit_pct: float = 0.0
@dataclass
class OrderResult:
success: bool
ord_no: Optional[str] = None
filled_qty: int = 0
filled_avg_price: float = 0.0
reason: str = "" # 실패 사유 (차단/거부/오류)
request: Optional[OrderRequest] = None
extra: Dict = field(default_factory=dict)
class OrderManager:
"""
단일 주문 실행자 (싱글톤처럼 운용).
- 종목별 Lock → 같은 종목에 대한 요청 직렬화.
- 실잔고 캐시 (짧은 TTL) → API 호출 횟수 절감.
"""
def __init__(
self,
*,
client: KISClient,
db: TradeDBExt,
cash_ledger: Optional[AccountCashLedger] = None,
):
self.client = client
self.db = db
self.cash_ledger: Optional[AccountCashLedger] = cash_ledger
# 종목별 Lock: 한 종목에 대한 주문 요청은 순차 처리
self._code_locks: Dict[str, threading.Lock] = defaultdict(threading.Lock)
# 전역 Lock: _code_locks 인스턴스 생성 시 경합 방지
self._global_lock = threading.Lock()
# 실잔고 맵 캐시
self._holdings_cache: Optional[Dict[str, Dict]] = None
self._holdings_cache_ts: float = 0.0
self._holdings_lock = threading.Lock()
self._holdings_last_fetch_ok: bool = True
# 유령잔고 정리 쿨다운 — (strategy_id, code) → epoch
self._ghost_purged_at: Dict[tuple, float] = {}
# 매도 실패 백오프 (영업일 아님 등)
self._sell_backoff: Dict[str, float] = {}
# 체결 알림에 덧붙일 자산 요약 라인 생성기(주입식).
# TradingOrchestrator.start() 가 self._asset_line_for_notify 를 세팅.
# signature: (side: str, extra: Optional[Dict]) -> str (여러 줄 가능)
self.asset_line_provider: Optional[Callable[..., str]] = None
# TradingOrchestrator.start() 가 self._strategy_daily_pnl_for_notify 를 세팅.
# signature: (strategy_id: str) -> (당일 실현손익, 청산건수) | None
self.strategy_pnl_provider: Optional[Callable[..., tuple]] = None
# 호가 조회 — WSManager.get_orderbook 등 (키움 0D 캐시 우선)
self.orderbook_provider: Optional[Callable[..., Optional[dict]]] = None
# ------------------------------------------------------------------
# Lock 헬퍼
# ------------------------------------------------------------------
def _lock_for(self, code: str) -> threading.Lock:
with self._global_lock:
return self._code_locks[code]
# ------------------------------------------------------------------
# 실잔고 조회 (캐시)
# ------------------------------------------------------------------
def _holdings_cache_ttl(self) -> float:
return float(get_env_int("BROKER_HOLDINGS_CACHE_TTL_SEC", 5))
def get_broker_holdings(self, force: bool = False) -> Dict[str, Dict]:
"""
실계좌 잔고 맵 {code: {qty, avg_price, ...}}.
TTL 캐시로 API 폭주 방지. API 실패 시 stale 캐시가 있으면 그걸 반환.
``_holdings_last_fetch_ok`` 로 최신 조회 성공 여부 확인.
"""
now = time.time()
ttl = self._holdings_cache_ttl()
with self._holdings_lock:
if (
not force
and self._holdings_cache is not None
and (now - self._holdings_cache_ts) < ttl
):
self._holdings_last_fetch_ok = True
return dict(self._holdings_cache)
m = self.client.get_broker_holdings_map()
if m is None:
self._holdings_last_fetch_ok = False
if self._holdings_cache is not None:
logger.debug(
"⏸ [잔고캐시] API 실패 → stale 캐시 사용 (age=%.1fs)",
now - self._holdings_cache_ts,
)
return dict(self._holdings_cache)
return {}
self._holdings_last_fetch_ok = True
self._holdings_cache = m
self._holdings_cache_ts = now
return dict(m)
def prefetch_broker_holdings(self) -> bool:
"""
전략 매도 루프 시작 전 1회 호출.
동일 루프에서 N종목 매도 시 inquire-balance 중복 호출을 줄인다.
"""
self.get_broker_holdings(force=True)
return self._holdings_last_fetch_ok
def invalidate_holdings_cache(self) -> None:
with self._holdings_lock:
self._holdings_cache = None
self._holdings_cache_ts = 0.0
# ------------------------------------------------------------------
# 체결 검증 (실전 항상 엄격 / 모의는 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, 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 _finalize_buy_fill(
self,
req: OrderRequest,
ord_no: str,
filled_qty: int,
filled_price: float,
order_qty: int,
*,
log_tag: str = "매수체결",
) -> OrderResult:
"""체결 확정 후 active_trades·알림 반영."""
if filled_qty <= 0 or filled_price <= 0:
return OrderResult(False, ord_no=ord_no, reason="zero_fill", request=req)
status = "FILLED" if filled_qty >= order_qty else "PARTIAL"
self.db.update_order_fill(
ord_no=ord_no,
filled_qty=filled_qty,
filled_avg_price=filled_price,
status=status,
)
now_str = dt.now().strftime("%Y-%m-%d %H:%M:%S")
from ..utils.strategy_ids import canonical_strategy_id
self.db.upsert_trade({
"code": req.code,
"name": req.name,
"strategy": canonical_strategy_id(req.strategy_id),
"avg_buy_price": filled_price,
"current_price": filled_price,
"stop_price": req.stop_price,
"target_price": req.target_price,
"max_price": filled_price,
"atr_entry": req.atr_entry,
"target_qty": filled_qty,
"current_qty": filled_qty,
"total_invested": filled_price * filled_qty,
"status": "HOLDING",
"buy_date": now_str,
"size_class": req.size_class or "",
"entry_features": req.entry_features or {},
})
self.invalidate_holdings_cache()
if self.cash_ledger is not None:
fee_buf = max(1.0, get_env_float("ORDER_CASH_FEE_BUFFER", 1.01))
self.cash_ledger.apply_trade_delta(
-filled_qty * filled_price * fee_buf,
source="trade_delta",
)
logger.info(
"%s✅ [%s] [%s] %s %s @ %d× %d주 (ODNO=%s)%s",
LOG_GREEN, log_tag, req.strategy_id, req.name, req.code,
int(filled_price), filled_qty, ord_no, LOG_RESET,
)
try:
disp = _strategy_display(req.strategy_id)
header = (
f"🔷 **[{log_tag}:{disp}]** {req.name}({req.code})\n"
f"{filled_price:,.0f}× {filled_qty}주 = {filled_price*filled_qty:,.0f}\n"
f"손절 {req.stop_price:,.0f} / 목표 {req.target_price:,.0f}\n"
f"ODNO={ord_no}"
)
tail = ""
if self.asset_line_provider is not None:
try:
tail = self.asset_line_provider("BUY", None) or ""
except Exception as _e:
logger.debug("asset_line_provider(BUY) 실패: %s", _e)
msg = header + ("\n\n" + tail if tail else "")
msg_mm_strategy(
msg,
_strategy_mm_channel(req.strategy_id),
jitter=False,
)
except Exception:
pass
return OrderResult(
True, ord_no=ord_no,
filled_qty=filled_qty, filled_avg_price=filled_price,
request=req,
)
def _finalize_sell_fill(
self,
req: OrderRequest,
ord_no: str,
filled_qty: int,
sell_price: float,
order_qty: int,
) -> OrderResult:
"""매도 체결 확정 후 close_trade·알림."""
if filled_qty <= 0 or sell_price <= 0:
return OrderResult(False, ord_no=ord_no, reason="zero_sell_fill", request=req)
status = "FILLED" if filled_qty >= order_qty else "PARTIAL"
self.db.update_order_fill(
ord_no=ord_no,
filled_qty=filled_qty,
filled_avg_price=sell_price,
status=status,
)
fee_rate = float(get_env_from_db("FEE_RATE_PCT", "0.015")) / 100.0
tax_rate = float(get_env_from_db("SELL_TAX_RATE_PCT", "0.18")) / 100.0
buy_price = req.buy_price or 0
if buy_price > 0:
fees = (
buy_price * filled_qty * fee_rate
+ sell_price * filled_qty * (fee_rate + tax_rate)
)
realized_pnl = (sell_price - buy_price) * filled_qty - fees
else:
realized_pnl = None
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,
)
self.invalidate_holdings_cache()
if self.cash_ledger is not None:
gross = filled_qty * sell_price
net = gross * (1.0 - fee_rate - tax_rate)
self.cash_ledger.apply_trade_delta(net, source="trade_delta")
color = LOG_GREEN if (realized_pnl is None or realized_pnl >= 0) else LOG_RED
logger.info(
"%s💸 [매도체결] [%s] %s %s × %d주 @ %d원 | 사유=%s (ODNO=%s)%s",
color, req.strategy_id, req.name, req.code,
filled_qty, int(sell_price), req.reason, ord_no, LOG_RESET,
)
try:
emoji = "🟢" if (realized_pnl is None or realized_pnl >= 0) else "🔴"
pnl_str = f"{realized_pnl:+,.0f}" if realized_pnl is not None else "-"
disp = _strategy_display(req.strategy_id)
header = (
f"{emoji} **[매도체결:{disp}]** {req.name}({req.code})\n"
f"{sell_price:,.0f}× {filled_qty}\n"
f"{req.reason} · 수익률 {req.profit_pct*100:+.2f}%\n"
f"실현 {pnl_str} · ODNO={ord_no}"
)
if self.strategy_pnl_provider is not None:
try:
_sp = self.strategy_pnl_provider(req.strategy_id)
if _sp is not None:
_spnl, _scnt = _sp
header += (
f"\n📊 {disp} 당일 {_spnl:+,.0f}원 · 청산 {_scnt}"
)
except Exception as _e:
logger.debug("strategy_pnl_provider(SELL) 실패: %s", _e)
tail = ""
if self.asset_line_provider is not None:
try:
tail = self.asset_line_provider(
"SELL",
{"realized_pnl": realized_pnl},
) or ""
except Exception as _e:
logger.debug("asset_line_provider(SELL) 실패: %s", _e)
msg = header + ("\n\n" + tail if tail else "")
msg_mm_strategy(
msg,
_strategy_mm_channel(req.strategy_id),
jitter=False,
)
except Exception:
pass
return OrderResult(
True, ord_no=ord_no,
filled_qty=filled_qty, filled_avg_price=sell_price,
request=req,
)
def _lookup_fill(
self,
ord_no: str,
code: str,
*,
fill_map: Optional[Dict[str, Dict]] = None,
wait_sec: float = 0.0,
) -> Optional[Dict]:
"""체결 맵 우선, 없으면 건별 inquire-daily-ccld."""
key = str(ord_no or "").strip()
if fill_map is not None:
hit = fill_map.get(key)
if hit:
row_code = str(hit.get("code") or "").strip()
if code and row_code and row_code != str(code).strip():
return None
return {
"filled_qty": int(hit["filled_qty"]),
"avg_price": float(hit["avg_price"]),
}
return None
return self.client.get_execution_by_odno(
key, code=code, wait_sec=wait_sec,
)
def poll_pending_fills(self) -> int:
"""
heartbeat — 미확인·부분체결 주문 재조회.
처리(체결 반영 또는 만료 취소) 건수 반환.
"""
rows = self.db.get_pending_fill_orders()
if not rows:
return 0
wait_sec = float(get_env_int("PENDING_FILL_POLL_SEC", 1))
handled = 0
fill_map: Optional[Dict[str, Dict]] = None
batch_ok = False
if get_env_bool("PENDING_POLL_BATCH_FETCH", True):
fill_map = self.client.get_today_execution_map(wait_sec=wait_sec)
batch_ok = fill_map is not None
if batch_ok:
logger.debug(
"poll_pending_fills 일괄체결조회 %d건 pending / map %d ODNO",
len(rows), len(fill_map or {}),
)
else:
logger.debug(
"poll_pending_fills 일괄체결조회 실패 → 건별 폴백 (%d건)",
len(rows),
)
fill_map = None
for row in rows:
ord_no = str(row.get("ord_no") or "").strip()
if not ord_no:
continue
req = self._req_from_order_row(row)
if req is None:
continue
order_qty = int(row.get("qty") or req.qty or 0)
prev_filled = int(row.get("filled_qty") or 0)
side = str(row.get("side") or req.side or "").upper()
age = self._order_age_sec(row)
if side == "BUY":
max_age = self._pending_buy_max_age_sec()
else:
max_age = self._pending_sell_max_age_sec(req.reason or "")
row_wait = 0.0 if batch_ok else wait_sec
fill = self._lookup_fill(
ord_no, req.code,
fill_map=fill_map if batch_ok else None,
wait_sec=row_wait,
)
if fill and int(fill.get("filled_qty", 0) or 0) > 0:
filled_qty = int(fill["filled_qty"])
avg_price = float(fill["avg_price"])
if side == "BUY":
if 0 < filled_qty < order_qty:
miss = order_qty - filled_qty
logger.warning(
"%s⚠️ [매수 부분체결·폴링] [%s] %s %s: "
"주문 %d주 → 체결 %d주 (미체결 %d주)%s",
LOG_YELLOW, req.strategy_id, req.name, req.code,
order_qty, filled_qty, miss, LOG_RESET,
)
self._try_cancel_buy_remainder(
ord_no, req.code, miss,
use_limit=bool(getattr(req, "use_limit_buy", False)),
)
self._finalize_buy_fill(
req, ord_no, filled_qty, avg_price, order_qty,
log_tag="매수체결(재조회)",
)
else:
self._finalize_sell_fill(
req, ord_no, filled_qty, avg_price, order_qty,
)
handled += 1
continue
if age < max_age:
continue
# 만료 — 미체결 또는 부분체결 잔량 정리
remain = max(0, order_qty - prev_filled)
if remain > 0:
try:
self.client.cancel_order(ord_no, qty=remain)
except Exception as e:
logger.debug("만료 주문 취소 실패 ord_no=%s: %s", ord_no, e)
if prev_filled <= 0:
self.db.update_order_status(ord_no=ord_no, 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, status="PARTIAL")
logger.warning(
"%s⏱ [부분체결만료] %s %s ODNO=%s — 잔량 %d주 취소%s",
LOG_YELLOW, req.name, req.code, ord_no, remain, LOG_RESET,
)
handled += 1
return handled
def _purge_ghost_position(self, req: OrderRequest, log_tag: str) -> OrderResult:
"""
브로커 0주인데 로컬만 남은 포지션 정리.
DB 삭제 + 전략 holdings.pop 트리거(extra.purge_holdings).
동일 (전략, 종목) 은 GHOST_POSITION_COOLDOWN_SEC 동안 재로그·재API 방지.
"""
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,
extra={"purge_holdings": True},
)
self._ghost_purged_at[key] = now
if log_tag == "broker_response":
logger.warning(
"%s⚠️ [유령잔고응답] [%s] %s %s: 로컬 active_trades 삭제%s",
LOG_YELLOW, req.strategy_id, req.name, req.code, LOG_RESET,
)
else:
logger.warning(
"%s⚠️ [유령잔고정리] [%s] %s %s: 브로커 0주 → 로컬 active_trades 삭제%s",
LOG_YELLOW, req.strategy_id, req.name, req.code, LOG_RESET,
)
self.db.delete_active_trade(code=req.code, strategy=req.strategy_id)
self.invalidate_holdings_cache()
return OrderResult(
False,
reason="broker_no_position",
request=req,
extra={"purge_holdings": True},
)
# ------------------------------------------------------------------
# 공개 API
# ------------------------------------------------------------------
def place(self, req: OrderRequest) -> OrderResult:
"""전략이 호출하는 유일한 진입점. BUY / SELL 모두 처리."""
side = (req.side or "").upper()
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)
# ------------------------------------------------------------------
# 매수
# ------------------------------------------------------------------
def _place_buy(self, req: OrderRequest) -> OrderResult:
if req.qty <= 0:
return OrderResult(False, reason="qty<=0", request=req)
with self._lock_for(req.code):
# ── [1] 같은 종목 다른 전략 보유 여부 체크 ─────────────────
policy = str(get_env_from_db("STRATEGY_SAME_CODE_POLICY", "allow")).lower()
if policy == "block":
# 다른 전략이 이미 active_trades 에 들고 있으면 차단
blocked = self._is_code_held_by_other_strategy(
req.code, req.strategy_id
)
if blocked:
logger.warning(
"%s🚫 [중복종목차단] %s %s → 다른 전략(%s) 보유 중 → 매수 스킵%s",
LOG_YELLOW, req.name, req.code, blocked, LOG_RESET,
)
return OrderResult(False, reason=f"held_by_{blocked}", request=req)
# ── [2] 매수 전 검증 (선택) ─────────────────────────────────
# ※ 계좌 잔고는 종목당 통짜이지만, 봇은 (code, strategy) + ODNO 로 전략별 qty 를 관리.
# 타 전략·수동(MANUAL) 보유가 있어도 추가 매수 가능. 매도는 요청 qty 만 청산.
if get_env_bool("REAL_BALANCE_VERIFY_BEFORE_BUY", False):
mode = str(
get_env_from_db("REAL_BALANCE_VERIFY_BEFORE_BUY_MODE", "strategy")
).lower()
if mode == "global":
real_map = self.get_broker_holdings()
if req.code in real_map and real_map[req.code]["qty"] > 0:
logger.warning(
"%s🚫 [이미실보유-global] %s %s × %d주 → 매수 스킵%s",
LOG_YELLOW, req.name, req.code,
real_map[req.code]["qty"], LOG_RESET,
)
return OrderResult(
False, reason="already_held_broker", request=req,
)
elif mode == "strategy":
try:
row = self.db.conn.execute(
"SELECT current_qty FROM active_trades "
"WHERE code=%s AND strategy=%s",
(req.code, req.strategy_id),
).fetchone()
db_qty = 0
if row:
db_qty = int(float(
row.get("current_qty")
if isinstance(row, dict)
else row[0]
) or 0)
if db_qty > 0:
logger.warning(
"%s🚫 [이미전략보유] [%s] %s %s DB %d주 → 매수 스킵%s",
LOG_YELLOW, req.strategy_id, req.name, req.code,
db_qty, LOG_RESET,
)
return OrderResult(
False, reason="already_held_strategy", request=req,
)
except Exception as e:
logger.debug("매수 전 전략 보유 조회 실패(%s): %s", req.code, e)
# ── [2a] 동일 종목 미체결 매수 있으면 중복 주문 방지 ───────
# 체결확인 API(inquire-daily-ccld) 장애로 PENDING_FILL 이 쌓이면,
# 전략 루프가 같은 종목을 매 턴(10초) 재주문하는 폭주가 발생한다.
# → 미체결 매수 1건이라도 있으면 스킵. 만료 시 poll 이 CANCELLED 처리해 자동 해제.
if get_env_bool("BUY_DEDUP_PENDING", True):
pending_buy = self.db.get_pending_buy_order(req.strategy_id, req.code)
if pending_buy:
pend_no = str(pending_buy.get("ord_no") or "")
logger.info(
"%s⏸ [매수대기중] [%s] %s %s — 기존 ODNO=%s 체결확인 대기 (재주문 스킵)%s",
LOG_YELLOW, req.strategy_id, req.name, req.code,
pend_no, LOG_RESET,
)
return OrderResult(
False,
ord_no=pend_no or None,
reason="buy_pending_dup",
request=req,
)
# ── [2b] 예수금 부족 시에만 qty 축소 (기존 전략 산식 우선) ─────
buy_qty = req.qty
if self.cash_ledger is not None:
buy_qty, cash_reason = self.cash_ledger.clamp_buy_qty_if_insufficient(
req.qty, req.price_ref, req.code, req.name, req.strategy_id,
)
if buy_qty <= 0:
return OrderResult(
False,
reason=cash_reason or "insufficient_cash",
request=req,
)
# ── [3] 주문 전송 (지정가 / 시장가) ─────────────────────
if req.use_limit_buy and req.price_ref > 0:
limit_px = int(req.price_ref)
ord_no = self.client.buy_limit_order(req.code, buy_qty, limit_px)
if ord_no:
logger.info(
"📤 [지정가매수] %s %s × %d주 @ %s",
req.name, req.code, buy_qty, f"{limit_px:,}",
)
else:
ord_no = self.client.buy_market_order(req.code, buy_qty)
if not ord_no:
cd = self.client._last_order_msg_cd or ""
m1 = self.client._last_order_msg1 or ""
reason = f"order_reject:{cd or m1}"
logger.error(
"%s❌ [매수주문거부] %s %s: %s%s",
LOG_RED, req.name, req.code, m1 or cd, LOG_RESET,
)
return OrderResult(False, reason=reason, request=req)
# ── [4] DB에 주문 기록 (ODNO PK, 전략단위 UNIQUE) ──────────
inserted = self.db.insert_order(
ord_no=ord_no,
strategy_id=req.strategy_id,
code=req.code, name=req.name,
side="BUY", qty=buy_qty,
price=req.price_ref,
status="SUBMITTED",
raw_json=self._buy_pending_meta(req),
)
if not inserted:
# ord_no PK 중복(동일 ODNO 재전송) — 브로커 주문은 이미 나갔을 수 있음
logger.error(
"%s❌ [주문DB중복] strategy=%s %s ord_no=%s"
"브로커 체결 여부 확인 필요 (active_trades 미반영 가능)%s",
LOG_RED, req.strategy_id, req.code, ord_no, LOG_RESET,
)
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, 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,
filled_qty=filled_qty,
filled_avg_price=filled_price,
status="SUBMITTED",
)
if filled_qty <= 0:
return OrderResult(
False, ord_no=ord_no, reason="zero_fill", request=req,
)
# ── [6] active_trades upsert (전략별 독립 row) ────────────
return self._finalize_buy_fill(
req, ord_no, filled_qty, filled_price, buy_qty,
)
def try_finalize_limit_buy(self, req: OrderRequest, ord_no: str) -> OrderResult:
"""미체결 지정가 — 체결됐으면 active_trades 반영."""
odno = str(ord_no or "").strip()
if not odno or req.qty <= 0:
return OrderResult(False, reason="invalid_limit_finalize", request=req)
wait_sec = float(get_env_int("LIMIT_ORDER_FILL_POLL_SEC", 1))
fill = self.client.get_execution_by_odno(odno, code=req.code, wait_sec=wait_sec)
if not fill or int(fill.get("filled_qty", 0) or 0) <= 0:
return OrderResult(False, reason="not_filled_yet", request=req)
filled_qty = int(fill["filled_qty"])
filled_price = float(fill["avg_price"])
if 0 < filled_qty < req.qty:
miss = req.qty - filled_qty
logger.warning(
"%s⚠️ [지정가 부분체결] [%s] %s %s: %d/%d%s",
LOG_YELLOW, req.strategy_id, req.name, req.code,
filled_qty, req.qty, LOG_RESET,
)
self._try_cancel_buy_remainder(
odno, req.code, miss, use_limit=True,
)
return self._finalize_buy_fill(
req, odno, filled_qty, filled_price, req.qty,
log_tag="지정가체결",
)
# ------------------------------------------------------------------
# 매도
# ------------------------------------------------------------------
def _place_sell(self, req: OrderRequest) -> OrderResult:
if req.qty <= 0:
return OrderResult(False, reason="qty<=0", request=req)
with self._lock_for(req.code):
# ── [1] 매도 실패 백오프 ───────────────────────────────────
backoff_until = self._sell_backoff.get(req.code, 0.0)
if time.time() < backoff_until:
remain = int(backoff_until - time.time())
logger.debug(
"⏸ [매도백오프] %s(%s) — %d초 남음",
req.name, req.code, remain,
)
return OrderResult(False, reason="sell_backoff", request=req)
# ── [2] 실계좌 잔고 재검증 ─────────────────────────────────
# force=True 는 전략 루프 시작 시 prefetch_broker_holdings() 1회만.
# 여기서는 TTL 캐시 공유 → N종목 매도 시 inquire-balance 폭주 방지.
real_qty = None
if get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True):
real_map = self.get_broker_holdings(force=False)
if not self._holdings_last_fetch_ok:
logger.warning(
"%s⏸ [매도보류] [%s] %s %s — 잔고 API 불가 (유령정리 스킵)%s",
LOG_YELLOW, req.strategy_id, req.name, req.code, LOG_RESET,
)
return OrderResult(False, reason="balance_unavailable", request=req)
real_row = real_map.get(req.code)
real_qty = int((real_row or {}).get("qty", 0))
if real_qty <= 0:
return self._purge_ghost_position(req, "broker_zero")
# 실제 보유수량 > 요청수량이면 요청수량만 매도 (다른 전략 몫 보호)
sell_qty = min(req.qty, real_qty)
if sell_qty < req.qty:
# 원인: (1) 모의서버 매수 부분체결 (2) 잔고 반영 지연
# → 실 브로커 수량 기준으로만 매도 (안전)
logger.warning(
"%s⚠️ [매도수량조정] [%s] %s %s: 요청 %d주 → 실보유 %d"
"(미체결 매수 %d주 추정 — 모의서버 부분체결 가능)%s",
LOG_YELLOW, req.strategy_id, req.name, req.code,
req.qty, sell_qty, req.qty - sell_qty, LOG_RESET,
)
else:
sell_qty = req.qty
# ── [2b] 동일 종목 미체결 매도 있으면 중복 주문 방지 ───────
pending_sell = self.db.get_pending_sell_order(req.strategy_id, req.code)
if pending_sell:
pend_no = str(pending_sell.get("ord_no") or "")
logger.info(
"%s⏸ [매도대기중] [%s] %s %s — 기존 ODNO=%s (poll 재조회)%s",
LOG_YELLOW, req.strategy_id, req.name, req.code,
pend_no, LOG_RESET,
)
return OrderResult(
False,
ord_no=pend_no or None,
reason="sell_order_pending",
request=req,
)
# ── [3] 주문 전송 (익절·수익청산만 호가 지정가, 손절·긴급은 시장가) ──
limit_price = 0
use_limit = False
if (
get_env_bool("SELL_USE_ORDERBOOK_ON_PROFIT", True)
and is_profit_take_sell_reason(req.reason or "")
):
ob_raw = None
ob_src = "none"
if self.orderbook_provider:
try:
ob_raw, ob_src = resolve_orderbook_raw(
req.code,
ws_get=self.orderbook_provider,
)
except Exception:
ob_raw = None
if not ob_raw:
ob_raw, ob_src = resolve_orderbook_raw(
req.code,
rest_client=self.client,
)
ok_ob, limit_price, ob_msg = evaluate_take_profit_limit_sell(
sell_qty, ob_raw,
)
if ok_ob and limit_price > 0:
use_limit = True
elif ob_msg == "orderbook_thin":
logger.info(
"%s⏸ [익절대기] %s %s — 매수호가 잔량 부족(%s), 다음 틱 재시도%s",
LOG_YELLOW, req.name, req.code, ob_src, LOG_RESET,
)
return OrderResult(False, reason="orderbook_thin", request=req)
if use_limit:
ord_no = self.client.sell_limit_order(req.code, sell_qty, limit_price)
logger.info(
"📤 [익절지정가] %s %s × %d주 @ %s원 (매수1호가)",
req.name, req.code, sell_qty, f"{limit_price:,}",
)
else:
ord_no = self.client.sell_market_order(req.code, sell_qty)
if not ord_no:
cd = self.client._last_sell_msg_cd or ""
m1 = self.client._last_sell_msg1 or ""
non_biz = {"40100000", "40200000", "APBK0013", "APBK0962", "40910000"}
if cd in non_biz or any(k in m1 for k in ("영업일", "장외", "시장")):
backoff_sec = get_env_int("SELL_FAILURE_BACKOFF_SEC", 1800)
self._sell_backoff[req.code] = time.time() + backoff_sec
logger.warning(
"⏸ [%s(%s)] 매도 실패(%s) → %d초 백오프",
req.name, req.code, m1 or cd, backoff_sec,
)
elif "잔고" in m1 or "보유" in m1 or "APBK3020" in cd:
return self._purge_ghost_position(req, "broker_response")
return OrderResult(False, reason=f"sell_reject:{cd or m1}", request=req)
# ── [4] 주문 기록 ────────────────────────────────────────
self.db.insert_order(
ord_no=ord_no, strategy_id=req.strategy_id,
code=req.code, name=req.name,
side="SELL", qty=sell_qty,
price=req.price_ref,
status="SUBMITTED",
raw_json=self._sell_pending_meta(req),
)
# ── [5] 체결 확인 ─────────────────────────────────────────
wait_sec = float(get_env_int("ORDER_FILL_WAIT_SEC", 2))
fill = self.client.get_execution_by_odno(ord_no, code=req.code, wait_sec=wait_sec)
sell_price = 0.0
filled_qty = 0
if fill and int(fill.get("filled_qty", 0) or 0) > 0:
filled_qty = int(fill["filled_qty"])
sell_price = float(fill["avg_price"])
elif self._strict_fill_required():
self.db.update_order_status(ord_no=ord_no, status="PENDING_FILL")
logger.warning(
"%s⏳ [매도체결대기] [%s] %s %s ODNO=%s — fill 미확인%s",
LOG_YELLOW, req.strategy_id, req.name, req.code, ord_no, LOG_RESET,
)
return OrderResult(
False, ord_no=ord_no, reason="sell_fill_pending", request=req,
)
else:
# 레거시 모의: 참고가로 가정 체결
sell_price = req.price_ref or req.buy_price
filled_qty = sell_qty
self.db.update_order_fill(
ord_no=ord_no, filled_qty=filled_qty,
filled_avg_price=sell_price, status="SUBMITTED",
)
# 익절 지정가 미체결 잔량 → 시장가로 잔여 청산 (손절은 처음부터 시장가)
if use_limit and fill and int(fill.get("filled_qty", 0) or 0) < sell_qty:
remain = sell_qty - int(fill["filled_qty"])
logger.warning(
"%s⚠️ [익절지정가 부분체결] %s 잔여 %d주 시장가 보완%s",
LOG_YELLOW, req.code, remain, LOG_RESET,
)
mkt_no = self.client.sell_market_order(req.code, remain)
if mkt_no:
mfill = self.client.get_execution_by_odno(
mkt_no, code=req.code, wait_sec=wait_sec,
)
if mfill:
add_q = int(mfill.get("filled_qty", 0) or 0)
add_p = float(mfill.get("avg_price", 0) or 0)
if add_q > 0 and add_p > 0:
prev_q = int(fill["filled_qty"])
prev_p = float(fill["avg_price"])
filled_qty = prev_q + add_q
sell_price = (
(prev_p * prev_q + add_p * add_q) / filled_qty
if filled_qty > 0 else add_p
)
fill = {"filled_qty": filled_qty, "avg_price": sell_price}
self.db.update_order_fill(
ord_no=ord_no,
filled_qty=filled_qty,
filled_avg_price=sell_price,
status="FILLED" if filled_qty >= sell_qty else "PARTIAL",
)
elif use_limit and not fill:
logger.warning(
"%s⚠️ [익절지정가 미확인] %s 시장가 재시도%s",
LOG_YELLOW, req.code, LOG_RESET,
)
ord_no2 = self.client.sell_market_order(req.code, sell_qty)
if ord_no2:
fill = self.client.get_execution_by_odno(
ord_no2, code=req.code, wait_sec=wait_sec,
)
if fill:
sell_price = float(fill["avg_price"])
filled_qty = int(fill["filled_qty"])
if filled_qty <= 0 or sell_price <= 0:
if self._strict_fill_required():
self.db.update_order_status(ord_no=ord_no, 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()
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