Files
kis_bot/kis_trader/strategies/us_momentum.py
Your Name bc2b1b642c feat(execution): AccountOrderWorker로 매수·매도 주문 직렬화
전략별 tick/scan 매도 락 대신 계좌 단일 PriorityQueue로 place를 B-full 직렬화한다.
틱매도 only_code 필터와 inflight 중복 enqueue 방지로 REST 폭주를 줄인다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 16:45:26 +09:00

1137 lines
48 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
kis_trader/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:
last = getattr(src, "get_price_last", None)
if callable(last):
wsd = last(code)
else:
wsd = src.get_price(code, max_age_sec=None)
except TypeError:
try:
wsd = src.get_price(code)
except Exception:
wsd = None
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, force: 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, force=force)
if self._should_skip_holdings_db_sync(force=force, log_restore=log_restore):
return
self._prof_sync_skipped = 0
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()
self._holdings_db_sync_ts = time.time()
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, only_code: Optional[str] = None):
"""국내 매도 엔진 + 종목별 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