feat: 새로운 안전 규칙 및 최적화 적용을 통한 트레이딩 시스템 개선

변경 사항 (Changes):

구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함.

스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함.

코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함.

시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함.

기대 효과 (Impact):

이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
This commit is contained in:
Your Name
2026-07-17 01:09:09 +09:00
parent a4626e0351
commit fc27e726f9
151 changed files with 20718 additions and 6450 deletions

View File

@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""SK 10:36 BT vs 10:38 live — DB 타임라인 + 틱/봉 진단."""
from __future__ import annotations
import subprocess
from database import TradeDB
CODE = "475150"
DAY = "20260713"
DAY_DASH = "2026-07-13"
def main() -> None:
db = TradeDB()
try:
cols = [r["Field"] for r in db.conn.execute("SHOW COLUMNS FROM trade_history").fetchall()]
print("trade_history cols:", cols)
rows = db.conn.execute(
"SELECT code, name, strategy, buy_price, sell_price, qty, realized_pnl, "
"buy_date, sell_date, sell_reason, hold_minutes "
"FROM trade_history WHERE code=%s AND buy_date LIKE %s ORDER BY buy_date",
(CODE, DAY_DASH + "%"),
).fetchall()
print(f"\ntrade_history SK today n={len(rows)}")
for r in rows:
d = dict(r)
print(
f" buy={d.get('buy_date')} sell={d.get('sell_date')} "
f"bp={d.get('buy_price')} sp={d.get('sell_price')} qty={d.get('qty')} "
f"pnl={d.get('realized_pnl')} reason={d.get('sell_reason')} "
f"strat={d.get('strategy')}"
)
print("\n1m candles 10:34-10:40:")
cans = db.conn.execute(
"SELECT candle_time, open, high, low, close, volume FROM ws_candles "
"WHERE code=%s AND timeframe=%s AND candle_time BETWEEN %s AND %s "
"ORDER BY candle_time",
(CODE, 1, DAY + "1034", DAY + "1040"),
).fetchall()
for c in cans:
print(
f" {c['candle_time']} O={c['open']} H={c['high']} "
f"L={c['low']} C={c['close']} V={c['volume']}"
)
print("\nfirst/last tick per minute 10:35-10:39:")
for m in ("1035", "1036", "1037", "1038", "1039"):
tt0, tt1 = DAY + m + "00", DAY + m + "59"
r = db.conn.execute(
"SELECT COUNT(*) n, MIN(tick_time) mn, MAX(tick_time) mx, "
"MIN(price) lo, MAX(price) hi FROM ws_ticks "
"WHERE code=%s AND tick_time BETWEEN %s AND %s",
(CODE, tt0, tt1),
).fetchone()
first = db.conn.execute(
"SELECT tick_time, price FROM ws_ticks "
"WHERE code=%s AND tick_time BETWEEN %s AND %s "
"ORDER BY tick_time ASC LIMIT 1",
(CODE, tt0, tt1),
).fetchone()
print(
f" {m}: n={r['n']} {r['mn']}~{r['mx']} "
f"first={dict(first) if first else None} range={r['lo']}~{r['hi']}"
)
# nearest tick to live buy 10:38:06 at 51600
print("\nticks near live buy 51600 @10:38:")
near = db.conn.execute(
"SELECT tick_time, price, volume FROM ws_ticks "
"WHERE code=%s AND tick_time BETWEEN %s AND %s AND price BETWEEN %s AND %s "
"ORDER BY tick_time LIMIT 20",
(CODE, DAY + "103700", DAY + "103900", 51500, 51700),
).fetchall()
for t in near:
print(f" {dict(t)}")
# ticks near BT buy 50800 @10:36
print("\nticks near BT buy 50800 @10:36:")
near2 = db.conn.execute(
"SELECT tick_time, price, volume FROM ws_ticks "
"WHERE code=%s AND tick_time BETWEEN %s AND %s AND price BETWEEN %s AND %s "
"ORDER BY tick_time LIMIT 20",
(CODE, DAY + "103600", DAY + "103700", 50700, 50900),
).fetchall()
for t in near2:
print(f" {dict(t)}")
hcols = [r["Field"] for r in db.conn.execute(
"SHOW COLUMNS FROM target_candidates_history"
).fetchall()]
print("\nhistory cols:", hcols)
if "slot_key" in hcols:
hs2 = db.conn.execute(
"SELECT slot_key, COUNT(*) n FROM target_candidates_history "
"WHERE code=%s AND slot_key LIKE %s GROUP BY slot_key ORDER BY slot_key",
(CODE, DAY + "103%"),
).fetchall()
print("SK slots 103x:")
for h in hs2:
print(f" {h['slot_key']} n={h['n']}")
finally:
db.close()
print("\n=== journal 10:34-10:41 ===")
p = subprocess.run(
[
"journalctl", "-u", "kis_trader_main.service",
"--since", "2026-07-13 10:34:00",
"--until", "2026-07-13 10:41:00",
"--no-pager",
],
capture_output=True, text=True,
)
keys = ("475150", "이터닉스", "MOMENTUM")
for line in p.stdout.splitlines():
if any(k in line for k in keys):
print(line[:240])
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""오늘 모멘텀 거래종목 vs 유니버스 틱 커버 진단 (adhoc)."""
from __future__ import annotations
from database import TradeDB
DAY = "20260713"
CODES = ["475150", "039340", "241710"]
TRADE_MINS = [
"202607130930",
"202607130931",
"202607130943",
"202607130944",
"202607130952",
"202607130953",
"202607130959",
"202607131014",
"202607131019",
"202607131020",
"202607131026",
"202607131027",
"202607131032",
"202607131033",
"202607131036",
"202607131038",
"202607131101",
"202607131102",
"202607131116",
"202607131118",
]
def main() -> None:
db = TradeDB()
try:
cols = [r["Field"] for r in db.conn.execute("SHOW COLUMNS FROM ws_ticks").fetchall()]
print("ws_ticks sample cols:", cols[:12])
for code in CODES:
rows = db.conn.execute(
"SELECT COUNT(*) n, MIN(tick_time) mn, MAX(tick_time) mx "
"FROM ws_ticks WHERE code=%s AND tick_time LIKE %s",
(code, DAY + "%"),
).fetchone()
print(f"ticks {code}: n={rows['n']} range={rows['mn']}~{rows['mx']}")
sk = db.conn.execute(
"SELECT LEFT(tick_time,12) m, COUNT(*) n FROM ws_ticks "
"WHERE code=%s AND tick_time >= %s AND tick_time <= %s "
"GROUP BY LEFT(tick_time,12) ORDER BY m",
("475150", DAY + "090000", DAY + "113059"),
).fetchall()
have = {r["m"]: int(r["n"]) for r in sk}
print(f"SK minutes with ticks 09:00-11:30: {len(have)}")
print("trade-related minutes tick count:")
for m in TRADE_MINS:
print(f" {m[8:]} n={have.get(m, 0)}")
nc = db.conn.execute(
"SELECT COUNT(DISTINCT code) c FROM ws_ticks WHERE tick_time LIKE %s",
(DAY + "%",),
).fetchone()
print("distinct codes with ticks today:", nc["c"])
try:
cc = db.conn.execute(
"SELECT COUNT(DISTINCT code) c FROM ws_candles "
"WHERE candle_time LIKE %s AND timeframe=%s",
(DAY + "%", 1),
).fetchone()
print("distinct codes with 1m candles today:", cc["c"])
except Exception as e:
print("candles query skip:", e)
# 30% coverage 의미: 유니버스 전종목 전분봉 중 틱 있는 분 비율
# 거래 3종만 보면?
for code in CODES:
bars = db.conn.execute(
"SELECT COUNT(DISTINCT LEFT(candle_time,12)) n FROM ws_candles "
"WHERE code=%s AND candle_time LIKE %s AND timeframe=%s "
"AND LEFT(candle_time,12) BETWEEN %s AND %s",
(code, DAY + "%", 1, DAY + "0900", DAY + "1530"),
).fetchone()
tmin = db.conn.execute(
"SELECT COUNT(DISTINCT LEFT(tick_time,12)) n FROM ws_ticks "
"WHERE code=%s AND tick_time LIKE %s "
"AND LEFT(tick_time,12) BETWEEN %s AND %s",
(code, DAY + "%", DAY + "0900", DAY + "1530"),
).fetchone()
bn = int(bars["n"] or 0)
tn = int(tmin["n"] or 0)
pct = (100.0 * tn / bn) if bn else 0.0
print(f"cover {code}: tick_mins={tn} candle_mins={bn} pct={pct:.1f}%")
finally:
db.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,26 @@
#!/bin/bash
set -euo pipefail
cd /home/hoon/kis_bot
mkdir -p logs
TS=$(date +%Y%m%d_%H%M%S)
STUDY="breakout_fine_20260715_${TS}"
LOG="logs/param_search_optuna_breakout_fine_${TS}.log"
echo "$LOG" > logs/param_search_optuna_breakout_fine_latest.logpath
echo "$STUDY" > logs/param_search_optuna_breakout_fine_latest.study
# 120만 한도 정합 (구 study는 env_config 단독→600만 버그). 새 study-name 필수.
nohup python3 -u kis_trader/backtest/param_search_optuna.py \
--strategy breakout \
--mode fine \
--start 2026-07-15 \
--end 2026-07-15 \
--trials 200 \
--min_trades 1 \
--orderbook-filter off \
--no-progress \
--apply-best \
--study-name "$STUDY" \
> "$LOG" 2>&1 &
echo "PID=$!"
echo "STUDY=$STUDY"
echo "LOG=$LOG"
echo "tail -f /home/hoon/kis_bot/$LOG"

View File

@@ -0,0 +1,28 @@
#!/bin/bash
set -euo pipefail
cd /home/hoon/kis_bot
mkdir -p logs
TS=$(date +%Y%m%d_%H%M%S)
STUDY="breakout_fine_wideTune_20260715_${TS}"
LOG="logs/param_search_optuna_breakout_fine_${TS}.log"
echo "$LOG" > logs/param_search_optuna_breakout_fine_latest.logpath
echo "$STUDY" > logs/param_search_optuna_breakout_fine_latest.study
# wide(7/15) Top 분지 fine — apply 없음. 확인 후 최빈/1위 적용.
nohup python3 -u kis_trader/backtest/param_search_optuna.py \
--strategy breakout \
--mode fine \
--start 2026-07-15 \
--end 2026-07-15 \
--trials 200 \
--min_trades 1 \
--min_win_rate 0 \
--min_pf 0 \
--sort-by pnl \
--orderbook-filter off \
--no-progress \
--study-name "$STUDY" \
> "$LOG" 2>&1 &
echo "PID=$!"
echo "STUDY=$STUDY"
echo "LOG=/home/hoon/kis_bot/$LOG"
echo "tail -f /home/hoon/kis_bot/$LOG"

View File

@@ -0,0 +1,28 @@
#!/bin/bash
set -euo pipefail
cd /home/hoon/kis_bot
mkdir -p logs
TS=$(date +%Y%m%d_%H%M%S)
STUDY="breakout_wide_20260715_${TS}"
LOG="logs/param_search_optuna_breakout_wide_${TS}.log"
echo "$LOG" > logs/param_search_optuna_breakout_wide_latest.logpath
echo "$STUDY" > logs/param_search_optuna_breakout_wide_latest.study
# wide 축 스크리닝 — apply 없음. 아침 fine/최빈은 wide Top 확인 후.
nohup python3 -u kis_trader/backtest/param_search_optuna.py \
--strategy breakout \
--mode wide \
--start 2026-07-15 \
--end 2026-07-15 \
--trials 100 \
--min_trades 1 \
--min_win_rate 0 \
--min_pf 0 \
--sort-by pnl \
--orderbook-filter off \
--no-progress \
--study-name "$STUDY" \
> "$LOG" 2>&1 &
echo "PID=$!"
echo "STUDY=$STUDY"
echo "LOG=/home/hoon/kis_bot/$LOG"
echo "tail -f /home/hoon/kis_bot/$LOG"

13
scripts/_run_error_watch_mm.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# 오류감시 기동 (systemd 없이 nohup). sudo 있으면 deploy 유닛 사용 권장.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
mkdir -p logs
pkill -f 'scripts/kis_error_watch_mm.py' 2>/dev/null || true
sleep 1
nohup "$ROOT/.venv/bin/python" -u "$ROOT/scripts/kis_error_watch_mm.py" \
>> "$ROOT/logs/kis_error_watch_mm.log" 2>&1 &
echo "PID=$!"
echo "LOG=$ROOT/logs/kis_error_watch_mm.log"
echo "tail -f $ROOT/logs/kis_error_watch_mm.log"

View File

@@ -0,0 +1,36 @@
#!/bin/bash
set -euo pipefail
cd /home/hoon/kis_bot
mkdir -p logs
TS=$(date +%Y%m%d_%H%M%S)
STUDY="momentum_fine_20260715_${TS}"
LOG="logs/param_search_optuna_momentum_fine_${TS}.log"
echo "$LOG" > logs/param_search_optuna_momentum_fine_latest.logpath
echo "$STUDY" > logs/param_search_optuna_momentum_fine_latest.study
# E(전일시가) ON 유지. DB 전일봉 없으면 prepare 시 키움 REST 1회/종목 → 메모리만.
# 이전 E-OFF 탐색은 폐기 — 새 study-name.
unset MOMENTUM_TRIGGER_E_CONFIRM || true
export MOMENTUM_BACKTEST_REST_WARMUP=1
export MOMENTUM_BACKTEST_REST_WARMUP_BARS="${MOMENTUM_BACKTEST_REST_WARMUP_BARS:-700}"
export MOMENTUM_BACKTEST_REST_SLEEP_SEC="${MOMENTUM_BACKTEST_REST_SLEEP_SEC:-0.25}"
nohup python3 -u kis_trader/backtest/param_search_optuna.py \
--strategy momentum \
--mode fine \
--start 2026-07-15 \
--end 2026-07-15 \
--trials 200 \
--min_trades 1 \
--min_win_rate 0 \
--min_pf 0 \
--sort-by pnl \
--orderbook-filter off \
--no-progress \
--apply-best \
--study-name "$STUDY" \
> "$LOG" 2>&1 &
echo "PID=$!"
echo "STUDY=$STUDY"
echo "LOG=$LOG"
echo "tail -f /home/hoon/kis_bot/$LOG"

View File

@@ -0,0 +1,32 @@
#!/bin/bash
set -euo pipefail
cd /home/hoon/kis_bot
mkdir -p logs
TS=$(date +%Y%m%d_%H%M%S)
STUDY="momentum_fine_lateRatchet_20260715_${TS}"
LOG="logs/param_search_optuna_momentum_fine_${TS}.log"
echo "$LOG" > logs/param_search_optuna_momentum_fine_latest.logpath
echo "$STUDY" > logs/param_search_optuna_momentum_fine_latest.study
unset MOMENTUM_TRIGGER_E_CONFIRM || true
export MOMENTUM_BACKTEST_REST_WARMUP=1
export MOMENTUM_BACKTEST_REST_WARMUP_BARS="${MOMENTUM_BACKTEST_REST_WARMUP_BARS:-700}"
export MOMENTUM_BACKTEST_REST_SLEEP_SEC="${MOMENTUM_BACKTEST_REST_SLEEP_SEC:-0.25}"
# fine + 늦게잠금 래칫 격자 + JSON 경로 고지 테스트 — apply 없음
nohup python3 -u kis_trader/backtest/param_search_optuna.py \
--strategy momentum \
--mode fine \
--start 2026-07-15 \
--end 2026-07-15 \
--trials 200 \
--min_trades 1 \
--min_win_rate 0 \
--min_pf 0 \
--sort-by pnl \
--orderbook-filter off \
--no-progress \
--study-name "$STUDY" \
> "$LOG" 2>&1 &
echo "PID=$!"
echo "STUDY=$STUDY"
echo "LOG=/home/hoon/kis_bot/$LOG"
echo "tail -f /home/hoon/kis_bot/$LOG"

View File

@@ -0,0 +1,34 @@
#!/bin/bash
set -euo pipefail
cd /home/hoon/kis_bot
mkdir -p logs
TS=$(date +%Y%m%d_%H%M%S)
STUDY="momentum_wide_20260715_${TS}"
LOG="logs/param_search_optuna_momentum_wide_${TS}.log"
echo "$LOG" > logs/param_search_optuna_momentum_wide_latest.logpath
echo "$STUDY" > logs/param_search_optuna_momentum_wide_latest.study
unset MOMENTUM_TRIGGER_E_CONFIRM || true
export MOMENTUM_BACKTEST_REST_WARMUP=1
export MOMENTUM_BACKTEST_REST_WARMUP_BARS="${MOMENTUM_BACKTEST_REST_WARMUP_BARS:-700}"
export MOMENTUM_BACKTEST_REST_SLEEP_SEC="${MOMENTUM_BACKTEST_REST_SLEEP_SEC:-0.25}"
# wide 축 스크리닝 — apply 없음. fine 재설계·적용은 wide Top 확인 후.
nohup python3 -u kis_trader/backtest/param_search_optuna.py \
--strategy momentum \
--mode wide \
--start 2026-07-15 \
--end 2026-07-15 \
--trials 100 \
--min_trades 1 \
--min_win_rate 0 \
--min_pf 0 \
--sort-by pnl \
--orderbook-filter off \
--no-progress \
--study-name "$STUDY" \
> "$LOG" 2>&1 &
echo "PID=$!"
echo "STUDY=$STUDY"
echo "LOG=$LOG"
echo "tail -f /home/hoon/kis_bot/$LOG"

View File

@@ -0,0 +1,31 @@
#!/bin/bash
set -euo pipefail
cd /home/hoon/kis_bot
mkdir -p logs
TS=$(date +%Y%m%d_%H%M%S)
STUDY="scalp_fine_20260715_${TS}"
LOG="logs/param_search_optuna_scalp_fine_${TS}.log"
echo "$LOG" > logs/param_search_optuna_scalp_fine_latest.logpath
echo "$STUDY" > logs/param_search_optuna_scalp_fine_latest.study
# 모멘텀과 동일: 거래일 2026-07-15 · fine 200 · 호가 OFF · apply-best
# 포트 한도 = config_scalp 병합 (SCALP_TOTAL_BUDGET_KRW=120만) — env_config 단독 금지(600만 버그)
nohup python3 -u kis_trader/backtest/param_search_optuna.py \
--strategy scalp \
--mode fine \
--start 2026-07-15 \
--end 2026-07-15 \
--trials 200 \
--min_trades 1 \
--min_win_rate 0 \
--min_pf 0 \
--sort-by score \
--orderbook-filter off \
--no-progress \
--apply-best \
--study-name "$STUDY" \
> "$LOG" 2>&1 &
echo "PID=$!"
echo "STUDY=$STUDY"
echo "LOG=$LOG"
echo "tail -f /home/hoon/kis_bot/$LOG"

View File

@@ -0,0 +1,25 @@
#!/bin/bash
set -euo pipefail
cd /home/hoon/kis_bot
mkdir -p logs
TS=$(date +%Y%m%d_%H%M%S)
STUDY="tail_fine_wide1Tune_20260715_${TS}"
LOG="logs/param_search_optuna_tail_fine_${TS}.log"
echo "$LOG" > logs/param_search_optuna_tail_fine_latest.logpath
echo "$STUDY" > logs/param_search_optuna_tail_fine_latest.study
# wide1 분지 fine — apply 없음. 격자 변경 → 새 study-name.
nohup python3 -u kis_trader/backtest/param_search_optuna.py \
--strategy tail \
--mode fine \
--start 2026-07-15 \
--end 2026-07-15 \
--trials 200 \
--min_trades 1 \
--orderbook-filter off \
--no-progress \
--study-name "$STUDY" \
> "$LOG" 2>&1 &
echo "PID=$!"
echo "STUDY=$STUDY"
echo "LOG=$LOG"
echo "tail -f /home/hoon/kis_bot/$LOG"

View File

@@ -0,0 +1,25 @@
#!/bin/bash
set -euo pipefail
cd /home/hoon/kis_bot
mkdir -p logs
TS=$(date +%Y%m%d_%H%M%S)
# wide 격자 v2(확장) — categorical 변경이라 구 study 재사용 금지
STUDY="tail_wide2_20260715_${TS}"
LOG="logs/param_search_optuna_tail_wide2_${TS}.log"
echo "$LOG" > logs/param_search_optuna_tail_wide2_latest.logpath
echo "$STUDY" > logs/param_search_optuna_tail_wide2_latest.study
nohup python3 -u kis_trader/backtest/param_search_optuna.py \
--strategy tail \
--mode wide \
--start 2026-07-15 \
--end 2026-07-15 \
--trials 100 \
--min_trades 1 \
--orderbook-filter off \
--no-progress \
--study-name "$STUDY" \
> "$LOG" 2>&1 &
echo "PID=$!"
echo "STUDY=$STUDY"
echo "LOG=$LOG"
echo "tail -f /home/hoon/kis_bot/$LOG"

View File

@@ -0,0 +1,25 @@
#!/bin/bash
set -euo pipefail
cd /home/hoon/kis_bot
mkdir -p logs
TS=$(date +%Y%m%d_%H%M%S)
STUDY="tail_wide_20260715_${TS}"
LOG="logs/param_search_optuna_tail_wide_${TS}.log"
echo "$LOG" > logs/param_search_optuna_tail_wide_latest.logpath
echo "$STUDY" > logs/param_search_optuna_tail_wide_latest.study
# 7/15 축 스크리닝 — apply 없음. 새 study-name 필수(wide 그리드 신규).
nohup python3 -u kis_trader/backtest/param_search_optuna.py \
--strategy tail \
--mode wide \
--start 2026-07-15 \
--end 2026-07-15 \
--trials 100 \
--min_trades 1 \
--orderbook-filter off \
--no-progress \
--study-name "$STUDY" \
> "$LOG" 2>&1 &
echo "PID=$!"
echo "STUDY=$STUDY"
echo "LOG=$LOG"
echo "tail -f /home/hoon/kis_bot/$LOG"

View File

@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""Optuna 결과 JSON vs 현재 DB — 백테 비교표를 로그 파일 끝에 append (DB 미저장)."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from database import TradeDB
from kis_trader.backtest import tail_backtest_common as tbc
from kis_trader.engine import tail_engine as te
COMPARE_KEYS = [
"entry_mode",
"min_drop_rate",
"min_recovery_ratio",
"tail_ratio_min",
"tail_pct_min",
"max_rec_3m",
"shoulder_min_high",
"shoulder_cut_pct",
"stop_atr_mult",
"target_atr_mult",
"atr_sl_min_pct",
"atr_sl_max_pct",
"atr_tp_min_pct",
"atr_tp_max_pct",
"max_daily_change",
"max_loss_krw",
"limit_atr_mult",
"tail_vol_mult",
"tail_vol_win",
"ratchet_tiers",
"rsi_threshold",
"cooldown_min",
"bar_chg_min_pct",
"bar_chg_max_pct",
"symbol_daily_loss_limit_krw",
"symbol_daily_loss_limit_pct",
"reentry_min_edge_krw",
"reentry_require_nonneg",
"trail_tiers",
"trail_drop_pct",
"trail_arm_krw",
"pattern_pin",
"pattern_engulfing",
"pattern_piercing",
"max_daily",
"max_spread_pct",
"min_bid_ask_ratio",
]
def _same(a, b) -> bool:
if a == b:
return True
try:
return abs(float(a) - float(b)) < 1e-9
except Exception:
return str(a) == str(b)
def _run(candles_by_code, universe, base, port, fee, tax, slot, budget, meta, overrides):
params = dict(base)
tbc.merge_tail_portfolio_into_params(params, port)
params.update(overrides or {})
trades = tbc.run_tail_backtest_web_aligned(
candles_by_code,
params,
universe,
slot_money=slot,
fee_rate=fee,
sell_tax=tax,
total_budget_krw=budget,
meta_out=dict(meta),
)
wins = [t for t in trades if float(t.get("pnl") or 0) > 0]
pnl = sum(float(t.get("pnl") or 0) for t in trades)
wr = (len(wins) / len(trades) * 100.0) if trades else 0.0
return {
"trades": len(trades),
"wins": len(wins),
"wr": wr,
"pnl": pnl,
"params": {k: params.get(k) for k in COMPARE_KEYS},
}
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--json", required=True, help="optuna_tail_*.json 경로")
ap.add_argument("--log", required=True, help="append 대상 로그 경로")
ap.add_argument("--date", default="2026-07-10", help="백테 일자 YYYY-MM-DD")
args = ap.parse_args()
json_path = Path(args.json)
log_path = Path(args.log)
data = json.loads(json_path.read_text(encoding="utf-8"))
best = (data.get("results") or [None])[0]
if not best:
msg = "❌ Optuna results 비어 있음 — 비교 스킵\n"
with log_path.open("a", encoding="utf-8") as f:
f.write(msg)
print(msg, end="")
return 1
best_params = best.get("params") or {}
day = args.date
start_key = day.replace("-", "") + "0000"
end_key = day.replace("-", "") + "2359"
start_ymd, end_ymd = start_key[:8], end_key[:8]
db = TradeDB()
base = te.get_tail_defaults_from_db(db)
universe, src, n_slots, _ = tbc.resolve_tail_universe(
start_ymd, end_ymd, use_saved_history=True, strategy_id="SHORT",
)
tf = int(base.get("timeframe") or 3)
rsi = int(base.get("rsi_period") or 14)
candles_by_code, _, _ = tbc.load_tail_candles_by_code(
db, start_key, end_key, tf, rsi_period=rsi,
)
row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone()
r = dict(row) if row else {}
fee, tax, _ = tbc.fee_and_slot_from_env_row(r)
port = tbc.resolve_tail_portfolio_params(r, base)
slot = float(port["slot_money"])
budget = float(port["total_budget_krw"])
meta = {"db": db, "start_key": start_key, "end_key": end_key}
cur = _run(candles_by_code, universe, base, port, fee, tax, slot, budget, meta, {})
bst = _run(
candles_by_code, universe, base, port, fee, tax, slot, budget, meta, best_params,
)
delta = bst["pnl"] - cur["pnl"]
trial_no = best.get("optuna_trial_number") or data.get("optuna_best_trial_number")
mode = data.get("mode") or "?"
elapsed = data.get("elapsed_sec")
lines = []
lines.append("")
lines.append("=" * 72)
lines.append(f"[COMPARE] CURRENT_DB vs OPTUNA_{mode.upper()}_BEST | {day} | DB미저장")
lines.append("=" * 72)
lines.append(f"json={json_path}")
lines.append(f"universe={src} slots={n_slots} | trial=#{trial_no} elapsed={elapsed}s")
lines.append("")
lines.append("| 구분 | 거래 | 승 | 승률 | 손익 |")
lines.append("|------|------|----|------|------|")
lines.append(
f"| CURRENT_DB | {cur['trades']} | {cur['wins']} | {cur['wr']:.1f}% | {cur['pnl']:+,.0f} |"
)
lines.append(
f"| OPTUNA_BEST | {bst['trades']} | {bst['wins']} | {bst['wr']:.1f}% | {bst['pnl']:+,.0f} |"
)
lines.append(f"| Δ(best-current) | | | | {delta:+,.0f} |")
lines.append("")
lines.append("| 파라미터 | CURRENT_DB | OPTUNA_BEST | diff |")
lines.append("|----------|------------|--------------|------|")
for k in COMPARE_KEYS:
a = cur["params"].get(k)
b = bst["params"].get(k)
mark = "" if _same(a, b) else "<<"
lines.append(f"| {k} | {a} | {b} | {mark} |")
lines.append("")
lines.append("DB 저장 없음 (--apply-best 미사용)")
lines.append("=" * 72)
lines.append("")
text = "\n".join(lines)
with log_path.open("a", encoding="utf-8") as f:
f.write(text)
print(text, end="")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""
청산·보유 종목의 매수~매도(또는 ~now) 구간 1분봉을 키움 REST로 즉시 백필.
예:
nohup python3 -u scripts/backfill_trade_candles.py \\
--like '2026-07-16%' > /tmp/backfill_trade_candles_0716.log 2>&1 &
tail -f /tmp/backfill_trade_candles_0716.log
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from database import TradeDB
from kis_trader.engine.post_sell_candle_backfill import backfill_trades_from_db
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--like", default="2026-07-16%", help="buy_date LIKE (%% 바인딩)")
ap.add_argument(
"--strategies",
default="",
help="콤마 구분 strategy (비우면 전체)",
)
ap.add_argument("--no-active", action="store_true", help="active_trades 제외")
ap.add_argument("--active-days", type=int, default=5)
args = ap.parse_args()
strategies = [s.strip() for s in str(args.strategies).split(",") if s.strip()] or None
db = TradeDB()
try:
results = backfill_trades_from_db(
db,
buy_date_like=str(args.like),
strategies=strategies,
include_active=not args.no_active,
active_max_age_days=int(args.active_days),
)
finally:
try:
db.close()
except Exception:
pass
ok = sum(1 for r in results if r.get("ok"))
improved = sum(1 for r in results if int(r.get("after") or 0) > int(r.get("before") or 0))
print(
f"DONE jobs={len(results)} ok={ok} improved={improved} "
f"upsert_sum={sum(int(r.get('upserted') or 0) for r in results)}"
)
for r in results:
if not r.get("ok") or int(r.get("after") or 0) > int(r.get("before") or 0):
print(
f" {r.get('strategy')} {r.get('code')} "
f"{r.get('start')}~{r.get('end')} "
f"{r.get('before')}{r.get('after')} "
f"upsert={r.get('upserted')} err={r.get('error')!r}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

387
scripts/kis_error_watch_mm.py Executable file
View File

@@ -0,0 +1,387 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
kis_error_watch_mm.py — kis_trader_main journalctl 실시간(tail -f) 감시 → Mattermost
실매 봇과 분리된 프로세스. journald 만 보고 오류 시 MM 알림.
- Traceback / FATAL / dead=[...] / 유닛 다운 등
- 동일·유사 알림은 쿨다운으로 스팸 방지
- 상태 JSON 즉시 저장(재시작 후에도 쿨다운 유지)
실행:
nohup .venv/bin/python -u scripts/kis_error_watch_mm.py \\
>> logs/kis_error_watch_mm.log 2>&1 &
tail -f logs/kis_error_watch_mm.log
테스트:
.venv/bin/python scripts/kis_error_watch_mm.py --test-mm
systemd (선택):
sudo cp deploy/kis_error_watch_mm.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now kis_error_watch_mm
"""
from __future__ import annotations
import argparse
import hashlib
import logging
import os
import re
import signal
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import List, Optional, Pattern, Tuple
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from kis_trader.utils.env import ( # noqa: E402
get_env_bool,
get_env_float,
get_env_from_db,
get_env_int,
)
from kis_trader.utils.logger import atomic_load_json, atomic_save_json, msg_mm # noqa: E402
LOG_PATH = ROOT / "logs" / "kis_error_watch_mm.log"
STATE_PATH = ROOT / "logs" / "kis_error_watch_mm_state.json"
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="[%(asctime)s] %(message)s",
datefmt="%H:%M:%S",
handlers=[logging.StreamHandler(sys.stdout)],
)
log = logging.getLogger("error_watch")
_STOP = False
def _on_signal(signum, _frame) -> None:
global _STOP
_STOP = True
log.info("⏹ signal=%s → 종료 예약", signum)
def _cfg() -> dict:
"""DB/env 설정 — 하드코딩 수치 금지, get_env_* 만."""
# Traceback·FATAL·비어있지 않은 dead=·유닛 크래시 시그니처
default_match = (
r"(?i)("
r"Traceback \(most recent call last\)|"
r"\bCRITICAL\b|\bFATAL\b|MemoryError|SIGBUS|Segmentation fault|"
r"dead=\[[^\]]|" # dead=[] 제외, dead=['Strat-... 매칭
r"Main process exited|Failed with result|"
r"can't open file|"
r"강제\s*종료|Out of memory"
r")"
)
default_ignore = (
r"(?i)("
r"numexpr\.utils|"
r"\[MM 스킵\]|"
r"MM 발송 실패|"
r"heartbeat ws="
r")"
)
return {
"enabled": get_env_bool("ERROR_WATCH_ENABLED", True),
"unit": str(
get_env_from_db("ERROR_WATCH_UNIT", "kis_trader_main.service")
or "kis_trader_main.service"
).strip(),
"channel": str(
get_env_from_db("ERROR_WATCH_MM_CHANNEL", "")
or get_env_from_db("KIS_SYSTEM_MM_CHANNEL", "default")
or "default"
).strip()
or "default",
"cooldown_sec": max(30, get_env_int("ERROR_WATCH_COOLDOWN_SEC", 180)),
"context_lines": max(1, min(20, get_env_int("ERROR_WATCH_CONTEXT_LINES", 5))),
"traceback_extra": max(0, min(40, get_env_int("ERROR_WATCH_TRACEBACK_EXTRA_LINES", 12))),
"health_sec": max(15, get_env_int("ERROR_WATCH_HEALTH_CHECK_SEC", 60)),
"match_re": str(
get_env_from_db("ERROR_WATCH_MATCH_REGEX", default_match) or default_match
),
"ignore_re": str(
get_env_from_db("ERROR_WATCH_IGNORE_REGEX", default_ignore) or default_ignore
),
"jitter": get_env_bool("ERROR_WATCH_MM_JITTER", False),
}
def _compile_re(pat: str, name: str) -> Optional[Pattern[str]]:
try:
return re.compile(pat)
except re.error as e:
log.error("❌ regex 컴파일 실패 (%s): %s", name, e)
return None
def _load_state() -> dict:
st = atomic_load_json(STATE_PATH, default={})
if not isinstance(st, dict):
return {}
return st
def _save_state(st: dict) -> None:
atomic_save_json(STATE_PATH, st)
def _fp(text: str) -> str:
# 시각·PID 제거 후 지문 → 같은 오류 반복 쿨다운
norm = re.sub(r"\d{2}:\d{2}:\d{2}", "", text)
norm = re.sub(r"python\[\d+\]", "python[PID]", norm)
norm = re.sub(r"\s+", " ", norm).strip()[:800]
return hashlib.sha1(norm.encode("utf-8", errors="ignore")).hexdigest()[:16]
def _can_alert(st: dict, fingerprint: str, cooldown_sec: int) -> bool:
now = time.time()
last_ts = float(st.get("last_alert_ts") or 0)
last_fp = str(st.get("last_fingerprint") or "")
if fingerprint == last_fp and (now - last_ts) < cooldown_sec:
return False
if (now - last_ts) < float(get_env_float("ERROR_WATCH_GLOBAL_MIN_GAP_SEC", 20.0)):
# 서로 다른 오류라도 최소 간격
if fingerprint != last_fp and (now - last_ts) < cooldown_sec * 0.15:
return False
return True
def _send_alert(title: str, lines: List[str], channel: str, jitter: bool, st: dict, fingerprint: str) -> bool:
body_lines = [
f"🚨 **[오류감시] {title}**",
f"- 시각: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
f"- 유닛: `{get_env_from_db('ERROR_WATCH_UNIT', 'kis_trader_main.service')}`",
"```",
]
clipped = "\n".join(lines)[:3500]
body_lines.append(clipped)
body_lines.append("```")
body = "\n".join(body_lines)
ok = msg_mm(body, channel_alias=channel, jitter=jitter)
st["last_alert_ts"] = time.time()
st["last_fingerprint"] = fingerprint
st["last_title"] = title
st["alert_count"] = int(st.get("alert_count") or 0) + 1
_save_state(st)
log.info("📤 MM %s title=%s fp=%s", "OK" if ok else "FAIL", title, fingerprint)
return ok
def _unit_active(unit: str) -> Tuple[bool, str]:
try:
r = subprocess.run(
["systemctl", "is-active", unit],
capture_output=True,
text=True,
timeout=5,
)
state = (r.stdout or "").strip() or (r.stderr or "").strip() or "unknown"
return state == "active", state
except Exception as e:
return False, f"check_error:{e}"
def _follow_journal(unit: str) -> subprocess.Popen:
# -n 0: 과거 덤프 없이 follow만 (기동 직후 과거 Traceback 폭주 방지)
cmd = [
"journalctl",
"-u", unit,
"-f",
"-n", "0",
"--output=short-iso",
"--no-pager",
]
log.info("📡 follow: %s", " ".join(cmd))
return subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
errors="replace",
)
def run_watch() -> int:
cfg = _cfg()
if not cfg["enabled"]:
log.warning("ERROR_WATCH_ENABLED=false → 종료")
return 0
match_re = _compile_re(cfg["match_re"], "MATCH")
ignore_re = _compile_re(cfg["ignore_re"], "IGNORE")
if match_re is None:
return 2
unit = cfg["unit"]
channel = cfg["channel"]
st = _load_state()
log.info(
"✅ 감시 시작 unit=%s ch=%s cooldown=%ss health=%ss",
unit, channel, cfg["cooldown_sec"], cfg["health_sec"],
)
# 기동 알림 (감시자 살아있음 확인)
if get_env_bool("ERROR_WATCH_STARTUP_NOTIFY", True):
active, state = _unit_active(unit)
msg_mm(
f"👁️ **[오류감시 기동]** `{unit}` → `{state}`"
f"{'' if active else ' ⚠️ 비활성'}",
channel_alias=channel,
jitter=False,
)
proc = _follow_journal(unit)
buf: List[str] = []
collecting_tb = False
tb_left = 0
last_health = time.time()
was_active = True
assert proc.stdout is not None
while not _STOP:
# health poll
now = time.time()
if now - last_health >= cfg["health_sec"]:
last_health = now
active, state = _unit_active(unit)
if not active:
fp = _fp(f"unit_down:{unit}:{state}")
if _can_alert(st, fp, cfg["cooldown_sec"]):
_send_alert(
f"유닛 비활성 ({state})",
[f"systemctl is-active {unit}{state}"],
channel,
cfg["jitter"],
st,
fp,
)
was_active = False
elif not was_active:
# 복구 알림
fp = _fp(f"unit_up:{unit}")
if _can_alert(st, fp, max(30, cfg["cooldown_sec"] // 3)):
_send_alert(
"유닛 복구 (active)",
[f"systemctl is-active {unit} → active"],
channel,
cfg["jitter"],
st,
fp,
)
was_active = True
# journalctl 죽었으면 재기동
if proc.poll() is not None:
log.warning("⚠️ journalctl 종료 code=%s → 재기동", proc.returncode)
proc = _follow_journal(unit)
assert proc.stdout is not None
# non-blocking-ish read with timeout via select
import select
ready, _, _ = select.select([proc.stdout], [], [], 1.0)
if not ready:
continue
line = proc.stdout.readline()
if line == "":
# EOF — 재기동
time.sleep(1.0)
if proc.poll() is not None:
proc = _follow_journal(unit)
assert proc.stdout is not None
continue
line = line.rstrip("\n")
if not line:
continue
# ignore
if ignore_re is not None and ignore_re.search(line):
continue
# Traceback 블록 수집
if "Traceback (most recent call last)" in line:
collecting_tb = True
tb_left = cfg["traceback_extra"]
buf = [line]
continue
if collecting_tb:
buf.append(line)
tb_left -= 1
# 들여쓴 프레임이 끝나고 일반 로그가 오면 종료
if tb_left <= 0 or (
len(buf) > 2
and not line.startswith(" ")
and not line.startswith("\t")
and "File \"" not in line
and not line.lstrip().startswith("File ")
and "Error" not in line
and "Exception" not in line
):
collecting_tb = False
block = buf[:]
buf = []
fp = _fp("\n".join(block))
if _can_alert(st, fp, cfg["cooldown_sec"]):
_send_alert("Traceback", block, channel, cfg["jitter"], st, fp)
continue
if match_re.search(line):
# 직전 컨텍스트는 journal에 없으므로 히트 라인 + 이후 N줄은 어려움 → 히트만
ctx = [line]
fp = _fp(line)
if _can_alert(st, fp, cfg["cooldown_sec"]):
title = "로그 오류 매칭"
if "dead=[" in line:
title = "전략 dead 감지"
elif "exited" in line.lower() or "Failed with result" in line:
title = "프로세스 종료"
_send_alert(title, ctx, channel, cfg["jitter"], st, fp)
try:
proc.terminate()
except Exception:
pass
log.info("👋 오류감시 종료")
return 0
def run_test_mm() -> int:
ch = str(
get_env_from_db("ERROR_WATCH_MM_CHANNEL", "")
or get_env_from_db("KIS_SYSTEM_MM_CHANNEL", "default")
or "default"
).strip() or "default"
ok = msg_mm(
"🧪 **[오류감시 테스트]** kis_error_watch_mm.py --test-mm OK",
channel_alias=ch,
jitter=False,
)
print(f"test_mm channel={ch} ok={ok}")
return 0 if ok else 1
def main() -> int:
signal.signal(signal.SIGINT, _on_signal)
signal.signal(signal.SIGTERM, _on_signal)
ap = argparse.ArgumentParser(description="kis_trader journal 오류 → Mattermost")
ap.add_argument("--test-mm", action="` `", help="테스트 메시지 1회 발송 후 종료")
args = ap.parse_args()
if args.test_mm:
return run_test_mm()
return run_watch()
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,362 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
내일(다음 거래일) 장 시작(09:05)을 가정한 격리 스모크.
- 실매매 서비스(WS/주문)는 건드리지 않음
- FORCE_MARKET_OPEN 을 DB/실매에 쓰지 않음 (프로세스 내 datetime 패치만)
- 계좌 조회(REST) + 엔진/DB 기본값 + 장시작 리포트 문자열 + 직전 거래일 백테 스모크
로그: logs/market_open_sim_smoke.log
"""
from __future__ import annotations
import logging
import os
import sys
import traceback
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any, List, Tuple
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
LOG_PATH = ROOT / "logs" / "market_open_sim_smoke.log"
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="[%(asctime)s] %(message)s",
datefmt="%H:%M:%S",
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler(LOG_PATH, encoding="utf-8"),
],
)
log = logging.getLogger("market_open_sim")
errors: List[str] = []
oks: List[str] = []
def _ok(msg: str) -> None:
oks.append(msg)
log.info("%s", msg)
def _err(msg: str) -> None:
errors.append(msg)
log.error("%s", msg)
def _next_trading_day(from_d: date) -> date:
from kis_trader.utils.kr_trading_day import is_kr_trading_day
d = from_d + timedelta(days=1)
for _ in range(21):
if is_kr_trading_day(d):
return d
d += timedelta(days=1)
raise RuntimeError("next trading day not found")
def step_calendar() -> datetime:
from kis_trader.utils.kr_trading_day import (
clamp_to_prev_kr_trading_day,
is_kr_trading_day,
trading_dates_payload,
)
today = date.today()
tom = _next_trading_day(today)
assert is_kr_trading_day(tom), tom
prev = clamp_to_prev_kr_trading_day(today)
payload = trading_dates_payload(7)
_ok(
f"달력: today={today} next_open={tom} prev_td={prev} "
f"web_defaults={payload.get('start')}~{payload.get('end')}"
)
# 장시작 09:05 가정
return datetime(tom.year, tom.month, tom.day, 9, 5, 0)
def step_strategy_flags() -> None:
from kis_trader.utils.env import get_env_bool
flags = {
"SCALP": get_env_bool("STRATEGY_SCALP_ENABLED", True),
"SHORT": get_env_bool("STRATEGY_SHORT_ENABLED", True),
"MOMENTUM": get_env_bool("STRATEGY_MOMENTUM_ENABLED", False),
"BREAKOUT": get_env_bool("STRATEGY_BREAKOUT_ENABLED", False),
"RANGE_BREAK": get_env_bool("STRATEGY_RANGE_BREAK_ENABLED", False),
"UPDOW": get_env_bool("STRATEGY_UPDOW_ENABLED", False),
"DBBAND": get_env_bool("STRATEGY_DBBAND_ENABLED", False),
}
on = [k for k, v in flags.items() if v]
off = [k for k, v in flags.items() if not v]
_ok(f"전략 ON={on} OFF={off}")
# HTS 스킵은 false 유지 규칙
for key in (
"TAIL_SKIP_HTS_SCAN_DUPES",
"SHORT_SKIP_HTS_SCAN_DUPES",
"MOMENTUM_SKIP_HTS_SCAN_DUPES",
"BREAKOUT_SKIP_HTS_SCAN_DUPES",
"SCALP_SKIP_HTS_SCAN_DUPES",
):
if get_env_bool(key, False):
_err(f"{key}=true (기본 false 유지 규칙 위반)")
else:
_ok(f"{key}=false")
def step_market_hours(fake_now: datetime) -> None:
from kis_trader.strategies.base import BaseStrategy
from kis_trader.network.market_guard import MarketGuard
class _Dummy(BaseStrategy):
strategy_id = "SHORT"
def __init__(self):
# Thread/풀 초기화 우회: 최소 속성만
self.strategy_id = "SHORT"
def check_buy(self, *a, **k): # pragma: no cover
return False
def check_sell_signals(self, *a, **k): # pragma: no cover
return None
def run(self): # pragma: no cover
return None
with patch("kis_trader.strategies.base.dt") as mock_dt, patch(
"kis_trader.network.market_guard.dt"
) as mock_dt2:
mock_dt.now.return_value = fake_now
mock_dt2.now.return_value = fake_now
d = _Dummy()
# BaseStrategy.check_market_status 는 self 만 필요
open_ok = BaseStrategy.check_market_status(d)
buy_ok = BaseStrategy.check_buy_allowed(d)
mg_ok = MarketGuard._is_market_hours()
if open_ok and buy_ok and mg_ok:
_ok(f"장시간 판정 (fake {fake_now}): market=True buy=True guard=True")
else:
_err(
f"장시간 판정 실패: market={open_ok} buy={buy_ok} guard={mg_ok} "
f"fake={fake_now}"
)
def step_engine_defaults() -> None:
try:
from kis_trader.engine import momentum_engine as me
from kis_trader.engine import scalping_engine as se
from kis_trader.engine import tail_engine as te
from kis_trader.strategies import breakout as bo
te_d = te.get_tail_defaults_from_db() if hasattr(te, "get_tail_defaults_from_db") else None
me_d = me.get_momentum_defaults_from_db()
# scalping / breakout
if hasattr(se, "get_scalping_defaults_from_db"):
se_d = se.get_scalping_defaults_from_db()
else:
se_d = {"ok": True}
from kis_trader.backtest import breakout_backtest_common as bbc
from kis_trader.utils.env import get_merged_env_dict
env_row = get_merged_env_dict() or {}
if hasattr(bbc, "get_breakout_defaults_from_env_row"):
bo_d = bbc.get_breakout_defaults_from_env_row(env_row)
else:
bo_d = {}
_ok(
f"엔진 DB 기본값 로드: tail_keys={len(te_d or {})} "
f"mom={len(me_d or {})} scalp={len(se_d or {})} bo={len(bo_d or {})}"
)
# 손절 키 존재 스모크
for name, d in (("mom", me_d),):
if d and "sl_pct" in d and float(d["sl_pct"]) <= 0:
_err(f"{name} sl_pct 비정상: {d.get('sl_pct')}")
except Exception as e:
_err(f"엔진 기본값 로드 실패: {e}")
log.error(traceback.format_exc())
def step_account_and_open_report() -> None:
"""REST 잔고만 — WS/주문 없음. 장시작 리포트 문자열 생성."""
try:
from kis_trader.execution.kis_client import KISClient
client = KISClient()
# TradingBot._fetch_asset_snapshot 과 유사하게 잔고 조회
bal = None
for meth in ("get_balance", "inquire_balance", "account_balance"):
fn = getattr(client, meth, None)
if callable(fn):
try:
bal = fn()
break
except Exception:
continue
if bal is None:
# inquire-balance 계열 탐색
for name in dir(client):
if "balance" in name.lower() and callable(getattr(client, name)):
try:
bal = getattr(client, name)()
if bal:
break
except Exception:
continue
mock = getattr(client, "mock", None)
acct = getattr(client, "account_no", "") or ""
_ok(f"KISClient 생성 ok mock={mock} acct=***{str(acct)[-4:]}")
if bal is not None:
_ok(f"잔고 조회 응답 type={type(bal).__name__}")
else:
# 주말이면 모의/실전 REST 가 비정상일 수 있음 — 치명으로 안 봄
log.warning("⚠️ 잔고 조회 메서드 미확인/실패 (장외 REST 가능) — 계속")
# 장시작 리포트 포맷만 검증 (MM 미전송)
lines = [
"🌅 **[장 시작 알림 - 09:00]** (SIM)",
f"- 🤖 활성 전략: (smoke)",
f"- 계좌: {'모의' if mock else '실전'}",
"📈 오늘도 안전 매매! 손절 라인 준수.",
]
body = "\n".join(lines)
assert "장 시작" in body
_ok("장시작 리포트 문자열 생성 OK (미전송)")
except Exception as e:
_err(f"계좌/장시작 리포트 실패: {e}")
log.error(traceback.format_exc())
def step_verify_three_paths() -> None:
try:
from kis_trader.scripts import verify_three_paths as v3
bad = 0
for fn_name in ("verify_momentum", "verify_breakout", "verify_scalping", "verify_tail"):
fn = getattr(v3, fn_name, None)
if not callable(fn):
continue
n = int(fn() or 0)
bad += n
if n:
_err(f"{fn_name} mismatch={n}")
else:
_ok(f"{fn_name} parity OK")
if bad == 0:
_ok("실매↔웹↔파람 변환 정합 스모크 통과")
except Exception as e:
_err(f"verify_three_paths 실패: {e}")
log.error(traceback.format_exc())
def step_prev_day_backtest_smoke() -> None:
"""직전 거래일 1일 · 꼬리+돌파 웹 API 경로 (Flask test_client, 서버 불필요)."""
from kis_trader.utils.kr_trading_day import clamp_to_prev_kr_trading_day
day = clamp_to_prev_kr_trading_day(date.today())
try:
from backtest_web import app
with app.test_client() as c:
for label, path in (
("꼬리", "/api/backtest/tail"),
("돌파", "/api/backtest/breakout"),
):
r = c.get(
path,
query_string={
"start": day,
"end": day,
"universe": "history",
},
)
if r.status_code != 200:
_err(f"{label} 백테 HTTP {r.status_code}")
continue
d = r.get_json(silent=True) or {}
if d.get("error"):
_err(f"{label} 백테 error: {d.get('error')}")
continue
s = d.get("summary") or {}
_ok(
f"{label} 백테 {day}: trades={s.get('total_trades', '?')} "
f"pnl={s.get('total_pnl', '?')}"
)
except Exception as e:
_err(f"웹 백테 스모크 실패: {e}")
log.error(traceback.format_exc())
def step_holdings_db() -> None:
try:
from kis_trader.utils.env import _get_db
db = _get_db()
if not db:
_err("TradeDB 연결 실패")
return
cols = db.conn.execute("SHOW COLUMNS FROM active_trades").fetchall()
col_names = [
(c["Field"] if isinstance(c, dict) else c[0]) for c in (cols or [])
]
if "status" in col_names:
rows = db.conn.execute(
"SELECT strategy, COUNT(*) AS n FROM active_trades "
"WHERE status=%s GROUP BY strategy",
("HOLDING",),
).fetchall()
else:
rows = db.conn.execute(
"SELECT strategy, COUNT(*) AS n FROM active_trades GROUP BY strategy"
).fetchall()
summary = []
for r in rows or []:
if isinstance(r, dict):
summary.append(f"{r.get('strategy')}={r.get('n')}")
else:
summary.append(f"{r[0]}={r[1]}")
_ok(f"active_trades HOLDING: {', '.join(summary) or '(없음)'}")
except Exception as e:
_err(f"active_trades 조회 실패: {e}")
log.error(traceback.format_exc())
def main() -> int:
log.info("=== market_open_sim_smoke START ===")
log.info("log=%s", LOG_PATH)
# 실매 FORCE 오염 방지
os.environ.pop("FORCE_MARKET_OPEN", None)
os.environ.pop("FORCE_BUY_TEST", None)
try:
fake_now = step_calendar()
step_strategy_flags()
step_market_hours(fake_now)
step_engine_defaults()
step_holdings_db()
step_account_and_open_report()
step_verify_three_paths()
step_prev_day_backtest_smoke()
except Exception as e:
_err(f"치명: {e}")
log.error(traceback.format_exc())
log.info("=== SUMMARY ok=%d err=%d ===", len(oks), len(errors))
for e in errors:
log.info("ERR: %s", e)
if errors:
log.info("RESULT: FAIL")
return 1
log.info("RESULT: PASS")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""
이전 모멘텀 fine #1 파라미터 고정 × 래칫만 A/B (apply 없음).
기준 JSON: optuna_momentum_fine_20260716_014654.json (best ~+83k, 래칫 축 없음)
기간: 2026-07-15 / 포트 120만 / orderbook off
"""
from __future__ import annotations
import json
import os
import sys
import time
from datetime import datetime
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
os.environ.setdefault("MOMENTUM_BACKTEST_REST_WARMUP", "1")
os.environ.setdefault("MOMENTUM_BACKTEST_REST_WARMUP_BARS", "700")
os.environ.setdefault("MOMENTUM_BACKTEST_REST_SLEEP_SEC", "0.25")
from kis_trader.backtest.optuna_common import announce_optuna_json_path
from kis_trader.backtest.optuna_momentum import prepare_momentum_search_context
from kis_trader.backtest.param_search_momentum import evaluate_momentum_param_combo
from kis_trader.utils.env import get_env_from_db
# 래칫 후보: OFF + 실매 + fine 격자 대표 + 최근 Optuna 선호
RATCHET_CASES = [
("OFF", ""),
("LIVE", "5:2,10:1.5"),
("OPTUNA_TOP", "2:1.5,5:1"),
("MID", "2:1,5:0.8,8:0.6"),
]
BASE_JSON = os.path.join(
ROOT,
"kis_trader/backtest/results/optuna_momentum_fine_20260716_014654.json",
)
def main() -> int:
with open(BASE_JSON, encoding="utf-8") as f:
src = json.load(f)
base_combo = dict(src["results"][0]["params"])
start = str(src.get("start") or "2026-07-15")
end = str(src.get("end") or start)
print("=" * 72, flush=True)
print("모멘텀 래칫 A/B | 이전 fine#1 고정 | apply 없음", flush=True)
print(f"기준 JSON: {BASE_JSON}", flush=True)
print(f"기간: {start} ~ {end}", flush=True)
print(f"고정 params: {json.dumps(base_combo, ensure_ascii=False)}", flush=True)
print(
f"DB MOMENTUM_RATCHET_TIERS(참고): {get_env_from_db('MOMENTUM_RATCHET_TIERS', '')!r}",
flush=True,
)
print("=" * 72, flush=True)
t0 = time.time()
ctx = prepare_momentum_search_context(
start,
end,
"fine",
orderbook_filter="off",
)
if ctx is None:
print("❌ context 준비 실패", flush=True)
return 1
print(
f"✅ context OK | {time.time() - t0:.1f}s | "
f"slot={ctx.slot_money:,.0f} max={ctx.max_stocks} budget={ctx.total_budget_krw:,.0f}",
flush=True,
)
rows = []
for label, ratchet in RATCHET_CASES:
combo = dict(base_combo)
combo["ratchet_tiers"] = ratchet
print("-" * 72, flush=True)
print(f"▶ 케이스 {label} | ratchet_tiers={ratchet!r}", flush=True)
t1 = time.time()
result = evaluate_momentum_param_combo(
combo,
base_fixed=ctx.base_fixed,
grid_keys=list(ctx.grid_keys) + ["ratchet_tiers"],
codes_candles=ctx.codes_candles,
min_trades=1,
min_win_rate=0.0,
min_pf=0.0,
universe_by_slot=ctx.universe_by_slot,
slot_money=ctx.slot_money,
max_stocks=ctx.max_stocks,
total_budget_krw=ctx.total_budget_krw,
fee_rate=ctx.fee_rate,
sell_tax=ctx.sell_tax,
period_days=ctx.period_days,
cache_holder=ctx.cache_holder,
ticks_by_code=ctx.ticks_by_code,
orderbook_by_code=ctx.orderbook_by_code,
program_by_code=ctx.program_by_code,
log_verdict_by_code=ctx.log_verdict_by_code,
start_key=ctx.start_key,
end_key=ctx.end_key,
)
elapsed = time.time() - t1
if result is None:
row = {
"label": label,
"ratchet_tiers": ratchet,
"ok": False,
"elapsed_sec": round(elapsed, 2),
"note": "evaluate None",
}
print(f" ❌ None ({elapsed:.1f}s)", flush=True)
else:
row = {
"label": label,
"ratchet_tiers": ratchet,
"ok": True,
"elapsed_sec": round(elapsed, 2),
"total_pnl": float(result.get("total_pnl") or 0),
"total_trades": int(result.get("total_trades") or 0),
"win_rate": float(result.get("win_rate") or 0),
"pf": float(result.get("pf") or 0) if result.get("pf") is not None else None,
}
print(
f" ✅ pnl={row['total_pnl']:,.0f} | trades={row['total_trades']} | "
f"wr={row['win_rate']:.1f}% | pf={row['pf']} | {elapsed:.1f}s",
flush=True,
)
rows.append(row)
ok_rows = [r for r in rows if r.get("ok")]
ok_rows.sort(key=lambda r: (-float(r["total_pnl"]), -int(r["total_trades"])))
print("=" * 72, flush=True)
print("📊 A/B 결과 (PnL 내림차순)", flush=True)
for i, r in enumerate(ok_rows, 1):
print(
f" {i}. [{r['label']}] ratchet={r['ratchet_tiers']!r} | "
f"pnl={r['total_pnl']:,.0f} | trades={r['total_trades']} | wr={r['win_rate']:.1f}%",
flush=True,
)
if len(ok_rows) >= 2:
best, worst = ok_rows[0], ok_rows[-1]
print(
f"Δ bestworst = {best['total_pnl'] - worst['total_pnl']:+,.0f}"
f"({best['label']} vs {worst['label']})",
flush=True,
)
off = next((r for r in ok_rows if r["label"] == "OFF"), None)
live = next((r for r in ok_rows if r["label"] == "LIVE"), None)
if off and live:
print(
f"Δ OFFLIVE = {off['total_pnl'] - live['total_pnl']:+,.0f}"
f"(OFF {off['total_pnl']:,.0f} / LIVE {live['total_pnl']:,.0f})",
flush=True,
)
print("=" * 72, flush=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
out_dir = os.path.join(ROOT, "kis_trader/backtest/results")
os.makedirs(out_dir, exist_ok=True)
out_path = os.path.join(out_dir, f"momentum_ratchet_ab_{ts}.json")
out = {
"kind": "momentum_ratchet_ab",
"apply": False,
"base_json": BASE_JSON,
"base_pnl_reported": src["results"][0].get("total_pnl"),
"base_params": base_combo,
"start": start,
"end": end,
"slot_money": int(ctx.slot_money),
"max_stocks": int(ctx.max_stocks),
"total_budget_krw": int(ctx.total_budget_krw),
"cases": rows,
"ranked": ok_rows,
"elapsed_sec": round(time.time() - t0, 1),
}
with open(out_path, "w", encoding="utf-8") as f:
json.dump(out, f, indent=2, ensure_ascii=False)
announce_optuna_json_path(
out_path, strategy="momentum", mode="ratchet_ab", note="래칫 A/B 최종 JSON",
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""
스모크: 1M→N분 완전버킷 롤업 + confirm/merge volume upsert.
근본원인(2026-07-16 샘표): 불완전 롤업 삽입 + 동일 candle_time append 중복
→ RAM prior volume 왜곡 → 실매 vol 통과 / 백테 탈락.
실행:
python3 -u scripts/smoke_candle_upsert_rollup.py
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from kis_trader.engine.candle_rollup import floor_candle_time_to_tf, rollup_1m_bars_to_tf
from kis_trader.ws.kis_ws import CandleAggregator
def bar(ct, o, h, l, c, v, src="ws"):
return {
"candle_time": ct,
"open": o,
"high": h,
"low": l,
"close": c,
"volume": v,
"source": src,
}
def main() -> None:
assert floor_candle_time_to_tf("202607160912", 3) == "202607160912"
assert floor_candle_time_to_tf("202607160913", 3) == "202607160912"
assert floor_candle_time_to_tf("202607160914", 3) == "202607160912"
partial = [
bar("202607160912", 100, 101, 99, 100, 100),
bar("202607160913", 100, 102, 99, 101, 200),
]
assert rollup_1m_bars_to_tf(partial, 3) == []
full = partial + [bar("202607160914", 101, 110, 100, 105, 4226)]
rolled = rollup_1m_bars_to_tf(full, 3)
assert len(rolled) == 1
assert rolled[0]["candle_time"] == "202607160912"
assert rolled[0]["volume"] == 100 + 200 + 4226
more = full + [
bar("202607160915", 105, 106, 104, 105, 50),
bar("202607160916", 105, 107, 104, 106, 60),
]
assert len(rollup_1m_bars_to_tf(more, 3)) == 1
agg = CandleAggregator(db=None, timeframes=[1, 3])
code = "007540"
assert agg.merge_confirmed_bars(
code, 3,
[bar("202607160912", 43000, 44000, 42000, 43500, 515, "rollup_1m")],
log_tag="smoke_partial",
) == 1
assert agg.merge_confirmed_bars(
code, 3,
[bar("202607160912", 43000, 44500, 42000, 43800, 4526, "rest")],
log_tag="smoke_full",
) == 1
buf = agg._confirmed[(code, 3)]
assert len(buf) == 1 and buf[0]["volume"] == 4526
assert agg.merge_confirmed_bars(
code, 3,
[bar("202607160912", 43000, 44000, 42000, 43700, 100, "ws")],
log_tag="smoke_small",
) == 0
assert buf[0]["volume"] == 4526
agg2 = CandleAggregator(db=None, timeframes=[3])
key = (code, 3)
agg2.merge_confirmed_bars(
code, 3,
[bar("202607160912", 43000, 44000, 42000, 43500, 515, "rollup_1m")],
)
with agg2._lock:
confirmed = agg2._confirm_current_bucket(key, {
"candle_time": "202607160912",
"open": 43000,
"high": 44200,
"low": 42000,
"close": 43600,
"volume": 800,
"source": "ws",
})
assert len(agg2._confirmed[key]) == 1
assert confirmed["volume"] == 800
with agg2._lock:
agg2._confirm_current_bucket(key, {
"candle_time": "202607160912",
"open": 43000,
"high": 44100,
"low": 42000,
"close": 43400,
"volume": 100,
"source": "ws",
})
assert agg2._confirmed[key][0]["volume"] == 800
print("SMOKE_OK candle_upsert_rollup")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""
꼬리 실매 vs 백테 건별 forensics (C).
실매 trade_history(SHORT) 각 건에 대해:
유니버스 IN/OUT · 재편입 · 당일봉 entry_i · 웜업 후 align 신호 · 백테 체결 여부
사용:
python3 -u scripts/tail_live_bt_forensics.py --date 2026-07-16
nohup python3 -u scripts/tail_live_bt_forensics.py --date 2026-07-16 \
> logs/tail_live_bt_forensics_20260716.log 2>&1 &
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--date", default="2026-07-16", help="YYYY-MM-DD")
args = ap.parse_args()
day = args.date.replace("-", "")
day_dash = f"{day[:4]}-{day[4:6]}-{day[6:8]}"
from database import TradeDB
from kis_trader.engine import tail_engine as te
from kis_trader.engine.tail_engine import (
_eval_live_align_lookback,
_last_closed_bar_index,
_universe_enter_minutes,
)
from kis_trader.backtest import tail_backtest_common as tbc
from kis_trader.backtest.universe_timeline import build_universe_timeline
db = TradeDB()
live = db.conn.execute(
"SELECT code, name, buy_date, buy_price, sell_date, realized_pnl "
"FROM trade_history WHERE strategy=%s AND buy_date LIKE %s "
"ORDER BY buy_date",
("SHORT", f"{day_dash}%"),
).fetchall()
print(f"=== 꼬리 forensics {day_dash} live={len(live)} ===")
base = te.get_tail_defaults_from_db(db)
universe, src, n_slots, _ = tbc.resolve_tail_universe(
day, day, use_saved_history=True, strategy_id="SHORT",
)
tl = build_universe_timeline(
strategy_id="SHORT", start_ymd=day, end_ymd=day,
debounce_sec=0, strict=False,
)
start_key, end_key = day + "0000", day + "2359"
candles_by_code, _, _ = tbc.load_tail_candles_by_code(
db, start_key, end_key, int(base.get("timeframe") or 3),
rsi_period=int(base.get("rsi_period") or 14),
)
# REST 웜업 (유니버스 교집합)
tbc.inject_tail_rest_warmup_memory(
candles_by_code, start_key,
timeframe=int(base.get("timeframe") or 3),
universe_by_slot=universe,
)
port = tbc.resolve_tail_portfolio_params(
dict(db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone() or {}),
base,
)
row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone()
fee, tax, _ = tbc.fee_and_slot_from_env_row(dict(row) if row else None)
params = dict(base)
tbc.merge_tail_portfolio_into_params(params, port)
meta = {"db": db, "start_key": start_key, "end_key": end_key}
bt_trades = tbc.run_tail_backtest_web_aligned(
candles_by_code, params, universe,
slot_money=float(port["slot_money"]),
fee_rate=fee, sell_tax=tax,
total_budget_krw=float(port["total_budget_krw"]),
meta_out=meta,
)
bt_by_code = {}
for t in bt_trades:
bt_by_code.setdefault(str(t.get("code")), []).append(t)
enter_mins = _universe_enter_minutes(universe, tl, None)
print(f"universe src={src} slots={n_slots} enter_minutes={len(enter_mins)}")
print(f"warmup bars target={tbc.tail_backtest_candle_warmup_bars()} "
f"rest={meta.get('skip_stats', {}).get('rest_warmup')}")
print(f"BT trades={len(bt_trades)} pnl={sum(int(t.get('pnl') or 0) for t in bt_trades)}")
print()
for r in live:
code = str(r["code"])
buy_ts = str(r["buy_date"])
buy_hm = buy_ts[11:16].replace(":", "")
t12 = day + buy_hm
name = r.get("name") or code
print(f"── {code} {name} live {buy_ts} @{int(r['buy_price'])} pnl={r['realized_pnl']}")
# transitions that day
prev = False
trans = []
for et_row in db.conn.execute(
"""SELECT event_time, MAX(code=%s) has_me
FROM target_candidates_history
WHERE strategy_id=%s AND event_time LIKE %s
GROUP BY event_time ORDER BY event_time""",
(code, "SHORT", f"{day_dash}%"),
).fetchall():
has = bool(et_row["has_me"])
if has != prev:
trans.append((str(et_row["event_time"]), "IN" if has else "OUT"))
prev = has
print(f" transitions: {trans[:8]}{'...' if len(trans) > 8 else ''}")
in_at_buy = False
if tl is not None:
codes = tl.codes_at(t12 + "00") or []
in_at_buy = code in codes
print(f" universe@buy {t12}: {'IN' if in_at_buy else 'OUT'}")
bars = candles_by_code.get(code) or []
n_prev = sum(1 for c in bars if str(c.get("candle_time") or "")[:8] < day)
ei = _last_closed_bar_index(bars, t12, int(base.get("timeframe") or 3))
print(f" candles n={len(bars)} prev_day={n_prev} entry_i@buy={ei}",
f"bar={bars[ei]['candle_time'] if ei >= 0 else None}")
st = {"daily_cnt": 0, "last_exit_dt": None, "daily_pnl_krw": 0.0}
if ei >= 19:
rej, msg, sig = _eval_live_align_lookback(
bars, ei, params, st,
lookback=max(1, int(params.get("live_signal_lookback_bars") or 1)),
)
print(f" align: reject={rej} msg={(msg or '')[:70]} "
f"sig={bool(sig)} px={sig.get('entry_price') if sig else None}")
else:
print(f" align: SKIP entry_i={ei} < 19 (웜업 부족)")
hits = bt_by_code.get(code) or []
if hits:
for h in hits:
print(f" BT hit: {h.get('entry_time')} @{h.get('entry')} "
f"{h.get('exit_time')} pnl={h.get('pnl')}")
else:
print(" BT hit: NONE")
print()
db.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""7/9 SHORT 종목일일손익게이트 백테 검증 — Case A(edge=2000) vs B(edge=0)."""
from __future__ import annotations
import sys
import traceback
from collections import defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from database import TradeDB
from kis_trader.engine import tail_engine as te
from kis_trader.backtest import tail_backtest_common as tbc
from kis_trader.engine.tail_tick_replay import tail_backtest_wants_tick_replay
def _summarize(trades, label: str) -> dict:
total = len(trades)
wins = [t for t in trades if float(t.get("pnl") or 0) > 0]
pnl = sum(float(t.get("pnl") or 0) for t in trades)
by_code: dict = defaultdict(list)
for t in trades:
by_code[t.get("code")].append(t)
multi = sum(1 for v in by_code.values() if len(v) > 1)
wr = (len(wins) / total * 100) if total else 0.0
print(
f" trades={total} wins={len(wins)} WR={wr:.1f}% "
f"pnl={pnl:+,.0f} multi_codes={multi}",
flush=True,
)
for code, ts in sorted(by_code.items()):
cum = 0.0
parts = []
for t in ts:
cum += float(t.get("pnl") or 0)
hm = str(t.get("entry_time") or t.get("candle_time") or "")[8:12]
parts.append(
f"{hm}:{t.get('pnl', 0):+.0f}({t.get('exit_reason', '')})→cum{cum:+.0f}"
)
if len(ts) > 1:
print(f" [{code}] " + " | ".join(parts), flush=True)
return {"label": label, "trades": total, "pnl": pnl, "multi": multi}
def main() -> int:
print("=== 7/9 SHORT 게이트 백테 검증 (skip_hts=DB) ===", flush=True)
db = TradeDB()
start, end = "2026-07-09", "2026-07-09"
start_key = start.replace("-", "") + "0000"
end_key = end.replace("-", "") + "2359"
start_ymd, end_ymd = start_key[:8], end_key[:8]
try:
base = te.get_tail_defaults_from_db(db)
skip_hts = bool(base.get("skip_hts_scan_dupes"))
print(f"skip_hts_scan_dupes(DB)={skip_hts}", flush=True)
universe, src, n_slots, _ = tbc.resolve_tail_universe(
start_ymd, end_ymd, use_saved_history=True, strategy_id="SHORT",
)
tf = int(base.get("timeframe") or 3)
rsi = int(base.get("rsi_period") or 14)
candles_by_code, _, _ = tbc.load_tail_candles_by_code(
db, start_key, end_key, tf, rsi_period=rsi,
)
codes = len(candles_by_code)
bars = sum(len(v) for v in candles_by_code.values())
slot = float(base.get("slot_money") or 300000)
budget = float(base.get("total_budget_krw") or slot * int(base.get("max_stocks") or 4))
print(
f"[로드] universe={src} slots={n_slots} codes={codes} bars={bars} "
f"slot={slot} budget={budget}",
flush=True,
)
fee = float(base.get("fee_rate") or 0.00015)
tax = float(base.get("sell_tax") or 0.0023)
meta = {"db": db, "start_key": start_key, "end_key": end_key}
use_tick = tail_backtest_wants_tick_replay(base)
print(f"[로드] tick_replay={use_tick}", flush=True)
cases = [
("A GATE_ON edge=2000", 30000.0, 1.5, 2000.0),
("B GATE_ON edge=0", 30000.0, 1.5, 0.0),
]
results = []
for label, krw, pct, edge in cases:
params = dict(base)
params["symbol_daily_loss_limit_krw"] = krw
params["symbol_daily_loss_limit_pct"] = pct
params["reentry_min_edge_krw"] = edge
params["skip_hts_scan_dupes"] = skip_hts
print(
f"\n--- {label}: krw={krw} pct={pct} edge={edge} ---",
flush=True,
)
trades = tbc.run_tail_backtest_web_aligned(
candles_by_code,
params,
universe,
slot_money=slot,
fee_rate=fee,
sell_tax=tax,
total_budget_krw=budget,
meta_out=meta,
)
results.append(_summarize(trades, label))
print("\n======== SUMMARY ========", flush=True)
for r in results:
print(f" {r['label']}: trades={r['trades']} pnl={r['pnl']:+,.0f} multi={r['multi']}", flush=True)
print("\n[LIVE 7/9] trades=3(신규2) pnl=-7,053 (376980 전일포지션 포함)", flush=True)
print("✅ VERIFY DONE", flush=True)
return 0
except Exception:
traceback.print_exc()
return 1
finally:
db.close()
if __name__ == "__main__":
raise SystemExit(main())