ls증권 히스토리 구독 넣음

This commit is contained in:
Your Name
2026-07-30 18:05:07 +09:00
parent 61bec4bd1d
commit 67eab24603
1593 changed files with 135733 additions and 1232 deletions

View File

@@ -9,6 +9,29 @@ from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
def ensure_meta_db(meta_out: Optional[Dict[str, Any]]) -> Any:
"""``meta_out['db']`` 보장 — 틱/스냅샷 로드용.
웹 API는 항상 db 를 넣지만, CLI·잡·일부 스크립트에서 빠지면
틱 미로드 → OHLC만 청산 → 실매와 크게 어긋난다.
없을 때만 TradeDB 를 만들고 ``_db_created_for_bt`` 표시.
"""
if meta_out is None:
return None
db = meta_out.get("db")
if db is not None:
return db
try:
from database import TradeDB
db = TradeDB()
meta_out["db"] = db
meta_out["_db_created_for_bt"] = True
return db
except Exception:
return None
def flatten_remaining_portfolio_trades(
portfolio: Dict[str, Dict[str, Any]],
ctx_by_code: Dict[str, Dict[str, Any]],
@@ -168,6 +191,13 @@ STRATEGY_PORTFOLIO_KEYS: Dict[str, Dict[str, Tuple[str, ...]]] = {
"per_stock_cap": ("MOMENTUM_MAX_BUY_AMOUNT", "MAX_BUY_AMOUNT_PER_STOCK"),
"min_invest_env": ("MOMENTUM_MIN_INVEST_RATIO_OF_SLOT", "MIN_INVEST_RATIO_OF_SLOT"),
},
"US_MOMENTUM": {
"max_stocks": ("US_MOMENTUM_MAX_STOCKS",),
"total_budget": ("US_MOMENTUM_TOTAL_BUDGET", "US_MOMENTUM_TOTAL_BUDGET_KRW"),
"slot": ("US_MOMENTUM_SLOT_MONEY",),
"per_stock_cap": ("US_MOMENTUM_MAX_BUY_AMOUNT",),
"min_invest_env": ("US_MOMENTUM_MIN_INVEST_RATIO_OF_SLOT", "MIN_INVEST_RATIO_OF_SLOT"),
},
"BREAKOUT": {
"max_stocks": ("BREAKOUT_MAX_STOCKS", "MAX_STOCKS"),
"total_budget": ("BREAKOUT_TOTAL_BUDGET_KRW", "SHORT_TOTAL_BUDGET_KRW"),
@@ -323,7 +353,18 @@ def merge_param_search_apply_source(
def session_env_patch(strategy: str, p: Dict[str, Any]) -> Dict[str, str]:
"""매매시간 HHMM → env (전략별 TIME_*). 꼬리(SHORT/TAIL)는 TAIL_TIME_* 만."""
"""매매시간 HHMM → env (전략별 TIME_*). 꼬리(SHORT/TAIL)는 TAIL_TIME_* 만.
기본 OFF — 파라서치/Optuna apply 가 TIME_* 를 덮어쓰지 않음
(짧은 창 과적합 → 실매 오후 진입 잠금 방지). 운영 시간은 DB·웹에서 수동.
켜려면 PARAM_SEARCH_APPLY_SESSION_TIME=1.
"""
try:
from kis_trader.utils.env import get_env_bool
if not get_env_bool("PARAM_SEARCH_APPLY_SESSION_TIME", False):
return {}
except Exception:
return {}
patch: Dict[str, str] = {}
ts = p.get("time_start_hm")
te = p.get("time_end_hm")
@@ -341,6 +382,8 @@ def session_env_patch(strategy: str, p: Dict[str, Any]) -> Dict[str, str]:
patch["TIME_START"] = v
elif strat == "MOMENTUM":
patch["MOMENTUM_TIME_START"] = v
elif strat == "US_MOMENTUM":
patch["US_MOMENTUM_TIME_START"] = v
elif strat == "BREAKOUT":
patch["BREAKOUT_TIME_START"] = v
elif strat == "RANGE_BREAK":
@@ -356,6 +399,8 @@ def session_env_patch(strategy: str, p: Dict[str, Any]) -> Dict[str, str]:
patch["TIME_END"] = v
elif strat == "MOMENTUM":
patch["MOMENTUM_TIME_END"] = v
elif strat == "US_MOMENTUM":
patch["US_MOMENTUM_TIME_END"] = v
elif strat == "BREAKOUT":
patch["BREAKOUT_TIME_END"] = v
patch["BREAKOUT_GOLDEN_END_HM"] = v
@@ -368,8 +413,9 @@ def session_env_patch(strategy: str, p: Dict[str, Any]) -> Dict[str, str]:
# 전략 전용 키 prefix — 쓰기 시 '남의 전략' 키 오염 방지용.
# (읽기 fallback 튜플엔 타 전략 키가 폴백으로 들어있어, 그대로 쓰면 SCALP·SHORT 등이 덮인다)
# US_MOMENTUM_ 은 MOMENTUM_ 보다 먼저 매칭되도록 앞에 둠.
_KNOWN_STRATEGY_PREFIXES: Tuple[str, ...] = (
"TAIL_", "SHORT_", "SCALP_", "MOMENTUM_",
"TAIL_", "SHORT_", "SCALP_", "US_MOMENTUM_", "MOMENTUM_",
"BREAKOUT_", "RANGE_BREAK_", "UPDOW_", "DBBAND_",
)
@@ -613,6 +659,10 @@ def apply_daily_profit_halt_sim(
고점대비 trail_drop_pct% 되돌림이 처음 발생한 시각 이후의 신규 진입을 차단한다.
(보유분 청산은 그대로 — 실매 buy_allowed 와 동일: 신규매수만 중단.)
B(리스크버짓): ``_backtest_daily_profit_risk_budget`` ON 이면 hit 시점에
미청산 포지션의 최악손절합 > cushion 인 종목을 **손절가 전량청산**으로 근사
(실매 plan_risk_budget_trim 과 동일 선정 · 체결가는 stop 보수 가정).
게이트:
- params['_backtest_daily_profit_trail'] 명시 시 그 값 (웹 손익감시 스위치).
- 미지정이면 실매 ``{pfx}_DAILY_PROFIT_TARGET_ENABLED`` 추종.
@@ -634,12 +684,29 @@ def apply_daily_profit_halt_sim(
from kis_trader.engine.daily_profit_halt import (
_guard_active, _target_reached, _trail_reached,
plan_risk_budget_trim, resolve_stop_price, position_stop_risk_krw,
)
cfg = _daily_halt_cfg(p)
if not _guard_active(cfg):
return trades
mode = str(cfg.get("mode") or "fixed").lower()
rb_gate = p.get("_backtest_daily_profit_risk_budget")
if rb_gate is None:
sid = str(p.get("_daily_profit_strategy_id") or p.get("strategy_id") or "SHORT").strip().upper()
if sid == "TAIL":
sid = "SHORT"
try:
from kis_trader.engine.daily_profit_halt import load_strategy_profit_target
rb_gate = bool(load_strategy_profit_target(sid).get("risk_budget_enabled"))
except Exception:
from kis_trader.utils.env import get_env_bool
rb_gate = get_env_bool(f"{sid}_DAILY_PROFIT_RISK_BUDGET_ENABLED", False)
rb_on = bool(rb_gate)
sid_rb = str(p.get("_daily_profit_strategy_id") or p.get("strategy_id") or "SHORT").strip().upper()
if sid_rb == "TAIL":
sid_rb = "SHORT"
by_day: Dict[str, List[Dict]] = {}
for t in trades:
et = str(t.get("entry_time") or t.get("buy_time") or "")
@@ -653,6 +720,8 @@ def apply_daily_profit_halt_sim(
cum = 0.0
peak = 0.0
trigger_time: Optional[str] = None
trail_hit = False
fixed_hit = False
for t in closes:
cum += float(t.get("pnl") or 0)
peak = max(peak, cum)
@@ -662,9 +731,63 @@ def apply_daily_profit_halt_sim(
if fixed_hit or trail_hit:
trigger_time = str(t.get("exit_time") or t.get("sell_time") or "")
break
# B: hit 시점 미청산 → 리스크버짓 전량(손절가) 근사
force_codes: set = set()
if rb_on and trigger_time:
open_rows: List[Dict[str, Any]] = []
for t in ts:
et = str(t.get("entry_time") or t.get("buy_time") or "")
xt = str(t.get("exit_time") or t.get("sell_time") or "")
if et and et < trigger_time and (not xt or xt > trigger_time):
entry = float(t.get("buy_price") or t.get("entry") or 0)
qty = int(t.get("qty") or 1)
stop = float(t.get("stop_price") or 0)
stop = resolve_stop_price(
entry=entry, stop_price=stop, strategy_id=sid_rb,
)
risk = position_stop_risk_krw(qty=qty, entry=entry, stop_price=stop)
code = str(t.get("code") or t.get("ticker") or id(t))
open_rows.append({
"code": code,
"_trade": t,
"qty": qty,
"buy_price": entry,
"stop_price": stop,
"risk_krw": risk,
})
plan = plan_risk_budget_trim(
pnl_krw=cum,
peak_krw=peak,
cfg=cfg,
budget_krw=budget_krw,
positions=open_rows,
trail_hit=trail_hit,
fixed_hit=fixed_hit,
)
if plan.get("action") == "trim":
for row in plan.get("closes") or []:
tr = row.get("_trade")
if not tr:
continue
code = str(row.get("code") or "")
force_codes.add(code)
stop = float(row.get("stop_price") or 0)
entry = float(row.get("buy_price") or 0)
qty = int(row.get("qty") or 1)
# 보수: 손절가 청산으로 pnl 재계산 (수수료 미차감 — 기존 trade pnl 스케일 유지)
if entry > 0 and stop > 0:
tr["sell_price"] = stop
tr["exit"] = stop
tr["pnl"] = round((stop - entry) * qty)
tr["exit_time"] = trigger_time
tr["sell_time"] = trigger_time
tr["sell_reason"] = "리스크버짓"
tr["_risk_budget_force"] = True
for t in ts:
et = str(t.get("entry_time") or t.get("buy_time") or "")
if trigger_time and et >= trigger_time:
if trigger_time and et >= trigger_time and not t.get("_risk_budget_force"):
continue # 트리거 후 신규매수 차단
kept.append(t)
return kept
@@ -676,14 +799,21 @@ def attach_scalp_trade_pnl(
fee_rate: float,
sell_tax: float,
slip_pct: float = 0.0,
fx_fee_rate: float = 0.0,
pnl_decimals: int = 0,
) -> None:
"""스캘핑·모멘텀·돌파 공통 손익 (buy_time/sell_time 필드).
slip_pct: 백테 체결 슬리피지(편도 %). 시장가 즉시 체결 가정이라 매수는 불리(+)·
매도는 불리(-) 로 진입·청산 양측 반영. 표시용 가격은 그대로 두고 pnl 에만 반영.
0=OFF(동작 불변).
fx_fee_rate: 환전 스프레드 **편도** 비율 (예: 0.0005=0.05%). 매수·매도 금액에 각각 적용.
해외 US 등 — 0=OFF(국장 동작 불변).
pnl_decimals: 손익 반올림 자리 (국장 기본 0=원 단위 int, 해외 USD는 4 권장).
"""
slip = max(0.0, float(slip_pct or 0.0)) / 100.0
fx = max(0.0, float(fx_fee_rate or 0.0))
nd = max(0, int(pnl_decimals or 0))
for t in trades:
if t.get("pnl") is not None and t.get("pnl") != 0:
continue
@@ -695,9 +825,19 @@ def attach_scalp_trade_pnl(
xp = xp * (1.0 - slip) # 매도 체결 불리
buy_amt = ep * qty
sell_amt = xp * qty
t["pnl"] = round(
sell_amt - buy_amt - buy_amt * fee_rate - sell_amt * fee_rate - sell_amt * sell_tax
# 매매수수료(편도×2) + 매도세 + 환전(편도×매수·매도)
pnl = (
sell_amt - buy_amt
- buy_amt * float(fee_rate)
- sell_amt * float(fee_rate)
- sell_amt * float(sell_tax)
- (buy_amt + sell_amt) * fx
)
t["pnl"] = round(pnl, nd) if nd > 0 else round(pnl)
t["fee_rate"] = float(fee_rate)
t["sell_tax"] = float(sell_tax)
if fx > 0:
t["fx_fee_rate"] = fx
bt = t.get("buy_time") or t.get("entry_time")
st = t.get("sell_time") or t.get("exit_time")
if bt and st:
@@ -763,6 +903,8 @@ def resolve_trigger_snapshots_for_backtest(
start_key = str(meta_out.get("start_key") or "")
end_key = str(meta_out.get("end_key") or "")
db = meta_out.get("db")
if db is None and start_key and end_key:
db = ensure_meta_db(meta_out)
if db and start_key and end_key:
ob_loaded, pg_loaded, snap_meta = load_trigger_snapshots_by_code(
db, start_key, end_key, set(candles_by_code.keys()),