ckharche commited on
Commit
3ebcf0e
·
verified ·
1 Parent(s): 6bca33e

Make the signal reachable, directional, and timeframe-aware

Browse files

The 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 ADDED
@@ -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.")
tools/calibrate_confidence_gate.py ADDED
@@ -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())
tools/calibrate_direction_gate.py ADDED
@@ -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())
trade_analysis/data.py CHANGED
@@ -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"),
trade_analysis/enhanced_api.py CHANGED
@@ -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") -> Dict:
 
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": 70,
344
  "hold_time": "1-2 minutes",
345
  "position_multiplier": 0.5
346
  },
347
  "5m": {
348
  "threshold": 0.25,
349
- "min_confidence": 65,
350
  "hold_time": "2-5 minutes",
351
  "position_multiplier": 0.7
352
  },
353
  "15m": {
354
  "threshold": 0.3,
355
- "min_confidence": 60,
356
  "hold_time": "10-30 minutes",
357
  "position_multiplier": 1.0
358
  },
359
  "1h": {
360
  "threshold": 0.35,
361
- "min_confidence": 55,
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" and tft_prediction:
374
- if tft_direction != "FLAT" and tft_prediction.get("gap_probability", 50) > 70:
 
 
 
 
 
 
 
375
  config["min_confidence"] -= 10
 
 
 
 
376
 
377
  # Calculate weighted score
378
  if momentum_strategy in ["AGGRESSIVE_SCALP", "STANDARD_MOMENTUM"]:
379
- weighted_score = momentum_master.get("conviction", 0)
 
 
 
 
 
 
 
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"), 0)
 
 
443
 
444
- weighted_confidence = (momentum_confidence * 0.4 +
445
- llm_conf * 0.3 +
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 tft_prediction and tft_prediction.get("gap_probability", 50) > 70:
470
- reasoning.append(f"Gap probability: {tft_prediction['gap_probability']:.0f}%")
 
 
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,
trade_analysis/momentum_trading_engine.py CHANGED
@@ -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
- for timeframe, df in ohlcv_data.items():
 
 
 
 
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[timeframe] = {
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, timeframe)
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
- # Weight timeframes by importance for momentum trading
324
- timeframe_weights = {
325
- '15m': 0.5, # Primary for scalping
326
- 'hourly': 0.3, # Secondary confirmation
327
- 'daily': 0.2 # Trend context
 
 
 
 
 
 
 
 
 
328
  }
 
329
 
330
  weighted_momentum = 0
 
331
  total_weight = 0
332
 
333
- for timeframe, setup in setups.items():
334
- weight = timeframe_weights.get(timeframe, 0.1)
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 timeframe in ['15m', 'hourly', 'daily']:
541
- if timeframe in market_data:
542
- ohlcv_data[timeframe] = market_data[timeframe]
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
- if 'MOMENTUM' in momentum_signal:
585
- return 'CALLS' # Momentum typically bullish for options
586
- else:
587
- return 'HOLD'
 
 
 
 
 
 
 
 
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"""