Files
kis_bot/remove/legacy_root/diag_backtest_4_30.py
Your Name 6d2a706a48 커밋 1 — 실매 가격 TTL 구멍 (본체)
왜: 체결이 없어도 마지막가는 유지인데, TTL로 None 만들고 매도/EOD를 건너뛰어 8/5 돌파·금요일 leftover가 남음. 호가필터 TTL 구멍과 같은 병.

넣을 파일

신규: kis_trader/engine/live_sell_price.py
kis_trader/strategies/base.py (_ws_last_quote, _resolve_sell_price)
전략: momentum.py scalping.py tail_catch.py breakout.py range_break.py dart_strategy.py updow_strategy.py updown_feed.py us_momentum.py
WS: ws_manager.py kis_ws.py kiwoom_ws.py ls_ws.py kis_ws_overseas.py
kis_trader/web/live_config_schema.py (WS_PRICE_MAX_AGE_SEC 기본 0)
database.py (키 주석 + legacy/ sys.path)
kis_trader/execution/order_manager.py (잔고 있는데 40240000 ghost_purge 금지 — 같은 EOD 사고)
EOD가 min_hold에 안 막히게 손본 momentum_hts_logic.py / scalping_engine.py / tail_engine.py (이 대화에서 손본 부분만 확인 후)
문서: docs/like_mcp.md/db_erd.md code_architecture.md (가격 TTL 문구)
메시지 초안

fix: 매수·매도 현재가를 TTL로 버리지 않음 (마지막 RAM)
횡보·체결 공백을 죽은 캐시로 오인해 None 처리하면 손절·EOD가 스킵된다.
호가필터와 같이 나이는 무시하고 마지막 체결가를 유지한다. EOD는 매수가 폴백.
영향: 실매 O / 백테·옵투나 봉 경로 거의 무관 (엔진 식 변경 아님)

커밋 2 — 루트 정리 (remove/ vs legacy/)
왜: 루트 단독봇·테스트는 지울 보관함으로. 웹·알람이 아직 쓰는 모듈은 remove에 두면 나중에 폴더째 삭제 때 깨짐.

넣을 파일

이동: 미사용 → remove/legacy_root/ (래퍼, ETF/키움 옛봇, 테스트, kiwoom_rest_api 등)
이동: 사용 중 → legacy/ (holding_bot kis_holding_ver1 news_analyzer kis_long_ver1/2)
신규: kis_trader/utils/legacy_root.py legacy/README.md remove/README.md
import 경로: backtest_web.py mm_butler.py mm_remote.py updow_holding_cfg.py dbband_stock_cfg.py param_search_updow*.py dbband_param_search.py param_search_apply_snapshot.py verify_three_paths.py
docs/like_mcp.md/code_architecture.md 수동 노트
메시지 초안

chore: 미사용 루트는 remove/, 웹·알람 구모듈은 legacy/
remove는 나중에 통째 삭제 예정. holding_bot·news_analyzer·kis_long은
ensure_legacy_root로 legacy/만 본다.
빼기: scratch/set_ws_price_max_age_zero.py (일회성)
2026-08-18 00:13:17 +09:00

199 lines
7.2 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.
"""
4/30 SHORT/SCALP 백테스트 진단 스크립트.
목적:
1) SHORT 0건의 진짜 원인 — 어느 임계치에서 거래가 발생하는지 sweep
2) SCALP -90만 trades 의 종목·시각·매매 상세
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from database import TradeDB
from kis_trader.engine import tail_engine as te
from kis_trader.engine import scalping_engine as se
START = "202604300830"
END = "202604301600"
def _load_universe(db, sid: str) -> dict:
"""target_candidates_history → {slot_key: [codes]}."""
rows = db.conn.execute(
"SELECT slot_key, code FROM target_candidates_history "
"WHERE strategy_id=%s AND DATE(event_time)='2026-04-30'",
(sid,),
).fetchall()
history: dict = {}
for r in rows:
history.setdefault(r["slot_key"], []).append(r["code"])
return history
def _load_candles(db, codes, tf: int, min_n: int = 19) -> dict:
out = {}
for code in codes:
rows = db.conn.execute(
"SELECT candle_time, open, high, low, close, volume FROM ws_candles "
"WHERE timeframe=%s AND code=%s AND candle_time>=%s AND candle_time<=%s "
"AND is_confirmed=1 ORDER BY candle_time",
(tf, code, START, END),
).fetchall()
if len(rows) >= min_n:
out[code] = [dict(r) for r in rows]
return out
def _trade_pnl(t):
"""엔진별 trade dict 차이를 흡수해 pnl 추출."""
if t.get("pnl") is not None:
return float(t["pnl"])
try:
entry = float(t.get("entry") or t.get("buy_price") or 0)
exit_ = float(t.get("exit") or t.get("sell_price") or 0)
qty = int(t.get("qty") or 1)
return (exit_ - entry) * qty
except Exception:
return 0.0
def _summary(trades):
if not trades:
return "trades=0"
n = len(trades)
pnls = [_trade_pnl(t) for t in trades]
win = sum(1 for p in pnls if p > 0)
return (f"trades={n} pnl합={sum(pnls):,.0f}"
f"승률={win}/{n}={win/n*100:.0f}% avg={sum(pnls)/n:,.0f}")
def diag_short(db):
print("\n" + "=" * 70)
print("SHORT (꼬리잡기) 4/30 진단")
print("=" * 70)
history = _load_universe(db, "SHORT")
codes = {c for lst in history.values() for c in lst}
print(f"universe: {len(codes)}종목, {len(history)}슬롯")
candles = _load_candles(db, codes, tf=3, min_n=19)
print(f"3분봉 적재: {len(candles)}종목 (RSI 계산 가능)")
base = te.get_tail_defaults_from_db()
base["scan_interval_min"] = 1 # SHORT slot 1분 단위
print()
print("--- 현재 DB 임계치 ---")
print(f" min_drop_rate = {base['min_drop_rate']*100:.2f}%")
print(f" min_recovery_ratio = {base['min_recovery_ratio']*100:.0f}%")
print(f" tail_ratio_min = {base['tail_ratio_min']:.2f}")
print(f" tail_pct_min = {base['tail_pct_min']*100:.2f}%")
print(f" max_daily_change = {base['max_daily_change']:.1f}%")
print(f" ma20_max_above = {base['ma20_max_above']:.1f}%")
print(f" rsi_threshold = {base['rsi_threshold']:.1f}")
print(f" max_rec_3m = {base['max_rec_3m']*100:.0f}%")
print(f" high_chase_thr = {base['high_chase_thr']:.2f}")
print()
print("--- 현재 임계치로 백테스트 ---")
trades = te.run_tail_backtest(candles, dict(base), universe_by_slot=history)
print(f"{_summary(trades)}")
print()
print("--- 임계치 단계적 완화 sweep ---")
sweeps = [
("baseline (현재)", {}),
("MIN_DROP 1.5% / REC 25%", {"min_drop_rate": 0.015, "min_recovery_ratio": 0.25}),
("MIN_DROP 1.0% / REC 20%", {"min_drop_rate": 0.010, "min_recovery_ratio": 0.20}),
("MIN_DROP 0.5% / REC 10%", {"min_drop_rate": 0.005, "min_recovery_ratio": 0.10}),
("MIN_DROP 0% / REC 0%", {"min_drop_rate": 0.0, "min_recovery_ratio": 0.0}),
("MA20 ∞ / RSI ∞ + 위 같음", {
"min_drop_rate": 0.0, "min_recovery_ratio": 0.0,
"ma20_max_above": 999.0, "rsi_threshold": 999.0,
"max_daily_change": 999.0, "max_rec_3m": 1.0,
"high_chase_thr": 999.0,
"tail_ratio_min": 0.0, "tail_pct_min": 0.0,
}),
]
for name, ov in sweeps:
p = dict(base)
p.update(ov)
try:
t = te.run_tail_backtest(candles, p, universe_by_slot=history)
print(f" {name:35s}{_summary(t)}")
except Exception as e:
print(f" {name:35s} → 예외: {e}")
def diag_scalp(db):
print("\n" + "=" * 70)
print("SCALP 4/30 진단")
print("=" * 70)
history = _load_universe(db, "SCALP")
codes = {c for lst in history.values() for c in lst}
print(f"universe: {len(codes)}종목, {len(history)}슬롯")
# SCALP universe slot 의 분 단위 분포 (5분 정렬 매칭률 보기)
from collections import Counter
mins = Counter(int(k[10:12]) for k in history.keys() if len(k) >= 12)
aligned5 = sum(c for m, c in mins.items() if m % 5 == 0)
total = sum(mins.values())
print(f" slot 5분 정렬 매칭률 (backtest_web 의 scan_interval_min=5 와 매칭): "
f"{aligned5}/{total} = {aligned5/total*100:.0f}%" if total else "")
candles = _load_candles(db, codes, tf=1, min_n=10)
print(f"1분봉 적재: {len(candles)}종목")
base = se.get_scalping_defaults_from_db()
# backtest_web SCALP 와 동일하게 scan_interval_min=5 로 1차 시도
base["scan_interval_min"] = 5
print()
print("--- backtest_web 과 동일 (scan_interval_min=5) ---")
trades5 = se.run_scalping_backtest(candles, dict(base), universe_by_slot=history)
print(f"{_summary(trades5)}")
# 1분 슬롯과 1:1 매칭 (universe history 1분 키와 일치)
base["scan_interval_min"] = 1
print()
print("--- 보정: scan_interval_min=1 (universe slot 키와 일치) ---")
trades1 = se.run_scalping_backtest(candles, dict(base), universe_by_slot=history)
print(f"{_summary(trades1)}")
trades = trades1 if trades1 else trades5
if trades:
print()
print(f"--- trades 상세 (총 {len(trades)}건) ---")
for t in trades:
t["pnl"] = _trade_pnl(t)
trades.sort(key=lambda x: x["pnl"])
print(" ▼ 손실 큰 5건:")
for t in trades[:5]:
print(f" {t.get('code')} {t.get('buy_time')}{t.get('sell_time')} "
f"매수 {t.get('buy_price'):.0f} → 매도 {t.get('sell_price'):.0f} "
f"×{t.get('qty')} pnl={t['pnl']:,.0f} ({t.get('sell_reason')})")
print(" ▲ 수익 큰 5건:")
for t in trades[-5:][::-1]:
print(f" {t.get('code')} {t.get('buy_time')}{t.get('sell_time')} "
f"매수 {t.get('buy_price'):.0f} → 매도 {t.get('sell_price'):.0f} "
f"×{t.get('qty')} pnl={t['pnl']:,.0f} ({t.get('sell_reason')})")
cnt = Counter(t.get("sell_reason") for t in trades)
print(f" sell_reason 분포: {dict(cnt)}")
def main():
db = TradeDB()
try:
diag_short(db)
diag_scalp(db)
finally:
db.close()
if __name__ == "__main__":
main()