브랜치 분리 방식: A / B / C

A 선택 시 커밋 메시지: 위 초안 OK / 수정 / 직접 작성
작업 시점: 지금 / 운영 데이터 1~2일 쌓고 / 주말
This commit is contained in:
2026-05-05 21:04:17 +09:00
parent c2b2b711e0
commit f61c471aac
58 changed files with 803502 additions and 1430 deletions

View File

@@ -0,0 +1,5 @@
"""kis_trader.execution — 주문 실행 계층 (KIS 클라이언트 + Master OrderManager)."""
from .kis_client import KISClient
from .order_manager import OrderManager, OrderRequest, OrderResult
__all__ = ["KISClient", "OrderManager", "OrderRequest", "OrderResult"]

View File

@@ -0,0 +1,796 @@
"""
kis_trader/execution/kis_client.py — 한국투자증권 REST 클라이언트 (통합)
==========================================================================
기존 kis_scalping_ver2.KISClient / kis_short_ver3.KISClient 의 공통 기능을
SafeRequest 기반으로 통합. 두 봇이 독립 토큰을 발급받아 충돌나던 문제를
`kis_token_manager` 위임으로 해결.
제공 기능 (매매 봇이 쓰는 최소 세트):
- inquire_price : 현재가
- get_account_balance : 계좌 잔고 (output1=종목별, output2=예수금)
- get_broker_holdings_map : 잔고 → {code: {qty, avg_price, name}} 맵
- get_minute_chart : 분봉 (갭보정용)
- get_daily_chart : 일봉 (대/중/소형주 판정용)
- buy_order / sell_order : 매수·매도 주문 (ODNO 반환)
- get_execution_by_odno : 주문번호로 체결 확인
"""
from __future__ import annotations
import datetime
import logging
from datetime import datetime as dt
from pathlib import Path
from typing import Dict, List, Optional
import pandas as pd
from ..utils.env import (
get_env_bool,
get_env_float,
get_env_from_db,
get_env_int,
)
from ..utils.logger import get_logger
from ..utils.request_handler import SafeRequest
logger = get_logger("kis_trader.kis_client")
# 토큰 캐시 경로 (프로젝트 루트와 동일 위치 공유)
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
class KISClient(SafeRequest):
"""한국투자증권 REST API 통합 클라이언트. SafeRequest 상속."""
REAL_BASE = "https://openapi.koreainvestment.com:9443"
MOCK_BASE = "https://openapivts.koreainvestment.com:29443"
def __init__(
self,
*,
mock: Optional[bool] = None,
app_key: Optional[str] = None,
app_secret: Optional[str] = None,
account_no: Optional[str] = None,
account_code: Optional[str] = None,
):
# 최소 호출 간격 0.22초 ≈ 초당 4~5건 (한투 REST 제한 안전 마진)
super().__init__(
min_interval_sec=get_env_float("KIS_MIN_INTERVAL_SEC", 0.22),
max_retries=get_env_int("KIS_REST_MAX_RETRIES", 5),
backoff_base=1.0,
backoff_cap=get_env_float("KIS_REST_BACKOFF_CAP_SEC", 8.0),
timeout_sec=get_env_float("KIS_REST_TIMEOUT_SEC", 10.0),
)
self.mock = bool(get_env_bool("KIS_MOCK", True)) if mock is None else bool(mock)
if self.mock:
self.app_key = app_key or get_env_from_db("KIS_APP_KEY_MOCK", "")
self.app_secret = app_secret or get_env_from_db("KIS_APP_SECRET_MOCK", "")
self.account_no = account_no or get_env_from_db("KIS_ACCOUNT_NO_MOCK", "")
self.account_code = account_code or get_env_from_db(
"KIS_ACCOUNT_CODE_MOCK", "01"
)
else:
self.app_key = app_key or get_env_from_db("KIS_APP_KEY_REAL", "")
self.app_secret = app_secret or get_env_from_db("KIS_APP_SECRET_REAL", "")
self.account_no = account_no or get_env_from_db("KIS_ACCOUNT_NO_REAL", "")
self.account_code = account_code or get_env_from_db(
"KIS_ACCOUNT_CODE_REAL", "01"
)
self.base_url = self.MOCK_BASE if self.mock else self.REAL_BASE
self._token: Optional[str] = None
# 마지막 주문 실패 원인 (매매불가, 잔고없음 등 분기)
self._last_order_msg_cd: Optional[str] = None
self._last_order_msg1: Optional[str] = None
self._last_sell_msg_cd: Optional[str] = None
self._last_sell_msg1: Optional[str] = None
self._init_token()
# ------------------------------------------------------------------
# 토큰
# ------------------------------------------------------------------
def _init_token(self) -> None:
"""kis_token_manager 경로로만 발급 (23시간 캐시, 파일 잠금 준수)."""
try:
from kis_token_manager import KisTokenManager, ensure_token
# 캐시가 유효하면 즉시 사용, 만료면 재발급
ensure_token(self.mock)
self._token = KisTokenManager.instance(is_mock=self.mock).get_token()
except Exception as e:
logger.warning("kis_token_manager 연동 실패: %s", e)
self._token = None
def _refresh_token_if_needed(self) -> None:
"""호출 직전 토큰 만료 임박 시 선제 갱신."""
try:
from kis_token_manager import KisTokenManager
fresh = KisTokenManager.instance(is_mock=self.mock).get_token()
if fresh:
self._token = fresh
except Exception:
pass
# ------------------------------------------------------------------
# 저수준 GET/POST
# ------------------------------------------------------------------
def _headers(self, tr_id: str, is_post: bool = False) -> dict:
h = {
"authorization": f"Bearer {self._token}",
"appkey": self.app_key,
"appsecret": self.app_secret,
"tr_id": tr_id,
"custtype": "P",
}
if is_post:
h["content-type"] = "application/json; charset=utf-8"
return h
def _get(self, path: str, tr_id: str, params: dict):
self._refresh_token_if_needed()
return self.get(
self.base_url + path,
headers=self._headers(tr_id),
params=params,
)
def _post(self, path: str, tr_id: str, body: dict):
self._refresh_token_if_needed()
return self.post(
self.base_url + path,
headers=self._headers(tr_id, is_post=True),
json_body=body,
)
# ------------------------------------------------------------------
# 시세
# ------------------------------------------------------------------
def inquire_price(self, code: str) -> Optional[dict]:
"""현재가 [v1_국내주식-007]"""
r = self._get(
"/uapi/domestic-stock/v1/quotations/inquire-price",
"FHKST01010100",
{"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code},
)
if r.status_code != 200:
return None
j = r.json()
return j.get("output") if j.get("rt_cd") == "0" else None
def inquire_index_price(self, index_code: str = "0001") -> Optional[dict]:
"""
국내 업종지수 현재지수 [v1_국내주식-066] — MarketGuard 전용.
index_code:
- "0001": KOSPI 종합
- "1001": KOSDAQ 종합
- "2001": KOSPI200
반환 dict 주요 키:
- bstp_nmix_prpr : 현재 지수
- bstp_nmix_prdy_clpr : 전일 종가
- bstp_nmix_prdy_vrss : 전일 대비
- bstp_nmix_prdy_ctrt : 전일 대비 등락률(%)
FID_COND_MRKT_DIV_CODE = 'U' (업종/지수). 종목조회의 'J' 와 다름.
"""
r = self._get(
"/uapi/domestic-stock/v1/quotations/inquire-index-price",
"FHPUP02100000",
{"FID_COND_MRKT_DIV_CODE": "U", "FID_INPUT_ISCD": index_code},
)
if r.status_code != 200:
return None
j = r.json()
return j.get("output") if j.get("rt_cd") == "0" else None
def get_minute_chart(
self, code: str, period: str = "1", limit: int = 100
) -> pd.DataFrame:
"""
분봉 [v1_국내주식-017] — 갭 보정용.
⚠️ 한투 FHKST03010200 스펙:
* 1분봉만 지원 (period 인자는 상위 호출자 호환용으로만 유지)
* 1회 호출 ≤ 30봉, 응답은 최신→과거 역순
* FID_INPUT_HOUR_1 = HHMMSS 형식의 "조회 커서 시각"
* 커서를 뒤로 밀며 페이지네이션해 limit 개수만큼 수집
(holding_bot.fetch_and_store_min_candles 과 동일 패턴 — 검증된 파라미터)
"""
path = "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice"
tr_id = "FHKST03010200"
try:
# 페이지네이션 커서 — 장중이면 현재 시각, 장 마감 후엔 15:30:00
now = dt.now()
if now.hour < 9:
cursor_dt = now.replace(hour=15, minute=30, second=0, microsecond=0) \
- datetime.timedelta(days=1)
elif now.hour > 15 or (now.hour == 15 and now.minute >= 30):
cursor_dt = now.replace(hour=15, minute=30, second=0, microsecond=0)
else:
cursor_dt = now
rows: list = []
seen: set = set() # 중복 제거용 (time 키)
# 최대 페이지 수 — 한 페이지 ≈ 30봉 기준 여유 있게 계산
max_pages = max(1, (int(limit) // 25) + 2)
for _ in range(max_pages):
params = {
"FID_ETC_CLS_CODE": "",
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": code,
# HHMMSS 커서 (역순으로 이동)
"FID_INPUT_HOUR_1": cursor_dt.strftime("%H%M%S"),
"FID_PW_DATA_INCU_YN": "Y", # 과거 날짜 포함
}
r = self._get(path, tr_id, params)
if r.status_code != 200:
break
j = r.json()
if j.get("rt_cd") != "0":
break
out = j.get("output2", [])
if not out:
break
page_last_dt = None
for it in out:
try:
d = str(it.get("stck_bsop_date", "") or "")
t = str(it.get("stck_cntg_hour", "") or "000000")
if len(d) < 8 or len(t) < 6:
continue
# YYYYMMDDHHMM (12자리) — CandleAggregator.fill_gap_from_rest 호환
tkey = d + t[:4]
if tkey in seen:
continue
seen.add(tkey)
rows.append({
"time": tkey,
"open": abs(float(it.get("stck_oprc", 0))),
"high": abs(float(it.get("stck_hgpr", 0))),
"low": abs(float(it.get("stck_lwpr", 0))),
"close": abs(float(it.get("stck_clpr", 0))),
"volume": int(it.get("acml_vol", 0) or 0),
})
# 절대 시각 파싱 → 다음 커서 계산용
try:
page_last_dt = dt.strptime(d + t, "%Y%m%d%H%M%S")
except Exception:
pass
except Exception:
continue
if len(rows) >= int(limit) or page_last_dt is None:
break
# 다음 페이지: 마지막(가장 오래된) 레코드 시각 - 1분
cursor_dt = page_last_dt - datetime.timedelta(minutes=1)
# 장외시간으로 넘어가면 중단 (더 과거는 한투 FHKST03010200이 안 줌)
if cursor_dt.hour < 9 or (cursor_dt.hour == 15 and cursor_dt.minute >= 30) \
or cursor_dt.hour > 15:
break
if not rows:
return pd.DataFrame()
df = pd.DataFrame(rows).sort_values("time").reset_index(drop=True)
return df.tail(int(limit))
except Exception as e:
logger.debug("분봉 조회 실패(%s): %s", code, e)
return pd.DataFrame()
def get_daily_chart(self, code: str, limit: int = 10) -> pd.DataFrame:
"""일봉 [v1_국내주식-017] — 거래대금(대/중/소형) 판정용."""
path = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
tr_id = "FHKST03010100"
try:
end_dt = dt.now()
start_dt = end_dt - datetime.timedelta(days=limit + 30)
r = self._get(
path, tr_id,
{
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": code,
"FID_INPUT_DATE_1": start_dt.strftime("%Y%m%d"),
"FID_INPUT_DATE_2": end_dt.strftime("%Y%m%d"),
"FID_PERIOD_DIV_CODE": "D",
"FID_ORG_ADJ_PRC": "1",
},
)
if r.status_code != 200:
return pd.DataFrame()
j = r.json()
if j.get("rt_cd") != "0":
return pd.DataFrame()
out = j.get("output2", [])
if not out:
return pd.DataFrame()
rows = []
for it in out[:limit]:
try:
rows.append({
"date": str(it.get("stck_bsop_date", "") or ""),
"open": abs(float(it.get("stck_oprc", 0))),
"high": abs(float(it.get("stck_hgpr", 0))),
"low": abs(float(it.get("stck_lwpr", 0))),
"close": abs(float(it.get("stck_clpr", 0))),
"volume": int(it.get("acml_vol", 0)),
})
except Exception:
continue
if not rows:
return pd.DataFrame()
return pd.DataFrame(rows).sort_values("date").reset_index(drop=True)
except Exception as e:
logger.debug("일봉 조회 실패(%s): %s", code, e)
return pd.DataFrame()
# ------------------------------------------------------------------
# 랭킹 (거래량/거래대금/체결강도/등락률 상위 — FHPST01710000)
# ------------------------------------------------------------------
# volume-rank API 는 tr_cont 를 받지 않는다. 1회 호출 ≤ 30~50건 반환.
# FID_BLNG_CLS_CODE:
# 0=평균거래량, 1=거래증가율, 2=평균거래회전율,
# 3=거래금액순, 4=등락률(상승), 5=등락률(하락), 6=체결강도순
@staticmethod
def _is_valid_stock_for_rank(name: str, code: str) -> bool:
"""스팩/ETN/레버리지/인버스/우선주 등 비본주 제외 (기존 kiwoom 스캐너 규칙과 동일)."""
if not code or not name:
return False
code = code.strip()
name = name.strip()
if len(code) != 6:
return False
nm = name.upper()
# ETN / ETF / 레버리지 / 인버스
bad_kw = ("ETN", "ETF", "레버리지", "인버스", "2X", "3X", "선물", "KODEX", "TIGER",
"KBSTAR", "ARIRANG", "HANARO", "SOL ", "KOSEF", "ACE ", "KINDEX",
"파워", "히어로", "SMART", "TREX", "WON", "KOACT", "TIMEFOLIO")
if any(k in nm for k in bad_kw):
return False
# 스팩 (기업인수목적회사)
if "스팩" in name or "SPAC" in nm:
return False
# 우선주 (뒤에 우/우B 등)
if name.endswith("") or name.endswith("우B") or name.endswith("(전환)"):
return False
return True
def _filter_rank_rows(self, rows: list) -> list:
if not rows:
return []
out = []
for it in rows:
code = (
it.get("mksc_shrn_iscd") or it.get("stk_cd")
or it.get("code") or ""
).strip()
name = (
it.get("hts_kor_isnm") or it.get("stk_nm")
or it.get("prst_name") or ""
).strip()
if self._is_valid_stock_for_rank(name, code):
out.append(it)
return out
def _fetch_volume_rank(
self,
*,
market: str = "J",
blng_cls_code: str = "0",
limit: int = 100,
exclude_non_stock: bool = True,
) -> List[dict]:
"""FHPST01710000 1회 호출 → rows."""
path = "/uapi/domestic-stock/v1/quotations/volume-rank"
tr_id = "FHPST01710000"
params = {
"FID_COND_MRKT_DIV_CODE": market,
"FID_COND_SCR_DIV_CODE": "20171",
"FID_INPUT_ISCD": "0000",
"FID_DIV_CLS_CODE": "0",
"FID_BLNG_CLS_CODE": blng_cls_code,
"FID_TRGT_CLS_CODE": "111111111",
"FID_TRGT_EXLS_CLS_CODE": "0000000000",
"FID_INPUT_PRICE_1": "0",
"FID_INPUT_PRICE_2": "0",
"FID_VOL_CNT": "0",
"FID_INPUT_DATE_1": "",
}
try:
r = self._get(path, tr_id, params)
if r.status_code != 200:
logger.debug("랭킹 HTTP %s (blng=%s)", r.status_code, blng_cls_code)
return []
j = r.json()
if j.get("rt_cd") != "0":
logger.debug(
"랭킹 실패 rt_cd=%s msg=%s",
j.get("rt_cd"), j.get("msg1"),
)
return []
rows = j.get("output") or []
if isinstance(rows, dict):
rows = [rows]
if exclude_non_stock:
rows = self._filter_rank_rows(rows)
return rows[:limit]
except Exception as e:
logger.debug("랭킹 조회 예외 (blng=%s): %s", blng_cls_code, e)
return []
def get_volume_rank(
self, *, market: str = "J", limit: int = 100, exclude_non_stock: bool = True,
) -> List[dict]:
"""거래량 상위."""
return self._fetch_volume_rank(
market=market, blng_cls_code="0",
limit=limit, exclude_non_stock=exclude_non_stock,
)
def get_trading_value_rank(
self, *, market: str = "J", limit: int = 100, exclude_non_stock: bool = True,
) -> List[dict]:
"""거래대금 상위."""
return self._fetch_volume_rank(
market=market, blng_cls_code="3",
limit=limit, exclude_non_stock=exclude_non_stock,
)
def get_execution_strength_rank(
self, *, market: str = "J", limit: int = 100, exclude_non_stock: bool = True,
) -> List[dict]:
"""체결강도 상위 (매수세 강한 종목)."""
return self._fetch_volume_rank(
market=market, blng_cls_code="6",
limit=limit, exclude_non_stock=exclude_non_stock,
)
def get_price_change_rank(
self,
*,
market: str = "J",
sort_type: str = "up",
limit: int = 100,
exclude_non_stock: bool = True,
) -> List[dict]:
"""등락률 상위. sort_type='up' 상승, 'down' 하락(낙폭)."""
blng = "5" if str(sort_type).lower() in ("down", "decline", "2") else "4"
rows = self._fetch_volume_rank(
market=market, blng_cls_code=blng,
limit=limit, exclude_non_stock=exclude_non_stock,
)
if rows or blng == "5":
return rows
# 일부 계정에서 4/5 미지원 → 거래량 fallback (상승만)
return self.get_volume_rank(
market=market, limit=limit, exclude_non_stock=exclude_non_stock,
)
def get_execution_strength_map(
self, *, market: str = "J", limit: int = 200,
) -> Dict[str, float]:
"""
체결강도 상위 조회 → {code: cntr_str(체결강도값)} 맵.
⚠️ 검증된 제약 (실호출 확인, 2025-04 기준):
* KIS volume-rank(FHPST01710000) 응답에 **체결강도 값 필드가 없음**.
응답은 거래량/거래대금/회전율만 포함. 따라서 본 맵은 0.0 으로 채워짐.
* 모의투자 서버에서는 ``blng=6`` 정렬이 체결강도 순이 아닌 종목코드 순
으로 반환됨(실계좌는 정렬이 정상일 가능성 높음).
* ✅ **정확한 실시간 체결강도**는 WebSocket ``H0STCNT0`` 의 ``cttr``
필드를 사용할 것. ``WSManager.price_cache`` 에 이미 들어 있음.
실사용 가이드:
- 유니버스 시드만 필요하면 ``get_execution_strength_rank()`` 를 직접 호출
(정렬 순서만 쓰고 값 파싱은 생략).
- 체결강도 값 기반 필터(≥ 120 등)는 전략 코드에서 WS tick 으로 해결.
"""
rows = self.get_execution_strength_rank(market=market, limit=limit)
out: Dict[str, float] = {}
for it in rows or []:
code = (
it.get("mksc_shrn_iscd") or it.get("stk_cd")
or it.get("code") or ""
).strip()
if not code or len(code) != 6:
continue
raw = (
it.get("cntr_str") or it.get("exec_str")
or it.get("strg_rt") or ""
)
try:
out[code] = float(str(raw).replace(",", "").strip()) if raw else 0.0
except (ValueError, TypeError):
out[code] = 0.0
return out
# ------------------------------------------------------------------
# 계좌/잔고
# ------------------------------------------------------------------
def get_account_balance(self) -> Optional[dict]:
"""계좌 잔고 [국내주식-006] (inquire-balance)."""
tr_id = "VTTC8434R" if self.mock else "TTTC8434R"
try:
r = self._get(
"/uapi/domestic-stock/v1/trading/inquire-balance",
tr_id,
{
"CANO": self.account_no, "ACNT_PRDT_CD": self.account_code,
"AFHR_FLPR_YN": "N", "OFL_YN": "N", "INQR_DVSN": "01",
"UNPR_DVSN": "01", "FUND_STTL_ICLD_YN": "N",
"FNCG_AMT_AUTO_RDPT_YN": "N", "PRCS_DVSN": "00",
"CTX_AREA_FK100": "", "CTX_AREA_NK100": "",
},
)
if r.status_code != 200:
return None
j = r.json()
return j if j.get("rt_cd") == "0" else None
except Exception as e:
logger.error("계좌 잔고 조회 실패: %s", e)
return None
def get_broker_holdings_map(self) -> Dict[str, Dict]:
"""
잔고 API output1 → {code: {qty, avg_price, name, evlu_amt}} 맵 변환.
** 모든 매도 주문 직전 이 맵으로 실제 보유 수량을 검증해야 한다. **
"""
balance = self.get_account_balance()
if not balance:
return {}
output1 = balance.get("output1") or []
if isinstance(output1, dict):
output1 = [output1]
result: Dict[str, Dict] = {}
for it in output1:
code = (it.get("pdno") or it.get("PDNO") or "").strip()
if not code:
continue
qty_raw = (
it.get("hldg_qty") or it.get("HLDG_QTY")
or it.get("ord_psbl_qty") or it.get("ORD_PSBL_QTY") or 0
)
try:
qty = int(float(str(qty_raw).replace(",", "")))
except Exception:
qty = 0
if qty <= 0:
continue
avg_raw = it.get("pchs_avg_pric") or it.get("PCHS_AVG_PRIC") or 0
try:
avg_price = abs(float(str(avg_raw).replace(",", "")))
except Exception:
avg_price = 0.0
name = (it.get("prdt_name") or it.get("PRDT_NAME") or code).strip()
evlu_raw = it.get("evlu_amt") or it.get("EVLU_AMT") or 0
try:
evlu_amt = float(str(evlu_raw).replace(",", ""))
except Exception:
evlu_amt = 0.0
result[code] = {
"qty": qty,
"avg_price": avg_price,
"name": name,
"evlu_amt": evlu_amt,
}
return result
# ------------------------------------------------------------------
# 주문
# ------------------------------------------------------------------
def _order(
self,
*,
code: str,
qty: int,
price: int,
order_type: str,
side: str,
) -> Optional[str]:
"""
주문 공통 호출. 성공 시 ODNO(str) 반환, 실패 시 None.
side: 'BUY' | 'SELL'
"""
side = side.upper()
if side == "BUY":
tr_id = "VTTC0802U" if self.mock else "TTTC0802U"
else:
tr_id = "VTTC0801U" if self.mock else "TTTC0801U"
path = "/uapi/domestic-stock/v1/trading/order-cash"
body = {
"CANO": self.account_no, "ACNT_PRDT_CD": self.account_code,
"PDNO": code, "ORD_DVSN": order_type,
"ORD_QTY": str(qty), "ORD_UNPR": str(price),
}
try:
r = self._post(path, tr_id, body)
if r.status_code != 200:
logger.error("주문 HTTP 에러 side=%s code=%s status=%s",
side, code, r.status_code)
return None
j = r.json()
if j.get("rt_cd") == "0":
if side == "BUY":
self._last_order_msg_cd = None
self._last_order_msg1 = None
else:
self._last_sell_msg_cd = None
self._last_sell_msg1 = None
ord_no = str((j.get("output") or {}).get("ODNO", "") or "").strip()
return ord_no or None
# 실패: 원인 저장 (매매불가/영업일 아님 등 분기용)
if side == "BUY":
self._last_order_msg_cd = j.get("msg_cd", "")
self._last_order_msg1 = str(j.get("msg1", "") or "")
logger.error(
"[매수주문실패] code=%s rt_cd=%s msg_cd=%s msg1=%s",
code, j.get("rt_cd"),
self._last_order_msg_cd, self._last_order_msg1,
)
else:
self._last_sell_msg_cd = j.get("msg_cd", "")
self._last_sell_msg1 = str(j.get("msg1", "") or "")
logger.error(
"[매도주문실패] code=%s rt_cd=%s msg_cd=%s msg1=%s",
code, j.get("rt_cd"),
self._last_sell_msg_cd, self._last_sell_msg1,
)
return None
except Exception as e:
logger.error("주문 예외 side=%s code=%s: %s", side, code, e)
return None
def buy_market_order(self, code: str, qty: int) -> Optional[str]:
"""시장가 매수. 실전은 USE_MARKET_IOC 설정 시 IOC(13), 아니면 일반 시장가(01)."""
if self.mock:
order_type = "01"
else:
order_type = "13" if get_env_bool("USE_MARKET_IOC", True) else "01"
return self._order(
code=code, qty=qty, price=0, order_type=order_type, side="BUY"
)
def buy_limit_order(self, code: str, qty: int, price: int) -> Optional[str]:
"""지정가 매수 (ORD_DVSN=00). 돌파/꼬리잡기 전략에서 사용."""
return self._order(
code=code, qty=qty, price=int(price), order_type="00", side="BUY"
)
def sell_market_order(self, code: str, qty: int) -> Optional[str]:
"""시장가 매도. IOC 에러가 너무 빈번한 경우를 위해 기본 '01' 일반 시장가."""
return self._order(
code=code, qty=qty, price=0, order_type="01", side="SELL"
)
# ------------------------------------------------------------------
# 조건검색 (REST — 웹소켓은 공식 지원 안 함. 30초 폴링이 정석)
# ------------------------------------------------------------------
def get_condition_list(self, user_id: str) -> List[Dict]:
"""
HTS/MTS 에 서버 저장된 조건식 목록 조회.
[국내주식] 시세분석 - 종목조건검색 목록조회 (psearch-title)
Returns: [{"seq":"0","condition_name":"우상향돌파",...}, ...]
"""
try:
r = self._get(
"/uapi/domestic-stock/v1/quotations/psearch-title",
"HHKST03900300",
{"user_id": user_id},
)
if r.status_code != 200:
return []
j = r.json()
if j.get("rt_cd") != "0":
return []
out = j.get("output2") or []
if isinstance(out, dict):
out = [out]
return out
except Exception as e:
logger.debug("조건식 목록 조회 실패: %s", e)
return []
def get_condition_result(self, user_id: str, seq: str) -> List[Dict]:
"""
조건식에 걸려 있는 종목 목록 조회 (psearch-result).
Returns: [{"code":"005930","name":"삼성전자",...}, ...]
"""
try:
r = self._get(
"/uapi/domestic-stock/v1/quotations/psearch-result",
"HHKST03900400",
{"user_id": user_id, "seq": str(seq)},
)
if r.status_code != 200:
return []
j = r.json()
if j.get("rt_cd") != "0":
return []
out = j.get("output2") or []
if isinstance(out, dict):
out = [out]
parsed: List[Dict] = []
for it in out:
code = (
it.get("code") or it.get("stck_shrn_iscd")
or it.get("mksc_shrn_iscd") or ""
).strip()
if not code:
continue
name = (
it.get("name") or it.get("hts_kor_isnm")
or it.get("stck_prpr") or code
)
parsed.append({"code": code, "name": str(name).strip() or code})
return parsed
except Exception as e:
logger.debug("조건검색 결과 조회 실패 (seq=%s): %s", seq, e)
return []
def get_order_history_today(self) -> Optional[dict]:
"""당일 주문 내역 전체 조회 [국내주식-005 inquire-daily-ccld]."""
tr_id = "VTTC8001R" if self.mock else "TTTC8001R"
today = dt.now().strftime("%Y%m%d")
try:
r = self._get(
"/uapi/domestic-stock/v1/trading/inquire-daily-ccld",
tr_id,
{
"CANO": self.account_no, "ACNT_PRDT_CD": self.account_code,
"INQR_STRT_DT": today, "INQR_END_DT": today,
"SLL_BUY_DVSN_CD": "00", "INQR_DVSN": "00", "PDNO": "",
"CCLD_DVSN": "00", "ORD_GNO_BRNO": "", "ODNO": "",
"INQR_DVSN_3": "00", "INQR_DVSN_1": "",
"CTX_AREA_FK100": "", "CTX_AREA_NK100": "",
},
)
if r.status_code != 200:
return None
j = r.json()
return j if j.get("rt_cd") == "0" else None
except Exception as e:
logger.debug("주문 내역 조회 실패: %s", e)
return None
def get_execution_by_odno(
self, ord_no: str, code: Optional[str] = None, wait_sec: float = 2.0
) -> Optional[Dict]:
"""
ODNO 로 당일 체결 조회.
반환: {"filled_qty": int, "avg_price": float} 또는 None
"""
import time as _t
if not ord_no:
return None
_t.sleep(max(0.0, wait_sec))
j = self.get_order_history_today()
if not j:
return None
out1 = j.get("output1") or []
if isinstance(out1, dict):
out1 = [out1]
for row in out1:
row_odno = str(row.get("odno") or row.get("ODNO") or "").strip()
row_pdno = str(row.get("pdno") or row.get("PDNO") or "").strip()
if row_odno != str(ord_no).strip():
continue
if code and row_pdno and row_pdno != str(code).strip():
continue
filled = row.get("tot_ccld_qty") or row.get("TOT_CCLD_QTY") or 0
avg = (
row.get("avg_prvs") or row.get("AVG_PRVS")
or row.get("ord_unpr") or row.get("ORD_UNPR") or 0
)
try:
q = int(float(str(filled).replace(",", "")))
p = float(str(avg).replace(",", ""))
except Exception:
continue
if q > 0 and p > 0:
return {"filled_qty": q, "avg_price": p}
return None

View File

@@ -0,0 +1,510 @@
"""
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