브랜치 분리 방식: A / B / C
A 선택 시 커밋 메시지: 위 초안 OK / 수정 / 직접 작성 작업 시점: 지금 / 운영 데이터 1~2일 쌓고 / 주말
This commit is contained in:
5
kis_trader/execution/__init__.py
Normal file
5
kis_trader/execution/__init__.py
Normal 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"]
|
||||
796
kis_trader/execution/kis_client.py
Normal file
796
kis_trader/execution/kis_client.py
Normal 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
|
||||
510
kis_trader/execution/order_manager.py
Normal file
510
kis_trader/execution/order_manager.py
Normal 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
|
||||
Reference in New Issue
Block a user