""" 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