Files
kis_bot/kis_trader/strategies/updow_strategy.py
Your Name 61bec4bd1d feat: Add DART strategy and related configurations
ㅇ
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.
2026-07-21 07:50:24 +09:00

770 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
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/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×(1regime_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