511 lines
24 KiB
Python
511 lines
24 KiB
Python
"""
|
||
kis_trader/execution/order_manager.py — Master Executor
|
||
========================================================
|
||
두 전략(스캘핑/꼬리잡기)이 공유하는 "단 하나의 주문 실행자".
|
||
|
||
설계 목적:
|
||
* 전략은 **시그널만 생성**한다. 주문 실행은 전부 여기서 직렬화 처리.
|
||
* 매수 전 실계좌 잔고 재확인 → 다른 전략이 이미 보유 중이면 차단.
|
||
* 매도 전 실계좌 잔고 재확인 → 실제 보유 0 이면 매도 시도 자체를 차단
|
||
(DB·메모리 정리까지 한 번에).
|
||
* 주문번호(ODNO) 기준 UNIQUE 제약으로 **서버단 중복 차단**.
|
||
|
||
정책 (env_config 로 제어):
|
||
* ``STRATEGY_SAME_CODE_POLICY``
|
||
- ``block`` (기본): 한 종목은 한 전략만. 다른 전략이 이미 들고 있으면 신규 매수 차단.
|
||
- ``allow`` : 두 전략 독립 보유 허용 (기존 복합PK 구조 유지).
|
||
* ``REAL_BALANCE_VERIFY_BEFORE_SELL`` (기본 True): 매도 전 실잔고 조회.
|
||
* ``REAL_BALANCE_VERIFY_BEFORE_BUY`` (기본 True): 매수 전 실잔고 조회.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import datetime
|
||
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_from_db, get_env_int
|
||
from ..utils.logger import LOG_CYAN, LOG_GREEN, LOG_RED, LOG_RESET, LOG_YELLOW, get_logger, msg_mm
|
||
from .kis_client import KISClient
|
||
|
||
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
|
||
# 매도 시 계산 결과 전달 (로그용)
|
||
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 호출 횟수 절감.
|
||
"""
|
||
|
||
# 실잔고 캐시 TTL (초). 너무 짧으면 API 폭주, 너무 길면 sync out.
|
||
_HOLDINGS_CACHE_TTL = 2.0
|
||
|
||
def __init__(self, *, client: KISClient, db: TradeDBExt):
|
||
self.client = client
|
||
self.db = db
|
||
# 종목별 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._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
|
||
|
||
# ------------------------------------------------------------------
|
||
# Lock 헬퍼
|
||
# ------------------------------------------------------------------
|
||
def _lock_for(self, code: str) -> threading.Lock:
|
||
with self._global_lock:
|
||
return self._code_locks[code]
|
||
|
||
# ------------------------------------------------------------------
|
||
# 실잔고 조회 (캐시)
|
||
# ------------------------------------------------------------------
|
||
def get_broker_holdings(self, force: bool = False) -> Dict[str, Dict]:
|
||
"""
|
||
실계좌 잔고 맵 {code: {qty, avg_price, ...}}.
|
||
TTL 캐시로 API 폭주 방지.
|
||
"""
|
||
now = time.time()
|
||
with self._holdings_lock:
|
||
if (
|
||
not force
|
||
and self._holdings_cache is not None
|
||
and (now - self._holdings_cache_ts) < self._HOLDINGS_CACHE_TTL
|
||
):
|
||
return dict(self._holdings_cache)
|
||
m = self.client.get_broker_holdings_map()
|
||
# API 호출 실패 시 빈 dict 반환 → 정책상 "알 수 없음"으로 간주.
|
||
if m is not None:
|
||
self._holdings_cache = m
|
||
self._holdings_cache_ts = now
|
||
return dict(m)
|
||
return {}
|
||
|
||
def invalidate_holdings_cache(self) -> None:
|
||
with self._holdings_lock:
|
||
self._holdings_cache = None
|
||
self._holdings_cache_ts = 0.0
|
||
|
||
# ------------------------------------------------------------------
|
||
# 공개 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", "block")).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] 실계좌 잔고 재검증 (선택적) ────────────────────────
|
||
if get_env_bool("REAL_BALANCE_VERIFY_BEFORE_BUY", True):
|
||
real_map = self.get_broker_holdings()
|
||
if req.code in real_map and real_map[req.code]["qty"] > 0:
|
||
logger.warning(
|
||
"%s🚫 [이미실보유] %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)
|
||
|
||
# ── [3] 주문 전송 ────────────────────────────────────────
|
||
ord_no = self.client.buy_market_order(req.code, req.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=req.qty,
|
||
price=req.price_ref,
|
||
status="SUBMITTED",
|
||
)
|
||
if not inserted:
|
||
# ORDERS UNIQUE 위반 = "이미 같은 전략·같은 종목·같은 날·같은 방향"
|
||
# 아주 드문 케이스(동일 시각 중복 호출). 상태 REJECT 로 두고 스킵.
|
||
logger.warning(
|
||
"⚠️ [주문 UNIQUE 충돌] strategy=%s code=%s ord_no=%s — 이미 DB 기록 존재",
|
||
req.strategy_id, req.code, ord_no,
|
||
)
|
||
return OrderResult(
|
||
False, ord_no=ord_no, reason="duplicate_order_record", request=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)
|
||
if fill:
|
||
filled_qty = fill["filled_qty"]
|
||
filled_price = fill["avg_price"]
|
||
self.db.update_order_fill(
|
||
ord_no=ord_no,
|
||
filled_qty=filled_qty,
|
||
filled_avg_price=filled_price,
|
||
status=("FILLED" if filled_qty == req.qty else "PARTIAL"),
|
||
)
|
||
# 모의서버에서 빈번한 부분체결 감지 → 사용자 인지용 경고 로그
|
||
if 0 < filled_qty < req.qty:
|
||
miss = req.qty - filled_qty
|
||
logger.warning(
|
||
"%s⚠️ [매수 부분체결] [%s] %s %s: 주문 %d주 → 체결 %d주 (미체결 %d주, ODNO=%s)%s",
|
||
LOG_YELLOW, req.strategy_id, req.name, req.code,
|
||
req.qty, filled_qty, miss, ord_no, LOG_RESET,
|
||
)
|
||
else:
|
||
# 체결 확인 실패 → 주문 수량/시그널가 기준으로 가정 저장
|
||
filled_qty = req.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",
|
||
)
|
||
|
||
# ── [6] active_trades upsert (전략별 독립 row) ────────────
|
||
now_str = dt.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
self.db.upsert_trade({
|
||
"code": req.code,
|
||
"name": req.name,
|
||
"strategy": 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()
|
||
|
||
logger.info(
|
||
"%s✅ [매수체결] [%s] %s %s @ %d원 × %d주 (ODNO=%s)%s",
|
||
LOG_GREEN, req.strategy_id, req.name, req.code,
|
||
int(filled_price), filled_qty, ord_no, LOG_RESET,
|
||
)
|
||
# 체결 알림 (매매 루프 내부에서 호출되므로 jitter=False)
|
||
try:
|
||
disp = _strategy_display(req.strategy_id)
|
||
header = (
|
||
f"🔷 **[매수체결:{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} (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)
|
||
tail = ""
|
||
msg = header + ("\n" + tail if tail else "")
|
||
msg_mm(
|
||
msg,
|
||
channel_alias=_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 _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] 실계좌 잔고 재검증 ─────────────────────────────────
|
||
real_qty = None
|
||
if get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True):
|
||
real_map = self.get_broker_holdings(force=True)
|
||
real_row = real_map.get(req.code)
|
||
real_qty = int((real_row or {}).get("qty", 0))
|
||
if real_qty <= 0:
|
||
# 브로커에 없는데 DB/메모리에만 남은 유령 포지션 → 강제 정리
|
||
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)
|
||
return OrderResult(False, reason="broker_no_position", request=req)
|
||
# 실제 보유수량 > 요청수량이면 요청수량만 매도 (다른 전략 몫 보호)
|
||
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
|
||
|
||
# ── [3] 주문 전송 ─────────────────────────────────────────
|
||
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:
|
||
# 브로커 잔고 없음 → DB 정리
|
||
logger.warning(
|
||
"⚠️ [유령잔고응답] %s %s: 로컬 active_trades 삭제",
|
||
req.name, req.code,
|
||
)
|
||
self.db.delete_active_trade(code=req.code, strategy=req.strategy_id)
|
||
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",
|
||
)
|
||
|
||
# ── [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)
|
||
if fill:
|
||
self.db.update_order_fill(
|
||
ord_no=ord_no,
|
||
filled_qty=fill["filled_qty"],
|
||
filled_avg_price=fill["avg_price"],
|
||
status="FILLED" if fill["filled_qty"] == sell_qty else "PARTIAL",
|
||
)
|
||
sell_price = fill["avg_price"]
|
||
filled_qty = fill["filled_qty"]
|
||
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",
|
||
)
|
||
|
||
# ── [6] 손익 계산 + active_trades→trade_history 이동 ───────
|
||
# 수수료/거래세 반영한 순손익 계산 (전략 공통 규칙 + .cursorrules)
|
||
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 # close_trade 내부에서 gross 로 계산
|
||
|
||
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()
|
||
|
||
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}주 | {req.reason} | "
|
||
f"수익률 {req.profit_pct*100:+.2f}% (실현 {pnl_str}) (ODNO={ord_no})"
|
||
)
|
||
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)
|
||
tail = ""
|
||
msg = header + ("\n" + tail if tail else "")
|
||
msg_mm(
|
||
msg,
|
||
channel_alias=_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 _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. 환경변수에 없으면 기본 채널로 폴백."""
|
||
if strategy_id.startswith("SCALP"):
|
||
return str(get_env_from_db("KIS_SCALP_MM_CHANNEL", "scalping"))
|
||
if strategy_id.startswith("SHORT"):
|
||
return str(get_env_from_db("KIS_SHORT_MM_CHANNEL", "stock"))
|
||
return str(get_env_from_db("MATTERMOST_CHANNEL", "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
|