1124 lines
48 KiB
Python
1124 lines
48 KiB
Python
"""
|
||
kis_trader/strategies/us_momentum.py — 해외 모멘텀 실매 (US_MOMENTUM)
|
||
================================================================
|
||
국내 MomentumStrategy 엔진을 재사용하되:
|
||
|
||
- 유니버스: permanent_subscriptions (market=US) 고정 — HTS 없음
|
||
- 세션(루프·매도): 기본 22:00~06:30 — ``US_MOMENTUM_SESSION_START`` / ``SELL_TIME_END``
|
||
- 매수창: 기본 22:30~05:00 — ``US_MOMENTUM_TIME_START`` / ``TIME_END`` (국장 TIME_END 와 동일 개념)
|
||
- 봉: 1분봉만 매수체크 (3분은 국내와 같이 1m 롤업으로 충분, 엔진 미사용)
|
||
- 주문: KIS 해외주식 주문(지정가). US_MOMENTUM_DRY_RUN=true 이면 paper 테이블만.
|
||
DRY_RUN=false 이면 OrderManager 해외 경로
|
||
(orders + active_trades + trade_history + Mattermost).
|
||
종목별 TRIGGER/청산: us_momentum_stock_config (없으면 전역 US_MOMENTUM_*).
|
||
|
||
env (코드 기본값 · DB 등록 권장):
|
||
STRATEGY_US_MOMENTUM_ENABLED=true
|
||
US_MOMENTUM_SESSION_START=2200
|
||
US_MOMENTUM_TIME_START=2230
|
||
US_MOMENTUM_TIME_END=500
|
||
US_MOMENTUM_SELL_TIME_END=630
|
||
US_MOMENTUM_BUY_WAIT_LOG_SEC=60
|
||
US_MOMENTUM_DRY_RUN=true
|
||
US_MOMENTUM_MAX_STOCKS / US_MOMENTUM_SLOT_MONEY / US_MOMENTUM_FEE_RATE ...
|
||
※ US_MOMENTUM_SLOT_MONEY 는 해외 가격과 동일 단위(USD). 슬롯을 채울 주수 = floor(slot/price),
|
||
주문 직전 inquire-psamount(ord_psbl_qty) 로만 상한 (1주 강제·실패 쿨다운 땜빵 금지).
|
||
US_MOMENTUM_MAX_ORDER_QTY=0 # 선택 하드캡(0=없음). 평소 슬롯+매수가능으로 결정
|
||
US_MOMENTUM_LOOP_SLEEP_MIN/MAX # 스캔 루프 (해외는 REST 없음 → 국내보다 짧게)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import random
|
||
import time
|
||
from datetime import datetime as dt
|
||
from typing import Dict, List, Optional
|
||
|
||
from ..database import paper_store
|
||
from ..engine import momentum_engine as me
|
||
from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int
|
||
from ..utils.session_hm import hm_in_trading_window
|
||
from .momentum import MomentumStrategy
|
||
|
||
|
||
class UsMomentumStrategy(MomentumStrategy):
|
||
"""해외 고정 유니버스 모멘텀 — 시세는 overseas WS, 신호는 momentum_engine."""
|
||
|
||
strategy_id = "US_MOMENTUM"
|
||
# 해외 스캔은 REST 없음 — 국내 momentum(1~3s)보다 빠르게 돌려 로그/타점 반응 맞춤
|
||
# (탈락 sleep 기본은 국내 SCAN_REJECT 0.02~0.05 와 동일 — reload 캐시)
|
||
loop_min_sleep = 0.15
|
||
loop_max_sleep = 0.40
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
# MomentumStrategy.__init__ 가 reload_config() 를 먼저 호출하므로
|
||
# 해외 전용 필드는 super() 이전에 준비.
|
||
self.overseas_ws = None
|
||
self._cand_exch: Dict[str, str] = {}
|
||
self._paper_ensured = False
|
||
self._us_reload_ts = 0.0
|
||
self._us_trades_today: Optional[List[Dict]] = None
|
||
self._us_trades_today_ts: float = 0.0
|
||
self._psamount_cache: Dict[tuple, tuple] = {}
|
||
self._us_candle_cache: Dict[str, List[Dict]] = {}
|
||
# 탈락 sleep 등은 reload_config TTL 캐시 (핫패스 get_env 폭주 방지)
|
||
self._us_sleep: Dict[str, tuple] = {}
|
||
self._us_live_min_candles: int = 30
|
||
self._us_force_buy_test: bool = False
|
||
self._us_trades_cache_sec: float = 60.0
|
||
super().__init__(*args, **kwargs)
|
||
|
||
def _us_candles(self, code: str, n: int) -> List[Dict]:
|
||
"""RAM(공유 agg) → 전략캐시 → DB ws_candles 폴백. ws_ticks 조회 금지."""
|
||
cu = str(code or "").strip().upper()
|
||
candles_raw: List[Dict] = []
|
||
try:
|
||
candles_raw = list(self.ws.get_candles(code, self.candle_tf, n=n) or [])
|
||
except Exception:
|
||
candles_raw = []
|
||
if len(candles_raw) >= 6:
|
||
self._us_candle_cache[cu] = candles_raw
|
||
return candles_raw
|
||
cached = list((self._us_candle_cache or {}).get(cu) or [])
|
||
if len(cached) >= 6:
|
||
return cached[-n:] if n > 0 else cached
|
||
try:
|
||
db_rows = list(
|
||
self.db.get_ws_candles(code, self.candle_tf, limit=n, confirmed_only=True) or []
|
||
)
|
||
if len(db_rows) > len(candles_raw):
|
||
self._us_candle_cache[cu] = db_rows
|
||
return db_rows
|
||
except Exception:
|
||
pass
|
||
return candles_raw
|
||
|
||
def _us_last_price(self, code: str, fallback: float) -> float:
|
||
"""해외 WS 캐시 우선, 없으면 국내 ws 캐시/봉종가."""
|
||
for src in (getattr(self, "overseas_ws", None), self.ws):
|
||
if src is None or not hasattr(src, "get_price"):
|
||
continue
|
||
try:
|
||
wsd = src.get_price(code)
|
||
except Exception:
|
||
wsd = None
|
||
if not wsd:
|
||
continue
|
||
try:
|
||
raw = wsd.get("stck_prpr") if isinstance(wsd, dict) else wsd
|
||
p = abs(float(str(raw).replace(",", "")))
|
||
if p > 0:
|
||
return p
|
||
except Exception:
|
||
continue
|
||
return float(fallback or 0.0)
|
||
|
||
def _log_buy_reject(
|
||
self,
|
||
reject: str,
|
||
name: str,
|
||
code: str,
|
||
msg: str = "",
|
||
*,
|
||
price: float = 0.0,
|
||
) -> None:
|
||
"""국내 MOMENTUM/SHORT 과 동일 포맷 + 현재가.
|
||
|
||
해외는 name==ticker 인 경우가 많아 ``QQQM(QQQM)`` 중복을 피함.
|
||
"""
|
||
nm = (name or "").strip() or code
|
||
if nm.upper() == str(code).upper():
|
||
head = code
|
||
else:
|
||
head = "%s(%s)" % (nm, code)
|
||
if price and price > 0:
|
||
self.logger.info(
|
||
"🔍 [%s] %s: %s | price=%.4f",
|
||
reject, head, msg or "", price,
|
||
)
|
||
else:
|
||
self.logger.info("🔍 [%s] %s: %s", reject, head, msg or "")
|
||
|
||
def reload_config(self) -> None:
|
||
# 매 루프 full reload 는 DB/파싱 비용 → 해외 스캔 지연의 한 축. TTL 내 스킵.
|
||
now = time.time()
|
||
interval = float(get_env_float("US_MOMENTUM_RELOAD_INTERVAL_SEC", 15.0) or 15.0)
|
||
if (
|
||
self._engine_params
|
||
and self._us_reload_ts > 0
|
||
and interval > 0
|
||
and (now - self._us_reload_ts) < interval
|
||
):
|
||
return
|
||
super().reload_config()
|
||
base = dict(self._engine_params or {})
|
||
# 국내 MOMENTUM_* DB 값을 가져오되, 해외 전용 축만 덮어씀
|
||
base["_session_wrap_midnight"] = True
|
||
base["_orderbook_filter_enabled"] = False
|
||
base["_program_filter_enabled"] = False
|
||
# HTS SCAN 없음 — TRIGGER 에서 일봉 HTS 중복축 생략 (KR MOMENTUM_SKIP_HTS DB 와 별개)
|
||
base["skip_hts_scan_dupes"] = True
|
||
base["market"] = "US"
|
||
_costs = None
|
||
try:
|
||
from ..engine.us_momentum_env_keys import us_momentum_trading_cost_rates
|
||
_costs = us_momentum_trading_cost_rates()
|
||
except Exception:
|
||
_costs = {"fee_rate": 0.0025, "sell_tax": 0.0000206, "fx_fee_rate": 0.0005}
|
||
base["fee_rate"] = float(_costs["fee_rate"])
|
||
base["sell_tax"] = float(_costs["sell_tax"])
|
||
base["fx_fee_rate"] = float(_costs["fx_fee_rate"])
|
||
base["min_price"] = float(get_env_float("US_MOMENTUM_MIN_PRICE", 1.0))
|
||
# 슬롯·한도 (없으면 국내 모멘텀 값 유지). ※ USD (해외 가격과 동일 단위)
|
||
sm = get_env_int("US_MOMENTUM_SLOT_MONEY", 0)
|
||
if sm > 0:
|
||
base["slot_money"] = sm
|
||
self.slot_money = sm
|
||
self.min_price = float(base.get("min_price", 1.0))
|
||
self.eod_enabled = get_env_bool("US_MOMENTUM_EOD_ENABLED", False)
|
||
self.eod_hm = get_env_from_db("US_MOMENTUM_EOD_HM", "05:00")
|
||
self._engine_params = base
|
||
self._dry_run = get_env_bool("US_MOMENTUM_DRY_RUN", True)
|
||
try:
|
||
from ..engine.us_momentum_env import overlay_us_momentum_engine_params
|
||
base2 = overlay_us_momentum_engine_params(base)
|
||
try:
|
||
from ..engine.us_momentum_env_keys import us_momentum_trading_cost_rates
|
||
_c2 = us_momentum_trading_cost_rates()
|
||
base2["fee_rate"] = float(_c2["fee_rate"])
|
||
base2["sell_tax"] = float(_c2["sell_tax"])
|
||
base2["fx_fee_rate"] = float(_c2["fx_fee_rate"])
|
||
except Exception:
|
||
base2["fee_rate"] = float(get_env_float("US_MOMENTUM_FEE_RATE", 0.0025))
|
||
base2["sell_tax"] = float(get_env_float("US_MOMENTUM_SELL_TAX", 0.0000206))
|
||
base2["fx_fee_rate"] = float(get_env_float("US_MOMENTUM_FX_FEE_RATE", 0.0005))
|
||
self._engine_params = base2
|
||
self.min_price = float(base2.get("min_price", 1.0))
|
||
self.stop_loss_pct = -abs(float(base2.get("sl_pct", 0.015)))
|
||
self.take_profit_pct = abs(float(base2.get("tp_pct", 0.025)))
|
||
self.tp_max_pct = abs(float(base2.get("tp_max_pct", 0.02)))
|
||
if int(base2.get("slot_money") or 0) > 0:
|
||
self.slot_money = int(base2["slot_money"])
|
||
self.max_daily = int(base2.get("max_daily", self.max_daily))
|
||
except Exception as e:
|
||
self.logger.debug("US overlay 스킵: %s", e)
|
||
try:
|
||
from .us_momentum_stock_cfg import (
|
||
ensure_us_momentum_stock_config_table,
|
||
seed_us_momentum_stock_from_permanent,
|
||
)
|
||
ensure_us_momentum_stock_config_table(self.db)
|
||
# 첫 reload 때만 시드 (빈 행)
|
||
if not getattr(self, "_us_stock_seeded", False):
|
||
seed_us_momentum_stock_from_permanent(self.db)
|
||
self._us_stock_seeded = True
|
||
except Exception as e:
|
||
self.logger.debug("us stock_cfg seed 스킵: %s", e)
|
||
if not getattr(self, "_paper_ensured", False):
|
||
try:
|
||
paper_store.ensure_paper_tables(self.db)
|
||
self._paper_ensured = True
|
||
except Exception as e:
|
||
self.logger.debug("paper 테이블 ensure 스킵: %s", e)
|
||
# 국내 SCAN_REJECT(0.02~0.05) 에 맞춤 — 해외 REST 없음
|
||
self._us_sleep = {
|
||
"loop": (
|
||
float(get_env_float("US_MOMENTUM_LOOP_SLEEP_MIN", self.loop_min_sleep)),
|
||
float(get_env_float("US_MOMENTUM_LOOP_SLEEP_MAX", self.loop_max_sleep)),
|
||
),
|
||
"reject": (
|
||
float(get_env_float("US_MOMENTUM_SCAN_REJECT_SLEEP_MIN", 0.02)),
|
||
float(get_env_float("US_MOMENTUM_SCAN_REJECT_SLEEP_MAX", 0.05)),
|
||
),
|
||
"buy_ok": (
|
||
float(get_env_float("US_MOMENTUM_SCAN_BUY_OK_SLEEP_MIN", 0.5)),
|
||
float(get_env_float("US_MOMENTUM_SCAN_BUY_OK_SLEEP_MAX", 1.0)),
|
||
),
|
||
"buy_fail": (
|
||
float(get_env_float("US_MOMENTUM_SCAN_BUY_FAIL_SLEEP_MIN", 0.3)),
|
||
float(get_env_float("US_MOMENTUM_SCAN_BUY_FAIL_SLEEP_MAX", 0.6)),
|
||
),
|
||
}
|
||
self._us_live_min_candles = int(
|
||
get_env_int("US_MOMENTUM_LIVE_MIN_CANDLES", 30) or 30
|
||
)
|
||
self._us_force_buy_test = bool(get_env_bool("FORCE_BUY_TEST", False))
|
||
# 강제매수 1회 제한 (종목별) — 루프 연타로 주문 폭주 방지
|
||
if not hasattr(self, "_us_force_buy_done"):
|
||
self._us_force_buy_done = set()
|
||
self._us_force_buy_code = (
|
||
str(get_env_from_db("FORCE_BUY_TEST_CODE", "") or "").strip().upper()
|
||
)
|
||
self._us_force_buy_qty = max(
|
||
1, int(get_env_int("US_MOMENTUM_FORCE_BUY_QTY", 1) or 1)
|
||
)
|
||
self._us_trades_cache_sec = float(
|
||
get_env_float("US_MOMENTUM_TRADES_CACHE_SEC", 60.0) or 60.0
|
||
)
|
||
self._us_reload_ts = now
|
||
|
||
def _scan_sleep(self, kind: str) -> float:
|
||
"""해외: REST 없는 스캔 — 국내 STRATEGY_LOOP(1~3s)보다 짧은 기본값.
|
||
|
||
sleep 구간은 reload_config 캐시만 사용 (종목마다 get_env 금지).
|
||
"""
|
||
cached = (self._us_sleep or {}).get(kind)
|
||
if cached:
|
||
lo, hi = float(cached[0]), float(cached[1])
|
||
elif kind == "loop":
|
||
lo, hi = float(self.loop_min_sleep), float(self.loop_max_sleep)
|
||
elif kind == "reject":
|
||
lo, hi = 0.02, 0.05
|
||
elif kind == "buy_ok":
|
||
lo, hi = 0.5, 1.0
|
||
else:
|
||
lo, hi = 0.3, 0.6
|
||
lo = max(0.0, float(lo))
|
||
hi = max(lo, float(hi))
|
||
return random.uniform(lo, hi)
|
||
|
||
def _session_time_bounds(self):
|
||
# 매도·루프 세션: 프리마켓 포함 (기본 22:00~06:30). 매수 TIME_START(2230)와 분리.
|
||
start = get_env_int("US_MOMENTUM_SESSION_START", 0) or 2200
|
||
sell_end = get_env_int("US_MOMENTUM_SELL_TIME_END", 0) or 630
|
||
return start, sell_end
|
||
|
||
def _buy_time_bounds(self):
|
||
start = get_env_int("US_MOMENTUM_TIME_START", 0) or 2230
|
||
buy_end = get_env_int("US_MOMENTUM_TIME_END", 0) or 500
|
||
return start, buy_end
|
||
|
||
def _is_us_session_window(self) -> bool:
|
||
"""해외 WS 와 동일: 월~금 세션시작~ / 화~토 새벽 ~매도종료."""
|
||
now = dt.now()
|
||
wd = now.weekday()
|
||
hhmm = now.hour * 100 + now.minute
|
||
start, sell_end = self._session_time_bounds()
|
||
# 요일: 저녁(월~금) / 새벽(화~토) — hm 윈도우와 맞춤
|
||
if start > sell_end:
|
||
# wrap: evening or early morning
|
||
if hhmm >= start:
|
||
return 0 <= wd <= 4
|
||
if hhmm < sell_end:
|
||
return 1 <= wd <= 5
|
||
return False
|
||
return hm_in_trading_window(hhmm, start, sell_end, wrap_midnight=False) and (0 <= wd <= 4)
|
||
|
||
def check_market_status(self) -> bool:
|
||
"""세션 열림 = 보유·매도·루프 가동 (매수 TIME_START 와 무관)."""
|
||
if get_env_bool("FORCE_MARKET_OPEN", False):
|
||
return True
|
||
return self._is_us_session_window()
|
||
|
||
def _log_us_buy_wait(self, reason: str = "") -> None:
|
||
"""국장 매수체크처럼 — 세션 중·매수창 전이면 주기적으로 대기 로그."""
|
||
interval = float(get_env_float("US_MOMENTUM_BUY_WAIT_LOG_SEC", 60.0) or 60.0)
|
||
now = time.time()
|
||
last = float(getattr(self, "_us_buy_wait_log_ts", 0) or 0)
|
||
if interval > 0 and (now - last) < interval:
|
||
return
|
||
self._us_buy_wait_log_ts = now
|
||
hhmm = dt.now().hour * 100 + dt.now().minute
|
||
b0, b1 = self._buy_time_bounds()
|
||
s0, s1 = self._session_time_bounds()
|
||
try:
|
||
cands = self._load_candidates() or []
|
||
codes = ",".join(
|
||
str(c.get("code") or "").strip() for c in cands if c.get("code")
|
||
)[:80]
|
||
except Exception:
|
||
codes = ""
|
||
self.logger.info(
|
||
"🔍 [매수대기] US_MOMENTUM now=%04d session=%04d~%04d buy=%04d~%04d "
|
||
"hold=%d codes=%s %s",
|
||
hhmm, s0, s1, b0, b1, len(self.holdings), codes or "-",
|
||
("| " + reason) if reason else "",
|
||
)
|
||
|
||
def check_buy_allowed(self) -> bool:
|
||
"""해외 매수창 + (옵션) US EOD 이후 차단.
|
||
|
||
국장 SCALP/MOMENTUM EOD(15:xx) 와 무관.
|
||
``US_MOMENTUM_EOD_ENABLED`` 기본 false → 평소 EOD 매수차단 없음.
|
||
true 일 때만 ``US_MOMENTUM_EOD_HM``(기본 05:00) 이후 당일 세션 신규매수 금지.
|
||
"""
|
||
if get_env_bool("FORCE_MARKET_OPEN", False):
|
||
return True
|
||
if not self.check_market_status():
|
||
return False
|
||
now = dt.now()
|
||
hhmm_now = now.hour * 100 + now.minute
|
||
start, buy_end = self._buy_time_bounds()
|
||
ok = hm_in_trading_window(hhmm_now, start, buy_end, wrap_midnight=True)
|
||
if not ok:
|
||
self._log_us_buy_wait("매수창 전/후")
|
||
return False
|
||
# 해외 전용 EOD만 적용 (국장 15:25 와 분리)
|
||
from .base import is_live_eod_now
|
||
if is_live_eod_now(
|
||
bool(getattr(self, "eod_enabled", False)),
|
||
str(getattr(self, "eod_hm", "05:00") or "05:00"),
|
||
now,
|
||
default_hm="05:00",
|
||
):
|
||
self._log_us_buy_wait("US EOD 이후 신규매수 차단")
|
||
return False
|
||
return True
|
||
|
||
def _load_candidates(self) -> List[Dict]:
|
||
"""영구구독 US 고정 유니버스."""
|
||
out: List[Dict] = []
|
||
try:
|
||
from permanent_subs import codes_by_market
|
||
rows = codes_by_market(self.db, "US", enabled_only=True)
|
||
for r in rows:
|
||
code = str(r.get("code") or "").strip().upper()
|
||
if not code:
|
||
continue
|
||
out.append({
|
||
"code": code,
|
||
"name": code,
|
||
"exchange": str(r.get("exchange") or "NASD"),
|
||
"symbol": str(r.get("symbol") or code).upper(),
|
||
"scalp_on": True,
|
||
"score": 0.0,
|
||
})
|
||
except Exception as e:
|
||
self.logger.warning("US 영구구독 유니버스 로드 실패: %s", e)
|
||
return out
|
||
|
||
def _post_filter_candidates(self, candidates: List[Dict]) -> List[Dict]:
|
||
# ETF/영문 티커 제외 필터·HTS 슬롯 정렬 스킵 (고정 유니버스)
|
||
return list(candidates or [])
|
||
|
||
def _sync_ws_for_loop(self, cand_codes: List[str], hold_codes: List[str]) -> None:
|
||
# 해외 틱/봉은 overseas WS(permanent) 담당 — 키움·국내 KIS 에 알파 티커 넣지 않음
|
||
try:
|
||
self.ws.sync_targets_split(self.strategy_id, [], [])
|
||
except Exception:
|
||
pass
|
||
|
||
def _scan_and_buy(self, candidates: List[Dict], max_stocks: int, active_cnt: int) -> None:
|
||
"""후보의 exchange 를 캐시 후 Base 스캔 (시그널→해외주문에 AMEX 등 전달)."""
|
||
exch: Dict[str, str] = {}
|
||
for c in (candidates or []):
|
||
ccode = str(c.get("code") or "").strip().upper()
|
||
if not ccode:
|
||
continue
|
||
exch[ccode] = (
|
||
str(c.get("exchange") or "NASD").strip().upper() or "NASD"
|
||
)
|
||
self._cand_exch = exch
|
||
# 일일 체결: 스캔마다 DATE 풀스캔 금지 — TTL 캐시(기본 60s)
|
||
return super()._scan_and_buy(candidates, max_stocks, active_cnt)
|
||
|
||
def _us_client(self):
|
||
"""해외 주문·매수가능 조회용 클라이언트.
|
||
|
||
전략 ``self.client`` 는 시세 전용(항상 실키 market_client).
|
||
해외 매매/psamount 는 ``order_mgr.client``(KIS_MOCK 따름 — 모의면 모의)를 써야 한다.
|
||
시세 클라이언트로 주문하면 HTS/모의에 돈이 있어도 실전 계좌 기준으로 0/거절이 난다.
|
||
"""
|
||
om = getattr(self, "order_mgr", None) or getattr(self, "order_manager", None)
|
||
if om is not None:
|
||
trade = getattr(om, "client", None)
|
||
if trade is not None:
|
||
return trade
|
||
return getattr(self, "kis", None) or getattr(self, "client", None)
|
||
|
||
def _us_cap_qty_by_psamount(
|
||
self,
|
||
code: str,
|
||
qty: int,
|
||
price: float,
|
||
exchange: str,
|
||
) -> int:
|
||
"""슬롯 주수를 브로커 매수가능수량(inquire-psamount)으로만 상한.
|
||
|
||
지정가(슬리피지 반영) 기준 — 주문과 동일 가격으로 조회해야 APBK0952 를 예방.
|
||
"""
|
||
if qty < 1 or price <= 0:
|
||
return 0
|
||
client = self._us_client()
|
||
if client is None or not hasattr(client, "inquire_overseas_psamount"):
|
||
return qty
|
||
slip = abs(float(get_env_float("KIS_OVRS_BUY_LIMIT_SLIPPAGE_PCT", 0.3)))
|
||
limit_px = price * (1.0 + slip / 100.0) if slip > 0 else price
|
||
cache_ttl = float(get_env_float("US_MOMENTUM_PSAMOUNT_CACHE_SEC", 10.0) or 10.0)
|
||
key = (str(code).upper(), str(exchange).upper(), round(float(limit_px), 2))
|
||
now = time.time()
|
||
hit = (self._psamount_cache or {}).get(key)
|
||
if hit and cache_ttl > 0 and (now - float(hit[0])) < cache_ttl:
|
||
max_q = int(hit[1])
|
||
else:
|
||
info = client.inquire_overseas_psamount(
|
||
code, limit_px, exchange=exchange,
|
||
) or {}
|
||
max_q = int(
|
||
info.get("ord_psbl_qty")
|
||
or info.get("max_ord_psbl_qty")
|
||
or 0
|
||
)
|
||
self._psamount_cache[key] = (now, max_q)
|
||
frcr = info.get("ord_psbl_frcr_amt")
|
||
self.logger.info(
|
||
"💵 [US_MOMENTUM 매수가능] %s excg=%s limit=%.4f "
|
||
"ord_psbl_qty=%d frcr=%s slot_qty=%d",
|
||
code, exchange, limit_px, max_q, frcr, qty,
|
||
)
|
||
if max_q < 1:
|
||
return 0
|
||
return min(int(qty), int(max_q))
|
||
|
||
def _force_buy_test(self, code: str, name: str) -> Optional[Dict]:
|
||
"""해외 강제매수 테스트 — 패턴 스킵, 현재가·1주(기본)로 시그널.
|
||
|
||
- FORCE_BUY_TEST_CODE 있으면 해당 종목만 (권장: TSLA — ETP 이슈 분리)
|
||
- 종목당 1회만 시그널 (주문 성공/실패와 무관, 연타 방지)
|
||
- 국내 MomentumStrategy._force_buy_test 의 KR REST/슬롯 수량 경로 사용 금지
|
||
"""
|
||
cu = str(code or "").strip().upper()
|
||
only = str(getattr(self, "_us_force_buy_code", "") or "").strip().upper()
|
||
if only and cu != only:
|
||
return None
|
||
done = getattr(self, "_us_force_buy_done", None)
|
||
if done is None:
|
||
self._us_force_buy_done = set()
|
||
done = self._us_force_buy_done
|
||
if cu in done:
|
||
return None
|
||
|
||
# WS 틱 캐시 → 없으면 최근 1분봉 종가 (overseas_ws 미주입 시에도 주문 경로 검증 가능)
|
||
px = self._us_last_price(code, 0.0)
|
||
if px <= 0:
|
||
try:
|
||
bars = self._us_candles(code, 3)
|
||
if bars:
|
||
px = float((bars[-1] or {}).get("close", 0) or 0)
|
||
except Exception:
|
||
px = 0.0
|
||
if px <= 0:
|
||
# 연타 WARNING 스팸 방지 (종목당 30초 1회)
|
||
now_ts = time.time()
|
||
last_w = float(getattr(self, "_us_force_nopx_ts", 0) or 0)
|
||
if now_ts - last_w >= 30.0:
|
||
self._us_force_nopx_ts = now_ts
|
||
self.logger.warning(
|
||
"🧪 [FORCE_BUY_TEST] %s 현재가 없음 — 스킵 (overseas_ws=%s)",
|
||
cu, bool(getattr(self, "overseas_ws", None)),
|
||
)
|
||
return None
|
||
qty = max(1, int(getattr(self, "_us_force_buy_qty", 1) or 1))
|
||
max_q = get_env_int("US_MOMENTUM_MAX_ORDER_QTY", 0)
|
||
if max_q > 0:
|
||
qty = min(qty, int(max_q))
|
||
|
||
exchange = (
|
||
(self._cand_exch or {}).get(cu)
|
||
or get_env_from_db("KIS_OVRS_DEFAULT_EXCG", "NASD")
|
||
or "NASD"
|
||
)
|
||
exchange = str(exchange).strip().upper() or "NASD"
|
||
if not self._dry_run:
|
||
capped = self._us_cap_qty_by_psamount(code, qty, px, exchange)
|
||
if capped < 1:
|
||
done.add(cu)
|
||
self.logger.warning(
|
||
"🧪 [FORCE_BUY_TEST] %s psamount=0 — 주문불가 (1회 마킹)", cu
|
||
)
|
||
return None
|
||
qty = min(qty, int(capped))
|
||
|
||
done.add(cu)
|
||
eff_tp = me.resolve_effective_tp_pct(self.take_profit_pct, self.tp_max_pct)
|
||
self.logger.warning(
|
||
"🧪 [FORCE_BUY_TEST] US_MOMENTUM %s(%s) qty=%d @%.4f excg=%s dry=%s "
|
||
"(패턴스킵·종목1회)",
|
||
name, cu, qty, px, exchange, self._dry_run,
|
||
)
|
||
return {
|
||
"code": code,
|
||
"name": name,
|
||
"price": px,
|
||
"qty": qty,
|
||
"exchange": exchange,
|
||
"stop_price": px * (1 + self.stop_loss_pct),
|
||
"target_price": px * (1 + eff_tp),
|
||
"atr_entry": 0.0,
|
||
"size_class": "",
|
||
"entry_features": {"force_buy_test": True, "dry_run": self._dry_run},
|
||
"dry_run": self._dry_run,
|
||
}
|
||
|
||
def check_buy(self, code: str, name: str) -> Optional[Dict]:
|
||
"""국내 check_buy 재사용 + 키움 갭보정/전일시가 REST 스킵."""
|
||
try:
|
||
if self._us_force_buy_test:
|
||
return self._force_buy_test(code, name)
|
||
|
||
cu = str(code or "").strip().upper()
|
||
min_need = int(self._us_live_min_candles or 30)
|
||
candles_raw = self._us_candles(code, min_need)
|
||
use_forming = bool((self._engine_params or {}).get("live_align_use_forming_bar", True))
|
||
if (self._engine_params or {}).get("live_backtest_align", True) and use_forming:
|
||
try:
|
||
cur = self.ws.get_current_candle(code, self.candle_tf)
|
||
except Exception:
|
||
cur = None
|
||
if cur and float(cur.get("open", 0) or 0) > 0:
|
||
ct = str(cur.get("candle_time") or "")[:12]
|
||
last_ct = ""
|
||
if candles_raw:
|
||
last_ct = str(candles_raw[-1].get("candle_time") or "")[:12]
|
||
if ct and ct != last_ct:
|
||
cur_d = dict(cur)
|
||
cur_d["is_confirmed"] = 0
|
||
candles_raw.append(cur_d)
|
||
if len(candles_raw) < 6:
|
||
self._log_buy_reject(
|
||
"탈락-봉부족", name, code,
|
||
"candles=%d (해외 WS/DB 대기)" % len(candles_raw),
|
||
)
|
||
return None
|
||
candles = [self._norm_candle(c) for c in candles_raw]
|
||
# 탈락 로그용 현재가 (봉종가 → 해외 WS)
|
||
px_log = self._us_last_price(
|
||
code, float((candles[-1] or {}).get("close", 0) or 0),
|
||
)
|
||
|
||
today = dt.now().strftime("%Y%m%d")
|
||
last_exit_dt = None
|
||
if code in self.recently_sold:
|
||
try:
|
||
last_exit_dt = dt.fromtimestamp(self.recently_sold[code])
|
||
if last_exit_dt.strftime("%Y%m%d") != today:
|
||
last_exit_dt = None
|
||
elif bool((self._engine_params or {}).get("cooldown_use_candle_floor", True)):
|
||
last_exit_dt = last_exit_dt.replace(second=0, microsecond=0)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
today_ymd = dt.now().strftime("%Y%m%d")
|
||
if self._dry_run:
|
||
daily_cnt = paper_store.count_paper_trades_today(
|
||
self.db,
|
||
strategy_id=self.strategy_id,
|
||
code=code,
|
||
ymd=today_ymd,
|
||
)
|
||
else:
|
||
now_ts = time.time()
|
||
ttl = float(getattr(self, "_us_trades_cache_sec", 60.0) or 60.0)
|
||
if (
|
||
self._us_trades_today is None
|
||
or (now_ts - float(self._us_trades_today_ts or 0)) >= ttl
|
||
):
|
||
self._us_trades_today = list(
|
||
self.db.get_trades_by_date(today_ymd) or []
|
||
)
|
||
self._us_trades_today_ts = now_ts
|
||
daily_cnt = len([
|
||
t for t in self._us_trades_today
|
||
if t.get("code") == code
|
||
and str(t.get("strategy", "")).upper().startswith("US_MOMENTUM")
|
||
])
|
||
except Exception:
|
||
daily_cnt = 0
|
||
state = {"last_exit_dt": last_exit_dt, "daily_cnt": daily_cnt}
|
||
|
||
params = dict(self._engine_params or {})
|
||
try:
|
||
from .us_momentum_stock_cfg import apply_us_momentum_stock_overlay
|
||
params = apply_us_momentum_stock_overlay(self.db, code, params)
|
||
except Exception as _ov_e:
|
||
self.logger.debug("stock overlay 스킵 %s: %s", code, _ov_e)
|
||
# 종목 행 slot_money 있으면 수량 계산에 반영
|
||
if int(params.get("slot_money") or 0) > 0:
|
||
params["slot_money"] = int(params["slot_money"])
|
||
else:
|
||
params["slot_money"] = self.slot_money
|
||
# 호가/휩쏘 WS 훅 제거 (해외 없음)
|
||
reject, msg, sig = me.check_buy_signal_momentum_live(candles, params, state)
|
||
if reject:
|
||
# 국내: 🔍 [탈락-…] 이름 코드: 사유 — 해외는 현재가 추가
|
||
self._log_buy_reject(reject, name, code, msg or "", price=px_log)
|
||
return None
|
||
if not sig:
|
||
# 엔진 silent (쿨다운·일한도 등) — 국내도 무로그이나 US 는 사유 가시화
|
||
self._log_buy_reject(
|
||
"탈락-무신호", name, code,
|
||
"쿨다운/일일한도/세션",
|
||
price=px_log,
|
||
)
|
||
return None
|
||
|
||
_ebk = str(sig.get("entry_bar_key") or "")[:12]
|
||
_defer = self._defer_mid_enroll_entry(
|
||
code, _ebk, int(getattr(self, "candle_tf", 1) or 1), params,
|
||
)
|
||
if _defer:
|
||
self._log_buy_reject(_defer, name, code, "", price=px_log)
|
||
return None
|
||
|
||
# 국장과 동일: align ON이면 T 첫틱(RAM)→시가 — last로 덮지 않음 (실매↔백테 정합)
|
||
align_on = bool(params.get("live_backtest_align", True))
|
||
entry_open = float(sig.get("entry_price", 0) or 0)
|
||
if align_on and entry_open > 0:
|
||
from kis_trader.engine.tail_tick_replay import live_align_entry_price
|
||
curr_price, _entry_src = live_align_entry_price(
|
||
self.ws,
|
||
code,
|
||
entry_open,
|
||
entry_bar_key=_ebk,
|
||
tf_min=int(getattr(self, "candle_tf", 1) or 1),
|
||
)
|
||
else:
|
||
curr_price = float((candles[-1] or {}).get("close", 0) or 0)
|
||
curr_price = self._us_last_price(code, curr_price)
|
||
if curr_price <= 0 or curr_price < self.min_price:
|
||
self._log_buy_reject(
|
||
"탈락-최소가격", name, code,
|
||
"%.4f < min=%.4f" % (curr_price, self.min_price),
|
||
price=curr_price,
|
||
)
|
||
return None
|
||
|
||
exchange = (
|
||
params.get("_us_exchange")
|
||
or (self._cand_exch or {}).get(cu)
|
||
or get_env_from_db("KIS_OVRS_DEFAULT_EXCG", "NASD")
|
||
or "NASD"
|
||
)
|
||
exchange = str(exchange).strip().upper() or "NASD"
|
||
|
||
# 슬롯(USD): 종목 행 → 전역
|
||
slot_for_qty = float(params.get("slot_money") or self.slot_money or 0)
|
||
hard_cap = get_env_int("US_MOMENTUM_MAX_BUY_AMOUNT", 0)
|
||
# _resolve_buy_qty_live 는 self.slot_money 사용 → 임시 교체
|
||
_prev_slot = self.slot_money
|
||
try:
|
||
if slot_for_qty > 0:
|
||
self.slot_money = int(slot_for_qty)
|
||
qty, rej = self._resolve_buy_qty_live(curr_price, hard_cap=hard_cap)
|
||
finally:
|
||
self.slot_money = _prev_slot
|
||
if rej:
|
||
self.logger.info(
|
||
"🔍 [탈락-%s] %s(%s) price=%.4f",
|
||
rej, name, code, curr_price,
|
||
)
|
||
return None
|
||
max_q = get_env_int("US_MOMENTUM_MAX_ORDER_QTY", 0)
|
||
if max_q > 0 and qty > max_q:
|
||
self.logger.info(
|
||
"ℹ️ [US_MOMENTUM qty hardcap] %s raw_qty=%d → %d (MAX_ORDER_QTY)",
|
||
code, qty, max_q,
|
||
)
|
||
qty = int(max_q)
|
||
if not self._dry_run:
|
||
# 실주문만 psamount (paper 는 브로커 조회 불필요)
|
||
capped = self._us_cap_qty_by_psamount(code, qty, curr_price, exchange)
|
||
if capped < 1:
|
||
self._log_buy_reject(
|
||
"탈락-주문가능0", name, code,
|
||
"inquire-psamount ord_psbl_qty=0 (슬롯=%d)" % qty,
|
||
price=curr_price,
|
||
)
|
||
return None
|
||
if capped < qty:
|
||
self.logger.info(
|
||
"ℹ️ [US_MOMENTUM 슬롯→가능] %s slot_qty=%d → psamount=%d",
|
||
code, qty, capped,
|
||
)
|
||
qty = capped
|
||
if qty < 1:
|
||
self._log_buy_reject(
|
||
"탈락-수량0", name, code, "qty<1", price=curr_price,
|
||
)
|
||
return None
|
||
|
||
sl_ratio = -abs(float(params.get("sl_pct", abs(self.stop_loss_pct))))
|
||
tp_ratio = abs(float(params.get("tp_pct", self.take_profit_pct)))
|
||
tp_max = abs(float(params.get("tp_max_pct", self.tp_max_pct)))
|
||
eff_tp = me.resolve_effective_tp_pct(tp_ratio, tp_max)
|
||
stop_price = curr_price * (1 + sl_ratio)
|
||
target_price = curr_price * (1 + eff_tp)
|
||
|
||
self.logger.info(
|
||
"🎯 [US_MOMENTUM 시그널%s] %s(%s) price=%.4f qty=%d excg=%s "
|
||
"slot_usd=%s pattern=%s RSI=%.1f",
|
||
"·DRY" if self._dry_run else "",
|
||
name, code, curr_price, qty, exchange,
|
||
getattr(self, "slot_money", "?"),
|
||
sig.get("pattern", "?"),
|
||
float(sig.get("rsi", 0) or 0),
|
||
)
|
||
return {
|
||
"code": code,
|
||
"name": name,
|
||
"price": curr_price,
|
||
"qty": qty,
|
||
"exchange": exchange,
|
||
"stop_price": stop_price,
|
||
"target_price": target_price,
|
||
"atr_entry": 0.0,
|
||
"size_class": "",
|
||
"entry_features": {"rsi": sig.get("rsi", 0), "dry_run": self._dry_run},
|
||
"dry_run": self._dry_run,
|
||
}
|
||
except Exception as e:
|
||
self.logger.info("🔍 [탈락-예외] %s %s: %s", name, code, e)
|
||
return None
|
||
|
||
def _sync_holdings_from_db(self, *, log_restore: bool = False) -> None:
|
||
"""DRY_RUN: active_trades_paper 만. 실매 테이블과 교차 금지.
|
||
|
||
주의: Base 는 strategy_id ``US_MOMENTUM`` → prefix ``US`` 로 LIKE 조회하는데
|
||
paper 는 strategy 정확 일치로만 로드한다.
|
||
※ init 시점엔 reload_config 전이라 self._dry_run 이 없을 수 있음 → env 직접 조회.
|
||
"""
|
||
dry = get_env_bool("US_MOMENTUM_DRY_RUN", True)
|
||
self._dry_run = dry
|
||
if not dry:
|
||
return super()._sync_holdings_from_db(log_restore=log_restore)
|
||
try:
|
||
rows = paper_store.get_active_trades_paper(
|
||
self.db, strategy_id=self.strategy_id
|
||
)
|
||
new_holdings: Dict[str, dict] = {}
|
||
for code, t in rows.items():
|
||
avg_bp = float(t.get("avg_buy_price") or t.get("buy_price") or 0)
|
||
db_max = float(t.get("max_price") or 0)
|
||
db_sess = float(t.get("session_low") or avg_bp or 0)
|
||
max_p, sess_low = self._merge_runtime_overlay(code, avg_bp, db_max, db_sess)
|
||
holding = {
|
||
"buy_price": avg_bp,
|
||
"qty": int(t.get("current_qty") or t.get("qty") or 0),
|
||
"stop_price": float(t.get("stop_price") or 0),
|
||
"target_price": float(t.get("target_price") or 0),
|
||
"max_price": max_p,
|
||
"session_low": sess_low,
|
||
"atr_entry": float(t.get("atr_at_entry") or t.get("atr_entry") or 0),
|
||
"buy_time": t.get("buy_date") or "",
|
||
"name": t.get("name") or code,
|
||
"size_class": t.get("size_class") or "",
|
||
"paper": True,
|
||
}
|
||
self._apply_runtime_extra_fields(code, holding)
|
||
if holding["qty"] > 0 and avg_bp > 0:
|
||
new_holdings[code] = holding
|
||
for gone in set(self.holdings.keys()) - set(new_holdings.keys()):
|
||
self._runtime.pop(gone, None)
|
||
self.holdings.clear()
|
||
self.holdings.update(new_holdings)
|
||
self._after_holdings_sync()
|
||
if log_restore and self.holdings:
|
||
self.logger.info(
|
||
"📂 [PAPER] holdings 복원 %d종 [%s]",
|
||
len(self.holdings), self.strategy_id,
|
||
)
|
||
except Exception as e:
|
||
self.logger.error("paper holdings sync 실패: %s", e)
|
||
|
||
|
||
def check_sell_signals(self):
|
||
"""국내 매도 엔진 + 종목별 stock_cfg 오버레이 + 해외 WS 가격."""
|
||
if not self.holdings:
|
||
return []
|
||
from .base import is_live_eod_now
|
||
from .us_momentum_stock_cfg import apply_us_momentum_stock_overlay
|
||
|
||
signals = []
|
||
now = dt.now()
|
||
is_eod = is_live_eod_now(
|
||
getattr(self, "eod_enabled", False),
|
||
getattr(self, "eod_hm", "05:00"),
|
||
now,
|
||
default_hm="05:00",
|
||
)
|
||
base = dict(self._engine_params or me.get_momentum_defaults_from_db())
|
||
|
||
for code, holding in list(self.holdings.items()):
|
||
try:
|
||
name = holding.get("name", code)
|
||
buy_price = float(holding.get("buy_price", 0))
|
||
qty = int(holding.get("qty", 0))
|
||
stop = float(holding.get("stop_price", 0))
|
||
target = float(holding.get("target_price", 0))
|
||
max_price = float(holding.get("max_price", buy_price))
|
||
if qty <= 0 or buy_price <= 0:
|
||
continue
|
||
|
||
current_price = self._us_last_price(code, 0.0)
|
||
if current_price <= 0:
|
||
continue
|
||
if current_price > max_price:
|
||
max_price = current_price
|
||
holding["max_price"] = max_price
|
||
|
||
params = apply_us_momentum_stock_overlay(self.db, code, base)
|
||
profit_pct = (current_price - buy_price) / buy_price if buy_price > 0 else 0
|
||
position = {
|
||
"entry_price": buy_price,
|
||
"entry_time": holding.get("buy_time", ""),
|
||
"qty": qty,
|
||
"stop": stop,
|
||
"target": target,
|
||
"max_price": max_price,
|
||
}
|
||
candle = {
|
||
"high": max_price,
|
||
"low": current_price,
|
||
"close": current_price,
|
||
"candle_time": now.strftime("%Y%m%d%H%M"),
|
||
}
|
||
res = me.check_sell_signal_momentum_live(
|
||
position, candle, params, is_eod=is_eod,
|
||
)
|
||
if not res:
|
||
continue
|
||
reason, exit_price = res
|
||
signals.append({
|
||
"code": code,
|
||
"name": name,
|
||
"current_price": current_price,
|
||
"price": exit_price,
|
||
"qty": qty,
|
||
"buy_price": buy_price,
|
||
"profit_pct": profit_pct,
|
||
"reason": reason,
|
||
"exchange": holding.get("exchange") or "",
|
||
"market": "US",
|
||
})
|
||
except Exception as e:
|
||
self.logger.error("US 매도 시그널 체크 오류(%s): %s", code, e)
|
||
return signals
|
||
|
||
def _submit_buy(self, signal: Dict):
|
||
"""DRY=paper / 실주문=OrderManager 해외 경로 (DB+MM)."""
|
||
if signal.get("dry_run") or self._dry_run:
|
||
code = str(signal.get("code") or "")
|
||
name = str(signal.get("name") or code)
|
||
price = float(signal.get("price") or 0)
|
||
qty = int(signal.get("qty") or 0)
|
||
paper_ord = paper_store.open_paper_buy(
|
||
self.db,
|
||
strategy_id=self.strategy_id,
|
||
code=code,
|
||
name=name,
|
||
price=price,
|
||
qty=qty,
|
||
stop_price=float(signal.get("stop_price") or 0),
|
||
target_price=float(signal.get("target_price") or 0),
|
||
atr_entry=float(signal.get("atr_entry") or 0),
|
||
size_class=str(signal.get("size_class") or ""),
|
||
entry_features=signal.get("entry_features"),
|
||
)
|
||
ok = bool(paper_ord)
|
||
if ok:
|
||
now_str = dt.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
self.holdings[code] = {
|
||
"buy_price": price,
|
||
"qty": qty,
|
||
"stop_price": float(signal.get("stop_price") or 0),
|
||
"target_price": float(signal.get("target_price") or 0),
|
||
"max_price": price,
|
||
"session_low": price,
|
||
"atr_entry": float(signal.get("atr_entry") or 0),
|
||
"buy_time": now_str,
|
||
"name": name,
|
||
"size_class": str(signal.get("size_class") or ""),
|
||
"paper": True,
|
||
}
|
||
self._capture_runtime_overlay()
|
||
self.logger.info(
|
||
"📝 [US_MOMENTUM PAPER BUY] %s qty=%s @%.4f ord=%s",
|
||
code, qty, price, paper_ord,
|
||
)
|
||
|
||
class _Dry:
|
||
success = ok
|
||
filled_qty = qty if ok else 0
|
||
filled_avg_price = price if ok else 0.0
|
||
reason = "paper_ok" if ok else "paper_fail"
|
||
ord_no = paper_ord
|
||
|
||
return _Dry()
|
||
|
||
from ..execution.order_manager import OrderRequest
|
||
|
||
code = str(signal.get("code") or "").strip().upper()
|
||
name = str(signal.get("name") or code)
|
||
price = float(signal.get("price") or 0)
|
||
qty = int(signal.get("qty") or 0)
|
||
exchange = str(
|
||
signal.get("exchange")
|
||
or get_env_from_db("KIS_OVRS_DEFAULT_EXCG", "NASD")
|
||
or "NASD"
|
||
).strip().upper()
|
||
om = getattr(self, "order_mgr", None) or getattr(self, "order_manager", None)
|
||
if om is None:
|
||
self.logger.error("⚠️ US_MOMENTUM OrderManager 없음 (%s)", code)
|
||
|
||
class _NoOm:
|
||
success = False
|
||
filled_qty = 0
|
||
filled_avg_price = 0.0
|
||
reason = "no_order_mgr"
|
||
ord_no = None
|
||
|
||
return _NoOm()
|
||
|
||
req = OrderRequest(
|
||
strategy_id=self.strategy_id,
|
||
code=code,
|
||
name=name,
|
||
side="BUY",
|
||
qty=qty,
|
||
price_ref=price,
|
||
stop_price=float(signal.get("stop_price") or 0),
|
||
target_price=float(signal.get("target_price") or 0),
|
||
atr_entry=float(signal.get("atr_entry") or 0),
|
||
size_class=str(signal.get("size_class") or "") or None,
|
||
entry_features=signal.get("entry_features"),
|
||
market="US",
|
||
exchange=exchange,
|
||
currency="USD",
|
||
)
|
||
result = om.place(req)
|
||
if result and result.success:
|
||
now_str = dt.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
fill_px = float(result.filled_avg_price or price)
|
||
fill_qty = int(result.filled_qty or qty)
|
||
self.holdings[code] = {
|
||
"buy_price": fill_px,
|
||
"qty": fill_qty,
|
||
"stop_price": float(signal.get("stop_price") or 0),
|
||
"target_price": float(signal.get("target_price") or 0),
|
||
"max_price": fill_px,
|
||
"session_low": fill_px,
|
||
"atr_entry": float(signal.get("atr_entry") or 0),
|
||
"buy_time": now_str,
|
||
"name": name,
|
||
"size_class": str(signal.get("size_class") or ""),
|
||
"exchange": exchange,
|
||
"ord_no": result.ord_no,
|
||
"paper": False,
|
||
}
|
||
self._capture_runtime_overlay()
|
||
try:
|
||
self._psamount_cache.clear()
|
||
except Exception:
|
||
pass
|
||
self.logger.info(
|
||
"🧾 [US_MOMENTUM OM BUY] %s qty=%s @%.4f odno=%s excg=%s",
|
||
code, fill_qty, fill_px, result.ord_no, exchange,
|
||
)
|
||
else:
|
||
try:
|
||
self._psamount_cache.clear()
|
||
except Exception:
|
||
pass
|
||
reason = getattr(result, "reason", "") if result else "no_result"
|
||
self.logger.warning(
|
||
"⚠️ [US_MOMENTUM OM BUY 실패] %s reason=%s", code, reason,
|
||
)
|
||
return result
|
||
|
||
def _submit_sell(self, signal: Dict):
|
||
if self._dry_run:
|
||
code = str(signal.get("code") or "")
|
||
name = str(signal.get("name") or code)
|
||
qty = int(signal.get("qty") or 0)
|
||
px = float(signal.get("current_price") or signal.get("price") or 0)
|
||
buy_px = float(signal.get("buy_price") or 0)
|
||
reason = str(signal.get("reason") or "paper")
|
||
ok = paper_store.open_paper_sell(
|
||
self.db,
|
||
strategy_id=self.strategy_id,
|
||
code=code,
|
||
name=name,
|
||
qty=qty,
|
||
sell_price=px,
|
||
sell_reason=reason,
|
||
buy_price=buy_px,
|
||
)
|
||
if ok:
|
||
self.recently_sold[code] = time.time()
|
||
self._drop_local_position(code)
|
||
|
||
class _DrySell:
|
||
success = ok
|
||
filled_qty = qty if ok else 0
|
||
filled_avg_price = px if ok else 0.0
|
||
reason = "paper_ok" if ok else "paper_fail"
|
||
|
||
return _DrySell()
|
||
|
||
from ..execution.order_manager import OrderRequest
|
||
|
||
code = str(signal.get("code") or "").strip().upper()
|
||
qty = int(signal.get("qty") or 0)
|
||
px = float(signal.get("current_price") or signal.get("price") or 0)
|
||
exchange = str(
|
||
signal.get("exchange")
|
||
or (self.holdings.get(code) or {}).get("exchange")
|
||
or get_env_from_db("KIS_OVRS_DEFAULT_EXCG", "NASD")
|
||
or "NASD"
|
||
).strip().upper()
|
||
om = getattr(self, "order_mgr", None) or getattr(self, "order_manager", None)
|
||
if om is None:
|
||
self.logger.error("⚠️ US_MOMENTUM OrderManager 없음 (매도 %s)", code)
|
||
|
||
class _NoOmSell:
|
||
success = False
|
||
filled_qty = 0
|
||
filled_avg_price = 0.0
|
||
reason = "no_order_mgr"
|
||
|
||
return _NoOmSell()
|
||
|
||
buy_px = float(
|
||
(self.holdings.get(code) or {}).get("buy_price")
|
||
or signal.get("buy_price")
|
||
or 0
|
||
)
|
||
hold_name = str(
|
||
(self.holdings.get(code) or {}).get("name")
|
||
or signal.get("name")
|
||
or code
|
||
)
|
||
req = OrderRequest(
|
||
strategy_id=self.strategy_id,
|
||
code=code,
|
||
name=hold_name,
|
||
side="SELL",
|
||
qty=qty,
|
||
price_ref=px,
|
||
reason=str(signal.get("reason") or "overseas"),
|
||
buy_price=buy_px,
|
||
profit_pct=float(signal.get("profit_pct") or 0),
|
||
market="US",
|
||
exchange=exchange,
|
||
currency="USD",
|
||
)
|
||
result = om.place(req)
|
||
if result and result.success:
|
||
self.recently_sold[code] = time.time()
|
||
self._drop_local_position(code)
|
||
self.logger.info(
|
||
"🧾 [US_MOMENTUM OM SELL] %s qty=%s @%.4f odno=%s reason=%s",
|
||
code, qty, px, result.ord_no, signal.get("reason"),
|
||
)
|
||
else:
|
||
reason = getattr(result, "reason", "") if result else "no_result"
|
||
# 영구형 거절 쿨다운 중엔 초단위 WARNING 도배 방지
|
||
if "cooldown" in str(reason or "").lower():
|
||
self.logger.debug(
|
||
"⏳ [US_MOMENTUM OM SELL 쿨다운] %s reason=%s", code, reason,
|
||
)
|
||
else:
|
||
self.logger.warning(
|
||
"⚠️ [US_MOMENTUM OM SELL 실패] %s reason=%s", code, reason,
|
||
)
|
||
return result
|