You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

216 lines
7.7 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""
V42 Diagnostic: 52-Week High Proximity Signal
Hypothesis: Stocks trading near their 52-week high on the ORB entry day have:
(a) confirmed long-term uptrend momentum
(b) no overhead price resistance (buyers that are "underwater" don't sell)
(c) higher institutional confidence = better ORB follow-through
Features:
pct_from_52w_high : (last_close - 52w_high) / 52w_high [≤ 0; 0 = at high]
is_near_52w_high : 1 if within 5% of 52w high, else 0 (binary version)
dist_from_52w_low : (last_close - 52w_low) / (52w_high - 52w_low) [0-1, 1 = at high]
"position in 52-week range" — independent from proximity to high
Source: V24 400d run JSON + daily parquet cache (need 252 prior trading days).
"""
from __future__ import annotations
import concurrent.futures
import datetime as dt
import json
import os
import sys
from pathlib import Path
import pyarrow.parquet as pq
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../..")))
from zoneinfo import ZoneInfo
from libs.common.time_utils import trading_days_between
_ET = ZoneInfo("America/New_York")
_MKT_OPEN = dt.time(9, 30)
_MKT_CLOSE = dt.time(16, 0)
INTRADAY_CACHE_DIR = "data/cache/intraday"
V24_400D_RUN = "runs/intraday_orb/intraday_20260422_011012_06f59ede.json"
def _parse_ts(ts_raw: object) -> dt.datetime:
s = str(ts_raw)
if s.endswith("Z"):
s = s[:-1] + "+00:00"
return dt.datetime.fromisoformat(s).astimezone(_ET)
def get_daily_close(ticker: str, dates: list[str]) -> dict[str, float]:
closes = {}
for date in dates:
path = Path(INTRADAY_CACHE_DIR) / ticker / f"{date}.parquet"
if not path.exists():
continue
try:
table = pq.read_table(str(path))
rows = table.to_pydict()
except Exception:
continue
c_list = []
for i, ts_raw in enumerate(rows.get("timestamp", [])):
try:
ts = _parse_ts(ts_raw)
except Exception:
continue
if _MKT_OPEN <= ts.time() < _MKT_CLOSE:
c_list.append(float(rows["close"][i] or 0))
if c_list and c_list[-1] > 0:
closes[date] = c_list[-1]
return closes
def pearson(xs: list[float], ys: list[float]) -> float | None:
n = len(xs)
if n < 2:
return None
xm, ym = sum(xs) / n, sum(ys) / n
num = sum((xs[i] - xm) * (ys[i] - ym) for i in range(n))
dx = sum((x - xm) ** 2 for x in xs) ** 0.5
dy = sum((y - ym) ** 2 for y in ys) ** 0.5
if dx <= 0 or dy <= 0:
return None
return num / (dx * dy)
def tercile_stats(vals: list[float], rs: list[float]) -> dict:
if len(vals) < 9:
return {}
pairs = sorted(zip(vals, rs), key=lambda p: p[0])
n = len(pairs)
t = n // 3
def stats(sub):
ys = [p[1] for p in sub]
return {"n": len(ys), "wr": sum(1 for y in ys if y > 0) / len(ys), "avg_r": sum(ys) / len(ys)}
return {"low": stats(pairs[:t]), "mid": stats(pairs[t:2*t]), "high": stats(pairs[2*t:])}
def report_feature(label: str, vals: list[float], rs: list[float]) -> None:
n = len(vals)
p = pearson(vals, rs)
ts = tercile_stats(vals, rs)
if not ts or p is None:
print(f" {label}: n={n}, insufficient data")
return
low, mid, high = ts["low"], ts["mid"], ts["high"]
avg_r_gap = abs(high["avg_r"] - low["avg_r"])
wr_gap = abs(high["wr"] - low["wr"])
g1 = abs(p) >= 0.07 and n >= 120
g2 = avg_r_gap >= 0.30
g3 = wr_gap >= 0.05
print(f"\n [{label}] n={n} Pearson={p:+.3f}")
print(f" G1: {'PASS' if g1 else 'FAIL'} (|{abs(p):.3f}| {'' if abs(p)>=0.07 else '<'} 0.07, n={n})")
print(f" G2: {'PASS' if g2 else 'FAIL'} (avg_R gap = {avg_r_gap:.3f}R [threshold 0.30R])")
print(f" G3: {'PASS' if g3 else 'FAIL'} (WR gap = {wr_gap*100:.1f}pp [threshold 5pp])")
print(f" Bottom tercile: WR {low['wr']*100:.1f}% avg_R {low['avg_r']:+.3f} n={low['n']}")
print(f" Middle tercile: WR {mid['wr']*100:.1f}% avg_R {mid['avg_r']:+.3f} n={mid['n']}")
print(f" Top tercile: WR {high['wr']*100:.1f}% avg_R {high['avg_r']:+.3f} n={high['n']}")
print(f"{'ALL GATES PASS ✓' if (g1 and g2 and g3) else 'FAIL'}")
def main() -> None:
print("=== V42 52-Week High Proximity Diagnostic ===\n")
with open(V24_400D_RUN) as f:
run_data = json.load(f)
trades = run_data.get("trades", [])
m = run_data.get("metrics", {})
print(f"Loaded V24 400d: {m.get('total_trades')} trades, "
f"{m.get('start_date')}{m.get('end_date')}")
trade_records = [
{"ticker": t["ticker"], "date": t["date"][:10],
"r_multiple": float(t["r_multiple_at_exit"])}
for t in trades
if t.get("r_multiple_at_exit") is not None
]
tickers_needed = sorted(set(r["ticker"] for r in trade_records))
min_d = dt.date.fromisoformat(min(r["date"] for r in trade_records))
max_d = dt.date.fromisoformat(max(r["date"] for r in trade_records))
# Need 252 prior trading days = ~1 year lookback
start_d = min_d - dt.timedelta(days=400)
all_td = trading_days_between(start_d, max_d)
all_dates = [d.isoformat() for d in all_td]
print(f"Loading closes for {len(tickers_needed)} tickers across {len(all_dates)} trading days...")
ticker_closes: dict[str, dict[str, float]] = {}
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as ex:
def _load(ticker: str) -> tuple[str, dict[str, float]]:
return ticker, get_daily_close(ticker, all_dates)
for ticker, closes in ex.map(_load, tickers_needed):
if closes:
ticker_closes[ticker] = closes
print(f"Loaded: {len(ticker_closes)} / {len(tickers_needed)} tickers\n")
pct_from_high_vals, range_pos_vals, r_mults = [], [], []
missing = 0
for rec in trade_records:
ticker = rec["ticker"]
date = rec["date"]
r = rec["r_multiple"]
closes = ticker_closes.get(ticker, {})
# Get all closes STRICTLY before trade date
prev_closes = [(d, c) for d, c in closes.items() if d < date]
if len(prev_closes) < 50:
missing += 1
continue
# Sort by date and take last 252 trading days
prev_closes = sorted(prev_closes, key=lambda x: x[0])
window = prev_closes[-252:]
prices = [c for _, c in window]
last_close = prices[-1]
high_52w = max(prices)
low_52w = min(prices)
if high_52w <= 0:
missing += 1
continue
pct_from_high = (last_close - high_52w) / high_52w # ≤ 0
if high_52w == low_52w:
range_pos = 0.5
else:
range_pos = (last_close - low_52w) / (high_52w - low_52w) # [0, 1]
pct_from_high_vals.append(pct_from_high)
range_pos_vals.append(range_pos)
r_mults.append(r)
n_valid = len(r_mults)
print(f"Valid trades: {n_valid} / {len(trade_records)} (missing: {missing})")
if pct_from_high_vals:
avg_pfh = sum(pct_from_high_vals) / len(pct_from_high_vals)
print(f"Mean pct_from_52w_high: {avg_pfh:.1%}")
print(f"Mean range_pos: {sum(range_pos_vals)/len(range_pos_vals):.2f}")
print("\n" + "=" * 60)
print("GATE RESULTS (G1: |P|≥0.07 & n≥120; G2: avg_R≥0.30R; G3: WR≥5pp)")
report_feature("pct_from_52w_high (0 = AT high, negative = below)", pct_from_high_vals, r_mults)
report_feature("52w_range_position (0=at_low, 1=at_high)", range_pos_vals, r_mults)
p_corr = pearson(pct_from_high_vals, range_pos_vals)
print(f"\n ρ(pct_from_high, range_pos) = {p_corr:.3f}")
print("\n=== Summary ===")
print("Target: G2 ≥ 0.30R. Any value approaching this warrants further investigation.")
if __name__ == "__main__":
main()