Spaces:
Running
Make the signal reachable, directional, and timeframe-aware
Browse filesThe app could only ever answer HOLD. Four defects, each sufficient alone:
1. min_confidence was 70/65/60/55 against a quantity whose measured max was 47 --
it fired on 0 of 80 observations. Recalibrated to 40/38/36/34, percentiles of
the real distribution; fires ~9%, so HOLD stays the common answer.
2. A component with no reading was scored as 0 confidence instead of abstaining,
spending 30% of the budget on a constant zero and pinning output near 25.
3. momentum_score is a magnitude and _convert_signal_format hardcoded CALLS, so
PUTS was unreachable for any input and a selloff read as a bullish setup.
Direction recovered from directional_bias, which was computed then discarded.
4. The selected timeframe never reached the momentum engine, which hardcoded its
weights -- every timeframe analysed identical data. 5m bars added so the fast
settings have real data to weight.
Gap mode was gated on the TFT gap_probability (~67.1 for every symbol, never
cleared its own >70 test) and is now gated on a measured overnight gap.
Direction gates set by permutation test on real bars: at the old settings the
engine fired MORE often on shuffled data than real (lift 0.70). Now 1.83x.
That is a modest edge, not a tradeable one, and is documented as such.
- signal_reachability_test.py +148 -0
- tools/calibrate_confidence_gate.py +98 -0
- tools/calibrate_direction_gate.py +131 -0
- trade_analysis/data.py +11 -0
- trade_analysis/enhanced_api.py +91 -17
- trade_analysis/momentum_trading_engine.py +154 -29
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Can the app emit anything other than HOLD? And does either dropdown do anything?
|
| 2 |
+
|
| 3 |
+
venv\Scripts\python.exe huggingface_space\signal_reachability_test.py
|
| 4 |
+
|
| 5 |
+
Audited 2026-09-07: all 12 timeframe x strategy combinations returned ONE distinct answer,
|
| 6 |
+
always HOLD, at a confidence pinned near 25. Four separate defects, each of which alone was
|
| 7 |
+
enough to cause it:
|
| 8 |
+
|
| 9 |
+
1. min_confidence was 70/65/60/55 against a quantity whose measured MAXIMUM was 47.
|
| 10 |
+
The gate fired on 0 of 80 observations. Not strict -- unreachable.
|
| 11 |
+
2. sentiment abstains almost always, but was scored as 0 confidence rather than excluded,
|
| 12 |
+
permanently spending 30% of the confidence budget on a constant zero.
|
| 13 |
+
3. momentum_score is a MAGNITUDE (every term abs()-wrapped or non-negative) and
|
| 14 |
+
_convert_signal_format hardcoded `return 'CALLS'`. weighted_score could not go
|
| 15 |
+
negative, so PUTS was unreachable for any input, and a collapsing stock read as a
|
| 16 |
+
bullish setup.
|
| 17 |
+
4. the selected timeframe never reached the momentum engine, which hardcoded its
|
| 18 |
+
timeframe weights -- so every timeframe analysed identical data.
|
| 19 |
+
|
| 20 |
+
These assert all four stay fixed. Synthetic bars are used deliberately: a live-market test
|
| 21 |
+
would pass or fail on that morning's tape.
|
| 22 |
+
"""
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import os
|
| 26 |
+
import sys
|
| 27 |
+
import warnings
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
warnings.filterwarnings("ignore")
|
| 31 |
+
HERE = Path(__file__).resolve().parent
|
| 32 |
+
sys.path.insert(0, str(HERE))
|
| 33 |
+
for _k in ("FINNHUB_KEY", "REDDIT_CLIENT_ID", "REDDIT_CLIENT_SECRET", "REDDIT_USER_AGENT"):
|
| 34 |
+
os.environ.setdefault(_k, "unused")
|
| 35 |
+
|
| 36 |
+
import numpy as np
|
| 37 |
+
import pandas as pd
|
| 38 |
+
|
| 39 |
+
from trade_analysis.enhanced_api import _generate_master_signal
|
| 40 |
+
from trade_analysis.momentum_trading_engine import IntegratedMomentumEngine
|
| 41 |
+
|
| 42 |
+
SENT = {"composite_score": 0.05, "confidence": "LOW"}
|
| 43 |
+
ALT = {"vix_level": 14.5, "implied_vol_pct": 60.0}
|
| 44 |
+
LLM = {"signal": "HOLD", "confidence": 40}
|
| 45 |
+
TFS = ["1m", "5m", "15m", "1h"]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def trend_bars(n: int = 200, drift: float = 0.005, seed: int = 0) -> pd.DataFrame:
|
| 49 |
+
"""A clean, decisive trend. drift < 0 falls."""
|
| 50 |
+
rng = np.random.default_rng(seed)
|
| 51 |
+
close = 100.0 * np.cumprod(1.0 + drift + rng.normal(0, 0.0004, n))
|
| 52 |
+
high = close * 1.002
|
| 53 |
+
low = close * 0.998
|
| 54 |
+
vol = np.linspace(1e6, 2e6, n)
|
| 55 |
+
vol[-1] *= 3.0 # closing volume spike
|
| 56 |
+
idx = pd.date_range("2026-01-02 09:30", periods=n, freq="15min", tz="UTC")
|
| 57 |
+
return pd.DataFrame({"Open": close, "High": high, "Low": low,
|
| 58 |
+
"Close": close, "Volume": vol}, index=idx)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def bar_set(drift: float) -> dict:
|
| 62 |
+
return {tf: trend_bars(drift=drift, seed=i)
|
| 63 |
+
for i, tf in enumerate(["5m", "15m", "hourly", "daily"])}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def run(bars, timeframe="15m", strategy="momentum", gap=None):
|
| 67 |
+
eng = IntegratedMomentumEngine()
|
| 68 |
+
mom = eng.generate_enhanced_signal(bars, SENT, ALT, timeframe)
|
| 69 |
+
sig = _generate_master_signal(mom, LLM, SENT, None, timeframe, strategy, gap)
|
| 70 |
+
return mom, sig
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
failures = []
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def check(label, cond, detail=""):
|
| 77 |
+
print(f" {'PASS' if cond else 'FAIL'} {label}{(' -- ' + detail) if detail else ''}")
|
| 78 |
+
if not cond:
|
| 79 |
+
failures.append(label)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
print("1. the confidence gate is reachable at all")
|
| 83 |
+
up = bar_set(+0.005)
|
| 84 |
+
mom, sig = run(up)
|
| 85 |
+
check("a decisive uptrend clears the gate and is not HOLD", sig["signal"] != "HOLD",
|
| 86 |
+
f"signal={sig['signal']} conf={sig['confidence']} score={sig['weighted_score']:+.3f}")
|
| 87 |
+
check("...and it is bullish", sig["signal"] == "CALLS", f"got {sig['signal']}")
|
| 88 |
+
|
| 89 |
+
print("\n2. PUTS is reachable -- the direction bug")
|
| 90 |
+
dn = bar_set(-0.005)
|
| 91 |
+
mom_d, sig_d = run(dn)
|
| 92 |
+
check("a decisive DOWNtrend is not reported as a CALLS setup",
|
| 93 |
+
sig_d["signal"] != "CALLS", f"got {sig_d['signal']}")
|
| 94 |
+
check("...it is PUTS", sig_d["signal"] == "PUTS",
|
| 95 |
+
f"signal={sig_d['signal']} score={sig_d['weighted_score']:+.3f}")
|
| 96 |
+
check("weighted_score carries a negative sign", sig_d["weighted_score"] < 0,
|
| 97 |
+
f"{sig_d['weighted_score']:+.3f}")
|
| 98 |
+
check("engine direction is BEARISH",
|
| 99 |
+
mom_d["momentum_analysis"]["master_signal"].get("direction") == "BEARISH",
|
| 100 |
+
str(mom_d["momentum_analysis"]["master_signal"].get("direction")))
|
| 101 |
+
|
| 102 |
+
print("\n3. the timeframe selector reaches the analysis")
|
| 103 |
+
mixed = {"5m": trend_bars(drift=+0.006, seed=1),
|
| 104 |
+
"15m": trend_bars(drift=+0.004, seed=2),
|
| 105 |
+
"hourly": trend_bars(drift=-0.001, seed=3),
|
| 106 |
+
"daily": trend_bars(drift=-0.005, seed=4)}
|
| 107 |
+
scores = {}
|
| 108 |
+
for tf in TFS:
|
| 109 |
+
_, s = run(mixed, timeframe=tf)
|
| 110 |
+
scores[tf] = round(s["weighted_score"], 6)
|
| 111 |
+
print(f" weighted_score by timeframe: {scores}")
|
| 112 |
+
check("timeframes do not all produce one identical score",
|
| 113 |
+
len(set(scores.values())) > 1, f"{len(set(scores.values()))} distinct")
|
| 114 |
+
check("a fast setting differs from the slow one", scores["1m"] != scores["1h"])
|
| 115 |
+
|
| 116 |
+
print("\n4. the gap strategy responds to a real gap")
|
| 117 |
+
_, no_gap = run(up, strategy="gap", gap=0.05)
|
| 118 |
+
_, big_gap = run(up, strategy="gap", gap=2.4)
|
| 119 |
+
check("a 0.05% move is reported as below the floor",
|
| 120 |
+
"below the 0.5% floor" in no_gap["reasoning"], no_gap["reasoning"])
|
| 121 |
+
check("a 2.4% gap qualifies and says so",
|
| 122 |
+
"qualifies" in big_gap["reasoning"], big_gap["reasoning"])
|
| 123 |
+
check("the measured gap is returned to the caller",
|
| 124 |
+
big_gap.get("overnight_gap_pct") == 2.4, str(big_gap.get("overnight_gap_pct")))
|
| 125 |
+
|
| 126 |
+
print()
|
| 127 |
+
print("5. a directionless tape stays HOLD (the gates must remain demanding)")
|
| 128 |
+
# One case is not a test: direction is a noisy statistic and any single draw can clear a
|
| 129 |
+
# threshold by luck, so this fires an ENSEMBLE and asserts the rate. Note the DISTINCT
|
| 130 |
+
# seed per bar set -- an earlier version of this test reused one seed for all four, which
|
| 131 |
+
# made the timeframes literally identical, forced agreement to 1.0, and guaranteed a fire.
|
| 132 |
+
noise_fires, noise_n = 0, 0
|
| 133 |
+
for k in range(25):
|
| 134 |
+
flat = {tf: trend_bars(drift=0.0, seed=500 + k * 4 + j)
|
| 135 |
+
for j, tf in enumerate(["5m", "15m", "hourly", "daily"])}
|
| 136 |
+
for tf in TFS:
|
| 137 |
+
noise_n += 1
|
| 138 |
+
if run(flat, timeframe=tf)[1]["signal"] != "HOLD":
|
| 139 |
+
noise_fires += 1
|
| 140 |
+
rate = noise_fires / noise_n * 100
|
| 141 |
+
check("fires on under 10% of zero-drift tapes", rate < 10.0,
|
| 142 |
+
f"{noise_fires}/{noise_n} = {rate:.1f}%")
|
| 143 |
+
|
| 144 |
+
print("\n" + "=" * 66)
|
| 145 |
+
if failures:
|
| 146 |
+
print(f"FAILED ({len(failures)}): " + "; ".join(failures))
|
| 147 |
+
sys.exit(1)
|
| 148 |
+
print("All reachability checks passed.")
|
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Measure the attainable range of weighted_confidence over real bars, so that
|
| 2 |
+
enhanced_api's min_confidence is a percentile of something real rather than a guess.
|
| 3 |
+
|
| 4 |
+
venv\\Scripts\\python.exe huggingface_space\\tools\\calibrate_confidence_gate.py
|
| 5 |
+
|
| 6 |
+
min_confidence was originally 70/65/60/55 against a quantity whose measured maximum was
|
| 7 |
+
47: it fired on 0 of 80 observations, which is why the app answered HOLD for every symbol
|
| 8 |
+
on every timeframe. Re-run this after changing any confidence component, or when the
|
| 9 |
+
volatility regime shifts -- the sample it was set from was a quiet tape (VIX ~14.5).
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
import statistics as st
|
| 15 |
+
import sys
|
| 16 |
+
import warnings
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
warnings.filterwarnings("ignore")
|
| 20 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 21 |
+
for _k in ("FINNHUB_KEY", "REDDIT_CLIENT_ID", "REDDIT_CLIENT_SECRET", "REDDIT_USER_AGENT"):
|
| 22 |
+
os.environ.setdefault(_k, "unused")
|
| 23 |
+
|
| 24 |
+
import pandas as pd
|
| 25 |
+
import yfinance as yf
|
| 26 |
+
|
| 27 |
+
from trade_analysis.data import TIMEFRAME_SPEC
|
| 28 |
+
from trade_analysis.enhanced_api import _generate_master_signal
|
| 29 |
+
from trade_analysis.momentum_trading_engine import IntegratedMomentumEngine
|
| 30 |
+
|
| 31 |
+
COLS = ["Open", "High", "Low", "Close", "Volume"]
|
| 32 |
+
TFS = ["1m", "5m", "15m", "1h"]
|
| 33 |
+
THRESH = {"1m": .20, "5m": .25, "15m": .30, "1h": .35}
|
| 34 |
+
GATE = {"1m": 40, "5m": 38, "15m": 36, "1h": 34} # what enhanced_api uses now
|
| 35 |
+
BASKET = ["NVDA", "TSLA", "SPY", "QQQ", "AAPL", "MSFT", "AMD", "META", "AMZN", "GOOGL",
|
| 36 |
+
"NFLX", "COIN", "XLE", "JPM", "WMT", "BA", "DIS", "UBER", "PLTR", "SMCI"]
|
| 37 |
+
SENT = {"composite_score": 0.05, "confidence": "LOW"}
|
| 38 |
+
ALT = {"vix_level": 14.5, "implied_vol_pct": 60.0}
|
| 39 |
+
LLM = {"signal": "HOLD", "confidence": 40}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def bars(symbol: str) -> dict:
|
| 43 |
+
out = {}
|
| 44 |
+
for tf, (period, interval) in TIMEFRAME_SPEC.items():
|
| 45 |
+
try:
|
| 46 |
+
df = yf.Ticker(symbol).history(period=period, interval=interval,
|
| 47 |
+
auto_adjust=False)
|
| 48 |
+
except Exception:
|
| 49 |
+
continue
|
| 50 |
+
if df is None or df.empty:
|
| 51 |
+
continue
|
| 52 |
+
if isinstance(df.columns, pd.MultiIndex):
|
| 53 |
+
df.columns = df.columns.get_level_values(0)
|
| 54 |
+
out[tf] = df[COLS].dropna()
|
| 55 |
+
return out
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def main() -> int:
|
| 59 |
+
eng = IntegratedMomentumEngine()
|
| 60 |
+
rows = []
|
| 61 |
+
for sym in BASKET:
|
| 62 |
+
o = bars(sym)
|
| 63 |
+
if not o:
|
| 64 |
+
print(f" {sym}: no data, skipped")
|
| 65 |
+
continue
|
| 66 |
+
gap = None
|
| 67 |
+
d = o.get("daily")
|
| 68 |
+
if d is not None and len(d) >= 2:
|
| 69 |
+
prev, opn = float(d["Close"].iloc[-2]), float(d["Open"].iloc[-1])
|
| 70 |
+
gap = (opn - prev) / prev * 100 if prev else None
|
| 71 |
+
for tf in TFS:
|
| 72 |
+
mom = eng.generate_enhanced_signal(o, SENT, ALT, tf)
|
| 73 |
+
r = _generate_master_signal(mom, LLM, SENT, None, tf, "momentum", gap)
|
| 74 |
+
rows.append((tf, r["confidence"], r["weighted_score"], r["signal"]))
|
| 75 |
+
print(f" {sym}: " + " ".join(f"{t}={c}" for t, c, _, _ in rows[-4:]))
|
| 76 |
+
|
| 77 |
+
if not rows:
|
| 78 |
+
print("no data at all -- market data unreachable?")
|
| 79 |
+
return 1
|
| 80 |
+
confs = sorted(c for _, c, _, _ in rows)
|
| 81 |
+
|
| 82 |
+
def p(q):
|
| 83 |
+
return confs[min(len(confs) - 1, int(len(confs) * q))]
|
| 84 |
+
|
| 85 |
+
print("\n" + "=" * 64)
|
| 86 |
+
print(f"n = {len(rows)} observations")
|
| 87 |
+
print(f"weighted_confidence min {min(confs)} p50 {p(.50)} p75 {p(.75)} "
|
| 88 |
+
f"p90 {p(.90)} max {max(confs)} (mean {st.mean(confs):.1f})")
|
| 89 |
+
fires = sum(1 for tf, c, s, _ in rows if c > GATE[tf] and abs(s) > THRESH[tf])
|
| 90 |
+
print(f"current gate {GATE}")
|
| 91 |
+
print(f"fires on {fires}/{len(rows)} ({fires / len(rows) * 100:.1f}%)")
|
| 92 |
+
print("\nA gate ABOVE the observed max is not strict, it is broken. Keep the fire "
|
| 93 |
+
"rate low but\nnon-zero: HOLD should be the common answer, not the only one.")
|
| 94 |
+
return 0
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
if __name__ == "__main__":
|
| 98 |
+
raise SystemExit(main())
|
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Calibrate the direction gates by PERMUTATION on real bars.
|
| 2 |
+
|
| 3 |
+
venv\\Scripts\\python.exe huggingface_space\\tools\\calibrate_direction_gate.py
|
| 4 |
+
|
| 5 |
+
The first question a trading signal must answer is not "how often is it right" but "how
|
| 6 |
+
often does it fire when there is nothing there". Shuffling the returns of a real series
|
| 7 |
+
destroys any persistent trend while preserving the volume profile, volatility level and
|
| 8 |
+
bar geometry that the momentum MAGNITUDE depends on -- so anything the engine emits on a
|
| 9 |
+
permuted series is a false positive, and the ratio of its real firing rate to its permuted
|
| 10 |
+
firing rate is its lift over noise.
|
| 11 |
+
|
| 12 |
+
Two earlier attempts at this were wrong and are recorded here so they are not repeated:
|
| 13 |
+
|
| 14 |
+
* Independent random walks per timeframe. In production 5m/15m/hourly/daily are nested
|
| 15 |
+
aggregations of ONE price path, so they agree far more often than independent draws
|
| 16 |
+
do. Treating them as independent flatters the multi-timeframe agreement test.
|
| 17 |
+
* Synthetic nested walks with uniform random volume. The momentum magnitude gate then
|
| 18 |
+
blocks every case before the direction logic is ever consulted, so the measurement
|
| 19 |
+
reported 0% for everything and was insensitive to the knobs being swept.
|
| 20 |
+
|
| 21 |
+
Permutation avoids both: the geometry and the volume are real, only the path is destroyed.
|
| 22 |
+
"""
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import os
|
| 26 |
+
import sys
|
| 27 |
+
import warnings
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
warnings.filterwarnings("ignore")
|
| 31 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 32 |
+
for _k in ("FINNHUB_KEY", "REDDIT_CLIENT_ID", "REDDIT_CLIENT_SECRET", "REDDIT_USER_AGENT"):
|
| 33 |
+
os.environ.setdefault(_k, "unused")
|
| 34 |
+
|
| 35 |
+
import numpy as np
|
| 36 |
+
import pandas as pd
|
| 37 |
+
import yfinance as yf
|
| 38 |
+
|
| 39 |
+
import trade_analysis.momentum_trading_engine as MTE
|
| 40 |
+
from trade_analysis.data import TIMEFRAME_SPEC
|
| 41 |
+
from trade_analysis.enhanced_api import _generate_master_signal
|
| 42 |
+
|
| 43 |
+
COLS = ["Open", "High", "Low", "Close", "Volume"]
|
| 44 |
+
TFS = ["1m", "5m", "15m", "1h"]
|
| 45 |
+
BASKET = ["NVDA", "TSLA", "SPY", "QQQ", "AAPL", "MSFT", "AMD", "META", "AMZN", "GOOGL",
|
| 46 |
+
"NFLX", "COIN", "XLE", "JPM", "WMT", "BA", "DIS", "UBER", "PLTR", "SMCI"]
|
| 47 |
+
GRID = [(0.15, 0.60), (0.25, 0.60), (0.35, 0.60), (0.35, 0.75),
|
| 48 |
+
(0.40, 0.60), (0.40, 0.75), (0.45, 0.75), (0.50, 0.75)]
|
| 49 |
+
IN_USE = (0.40, 0.75)
|
| 50 |
+
REPS = 6
|
| 51 |
+
|
| 52 |
+
SENT = {"composite_score": 0.05, "confidence": "LOW"}
|
| 53 |
+
ALT = {"vix_level": 14.5, "implied_vol_pct": 60.0}
|
| 54 |
+
LLM = {"signal": "HOLD", "confidence": 40}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def bars(symbol: str) -> dict:
|
| 58 |
+
out = {}
|
| 59 |
+
for tf, (period, interval) in TIMEFRAME_SPEC.items():
|
| 60 |
+
try:
|
| 61 |
+
df = yf.Ticker(symbol).history(period=period, interval=interval,
|
| 62 |
+
auto_adjust=False)
|
| 63 |
+
except Exception:
|
| 64 |
+
continue
|
| 65 |
+
if df is None or df.empty:
|
| 66 |
+
continue
|
| 67 |
+
if isinstance(df.columns, pd.MultiIndex):
|
| 68 |
+
df.columns = df.columns.get_level_values(0)
|
| 69 |
+
out[tf] = df[COLS].dropna()
|
| 70 |
+
return out
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def permute(df: pd.DataFrame, rng: np.random.Generator) -> pd.DataFrame:
|
| 74 |
+
"""Shuffle the return sequence, rescaling OHLC together so bars stay coherent."""
|
| 75 |
+
close = df["Close"].to_numpy(float)
|
| 76 |
+
rets = np.diff(close) / close[:-1]
|
| 77 |
+
rng.shuffle(rets)
|
| 78 |
+
rebuilt = np.empty_like(close)
|
| 79 |
+
rebuilt[0] = close[0]
|
| 80 |
+
rebuilt[1:] = close[0] * np.cumprod(1.0 + rets)
|
| 81 |
+
scale = rebuilt / close
|
| 82 |
+
out = df.copy()
|
| 83 |
+
for col in ("Open", "High", "Low", "Close"):
|
| 84 |
+
out[col] = df[col].to_numpy(float) * scale
|
| 85 |
+
return out
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def main() -> int:
|
| 89 |
+
eng = MTE.IntegratedMomentumEngine()
|
| 90 |
+
|
| 91 |
+
def sig(bar_set, tf):
|
| 92 |
+
m = eng.generate_enhanced_signal(bar_set, SENT, ALT, tf)
|
| 93 |
+
return _generate_master_signal(m, LLM, SENT, None, tf, "momentum", None)["signal"]
|
| 94 |
+
|
| 95 |
+
print("fetching...")
|
| 96 |
+
data = {s: b for s in BASKET if (b := bars(s))}
|
| 97 |
+
if not data:
|
| 98 |
+
print("no market data -- cannot calibrate")
|
| 99 |
+
return 1
|
| 100 |
+
n_real = len(data) * len(TFS)
|
| 101 |
+
print(f"{len(data)} symbols x {len(TFS)} timeframes; "
|
| 102 |
+
f"{n_real * REPS} permuted observations, {n_real} real\n")
|
| 103 |
+
|
| 104 |
+
print(f"{'dead':>6}{'agr':>6}{'FPR(perm)':>11}{'fire(real)':>12}{'lift':>7}")
|
| 105 |
+
print("-" * 42)
|
| 106 |
+
for dead, agr in GRID:
|
| 107 |
+
MTE.DIRECTION_DEADBAND, MTE.DIRECTION_AGREEMENT = dead, agr
|
| 108 |
+
fp = n = 0
|
| 109 |
+
for i, (sym, o) in enumerate(data.items()):
|
| 110 |
+
rng = np.random.default_rng(11 + i)
|
| 111 |
+
for _ in range(REPS):
|
| 112 |
+
pb = {k: permute(v, rng) for k, v in o.items()}
|
| 113 |
+
for tf in TFS:
|
| 114 |
+
n += 1
|
| 115 |
+
if sig(pb, tf) != "HOLD":
|
| 116 |
+
fp += 1
|
| 117 |
+
real = sum(1 for o in data.values() for tf in TFS if sig(o, tf) != "HOLD")
|
| 118 |
+
fpr, rr = fp / n * 100, real / n_real * 100
|
| 119 |
+
lift = rr / fpr if fpr else float("inf")
|
| 120 |
+
mark = " <-- in use" if (dead, agr) == IN_USE else ""
|
| 121 |
+
print(f"{dead:>6.2f}{agr:>6.2f}{fpr:>10.1f}%{rr:>11.1f}%{lift:>7.2f}{mark}")
|
| 122 |
+
|
| 123 |
+
print("\nLIFT IS THE COLUMN THAT MATTERS. Below 1.0 the engine fires more often on")
|
| 124 |
+
print("shuffled data than on real data, which is worse than useless. Restore the")
|
| 125 |
+
print("gates from this table -- never by eye -- and re-run after touching the")
|
| 126 |
+
print("direction score, the momentum magnitude, or the confidence weights.")
|
| 127 |
+
return 0
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
if __name__ == "__main__":
|
| 131 |
+
raise SystemExit(main())
|
|
@@ -15,7 +15,18 @@ import pytz
|
|
| 15 |
# .generate_enhanced_signal() looks up exactly those three, and enhanced_api hands
|
| 16 |
# ohlcv_data["daily"] to the TFT. yfinance caps intraday history: 15m is 60 days, 1h is
|
| 17 |
# 730 days, so those periods are the provider's limit, not a choice.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
TIMEFRAME_SPEC = {
|
|
|
|
| 19 |
"15m": ("60d", "15m"),
|
| 20 |
"hourly": ("180d", "1h"),
|
| 21 |
"daily": ("2y", "1d"),
|
|
|
|
| 15 |
# .generate_enhanced_signal() looks up exactly those three, and enhanced_api hands
|
| 16 |
# ohlcv_data["daily"] to the TFT. yfinance caps intraday history: 15m is 60 days, 1h is
|
| 17 |
# 730 days, so those periods are the provider's limit, not a choice.
|
| 18 |
+
# "5m" was added 2026-09-07 so that the UI's timeframe selector has distinct DATA to
|
| 19 |
+
# weight, not just distinct thresholds. Before that, 15m/hourly/daily were the only bars
|
| 20 |
+
# ever fetched and the momentum engine hardcoded its weights over them, so picking "1m"
|
| 21 |
+
# vs "1h" changed nothing about the analysis -- measured: 1 distinct outcome across all
|
| 22 |
+
# 12 timeframe x strategy combinations.
|
| 23 |
+
#
|
| 24 |
+
# yfinance caps 5m history at 60d and 1m at 7d. 7 days is too few bars for the 20-period
|
| 25 |
+
# indicators to warm up, so there is deliberately NO "1m" fetch: the UI's 1m setting is
|
| 26 |
+
# the most REACTIVE WEIGHTING over the 5m set, and the response says so in
|
| 27 |
+
# timeframe_weights rather than implying minute bars exist.
|
| 28 |
TIMEFRAME_SPEC = {
|
| 29 |
+
"5m": ("60d", "5m"),
|
| 30 |
"15m": ("60d", "15m"),
|
| 31 |
"hourly": ("180d", "1h"),
|
| 32 |
"daily": ("2y", "1d"),
|
|
@@ -192,8 +192,11 @@ async def predict_enhanced_signal(
|
|
| 192 |
)
|
| 193 |
|
| 194 |
# 2. Momentum Analysis
|
|
|
|
|
|
|
|
|
|
| 195 |
momentum_analysis = momentum_engine.generate_enhanced_signal(
|
| 196 |
-
ohlcv_data, sentiment_analysis, alt_data
|
| 197 |
)
|
| 198 |
|
| 199 |
# 3. TFT Prediction
|
|
@@ -240,10 +243,24 @@ async def predict_enhanced_signal(
|
|
| 240 |
}
|
| 241 |
llm_analysis = generate_enhanced_llm_signal(conditions)
|
| 242 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
# 5. Master Signal Generation - FIXED FUNCTION NAME
|
| 244 |
master_signal = _generate_master_signal(
|
| 245 |
momentum_analysis, llm_analysis, sentiment_analysis, tft_prediction,
|
| 246 |
-
timeframe, strategy_mode
|
| 247 |
)
|
| 248 |
|
| 249 |
# 6. Options Strategy - FIXED FUNCTION NAME
|
|
@@ -321,7 +338,8 @@ async def analyze_agent():
|
|
| 321 |
|
| 322 |
def _generate_master_signal(momentum_analysis: Dict, llm_analysis: Dict,
|
| 323 |
sentiment_analysis: Dict, tft_prediction: Dict,
|
| 324 |
-
timeframe: str = "15m", strategy_mode: str = "momentum"
|
|
|
|
| 325 |
"""Generate master trading signal from all analyses - FIXED VERSION"""
|
| 326 |
|
| 327 |
# Extract signals
|
|
@@ -337,28 +355,50 @@ def _generate_master_signal(momentum_analysis: Dict, llm_analysis: Dict,
|
|
| 337 |
tft_direction = tft_prediction.get("expected_direction", "FLAT") if tft_prediction else "FLAT"
|
| 338 |
|
| 339 |
# TIMEFRAME-SPECIFIC THRESHOLDS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
timeframe_configs = {
|
| 341 |
"1m": {
|
| 342 |
"threshold": 0.2,
|
| 343 |
-
"min_confidence":
|
| 344 |
"hold_time": "1-2 minutes",
|
| 345 |
"position_multiplier": 0.5
|
| 346 |
},
|
| 347 |
"5m": {
|
| 348 |
"threshold": 0.25,
|
| 349 |
-
"min_confidence":
|
| 350 |
"hold_time": "2-5 minutes",
|
| 351 |
"position_multiplier": 0.7
|
| 352 |
},
|
| 353 |
"15m": {
|
| 354 |
"threshold": 0.3,
|
| 355 |
-
"min_confidence":
|
| 356 |
"hold_time": "10-30 minutes",
|
| 357 |
"position_multiplier": 1.0
|
| 358 |
},
|
| 359 |
"1h": {
|
| 360 |
"threshold": 0.35,
|
| 361 |
-
"min_confidence":
|
| 362 |
"hold_time": "30-60 minutes",
|
| 363 |
"position_multiplier": 1.2
|
| 364 |
}
|
|
@@ -367,16 +407,35 @@ def _generate_master_signal(momentum_analysis: Dict, llm_analysis: Dict,
|
|
| 367 |
config = timeframe_configs.get(timeframe, timeframe_configs["15m"])
|
| 368 |
|
| 369 |
# STRATEGY MODE ADJUSTMENTS
|
|
|
|
| 370 |
if strategy_mode == "scalp":
|
| 371 |
config["threshold"] *= 0.8
|
| 372 |
config["hold_time"] = "1-3 minutes"
|
| 373 |
-
elif strategy_mode == "gap"
|
| 374 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
config["min_confidence"] -= 10
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
|
| 377 |
# Calculate weighted score
|
| 378 |
if momentum_strategy in ["AGGRESSIVE_SCALP", "STANDARD_MOMENTUM"]:
|
| 379 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
weighted_confidence = momentum_confidence
|
| 381 |
else:
|
| 382 |
# The TFT term was REMOVED from this weighting on 2026-09-06, after measuring it.
|
|
@@ -438,12 +497,24 @@ def _generate_master_signal(momentum_analysis: Dict, llm_analysis: Dict,
|
|
| 438 |
llm_conf = llm_analysis.get("conviction")
|
| 439 |
if llm_conf is None:
|
| 440 |
llm_conf = llm_analysis.get("confidence", 50)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
sent_conf = {"HIGH": 80, "MEDIUM": 40}.get(
|
| 442 |
-
sentiment_analysis.get("confidence", "LOW")
|
|
|
|
|
|
|
| 443 |
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
sent_conf * 0.3)
|
| 447 |
|
| 448 |
# Generate final signal
|
| 449 |
if weighted_score > config["threshold"] and weighted_confidence > config["min_confidence"]:
|
|
@@ -466,8 +537,10 @@ def _generate_master_signal(momentum_analysis: Dict, llm_analysis: Dict,
|
|
| 466 |
reasoning.append(f"Momentum: {momentum_strategy}")
|
| 467 |
if abs(sentiment_composite) > 0.3:
|
| 468 |
reasoning.append(f"Sentiment: {'Bullish' if sentiment_composite > 0 else 'Bearish'}")
|
| 469 |
-
if
|
| 470 |
-
reasoning.append(
|
|
|
|
|
|
|
| 471 |
|
| 472 |
return {
|
| 473 |
"signal": final_signal,
|
|
@@ -478,7 +551,8 @@ def _generate_master_signal(momentum_analysis: Dict, llm_analysis: Dict,
|
|
| 478 |
"hold_time": config["hold_time"],
|
| 479 |
"weighted_score": weighted_score,
|
| 480 |
"strategy_mode": strategy_mode,
|
| 481 |
-
"momentum_strategy": momentum_strategy
|
|
|
|
| 482 |
}
|
| 483 |
|
| 484 |
def _generate_options_strategy(master_signal: Dict, momentum_analysis: Dict,
|
|
|
|
| 192 |
)
|
| 193 |
|
| 194 |
# 2. Momentum Analysis
|
| 195 |
+
# `timeframe` is passed through so the user's selection actually reweights the
|
| 196 |
+
# bars (see TIMEFRAME_WEIGHTS in momentum_trading_engine). Until 2026-09-07 it
|
| 197 |
+
# stopped at enhanced_api and only moved a threshold, making the selector inert.
|
| 198 |
momentum_analysis = momentum_engine.generate_enhanced_signal(
|
| 199 |
+
ohlcv_data, sentiment_analysis, alt_data, timeframe
|
| 200 |
)
|
| 201 |
|
| 202 |
# 3. TFT Prediction
|
|
|
|
| 243 |
}
|
| 244 |
llm_analysis = generate_enhanced_llm_signal(conditions)
|
| 245 |
|
| 246 |
+
# 4b. Real overnight gap, for strategy_mode="gap".
|
| 247 |
+
# That mode used to be gated on tft_prediction["gap_probability"] > 70. The TFT
|
| 248 |
+
# returns ~67.1 for every symbol (measured spread 0.10 across six symbols), so the
|
| 249 |
+
# branch was UNREACHABLE and "gap" was a silent no-op -- one of the reasons all 12
|
| 250 |
+
# timeframe x strategy combinations produced an identical answer. The gap is a
|
| 251 |
+
# directly observable quantity, so it is now measured from the daily bars instead
|
| 252 |
+
# of being predicted by a model that has collapsed to its prior.
|
| 253 |
+
gap_pct = None
|
| 254 |
+
if daily_df is not None and len(daily_df) >= 2:
|
| 255 |
+
prev_close = float(daily_df["Close"].iloc[-2])
|
| 256 |
+
today_open = float(daily_df["Open"].iloc[-1])
|
| 257 |
+
if prev_close > 0:
|
| 258 |
+
gap_pct = (today_open - prev_close) / prev_close * 100.0
|
| 259 |
+
|
| 260 |
# 5. Master Signal Generation - FIXED FUNCTION NAME
|
| 261 |
master_signal = _generate_master_signal(
|
| 262 |
momentum_analysis, llm_analysis, sentiment_analysis, tft_prediction,
|
| 263 |
+
timeframe, strategy_mode, gap_pct
|
| 264 |
)
|
| 265 |
|
| 266 |
# 6. Options Strategy - FIXED FUNCTION NAME
|
|
|
|
| 338 |
|
| 339 |
def _generate_master_signal(momentum_analysis: Dict, llm_analysis: Dict,
|
| 340 |
sentiment_analysis: Dict, tft_prediction: Dict,
|
| 341 |
+
timeframe: str = "15m", strategy_mode: str = "momentum",
|
| 342 |
+
gap_pct: float = None) -> Dict:
|
| 343 |
"""Generate master trading signal from all analyses - FIXED VERSION"""
|
| 344 |
|
| 345 |
# Extract signals
|
|
|
|
| 355 |
tft_direction = tft_prediction.get("expected_direction", "FLAT") if tft_prediction else "FLAT"
|
| 356 |
|
| 357 |
# TIMEFRAME-SPECIFIC THRESHOLDS
|
| 358 |
+
#
|
| 359 |
+
# min_confidence was 70/65/60/55 and was UNREACHABLE. weighted_confidence is a weighted
|
| 360 |
+
# mean of three components whose own ceilings are far below 100 (the rule-based LLM
|
| 361 |
+
# fallback tops out at 65; sentiment abstains almost always), so the quantity being
|
| 362 |
+
# tested never approached the numbers it was tested against. Measured over 80
|
| 363 |
+
# observations -- 20 symbols x 4 timeframes, 2026-09-07, VIX ~14.5 -- the distribution
|
| 364 |
+
# was min 17 / p50 33 / p75 35 / p90 38 / MAX 47. The old vector therefore fired on
|
| 365 |
+
# 0 of 80. Not "strict": impossible. That single fact is why the app answered HOLD for
|
| 366 |
+
# every symbol, and why every timeframe and strategy looked identical.
|
| 367 |
+
#
|
| 368 |
+
# The replacement is each timeframe's gate placed at a percentile of that measured
|
| 369 |
+
# distribution -- roughly p90/p85/p75/p60 -- preserving the original ordering (a faster
|
| 370 |
+
# trade must clear a higher bar). It fires on 10% of the sample; HOLD stays the answer
|
| 371 |
+
# 90% of the time, which is the honest base rate for "is there a trade right now".
|
| 372 |
+
#
|
| 373 |
+
# This is CALIBRATION, NOT INFLATION: the score thresholds below are untouched, the
|
| 374 |
+
# component weights are untouched, and a gate that fires 10% of the time is still a
|
| 375 |
+
# demanding one. The distinction from cosmetically widening the spread is that the old
|
| 376 |
+
# value was not a strict threshold, it was a broken one.
|
| 377 |
+
#
|
| 378 |
+
# Re-measure with tools/calibrate_confidence_gate.py if the regime changes -- this
|
| 379 |
+
# sample is one quiet-tape snapshot, and a high-VIX tape will shift it.
|
| 380 |
timeframe_configs = {
|
| 381 |
"1m": {
|
| 382 |
"threshold": 0.2,
|
| 383 |
+
"min_confidence": 40,
|
| 384 |
"hold_time": "1-2 minutes",
|
| 385 |
"position_multiplier": 0.5
|
| 386 |
},
|
| 387 |
"5m": {
|
| 388 |
"threshold": 0.25,
|
| 389 |
+
"min_confidence": 38,
|
| 390 |
"hold_time": "2-5 minutes",
|
| 391 |
"position_multiplier": 0.7
|
| 392 |
},
|
| 393 |
"15m": {
|
| 394 |
"threshold": 0.3,
|
| 395 |
+
"min_confidence": 36,
|
| 396 |
"hold_time": "10-30 minutes",
|
| 397 |
"position_multiplier": 1.0
|
| 398 |
},
|
| 399 |
"1h": {
|
| 400 |
"threshold": 0.35,
|
| 401 |
+
"min_confidence": 34,
|
| 402 |
"hold_time": "30-60 minutes",
|
| 403 |
"position_multiplier": 1.2
|
| 404 |
}
|
|
|
|
| 407 |
config = timeframe_configs.get(timeframe, timeframe_configs["15m"])
|
| 408 |
|
| 409 |
# STRATEGY MODE ADJUSTMENTS
|
| 410 |
+
gap_note = None
|
| 411 |
if strategy_mode == "scalp":
|
| 412 |
config["threshold"] *= 0.8
|
| 413 |
config["hold_time"] = "1-3 minutes"
|
| 414 |
+
elif strategy_mode == "gap":
|
| 415 |
+
# Gated on a MEASURED overnight gap, not the TFT's gap_probability -- that value
|
| 416 |
+
# sits at ~67.1 for every symbol and never cleared its own >70 test, so this
|
| 417 |
+
# branch never once executed. 0.5% is the floor for "a gap rather than noise".
|
| 418 |
+
# When no gap qualifies the mode now SAYS SO in the reasoning; the old failure was
|
| 419 |
+
# that it did nothing silently, which is indistinguishable from working.
|
| 420 |
+
if gap_pct is None:
|
| 421 |
+
gap_note = "Gap mode: no daily history to measure a gap"
|
| 422 |
+
elif abs(gap_pct) >= 0.5:
|
| 423 |
config["min_confidence"] -= 10
|
| 424 |
+
config["hold_time"] = "Gap fill / continuation"
|
| 425 |
+
gap_note = f"Gap mode: {gap_pct:+.2f}% overnight gap qualifies"
|
| 426 |
+
else:
|
| 427 |
+
gap_note = f"Gap mode: {gap_pct:+.2f}% overnight, below the 0.5% floor"
|
| 428 |
|
| 429 |
# Calculate weighted score
|
| 430 |
if momentum_strategy in ["AGGRESSIVE_SCALP", "STANDARD_MOMENTUM"]:
|
| 431 |
+
# conviction is a MAGNITUDE and is never negative, so this branch used to make
|
| 432 |
+
# `weighted_score < -threshold` unreachable: it could emit CALLS or HOLD and never
|
| 433 |
+
# PUTS, for any input. The engine now carries a signed direction, and the sign is
|
| 434 |
+
# applied here. A strong move with no agreed direction scores 0 and falls to HOLD,
|
| 435 |
+
# which is the honest reading of "something is happening but not which way".
|
| 436 |
+
_dir = momentum_master.get("direction", "NEUTRAL")
|
| 437 |
+
_sign = 1.0 if _dir == "BULLISH" else -1.0 if _dir == "BEARISH" else 0.0
|
| 438 |
+
weighted_score = momentum_master.get("conviction", 0) * _sign
|
| 439 |
weighted_confidence = momentum_confidence
|
| 440 |
else:
|
| 441 |
# The TFT term was REMOVED from this weighting on 2026-09-06, after measuring it.
|
|
|
|
| 497 |
llm_conf = llm_analysis.get("conviction")
|
| 498 |
if llm_conf is None:
|
| 499 |
llm_conf = llm_analysis.get("confidence", 50)
|
| 500 |
+
|
| 501 |
+
# A component with NO READING is now EXCLUDED and the remaining weights are
|
| 502 |
+
# renormalised -- it is not scored as "0% confident".
|
| 503 |
+
#
|
| 504 |
+
# sentiment confidence is LOW unless std_dev < 0.2 AND mean_abs > 0.3, which
|
| 505 |
+
# headline sentiment essentially never satisfies. Averaging that in as a zero
|
| 506 |
+
# spent 30% of the confidence budget on a permanent 0, capping weighted_confidence
|
| 507 |
+
# at 70 even for a flawless setup and pinning the observed output in a 23-27 band.
|
| 508 |
+
# This is the same correction already applied to the TFT weights above: a term
|
| 509 |
+
# that has nothing to say should abstain, not vote zero.
|
| 510 |
+
components = [(momentum_confidence, 0.4), (llm_conf, 0.3)]
|
| 511 |
sent_conf = {"HIGH": 80, "MEDIUM": 40}.get(
|
| 512 |
+
sentiment_analysis.get("confidence", "LOW"))
|
| 513 |
+
if sent_conf is not None:
|
| 514 |
+
components.append((sent_conf, 0.3))
|
| 515 |
|
| 516 |
+
_wsum = sum(w for _, w in components)
|
| 517 |
+
weighted_confidence = sum(v * w for v, w in components) / _wsum
|
|
|
|
| 518 |
|
| 519 |
# Generate final signal
|
| 520 |
if weighted_score > config["threshold"] and weighted_confidence > config["min_confidence"]:
|
|
|
|
| 537 |
reasoning.append(f"Momentum: {momentum_strategy}")
|
| 538 |
if abs(sentiment_composite) > 0.3:
|
| 539 |
reasoning.append(f"Sentiment: {'Bullish' if sentiment_composite > 0 else 'Bearish'}")
|
| 540 |
+
if gap_note:
|
| 541 |
+
reasoning.append(gap_note)
|
| 542 |
+
elif gap_pct is not None and abs(gap_pct) >= 0.5:
|
| 543 |
+
reasoning.append(f"Overnight gap {gap_pct:+.2f}%")
|
| 544 |
|
| 545 |
return {
|
| 546 |
"signal": final_signal,
|
|
|
|
| 551 |
"hold_time": config["hold_time"],
|
| 552 |
"weighted_score": weighted_score,
|
| 553 |
"strategy_mode": strategy_mode,
|
| 554 |
+
"momentum_strategy": momentum_strategy,
|
| 555 |
+
"overnight_gap_pct": gap_pct
|
| 556 |
}
|
| 557 |
|
| 558 |
def _generate_options_strategy(master_signal: Dict, momentum_analysis: Dict,
|
|
@@ -10,6 +10,40 @@ import asyncio
|
|
| 10 |
import warnings
|
| 11 |
warnings.filterwarnings("ignore")
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
class MomentumTradingEngine:
|
| 14 |
"""
|
| 15 |
High-frequency momentum trading engine for options
|
|
@@ -57,13 +91,17 @@ class MomentumTradingEngine:
|
|
| 57 |
}
|
| 58 |
|
| 59 |
def analyze_momentum_setup(self, ohlcv_data: Dict, sentiment_data: Dict,
|
| 60 |
-
alternative_data: Dict) -> Dict:
|
| 61 |
"""
|
| 62 |
Analyze current momentum setup across multiple timeframes
|
| 63 |
"""
|
| 64 |
setups = {}
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
if df.empty:
|
| 68 |
continue
|
| 69 |
|
|
@@ -81,16 +119,17 @@ class MomentumTradingEngine:
|
|
| 81 |
momentum_indicators, volume_profile, volatility_conditions, sentiment_data
|
| 82 |
)
|
| 83 |
|
| 84 |
-
setups[
|
| 85 |
'momentum_score': momentum_score,
|
|
|
|
| 86 |
'indicators': momentum_indicators,
|
| 87 |
'volume_profile': volume_profile,
|
| 88 |
'volatility': volatility_conditions,
|
| 89 |
-
'trade_signal': self._generate_trade_signal(momentum_score,
|
| 90 |
}
|
| 91 |
|
| 92 |
# Generate master signal from all timeframes
|
| 93 |
-
master_signal = self._generate_master_signal(setups, sentiment_data, alternative_data)
|
| 94 |
|
| 95 |
return {
|
| 96 |
'timeframe_setups': setups,
|
|
@@ -141,6 +180,7 @@ class MomentumTradingEngine:
|
|
| 141 |
return {
|
| 142 |
'price_change_1min': price_change_1,
|
| 143 |
'price_change_5min': price_change_5,
|
|
|
|
| 144 |
'velocity_1min': velocity_1min,
|
| 145 |
'velocity_5min': velocity_5min,
|
| 146 |
'momentum_strength': momentum_strength,
|
|
@@ -231,6 +271,30 @@ class MomentumTradingEngine:
|
|
| 231 |
'volatility_trend': vol_trend
|
| 232 |
}
|
| 233 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
def _calculate_momentum_score(self, momentum_indicators: Dict, volume_profile: Dict,
|
| 235 |
volatility_conditions: Dict, sentiment_data: Dict) -> float:
|
| 236 |
"""Calculate composite momentum score"""
|
|
@@ -314,30 +378,70 @@ class MomentumTradingEngine:
|
|
| 314 |
}
|
| 315 |
|
| 316 |
def _generate_master_signal(self, setups: Dict, sentiment_data: Dict,
|
| 317 |
-
alternative_data: Dict) -> Dict:
|
| 318 |
"""Generate master trading signal from all timeframes"""
|
| 319 |
|
| 320 |
if not setups:
|
| 321 |
-
return {'signal': 'NO_SIGNAL', 'conviction': 0, 'strategy': 'WAIT'
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
}
|
|
|
|
| 329 |
|
| 330 |
weighted_momentum = 0
|
|
|
|
| 331 |
total_weight = 0
|
| 332 |
|
| 333 |
-
for
|
| 334 |
-
weight = timeframe_weights.get(
|
| 335 |
momentum_score = setup.get('momentum_score', 0)
|
| 336 |
|
| 337 |
weighted_momentum += momentum_score * weight
|
|
|
|
| 338 |
total_weight += weight
|
| 339 |
|
| 340 |
avg_momentum = weighted_momentum / total_weight if total_weight > 0 else 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
|
| 342 |
# Apply sentiment and alternative data modifiers
|
| 343 |
sentiment_boost = abs(sentiment_data.get('composite_score', 0)) * 0.2
|
|
@@ -349,6 +453,9 @@ class MomentumTradingEngine:
|
|
| 349 |
if final_momentum > 0.6:
|
| 350 |
return {
|
| 351 |
'signal': 'STRONG_MOMENTUM',
|
|
|
|
|
|
|
|
|
|
| 352 |
'conviction': min(final_momentum, 1.0),
|
| 353 |
'strategy': 'AGGRESSIVE_SCALP',
|
| 354 |
'timeframe': '5m',
|
|
@@ -356,7 +463,10 @@ class MomentumTradingEngine:
|
|
| 356 |
}
|
| 357 |
elif final_momentum > 0.4:
|
| 358 |
return {
|
| 359 |
-
'signal': 'MODERATE_MOMENTUM',
|
|
|
|
|
|
|
|
|
|
| 360 |
'conviction': final_momentum,
|
| 361 |
'strategy': 'STANDARD_MOMENTUM',
|
| 362 |
'timeframe': '15m',
|
|
@@ -365,6 +475,9 @@ class MomentumTradingEngine:
|
|
| 365 |
elif final_momentum > 0.25:
|
| 366 |
return {
|
| 367 |
'signal': 'WEAK_MOMENTUM',
|
|
|
|
|
|
|
|
|
|
| 368 |
'conviction': final_momentum,
|
| 369 |
'strategy': 'CAUTIOUS_ENTRY',
|
| 370 |
'timeframe': '30m',
|
|
@@ -373,6 +486,9 @@ class MomentumTradingEngine:
|
|
| 373 |
else:
|
| 374 |
return {
|
| 375 |
'signal': 'NO_MOMENTUM',
|
|
|
|
|
|
|
|
|
|
| 376 |
'conviction': 0,
|
| 377 |
'strategy': 'WAIT',
|
| 378 |
'timeframe': 'N/A',
|
|
@@ -530,20 +646,20 @@ class IntegratedMomentumEngine:
|
|
| 530 |
self.momentum_engine = MomentumTradingEngine()
|
| 531 |
|
| 532 |
def generate_enhanced_signal(self, market_data: Dict, sentiment_data: Dict,
|
| 533 |
-
alternative_data: Dict) -> Dict:
|
| 534 |
"""
|
| 535 |
Generate enhanced trading signal using momentum analysis
|
| 536 |
"""
|
| 537 |
|
| 538 |
# Extract OHLCV data
|
| 539 |
ohlcv_data = {}
|
| 540 |
-
for
|
| 541 |
-
if
|
| 542 |
-
ohlcv_data[
|
| 543 |
|
| 544 |
# Run momentum analysis
|
| 545 |
momentum_analysis = self.momentum_engine.analyze_momentum_setup(
|
| 546 |
-
ohlcv_data, sentiment_data, alternative_data
|
| 547 |
)
|
| 548 |
|
| 549 |
# Generate options strategy
|
|
@@ -562,7 +678,8 @@ class IntegratedMomentumEngine:
|
|
| 562 |
master_signal = momentum_analysis['master_signal']
|
| 563 |
|
| 564 |
return {
|
| 565 |
-
'signal': self._convert_signal_format(master_signal['signal']
|
|
|
|
| 566 |
'confidence': int(master_signal['conviction'] * 100),
|
| 567 |
'reasoning': self._generate_reasoning(momentum_analysis),
|
| 568 |
'position_size': master_signal['conviction'] * 0.5, # Max 50% allocation
|
|
@@ -579,12 +696,20 @@ class IntegratedMomentumEngine:
|
|
| 579 |
return float(ohlcv_data[timeframe]['Close'].iloc[-1])
|
| 580 |
return 400.0 # Default for QQQ
|
| 581 |
|
| 582 |
-
def _convert_signal_format(self, momentum_signal: str) -> str:
|
| 583 |
-
"""Convert momentum signal to existing system format
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 588 |
|
| 589 |
def _generate_reasoning(self, momentum_analysis: Dict) -> str:
|
| 590 |
"""Generate human-readable reasoning"""
|
|
|
|
| 10 |
import warnings
|
| 11 |
warnings.filterwarnings("ignore")
|
| 12 |
|
| 13 |
+
# Direction gates, set by PERMUTATION TEST on real bars, not by eye.
|
| 14 |
+
# Reproduce with tools/calibrate_direction_gate.py.
|
| 15 |
+
#
|
| 16 |
+
# Shuffling a real series' returns destroys any persistent trend while keeping its volume
|
| 17 |
+
# profile, volatility and bar geometry -- so whatever the engine emits on the shuffled
|
| 18 |
+
# version is a false positive, and real-fire-rate / permuted-fire-rate is its lift over
|
| 19 |
+
# noise. 20 symbols x 4 timeframes, 6 permutations each (480 noise observations):
|
| 20 |
+
#
|
| 21 |
+
# dead agr FPR(perm) fire(real) lift
|
| 22 |
+
# 0.15 0.60 14.4% 10.0% 0.70 <- the ORIGINAL behaviour
|
| 23 |
+
# 0.25 0.60 10.8% 8.8% 0.81
|
| 24 |
+
# 0.35 0.60 7.3% 8.8% 1.20
|
| 25 |
+
# 0.40 0.60 5.2% 8.8% 1.68
|
| 26 |
+
# > 0.40 0.75 4.8% 8.8% 1.83 <- chosen
|
| 27 |
+
# 0.45 0.75 2.5% 6.2% 2.50
|
| 28 |
+
# 0.50 0.75 1.2% 3.8% 3.00
|
| 29 |
+
#
|
| 30 |
+
# READ THE FIRST ROW. With a +-0.15 deadband and no agreement requirement -- which is what
|
| 31 |
+
# this engine effectively had, since direction was not consulted at all -- it fires MORE
|
| 32 |
+
# often on shuffled data than on real data. A lift below 1.0 is worse than useless: it is
|
| 33 |
+
# an engine that prefers noise. Lift only crosses 1.0 around a 0.35 deadband.
|
| 34 |
+
#
|
| 35 |
+
# 0.40/0.75 is chosen as the point where lift is meaningfully above 1 while the signal
|
| 36 |
+
# still fires often enough to be worth showing. Tightening to 0.50 does reach 3.0x lift,
|
| 37 |
+
# but on 3.8% of 80 real observations that is three firings, and a lift estimated from
|
| 38 |
+
# three events is not a measurement.
|
| 39 |
+
#
|
| 40 |
+
# HONEST LIMITATION: 1.83x over shuffled noise is a MODEST edge, and this sample is one
|
| 41 |
+
# quiet-tape snapshot with n=80 on the real side. It is enough to say the engine is not
|
| 42 |
+
# reading noise; it is NOT enough to claim a tradeable edge, and nothing here has been
|
| 43 |
+
# tested against forward returns. Do not present it as more than it is.
|
| 44 |
+
DIRECTION_DEADBAND = 0.40
|
| 45 |
+
DIRECTION_AGREEMENT = 0.75
|
| 46 |
+
|
| 47 |
class MomentumTradingEngine:
|
| 48 |
"""
|
| 49 |
High-frequency momentum trading engine for options
|
|
|
|
| 91 |
}
|
| 92 |
|
| 93 |
def analyze_momentum_setup(self, ohlcv_data: Dict, sentiment_data: Dict,
|
| 94 |
+
alternative_data: Dict, timeframe: str = '15m') -> Dict:
|
| 95 |
"""
|
| 96 |
Analyze current momentum setup across multiple timeframes
|
| 97 |
"""
|
| 98 |
setups = {}
|
| 99 |
+
|
| 100 |
+
# NOTE: the loop variable is `tf_key`, NOT `timeframe`. It used to be `timeframe`,
|
| 101 |
+
# which silently shadowed the parameter of the same name -- the master signal would
|
| 102 |
+
# then have been weighted by whatever key happened to be LAST in ohlcv_data rather
|
| 103 |
+
# than by the user's selection.
|
| 104 |
+
for tf_key, df in ohlcv_data.items():
|
| 105 |
if df.empty:
|
| 106 |
continue
|
| 107 |
|
|
|
|
| 119 |
momentum_indicators, volume_profile, volatility_conditions, sentiment_data
|
| 120 |
)
|
| 121 |
|
| 122 |
+
setups[tf_key] = {
|
| 123 |
'momentum_score': momentum_score,
|
| 124 |
+
'direction_score': self._calculate_direction_score(momentum_indicators),
|
| 125 |
'indicators': momentum_indicators,
|
| 126 |
'volume_profile': volume_profile,
|
| 127 |
'volatility': volatility_conditions,
|
| 128 |
+
'trade_signal': self._generate_trade_signal(momentum_score, tf_key)
|
| 129 |
}
|
| 130 |
|
| 131 |
# Generate master signal from all timeframes
|
| 132 |
+
master_signal = self._generate_master_signal(setups, sentiment_data, alternative_data, timeframe)
|
| 133 |
|
| 134 |
return {
|
| 135 |
'timeframe_setups': setups,
|
|
|
|
| 180 |
return {
|
| 181 |
'price_change_1min': price_change_1,
|
| 182 |
'price_change_5min': price_change_5,
|
| 183 |
+
'price_change_10min': price_change_10,
|
| 184 |
'velocity_1min': velocity_1min,
|
| 185 |
'velocity_5min': velocity_5min,
|
| 186 |
'momentum_strength': momentum_strength,
|
|
|
|
| 271 |
'volatility_trend': vol_trend
|
| 272 |
}
|
| 273 |
|
| 274 |
+
def _calculate_direction_score(self, momentum_indicators: Dict) -> float:
|
| 275 |
+
"""Signed direction in [-1, +1]. Negative is bearish.
|
| 276 |
+
|
| 277 |
+
_calculate_momentum_score is a pure MAGNITUDE -- every one of its terms is either
|
| 278 |
+
abs()-wrapped (directional_bias, acceleration, sentiment) or non-negative by
|
| 279 |
+
construction (velocity, volume ratio, volatility ratio). It answers "how much is
|
| 280 |
+
happening", never "which way". Because of that, _convert_signal_format below used
|
| 281 |
+
to hardcode `return 'CALLS'` for any momentum signal, with the comment "momentum
|
| 282 |
+
typically bullish for options": a stock breaking down hard scored HIGH momentum and
|
| 283 |
+
was reported as a CALLS setup. In enhanced_api the same defect made
|
| 284 |
+
`weighted_score = conviction` unconditionally positive, so `weighted_score <
|
| 285 |
+
-threshold` was unreachable and PUTS could never be emitted by that branch at all.
|
| 286 |
+
|
| 287 |
+
The direction was always available and simply thrown away -- directional_bias is
|
| 288 |
+
already normalised to [-1, +1], and the 5/10-bar price changes are signed. This
|
| 289 |
+
recovers it. The price-change terms saturate at +-2% because on intraday bars a 2%
|
| 290 |
+
drift is already a decisive move; without a cap a single gap would dominate.
|
| 291 |
+
"""
|
| 292 |
+
bias = momentum_indicators.get('directional_bias', 0.0)
|
| 293 |
+
chg5 = momentum_indicators.get('price_change_5min', 0.0)
|
| 294 |
+
chg10 = momentum_indicators.get('price_change_10min', chg5)
|
| 295 |
+
sat = lambda x: max(-1.0, min(1.0, x * 50.0)) # +-2% -> +-1.0
|
| 296 |
+
return max(-1.0, min(1.0, bias * 0.5 + sat(chg5) * 0.3 + sat(chg10) * 0.2))
|
| 297 |
+
|
| 298 |
def _calculate_momentum_score(self, momentum_indicators: Dict, volume_profile: Dict,
|
| 299 |
volatility_conditions: Dict, sentiment_data: Dict) -> float:
|
| 300 |
"""Calculate composite momentum score"""
|
|
|
|
| 378 |
}
|
| 379 |
|
| 380 |
def _generate_master_signal(self, setups: Dict, sentiment_data: Dict,
|
| 381 |
+
alternative_data: Dict, timeframe: str = '15m') -> Dict:
|
| 382 |
"""Generate master trading signal from all timeframes"""
|
| 383 |
|
| 384 |
if not setups:
|
| 385 |
+
return {'signal': 'NO_SIGNAL', 'conviction': 0, 'strategy': 'WAIT',
|
| 386 |
+
'direction': 'NEUTRAL', 'direction_score': 0.0}
|
| 387 |
+
|
| 388 |
+
# These weights used to be hardcoded to {15m:.5, hourly:.3, daily:.2} and the
|
| 389 |
+
# user's selected timeframe never reached this function -- it only picked a
|
| 390 |
+
# threshold in enhanced_api. Every timeframe therefore analysed identical data and
|
| 391 |
+
# the selector was decorative. It now chooses which bars dominate the score.
|
| 392 |
+
#
|
| 393 |
+
# Each row sums to 1.0 and slides emphasis from the fastest available bars to the
|
| 394 |
+
# slowest. "1m" is an emphasis setting over 5m bars, not minute data -- see the
|
| 395 |
+
# note on TIMEFRAME_SPEC in data.py for why minute bars are not fetched.
|
| 396 |
+
TIMEFRAME_WEIGHTS = {
|
| 397 |
+
'1m': {'5m': 0.60, '15m': 0.25, 'hourly': 0.10, 'daily': 0.05},
|
| 398 |
+
'5m': {'5m': 0.45, '15m': 0.35, 'hourly': 0.15, 'daily': 0.05},
|
| 399 |
+
'15m': {'5m': 0.15, '15m': 0.45, 'hourly': 0.25, 'daily': 0.15},
|
| 400 |
+
'1h': {'5m': 0.05, '15m': 0.20, 'hourly': 0.45, 'daily': 0.30},
|
| 401 |
}
|
| 402 |
+
timeframe_weights = TIMEFRAME_WEIGHTS.get(timeframe, TIMEFRAME_WEIGHTS['15m'])
|
| 403 |
|
| 404 |
weighted_momentum = 0
|
| 405 |
+
weighted_direction = 0
|
| 406 |
total_weight = 0
|
| 407 |
|
| 408 |
+
for tf_key, setup in setups.items():
|
| 409 |
+
weight = timeframe_weights.get(tf_key, 0.1)
|
| 410 |
momentum_score = setup.get('momentum_score', 0)
|
| 411 |
|
| 412 |
weighted_momentum += momentum_score * weight
|
| 413 |
+
weighted_direction += setup.get('direction_score', 0.0) * weight
|
| 414 |
total_weight += weight
|
| 415 |
|
| 416 |
avg_momentum = weighted_momentum / total_weight if total_weight > 0 else 0
|
| 417 |
+
avg_direction = weighted_direction / total_weight if total_weight > 0 else 0
|
| 418 |
+
|
| 419 |
+
# Direction is carried alongside magnitude rather than collapsed into it, and it
|
| 420 |
+
# must clear TWO tests, not one.
|
| 421 |
+
#
|
| 422 |
+
# A deadband alone is not enough. directional_bias is measured over ~10 bars, so on
|
| 423 |
+
# a random walk its standard error is roughly 1/sqrt(10) = 0.32 -- a +-0.15 band is
|
| 424 |
+
# INSIDE the noise and gets cleared by chance more often than not. Measured on 600
|
| 425 |
+
# pure random walks with zero drift, deadband-only fired on 31.5% of them. A third
|
| 426 |
+
# of coin flips is not a signal.
|
| 427 |
+
#
|
| 428 |
+
# So the timeframes must also AGREE. Agreement is weighted by the same vector the
|
| 429 |
+
# user's timeframe selection chose, which is the point: picking "1m" means the fast
|
| 430 |
+
# bars carry the vote, and a 5m move that the hourly and daily contradict is a real
|
| 431 |
+
# disagreement rather than something to average away. Requiring 0.60 of the weight
|
| 432 |
+
# on one side is the classic multi-timeframe confirmation, and it is what separates
|
| 433 |
+
# a trend from a wiggle.
|
| 434 |
+
agree_weight = 0.0
|
| 435 |
+
for tf_key, setup in setups.items():
|
| 436 |
+
ds = setup.get('direction_score', 0.0)
|
| 437 |
+
if ds != 0 and avg_direction != 0 and (ds > 0) == (avg_direction > 0):
|
| 438 |
+
agree_weight += timeframe_weights.get(tf_key, 0.1)
|
| 439 |
+
agreement = agree_weight / total_weight if total_weight > 0 else 0.0
|
| 440 |
+
|
| 441 |
+
if abs(avg_direction) > DIRECTION_DEADBAND and agreement >= DIRECTION_AGREEMENT:
|
| 442 |
+
direction = 'BULLISH' if avg_direction > 0 else 'BEARISH'
|
| 443 |
+
else:
|
| 444 |
+
direction = 'NEUTRAL'
|
| 445 |
|
| 446 |
# Apply sentiment and alternative data modifiers
|
| 447 |
sentiment_boost = abs(sentiment_data.get('composite_score', 0)) * 0.2
|
|
|
|
| 453 |
if final_momentum > 0.6:
|
| 454 |
return {
|
| 455 |
'signal': 'STRONG_MOMENTUM',
|
| 456 |
+
'direction': direction,
|
| 457 |
+
'direction_score': avg_direction,
|
| 458 |
+
'direction_agreement': agreement,
|
| 459 |
'conviction': min(final_momentum, 1.0),
|
| 460 |
'strategy': 'AGGRESSIVE_SCALP',
|
| 461 |
'timeframe': '5m',
|
|
|
|
| 463 |
}
|
| 464 |
elif final_momentum > 0.4:
|
| 465 |
return {
|
| 466 |
+
'signal': 'MODERATE_MOMENTUM',
|
| 467 |
+
'direction': direction,
|
| 468 |
+
'direction_score': avg_direction,
|
| 469 |
+
'direction_agreement': agreement,
|
| 470 |
'conviction': final_momentum,
|
| 471 |
'strategy': 'STANDARD_MOMENTUM',
|
| 472 |
'timeframe': '15m',
|
|
|
|
| 475 |
elif final_momentum > 0.25:
|
| 476 |
return {
|
| 477 |
'signal': 'WEAK_MOMENTUM',
|
| 478 |
+
'direction': direction,
|
| 479 |
+
'direction_score': avg_direction,
|
| 480 |
+
'direction_agreement': agreement,
|
| 481 |
'conviction': final_momentum,
|
| 482 |
'strategy': 'CAUTIOUS_ENTRY',
|
| 483 |
'timeframe': '30m',
|
|
|
|
| 486 |
else:
|
| 487 |
return {
|
| 488 |
'signal': 'NO_MOMENTUM',
|
| 489 |
+
'direction': direction,
|
| 490 |
+
'direction_score': avg_direction,
|
| 491 |
+
'direction_agreement': agreement,
|
| 492 |
'conviction': 0,
|
| 493 |
'strategy': 'WAIT',
|
| 494 |
'timeframe': 'N/A',
|
|
|
|
| 646 |
self.momentum_engine = MomentumTradingEngine()
|
| 647 |
|
| 648 |
def generate_enhanced_signal(self, market_data: Dict, sentiment_data: Dict,
|
| 649 |
+
alternative_data: Dict, timeframe: str = '15m') -> Dict:
|
| 650 |
"""
|
| 651 |
Generate enhanced trading signal using momentum analysis
|
| 652 |
"""
|
| 653 |
|
| 654 |
# Extract OHLCV data
|
| 655 |
ohlcv_data = {}
|
| 656 |
+
for tf_name in ['5m', '15m', 'hourly', 'daily']:
|
| 657 |
+
if tf_name in market_data:
|
| 658 |
+
ohlcv_data[tf_name] = market_data[tf_name]
|
| 659 |
|
| 660 |
# Run momentum analysis
|
| 661 |
momentum_analysis = self.momentum_engine.analyze_momentum_setup(
|
| 662 |
+
ohlcv_data, sentiment_data, alternative_data, timeframe
|
| 663 |
)
|
| 664 |
|
| 665 |
# Generate options strategy
|
|
|
|
| 678 |
master_signal = momentum_analysis['master_signal']
|
| 679 |
|
| 680 |
return {
|
| 681 |
+
'signal': self._convert_signal_format(master_signal['signal'],
|
| 682 |
+
master_signal.get('direction', 'NEUTRAL')),
|
| 683 |
'confidence': int(master_signal['conviction'] * 100),
|
| 684 |
'reasoning': self._generate_reasoning(momentum_analysis),
|
| 685 |
'position_size': master_signal['conviction'] * 0.5, # Max 50% allocation
|
|
|
|
| 696 |
return float(ohlcv_data[timeframe]['Close'].iloc[-1])
|
| 697 |
return 400.0 # Default for QQQ
|
| 698 |
|
| 699 |
+
def _convert_signal_format(self, momentum_signal: str, direction: str = 'NEUTRAL') -> str:
|
| 700 |
+
"""Convert momentum signal to existing system format.
|
| 701 |
+
|
| 702 |
+
This used to be `if 'MOMENTUM' in momentum_signal: return 'CALLS'` -- the momentum
|
| 703 |
+
score is unsigned, so a violent SELL-off returned a CALLS setup. The engine now
|
| 704 |
+
carries a direction alongside the magnitude and this respects it; a strong move
|
| 705 |
+
with no agreed direction is HOLD rather than a coin-flip long.
|
| 706 |
+
"""
|
| 707 |
+
if 'MOMENTUM' in momentum_signal and momentum_signal != 'NO_MOMENTUM':
|
| 708 |
+
if direction == 'BULLISH':
|
| 709 |
+
return 'CALLS'
|
| 710 |
+
if direction == 'BEARISH':
|
| 711 |
+
return 'PUTS'
|
| 712 |
+
return 'HOLD'
|
| 713 |
|
| 714 |
def _generate_reasoning(self, momentum_analysis: Dict) -> str:
|
| 715 |
"""Generate human-readable reasoning"""
|