flash-crash-watchdog / scripts /_analyze_normal_high.py
Dev2506's picture
Publish Flash Crash Watchdog — source, configs, docs, ml package, trained models, demo data
d699ea7
Raw History Blame Contribute Delete
3.78 kB
#!/usr/bin/env python3
"""Find which normal-day windows score > 0.45 with the prod TCN and characterize
their price action (small dip? noise?) vs the full normal-day distribution."""
import argparse, logging, sys
from pathlib import Path
import numpy as np, pandas as pd, torch
ML_DIR = Path(__file__).resolve().parent.parent / "ml"
sys.path.insert(0, str(ML_DIR))
from flash_crash_watchdog.data.historical_loader import df_to_ticks
from flash_crash_watchdog.features import FEATURE_NAMES, FeatureExtractor
from flash_crash_watchdog.models.stage3_tcn import TCNConfig, TCNDetector
torch.serialization.add_safe_globals([TCNConfig])
logging.basicConfig(level=logging.WARNING)
W = 200
NORM = 500
CH = 4096
def normalize_z(f, window):
pdf = pd.DataFrame(f)
mean = pdf.rolling(window, min_periods=1).mean()
std = pdf.rolling(window, min_periods=1).std()
return ((pdf - mean) / std).where(std.abs() > 1e-8, 0.0).fillna(0.0).to_numpy(np.float32)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--data", required=True)
ap.add_argument("--model", default="models/stage3_tcn_prod.pt")
ap.add_argument("--thr", type=float, default=0.45)
ap.add_argument("--lookahead", type=int, default=500)
ap.add_argument("--max-ticks", type=int, default=0)
a = ap.parse_args()
dev = "cuda" if torch.cuda.is_available() else "cpu"
df = pd.read_parquet(a.data)
if a.max_ticks and len(df) > a.max_ticks: df = df.iloc[:a.max_ticks]
ticks = list(df_to_ticks(df, symbol="EVAL"))
ex = FeatureExtractor(); F = np.zeros((len(ticks),17), np.float32); mid = np.zeros(len(ticks), np.float64)
ts = np.zeros(len(ticks), np.float64)
for i,t in enumerate(ticks):
fd = ex.extract(t); F[i]=[float(fd.get(k,0.0)) or 0.0 for k in FEATURE_NAMES[:17]]
mid[i]=float(t.book.mid_price) if t.book.mid_price else 0.0
ts[i]=float(t.book.timestamp_ms)
st = torch.load(a.model, map_location='cpu', weights_only=True)
cfg = st["config"] if isinstance(st["config"], TCNConfig) else TCNConfig(**st["config"])
model = TCNDetector(cfg).to(dev); model.load_state_dict(st["model_state"]); model.eval()
norm = normalize_z(F, NORM)
n = len(norm)-W+1
scores = np.empty(n, np.float32)
for s in range(0,n,CH):
e=min(s+CH,n)
win=np.stack([norm[i:i+W] for i in range(s,e)])
x=torch.from_numpy(np.ascontiguousarray(win)).permute(0,2,1).float().to(dev)
with torch.no_grad(): scores[s:e]=model(x)[:, -1].cpu().numpy()
hi = np.where(scores > a.thr)[0]
print("total windows=%d >%.2f: %d" % (n, a.thr, len(hi)))
print("score dist p50=%.3f p90=%.3f p99=%.3f max=%.3f"%(
np.percentile(scores,50),np.percentile(scores,90),np.percentile(scores,99),scores.max()))
# characterize the high windows
print("\nidx score win_drop% fut_drop% mean_|1s return|%")
drops=[]; big=0
for j in hi:
w0 = mid[j]; wend = mid[j+W-1]
fut = mid[j+W:j+W+a.lookahead]
win_drop = (w0-wend)/w0*100 if w0 else 0
fut_drop = (wend-min(fut))/wend*100 if wend>0 and len(fut) else 0
# intra-window volatility: mean abs per-tick return
rets=np.abs(np.diff(mid[j:j+W])); r=rets.mean()/wend*100 if wend else 0
drops={"win":win_drop,"fut":fut_drop}
big += 1 if (win_drop>1.0 or fut_drop>1.0) else 0
print("%5d %.3f %8.2f %8.2f %7.3f"%(j+W-1, scores[j], win_drop, fut_drop, r))
print("high windows with >1%% window or future drop: %d of %d"%(big,len(hi)))
# overall normal-day drop stats for context
ov=np.abs(np.diff(mid)); print("normal-day median abs-per-tick%%=%.4f p99=%.4f"%(np.mean(ov)/np.mean(mid)*100, np.percentile(ov,99)/np.mean(mid)*100))
if __name__=="__main__": main()