Changes: - Introduced new files for strategy definitions and study names. - Enhanced `backtest_web.py` with functions to handle integer display prices and trade data formatting. - Updated backtesting logic to incorporate end-of-day (EOD) parameters for breakout and momentum strategies. - Added EOD configuration options in the database and parameter search files. Impact: - These changes improve the modularity and usability of the backtesting framework, allowing for better integration of EOD strategies and clearer trade data presentation.
766 lines
32 KiB
Python
766 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:
|
||
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
|