ㅇ Changes: - Introduced the DART strategy to the trading system, including its configuration and integration into the existing framework. - Updated the database schema to include DART-specific tables for disclosures and watchlists. - Enhanced the backtesting and parameter search functionalities to support the DART strategy. - Implemented new rules for browser verification and API interactions to ensure compliance with the updated DART strategy. Impact: - These additions expand the trading capabilities of the system, allowing for more comprehensive analysis and execution of DART-related strategies, while maintaining system integrity and performance.
770 lines
32 KiB
Python
770 lines
32 KiB
Python
"""
|
||
kis_trader/strategies/updow_strategy.py — Updow 라이브 전략
|
||
==========================================================
|
||
- **유니버스**: ``updow_stock_config`` 에 등록된 종목만 매수 스캔.
|
||
- **파라미터**: 종목별 ``updow_stock_config`` (없으면 ``env_config`` ``UPDOW_*`` 폴백).
|
||
- **분봉(tf)**: 종목별 ``updow_tf_min`` 이 양수면 해당 분봉 WS/백테, 아니면 env ``UPDOW_TF_MIN``.
|
||
- 신호·청산 로직: ``updow_buy`` 엔진과 동일.
|
||
- 진입봉 시각은 ``active_trades.size_class`` 에 ``u|YYYYMMDDHHMM`` 저장 (재기동 복원).
|
||
- **시장 레짐(실매)**: ``UPDOW_KOSPI_1MIN_PROXY_CODE`` 가 ``PERMANENT_WS_CODES`` 등으로 WS에
|
||
구독돼 있으면 **1분 확정봉을 WS에서 우선** 읽고(REST 절약), 봉 수 부족 시에만
|
||
``get_minute_chart`` REST 폴백. env ``UPDOW_REGIME_PREFER_WS_CANDLES``(기본 true),
|
||
``UPDOW_REGIME_WS_CANDLE_MIN``(기본 40, 최소 확보 봉 수 하한).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import random
|
||
import time
|
||
from typing import Dict, List, Optional, Tuple
|
||
|
||
from ..engine.limit_entry_common import (
|
||
floor_limit_price_krw,
|
||
is_limit_atr_entry,
|
||
should_cancel_unfilled_limit,
|
||
updow_entry_mode,
|
||
)
|
||
from ..engine.updown_box import (
|
||
check_buy_signal_box_live,
|
||
check_sell_signal_box_live,
|
||
get_box_cfg_from_env,
|
||
)
|
||
from ..utils.env import get_env_bool, get_env_float, get_env_int, get_env_from_db
|
||
from .base import BaseStrategy
|
||
from .updow_buy import (
|
||
clamp_regime_ma_ease_pct,
|
||
kospi_proxy_regime_block_state,
|
||
)
|
||
from .updow_holding_cfg import (
|
||
ensure_updow_stock_config_table,
|
||
effective_updow_tf_for_code,
|
||
fetch_latest_updow_stock_config_by_code,
|
||
load_updow_engine_cfg,
|
||
)
|
||
from .updown_box_stock_cfg import (
|
||
ensure_updown_box_stock_cfg_table,
|
||
get_box_overrides,
|
||
)
|
||
from .updown_scan import run_updown_scan, scan_fetch_n, scan_tf_min
|
||
from .updown_watchlist import (
|
||
ensure_updown_watchlist_table,
|
||
list_active_watchlist,
|
||
)
|
||
|
||
|
||
class UpdowStrategy(BaseStrategy):
|
||
strategy_id = "UPDOW"
|
||
loop_min_sleep = 1.5
|
||
loop_max_sleep = 2.5
|
||
|
||
# DB size_class 에 저장하는 진입봉 키 접두사 (VARCHAR(20) 이하 유지)
|
||
_ENTRY_TAG_PREFIX = "u|"
|
||
|
||
def __init__(self, **kwargs):
|
||
super().__init__(**kwargs)
|
||
self.candle_tf = 60
|
||
self._env_tf_default = 60
|
||
self._updow_cfg: Dict = {}
|
||
self._holding_row_by_code: Dict[str, Dict] = {}
|
||
self.min_price = 1000.0
|
||
self._updow_fired_entry_key: Dict[str, str] = {}
|
||
self._pending_limit_orders: Dict[str, Dict] = {}
|
||
# SCAN(조건검색→박스필터→watchlist) 자체 주기 스로틀 타임스탬프
|
||
self._last_scan_ts: float = 0.0
|
||
# watchlist 박스 경계 캐시 (code → {box_low, box_high}) — 박스이탈 손절 참조용
|
||
self._watch_box_by_code: Dict[str, Dict] = {}
|
||
self.reload_config()
|
||
|
||
def reload_config(self) -> None:
|
||
snap = None
|
||
try:
|
||
snap = self.db.get_latest_env()
|
||
except Exception:
|
||
pass
|
||
self._updow_cfg = get_box_cfg_from_env()
|
||
self.candle_tf = scan_tf_min()
|
||
self._env_tf_default = int(self.candle_tf)
|
||
self.min_price = get_env_float("MIN_STOCK_PRICE", 1000.0)
|
||
self.slot_money = int(
|
||
float(self._updow_cfg.get("slot_money", get_env_float("UPDOW_SLOT_MONEY", 3_000_000.0)))
|
||
)
|
||
try:
|
||
ensure_updow_stock_config_table(self.db)
|
||
from .updow_holding_cfg import migrate_all_updow_from_holding
|
||
|
||
migrate_all_updow_from_holding(self.db, self._updow_cfg)
|
||
self._holding_row_by_code = fetch_latest_updow_stock_config_by_code(self.db)
|
||
except Exception as e:
|
||
self.logger.warning("updow_stock_config 로드 실패: %s", e)
|
||
self._holding_row_by_code = {}
|
||
|
||
def manage_pending_orders(self) -> None:
|
||
if not self._pending_limit_orders:
|
||
return
|
||
from ..execution.order_manager import OrderRequest
|
||
|
||
for code in list(self._pending_limit_orders.keys()):
|
||
pend = self._pending_limit_orders.get(code)
|
||
if not pend:
|
||
continue
|
||
if code in self.holdings:
|
||
self._pending_limit_orders.pop(code, None)
|
||
continue
|
||
req = pend.get("request")
|
||
ord_no = pend.get("ord_no")
|
||
if req and ord_no:
|
||
fin = self.order_mgr.try_finalize_limit_buy(req, ord_no)
|
||
if fin.success and fin.filled_qty > 0:
|
||
self._load_holdings_from_db()
|
||
self._pending_limit_orders.pop(code, None)
|
||
continue
|
||
tf_code = self._candle_tf_for_code(code)
|
||
candles_raw = self.ws.get_candles(code, tf_code, n=30)
|
||
if not candles_raw:
|
||
continue
|
||
candles = [self._norm_candle(c) for c in candles_raw]
|
||
latest_key = str(candles[-1].get("candle_time") or "")[:12]
|
||
vu = str(pend.get("valid_until_bar_key") or "")[:12]
|
||
if not should_cancel_unfilled_limit(latest_key, vu):
|
||
continue
|
||
disp = pend.get("name") or code
|
||
if ord_no and self.order_mgr.client.cancel_order(str(ord_no)):
|
||
self.logger.info(
|
||
"🚫 [UPDOW 지정가취소] %s %s — 유효봉 %s 종료 미체결",
|
||
disp, code, vu,
|
||
)
|
||
else:
|
||
self.logger.info(
|
||
"🚫 [UPDOW 지정가만료] %s — 유효봉 %s (HTS 미체결 확인)",
|
||
code, vu,
|
||
)
|
||
self._pending_limit_orders.pop(code, None)
|
||
|
||
def on_limit_buy_submitted(self, signal: Dict, result) -> None:
|
||
from ..execution.order_manager import OrderRequest
|
||
|
||
code = signal["code"]
|
||
self._pending_limit_orders[code] = {
|
||
"ord_no": result.ord_no,
|
||
"valid_until_bar_key": signal.get("valid_until_bar_key"),
|
||
"name": signal.get("name", code),
|
||
"request": OrderRequest(
|
||
strategy_id=self.strategy_id,
|
||
code=code,
|
||
name=signal.get("name", code),
|
||
side="BUY",
|
||
qty=int(signal.get("qty", 0)),
|
||
price_ref=float(signal.get("price", 0)),
|
||
stop_price=float(signal.get("stop_price", 0)),
|
||
target_price=float(signal.get("target_price", 0)),
|
||
atr_entry=float(signal.get("atr_entry", 0)),
|
||
size_class=signal.get("size_class"),
|
||
entry_features=signal.get("entry_features"),
|
||
use_limit_buy=True,
|
||
),
|
||
}
|
||
|
||
def _reentry_cooldown_sec(self) -> int:
|
||
v = get_env_int("UPDOW_REENTRY_COOLDOWN_SEC", 0)
|
||
if v > 0:
|
||
return v
|
||
return super()._reentry_cooldown_sec()
|
||
|
||
def _max_stocks(self) -> int:
|
||
"""동시 보유 종목 수 — ``UPDOW_MAX_STOCKS`` (없으면 ``MAX_STOCKS``)."""
|
||
n = get_env_int("UPDOW_MAX_STOCKS", 0)
|
||
if n > 0:
|
||
return n
|
||
return get_env_int("MAX_STOCKS", 3)
|
||
|
||
@staticmethod
|
||
def _updow_slot_default_krw() -> int:
|
||
"""1회 매수 시도 금액 기본값 — ``UPDOW_SLOT_MONEY`` (총 한도와 별개)."""
|
||
return int(get_env_float("UPDOW_SLOT_MONEY", 3_000_000.0))
|
||
|
||
@staticmethod
|
||
def _updow_total_budget_krw() -> int:
|
||
"""
|
||
전략 총 운용 한도(원). UPDOW 보유 종목 매입금 합 ≤ 이 값.
|
||
0 이면 총 한도 검사 생략.
|
||
"""
|
||
cap = get_env_int("UPDOW_MAX_BUY_AMOUNT", 0)
|
||
if cap <= 0:
|
||
cap = get_env_int("MAX_BUY_AMOUNT_PER_STOCK", 0)
|
||
return int(cap)
|
||
|
||
def _updow_exposure_krw(self) -> float:
|
||
"""현재 UPDOW 메모리 보유의 매입금 합 (재기동 시 DB 로드분 포함)."""
|
||
total = 0.0
|
||
for h in self.holdings.values():
|
||
q = int(h.get("qty") or 0)
|
||
p = float(h.get("buy_price") or 0)
|
||
if q > 0 and p > 0:
|
||
total += q * p
|
||
return total
|
||
|
||
def _updow_remaining_budget_krw(self) -> float:
|
||
"""신규 매수에 쓸 수 있는 잔여 운용 한도."""
|
||
cap = self._updow_total_budget_krw()
|
||
if cap <= 0:
|
||
return float("inf")
|
||
return max(0.0, float(cap) - self._updow_exposure_krw())
|
||
|
||
def _candidate_filter(self, candidate: Dict) -> bool:
|
||
return bool(candidate.get("updow_on", True))
|
||
|
||
# ──────────────────────────────────────────────────────────
|
||
# SCAN: 조건검색 → 박스필터 → updown_watchlist 충전 (자체 주기)
|
||
# ──────────────────────────────────────────────────────────
|
||
def _scan_interval_sec(self) -> int:
|
||
"""SCAN 실행 최소 간격(초). 박스는 느리게 변하므로 기본 300초(5분)."""
|
||
return get_env_int("UPDOWN_SCAN_INTERVAL_SEC", 300)
|
||
|
||
def _scan_get_candles(self, code: str, tf_min: int, n: int) -> List[Dict]:
|
||
"""SCAN 박스 판별용 분봉 조회 — WS 확정봉 우선, 부족 시 REST 폴백.
|
||
|
||
WS(키움) 에 이미 구독돼 있으면 REST 절약. 봉 수 부족하면 한투
|
||
``get_minute_chart`` REST 로 보충(조회는 항상 실키 market_client).
|
||
"""
|
||
out: List[Dict] = []
|
||
try:
|
||
raw = self.ws.get_candles(code, tf_min, n=n) if getattr(self, "ws", None) else []
|
||
out = [self._norm_candle(c) for c in (raw or [])]
|
||
except Exception:
|
||
out = []
|
||
if len(out) >= n:
|
||
return out
|
||
# REST 폴백
|
||
try:
|
||
df = self.client.get_minute_chart(code, str(tf_min), limit=n)
|
||
except Exception:
|
||
df = None
|
||
if df is not None and not getattr(df, "empty", True):
|
||
try:
|
||
rest_rows = [
|
||
{
|
||
"candle_time": "",
|
||
"open": float(r.get("open", 0) or 0),
|
||
"high": float(r.get("high", 0) or 0),
|
||
"low": float(r.get("low", 0) or 0),
|
||
"close": float(r.get("close", 0) or 0),
|
||
"volume": float(r.get("volume", 0) or 0),
|
||
}
|
||
for r in df.to_dict("records")
|
||
]
|
||
if len(rest_rows) > len(out):
|
||
out = rest_rows
|
||
except Exception:
|
||
pass
|
||
return out
|
||
|
||
def _run_scan_if_due(self) -> None:
|
||
"""조건검색 후보를 박스필터링해 watchlist 에 충전 (스로틀 적용).
|
||
|
||
- 조건검색 매니저(``self.condition_mgr``) 가 없으면 SCAN 생략(폴백 운영).
|
||
- ``UPDOWN_SCAN_INTERVAL_SEC`` 간격으로만 실행 (5분 스캔 원칙).
|
||
"""
|
||
if not getattr(self, "condition_mgr", None):
|
||
return
|
||
now = time.time()
|
||
if now - float(self._last_scan_ts or 0) < self._scan_interval_sec():
|
||
return
|
||
self._last_scan_ts = now
|
||
try:
|
||
cands = self.condition_mgr.get_candidates_for(self.strategy_id) or []
|
||
except Exception as e:
|
||
self.logger.debug("[UPDOWN SCAN] 조건검색 후보 조회 실패: %s", e)
|
||
return
|
||
# 전략별 후보 하드캡 (UPDOW_CAND_LIMIT) + 비본주 필터 — WS/REST 부하 절약
|
||
cands = self._post_filter_candidates(cands)
|
||
if not cands:
|
||
self.logger.debug("[UPDOWN SCAN] 조건후보 0 (CAND_LIMIT/필터 후)")
|
||
return
|
||
try:
|
||
run_updown_scan(
|
||
self.db,
|
||
cands,
|
||
self._scan_get_candles,
|
||
source="condition",
|
||
sleep_between=True,
|
||
)
|
||
except Exception as e:
|
||
self.logger.warning("[UPDOWN SCAN] 실행 실패: %s", e)
|
||
|
||
def _load_candidates(self) -> List[Dict]:
|
||
"""유니버스 = updown_watchlist(active) 우선, 비면 updow_stock_config 폴백.
|
||
|
||
매 루프 호출되지만 SCAN 은 ``_run_scan_if_due`` 내부에서 5분 스로틀.
|
||
(전략 전용 — BaseStrategy 랭킹 경로 미사용)
|
||
"""
|
||
# 1) 조건검색 → 박스필터 → watchlist 충전 (스로틀)
|
||
self._run_scan_if_due()
|
||
|
||
# 2) watchlist active 를 1순위 유니버스로 사용 (sticky)
|
||
cands: List[Dict] = []
|
||
self._watch_box_by_code = {}
|
||
try:
|
||
ensure_updown_watchlist_table(self.db)
|
||
for row in list_active_watchlist(self.db):
|
||
code = str(row.get("code") or "").strip()
|
||
if not code:
|
||
continue
|
||
nm = (row.get("name") or code or "").strip() or code
|
||
self._watch_box_by_code[code] = {
|
||
"box_low": float(row.get("box_low") or 0),
|
||
"box_high": float(row.get("box_high") or 0),
|
||
}
|
||
cands.append({
|
||
"code": code, "name": nm,
|
||
"updow_on": True, "scalp_on": False, "tail_on": False,
|
||
})
|
||
except Exception as e:
|
||
self.logger.warning("[UPDOWN 유니버스] watchlist 로드 실패: %s", e)
|
||
|
||
if cands:
|
||
return self._post_filter_candidates(cands)
|
||
|
||
# 3) 폴백: 기존 updow_stock_config 기반 유니버스 (수동 등록 종목 보존)
|
||
rows = getattr(self, "_holding_row_by_code", None) or {}
|
||
if not rows:
|
||
self.logger.info(
|
||
"📂 [UPDOW 유니버스] watchlist active 0 + updow_stock_config 0 → 후보 0"
|
||
)
|
||
return []
|
||
fb: List[Dict] = []
|
||
for code in sorted(rows.keys()):
|
||
row = rows[code]
|
||
nm = (row.get("name") or code or "").strip() or code
|
||
fb.append({
|
||
"code": code,
|
||
"name": nm,
|
||
"updow_on": True,
|
||
"scalp_on": False,
|
||
"tail_on": False,
|
||
})
|
||
return self._post_filter_candidates(fb)
|
||
|
||
def _norm_candle(self, c: dict) -> dict:
|
||
ct = c.get("candle_time") or c.get("candle_time_str", "")
|
||
if isinstance(ct, str) and len(ct) == 19 and " " in ct:
|
||
ct = ct.replace("-", "").replace(" ", "").replace(":", "")[:12]
|
||
return {
|
||
"candle_time": ct,
|
||
"open": float(c.get("open", 0)),
|
||
"high": float(c.get("high", 0)),
|
||
"low": float(c.get("low", 0)),
|
||
"close": float(c.get("close", 0)),
|
||
"volume": float(c.get("volume", 0)),
|
||
}
|
||
|
||
def _parse_entry_bar_key(self, holding: dict) -> str:
|
||
sc = (holding.get("size_class") or "").strip()
|
||
if sc.startswith(self._ENTRY_TAG_PREFIX):
|
||
return sc[len(self._ENTRY_TAG_PREFIX) :]
|
||
return ""
|
||
|
||
def _after_holdings_sync(self) -> None:
|
||
for _code, h in self.holdings.items():
|
||
ek = self._parse_entry_bar_key(h)
|
||
if ek:
|
||
h["updow_entry_bar_key"] = ek
|
||
self._runtime.setdefault(_code, {})["updow_entry_bar_key"] = ek
|
||
|
||
def _load_holdings_from_db(self, *, log_restore: bool = False) -> None:
|
||
super()._load_holdings_from_db(log_restore=log_restore)
|
||
|
||
def _submit_buy(self, signal: Dict):
|
||
code = signal.get("code") or ""
|
||
key = signal.get("updow_entry_bar_key") or ""
|
||
if key:
|
||
self._updow_fired_entry_key[code] = key
|
||
result = super()._submit_buy(signal)
|
||
if not (result and result.success) and key:
|
||
self._updow_fired_entry_key.pop(code, None)
|
||
elif result and result.success and code in self.holdings:
|
||
sc = (signal.get("size_class") or "").strip()
|
||
if sc:
|
||
self.holdings[code]["size_class"] = sc
|
||
if key:
|
||
self.holdings[code]["updow_entry_bar_key"] = key
|
||
ef = signal.get("entry_features") or {}
|
||
if ef.get("box_low"):
|
||
self.holdings[code]["box_low"] = float(ef["box_low"])
|
||
if ef.get("box_high"):
|
||
self.holdings[code]["box_high"] = float(ef["box_high"])
|
||
self._capture_runtime_overlay()
|
||
return result
|
||
|
||
def _merged_cfg(self, code: str) -> Dict:
|
||
"""박스 엔진 글로벌 cfg + (옵션) 종목별 slot/레짐 + 종목별 박스 파라미터 오버라이드.
|
||
|
||
우선순위(낮음→높음): 글로벌 env UPDOWN_BOX_* → 구 updow_stock_config(slot/레짐만)
|
||
→ updown_box_stock_cfg(종목별 박스 파라미터 핀, 설정된 키만).
|
||
"""
|
||
cfg = dict(get_box_cfg_from_env())
|
||
# 1) slot/레짐 — 구 updow_stock_config 폴백 (박스 파라미터는 건드리지 않음)
|
||
row = self._holding_row_by_code.get(code)
|
||
if row:
|
||
for k in ("slot_money", "regime_ma_bars", "regime_ma_ease_pct"):
|
||
if row.get(k) is not None:
|
||
cfg[k] = row[k]
|
||
else:
|
||
extra = load_updow_engine_cfg(self.db, code, {})
|
||
for k in ("slot_money", "regime_ma_bars", "regime_ma_ease_pct"):
|
||
if extra.get(k) is not None:
|
||
cfg[k] = extra[k]
|
||
# 2) 종목별 박스 파라미터 핀(updown_box_stock_cfg) — 설정된 키만 글로벌 위에 덮음(hybrid)
|
||
try:
|
||
ov = get_box_overrides(self.db, code)
|
||
for k, v in ov.items():
|
||
cfg[k] = v
|
||
except Exception as e:
|
||
self.logger.debug("box override 로드 실패 %s: %s", code, e)
|
||
return cfg
|
||
|
||
def _candle_tf_for_code(self, code: str) -> int:
|
||
"""박스 판별·진입 분봉 — SCAN 과 동일 (기본 15분)."""
|
||
return scan_tf_min()
|
||
|
||
def _kospi_proxy_regime_eval(self, merged: Dict[str, Any]) -> Tuple[bool, str]:
|
||
"""KOSPI 추적 ETF 1분 종가 < N분 단순 MA 이면 신규 매수 차단.
|
||
|
||
WS ``CandleAggregator`` 확정 1분봉 우선(``PERMANENT_WS_CODES`` 등과 정합, REST 절약),
|
||
``ma``개 미만이면 한투 ``get_minute_chart`` 로 폴백.
|
||
차단 조건: ``종가 < SMA×(1−regime_ma_ease_pct/100)`` (ease=0 이면 종가<SMA).
|
||
|
||
Returns:
|
||
(True, detail) — 차단 시 ``detail`` 에 프록시·종가·SMA·출처(WS/REST) 요약
|
||
(False, "") — 차단 아님 또는 레짐 미사용·데이터 부족
|
||
"""
|
||
try:
|
||
ma = int(float(merged.get("regime_ma_bars", 0)))
|
||
except (TypeError, ValueError):
|
||
ma = 0
|
||
if ma < 1:
|
||
return False, ""
|
||
proxy = (get_env_from_db("UPDOW_KOSPI_1MIN_PROXY_CODE", "069500") or "069500").strip()
|
||
if len(proxy) != 6 or not proxy.isdigit():
|
||
return False, ""
|
||
|
||
ws_floor = get_env_int("UPDOW_REGIME_WS_CANDLE_MIN", 40)
|
||
need = max(ma + 10, ws_floor)
|
||
closes: List[float] = []
|
||
src = "WS"
|
||
|
||
if get_env_bool("UPDOW_REGIME_PREFER_WS_CANDLES", True) and getattr(self, "ws", None):
|
||
try:
|
||
raw = self.ws.get_candles(proxy, 1, need)
|
||
except Exception:
|
||
raw = []
|
||
for c in raw or []:
|
||
try:
|
||
cl = float(c.get("close", 0) or 0)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if cl > 0:
|
||
closes.append(cl)
|
||
|
||
if len(closes) < ma:
|
||
closes = []
|
||
src = "REST"
|
||
try:
|
||
df = self.client.get_minute_chart(proxy, "1", limit=max(need, ma + 10, ws_floor))
|
||
except Exception:
|
||
df = None
|
||
if df is None or getattr(df, "empty", True):
|
||
return False, ""
|
||
try:
|
||
closes = [float(x) for x in df["close"].tolist()]
|
||
except Exception:
|
||
return False, ""
|
||
|
||
if len(closes) < ma:
|
||
return False, ""
|
||
|
||
ease = clamp_regime_ma_ease_pct(merged.get("regime_ma_ease_pct", 0))
|
||
st = kospi_proxy_regime_block_state(closes, ma, ease)
|
||
if st is None:
|
||
return False, ""
|
||
blocks, last, sma, floor, easeu = st
|
||
if not blocks:
|
||
return False, ""
|
||
|
||
detail = (
|
||
f"프록시={proxy}({src}) 1분종가={last:.2f} SMA({ma})={sma:.2f} "
|
||
f"ease={easeu:.3f}% 차단선={floor:.2f} 갭(종가−차단선)={last - floor:.2f} → 신규매수 정지"
|
||
)
|
||
return True, detail
|
||
|
||
def check_buy(self, code: str, name: str) -> Optional[Dict]:
|
||
merged = self._merged_cfg(code)
|
||
|
||
# 대형 주도주 등 하락매수 제외 종목 차단 (DIP_BUY_EXCLUDE_CODES 비면 무효)
|
||
if self.is_dip_buy_excluded(code):
|
||
self.logger.info("🔍 [탈락-대형주제외] %s %s: DIP_BUY_EXCLUDE_CODES", name, code)
|
||
return None
|
||
|
||
regime_block, regime_detail = self._kospi_proxy_regime_eval(merged)
|
||
if regime_block:
|
||
self.logger.info(
|
||
"🔍 [탈락-시장레짐] %s %s: %s | 참고: 코스피200·지수 일봉 상승과 무관 "
|
||
"(레짐은 프록시 ETF 확정 1분봉의 단기 MA 대비)",
|
||
name,
|
||
code,
|
||
regime_detail,
|
||
)
|
||
return None
|
||
|
||
if get_env_bool("FORCE_BUY_TEST", False):
|
||
px = 0.0
|
||
wsd = self.ws.get_price(code)
|
||
if wsd:
|
||
try:
|
||
px = abs(float(str(wsd.get("stck_prpr", 0)).replace(",", "")))
|
||
except Exception:
|
||
px = 0.0
|
||
if px <= 0:
|
||
self.logger.info("🔍 [탈락-FORCE] %s %s: 현재가 없음", name, code)
|
||
return None
|
||
slot_def = self._updow_slot_default_krw()
|
||
slot = int(float(merged.get("slot_money", slot_def)))
|
||
remain = self._updow_remaining_budget_krw()
|
||
invest = min(float(slot), remain) if remain != float("inf") else float(slot)
|
||
if invest <= 0:
|
||
self.logger.info(
|
||
"🔍 [탈락-총한도] %s %s: UPDOW 총운용한도 소진 (노출 %.0f / 한도 %d)",
|
||
name, code, self._updow_exposure_krw(), self._updow_total_budget_krw(),
|
||
)
|
||
return None
|
||
qty = max(1, int(invest / px))
|
||
sl_pct = float(merged.get("stop_loss_pct", get_env_float("UPDOW_STOP_LOSS_PCT", 2.0))) / 100.0
|
||
tp_pct = float(merged.get("tp_pct", get_env_float("UPDOW_TP_PCT", 3.0))) / 100.0
|
||
return {
|
||
"code": code,
|
||
"name": name,
|
||
"price": px,
|
||
"qty": qty,
|
||
"stop_price": px * (1.0 - sl_pct),
|
||
"target_price": px * (1.0 + tp_pct),
|
||
"atr_entry": 0.0,
|
||
"size_class": "",
|
||
"entry_features": {},
|
||
}
|
||
|
||
min_len = max(
|
||
int(self._updow_cfg.get("min_bars", 20)),
|
||
get_env_int("UPDOW_MIN_CANDLE_LEN", get_env_int("MIN_CANDLE_LEN_UPDOW", 20)),
|
||
)
|
||
n_fetch = max(min_len + 5, scan_fetch_n(), get_env_int("UPDOW_CANDLE_FETCH_N", 50))
|
||
tf_code = self._candle_tf_for_code(code)
|
||
candles_raw = self.ws.get_candles(code, tf_code, n=n_fetch)
|
||
if len(candles_raw) < min_len:
|
||
try:
|
||
self.ws.fill_gap([code], force=True)
|
||
except Exception:
|
||
pass
|
||
self.logger.info(
|
||
"🔍 [탈락-봉부족] %s %s: WS확정봉 %d개 (최소 %d, tf=%d)",
|
||
name, code, len(candles_raw), min_len, tf_code,
|
||
)
|
||
return None
|
||
|
||
candles = [self._norm_candle(c) for c in candles_raw]
|
||
box_cfg = self._merged_cfg(code)
|
||
reject, msg, sig = check_buy_signal_box_live(
|
||
candles,
|
||
box_cfg,
|
||
last_fired_entry_key=self._updow_fired_entry_key.get(code),
|
||
)
|
||
if reject:
|
||
self.logger.info("🔍 [%s] %s %s: %s", reject, name, code, msg or "")
|
||
return None
|
||
if not sig:
|
||
self.logger.info("🔍 [탈락-무신호] %s %s", name, code)
|
||
return None
|
||
|
||
entry_open = float(sig["entry_price"])
|
||
wsd = self.ws.get_price(code)
|
||
curr_price = entry_open
|
||
if wsd:
|
||
try:
|
||
curr_price = abs(float(str(wsd.get("stck_prpr", entry_open)).replace(",", ""))) or entry_open
|
||
except Exception:
|
||
curr_price = entry_open
|
||
|
||
if curr_price <= 0 or curr_price < self.min_price:
|
||
self.logger.info(
|
||
"🔍 [탈락-가격] %s %s: 현재가 %.0f (최소 %.0f)",
|
||
name, code, curr_price, self.min_price,
|
||
)
|
||
return None
|
||
|
||
slot_def = self._updow_slot_default_krw()
|
||
total_cap = self._updow_total_budget_krw()
|
||
remain = self._updow_remaining_budget_krw()
|
||
exposure = self._updow_exposure_krw()
|
||
slot = float(box_cfg.get("slot_money", float(self.slot_money)))
|
||
if slot <= 0:
|
||
slot = float(slot_def)
|
||
from ..utils.position_sizing import resolve_invest_amount_krw
|
||
|
||
cap_remain = int(remain) if remain != float("inf") else 0
|
||
invest_amount = resolve_invest_amount_krw(
|
||
int(slot),
|
||
extra_cap=cap_remain if cap_remain > 0 else 0,
|
||
)
|
||
if remain != float("inf"):
|
||
invest_amount = min(invest_amount, int(remain))
|
||
|
||
if remain != float("inf") and remain < entry_open * 0.99:
|
||
self.logger.info(
|
||
"🔍 [탈락-총한도] %s %s: 잔여 %.0f원 < 1주(%.0f원) | 노출 %.0f / 총한도 %d",
|
||
name, code, remain, entry_open, exposure, total_cap,
|
||
)
|
||
return None
|
||
|
||
if invest_amount <= 0:
|
||
self.logger.info(
|
||
"🔍 [탈락-금액] %s %s: invest=0 (slot=%.0f 잔여=%s)",
|
||
name, code, slot,
|
||
f"{remain:,.0f}" if remain != float("inf") else "무제한",
|
||
)
|
||
return None
|
||
|
||
qty = max(1, int(invest_amount / entry_open))
|
||
order_krw = qty * entry_open
|
||
if remain != float("inf") and order_krw > remain * 1.001:
|
||
qty = max(1, int(remain / entry_open))
|
||
order_krw = qty * entry_open
|
||
ent_key = str(sig.get("updow_entry_bar_key") or "")
|
||
size_class = f"{self._ENTRY_TAG_PREFIX}{ent_key}" if ent_key else ""
|
||
box_low = float(sig.get("box_low", 0) or self._watch_box_by_code.get(code, {}).get("box_low", 0) or 0)
|
||
|
||
self.logger.info(
|
||
"✅ [통과-박스매수] %s %s: tp=%.2f%% sl=%.2f%% box_low=%.0f "
|
||
"주문≈%s원 qty=%d 진입=%.0f score=%.2f",
|
||
name, code,
|
||
float(sig.get("tp_pct", 0)), float(sig.get("sl_pct", 0)),
|
||
box_low, f"{order_krw:,.0f}", qty, entry_open,
|
||
float(sig.get("box_score", 0)),
|
||
)
|
||
|
||
return {
|
||
"code": code,
|
||
"name": name,
|
||
"price": entry_open,
|
||
"qty": qty,
|
||
"stop_price": float(sig.get("stop_price", 0.0)),
|
||
"target_price": float(sig.get("target_price", 0.0)),
|
||
"atr_entry": 0.0,
|
||
"size_class": size_class,
|
||
"updow_entry_bar_key": ent_key,
|
||
"entry_features": {
|
||
"box_low": box_low,
|
||
"box_high": float(sig.get("box_high", 0) or 0),
|
||
"box_score": float(sig.get("box_score", 0) or 0),
|
||
"sl_pct": float(sig.get("sl_pct", 0.0)),
|
||
"tp_pct": float(sig.get("tp_pct", 0.0)),
|
||
},
|
||
}
|
||
|
||
def check_sell_signals(self) -> List[Dict]:
|
||
if not self.holdings:
|
||
return []
|
||
|
||
signals: List[Dict] = []
|
||
base_n = get_env_int("UPDOW_CANDLE_FETCH_N", 50)
|
||
|
||
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))
|
||
if qty <= 0 or buy_price <= 0:
|
||
continue
|
||
|
||
merged = self._merged_cfg(code)
|
||
max_hold = int(float(merged.get("max_hold_bars", 16)))
|
||
n_fetch = max(max_hold + 8, base_n)
|
||
|
||
current_price = 0.0
|
||
wsd = self.ws.get_price(code)
|
||
if wsd:
|
||
try:
|
||
current_price = abs(float(str(wsd.get("stck_prpr", 0)).replace(",", "")))
|
||
except Exception:
|
||
current_price = 0.0
|
||
if current_price <= 0:
|
||
pd_ = self.client.inquire_price(code)
|
||
if pd_:
|
||
try:
|
||
current_price = abs(float(str(pd_.get("stck_prpr", 0)).replace(",", "")))
|
||
except Exception:
|
||
current_price = 0.0
|
||
if current_price <= 0:
|
||
self.logger.debug("[UPDOW 매도] %s %s: 현재가 없음 — 스킵", name, code)
|
||
continue
|
||
|
||
tf_code = self._candle_tf_for_code(code)
|
||
candles_raw = self.ws.get_candles(code, tf_code, n=n_fetch)
|
||
candles = [self._norm_candle(c) for c in candles_raw]
|
||
entry_key = holding.get("updow_entry_bar_key") or self._parse_entry_bar_key(holding)
|
||
buy_time = str(holding.get("buy_time", "") or "")
|
||
|
||
mp = float(holding.get("max_price") or buy_price)
|
||
try:
|
||
h_now = float(candles[-1].get("high", 0) or 0) if candles else 0.0
|
||
except (TypeError, ValueError):
|
||
h_now = 0.0
|
||
mp = max(mp, current_price, h_now)
|
||
holding["max_price"] = mp
|
||
|
||
# 진입 시 저장한 박스 경계 (entry_features → holding) 우선, 없으면 watchlist 캐시
|
||
box_low = float(holding.get("box_low") or 0)
|
||
if box_low <= 0:
|
||
box_low = float(self._watch_box_by_code.get(code, {}).get("box_low", 0) or 0)
|
||
box_high = float(holding.get("box_high") or 0)
|
||
if box_high <= 0:
|
||
box_high = float(self._watch_box_by_code.get(code, {}).get("box_high", 0) or 0)
|
||
|
||
res = check_sell_signal_box_live(
|
||
buy_price=buy_price,
|
||
candles=candles,
|
||
cfg=merged,
|
||
entry_bar_key=entry_key,
|
||
buy_time_str=buy_time,
|
||
current_price=current_price,
|
||
max_price=mp,
|
||
box_low=box_low,
|
||
box_high=box_high,
|
||
)
|
||
if not res:
|
||
continue
|
||
reason, exit_price = res
|
||
profit_pct = (current_price - buy_price) / buy_price if buy_price > 0 else 0.0
|
||
self.logger.info(
|
||
"✅ [통과-매도신호] %s %s: %s (참고가 %.0f, 손익 %.2f%%) [holding tp/sl/hold=%.1f/%.1f/%d]",
|
||
name,
|
||
code,
|
||
reason,
|
||
exit_price,
|
||
profit_pct * 100.0,
|
||
float(merged.get("tp_pct", 0)),
|
||
float(merged.get("stop_loss_pct", 0)),
|
||
int(float(merged.get("max_hold_bars", 0))),
|
||
)
|
||
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,
|
||
})
|
||
except Exception as e:
|
||
self.logger.error("UPDOW 매도 시그널 체크 오류(%s): %s", code, e)
|
||
|
||
time.sleep(random.uniform(0.05, 0.15))
|
||
return signals
|