커밋 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 (일회성)
This commit is contained in:
Your Name
2026-08-18 00:13:17 +09:00
parent 36a3e2b4a1
commit 6d2a706a48
1416 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,198 @@
"""
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()