3VVM commited on
Commit
abdddf3
·
verified ·
1 Parent(s): 2acb65e

Delete files * with huggingface_hub

Browse files
MERGE_NOTES.md DELETED
@@ -1,115 +0,0 @@
1
- # Merge notes — MAYTHOS engine integrated into Rinking_out
2
-
3
- This project is the result of merging the standalone `maythos_patched.py` decision
4
- engine into the Rinking_out forecasting dashboard. Rinking_out is the base project;
5
- MAYTHOS was refactored to live inside it as a first-class model. There is **one**
6
- project, one live signal generator, one backtester, and one model contract — no
7
- bridges, adapters, wrappers, or duplicate systems.
8
-
9
- ## What MAYTHOS is vs. what Rinking_out was
10
-
11
- - **Rinking_out** forecasts the next *close price* with statistical / foundation
12
- models (ARIMA, Auto-ARIMA, ARIMA-GARCH, Moirai, TimesFM). Every model returns a
13
- numeric forecast series; direction is derived by comparing the forecast to the
14
- last close.
15
- - **MAYTHOS** is a tick-driven decision engine. It ingests OHLCV candles one at a
16
- time, maintains internal indicator/regime state, and emits a discrete
17
- **BUY/SELL** decision with a confidence and rich market-state metadata. It does
18
- **not** predict a price level.
19
-
20
- These are genuinely different output shapes, which is the central conflict the
21
- merge had to resolve (Rule 13).
22
-
23
- ## Decision 1 — One unified model contract: `Signal` + `predict_signal`
24
-
25
- The two projects had conflicting model interfaces: Rinking_out models exposed
26
- `predict(history, horizon, features) -> list[float]`; MAYTHOS exposed
27
- `tick(candle) -> dict`. Gluing them with an adapter was disallowed and would have
28
- left two contracts. Instead the model layer was refactored to a single contract in
29
- `models/base_model.py`:
30
-
31
- - `Signal(direction, confidence, predicted_close=None, horizon=1, meta={})` is the
32
- one value every model returns. `direction` is always `"up"`/`"down"`;
33
- `predicted_close` is `None` for models that do not forecast a price.
34
- - `BaseModel` is abstract with the single required method `predict_signal(df, horizon, features)`.
35
- - `BaseForecastModel(BaseModel)` keeps the price-forecasting family: subclasses
36
- still implement `predict(...) -> list[float]`, and the base class provides a
37
- concrete `predict_signal` that runs `predict`, takes the last forecast value as
38
- `predicted_close`, derives `direction`, and computes a confidence from the
39
- forecast move relative to recent volatility (`_confidence_from_forecast`).
40
-
41
- So the ARIMA-family code path is unchanged in behaviour (it still forecasts prices
42
- via `predict`), but every model — including MAYTHOS — is now callable through the
43
- exact same `predict_signal` entry point. Nothing downstream needs to know which
44
- kind of model it holds.
45
-
46
- ## Decision 2 — MAYTHOS as a native model, not a bolted-on engine
47
-
48
- The MAYTHOS engine was moved into the package at `models/maythos/engine.py` (the
49
- engine itself, `MAYTHOS`/`Candle`), with a thin `MaythosModel(BaseModel)` in
50
- `models/maythos/model.py` that:
51
-
52
- - converts a window of the standard Rinking_out OHLCV DataFrame into MAYTHOS
53
- `Candle` objects (using the DataFrame's own timestamps, converted to UTC epoch
54
- seconds), replays them through a fresh engine, and returns a `Signal`.
55
- - maps BUY -> `"up"`, SELL -> `"down"`, carries the engine's confidence, sets
56
- `predicted_close=None` (MAYTHOS does not forecast a price), and surfaces the
57
- engine's market-state fields (`market_state`, `regime_label`,
58
- `execution_suitability`, etc.) in `Signal.meta`.
59
-
60
- MAYTHOS is registered in `models/registry.py` and added to `config.MODEL_NAMES`, so
61
- it automatically appears everywhere the app iterates models (Prediction tab,
62
- Backtest tab and its per-model verify-log sub-tabs, Live tab, and the CLI logger).
63
- No separate MAYTHOS UI, no special-casing at call sites.
64
-
65
- ## Decision 3 — One backtester (Rule 10)
66
-
67
- There is a single backtesting system, `backtest/backtester.py`. It was refactored
68
- to drive models through `predict_signal` instead of `predict`, so it works for
69
- both price forecasters and MAYTHOS uniformly:
70
-
71
- - It passes the full OHLCV window (not just the close series) to `predict_signal`,
72
- because MAYTHOS needs OHLCV; forecasters ignore the extra columns.
73
- - Direction accuracy is computed for every model. Price-error metrics (MAE/RMSE)
74
- are computed only where `predicted_close` is present, so MAYTHOS rows carry a
75
- `NaN` predicted price and its MAE/RMSE summarise as `None` — correct, not faked.
76
- - A `confidence` column was added to the per-candle results.
77
-
78
- ## Decision 4 — One live signal generator (Rule 9)
79
-
80
- The live path previously existed twice: once inside `app.py` (`_check_live_impl`)
81
- and once inside `live_runner.py`, with subtly different logic. Both were deleted
82
- and replaced by a single implementation in `live/engine.py::poll_once`, which does
83
- the whole fetch -> verify-matured-predictions -> stage-new-prediction cycle via
84
- `predict_signal`. `app.py` and `live_runner.py` are now thin callers of it. The
85
- live log gained a `confidence` column.
86
-
87
- ## Decision 5 — Single indicator period source
88
-
89
- MAYTHOS and Rinking_out both computed RSI/MACD/Bollinger with their own hard-coded
90
- periods. The periods now live once in `config.py` (`RSI_PERIOD`, `MACD_*`,
91
- `BB_PERIOD`, etc.) and are imported by both `indicators/indicators.py` and the
92
- MAYTHOS engine, so the two never drift. Column names (`RSI_14`, `SMA_20`, …) are
93
- unchanged so the feature pipeline and its tests are unaffected.
94
-
95
- ## Error handling (Rule 7)
96
-
97
- No broad `except Exception: pass`. The live engine catches only the expected
98
- `DataFetchError` (transient Yahoo outage) and surfaces it as visible status text;
99
- unexpected errors propagate. Per-model failures in the Prediction/Backtest UIs are
100
- shown to the user as explicit "failed: …" messages rather than being swallowed.
101
- MAYTHOS's internal per-subengine fault isolation (it records faults to its
102
- diagnostics and keeps ticking rather than crashing a live feed) is an intentional,
103
- documented health mechanism of the engine, not error hiding — a single corrupt
104
- tick must not kill a long-running decision stream.
105
-
106
- ## Dead / experimental code removed (Rules 3–5)
107
-
108
- While porting the engine, the standalone-only scaffolding was removed: the
109
- unused bounded-cache class and its tuning constants, and the `__main__` smoke
110
- test (its self-test was re-homed to `tests/test_maythos_engine.py`).
111
-
112
- ## Dependencies
113
-
114
- MAYTHOS needs only `numpy`, which Rinking_out already required, so
115
- `requirements.txt` is unchanged.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md DELETED
@@ -1,10 +0,0 @@
1
- ---
2
- title: MythosLive
3
- emoji: 🚀
4
- colorFrom: blue
5
- colorTo: purple
6
- sdk: gradio
7
- sdk_version: 6.28.0
8
- app_file: app.py
9
- pinned: false
10
- ---
 
 
 
 
 
 
 
 
 
 
 
app.py DELETED
@@ -1,413 +0,0 @@
1
- import pandas as pd
2
- import plotly.graph_objects as go
3
- import gradio as gr
4
- from config import ALL_DEFAULT_SYMBOLS, CUSTOM_TIMEFRAMES, MODEL_NAMES
5
- from data.historical import get_historical, get_historical_with_features, DataFetchError
6
- from indicators.indicators import add_all_indicators, latest_signals
7
- from models.registry import get_model, fresh_model
8
- from backtest.backtester import run_all_models_backtest, run_comparison_backtest, BASELINE_LABEL, FEATURED_LABEL
9
- from utils.helpers import drop_forming_candle, future_index, infer_step
10
- from live.engine import make_empty_live_log, poll_once
11
-
12
- TIMEFRAME_CHOICES = list(CUSTOM_TIMEFRAMES.keys())
13
- DISCLAIMER = "**Educational / research project — not financial advice.** Forex and crypto trading carries substantial risk of loss."
14
- FEATURES_HELP = (
15
- "**14-feature input** feeds each model Open/High/Low/Volume plus RSI, MACD (line/signal/hist), "
16
- "Bollinger Bands (upper/lower), SMA†20, EMA†20, and Stochastic (%K/%D) — computed leak-free "
17
- "(only data available at each prediction\u2019s own point in time) — in addition to the Close-price "
18
- "series every model already uses. Off by default; this changes nothing about the original "
19
- "Close-only behaviour unless you turn it on. See features/feature_pipeline.py for exactly "
20
- "what\u2019s computed and how each model consumes it."
21
- )
22
-
23
-
24
- def make_candlestick_figure(df: pd.DataFrame, symbol: str, timeframe: str) -> go.Figure:
25
- fig = go.Figure(
26
- data=[
27
- go.Candlestick(
28
- x=df.index,
29
- open=df["Open"],
30
- high=df["High"],
31
- low=df["Low"],
32
- close=df["Close"],
33
- name=symbol,
34
- )
35
- ]
36
- )
37
- if "SMA_20" in df:
38
- fig.add_trace(go.Scatter(x=df.index, y=df["SMA_20"], name="SMA 20", line=dict(width=1)))
39
- if "EMA_20" in df:
40
- fig.add_trace(go.Scatter(x=df.index, y=df["EMA_20"], name="EMA 20", line=dict(width=1)))
41
- if "BB_Upper" in df:
42
- fig.add_trace(
43
- go.Scatter(x=df.index, y=df["BB_Upper"], name="BB Upper", line=dict(width=1, dash="dot"))
44
- )
45
- fig.add_trace(
46
- go.Scatter(x=df.index, y=df["BB_Lower"], name="BB Lower", line=dict(width=1, dash="dot"))
47
- )
48
- fig.update_layout(
49
- title=f"{symbol} \u2014 {timeframe}",
50
- xaxis_rangeslider_visible=False,
51
- height=480,
52
- margin=dict(l=10, r=10, t=40, b=10),
53
- )
54
- return fig
55
-
56
-
57
- def fetch_and_chart(symbol, timeframe, lookback_days):
58
- try:
59
- df = get_historical(symbol, timeframe, lookback_days=int(lookback_days))
60
- df_ind = add_all_indicators(df)
61
- fig = make_candlestick_figure(df_ind, symbol, timeframe)
62
- signals = latest_signals(df_ind)
63
- signal_rows = [[name, str(v["value"]), v["signal"]] for name, v in signals.items()]
64
- status = f"Loaded {len(df)} candles for {symbol} ({timeframe})."
65
- return fig, signal_rows, status
66
- except DataFetchError as e:
67
- return None, [], f"Data error: {e}"
68
- except Exception as e:
69
- return None, [], f"Unexpected error: {e}"
70
-
71
-
72
- def run_prediction(symbol, timeframe, selected_models, horizon, lookback_days, use_features):
73
- if not selected_models:
74
- return None, "Pick at least one model."
75
- try:
76
- df, features_df = get_historical_with_features(
77
- symbol, timeframe, lookback_days=int(lookback_days), use_features=use_features
78
- )
79
- except Exception as e:
80
- return None, f"Data error: {e}"
81
- close = df["Close"]
82
- horizon = int(horizon)
83
- step = infer_step(df.index)
84
- fig = go.Figure()
85
- tail = close.iloc[-150:]
86
- fig.add_trace(
87
- go.Scatter(x=tail.index, y=tail.values, name="Actual Close", line=dict(color="#888888"))
88
- )
89
- mode_label = f" [{FEATURED_LABEL}]" if use_features else ""
90
- summary_lines = []
91
- for name in selected_models:
92
- try:
93
- model = get_model(name, symbol, timeframe, use_features=use_features)
94
- signal = model.predict_signal(df, horizon=horizon, features=features_df)
95
- direction = signal.direction.upper()
96
- conf_pct = f"{signal.confidence * 100:.0f}%"
97
- if hasattr(model, "predict"):
98
- forecast = model.predict(close, horizon=horizon, features=features_df)
99
- fc_index = future_index(df.index[-1], step, len(forecast))
100
- fig.add_trace(
101
- go.Scatter(
102
- x=fc_index,
103
- y=forecast,
104
- name=f"{name}{mode_label} forecast",
105
- mode="lines+markers",
106
- )
107
- )
108
- if signal.predicted_close is not None:
109
- summary_lines.append(
110
- f"**{name}**{mode_label} \u2014 next close (approx.) `{signal.predicted_close:.5f}` "
111
- f"({direction}, confidence {conf_pct})"
112
- )
113
- else:
114
- state = signal.meta.get("market_state", "n/a")
115
- execution = signal.meta.get("execution_suitability", "n/a")
116
- summary_lines.append(
117
- f"**{name}**{mode_label} \u2014 next candle **{direction}** "
118
- f"(confidence {conf_pct}; market state: {state}; execution: {execution})"
119
- )
120
- except Exception as e:
121
- summary_lines.append(f"**{name}**{mode_label} \u2014 failed: {e}")
122
- fig.update_layout(
123
- title=f"{symbol} \u2014 {timeframe} forecast ({horizon} candle(s) ahead)",
124
- height=450,
125
- margin=dict(l=10, r=10, t=40, b=10),
126
- )
127
- return fig, "\n\n".join(summary_lines) + "\n\n" + DISCLAIMER
128
-
129
-
130
- FEATURE_MODE_BASELINE = "Close-only (baseline)"
131
- FEATURE_MODE_FEATURED = "+14-feature input"
132
- FEATURE_MODE_COMPARE = "Compare both"
133
-
134
-
135
- def _summary_row(summary: dict, mode_label: str) -> list:
136
- return [
137
- mode_label,
138
- summary["model"],
139
- summary["total_predictions"],
140
- summary["correct"],
141
- summary["incorrect"],
142
- f"{summary['accuracy_pct']}%",
143
- summary["mae"],
144
- summary["rmse"],
145
- summary["failed_predictions"],
146
- ]
147
-
148
-
149
- def _backtest_return(summary_table, detail, status):
150
- return (summary_table, *[detail[name] for name in MODEL_NAMES], status)
151
-
152
-
153
- def run_backtest_ui(
154
- symbol,
155
- timeframe,
156
- selected_models,
157
- window,
158
- horizon,
159
- step_size,
160
- lookback_days,
161
- feature_mode,
162
- ):
163
- empty = pd.DataFrame()
164
- detail = {name: empty for name in MODEL_NAMES}
165
- if not selected_models:
166
- return _backtest_return([], detail, "Pick at least one model.")
167
- needs_features = feature_mode != FEATURE_MODE_BASELINE
168
- try:
169
- df, features_df = get_historical_with_features(
170
- symbol, timeframe, lookback_days=int(lookback_days), use_features=needs_features
171
- )
172
- except Exception as e:
173
- return _backtest_return([], detail, f"Data error: {e}")
174
- df, features_df = drop_forming_candle(df, features_df)
175
- window, horizon, step_size = int(window), int(horizon), int(step_size)
176
- if len(df) < window + horizon + 5:
177
- return _backtest_return(
178
- [],
179
- detail,
180
- f"Only {len(df)} candles available \u2014 raise \u2018Backtest lookback (days)\u2019 or lower the window size to backtest this symbol/timeframe.",
181
- )
182
- summary_table = []
183
- if feature_mode == FEATURE_MODE_COMPARE:
184
- models = {name: (fresh_model(name), fresh_model(name)) for name in selected_models}
185
- try:
186
- per_model_results, per_model_summaries = run_comparison_backtest(
187
- df, models, features_df, window=window, horizon=horizon, step=step_size
188
- )
189
- except Exception as e:
190
- return _backtest_return([], detail, f"Backtest error: {e}")
191
- for name in selected_models:
192
- for summary in per_model_summaries[name]:
193
- summary_table.append(_summary_row(summary, summary["mode"]))
194
- detail[name] = per_model_results[name]
195
- else:
196
- use_features = feature_mode == FEATURE_MODE_FEATURED
197
- mode_label = FEATURED_LABEL if use_features else BASELINE_LABEL
198
- models = {name: fresh_model(name) for name in selected_models}
199
- try:
200
- per_model_results, per_model_summaries = run_all_models_backtest(
201
- df,
202
- models,
203
- window=window,
204
- horizon=horizon,
205
- step=step_size,
206
- features_df=features_df if use_features else None,
207
- )
208
- except Exception as e:
209
- return _backtest_return([], detail, f"Backtest error: {e}")
210
- for name in selected_models:
211
- summary_table.append(_summary_row(per_model_summaries[name], mode_label))
212
- results = per_model_results[name].copy()
213
- results.insert(0, "mode", mode_label)
214
- detail[name] = results
215
- status = (
216
- f"Backtested {', '.join(selected_models)} on {len(df)} candles of "
217
- f"{symbol} ({timeframe}) \u2014 mode: {feature_mode}."
218
- )
219
- return _backtest_return(summary_table, detail, status)
220
-
221
-
222
- def check_live(symbol, timeframe, model_name, log_df, use_features=False):
223
- """Thin dashboard wrapper over the single live engine (live.engine.poll_once).
224
-
225
- Only :class:`DataFetchError` is caught here \u2014 it is the expected transient
226
- condition when Yahoo is briefly unreachable \u2014 and it is surfaced to the user
227
- as visible status text. Any other error propagates instead of being hidden.
228
- """
229
- if log_df is None or len(log_df) == 0:
230
- log_df = make_empty_live_log()
231
- try:
232
- status, updated = poll_once(
233
- symbol, timeframe, model_name, log_df, use_features=use_features
234
- )
235
- return status, updated.tail(20), updated
236
- except DataFetchError as e:
237
- return f"Data error: {e}", log_df.tail(20), log_df
238
-
239
-
240
- with gr.Blocks(title="Forex & Crypto Prediction Dashboard") as demo:
241
- gr.Markdown(
242
- "# \U0001f4c8 Forex & Crypto Prediction Dashboard\n"
243
- "ARIMA, Auto-ARIMA, ARIMA-GARCH, Moirai (Salesforce), TimesFM (Google) & MAYTHOS \u2014 "
244
- "each model\u2019s accuracy is tracked and shown **separately**, never averaged together.\n\n"
245
- + DISCLAIMER
246
- )
247
- with gr.Tab("\U0001f4ca Chart & Indicators"):
248
- with gr.Row():
249
- symbol_in1 = gr.Dropdown(
250
- choices=ALL_DEFAULT_SYMBOLS,
251
- value="EURUSD=X",
252
- allow_custom_value=True,
253
- label="Symbol",
254
- )
255
- timeframe_in1 = gr.Dropdown(
256
- choices=TIMEFRAME_CHOICES, value="15m", label="Timeframe"
257
- )
258
- lookback_in1 = gr.Slider(1, 60, value=5, step=1, label="Lookback (days)")
259
- fetch_btn = gr.Button("Fetch & Analyze", variant="primary")
260
- chart_out1 = gr.Plot()
261
- signals_out = gr.Dataframe(
262
- headers=["Indicator", "Value", "Signal"],
263
- label="Top 5 Indicators (latest reading)",
264
- )
265
- status_out1 = gr.Markdown()
266
- fetch_btn.click(
267
- fetch_and_chart,
268
- [symbol_in1, timeframe_in1, lookback_in1],
269
- [chart_out1, signals_out, status_out1],
270
- )
271
- with gr.Tab("\U0001f52e Prediction"):
272
- with gr.Row():
273
- symbol_in2 = gr.Dropdown(
274
- choices=ALL_DEFAULT_SYMBOLS,
275
- value="BTC-USD",
276
- allow_custom_value=True,
277
- label="Symbol",
278
- )
279
- timeframe_in2 = gr.Dropdown(
280
- choices=TIMEFRAME_CHOICES, value="15m", label="Timeframe"
281
- )
282
- with gr.Row():
283
- models_in2 = gr.CheckboxGroup(
284
- choices=MODEL_NAMES, value=["ARIMA"], label="Models"
285
- )
286
- horizon_in2 = gr.Slider(1, 10, value=1, step=1, label="Candles ahead")
287
- lookback_in2 = gr.Slider(1, 60, value=5, step=1, label="Lookback (days)")
288
- use_features_in2 = gr.Checkbox(
289
- value=False,
290
- label="Use 14-feature input (Open/High/Low/Volume + RSI/MACD/Bollinger/SMA/EMA/Stochastic)",
291
- )
292
- gr.Markdown(
293
- "Moirai and TimesFM download pretrained weights the first time you use them "
294
- "(needs internet on the Space; a few hundred MB each, one-time download)."
295
- )
296
- predict_btn = gr.Button("Predict", variant="primary")
297
- chart_out2 = gr.Plot()
298
- summary_out2 = gr.Markdown()
299
- predict_btn.click(
300
- run_prediction,
301
- [symbol_in2, timeframe_in2, models_in2, horizon_in2, lookback_in2, use_features_in2],
302
- [chart_out2, summary_out2],
303
- )
304
- with gr.Tab("\U0001f9ea Backtest"):
305
- gr.Markdown(
306
- "Each model\u2019s accuracy is computed and shown **separately** below \u2014 results are "
307
- "never averaged together. Raise **Step** to speed up Moirai/TimesFM (slower per-prediction "
308
- "than ARIMA on CPU-only hardware).\n\n" + FEATURES_HELP
309
- )
310
- with gr.Row():
311
- symbol_in3 = gr.Dropdown(
312
- choices=ALL_DEFAULT_SYMBOLS,
313
- value="EURUSD=X",
314
- allow_custom_value=True,
315
- label="Symbol",
316
- )
317
- timeframe_in3 = gr.Dropdown(
318
- choices=TIMEFRAME_CHOICES, value="15m", label="Timeframe"
319
- )
320
- with gr.Row():
321
- models_in3 = gr.CheckboxGroup(
322
- choices=MODEL_NAMES, value=["ARIMA"], label="Models to backtest"
323
- )
324
- window_in3 = gr.Slider(
325
- 20, 300, value=60, step=10, label="History window per prediction"
326
- )
327
- horizon_in3 = gr.Slider(1, 5, value=1, step=1, label="Horizon (candles ahead)")
328
- with gr.Row():
329
- step_in3 = gr.Slider(
330
- 1, 20, value=1, step=1, label="Step (skip candles between tests)"
331
- )
332
- lookback_in3 = gr.Slider(
333
- 1, 60, value=10, step=1, label="Backtest lookback (days)"
334
- )
335
- feature_mode_in3 = gr.Radio(
336
- choices=[FEATURE_MODE_BASELINE, FEATURE_MODE_FEATURED, FEATURE_MODE_COMPARE],
337
- value=FEATURE_MODE_BASELINE,
338
- label="Feature mode",
339
- info=(
340
- "\u2018Compare both\u2019 runs every selected model twice \u2014 Close-only and +14-feature "
341
- "\u2014 on the identical symbol/timeframe/window/horizon/step/period, so you can see "
342
- "whether the extra features actually helped."
343
- ),
344
- )
345
- backtest_btn = gr.Button("Run Backtest", variant="primary")
346
- summary_out3 = gr.Dataframe(
347
- headers=["Mode", "Model", "Total", "Correct", "Incorrect", "Accuracy", "MAE", "RMSE", "Failed"],
348
- label="Per-model accuracy \u2014 kept separate, never mixed",
349
- )
350
- status_out3 = gr.Markdown()
351
- detail_outputs = []
352
- with gr.Tabs():
353
- for _model_name in MODEL_NAMES:
354
- with gr.Tab(f"{_model_name} \u2014 verify log"):
355
- detail_outputs.append(
356
- gr.Dataframe(
357
- label="Per-candle: predicted vs actual, correct/incorrect"
358
- )
359
- )
360
- backtest_btn.click(
361
- run_backtest_ui,
362
- [
363
- symbol_in3,
364
- timeframe_in3,
365
- models_in3,
366
- window_in3,
367
- horizon_in3,
368
- step_in3,
369
- lookback_in3,
370
- feature_mode_in3,
371
- ],
372
- [summary_out3, *detail_outputs, status_out3],
373
- )
374
- with gr.Tab("\U0001f4e1 Live"):
375
- gr.Markdown(
376
- "Polls Yahoo Finance for the newest candle each time you click the button, predicts "
377
- "the next one, and verifies the previous prediction once that candle has closed. "
378
- "For continuous background logging run `python live_runner.py --symbol BTC-USD "
379
- "--timeframe 5m --model ARIMA` from the project directory."
380
- )
381
- with gr.Row():
382
- symbol_in4 = gr.Dropdown(
383
- choices=ALL_DEFAULT_SYMBOLS,
384
- value="BTC-USD",
385
- allow_custom_value=True,
386
- label="Symbol",
387
- )
388
- timeframe_in4 = gr.Dropdown(
389
- choices=TIMEFRAME_CHOICES, value="5m", label="Timeframe"
390
- )
391
- model_in4 = gr.Dropdown(choices=MODEL_NAMES, value="ARIMA", label="Model")
392
- use_features_in4 = gr.Checkbox(
393
- value=False,
394
- label="Use 14-feature input",
395
- info=(
396
- "Uses the exact same feature-generation pipeline as the Backtest tab. "
397
- "Toggling this mid-session keeps both modes\u2019 predictions in the log below, "
398
- "but running accuracy is always computed for the current mode only."
399
- ),
400
- )
401
- live_btn = gr.Button("\U0001f504 Check Latest & Predict Next", variant="primary")
402
- live_log_state = gr.State(make_empty_live_log())
403
- live_status = gr.Markdown()
404
- live_table = gr.Dataframe(label="Live verification log (this model, this session)")
405
- live_btn.click(
406
- check_live,
407
- [symbol_in4, timeframe_in4, model_in4, live_log_state, use_features_in4],
408
- [live_status, live_table, live_log_state],
409
- )
410
- gr.Markdown("---\n" + DISCLAIMER)
411
-
412
- if __name__ == "__main__":
413
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backtest/__init__.py DELETED
@@ -1 +0,0 @@
1
- pass
 
 
backtest/backtester.py DELETED
@@ -1,178 +0,0 @@
1
- from math import floor, log10
2
- import numpy as np
3
- import pandas as pd
4
- from features.feature_pipeline import slice_features
5
-
6
-
7
- def _round_sig(x, sig: int = 6):
8
- if x is None or not np.isfinite(x) or x == 0:
9
- return x
10
- decimals = sig - 1 - floor(log10(abs(x)))
11
- return round(x, decimals)
12
-
13
-
14
- def backtest_model(
15
- df: pd.DataFrame,
16
- model,
17
- window: int = 100,
18
- horizon: int = 1,
19
- step: int = 1,
20
- features_df: pd.DataFrame = None,
21
- ) -> pd.DataFrame:
22
- close = df["Close"]
23
- timestamps = [
24
- ts.isoformat() if hasattr(ts, "isoformat") else ts for ts in df.index
25
- ]
26
- last_i = len(close) - horizon
27
- rows = []
28
- for i in range(window, last_i + 1, step):
29
- window_df = df.iloc[i - window : i]
30
- feature_window = (
31
- slice_features(features_df, i - window, i)
32
- if features_df is not None
33
- else None
34
- )
35
- current_close = float(close.iloc[i - 1])
36
- target_idx = i + horizon - 1
37
- actual_next_close = float(close.iloc[target_idx])
38
- try:
39
- signal = model.predict_signal(
40
- window_df, horizon=horizon, features=feature_window
41
- )
42
- predicted_direction = signal.direction
43
- if predicted_direction not in ("up", "down"):
44
- raise ValueError(
45
- f"invalid signal direction: {predicted_direction!r}"
46
- )
47
- confidence = float(signal.confidence)
48
- if not np.isfinite(confidence):
49
- raise ValueError(f"non-finite confidence: {confidence!r}")
50
- predicted_close = signal.predicted_close
51
- if predicted_close is not None:
52
- predicted_close = float(predicted_close)
53
- if not np.isfinite(predicted_close):
54
- raise ValueError(
55
- f"non-finite forecast value: {predicted_close!r}"
56
- )
57
- except Exception as e:
58
- rows.append(
59
- {
60
- "datetime": timestamps[target_idx],
61
- "current_close": current_close,
62
- "predicted_close": np.nan,
63
- "confidence": np.nan,
64
- "actual_close": actual_next_close,
65
- "predicted_direction": None,
66
- "actual_direction": None,
67
- "correct": None,
68
- "error": str(e),
69
- }
70
- )
71
- continue
72
- actual_direction = "up" if actual_next_close > current_close else "down"
73
- correct = predicted_direction == actual_direction
74
- rows.append(
75
- {
76
- "datetime": timestamps[target_idx],
77
- "current_close": current_close,
78
- "predicted_close": predicted_close if predicted_close is not None else np.nan,
79
- "confidence": confidence,
80
- "actual_close": actual_next_close,
81
- "predicted_direction": predicted_direction,
82
- "actual_direction": actual_direction,
83
- "correct": correct,
84
- "error": None,
85
- }
86
- )
87
- return pd.DataFrame(rows)
88
-
89
-
90
- def summarize_backtest(results: pd.DataFrame, model_name: str) -> dict:
91
- if results.empty:
92
- return {
93
- "model": model_name,
94
- "total_predictions": 0,
95
- "correct": 0,
96
- "incorrect": 0,
97
- "accuracy_pct": 0.0,
98
- "mae": None,
99
- "rmse": None,
100
- "failed_predictions": 0,
101
- }
102
- valid = results.dropna(subset=["correct"])
103
- total = len(valid)
104
- correct = int(valid["correct"].sum()) if total else 0
105
- incorrect = total - correct
106
- accuracy = round(100 * correct / total, 2) if total else 0.0
107
- price_valid = results.dropna(subset=["predicted_close", "actual_close"])
108
- if len(price_valid):
109
- errors = price_valid["predicted_close"] - price_valid["actual_close"]
110
- mae = float(np.mean(np.abs(errors)))
111
- rmse = float(np.sqrt(np.mean(errors**2)))
112
- else:
113
- mae = rmse = None
114
- failed = int(results["error"].notna().sum())
115
- return {
116
- "model": model_name,
117
- "total_predictions": total,
118
- "correct": correct,
119
- "incorrect": incorrect,
120
- "accuracy_pct": accuracy,
121
- "mae": _round_sig(mae) if mae is not None else None,
122
- "rmse": _round_sig(rmse) if rmse is not None else None,
123
- "failed_predictions": failed,
124
- }
125
-
126
-
127
- def run_all_models_backtest(
128
- df: pd.DataFrame,
129
- models: dict,
130
- window: int = 100,
131
- horizon: int = 1,
132
- step: int = 1,
133
- features_df: pd.DataFrame = None,
134
- ):
135
- per_model_results = {}
136
- per_model_summary = {}
137
- for name, model in models.items():
138
- results = backtest_model(
139
- df, model, window=window, horizon=horizon, step=step, features_df=features_df
140
- )
141
- per_model_results[name] = results
142
- per_model_summary[name] = summarize_backtest(results, name)
143
- return per_model_results, per_model_summary
144
-
145
-
146
- BASELINE_LABEL = "Close-only"
147
- FEATURED_LABEL = "+14 Features"
148
-
149
-
150
- def run_comparison_backtest(
151
- df: pd.DataFrame,
152
- models: dict,
153
- features_df: pd.DataFrame,
154
- window: int = 100,
155
- horizon: int = 1,
156
- step: int = 1,
157
- ):
158
- per_model_results = {}
159
- per_model_summary = {}
160
- for name, model_pair in models.items():
161
- baseline_model, featured_model = model_pair
162
- results_list, summary_list = [], []
163
- for label, model, feats in (
164
- (BASELINE_LABEL, baseline_model, None),
165
- (FEATURED_LABEL, featured_model, features_df),
166
- ):
167
- results = backtest_model(
168
- df, model, window=window, horizon=horizon, step=step, features_df=feats
169
- )
170
- summary = summarize_backtest(results, name)
171
- summary["mode"] = label
172
- results = results.copy()
173
- results.insert(0, "mode", label)
174
- results_list.append(results)
175
- summary_list.append(summary)
176
- per_model_results[name] = pd.concat(results_list, ignore_index=True)
177
- per_model_summary[name] = summary_list
178
- return per_model_results, per_model_summary
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
config.py DELETED
@@ -1,29 +0,0 @@
1
- import os
2
- BASE_DIR = os.path.dirname(os.path.abspath(__file__))
3
- OUTPUT_DIR = os.path.join(BASE_DIR, 'outputs')
4
- os.makedirs(OUTPUT_DIR, exist_ok=True)
5
- DEFAULT_FOREX_SYMBOLS = ['EURUSD=X', 'GBPUSD=X', 'USDJPY=X', 'USDCHF=X', 'AUDUSD=X', 'USDCAD=X', 'NZDUSD=X', 'EURJPY=X', 'EURGBP=X', 'GBPJPY=X']
6
- DEFAULT_CRYPTO_SYMBOLS = ['BTC-USD', 'ETH-USD', 'BNB-USD', 'XRP-USD', 'SOL-USD', 'ADA-USD', 'DOGE-USD', 'DOT-USD', 'LTC-USD', 'LINK-USD']
7
- ALL_DEFAULT_SYMBOLS = DEFAULT_FOREX_SYMBOLS + DEFAULT_CRYPTO_SYMBOLS
8
- YF_NATIVE_INTERVALS = {'1m': {'max_days': 30, 'max_request_days': 7}, '2m': {'max_days': 60, 'max_request_days': 60}, '5m': {'max_days': 60, 'max_request_days': 60}, '15m': {'max_days': 60, 'max_request_days': 60}, '30m': {'max_days': 60, 'max_request_days': 60}, '60m': {'max_days': 730, 'max_request_days': 730}, '90m': {'max_days': 60, 'max_request_days': 60}, '1d': {'max_days': None, 'max_request_days': None}, '1wk': {'max_days': None, 'max_request_days': None}, '1mo': {'max_days': None, 'max_request_days': None}}
9
- CUSTOM_TIMEFRAMES = {'1m': {'source': '1m', 'rule': None}, '5m': {'source': '5m', 'rule': None}, '10m': {'source': '5m', 'rule': '10min'}, '15m': {'source': '15m', 'rule': None}, '30m': {'source': '30m', 'rule': None}, '1h': {'source': '60m', 'rule': None}, '4h': {'source': '60m', 'rule': '4h'}, '1d': {'source': '1d', 'rule': None}}
10
- MODEL_NAMES = ['ARIMA', 'Auto-ARIMA', 'ARIMA-GARCH', 'Moirai', 'TimesFM', 'MAYTHOS']
11
-
12
- # --- Indicator parameters (single source of truth) ---
13
- # Both the batch indicator pipeline (indicators/indicators.py + the feature
14
- # pipeline) and the MAYTHOS streaming technical engines (models/maythos/engine.py)
15
- # read their periods from here. Keeping one definition means the two computation
16
- # modes -- vectorised pandas indicators vs. tick-by-tick streaming indicators --
17
- # can never silently drift apart. See MERGE_NOTES.md (Decision 4).
18
- RSI_PERIOD = 14
19
- MACD_FAST = 12
20
- MACD_SLOW = 26
21
- MACD_SIGNAL = 9
22
- BB_PERIOD = 20
23
- BB_STD_DEV = 2.0
24
- SMA_PERIOD = 20
25
- EMA_PERIOD = 20
26
- STOCH_K_PERIOD = 14
27
- STOCH_D_PERIOD = 3
28
- MOIRAI_CHECKPOINT = 'Salesforce/moirai-1.1-R-small'
29
- TIMESFM_CHECKPOINT = 'google/timesfm-2.5-200m-pytorch'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
data/__init__.py DELETED
@@ -1 +0,0 @@
1
- pass
 
 
data/historical.py DELETED
@@ -1,106 +0,0 @@
1
- import random
2
- import time
3
- from concurrent.futures import ThreadPoolExecutor, TimeoutError as _FuturesTimeout
4
- from datetime import datetime, timedelta, timezone
5
- import pandas as pd
6
- from config import YF_NATIVE_INTERVALS, CUSTOM_TIMEFRAMES
7
- from features.feature_pipeline import compute_feature_frame_for_period
8
- from utils.helpers import feature_warmup_days
9
-
10
- _YF_TIMEOUT_SECONDS = 30
11
- OHLCV_COLUMNS = ['Open', 'High', 'Low', 'Close', 'Volume']
12
-
13
- class DataFetchError(Exception):
14
- pass
15
-
16
- def _fetch_yf(symbol: str, interval: str, start: datetime, end: datetime) -> pd.DataFrame:
17
- import yfinance as yf
18
- def _call():
19
- return yf.Ticker(symbol).history(start=start, end=end, interval=interval)
20
- with ThreadPoolExecutor(max_workers=1) as pool:
21
- future = pool.submit(_call)
22
- try:
23
- return future.result(timeout=_YF_TIMEOUT_SECONDS)
24
- except _FuturesTimeout:
25
- future.cancel()
26
- raise DataFetchError(
27
- f"yfinance request for '{symbol}' interval='{interval}' timed out after "
28
- f"{_YF_TIMEOUT_SECONDS}s. The market data provider may be temporarily unavailable."
29
- )
30
-
31
- def _download_window(symbol: str, interval: str, start: datetime, end: datetime) -> pd.DataFrame:
32
- df = _fetch_yf(symbol, interval, start, end)
33
- if df is None or df.empty:
34
- return pd.DataFrame()
35
- df = df.rename(columns=str.title)
36
- df.index.name = 'Datetime'
37
- keep = [c for c in OHLCV_COLUMNS if c in df.columns]
38
- return df[keep]
39
-
40
- def _finalize_ohlcv(df: pd.DataFrame) -> pd.DataFrame:
41
- if df is None or df.empty:
42
- return df
43
- df = df.sort_index()
44
- return df[~df.index.duplicated(keep='last')]
45
-
46
- def _native_fetch(symbol: str, interval: str, days: int) -> pd.DataFrame:
47
- if interval not in YF_NATIVE_INTERVALS:
48
- raise DataFetchError(f"'{interval}' is not a native yfinance interval.")
49
- limits = YF_NATIVE_INTERVALS[interval]
50
- max_days, request_days = (limits['max_days'], limits['max_request_days'])
51
- if max_days is not None:
52
- days = min(days, max_days)
53
- if request_days is None:
54
- request_days = days
55
- frames = []
56
- window_end = datetime.now(timezone.utc)
57
- remaining = days
58
- while remaining > 0:
59
- window_days = min(request_days, remaining)
60
- window_start = window_end - timedelta(days=window_days)
61
- frame = _download_window(symbol, interval, window_start, window_end)
62
- if not frame.empty:
63
- frames.append(frame)
64
- window_end = window_start
65
- remaining -= window_days
66
- if remaining > 0:
67
- time.sleep(0.1 + random.uniform(0, 0.2))
68
- df = _finalize_ohlcv(pd.concat(frames)) if frames else pd.DataFrame()
69
- if df.empty or 'Close' not in df.columns:
70
- limit_note = f" Yahoo keeps at most {max_days} days of '{interval}' history." if max_days is not None else ''
71
- raise DataFetchError(f"No usable data returned for '{symbol}' at interval '{interval}' over the last {days} day(s). Check it's a valid yfinance ticker (e.g. EURUSD=X, BTC-USD) and that the market has traded recently." + limit_note)
72
- return df
73
-
74
- def resample_ohlc(df: pd.DataFrame, rule: str) -> pd.DataFrame:
75
- agg = {'Open': 'first', 'High': 'max', 'Low': 'min', 'Close': 'last', 'Volume': 'sum'}
76
- agg = {k: v for k, v in agg.items() if k in df.columns}
77
- price_cols = [c for c in ('Open', 'High', 'Low', 'Close') if c in agg]
78
- return df.resample(rule).agg(agg).dropna(subset=price_cols)
79
-
80
- def get_historical(symbol: str, timeframe: str, lookback_days: int=None) -> pd.DataFrame:
81
- if not symbol or not symbol.strip():
82
- raise DataFetchError('No symbol given.')
83
- symbol = symbol.strip()
84
- if timeframe in CUSTOM_TIMEFRAMES:
85
- spec = CUSTOM_TIMEFRAMES[timeframe]
86
- source_interval, rule = (spec['source'], spec['rule'])
87
- elif timeframe in YF_NATIVE_INTERVALS:
88
- source_interval, rule = (timeframe, None)
89
- else:
90
- raise DataFetchError(f"Unknown timeframe '{timeframe}'. Valid options: {', '.join(CUSTOM_TIMEFRAMES.keys())}")
91
- max_days = YF_NATIVE_INTERVALS[source_interval]['max_days']
92
- requested = int(lookback_days) if lookback_days is not None else 0
93
- days = requested if requested > 0 else max_days if max_days else 730
94
- df = _native_fetch(symbol, source_interval, days)
95
- if rule:
96
- df = resample_ohlc(df, rule)
97
- if df.empty:
98
- raise DataFetchError(f"Not enough '{source_interval}' candles to build '{timeframe}' bars. Try a longer lookback.")
99
- return df
100
-
101
- def get_historical_with_features(symbol: str, timeframe: str, lookback_days: int, use_features: bool):
102
- if not use_features:
103
- return (get_historical(symbol, timeframe, lookback_days=int(lookback_days)), None)
104
- padding_days = feature_warmup_days(timeframe, minimum_days=0)
105
- df_padded = get_historical(symbol, timeframe, lookback_days=int(lookback_days) + padding_days)
106
- return compute_feature_frame_for_period(df_padded, keep_days=int(lookback_days))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
features/__init__.py DELETED
@@ -1 +0,0 @@
1
- pass
 
 
features/feature_pipeline.py DELETED
@@ -1,43 +0,0 @@
1
- import numpy as np
2
- import pandas as pd
3
- from indicators.indicators import add_all_indicators
4
- REQUIRED_OHLCV_COLUMNS = ['Open', 'High', 'Low', 'Close', 'Volume']
5
- FEATURE_COLUMNS = ['Open', 'High', 'Low', 'Volume', 'RSI_14', 'MACD', 'MACD_Signal', 'MACD_Hist', 'BB_Upper', 'BB_Lower', 'SMA_20', 'EMA_20', 'Stoch_%K', 'Stoch_%D']
6
-
7
- def compute_feature_frame(df: pd.DataFrame) -> pd.DataFrame:
8
- missing = [c for c in REQUIRED_OHLCV_COLUMNS if c not in df.columns]
9
- if missing:
10
- raise ValueError(f'compute_feature_frame needs an OHLCV DataFrame; missing column(s): {missing}.')
11
- with_indicators = add_all_indicators(df)
12
- return with_indicators[FEATURE_COLUMNS].reset_index(drop=True).astype(float)
13
-
14
- def slice_features(features: pd.DataFrame, start: int, end: int) -> pd.DataFrame:
15
- return features.iloc[start:end].reset_index(drop=True)
16
-
17
- def compute_feature_frame_for_period(df_padded: pd.DataFrame, keep_days: float):
18
- features_padded = compute_feature_frame(df_padded)
19
- cutoff = df_padded.index[-1] - pd.Timedelta(days=keep_days)
20
- positions = np.flatnonzero(df_padded.index >= cutoff)
21
- complete = ~features_padded.isna().any(axis=1).to_numpy()
22
- if complete.any():
23
- positions = positions[positions >= int(np.argmax(complete))]
24
- else:
25
- positions = positions[:0]
26
- if len(positions) == 0:
27
- raise ValueError(f'No candle in the requested period has complete indicator values: only {len(df_padded)} candles were fetched, and the slowest indicator needs 20 candles of warm-up before the period starts. Increase the lookback (markets that close, e.g. forex over a weekend, need more calendar days to reach the same candle count).')
28
- return (df_padded.iloc[positions], features_padded.iloc[positions].reset_index(drop=True))
29
-
30
- def validate_features(features: pd.DataFrame, expected_len: int, model_name: str, check_nan: bool=True) -> None:
31
- if len(features) != expected_len:
32
- raise ValueError(f'{model_name}: feature window has {len(features)} rows, expected {expected_len} to match the price history it must line up with.')
33
- if check_nan and features.isna().any().any():
34
- bad_cols = features.columns[features.isna().any()].tolist()
35
- raise ValueError(f"{model_name}: feature window contains NaN in {bad_cols}. This usually means there isn't enough real history before this window for the slower indicators (SMA/Bollinger need 20 prior candles) to warm up on -- increase the lookback/history and try again.")
36
-
37
- def carry_forward_future(feature_window: pd.DataFrame, horizon: int) -> pd.DataFrame:
38
- if horizon < 1:
39
- raise ValueError('carry_forward_future: horizon must be >= 1.')
40
- if len(feature_window) == 0:
41
- raise ValueError('carry_forward_future: feature_window is empty.')
42
- last_row = feature_window.iloc[[-1]]
43
- return pd.concat([last_row] * horizon, ignore_index=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
indicators/__init__.py DELETED
@@ -1 +0,0 @@
1
- pass
 
 
indicators/indicators.py DELETED
@@ -1,107 +0,0 @@
1
- import numpy as np
2
- import pandas as pd
3
-
4
- from config import (
5
- RSI_PERIOD,
6
- MACD_FAST,
7
- MACD_SLOW,
8
- MACD_SIGNAL,
9
- BB_PERIOD,
10
- BB_STD_DEV,
11
- SMA_PERIOD,
12
- EMA_PERIOD,
13
- STOCH_K_PERIOD,
14
- STOCH_D_PERIOD,
15
- )
16
-
17
- def sma(series: pd.Series, window: int) -> pd.Series:
18
- return series.rolling(window=window).mean()
19
-
20
- def ema(series: pd.Series, window: int) -> pd.Series:
21
- return series.ewm(span=window, adjust=False).mean()
22
-
23
- def _wilder_rma(values: pd.Series, window: int) -> pd.Series:
24
- arr = values.to_numpy(dtype=float)
25
- n = len(arr)
26
- out = np.full(n, np.nan)
27
- first_valid_pos = 0
28
- while first_valid_pos < n and np.isnan(arr[first_valid_pos]):
29
- first_valid_pos += 1
30
- seed_end = first_valid_pos + window
31
- if seed_end > n:
32
- return pd.Series(out, index=values.index)
33
- prev = arr[first_valid_pos:seed_end].mean()
34
- out[seed_end - 1] = prev
35
- for i in range(seed_end, n):
36
- prev = (prev * (window - 1) + arr[i]) / window
37
- out[i] = prev
38
- return pd.Series(out, index=values.index)
39
-
40
- def rsi(series: pd.Series, window: int=14) -> pd.Series:
41
- delta = series.diff()
42
- gain = delta.clip(lower=0)
43
- loss = -delta.clip(upper=0)
44
- avg_gain = _wilder_rma(gain, window)
45
- avg_loss = _wilder_rma(loss, window)
46
- rs = avg_gain / avg_loss.replace(0, np.nan)
47
- out = 100 - 100 / (1 + rs)
48
- out = out.mask((avg_loss == 0) & (avg_gain > 0), 100.0)
49
- return out.mask((avg_loss == 0) & (avg_gain == 0), 50.0)
50
-
51
- def macd(series: pd.Series, fast: int=12, slow: int=26, signal: int=9):
52
- fast_ema = ema(series, fast)
53
- slow_ema = ema(series, slow)
54
- macd_line = fast_ema - slow_ema
55
- signal_line = ema(macd_line, signal)
56
- histogram = macd_line - signal_line
57
- return (macd_line, signal_line, histogram)
58
-
59
- def bollinger_bands(series: pd.Series, window: int=20, num_std: float=2.0):
60
- mid = sma(series, window)
61
- std = series.rolling(window=window).std(ddof=0)
62
- return (mid + num_std * std, mid, mid - num_std * std)
63
-
64
- def stochastic_oscillator(high: pd.Series, low: pd.Series, close: pd.Series, k_window: int=14, d_window: int=3):
65
- lowest_low = low.rolling(window=k_window).min()
66
- highest_high = high.rolling(window=k_window).max()
67
- span = highest_high - lowest_low
68
- percent_k = 100 * (close - lowest_low) / span.replace(0, np.nan)
69
- percent_k = percent_k.mask(span == 0, 50.0)
70
- percent_d = percent_k.rolling(window=d_window).mean()
71
- return (percent_k, percent_d)
72
-
73
- def add_all_indicators(df: pd.DataFrame) -> pd.DataFrame:
74
- out = df.copy()
75
- out['SMA_20'] = sma(out['Close'], SMA_PERIOD)
76
- out['EMA_20'] = ema(out['Close'], EMA_PERIOD)
77
- out['RSI_14'] = rsi(out['Close'], RSI_PERIOD)
78
- macd_line, signal_line, hist = macd(out['Close'], MACD_FAST, MACD_SLOW, MACD_SIGNAL)
79
- out['MACD'] = macd_line
80
- out['MACD_Signal'] = signal_line
81
- out['MACD_Hist'] = hist
82
- upper, mid, lower = bollinger_bands(out['Close'], BB_PERIOD, BB_STD_DEV)
83
- out['BB_Upper'] = upper
84
- out['BB_Mid'] = mid
85
- out['BB_Lower'] = lower
86
- k, d = stochastic_oscillator(out['High'], out['Low'], out['Close'], STOCH_K_PERIOD, STOCH_D_PERIOD)
87
- out['Stoch_%K'] = k
88
- out['Stoch_%D'] = d
89
- return out
90
-
91
- def _round(value, digits):
92
- return round(float(value), digits) if pd.notna(value) else 'n/a'
93
-
94
- def latest_signals(df_with_indicators: pd.DataFrame) -> dict:
95
- last = df_with_indicators.iloc[-1]
96
- signals = {}
97
- rsi_value = last['RSI_14']
98
- signals['RSI (14)'] = {'value': _round(rsi_value, 2), 'signal': 'Insufficient data' if pd.isna(rsi_value) else 'Overbought' if rsi_value > 70 else 'Oversold' if rsi_value < 30 else 'Neutral'}
99
- macd_ready = pd.notna(last['MACD']) and pd.notna(last['MACD_Signal'])
100
- signals['MACD'] = {'value': _round(last['MACD'], 5), 'signal': 'Insufficient data' if not macd_ready else 'Bullish (above signal)' if last['MACD'] > last['MACD_Signal'] else 'Bearish (below signal)'}
101
- bb_ready = pd.notna(last['BB_Upper']) and pd.notna(last['BB_Lower'])
102
- signals['Bollinger Bands'] = {'value': _round(last['Close'], 5), 'signal': 'Insufficient data' if not bb_ready else 'Above Upper Band' if last['Close'] > last['BB_Upper'] else 'Below Lower Band' if last['Close'] < last['BB_Lower'] else 'Inside Bands'}
103
- sma_ready = pd.notna(last['SMA_20'])
104
- signals['Moving Averages'] = {'value': f"SMA20={_round(last['SMA_20'], 5)}, EMA20={_round(last['EMA_20'], 5)}", 'signal': 'Insufficient data for SMA' if not sma_ready else 'Price Above MA (Uptrend)' if last['Close'] > last['SMA_20'] else 'Price Below MA (Downtrend)'}
105
- stoch_value = last['Stoch_%K']
106
- signals['Stochastic (14,3)'] = {'value': _round(stoch_value, 2), 'signal': 'Insufficient data' if pd.isna(stoch_value) else 'Overbought' if stoch_value > 80 else 'Oversold' if stoch_value < 20 else 'Neutral'}
107
- return signals
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
live/__init__.py DELETED
@@ -1 +0,0 @@
1
- """Unified live-prediction engine shared by the CLI logger and the dashboard."""
 
 
live/engine.py DELETED
@@ -1,182 +0,0 @@
1
- """Single live-prediction engine used by both the dashboard and the CLI logger.
2
-
3
- There is exactly one live signal generator in the project (Rule 9). Both
4
- ``app.py`` (the Live tab) and ``live_runner.py`` (the continuous CLI logger) call
5
- :func:`poll_once`, so the fetch -> verify -> predict cycle lives in one place and
6
- cannot drift. Every model is driven through the unified
7
- :meth:`~models.base_model.BaseModel.predict_signal` contract, so ARIMA-family
8
- forecasters and MAYTHOS behave identically here. See MERGE_NOTES.md (Decision 2).
9
- """
10
-
11
- import os
12
-
13
- import numpy as np
14
- import pandas as pd
15
-
16
- from config import CUSTOM_TIMEFRAMES, OUTPUT_DIR, YF_NATIVE_INTERVALS
17
- from data.historical import get_historical_with_features
18
- from models.registry import get_model
19
- from utils.helpers import (
20
- PREDICTION_HISTORY_CANDLES,
21
- history_days_for_candles,
22
- infer_step,
23
- )
24
- from backtest.backtester import BASELINE_LABEL, FEATURED_LABEL
25
-
26
- LIVE_COLUMNS = [
27
- "predicted_at",
28
- "mode",
29
- "target_time",
30
- "current_close",
31
- "predicted_close",
32
- "confidence",
33
- "predicted_direction",
34
- "actual_close",
35
- "actual_direction",
36
- "correct",
37
- ]
38
-
39
-
40
- def make_empty_live_log() -> pd.DataFrame:
41
- return pd.DataFrame(
42
- {
43
- "predicted_at": pd.Series(dtype="object"),
44
- "mode": pd.Series(dtype="object"),
45
- "target_time": pd.Series(dtype="object"),
46
- "current_close": pd.Series(dtype="float64"),
47
- "predicted_close": pd.Series(dtype="float64"),
48
- "confidence": pd.Series(dtype="float64"),
49
- "predicted_direction": pd.Series(dtype="object"),
50
- "actual_close": pd.Series(dtype="float64"),
51
- "actual_direction": pd.Series(dtype="object"),
52
- "correct": pd.Series(dtype="object"),
53
- }
54
- )
55
-
56
-
57
- def live_log_path(symbol: str, timeframe: str, model_name: str, use_features: bool) -> str:
58
- safe_symbol = symbol.replace("=", "_").replace("/", "_")
59
- suffix = "_featured" if use_features else ""
60
- return os.path.join(
61
- OUTPUT_DIR, f"live_{safe_symbol}_{timeframe}_{model_name}{suffix}.csv"
62
- )
63
-
64
-
65
- def _required_lookback_days(log_df, minimum_days: int = 2) -> int:
66
- if log_df is None or len(log_df) == 0:
67
- return minimum_days
68
- pending = log_df[log_df["actual_close"].isna()]
69
- if len(pending) == 0:
70
- return minimum_days
71
- oldest_target = pd.to_datetime(pending["target_time"], utc=True).min()
72
- now = pd.Timestamp.now(tz="UTC")
73
- age_days = (now - oldest_target).total_seconds() / 86400
74
- return max(minimum_days, int(age_days) + 2)
75
-
76
-
77
- def _normalize_ts(ts_val, ref_index: pd.DatetimeIndex) -> pd.Timestamp:
78
- ts = pd.Timestamp(ts_val)
79
- if ref_index.tz is not None:
80
- return ts.tz_convert("UTC") if ts.tzinfo is not None else ts.tz_localize("UTC")
81
- return ts.tz_localize(None) if ts.tzinfo is not None else ts
82
-
83
-
84
- def poll_once(symbol, timeframe, model_name, log_df, use_features=False):
85
- """Run one live cycle: verify matured predictions and stage a new one.
86
-
87
- Returns ``(status, log_df)`` where ``log_df`` is the full, updated log. Raises
88
- :class:`~data.historical.DataFetchError` on data problems so callers can
89
- decide how to surface them; a failure to *predict* (e.g. not enough warm
90
- candles yet) is reported visibly in ``status`` rather than silently dropped.
91
- """
92
- if log_df is None or len(log_df) == 0:
93
- log_df = make_empty_live_log()
94
- else:
95
- log_df = log_df.copy()
96
- source_interval = CUSTOM_TIMEFRAMES.get(timeframe, {}).get("source", timeframe)
97
- max_servable_days = YF_NATIVE_INTERVALS.get(source_interval, {}).get("max_days")
98
- wanted_days = max(
99
- _required_lookback_days(log_df),
100
- history_days_for_candles(timeframe, PREDICTION_HISTORY_CANDLES),
101
- )
102
- fetch_days = min(wanted_days, max_servable_days) if max_servable_days else wanted_days
103
- df, features_df = get_historical_with_features(
104
- symbol, timeframe, lookback_days=fetch_days, use_features=use_features
105
- )
106
- latest_time = df.index[-1]
107
- latest_close = float(df["Close"].iloc[-1])
108
-
109
- pending_mask = log_df["actual_close"].isna()
110
- for idx, row in log_df[pending_mask].iterrows():
111
- target = _normalize_ts(row["target_time"], df.index)
112
- if target in df.index and target < latest_time:
113
- actual_close = float(df.loc[target, "Close"])
114
- actual_direction = "up" if actual_close > row["current_close"] else "down"
115
- log_df.loc[idx, "actual_close"] = actual_close
116
- log_df.loc[idx, "actual_direction"] = actual_direction
117
- log_df.loc[idx, "correct"] = bool(actual_direction == row["predicted_direction"])
118
-
119
- if max_servable_days is not None:
120
- cutoff = pd.Timestamp.now(tz="UTC") - pd.Timedelta(days=max_servable_days)
121
- target_times = pd.to_datetime(log_df["target_time"], utc=True)
122
- unreachable = log_df["actual_close"].isna() & (target_times < cutoff)
123
- if unreachable.any():
124
- log_df.loc[unreachable, "actual_direction"] = "unresolvable (past history limit)"
125
- log_df.loc[unreachable, "correct"] = None
126
-
127
- current_mode = FEATURED_LABEL if use_features else BASELINE_LABEL
128
- have_pending_for_latest = False
129
- if len(log_df):
130
- last_row = log_df.iloc[-1]
131
- if pd.isna(last_row["actual_close"]) and last_row["mode"] == current_mode:
132
- predicted_at_ts = _normalize_ts(last_row["predicted_at"], df.index)
133
- have_pending_for_latest = predicted_at_ts == latest_time
134
-
135
- status_extra = ""
136
- if not have_pending_for_latest:
137
- try:
138
- model = get_model(model_name, symbol, timeframe, use_features=use_features)
139
- signal = model.predict_signal(df, horizon=1, features=features_df)
140
- predicted_close = (
141
- float(signal.predicted_close)
142
- if signal.predicted_close is not None
143
- else np.nan
144
- )
145
- step = infer_step(df.index)
146
- new_row = {
147
- "predicted_at": latest_time.isoformat(),
148
- "mode": current_mode,
149
- "target_time": (latest_time + step).isoformat(),
150
- "current_close": latest_close,
151
- "predicted_close": predicted_close,
152
- "confidence": float(signal.confidence),
153
- "predicted_direction": signal.direction,
154
- "actual_close": np.nan,
155
- "actual_direction": None,
156
- "correct": None,
157
- }
158
- log_df = pd.concat([log_df, pd.DataFrame([new_row])], ignore_index=True)
159
- except (ValueError, ImportError) as e:
160
- status_extra = f" (prediction failed: {e})"
161
-
162
- verified = log_df.dropna(subset=["correct"])
163
- verified_this_mode = verified[verified["mode"] == current_mode]
164
- if len(verified_this_mode):
165
- acc = round(100 * verified_this_mode["correct"].sum() / len(verified_this_mode), 2)
166
- acc_text = (
167
- f"Running accuracy for {model_name} [{current_mode}]: "
168
- f"{acc}% ({int(verified_this_mode['correct'].sum())}/{len(verified_this_mode)})"
169
- )
170
- other_count = len(verified) - len(verified_this_mode)
171
- if other_count:
172
- acc_text += (
173
- f" \u2014 {other_count} verified prediction(s) from the other mode are in "
174
- f"the table below but not counted here."
175
- )
176
- else:
177
- acc_text = "Collecting data \u2014 check again after the next candle closes to see accuracy."
178
- status = (
179
- f"Latest {symbol} candle: {latest_time} \u2014 Close {latest_close:.5f}. "
180
- f"{acc_text}{status_extra}"
181
- )
182
- return status, log_df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
live_runner.py DELETED
@@ -1,41 +0,0 @@
1
- import argparse
2
- import time
3
- from datetime import datetime, timezone
4
-
5
- from config import MODEL_NAMES
6
- from data.historical import DataFetchError
7
- from live.engine import make_empty_live_log, poll_once, live_log_path
8
-
9
-
10
- def main():
11
- parser = argparse.ArgumentParser(description='Continuous live prediction logger.')
12
- parser.add_argument('--symbol', required=True, help='e.g. BTC-USD, EURUSD=X')
13
- parser.add_argument('--timeframe', required=True, help='e.g. 1m, 5m, 15m, 1h')
14
- parser.add_argument('--model', required=True, choices=MODEL_NAMES)
15
- parser.add_argument('--poll-seconds', type=int, default=60, help='How often to check for a new candle (default: 60s)')
16
- parser.add_argument('--use-features', action='store_true', help='Feed OHLCV+RSI/MACD/Bollinger/SMA-EMA/Stochastic to the model (same leak-free pipeline as the Backtest tab), not just Close.')
17
- args = parser.parse_args()
18
-
19
- log_path = live_log_path(args.symbol, args.timeframe, args.model, args.use_features)
20
- log_df = make_empty_live_log()
21
- print(
22
- f"[live_runner] {args.symbol} [{args.timeframe}] using {args.model}"
23
- f"{(' (+14 features)' if args.use_features else '')}. Logging to {log_path}. Ctrl+C to stop."
24
- )
25
- try:
26
- while True:
27
- try:
28
- status, log_df = poll_once(
29
- args.symbol, args.timeframe, args.model, log_df, use_features=args.use_features
30
- )
31
- print(f'[{datetime.now(timezone.utc).isoformat()}] {status}')
32
- log_df.to_csv(log_path, index=False)
33
- except DataFetchError as e:
34
- print(f'[{datetime.now(timezone.utc).isoformat()}] Data error: {e}')
35
- time.sleep(args.poll_seconds)
36
- except KeyboardInterrupt:
37
- print('Stopped.')
38
-
39
-
40
- if __name__ == '__main__':
41
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
models/__init__.py DELETED
@@ -1 +0,0 @@
1
- pass
 
 
models/arima_garch_model.py DELETED
@@ -1,83 +0,0 @@
1
- import threading
2
- import numpy as np
3
- import pandas as pd
4
- from models.base_model import BaseForecastModel, validate_history_and_horizon
5
- from models.dependencies import import_arch_model, import_arima, quiet_fit
6
- from features.feature_pipeline import carry_forward_future, validate_features
7
-
8
- class ArimaGarchModel(BaseForecastModel):
9
- name = 'ARIMA-GARCH'
10
-
11
- def __init__(self, max_p=3, max_q=3, order=None, garch_p=1, garch_q=1, refit_order_every=None):
12
- self.max_p = max_p
13
- self.max_q = max_q
14
- self._cached_order = order
15
- self.garch_p = garch_p
16
- self.garch_q = garch_q
17
- self.refit_order_every = refit_order_every
18
- self._calls_since_search = 0
19
- self.last_volatility_forecast = None
20
- self._lock = threading.Lock()
21
-
22
- def _best_order(self, returns: pd.Series, exog: pd.DataFrame=None):
23
- arima = import_arima(self.name)
24
- best_aic = np.inf
25
- best_order = (1, 0, 1)
26
- with quiet_fit():
27
- for p in range(self.max_p + 1):
28
- for q in range(self.max_q + 1):
29
- if p == 0 and q == 0:
30
- continue
31
- try:
32
- fit = arima(returns, exog=exog, order=(p, 0, q)).fit()
33
- except Exception:
34
- continue
35
- if fit.aic < best_aic:
36
- best_aic = fit.aic
37
- best_order = (p, 0, q)
38
- return best_order
39
-
40
- def predict(self, history: pd.Series, horizon: int=1, features: pd.DataFrame=None) -> list:
41
- with self._lock:
42
- prices = pd.Series(history).astype(float).reset_index(drop=True)
43
- if len(prices) < 30:
44
- raise ValueError('ARIMA-GARCH needs at least 30 candles of history.')
45
- validate_history_and_horizon(prices, horizon, self.name)
46
- if (prices <= 0).any():
47
- raise ValueError('ARIMA-GARCH needs strictly positive prices (it uses log returns).')
48
- arima = import_arima(self.name)
49
- arch_model = import_arch_model(self.name)
50
- log_returns = np.log(prices / prices.shift(1)).dropna().reset_index(drop=True)
51
- exog = None
52
- if features is not None:
53
- validate_features(features, expected_len=len(prices), model_name='ARIMA-GARCH')
54
- exog = features.iloc[1:].reset_index(drop=True)
55
- need_search = self._cached_order is None
56
- if self.refit_order_every and self._calls_since_search >= self.refit_order_every:
57
- need_search = True
58
- if need_search:
59
- self._cached_order = self._best_order(log_returns, exog=exog)
60
- self._calls_since_search = 0
61
- self._calls_since_search += 1
62
- with quiet_fit():
63
- mean_fit = arima(log_returns, exog=exog, order=self._cached_order).fit()
64
- if exog is not None:
65
- future_exog = carry_forward_future(exog, horizon)
66
- mean_forecast = np.asarray(mean_fit.forecast(steps=horizon, exog=future_exog), dtype=float)
67
- else:
68
- mean_forecast = np.asarray(mean_fit.forecast(steps=horizon), dtype=float)
69
- resid = pd.Series(mean_fit.resid).dropna()
70
- scale = 100.0
71
- garch_fit = arch_model(resid * scale, mean='Zero', vol='GARCH', p=self.garch_p, q=self.garch_q, dist='normal', rescale=False).fit(disp='off')
72
- garch_forecast = garch_fit.forecast(horizon=horizon, reindex=False)
73
- variance = np.asarray(garch_forecast.variance.values[-1], dtype=float) / scale ** 2
74
- self.last_volatility_forecast = [float(x) for x in np.sqrt(variance)]
75
- last_price = float(prices.iloc[-1])
76
- prices_out = []
77
- cum_log_return = 0.0
78
- for r in mean_forecast:
79
- cum_log_return += float(r)
80
- prices_out.append(last_price * float(np.exp(cum_log_return)))
81
- if not all((np.isfinite(x) for x in prices_out)):
82
- raise ValueError(f'ARIMA-GARCH produced a non-finite forecast for order {self._cached_order}.')
83
- return prices_out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
models/arima_model.py DELETED
@@ -1,66 +0,0 @@
1
- import threading
2
- import numpy as np
3
- import pandas as pd
4
- from models.base_model import BaseForecastModel, validate_history_and_horizon
5
- from models.dependencies import import_arima, quiet_fit
6
- from features.feature_pipeline import carry_forward_future, validate_features
7
-
8
- class ArimaModel(BaseForecastModel):
9
- name = 'ARIMA'
10
-
11
- def __init__(self, max_p=3, max_d=2, max_q=3, order=None, refit_order_every=None):
12
- self.max_p = max_p
13
- self.max_d = max_d
14
- self.max_q = max_q
15
- self._cached_order = order
16
- self.refit_order_every = refit_order_every
17
- self._calls_since_search = 0
18
- self._lock = threading.Lock()
19
-
20
- def _best_order(self, series: pd.Series, exog: pd.DataFrame=None):
21
- arima = import_arima(self.name)
22
- best_aic = np.inf
23
- best_order = (1, 1, 1)
24
- with quiet_fit():
25
- for p in range(self.max_p + 1):
26
- for d in range(self.max_d + 1):
27
- for q in range(self.max_q + 1):
28
- if p == 0 and q == 0:
29
- continue
30
- try:
31
- fit = arima(series, exog=exog, order=(p, d, q)).fit()
32
- except Exception:
33
- continue
34
- if fit.aic < best_aic:
35
- best_aic = fit.aic
36
- best_order = (p, d, q)
37
- return best_order
38
-
39
- def predict(self, history: pd.Series, horizon: int=1, features: pd.DataFrame=None) -> list:
40
- with self._lock:
41
- series = pd.Series(history).astype(float).reset_index(drop=True)
42
- if len(series) < 20:
43
- raise ValueError('ARIMA needs at least 20 candles of history.')
44
- validate_history_and_horizon(series, horizon, self.name)
45
- arima = import_arima(self.name)
46
- exog = None
47
- if features is not None:
48
- validate_features(features, expected_len=len(series), model_name='ARIMA')
49
- exog = features.reset_index(drop=True)
50
- need_search = self._cached_order is None
51
- if self.refit_order_every and self._calls_since_search >= self.refit_order_every:
52
- need_search = True
53
- if need_search:
54
- self._cached_order = self._best_order(series, exog=exog)
55
- self._calls_since_search = 0
56
- self._calls_since_search += 1
57
- with quiet_fit():
58
- fit = arima(series, exog=exog, order=self._cached_order).fit()
59
- if exog is not None:
60
- future_exog = carry_forward_future(exog, horizon)
61
- forecast = np.asarray(fit.forecast(steps=horizon, exog=future_exog), dtype=float)
62
- else:
63
- forecast = np.asarray(fit.forecast(steps=horizon), dtype=float)
64
- if not np.all(np.isfinite(forecast)):
65
- raise ValueError(f'ARIMA produced a non-finite forecast for order {self._cached_order}.')
66
- return [float(x) for x in forecast]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
models/auto_arima_model.py DELETED
@@ -1,97 +0,0 @@
1
- import threading
2
- import numpy as np
3
- import pandas as pd
4
- from models.base_model import BaseForecastModel, validate_history_and_horizon
5
- from models.dependencies import import_arima, import_kpss, quiet_fit
6
- from features.feature_pipeline import carry_forward_future, validate_features
7
-
8
- class AutoArimaModel(BaseForecastModel):
9
- name = 'Auto-ARIMA'
10
-
11
- def __init__(self, max_p=5, max_q=5, max_d=2, order=None, refit_order_every=None):
12
- self.max_p = max_p
13
- self.max_q = max_q
14
- self.max_d = max_d
15
- self._cached_order = order
16
- self.refit_order_every = refit_order_every
17
- self._calls_since_search = 0
18
- self._lock = threading.Lock()
19
-
20
- def _find_d(self, series: pd.Series) -> int:
21
- kpss = import_kpss(self.name)
22
- s = series.copy()
23
- d = 0
24
- with quiet_fit():
25
- while d < self.max_d:
26
- try:
27
- _, p_value, _, _ = kpss(s, regression='c', nlags='auto')
28
- except Exception:
29
- break
30
- if p_value >= 0.05:
31
- break
32
- s = s.diff().dropna()
33
- d += 1
34
- return d
35
-
36
- def _aic(self, series: pd.Series, order, exog: pd.DataFrame=None) -> float:
37
- arima = import_arima(self.name)
38
- try:
39
- return arima(series, exog=exog, order=order).fit().aic
40
- except Exception:
41
- return np.inf
42
-
43
- def _stepwise_search(self, series: pd.Series, d: int, exog: pd.DataFrame=None):
44
- starting_points = [(0, 0), (1, 0), (0, 1), (2, 2)]
45
- best_order, best_aic = (None, np.inf)
46
- with quiet_fit():
47
- for p, q in starting_points:
48
- if p > self.max_p or q > self.max_q:
49
- continue
50
- aic = self._aic(series, (p, d, q), exog=exog)
51
- if aic < best_aic:
52
- best_aic, best_order = (aic, (p, q))
53
- if best_order is None:
54
- best_order, best_aic = ((0, 0), self._aic(series, (0, d, 0), exog=exog))
55
- improved = True
56
- while improved:
57
- improved = False
58
- p0, q0 = best_order
59
- for p, q in [(p0 + 1, q0), (p0 - 1, q0), (p0, q0 + 1), (p0, q0 - 1), (p0 + 1, q0 + 1), (p0 - 1, q0 - 1)]:
60
- if p < 0 or q < 0 or p > self.max_p or (q > self.max_q):
61
- continue
62
- aic = self._aic(series, (p, d, q), exog=exog)
63
- if aic < best_aic - 1e-06:
64
- best_aic, best_order = (aic, (p, q))
65
- improved = True
66
- p, q = best_order
67
- return (p, d, q)
68
-
69
- def predict(self, history: pd.Series, horizon: int=1, features: pd.DataFrame=None) -> list:
70
- with self._lock:
71
- series = pd.Series(history).astype(float).reset_index(drop=True)
72
- if len(series) < 20:
73
- raise ValueError('Auto-ARIMA needs at least 20 candles of history.')
74
- validate_history_and_horizon(series, horizon, self.name)
75
- arima = import_arima(self.name)
76
- exog = None
77
- if features is not None:
78
- validate_features(features, expected_len=len(series), model_name='Auto-ARIMA')
79
- exog = features.reset_index(drop=True)
80
- need_search = self._cached_order is None
81
- if self.refit_order_every and self._calls_since_search >= self.refit_order_every:
82
- need_search = True
83
- if need_search:
84
- d = self._find_d(series)
85
- self._cached_order = self._stepwise_search(series, d, exog=exog)
86
- self._calls_since_search = 0
87
- self._calls_since_search += 1
88
- with quiet_fit():
89
- fit = arima(series, exog=exog, order=self._cached_order).fit()
90
- if exog is not None:
91
- future_exog = carry_forward_future(exog, horizon)
92
- forecast = np.asarray(fit.forecast(steps=horizon, exog=future_exog), dtype=float)
93
- else:
94
- forecast = np.asarray(fit.forecast(steps=horizon), dtype=float)
95
- if not np.all(np.isfinite(forecast)):
96
- raise ValueError(f'Auto-ARIMA produced a non-finite forecast for order {self._cached_order}.')
97
- return [float(x) for x in forecast]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
models/base_model.py DELETED
@@ -1,103 +0,0 @@
1
- import numpy as np
2
- import pandas as pd
3
- from abc import ABC, abstractmethod
4
- from dataclasses import dataclass, field
5
- from typing import Any, Dict, List, Optional, Union
6
-
7
-
8
- def validate_history_and_horizon(history, horizon: int, model_name: str) -> None:
9
- if horizon < 1:
10
- raise ValueError(f'{model_name}: horizon must be >= 1, got {horizon}.')
11
- values = np.asarray(pd.Series(history).astype(float), dtype=float)
12
- if not np.all(np.isfinite(values)):
13
- raise ValueError(f'{model_name}: price history contains NaN/inf. This usually means a data glitch left a missing or malformed candle -- re-fetch the history and try again.')
14
-
15
-
16
- @dataclass
17
- class Signal:
18
- """Unified prediction contract shared by every model in the project.
19
-
20
- This is the single interface the backtester and the live engine consume, so
21
- that price-forecasting models (ARIMA, Auto-ARIMA, ARIMA-GARCH, Moirai,
22
- TimesFM) and the MAYTHOS decision engine are driven through exactly one code
23
- path. See MERGE_NOTES.md (Decision 1).
24
-
25
- Attributes:
26
- direction: 'up' or 'down' -- the predicted move of the next close.
27
- confidence: model confidence in [0, 1].
28
- predicted_close: the forecast price for the final horizon step, or None
29
- for models (like MAYTHOS) that emit a directional decision rather
30
- than a price level.
31
- horizon: number of steps ahead this signal describes.
32
- meta: model-specific diagnostic fields (market state, execution
33
- suitability, etc.). Empty for plain forecasters.
34
- """
35
- direction: str
36
- confidence: float
37
- predicted_close: Optional[float] = None
38
- horizon: int = 1
39
- meta: Dict[str, Any] = field(default_factory=dict)
40
-
41
-
42
- class BaseModel(ABC):
43
- """Root of the model hierarchy. Every model produces a :class:`Signal`."""
44
-
45
- name = 'base'
46
-
47
- @abstractmethod
48
- def predict_signal(self, window: pd.DataFrame, horizon: int = 1, features: pd.DataFrame = None) -> Signal:
49
- raise NotImplementedError
50
-
51
-
52
- def _confidence_from_forecast(history: np.ndarray, predicted_close: float) -> float:
53
- """Map a numeric forecast to a [0, 1] confidence for the unified Signal.
54
-
55
- The size of the predicted move is compared against the recent realised
56
- volatility (standard deviation of first differences). A move of two standard
57
- deviations or more saturates confidence at 1.0. This is a documented,
58
- deterministic heuristic so that forecast-only models still populate the same
59
- confidence field MAYTHOS reports natively. See MERGE_NOTES.md (Decision 1).
60
- """
61
- arr = np.asarray(history, dtype=float)
62
- current = float(arr[-1])
63
- move = abs(float(predicted_close) - current)
64
- diffs = np.diff(arr)
65
- scale = float(np.std(diffs)) if diffs.size else 0.0
66
- if scale <= 0.0:
67
- return 1.0 if move > 0.0 else 0.0
68
- return float(min(1.0, move / (2.0 * scale)))
69
-
70
-
71
- class BaseForecastModel(BaseModel):
72
- """Base class for models that forecast a price path.
73
-
74
- Subclasses implement :meth:`predict` (a list of future closes). The concrete
75
- :meth:`predict_signal` here turns that path into the unified Signal, so every
76
- forecaster automatically speaks the same contract as MAYTHOS without any
77
- adapter or bridge layer.
78
- """
79
-
80
- name = 'base'
81
-
82
- @abstractmethod
83
- def predict(self, history: pd.Series, horizon: int = 1, features: pd.DataFrame = None) -> list:
84
- raise NotImplementedError
85
-
86
- @staticmethod
87
- def _history_series(window: Union[pd.DataFrame, pd.Series, Any]) -> pd.Series:
88
- if isinstance(window, pd.DataFrame):
89
- if 'Close' not in window.columns:
90
- raise ValueError("forecast window DataFrame must contain a 'Close' column.")
91
- return window['Close']
92
- return pd.Series(window)
93
-
94
- def predict_signal(self, window: pd.DataFrame, horizon: int = 1, features: pd.DataFrame = None) -> Signal:
95
- history = self._history_series(window)
96
- forecast: List[float] = list(self.predict(history, horizon=horizon, features=features))
97
- if len(forecast) < 1:
98
- raise ValueError(f'{self.name}: predict() returned an empty forecast.')
99
- predicted_close = float(forecast[-1])
100
- current_close = float(pd.Series(history).astype(float).iloc[-1])
101
- direction = 'up' if predicted_close > current_close else 'down'
102
- confidence = _confidence_from_forecast(pd.Series(history).astype(float).to_numpy(), predicted_close)
103
- return Signal(direction=direction, confidence=confidence, predicted_close=predicted_close, horizon=horizon, meta={})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
models/dependencies.py DELETED
@@ -1,32 +0,0 @@
1
- import contextlib
2
- import warnings
3
-
4
- def _missing(package: str, model: str, error: Exception) -> ImportError:
5
- return ImportError(f"{model} needs the '{package}' package. Install with:\n pip install {package}\n(original error: {error})")
6
-
7
- def import_arima(model_name: str):
8
- try:
9
- from statsmodels.tsa.arima.model import ARIMA
10
- except ImportError as e:
11
- raise _missing('statsmodels', model_name, e) from e
12
- return ARIMA
13
-
14
- def import_kpss(model_name: str):
15
- try:
16
- from statsmodels.tsa.stattools import kpss
17
- except ImportError as e:
18
- raise _missing('statsmodels', model_name, e) from e
19
- return kpss
20
-
21
- def import_arch_model(model_name: str):
22
- try:
23
- from arch import arch_model
24
- except ImportError as e:
25
- raise _missing('arch', model_name, e) from e
26
- return arch_model
27
-
28
- @contextlib.contextmanager
29
- def quiet_fit():
30
- with warnings.catch_warnings():
31
- warnings.simplefilter('ignore')
32
- yield
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
models/maythos/__init__.py DELETED
@@ -1,10 +0,0 @@
1
- """MAYTHOS decision-engine subpackage.
2
-
3
- Exposes the streaming engine (``MAYTHOS``, ``Candle``) and its model wrapper
4
- (``MaythosModel``) that plugs MAYTHOS into the unified model registry.
5
- """
6
-
7
- from models.maythos.engine import MAYTHOS, Candle
8
- from models.maythos.model import MaythosModel
9
-
10
- __all__ = ['MAYTHOS', 'Candle', 'MaythosModel']
 
 
 
 
 
 
 
 
 
 
 
models/maythos/engine.py DELETED
The diff for this file is too large to render. See raw diff
 
models/maythos/model.py DELETED
@@ -1,98 +0,0 @@
1
- """MAYTHOS as a first-class model in the unified project.
2
-
3
- ``MaythosModel`` wraps the streaming :class:`~models.maythos.engine.MAYTHOS`
4
- decision engine so it satisfies the same :class:`~models.base_model.BaseModel`
5
- contract as every forecasting model. It is registered in
6
- ``models.registry`` exactly like ARIMA/Moirai/TimesFM -- it is a real model, not
7
- an adapter or bridge. See MERGE_NOTES.md (Decision 3).
8
-
9
- MAYTHOS consumes full OHLCV candles and replays the history window tick by tick
10
- through a fresh engine instance, mirroring how the ARIMA family refits on each
11
- backtest window. It emits a directional decision (BUY/SELL) plus a confidence
12
- and rich market-state metadata rather than a price level, so ``predicted_close``
13
- in the resulting :class:`~models.base_model.Signal` is ``None``.
14
- """
15
-
16
- import numpy as np
17
- import pandas as pd
18
-
19
- from models.base_model import BaseModel, Signal, validate_history_and_horizon
20
- from models.maythos.engine import MAYTHOS, Candle
21
-
22
- _OHLC_COLUMNS = ('Open', 'High', 'Low', 'Close')
23
-
24
-
25
- def _epoch_seconds(index: pd.Index) -> np.ndarray:
26
- """Return monotonically increasing UTC epoch-seconds for a candle index.
27
-
28
- MAYTHOS uses timestamps only for gap/interval/latency reasoning, so any
29
- strictly increasing, evenly spaced sequence preserves its behaviour. A real
30
- DatetimeIndex is converted faithfully; a non-datetime index falls back to a
31
- one-minute synthetic cadence.
32
- """
33
- if isinstance(index, pd.DatetimeIndex):
34
- idx = index.tz_convert('UTC') if index.tz is not None else index
35
- nanos = idx.astype('int64').to_numpy()
36
- return nanos.astype('float64') / 1e9
37
- return np.arange(len(index), dtype='float64') * 60.0
38
-
39
-
40
- class MaythosModel(BaseModel):
41
- name = 'MAYTHOS'
42
-
43
- def predict_signal(self, window: pd.DataFrame, horizon: int = 1, features: pd.DataFrame = None) -> Signal:
44
- if not isinstance(window, pd.DataFrame):
45
- raise ValueError('MAYTHOS requires an OHLCV DataFrame window, not a bare series.')
46
- missing = [col for col in _OHLC_COLUMNS if col not in window.columns]
47
- if missing:
48
- raise ValueError(f"MAYTHOS requires OHLC columns; missing {missing} in the history window.")
49
- validate_history_and_horizon(window['Close'], horizon, self.name)
50
-
51
- timestamps = _epoch_seconds(window.index)
52
- opens = window['Open'].to_numpy(dtype=float)
53
- highs = window['High'].to_numpy(dtype=float)
54
- lows = window['Low'].to_numpy(dtype=float)
55
- closes = window['Close'].to_numpy(dtype=float)
56
- if 'Volume' in window.columns:
57
- volumes = window['Volume'].to_numpy(dtype=float)
58
- else:
59
- volumes = np.zeros(len(window), dtype=float)
60
-
61
- engine = MAYTHOS()
62
- output = None
63
- for k in range(len(window)):
64
- candle = Candle(
65
- timestamp=float(timestamps[k]),
66
- open=float(opens[k]),
67
- high=float(highs[k]),
68
- low=float(lows[k]),
69
- close=float(closes[k]),
70
- volume=float(volumes[k]),
71
- )
72
- output = engine.tick(candle)
73
-
74
- if output is None:
75
- raise ValueError('MAYTHOS received an empty history window.')
76
- errors = engine.validate(output)
77
- if errors:
78
- raise ValueError(f'MAYTHOS produced invalid output: {errors}')
79
-
80
- direction = 'up' if output['direction'] == 'BUY' else 'down'
81
- confidence = float(output['confidence'])
82
- meta = {
83
- 'execution_suitability': output.get('execution_suitability'),
84
- 'market_state': output.get('market_state'),
85
- 'regime_label': output.get('regime_label'),
86
- 'asset_mode': output.get('asset_mode'),
87
- 'internal_trust': output.get('internal_trust'),
88
- 'reason_summary': output.get('reason_summary'),
89
- 'is_warm': engine.is_warm,
90
- 'raw_direction': output['direction'],
91
- }
92
- return Signal(
93
- direction=direction,
94
- confidence=confidence,
95
- predicted_close=None,
96
- horizon=horizon,
97
- meta=meta,
98
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
models/moirai_model.py DELETED
@@ -1,103 +0,0 @@
1
- import numpy as np
2
- import pandas as pd
3
- from models.base_model import BaseForecastModel, validate_history_and_horizon
4
- from config import MOIRAI_CHECKPOINT
5
- from features.feature_pipeline import validate_features
6
-
7
-
8
- def _step_from_history(history: pd.Series) -> pd.Timedelta:
9
- if isinstance(history.index, pd.DatetimeIndex) and len(history.index) >= 2:
10
- diffs = history.index.to_series().diff().dropna()
11
- if not diffs.empty:
12
- step = diffs.median()
13
- if step > pd.Timedelta(0):
14
- return step
15
- return pd.Timedelta(minutes=1)
16
-
17
-
18
- class MoiraiModel(BaseForecastModel):
19
- name = "Moirai"
20
-
21
- def __init__(
22
- self,
23
- checkpoint: str = MOIRAI_CHECKPOINT,
24
- context_length: int = 200,
25
- num_samples: int = 100,
26
- ):
27
- self.checkpoint = checkpoint
28
- self.context_length = context_length
29
- self.num_samples = num_samples
30
- self._module = None
31
-
32
- def _get_module(self):
33
- from uni2ts.model.moirai import MoiraiModule
34
-
35
- if self._module is None:
36
- self._module = MoiraiModule.from_pretrained(self.checkpoint)
37
- return self._module
38
-
39
- def predict(
40
- self, history: pd.Series, horizon: int = 1, features: pd.DataFrame = None
41
- ) -> list:
42
- values = np.asarray(history, dtype=np.float32)
43
- if len(values) < 10:
44
- raise ValueError("Moirai needs at least 10 candles of history.")
45
- validate_history_and_horizon(values, horizon, self.name)
46
- try:
47
- from uni2ts.model.moirai import MoiraiForecast
48
- from gluonts.dataset.pandas import PandasDataset
49
- except ImportError as e:
50
- raise ImportError(
51
- f"Moirai needs the 'uni2ts' and 'gluonts' packages. Install with:\n pip install uni2ts torch\n(original error: {e})"
52
- )
53
- if features is not None:
54
- validate_features(
55
- features,
56
- expected_len=len(values),
57
- model_name="Moirai",
58
- check_nan=False,
59
- )
60
- context = (
61
- values[-self.context_length :]
62
- if len(values) > self.context_length
63
- else values
64
- )
65
- step = _step_from_history(history)
66
- end_ts = pd.Timestamp.now("UTC").floor(step)
67
- idx = pd.date_range(end=end_ts, periods=len(context), freq=step)
68
- data = {"target": context}
69
- feat_cols = []
70
- if features is not None:
71
- feat_context = features.iloc[-len(context) :].reset_index(drop=True)
72
- validate_features(
73
- feat_context, expected_len=len(context), model_name="Moirai"
74
- )
75
- for col in feat_context.columns:
76
- data[col] = feat_context[col].to_numpy(dtype=np.float32)
77
- feat_cols = list(feat_context.columns)
78
- series_df = pd.DataFrame(data, index=idx)
79
- dataset = PandasDataset(
80
- series_df,
81
- target="target",
82
- past_feat_dynamic_real=feat_cols if feat_cols else None,
83
- )
84
- module = self._get_module()
85
- forecaster = MoiraiForecast(
86
- module=module,
87
- prediction_length=horizon,
88
- context_length=len(context),
89
- patch_size="auto",
90
- num_samples=self.num_samples,
91
- target_dim=1,
92
- feat_dynamic_real_dim=0,
93
- past_feat_dynamic_real_dim=len(feat_cols),
94
- )
95
- predictor = forecaster.create_predictor(batch_size=1)
96
- forecast = next(iter(predictor.predict(dataset)))
97
- point_forecast = np.median(forecast.samples, axis=0)
98
- out = [float(x) for x in point_forecast]
99
- if not all(np.isfinite(x) for x in out):
100
- raise ValueError(
101
- "Moirai produced a non-finite forecast (NaN/inf) for this window."
102
- )
103
- return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
models/registry.py DELETED
@@ -1,42 +0,0 @@
1
- import threading
2
- from models.base_model import BaseModel
3
- _ORDER_SEARCH_MODELS = {'ARIMA', 'Auto-ARIMA', 'ARIMA-GARCH'}
4
- _CACHE: dict = {}
5
- _CACHE_LOCK = threading.Lock()
6
-
7
- def _build_model(name: str) -> BaseModel:
8
- if name == 'ARIMA':
9
- from models.arima_model import ArimaModel
10
- return ArimaModel()
11
- elif name == 'Auto-ARIMA':
12
- from models.auto_arima_model import AutoArimaModel
13
- return AutoArimaModel()
14
- elif name == 'ARIMA-GARCH':
15
- from models.arima_garch_model import ArimaGarchModel
16
- return ArimaGarchModel()
17
- elif name == 'Moirai':
18
- from models.moirai_model import MoiraiModel
19
- return MoiraiModel()
20
- elif name == 'TimesFM':
21
- from models.timesfm_model import TimesFMModel
22
- return TimesFMModel()
23
- elif name == 'MAYTHOS':
24
- from models.maythos.model import MaythosModel
25
- return MaythosModel()
26
- raise ValueError(f"Unknown model '{name}'. Expected one of: ARIMA, Auto-ARIMA, ARIMA-GARCH, Moirai, TimesFM, MAYTHOS.")
27
-
28
- def get_model(name: str, symbol: str=None, timeframe: str=None, use_features: bool=False) -> BaseModel:
29
- if name in _ORDER_SEARCH_MODELS:
30
- variant = 'featured' if use_features else 'base'
31
- key = (name, symbol, timeframe, variant)
32
- else:
33
- key = name
34
- with _CACHE_LOCK:
35
- if key in _CACHE:
36
- return _CACHE[key]
37
- model = _build_model(name)
38
- _CACHE[key] = model
39
- return model
40
-
41
- def fresh_model(name: str) -> BaseModel:
42
- return _build_model(name)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
models/timesfm_model.py DELETED
@@ -1,76 +0,0 @@
1
- import numpy as np
2
- import pandas as pd
3
- from models.base_model import BaseForecastModel, validate_history_and_horizon
4
- from config import TIMESFM_CHECKPOINT
5
- from features.feature_pipeline import carry_forward_future, validate_features
6
- _MAX_HORIZON = 64
7
-
8
- class TimesFMModel(BaseForecastModel):
9
- name = 'TimesFM'
10
-
11
- def __init__(self, checkpoint: str=TIMESFM_CHECKPOINT, max_context: int=512):
12
- self.checkpoint = checkpoint
13
- self.max_context = max_context
14
- self._model = None
15
- self._compiled_context = None
16
-
17
- @staticmethod
18
- def _forecast_config(max_context: int):
19
- import timesfm
20
- return timesfm.ForecastConfig(max_context=max_context, max_horizon=_MAX_HORIZON, normalize_inputs=True, use_continuous_quantile_head=True, force_flip_invariance=True, infer_is_positive=True, fix_quantile_crossing=True, return_backcast=True)
21
-
22
- def _load(self):
23
- import torch
24
- import timesfm
25
- if self._model is None:
26
- torch.set_float32_matmul_precision('high')
27
- model = timesfm.TimesFM_2p5_200M_torch.from_pretrained(self.checkpoint)
28
- model.compile(self._forecast_config(self.max_context))
29
- self._model = model
30
- self._compiled_context = self.max_context
31
- return self._model
32
-
33
- def _ensure_compiled_for(self, context_len: int):
34
- model = self._load()
35
- patch = getattr(model.model, 'p', 32)
36
- desired = min(self.max_context, max(patch, (context_len + patch - 1) // patch * patch))
37
- if desired != self._compiled_context:
38
- model.compile(self._forecast_config(desired))
39
- self._compiled_context = desired
40
- return model
41
-
42
- def predict(self, history: pd.Series, horizon: int=1, features: pd.DataFrame=None) -> list:
43
- values = np.asarray(history, dtype=np.float32)
44
- if len(values) < 32:
45
- raise ValueError('TimesFM needs at least 32 candles of history.')
46
- validate_history_and_horizon(values, horizon, self.name)
47
- try:
48
- import timesfm
49
- except ImportError as e:
50
- raise ImportError(f"TimesFM needs the 'timesfm' package. Install with:\n pip install timesfm[torch]\n(original error: {e})")
51
- if horizon > _MAX_HORIZON:
52
- raise ValueError(f'This TimesFM wrapper supports at most {_MAX_HORIZON} candles ahead.')
53
- if features is not None:
54
- validate_features(features, expected_len=len(values), model_name='TimesFM', check_nan=False)
55
- context = values[-self.max_context:] if len(values) > self.max_context else values
56
- model = self._ensure_compiled_for(len(context))
57
- if features is None:
58
- point_forecast, _quantiles = model.forecast(horizon=horizon, inputs=[context])
59
- point_values = np.asarray(point_forecast[0])[-horizon:]
60
- else:
61
- feat_context = features.iloc[-len(context):].reset_index(drop=True)
62
- validate_features(feat_context, expected_len=len(context), model_name='TimesFM')
63
- feat_future = carry_forward_future(feat_context, horizon)
64
- dynamic_numerical_covariates = {}
65
- for col in feat_context.columns:
66
- full_array = np.concatenate([feat_context[col].to_numpy(dtype=np.float32), feat_future[col].to_numpy(dtype=np.float32)])
67
- dynamic_numerical_covariates[col] = [full_array]
68
- try:
69
- point_forecast, _quantiles = model.forecast_with_covariates(inputs=[context], dynamic_numerical_covariates=dynamic_numerical_covariates, xreg_mode='xreg + timesfm')
70
- point_values = np.asarray(point_forecast[0])[:horizon]
71
- except (ImportError, AttributeError) as e:
72
- raise ImportError(f"TimesFM's covariate forecasting needs the 'xreg' extra (scikit-learn + JAX). Install with:\n pip install timesfm[torch,xreg]\n(original error: {e})")
73
- out = [float(x) for x in point_values]
74
- if not all((np.isfinite(x) for x in out)):
75
- raise ValueError('TimesFM returned a non-finite forecast (NaN/inf) for this window. This is usually a sign the input window is degenerate (e.g. a flat or near-constant price run) — try a different window size or symbol.')
76
- return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt DELETED
@@ -1,14 +0,0 @@
1
- --extra-index-url https://download.pytorch.org/whl/cpu
2
- pandas>=2.1
3
- numpy>=1.24
4
- yfinance>=0.2.40
5
- gradio>=4.36
6
- plotly>=5.20
7
- statsmodels>=0.14
8
- arch>=6.3
9
- uni2ts
10
- gluonts
11
- timesfm[torch,xreg]
12
- torch
13
- einops
14
- huggingface_hub
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/__init__.py DELETED
@@ -1 +0,0 @@
1
- pass
 
 
tests/test_backtester_alignment.py DELETED
@@ -1,175 +0,0 @@
1
- import unittest
2
- import json
3
- import numpy as np
4
- import pandas as pd
5
- from backtest.backtester import backtest_model, run_all_models_backtest, run_comparison_backtest
6
- from features.feature_pipeline import FEATURE_COLUMNS, compute_feature_frame
7
- from models.base_model import BaseForecastModel
8
-
9
- def _synthetic_ohlcv(n: int, seed: int=7) -> pd.DataFrame:
10
- rng = np.random.default_rng(seed)
11
- steps = rng.normal(loc=0.0, scale=0.4, size=n)
12
- close = 100 + np.cumsum(steps)
13
- close = np.abs(close) + 5.0
14
- open_ = close + rng.normal(0, 0.1, n)
15
- high = np.maximum(open_, close) + np.abs(rng.normal(0, 0.2, n))
16
- low = np.minimum(open_, close) - np.abs(rng.normal(0, 0.2, n))
17
- volume = rng.integers(100, 10000, n).astype(float)
18
- idx = pd.date_range('2024-01-01', periods=n, freq='min')
19
- return pd.DataFrame({'Open': open_, 'High': high, 'Low': low, 'Close': close, 'Volume': volume}, index=idx)
20
-
21
- class _AlignmentCheckingModel(BaseForecastModel):
22
- name = 'AlignmentChecker'
23
- RAW_COLUMNS = ['Open', 'High', 'Low', 'Volume']
24
-
25
- def __init__(self, df: pd.DataFrame):
26
- self.full_close = df['Close'].reset_index(drop=True)
27
- self.raw = df[self.RAW_COLUMNS].reset_index(drop=True)
28
-
29
- def predict(self, history, horizon=1, features=None):
30
- history = pd.Series(history).reset_index(drop=True)
31
- if features is not None:
32
- assert len(features) == len(history), f'length mismatch: history={len(history)} features={len(features)}'
33
- assert list(features.columns) == FEATURE_COLUMNS
34
- n = len(self.full_close)
35
- w = len(history)
36
- match_start = None
37
- for start in range(0, n - w + 1):
38
- if np.allclose(self.full_close.iloc[start:start + w].to_numpy(), history.to_numpy()):
39
- match_start = start
40
- break
41
- assert match_start is not None, 'history window not found in full series -- corrupted slice'
42
- if features is not None:
43
- expected = self.raw.iloc[match_start:match_start + w].to_numpy()
44
- got = features[self.RAW_COLUMNS].to_numpy()
45
- assert np.allclose(got, expected), f'features window is shifted relative to history at position {match_start}'
46
- return [float(history.iloc[-1])] * horizon
47
-
48
- class TestBacktesterAlignment(unittest.TestCase):
49
-
50
- def setUp(self):
51
- self.df = _synthetic_ohlcv(200, seed=11)
52
- self.features_df = compute_feature_frame(self.df)
53
-
54
- def test_no_failed_rows_with_features(self):
55
- model = _AlignmentCheckingModel(self.df)
56
- results = backtest_model(self.df, model, window=40, horizon=1, step=1, features_df=self.features_df)
57
- failed = results['error'].notna().sum()
58
- self.assertEqual(failed, 0, f"{failed} rows failed alignment checks:\n{results[results['error'].notna()]}")
59
- self.assertGreater(len(results), 0)
60
-
61
- def test_features_window_matches_close_window_exactly(self):
62
- close = self.df['Close'].reset_index(drop=True)
63
- window, horizon, step = (35, 2, 3)
64
- last_i = len(close) - horizon
65
- for i in range(window, last_i + 1, step):
66
- expected_close_window = close.iloc[i - window:i].reset_index(drop=True)
67
- expected_feat_window = self.features_df.iloc[i - window:i].reset_index(drop=True)
68
- source_volume = self.df['Volume'].reset_index(drop=True).iloc[i - window:i].reset_index(drop=True)
69
- pd.testing.assert_series_equal(expected_feat_window['Volume'], source_volume, check_names=False)
70
- self.assertEqual(len(expected_close_window), len(expected_feat_window))
71
-
72
- def test_baseline_mode_never_touches_features_module(self):
73
-
74
- class RecordsFeatureArg(BaseForecastModel):
75
- name = 'Recorder'
76
- saw_features = []
77
-
78
- def predict(self, history, horizon=1, features=None):
79
- RecordsFeatureArg.saw_features.append(features)
80
- return [float(pd.Series(history).iloc[-1])] * horizon
81
- RecordsFeatureArg.saw_features.clear()
82
- _ = backtest_model(self.df, RecordsFeatureArg(), window=40, horizon=1, step=5)
83
- self.assertTrue(len(RecordsFeatureArg.saw_features) > 0)
84
- self.assertTrue(all((f is None for f in RecordsFeatureArg.saw_features)))
85
-
86
- class TestRunAllModelsBacktestFeaturesDf(unittest.TestCase):
87
-
88
- def setUp(self):
89
- self.df = _synthetic_ohlcv(150, seed=13)
90
- self.features_df = compute_feature_frame(self.df)
91
-
92
- def test_features_df_none_passes_none_through(self):
93
-
94
- class Recorder(BaseForecastModel):
95
- name = 'Recorder'
96
-
97
- def __init__(self):
98
- self.seen = []
99
-
100
- def predict(self, history, horizon=1, features=None):
101
- self.seen.append(features is None)
102
- return [float(pd.Series(history).iloc[-1])] * horizon
103
- m = Recorder()
104
- run_all_models_backtest(self.df, {'Recorder': m}, window=40, horizon=1, step=10, features_df=None)
105
- self.assertTrue(all(m.seen))
106
-
107
- def test_features_df_given_passes_dataframe_through(self):
108
-
109
- class Recorder(BaseForecastModel):
110
- name = 'Recorder'
111
-
112
- def __init__(self):
113
- self.seen = []
114
-
115
- def predict(self, history, horizon=1, features=None):
116
- self.seen.append(features is not None and len(features) == len(history))
117
- return [float(pd.Series(history).iloc[-1])] * horizon
118
- m = Recorder()
119
- run_all_models_backtest(self.df, {'Recorder': m}, window=40, horizon=1, step=10, features_df=self.features_df)
120
- self.assertTrue(len(m.seen) > 0)
121
- self.assertTrue(all(m.seen))
122
-
123
- class TestRunComparisonBacktest(unittest.TestCase):
124
-
125
- def setUp(self):
126
- self.df = _synthetic_ohlcv(150, seed=17)
127
- self.features_df = compute_feature_frame(self.df)
128
-
129
- def test_both_modes_run_and_are_labeled_separately(self):
130
-
131
- class Recorder(BaseForecastModel):
132
- name = 'Recorder'
133
-
134
- def __init__(self):
135
- self.seen_features = []
136
-
137
- def predict(self, history, horizon=1, features=None):
138
- self.seen_features.append(features is not None)
139
- return [float(pd.Series(history).iloc[-1])] * horizon
140
- baseline_model, featured_model = (Recorder(), Recorder())
141
- results, summaries = run_comparison_backtest(self.df, {'Recorder': (baseline_model, featured_model)}, self.features_df, window=40, horizon=1, step=10)
142
- self.assertTrue(all((f is False for f in baseline_model.seen_features)))
143
- self.assertTrue(all((f is True for f in featured_model.seen_features)))
144
- modes_present = set(results['Recorder']['mode'].unique())
145
- self.assertEqual(modes_present, {'Close-only', '+14 Features'})
146
- self.assertEqual(len(summaries['Recorder']), 2)
147
- labels = {s['mode'] for s in summaries['Recorder']}
148
- self.assertEqual(labels, {'Close-only', '+14 Features'})
149
-
150
- class TestUiSerializableTimestamps(unittest.TestCase):
151
-
152
- def test_backtest_results_json_serializable(self):
153
- model = _ConstantModel()
154
- results = backtest_model(self._ohlcv(), model, window=20, horizon=1, step=5)
155
- self.assertEqual(results['datetime'].map(type).eq(str).all(), True)
156
- json.dumps(results.to_dict(orient='records'))
157
-
158
- def test_live_log_new_row_json_serializable(self):
159
- row = {'predicted_at': pd.Timestamp('2026-01-01 00:00:00+00:00').isoformat(), 'mode': 'Close-only', 'target_time': pd.Timestamp('2026-01-01 00:01:00+00:00').isoformat(), 'current_close': 1.0, 'predicted_close': 1.0, 'predicted_direction': 'up', 'actual_close': np.nan, 'actual_direction': None, 'correct': None}
160
- json.dumps(row)
161
-
162
- @staticmethod
163
- def _ohlcv(n: int=60, seed: int=3) -> pd.DataFrame:
164
- rng = np.random.default_rng(seed)
165
- close = 100 + np.cumsum(rng.normal(0, 0.5, n))
166
- idx = pd.date_range('2026-01-01', periods=n, freq='15min', tz='UTC')
167
- return pd.DataFrame({'Open': close, 'High': close + 1, 'Low': close - 1, 'Close': close, 'Volume': 1000.0}, index=idx)
168
-
169
- class _ConstantModel(BaseForecastModel):
170
-
171
- def predict(self, history, horizon=1, features=None):
172
- return np.array([float(history.iloc[-1])] * horizon)
173
- name = 'ConstantModel'
174
- if __name__ == '__main__':
175
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_data_layer.py DELETED
@@ -1,191 +0,0 @@
1
- import math
2
- import unittest
3
- from datetime import timedelta
4
- from unittest import mock
5
- import numpy as np
6
- import pandas as pd
7
- from config import YF_NATIVE_INTERVALS
8
- from data.historical import OHLCV_COLUMNS, DataFetchError, get_historical, get_historical_with_features, resample_ohlc
9
- from features.feature_pipeline import FEATURE_COLUMNS
10
-
11
- def _window_candles(start, end, freq: str) -> pd.DataFrame:
12
- idx = pd.date_range(start=start, end=end, freq=freq, inclusive='left')
13
- if len(idx) == 0:
14
- return pd.DataFrame()
15
- n = len(idx)
16
- base = 100.0 + np.arange(n, dtype=float) * 0.1
17
- return pd.DataFrame({'Open': base, 'High': base + 0.5, 'Low': base - 0.5, 'Close': base + 0.2, 'Volume': np.arange(n, dtype=float) + 1.0, 'Dividends': np.zeros(n)}, index=idx)
18
-
19
- class _RecordingFetcher:
20
-
21
- def __init__(self, freq: str='1h', empty_calls=()):
22
- self.freq = freq
23
- self.empty_calls = set(empty_calls)
24
- self.calls = []
25
- self.frames = []
26
-
27
- def __call__(self, symbol, interval, start, end):
28
- self.calls.append({'symbol': symbol, 'interval': interval, 'start': start, 'end': end})
29
- if len(self.calls) in self.empty_calls:
30
- return pd.DataFrame()
31
- frame = _window_candles(start, end, self.freq)
32
- self.frames.append(frame)
33
- return frame
34
-
35
- @property
36
- def spans(self):
37
- return [c['end'] - c['start'] for c in self.calls]
38
-
39
- @property
40
- def total_span(self):
41
- return max((c['end'] for c in self.calls)) - min((c['start'] for c in self.calls))
42
-
43
- def raw(self) -> pd.DataFrame:
44
- return pd.concat(self.frames).sort_index()
45
-
46
- class TestLookbackIsHonored(unittest.TestCase):
47
-
48
- def test_daily_lookback_requests_exactly_that_window(self):
49
- fetcher = _RecordingFetcher(freq='1D')
50
- with mock.patch('data.historical._fetch_yf', fetcher):
51
- df = get_historical('BTC-USD', '1d', lookback_days=30)
52
- self.assertEqual(len(fetcher.calls), 1)
53
- self.assertEqual(fetcher.calls[0]['interval'], '1d')
54
- self.assertEqual(fetcher.spans[0], timedelta(days=30))
55
- self.assertLessEqual(len(df), 31)
56
- self.assertGreater(len(df), 0)
57
-
58
- def test_returned_frame_is_ordered_and_ohlcv_only(self):
59
- fetcher = _RecordingFetcher(freq='1D')
60
- with mock.patch('data.historical._fetch_yf', fetcher):
61
- df = get_historical('BTC-USD', '1d', lookback_days=10)
62
- self.assertEqual(list(df.columns), OHLCV_COLUMNS)
63
- self.assertNotIn('Dividends', df.columns)
64
- self.assertTrue(df.index.is_monotonic_increasing)
65
- self.assertFalse(df.index.duplicated().any())
66
-
67
- def test_no_lookback_falls_back_to_the_interval_limit(self):
68
- fetcher = _RecordingFetcher(freq='1h')
69
- with mock.patch('data.historical._fetch_yf', fetcher):
70
- get_historical('BTC-USD', '60m')
71
- self.assertEqual(fetcher.total_span, timedelta(days=YF_NATIVE_INTERVALS['60m']['max_days']))
72
-
73
- class TestChunkedFetching(unittest.TestCase):
74
-
75
- def setUp(self):
76
- self.limits = YF_NATIVE_INTERVALS['1m']
77
-
78
- def test_window_wider_than_the_request_limit_is_split(self):
79
- requested = 30
80
- fetcher = _RecordingFetcher(freq='1h')
81
- with mock.patch('data.historical._fetch_yf', fetcher):
82
- df = get_historical('BTC-USD', '1m', lookback_days=requested)
83
- request_days = self.limits['max_request_days']
84
- self.assertEqual(len(fetcher.calls), math.ceil(requested / request_days))
85
- for span in fetcher.spans:
86
- self.assertLessEqual(span, timedelta(days=request_days))
87
- self.assertEqual(sum(fetcher.spans, timedelta()), timedelta(days=requested))
88
- self.assertEqual(fetcher.total_span, timedelta(days=requested))
89
- self.assertGreater(len(df), 0)
90
-
91
- def test_chunks_are_contiguous_with_no_gap_or_overlap(self):
92
- fetcher = _RecordingFetcher(freq='1h')
93
- with mock.patch('data.historical._fetch_yf', fetcher):
94
- get_historical('BTC-USD', '1m', lookback_days=20)
95
- for newer, older in zip(fetcher.calls, fetcher.calls[1:]):
96
- self.assertEqual(newer['start'], older['end'])
97
-
98
- def test_lookback_beyond_the_source_limit_is_clamped(self):
99
- fetcher = _RecordingFetcher(freq='1h')
100
- with mock.patch('data.historical._fetch_yf', fetcher):
101
- get_historical('BTC-USD', '1m', lookback_days=90)
102
- max_days = self.limits['max_days']
103
- self.assertEqual(fetcher.total_span, timedelta(days=max_days))
104
- self.assertEqual(len(fetcher.calls), math.ceil(max_days / self.limits['max_request_days']))
105
-
106
- def test_one_empty_chunk_does_not_abort_the_fetch(self):
107
- fetcher = _RecordingFetcher(freq='1h', empty_calls=(2,))
108
- with mock.patch('data.historical._fetch_yf', fetcher):
109
- df = get_historical('BTC-USD', '1m', lookback_days=30)
110
- self.assertEqual(len(fetcher.calls), 5)
111
- self.assertEqual(len(df), len(fetcher.raw()))
112
- self.assertGreater(len(df), 0)
113
-
114
- def test_all_chunks_empty_reports_the_source_limit(self):
115
- fetcher = _RecordingFetcher(freq='1h', empty_calls=range(1, 6))
116
- with mock.patch('data.historical._fetch_yf', fetcher):
117
- with self.assertRaises(DataFetchError) as ctx:
118
- get_historical('NOPE-USD', '1m', lookback_days=30)
119
- message = str(ctx.exception)
120
- self.assertIn('NOPE-USD', message)
121
- self.assertIn('1m', message)
122
- self.assertIn(str(self.limits['max_days']), message)
123
-
124
- class TestTimeframeValidation(unittest.TestCase):
125
-
126
- def test_unknown_timeframe_raises_before_any_request(self):
127
- fetcher = _RecordingFetcher()
128
- with mock.patch('data.historical._fetch_yf', fetcher):
129
- with self.assertRaises(DataFetchError):
130
- get_historical('BTC-USD', '3y', lookback_days=5)
131
- self.assertEqual(fetcher.calls, [])
132
-
133
- def test_blank_symbol_raises_before_any_request(self):
134
- fetcher = _RecordingFetcher()
135
- with mock.patch('data.historical._fetch_yf', fetcher):
136
- with self.assertRaises(DataFetchError):
137
- get_historical(' ', '1d', lookback_days=5)
138
- self.assertEqual(fetcher.calls, [])
139
-
140
- class TestResampling(unittest.TestCase):
141
-
142
- def test_custom_timeframe_resamples_from_its_source_interval(self):
143
- fetcher = _RecordingFetcher(freq='1h')
144
- with mock.patch('data.historical._fetch_yf', fetcher):
145
- df = get_historical('BTC-USD', '4h', lookback_days=5)
146
- self.assertEqual([c['interval'] for c in fetcher.calls], ['60m'])
147
- spacing = df.index.to_series().diff().dropna().unique()
148
- self.assertEqual(list(spacing), [pd.Timedelta(hours=4)])
149
- raw = fetcher.raw()
150
- bar_time = df.index[len(df) // 2]
151
- bucket = raw[(raw.index >= bar_time) & (raw.index < bar_time + pd.Timedelta(hours=4))]
152
- self.assertEqual(len(bucket), 4)
153
- self.assertAlmostEqual(df.loc[bar_time, 'Open'], float(bucket['Open'].iloc[0]))
154
- self.assertAlmostEqual(df.loc[bar_time, 'High'], float(bucket['High'].max()))
155
- self.assertAlmostEqual(df.loc[bar_time, 'Low'], float(bucket['Low'].min()))
156
- self.assertAlmostEqual(df.loc[bar_time, 'Close'], float(bucket['Close'].iloc[-1]))
157
- self.assertAlmostEqual(df.loc[bar_time, 'Volume'], float(bucket['Volume'].sum()))
158
-
159
- def test_resample_ohlc_aggregates_each_field(self):
160
- idx = pd.date_range('2024-01-01', periods=4, freq='1h')
161
- df = pd.DataFrame({'Open': [10.0, 11.0, 12.0, 13.0], 'High': [15.0, 11.5, 20.0, 13.5], 'Low': [9.0, 8.0, 11.0, 12.0], 'Close': [11.0, 12.0, 13.0, 14.0], 'Volume': [1.0, 2.0, 3.0, 4.0]}, index=idx)
162
- out = resample_ohlc(df, '4h')
163
- self.assertEqual(len(out), 1)
164
- row = out.iloc[0]
165
- self.assertEqual(row['Open'], 10.0)
166
- self.assertEqual(row['High'], 20.0)
167
- self.assertEqual(row['Low'], 8.0)
168
- self.assertEqual(row['Close'], 14.0)
169
- self.assertEqual(row['Volume'], 10.0)
170
-
171
- class TestGetHistoricalWithFeatures(unittest.TestCase):
172
-
173
- def test_features_off_returns_none_and_requests_no_padding(self):
174
- fetcher = _RecordingFetcher(freq='1h')
175
- with mock.patch('data.historical._fetch_yf', fetcher):
176
- df, features = get_historical_with_features('BTC-USD', '1h', lookback_days=3, use_features=False)
177
- self.assertIsNone(features)
178
- self.assertEqual(fetcher.total_span, timedelta(days=3))
179
- self.assertGreater(len(df), 0)
180
-
181
- def test_features_on_pads_history_and_returns_aligned_clean_frames(self):
182
- fetcher = _RecordingFetcher(freq='1h')
183
- with mock.patch('data.historical._fetch_yf', fetcher):
184
- df, features = get_historical_with_features('BTC-USD', '1h', lookback_days=3, use_features=True)
185
- self.assertGreater(fetcher.total_span, timedelta(days=3))
186
- self.assertEqual(list(features.columns), FEATURE_COLUMNS)
187
- self.assertEqual(len(features), len(df))
188
- self.assertFalse(features.isna().any().any())
189
- self.assertLess(len(df), len(fetcher.raw()))
190
- if __name__ == '__main__':
191
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_feature_pipeline.py DELETED
@@ -1,167 +0,0 @@
1
- import unittest
2
- import numpy as np
3
- import pandas as pd
4
- from features.feature_pipeline import FEATURE_COLUMNS, carry_forward_future, compute_feature_frame, compute_feature_frame_for_period, slice_features, validate_features
5
-
6
- def _synthetic_ohlcv(n: int, seed: int=0) -> pd.DataFrame:
7
- rng = np.random.default_rng(seed)
8
- steps = rng.normal(loc=0.0, scale=0.5, size=n)
9
- close = 100 + np.cumsum(steps)
10
- close = np.abs(close) + 1.0
11
- open_ = close + rng.normal(0, 0.1, n)
12
- high = np.maximum(open_, close) + np.abs(rng.normal(0, 0.2, n))
13
- low = np.minimum(open_, close) - np.abs(rng.normal(0, 0.2, n))
14
- volume = rng.integers(100, 10000, n).astype(float)
15
- idx = pd.date_range('2024-01-01', periods=n, freq='min')
16
- return pd.DataFrame({'Open': open_, 'High': high, 'Low': low, 'Close': close, 'Volume': volume}, index=idx)
17
-
18
- class TestNoLookahead(unittest.TestCase):
19
-
20
- def test_truncation_invariance(self):
21
- df = _synthetic_ohlcv(300, seed=1)
22
- full = compute_feature_frame(df)
23
- for cutoff in (50, 100, 150, 250, 299):
24
- truncated = compute_feature_frame(df.iloc[:cutoff + 1])
25
- overlap = min(len(full), len(truncated))
26
- pd.testing.assert_frame_equal(full.iloc[:overlap].reset_index(drop=True), truncated.iloc[:overlap].reset_index(drop=True), check_exact=False, atol=1e-10, obj=f'feature frame truncated at row {cutoff}')
27
-
28
- def test_single_row_extension_does_not_rewrite_history(self):
29
- df = _synthetic_ohlcv(120, seed=2)
30
- shorter = compute_feature_frame(df.iloc[:100])
31
- longer = compute_feature_frame(df.iloc[:101])
32
- pd.testing.assert_frame_equal(shorter.reset_index(drop=True), longer.iloc[:100].reset_index(drop=True), check_exact=False, atol=1e-10)
33
-
34
- class TestShapeAndColumns(unittest.TestCase):
35
-
36
- def test_columns_are_exactly_the_expected_14(self):
37
- df = _synthetic_ohlcv(200, seed=3)
38
- feats = compute_feature_frame(df)
39
- self.assertEqual(list(feats.columns), FEATURE_COLUMNS)
40
- self.assertEqual(len(FEATURE_COLUMNS), 14)
41
-
42
- def test_close_is_not_a_feature_column(self):
43
- self.assertNotIn('Close', FEATURE_COLUMNS)
44
-
45
- def test_bb_mid_not_duplicated_as_sma20(self):
46
- self.assertIn('SMA_20', FEATURE_COLUMNS)
47
- self.assertNotIn('BB_Mid', FEATURE_COLUMNS)
48
-
49
- class TestNoNaN(unittest.TestCase):
50
-
51
- def test_no_nan_once_past_warmup(self):
52
- df = _synthetic_ohlcv(200, seed=4)
53
- feats = compute_feature_frame(df)
54
- self.assertFalse(feats.iloc[60:].isna().any().any())
55
-
56
- def test_first_19_rows_ARE_nan_without_padding(self):
57
- df = _synthetic_ohlcv(500, seed=99)
58
- feats = compute_feature_frame(df)
59
- self.assertTrue(feats.iloc[:19].isna().any().any())
60
- self.assertFalse(feats.iloc[19:].isna().any().any())
61
-
62
- def test_missing_ohlcv_column_raises(self):
63
- bad = pd.DataFrame({'Open': [1, 2], 'High': [1, 2], 'Low': [1, 2]})
64
- with self.assertRaises(ValueError):
65
- compute_feature_frame(bad)
66
-
67
- class TestComputeFeatureFrameForPeriod(unittest.TestCase):
68
-
69
- def test_no_nan_anywhere_in_the_trimmed_result(self):
70
- df_padded = _synthetic_ohlcv(60 * 24 * 3, seed=21)
71
- df_trimmed, feats_trimmed = compute_feature_frame_for_period(df_padded, keep_days=2)
72
- self.assertFalse(feats_trimmed.isna().any().any(), 'compute_feature_frame_for_period must never leave leading NaN in the period actually requested')
73
-
74
- def test_trimmed_period_is_the_requested_length_not_the_padded_one(self):
75
- df_padded = _synthetic_ohlcv(60 * 24 * 3, seed=22)
76
- keep_days = 1
77
- df_trimmed, feats_trimmed = compute_feature_frame_for_period(df_padded, keep_days=keep_days)
78
- expected_span = df_padded.index[-1] - df_trimmed.index[0]
79
- self.assertLessEqual(expected_span, pd.Timedelta(days=keep_days) + pd.Timedelta(minutes=1))
80
- self.assertEqual(len(df_trimmed), len(feats_trimmed))
81
-
82
- def test_trimmed_values_match_computing_on_the_full_padded_series(self):
83
- df_padded = _synthetic_ohlcv(60 * 24 * 2, seed=23)
84
- full_features = compute_feature_frame(df_padded)
85
- df_trimmed, feats_trimmed = compute_feature_frame_for_period(df_padded, keep_days=1)
86
- keep_mask = df_padded.index >= df_padded.index[-1] - pd.Timedelta(days=1)
87
- expected = full_features.iloc[keep_mask].reset_index(drop=True)
88
- pd.testing.assert_frame_equal(feats_trimmed, expected, atol=1e-10)
89
-
90
- def test_reproduces_and_fixes_the_reported_btc_1m_window60_case(self):
91
- from utils.helpers import feature_warmup_days
92
- keep_days = 1
93
- padding_days = feature_warmup_days('1m', minimum_days=0)
94
- df_padded = _synthetic_ohlcv(60 * 24 * (keep_days + padding_days) + 100, seed=24)
95
- df_trimmed, feats_trimmed = compute_feature_frame_for_period(df_padded, keep_days=keep_days)
96
- window = 60
97
- first_window = slice_features(feats_trimmed, 0, window)
98
- self.assertFalse(first_window.isna().any().any(), 'the FIRST walk-forward window must be NaN-free after padding')
99
-
100
- class TestSliceFeatures(unittest.TestCase):
101
-
102
- def test_slice_matches_close_slice_positionally(self):
103
- df = _synthetic_ohlcv(200, seed=5)
104
- feats = compute_feature_frame(df)
105
- close = df['Close'].reset_index(drop=True)
106
- window, i = (60, 130)
107
- close_window = close.iloc[i - window:i]
108
- feat_window = slice_features(feats, i - window, i)
109
- self.assertEqual(len(close_window), len(feat_window))
110
- self.assertAlmostEqual(df['Close'].iloc[i - 1], close_window.iloc[-1], places=8)
111
-
112
- class TestValidateFeatures(unittest.TestCase):
113
-
114
- def test_length_mismatch_raises(self):
115
- feats = pd.DataFrame({c: [1.0, 2.0] for c in FEATURE_COLUMNS})
116
- with self.assertRaises(ValueError):
117
- validate_features(feats, expected_len=3, model_name='Test')
118
-
119
- def test_nan_raises(self):
120
- feats = pd.DataFrame({c: [1.0, np.nan] for c in FEATURE_COLUMNS})
121
- with self.assertRaises(ValueError):
122
- validate_features(feats, expected_len=2, model_name='Test')
123
-
124
- def test_check_nan_false_skips_nan_but_still_checks_length(self):
125
- feats_with_nan = pd.DataFrame({c: [np.nan, 1.0, 2.0] for c in FEATURE_COLUMNS})
126
- validate_features(feats_with_nan, expected_len=3, model_name='Test', check_nan=False)
127
- with self.assertRaises(ValueError):
128
- validate_features(feats_with_nan, expected_len=2, model_name='Test', check_nan=False)
129
-
130
- def test_check_nan_false_then_true_on_truncated_tail_mirrors_model_usage(self):
131
- feats = pd.DataFrame({c: [np.nan, 1.0, 2.0] for c in FEATURE_COLUMNS})
132
- validate_features(feats, expected_len=3, model_name='Test', check_nan=False)
133
- clean_tail = feats.iloc[-2:].reset_index(drop=True)
134
- validate_features(clean_tail, expected_len=2, model_name='Test')
135
- dirty_tail = feats.iloc[-3:].reset_index(drop=True)
136
- with self.assertRaises(ValueError):
137
- validate_features(dirty_tail, expected_len=3, model_name='Test')
138
-
139
- def test_clean_window_passes(self):
140
- feats = pd.DataFrame({c: [1.0, 2.0, 3.0] for c in FEATURE_COLUMNS})
141
- validate_features(feats, expected_len=3, model_name='Test')
142
-
143
- class TestCarryForwardFuture(unittest.TestCase):
144
-
145
- def test_repeats_last_row(self):
146
- feats = pd.DataFrame({'a': [1.0, 2.0, 3.0], 'b': [10.0, 20.0, 30.0]})
147
- future = carry_forward_future(feats, horizon=4)
148
- self.assertEqual(len(future), 4)
149
- self.assertTrue((future['a'] == 3.0).all())
150
- self.assertTrue((future['b'] == 30.0).all())
151
-
152
- def test_uses_zero_future_information(self):
153
- df = _synthetic_ohlcv(150, seed=6)
154
- feats_short = compute_feature_frame(df.iloc[:80])
155
- feats_long = compute_feature_frame(df)
156
- future_from_short = carry_forward_future(feats_short.iloc[:80], horizon=5)
157
- future_from_long = carry_forward_future(feats_long.iloc[:80], horizon=5)
158
- pd.testing.assert_frame_equal(future_from_short, future_from_long, atol=1e-10)
159
-
160
- def test_rejects_bad_input(self):
161
- feats = pd.DataFrame({'a': [1.0]})
162
- with self.assertRaises(ValueError):
163
- carry_forward_future(feats, horizon=0)
164
- with self.assertRaises(ValueError):
165
- carry_forward_future(pd.DataFrame({'a': []}), horizon=1)
166
- if __name__ == '__main__':
167
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_helpers.py DELETED
@@ -1,113 +0,0 @@
1
- import unittest
2
- import pandas as pd
3
- from config import CUSTOM_TIMEFRAMES, YF_NATIVE_INTERVALS
4
- from utils.helpers import INDICATOR_WARMUP_CANDLES, MINUTES_PER_CANDLE, PREDICTION_HISTORY_CANDLES, drop_forming_candle, feature_warmup_days, future_index, history_days_for_candles, infer_step
5
-
6
- def _frame(index) -> pd.DataFrame:
7
- return pd.DataFrame({'Close': range(len(index))}, index=index)
8
-
9
- class TestHistoryDaysForCandles(unittest.TestCase):
10
-
11
- def test_window_covers_the_requested_candle_count(self):
12
- for timeframe, minutes in MINUTES_PER_CANDLE.items():
13
- days = history_days_for_candles(timeframe, 200)
14
- self.assertGreaterEqual(days * 24 * 60, 200 * minutes, timeframe)
15
-
16
- def test_known_sizes(self):
17
- self.assertEqual(history_days_for_candles('1d', 200), 200)
18
- self.assertEqual(history_days_for_candles('4h', 200), 34)
19
- self.assertEqual(history_days_for_candles('5m', 200), 1)
20
-
21
- def test_minimum_days_acts_as_a_floor_only(self):
22
- self.assertEqual(history_days_for_candles('1m', 10, minimum_days=2), 2)
23
- self.assertEqual(history_days_for_candles('1d', 10, minimum_days=2), 10)
24
-
25
- def test_non_positive_candle_counts_still_request_a_real_window(self):
26
- self.assertEqual(history_days_for_candles('1h', 0), 1)
27
- self.assertEqual(history_days_for_candles('1h', -5), 1)
28
-
29
- def test_unknown_timeframe_uses_the_hourly_fallback(self):
30
- self.assertEqual(history_days_for_candles('7m', 200), history_days_for_candles('1h', 200))
31
-
32
- class TestFeatureWarmupDays(unittest.TestCase):
33
-
34
- def test_padding_covers_the_indicator_warmup(self):
35
- for timeframe, minutes in MINUTES_PER_CANDLE.items():
36
- days = feature_warmup_days(timeframe, minimum_days=0)
37
- self.assertGreaterEqual(days * 24 * 60, INDICATOR_WARMUP_CANDLES * minutes, timeframe)
38
-
39
- def test_default_floor_is_two_days(self):
40
- self.assertEqual(feature_warmup_days('1m'), 2)
41
-
42
- def test_floor_can_be_dropped_for_padding_math(self):
43
- self.assertEqual(feature_warmup_days('1m', minimum_days=0), 1)
44
- self.assertEqual(feature_warmup_days('1d', minimum_days=0), 30)
45
-
46
- class TestLiveSizingFitsSourceLimits(unittest.TestCase):
47
-
48
- def test_prediction_history_is_servable_for_every_timeframe(self):
49
- for timeframe in MINUTES_PER_CANDLE:
50
- source = CUSTOM_TIMEFRAMES.get(timeframe, {}).get('source', timeframe)
51
- limit = YF_NATIVE_INTERVALS.get(source, {}).get('max_days')
52
- if limit is None:
53
- continue
54
- wanted = history_days_for_candles(timeframe, PREDICTION_HISTORY_CANDLES)
55
- self.assertLessEqual(wanted, limit, f"{timeframe} wants {wanted}d but '{source}' serves {limit}d")
56
-
57
- class TestInferStep(unittest.TestCase):
58
-
59
- def test_uses_the_median_spacing(self):
60
- idx = pd.date_range('2024-01-01', periods=5, freq='15min')
61
- self.assertEqual(infer_step(idx), pd.Timedelta(minutes=15))
62
-
63
- def test_market_closure_gap_does_not_dominate(self):
64
- idx = pd.DatetimeIndex(['2024-01-05 20:00', '2024-01-05 21:00', '2024-01-05 22:00', '2024-01-08 20:00', '2024-01-08 21:00'])
65
- self.assertEqual(infer_step(idx), pd.Timedelta(hours=1))
66
-
67
- def test_single_candle_falls_back_to_one_minute(self):
68
- idx = pd.date_range('2024-01-01', periods=1, freq='5min')
69
- self.assertEqual(infer_step(idx), pd.Timedelta(minutes=1))
70
-
71
- class TestFutureIndex(unittest.TestCase):
72
-
73
- def test_projects_timestamps_after_the_last_candle(self):
74
- last = pd.Timestamp('2024-01-01 00:00')
75
- self.assertEqual(future_index(last, pd.Timedelta(minutes=5), 3), [pd.Timestamp('2024-01-01 00:05'), pd.Timestamp('2024-01-01 00:10'), pd.Timestamp('2024-01-01 00:15')])
76
-
77
- def test_zero_horizon_projects_nothing(self):
78
- self.assertEqual(future_index(pd.Timestamp('2024-01-01'), pd.Timedelta(minutes=1), 0), [])
79
-
80
- class TestDropFormingCandle(unittest.TestCase):
81
-
82
- def test_drops_the_still_forming_candle_and_its_feature_row(self):
83
- now = pd.Timestamp.now(tz='UTC').floor('min')
84
- idx = pd.DatetimeIndex([now - pd.Timedelta(minutes=10), now - pd.Timedelta(minutes=5), now])
85
- features = pd.DataFrame({'f': [1.0, 2.0, 3.0]})
86
- df, trimmed_features = drop_forming_candle(_frame(idx), features)
87
- self.assertEqual(len(df), 2)
88
- self.assertEqual(df.index[-1], idx[-2])
89
- self.assertEqual(len(trimmed_features), 2)
90
- self.assertEqual(list(trimmed_features.index), [0, 1])
91
-
92
- def test_keeps_a_closed_final_candle(self):
93
- now = pd.Timestamp.now(tz='UTC').floor('min')
94
- idx = pd.DatetimeIndex([now - pd.Timedelta(minutes=20), now - pd.Timedelta(minutes=15), now - pd.Timedelta(minutes=10)])
95
- features = pd.DataFrame({'f': [1.0, 2.0, 3.0]})
96
- df, kept_features = drop_forming_candle(_frame(idx), features)
97
- self.assertEqual(len(df), 3)
98
- self.assertEqual(len(kept_features), 3)
99
-
100
- def test_handles_a_tz_naive_index(self):
101
- now = pd.Timestamp.now(tz='UTC').tz_localize(None).floor('min')
102
- idx = pd.DatetimeIndex([now - pd.Timedelta(minutes=10), now - pd.Timedelta(minutes=5), now])
103
- df, features = drop_forming_candle(_frame(idx), None)
104
- self.assertEqual(len(df), 2)
105
- self.assertIsNone(features)
106
-
107
- def test_empty_frame_passes_through(self):
108
- empty = pd.DataFrame({'Close': []}, index=pd.DatetimeIndex([]))
109
- df, features = drop_forming_candle(empty, None)
110
- self.assertEqual(len(df), 0)
111
- self.assertIsNone(features)
112
- if __name__ == '__main__':
113
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_indicators.py DELETED
@@ -1,144 +0,0 @@
1
- import unittest
2
- import numpy as np
3
- import pandas as pd
4
- from indicators.indicators import _wilder_rma, add_all_indicators, bollinger_bands, ema, latest_signals, macd, rsi, sma, stochastic_oscillator
5
-
6
- def _ohlcv(n: int, seed: int=5) -> pd.DataFrame:
7
- rng = np.random.default_rng(seed)
8
- close = 100 + np.cumsum(rng.normal(0, 0.5, n))
9
- open_ = close + rng.normal(0, 0.1, n)
10
- high = np.maximum(open_, close) + np.abs(rng.normal(0, 0.3, n))
11
- low = np.minimum(open_, close) - np.abs(rng.normal(0, 0.3, n))
12
- volume = rng.integers(100, 5000, n).astype(float)
13
- idx = pd.date_range('2024-01-01', periods=n, freq='5min')
14
- return pd.DataFrame({'Open': open_, 'High': high, 'Low': low, 'Close': close, 'Volume': volume}, index=idx)
15
-
16
- class TestWilderRma(unittest.TestCase):
17
-
18
- def test_seeds_from_a_window_length_average_then_recurses(self):
19
- values = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
20
- out = _wilder_rma(values, 3)
21
- self.assertTrue(out.iloc[:2].isna().all())
22
- self.assertAlmostEqual(out.iloc[2], 2.0)
23
- self.assertAlmostEqual(out.iloc[3], (2.0 * 2 + 4.0) / 3)
24
- self.assertAlmostEqual(out.iloc[4], (out.iloc[3] * 2 + 5.0) / 3)
25
-
26
- def test_skips_a_leading_nan_before_seeding(self):
27
- values = pd.Series([np.nan, 2.0, 4.0, 6.0])
28
- out = _wilder_rma(values, 3)
29
- self.assertTrue(out.iloc[:3].isna().all())
30
- self.assertAlmostEqual(out.iloc[3], 4.0)
31
-
32
- def test_series_shorter_than_the_window_is_all_nan(self):
33
- out = _wilder_rma(pd.Series([1.0, 2.0]), 14)
34
- self.assertTrue(out.isna().all())
35
-
36
- class TestRsi(unittest.TestCase):
37
-
38
- def test_warmup_rows_are_nan_not_fifty(self):
39
- out = rsi(_ohlcv(60)['Close'], 14)
40
- self.assertTrue(out.iloc[:14].isna().all())
41
- self.assertTrue(out.iloc[14:].notna().all())
42
- self.assertEqual(int((out.iloc[:14] == 50.0).sum()), 0)
43
-
44
- def test_only_gains_reads_one_hundred(self):
45
- out = rsi(pd.Series(np.arange(40, dtype=float) + 100), 14)
46
- self.assertTrue(out.iloc[:14].isna().all())
47
- self.assertTrue((out.iloc[14:] == 100.0).all())
48
-
49
- def test_flat_market_is_neutral_only_after_warmup(self):
50
- out = rsi(pd.Series([100.0] * 40), 14)
51
- self.assertTrue(out.iloc[:14].isna().all())
52
- self.assertTrue((out.iloc[14:] == 50.0).all())
53
-
54
- def test_stays_within_bounds(self):
55
- out = rsi(_ohlcv(200, seed=9)['Close'], 14).dropna()
56
- self.assertGreaterEqual(out.min(), 0.0)
57
- self.assertLessEqual(out.max(), 100.0)
58
-
59
- class TestMacdAndBollinger(unittest.TestCase):
60
-
61
- def setUp(self):
62
- self.close = _ohlcv(80, seed=3)['Close']
63
-
64
- def test_macd_is_the_fast_minus_slow_ema_with_its_own_signal_ema(self):
65
- line, signal, hist = macd(self.close)
66
- expected_line = ema(self.close, 12) - ema(self.close, 26)
67
- pd.testing.assert_series_equal(line, expected_line, check_names=False)
68
- pd.testing.assert_series_equal(signal, ema(expected_line, 9), check_names=False)
69
- pd.testing.assert_series_equal(hist, expected_line - signal, check_names=False)
70
-
71
- def test_bollinger_uses_population_std_around_the_sma(self):
72
- upper, mid, lower = bollinger_bands(self.close, window=20, num_std=2.0)
73
- pd.testing.assert_series_equal(mid, sma(self.close, 20), check_names=False)
74
- self.assertTrue(upper.iloc[:19].isna().all())
75
- window = self.close.iloc[-20:].to_numpy()
76
- expected_std = float(np.std(window))
77
- self.assertAlmostEqual(float(upper.iloc[-1]), float(window.mean()) + 2 * expected_std, places=9)
78
- self.assertAlmostEqual(float(lower.iloc[-1]), float(window.mean()) - 2 * expected_std, places=9)
79
-
80
- class TestStochastic(unittest.TestCase):
81
-
82
- def test_warmup_rows_are_nan_not_fifty(self):
83
- df = _ohlcv(60, seed=4)
84
- k, d = stochastic_oscillator(df['High'], df['Low'], df['Close'])
85
- self.assertTrue(k.iloc[:13].isna().all())
86
- self.assertTrue(k.iloc[13:].notna().all())
87
- self.assertTrue(d.iloc[:15].isna().all())
88
- self.assertEqual(int((k.iloc[:13] == 50.0).sum()), 0)
89
-
90
- def test_percent_k_matches_the_definition(self):
91
- df = _ohlcv(40, seed=6)
92
- k, _ = stochastic_oscillator(df['High'], df['Low'], df['Close'])
93
- lowest = float(df['Low'].iloc[-14:].min())
94
- highest = float(df['High'].iloc[-14:].max())
95
- expected = 100 * (float(df['Close'].iloc[-1]) - lowest) / (highest - lowest)
96
- self.assertAlmostEqual(float(k.iloc[-1]), expected, places=9)
97
-
98
- def test_zero_range_is_neutral_only_after_warmup(self):
99
- flat = pd.Series([100.0] * 30)
100
- k, d = stochastic_oscillator(flat, flat, flat)
101
- self.assertTrue(k.iloc[:13].isna().all())
102
- self.assertTrue((k.iloc[13:] == 50.0).all())
103
- self.assertTrue((d.iloc[15:] == 50.0).all())
104
-
105
- class TestAddAllIndicators(unittest.TestCase):
106
-
107
- def setUp(self):
108
- self.df = _ohlcv(60, seed=8)
109
- self.out = add_all_indicators(self.df)
110
-
111
- def test_adds_every_indicator_column_without_mutating_the_input(self):
112
- expected = ['SMA_20', 'EMA_20', 'RSI_14', 'MACD', 'MACD_Signal', 'MACD_Hist', 'BB_Upper', 'BB_Mid', 'BB_Lower', 'Stoch_%K', 'Stoch_%D']
113
- for column in expected:
114
- self.assertIn(column, self.out.columns)
115
- self.assertNotIn(column, self.df.columns)
116
-
117
- def test_warmup_nan_counts_are_the_real_indicator_periods(self):
118
- self.assertEqual(int(self.out['RSI_14'].isna().sum()), 14)
119
- self.assertEqual(int(self.out['Stoch_%K'].isna().sum()), 13)
120
- self.assertEqual(int(self.out['Stoch_%D'].isna().sum()), 15)
121
- self.assertEqual(int(self.out['SMA_20'].isna().sum()), 19)
122
- self.assertEqual(int(self.out['BB_Upper'].isna().sum()), 19)
123
-
124
- class TestLatestSignals(unittest.TestCase):
125
-
126
- def test_reports_insufficient_data_instead_of_a_fabricated_reading(self):
127
- out = add_all_indicators(_ohlcv(3, seed=2))
128
- signals = latest_signals(out)
129
- self.assertEqual(signals['RSI (14)']['value'], 'n/a')
130
- self.assertEqual(signals['RSI (14)']['signal'], 'Insufficient data')
131
- self.assertEqual(signals['Stochastic (14,3)']['value'], 'n/a')
132
- self.assertEqual(signals['Stochastic (14,3)']['signal'], 'Insufficient data')
133
- self.assertEqual(signals['Bollinger Bands']['signal'], 'Insufficient data')
134
- self.assertEqual(signals['Moving Averages']['signal'], 'Insufficient data for SMA')
135
- self.assertIn('SMA20=n/a', signals['Moving Averages']['value'])
136
-
137
- def test_reports_real_readings_once_warmed_up(self):
138
- signals = latest_signals(add_all_indicators(_ohlcv(80, seed=1)))
139
- for name, entry in signals.items():
140
- self.assertNotIn('Insufficient', entry['signal'], name)
141
- self.assertIsInstance(signals['RSI (14)']['value'], float)
142
- self.assertIsInstance(signals['Stochastic (14,3)']['value'], float)
143
- if __name__ == '__main__':
144
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_maythos_engine.py DELETED
@@ -1,71 +0,0 @@
1
- """Self-tests for the ported MAYTHOS streaming engine.
2
-
3
- The original standalone engine shipped a ``__main__`` smoke test. That was removed
4
- during the merge (no experimental / dead code) and its oracle is preserved here as
5
- a real unit test.
6
- """
7
-
8
- import unittest
9
-
10
- from models.maythos.engine import MAYTHOS, Candle
11
-
12
-
13
- def _ramp_candles(n: int = 150):
14
- base_ts = 1_700_000_000.0
15
- candles = []
16
- for i in range(n):
17
- close = 1.0000 + i * 0.0005
18
- candles.append(
19
- Candle(
20
- timestamp=base_ts + i * 60,
21
- open=close,
22
- high=close + 0.0002,
23
- low=close - 0.0002,
24
- close=close,
25
- volume=100.0,
26
- )
27
- )
28
- return candles
29
-
30
-
31
- class TestMaythosEngine(unittest.TestCase):
32
- def test_ramp_stream_produces_valid_output(self):
33
- engine = MAYTHOS()
34
- output = None
35
- for candle in _ramp_candles(150):
36
- output = engine.tick(candle)
37
- self.assertIsNotNone(output)
38
- self.assertEqual(engine.validate(output), [])
39
- self.assertIn(output["direction"], ("BUY", "SELL"))
40
- self.assertGreaterEqual(output["confidence"], 0.0)
41
- self.assertLessEqual(output["confidence"], 1.0)
42
- self.assertTrue(engine.is_warm)
43
- self.assertEqual(engine.tick_count, 150)
44
-
45
- def test_reset_clears_state(self):
46
- engine = MAYTHOS()
47
- for candle in _ramp_candles(60):
48
- engine.tick(candle)
49
- self.assertGreater(engine.tick_count, 0)
50
- engine.reset()
51
- self.assertEqual(engine.tick_count, 0)
52
- self.assertFalse(engine.is_warm)
53
-
54
- def test_non_finite_candle_is_sanitized_not_crashing(self):
55
- engine = MAYTHOS()
56
- for candle in _ramp_candles(40):
57
- engine.tick(candle)
58
- bad = Candle(
59
- timestamp=1_700_000_000.0 + 40 * 60,
60
- open=float("nan"),
61
- high=float("inf"),
62
- low=float("-inf"),
63
- close=float("nan"),
64
- volume=0.0,
65
- )
66
- output = engine.tick(bad)
67
- self.assertEqual(engine.validate(output), [])
68
-
69
-
70
- if __name__ == "__main__":
71
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_maythos_model.py DELETED
@@ -1,79 +0,0 @@
1
- """Tests for MaythosModel and its integration into the unified model layer."""
2
-
3
- import math
4
- import unittest
5
-
6
- import numpy as np
7
- import pandas as pd
8
-
9
- from backtest.backtester import backtest_model, summarize_backtest
10
- from models.base_model import BaseModel, Signal
11
- from models.maythos.model import MaythosModel
12
- from models.registry import fresh_model, get_model
13
-
14
-
15
- def _synthetic_ohlcv(n: int, seed: int = 5) -> pd.DataFrame:
16
- rng = np.random.default_rng(seed)
17
- steps = rng.normal(loc=0.02, scale=0.4, size=n)
18
- close = 100 + np.cumsum(steps)
19
- close = np.abs(close) + 5.0
20
- open_ = close + rng.normal(0, 0.1, n)
21
- high = np.maximum(open_, close) + np.abs(rng.normal(0, 0.2, n))
22
- low = np.minimum(open_, close) - np.abs(rng.normal(0, 0.2, n))
23
- volume = rng.integers(100, 10000, n).astype(float)
24
- idx = pd.date_range("2024-01-01", periods=n, freq="min", tz="UTC")
25
- return pd.DataFrame(
26
- {"Open": open_, "High": high, "Low": low, "Close": close, "Volume": volume},
27
- index=idx,
28
- )
29
-
30
-
31
- class TestMaythosModel(unittest.TestCase):
32
- def setUp(self):
33
- self.df = _synthetic_ohlcv(120)
34
-
35
- def test_predict_signal_shape(self):
36
- model = MaythosModel()
37
- signal = model.predict_signal(self.df, horizon=1)
38
- self.assertIsInstance(signal, Signal)
39
- self.assertIn(signal.direction, ("up", "down"))
40
- self.assertGreaterEqual(signal.confidence, 0.0)
41
- self.assertLessEqual(signal.confidence, 1.0)
42
- self.assertIsNone(signal.predicted_close)
43
- self.assertIn(signal.meta["raw_direction"], ("BUY", "SELL"))
44
- self.assertIn("market_state", signal.meta)
45
-
46
- def test_requires_ohlc_columns(self):
47
- model = MaythosModel()
48
- with self.assertRaises(ValueError):
49
- model.predict_signal(self.df[["Close"]], horizon=1)
50
-
51
- def test_rejects_bare_series(self):
52
- model = MaythosModel()
53
- with self.assertRaises(ValueError):
54
- model.predict_signal(self.df["Close"], horizon=1)
55
-
56
- def test_registry_returns_maythos(self):
57
- self.assertIsInstance(fresh_model("MAYTHOS"), MaythosModel)
58
- self.assertIsInstance(get_model("MAYTHOS"), BaseModel)
59
-
60
- def test_backtest_runs_with_direction_only(self):
61
- model = fresh_model("MAYTHOS")
62
- results = backtest_model(self.df, model, window=60, horizon=1, step=5)
63
- self.assertGreater(len(results), 0)
64
- # No prediction should fail: MAYTHOS always emits a decision.
65
- self.assertEqual(int(results["error"].notna().sum()), 0)
66
- # It reports direction, not a price level, so predicted_close is NaN
67
- # and price-error metrics are undefined (never faked).
68
- self.assertTrue(results["predicted_close"].isna().all())
69
- self.assertIn("confidence", results.columns)
70
- self.assertTrue((results["confidence"] >= 0).all())
71
- summary = summarize_backtest(results, "MAYTHOS")
72
- self.assertEqual(summary["failed_predictions"], 0)
73
- self.assertIsNone(summary["mae"])
74
- self.assertIsNone(summary["rmse"])
75
- self.assertGreaterEqual(summary["accuracy_pct"], 0.0)
76
-
77
-
78
- if __name__ == "__main__":
79
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_models_with_features.py DELETED
@@ -1,177 +0,0 @@
1
- import importlib.util
2
- import unittest
3
- import numpy as np
4
- import pandas as pd
5
- from features.feature_pipeline import compute_feature_frame
6
-
7
- def _have(module_name: str) -> bool:
8
- return importlib.util.find_spec(module_name) is not None
9
- _HAVE_STATSMODELS = _have('statsmodels')
10
- _HAVE_ARCH = _have('arch')
11
- _HAVE_MOIRAI = _have('uni2ts') and _have('gluonts')
12
- _HAVE_TIMESFM = _have('timesfm') and _have('torch')
13
-
14
- def _synthetic_ohlcv(n: int, seed: int=42) -> pd.DataFrame:
15
- rng = np.random.default_rng(seed)
16
- steps = rng.normal(loc=0.0, scale=0.4, size=n)
17
- close = 100 + np.cumsum(steps)
18
- close = np.abs(close) + 5.0
19
- open_ = close + rng.normal(0, 0.1, n)
20
- high = np.maximum(open_, close) + np.abs(rng.normal(0, 0.2, n))
21
- low = np.minimum(open_, close) - np.abs(rng.normal(0, 0.2, n))
22
- volume = rng.integers(100, 10000, n).astype(float)
23
- idx = pd.date_range('2024-01-01', periods=n, freq='min')
24
- return pd.DataFrame({'Open': open_, 'High': high, 'Low': low, 'Close': close, 'Volume': volume}, index=idx)
25
-
26
- def _warm_close_and_features(df: pd.DataFrame, feats: pd.DataFrame):
27
- complete = ~feats.isna().any(axis=1).to_numpy()
28
- start = int(np.argmax(complete)) if complete.any() else len(df)
29
- return (df['Close'].reset_index(drop=True).iloc[start:], feats.iloc[start:].reset_index(drop=True))
30
-
31
- class TestArimaFamilyAlignment(unittest.TestCase):
32
-
33
- def test_returns_alignment_has_no_off_by_one(self):
34
- n = 10
35
- prices = pd.Series(100 + np.arange(n, dtype=float))
36
- features = pd.Series([float(i) for i in range(n)])
37
- log_returns = np.log(prices / prices.shift(1)).dropna().reset_index(drop=True)
38
- exog = features.iloc[1:].reset_index(drop=True)
39
- self.assertEqual(len(exog), len(log_returns))
40
- for k in range(len(log_returns)):
41
- self.assertEqual(exog[k], float(k + 1))
42
-
43
- @unittest.skipUnless(_HAVE_STATSMODELS, 'statsmodels not installed in this environment')
44
- class TestArimaWithFeatures(unittest.TestCase):
45
-
46
- def setUp(self):
47
- self.df = _synthetic_ohlcv(120)
48
- self.feats = compute_feature_frame(self.df)
49
- self.close, self.warm_feats = _warm_close_and_features(self.df, self.feats)
50
-
51
- def test_baseline_unchanged_shape(self):
52
- from models.arima_model import ArimaModel
53
- m = ArimaModel(max_p=2, max_d=1, max_q=2)
54
- out = m.predict(self.close, horizon=3)
55
- self.assertEqual(len(out), 3)
56
- self.assertTrue(all(np.isfinite(out)))
57
-
58
- def test_with_features_runs_and_is_finite(self):
59
- from models.arima_model import ArimaModel
60
- m = ArimaModel(max_p=2, max_d=1, max_q=2)
61
- out = m.predict(self.close, horizon=3, features=self.warm_feats)
62
- self.assertEqual(len(out), 3)
63
- self.assertTrue(all(np.isfinite(out)))
64
-
65
- def test_mismatched_features_length_raises(self):
66
- from models.arima_model import ArimaModel
67
- m = ArimaModel(max_p=2, max_d=1, max_q=2)
68
- short_window = self.warm_feats.iloc[:-5].reset_index(drop=True)
69
- with self.assertRaises(ValueError):
70
- m.predict(self.close, horizon=3, features=short_window)
71
-
72
- @unittest.skipUnless(_HAVE_STATSMODELS, 'statsmodels not installed in this environment')
73
- class TestAutoArimaWithFeatures(unittest.TestCase):
74
-
75
- def setUp(self):
76
- self.df = _synthetic_ohlcv(120)
77
- self.feats = compute_feature_frame(self.df)
78
- self.close, self.warm_feats = _warm_close_and_features(self.df, self.feats)
79
-
80
- def test_with_features_runs_and_is_finite(self):
81
- from models.auto_arima_model import AutoArimaModel
82
- m = AutoArimaModel(max_p=3, max_q=3, max_d=1)
83
- out = m.predict(self.close, horizon=2, features=self.warm_feats)
84
- self.assertEqual(len(out), 2)
85
- self.assertTrue(all(np.isfinite(out)))
86
-
87
- @unittest.skipUnless(_HAVE_STATSMODELS and _HAVE_ARCH, 'statsmodels+arch not installed in this environment')
88
- class TestArimaGarchWithFeatures(unittest.TestCase):
89
-
90
- def setUp(self):
91
- self.df = _synthetic_ohlcv(150)
92
- self.feats = compute_feature_frame(self.df)
93
- self.close, self.warm_feats = _warm_close_and_features(self.df, self.feats)
94
-
95
- def test_with_features_runs_and_is_finite(self):
96
- from models.arima_garch_model import ArimaGarchModel
97
- m = ArimaGarchModel(max_p=2, max_q=2)
98
- out = m.predict(self.close, horizon=2, features=self.warm_feats)
99
- self.assertEqual(len(out), 2)
100
- self.assertTrue(all(np.isfinite(out)))
101
- self.assertIsNotNone(m.last_volatility_forecast)
102
-
103
- @unittest.skipUnless(_HAVE_MOIRAI, 'uni2ts/gluonts not installed in this environment')
104
- class TestMoiraiWithFeatures(unittest.TestCase):
105
-
106
- def test_with_features_runs_and_is_finite(self):
107
- from models.moirai_model import MoiraiModel
108
- df = _synthetic_ohlcv(60)
109
- feats = compute_feature_frame(df)
110
- close, warm_feats = _warm_close_and_features(df, feats)
111
- m = MoiraiModel(context_length=60, num_samples=10)
112
- out = m.predict(close, horizon=2, features=warm_feats)
113
- self.assertEqual(len(out), 2)
114
- self.assertTrue(all(np.isfinite(out)))
115
-
116
- @unittest.skipUnless(_HAVE_TIMESFM, 'timesfm/torch not installed in this environment')
117
- class TestTimesFMWithFeatures(unittest.TestCase):
118
-
119
- def test_with_features_runs_and_is_finite(self):
120
- from models.timesfm_model import TimesFMModel
121
- df = _synthetic_ohlcv(64)
122
- feats = compute_feature_frame(df)
123
- close, warm_feats = _warm_close_and_features(df, feats)
124
- m = TimesFMModel(max_context=64)
125
- out = m.predict(close, horizon=2, features=warm_feats)
126
- self.assertEqual(len(out), 2)
127
- self.assertTrue(all(np.isfinite(out)))
128
-
129
- def test_short_input_below_compiled_context_is_finite(self):
130
- from models.timesfm_model import TimesFMModel
131
- rng = np.random.default_rng(99)
132
- df = _synthetic_ohlcv(60, seed=99)
133
- close = 40000 + df['Close'].reset_index(drop=True)
134
- m = TimesFMModel(max_context=512)
135
- out = m.predict(close, horizon=2)
136
- self.assertEqual(len(out), 2)
137
- self.assertTrue(all(np.isfinite(out)), f'TimesFM NaN on short window: {out}')
138
-
139
- def test_repeated_window_lengths_stay_finite(self):
140
- from models.timesfm_model import TimesFMModel
141
- df = _synthetic_ohlcv(120, seed=7)
142
- close = 40000 + df['Close'].reset_index(drop=True)
143
- m = TimesFMModel(max_context=512)
144
- for w in (40, 100, 40):
145
- out = m.predict(close.iloc[-w:], horizon=1)
146
- self.assertEqual(len(out), 1)
147
- self.assertTrue(np.isfinite(out[0]), f'TimesFM NaN at w={w}: {out[0]}')
148
-
149
- class TestInputContractGuards(unittest.TestCase):
150
-
151
- def test_nan_history_raises_for_all_models(self):
152
- from models.registry import fresh_model
153
- rng = np.random.default_rng(3)
154
- good = pd.Series(100 + np.cumsum(rng.normal(0, 0.5, 200)))
155
- for name in ('ARIMA', 'Auto-ARIMA', 'ARIMA-GARCH', 'Moirai'):
156
- m = fresh_model(name)
157
- h = good.copy()
158
- h.iloc[50] = np.nan
159
- with self.assertRaises(ValueError, msg=name):
160
- m.predict(h, horizon=1)
161
- m = fresh_model('TimesFM')
162
- h = good.copy()
163
- h.iloc[50] = np.nan
164
- with self.assertRaises(ValueError):
165
- m.predict(h, horizon=1)
166
-
167
- def test_horizon_below_one_raises_for_all_models(self):
168
- from models.registry import fresh_model
169
- rng = np.random.default_rng(3)
170
- good = pd.Series(100 + np.cumsum(rng.normal(0, 0.5, 200)))
171
- for name in ('ARIMA', 'Auto-ARIMA', 'ARIMA-GARCH', 'Moirai', 'TimesFM'):
172
- m = fresh_model(name)
173
- for bad in (0, -1):
174
- with self.assertRaises(ValueError, msg=f'{name} horizon={bad}'):
175
- m.predict(good, horizon=bad)
176
- if __name__ == '__main__':
177
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_signal_contract.py DELETED
@@ -1,96 +0,0 @@
1
- """Tests that the unified Signal contract behaves consistently across model types.
2
-
3
- These exercise the shared contract without any heavy optional dependencies: a
4
- minimal in-test forecaster stands in for the ARIMA family (whose libraries are
5
- not always installed), while MAYTHOS is real.
6
- """
7
-
8
- import unittest
9
-
10
- import numpy as np
11
- import pandas as pd
12
-
13
- from models.base_model import (
14
- BaseForecastModel,
15
- Signal,
16
- _confidence_from_forecast,
17
- )
18
- from models.maythos.model import MaythosModel
19
-
20
-
21
- def _synthetic_ohlcv(n: int, seed: int = 9) -> pd.DataFrame:
22
- rng = np.random.default_rng(seed)
23
- close = 100 + np.cumsum(rng.normal(0, 0.4, n))
24
- close = np.abs(close) + 5.0
25
- open_ = close + rng.normal(0, 0.1, n)
26
- high = np.maximum(open_, close) + 0.2
27
- low = np.minimum(open_, close) - 0.2
28
- idx = pd.date_range("2024-01-01", periods=n, freq="min", tz="UTC")
29
- return pd.DataFrame(
30
- {"Open": open_, "High": high, "Low": low, "Close": close, "Volume": 1000.0},
31
- index=idx,
32
- )
33
-
34
-
35
- class _UpForecaster(BaseForecastModel):
36
- name = "UpForecaster"
37
-
38
- def predict(self, history, horizon=1, features=None):
39
- last = float(pd.Series(history).iloc[-1])
40
- return [last + 1.0 * (k + 1) for k in range(horizon)]
41
-
42
-
43
- class _DownForecaster(BaseForecastModel):
44
- name = "DownForecaster"
45
-
46
- def predict(self, history, horizon=1, features=None):
47
- last = float(pd.Series(history).iloc[-1])
48
- return [last - 1.0 * (k + 1) for k in range(horizon)]
49
-
50
-
51
- class TestForecastSignalContract(unittest.TestCase):
52
- def setUp(self):
53
- self.df = _synthetic_ohlcv(80)
54
-
55
- def test_forecaster_signal_matches_predict(self):
56
- model = _UpForecaster()
57
- signal = model.predict_signal(self.df, horizon=3)
58
- forecast = model.predict(self.df["Close"], horizon=3)
59
- self.assertIsInstance(signal, Signal)
60
- self.assertEqual(signal.predicted_close, float(forecast[-1]))
61
- self.assertEqual(signal.direction, "up")
62
- self.assertEqual(signal.horizon, 3)
63
- self.assertGreaterEqual(signal.confidence, 0.0)
64
- self.assertLessEqual(signal.confidence, 1.0)
65
-
66
- def test_down_direction(self):
67
- signal = _DownForecaster().predict_signal(self.df, horizon=1)
68
- self.assertEqual(signal.direction, "down")
69
-
70
- def test_accepts_bare_close_series(self):
71
- signal = _UpForecaster().predict_signal(self.df["Close"], horizon=1)
72
- self.assertEqual(signal.direction, "up")
73
-
74
- def test_confidence_saturates_at_two_sigma(self):
75
- history = np.array([1.0, 2.0, 1.0, 2.0, 1.0, 2.0], dtype=float)
76
- # A huge move relative to volatility saturates confidence at 1.0.
77
- self.assertEqual(_confidence_from_forecast(history, 100.0), 1.0)
78
- # A flat history with no move yields zero confidence.
79
- flat = np.array([5.0, 5.0, 5.0], dtype=float)
80
- self.assertEqual(_confidence_from_forecast(flat, 5.0), 0.0)
81
-
82
- def test_every_model_type_speaks_signal(self):
83
- forecaster_signal = _UpForecaster().predict_signal(self.df, horizon=1)
84
- maythos_signal = MaythosModel().predict_signal(self.df, horizon=1)
85
- for signal in (forecaster_signal, maythos_signal):
86
- self.assertIsInstance(signal, Signal)
87
- self.assertIn(signal.direction, ("up", "down"))
88
- self.assertGreaterEqual(signal.confidence, 0.0)
89
- self.assertLessEqual(signal.confidence, 1.0)
90
- # The forecaster reports a price; MAYTHOS reports a decision only.
91
- self.assertIsNotNone(forecaster_signal.predicted_close)
92
- self.assertIsNone(maythos_signal.predicted_close)
93
-
94
-
95
- if __name__ == "__main__":
96
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
utils/__init__.py DELETED
@@ -1 +0,0 @@
1
- pass
 
 
utils/helpers.py DELETED
@@ -1,37 +0,0 @@
1
- import math
2
- import pandas as pd
3
- MINUTES_PER_CANDLE = {'1m': 1, '5m': 5, '10m': 10, '15m': 15, '30m': 30, '1h': 60, '4h': 240, '1d': 1440}
4
- INDICATOR_WARMUP_CANDLES = 30
5
- PREDICTION_HISTORY_CANDLES = 200
6
-
7
- def infer_step(index: pd.DatetimeIndex):
8
- if len(index) < 2:
9
- return pd.Timedelta(minutes=1)
10
- diffs = index.to_series().diff().dropna()
11
- if diffs.empty:
12
- return pd.Timedelta(minutes=1)
13
- return diffs.median()
14
-
15
- def future_index(last_timestamp, step, n):
16
- return [last_timestamp + step * (i + 1) for i in range(n)]
17
-
18
- def history_days_for_candles(timeframe: str, candles: int, minimum_days: int=1) -> int:
19
- minutes = MINUTES_PER_CANDLE.get(timeframe, 60) * max(int(candles), 0)
20
- return max(minimum_days, math.ceil(minutes / (24 * 60)))
21
-
22
- def feature_warmup_days(timeframe: str, minimum_days: int=2) -> int:
23
- return history_days_for_candles(timeframe, INDICATOR_WARMUP_CANDLES, minimum_days=minimum_days)
24
-
25
- def drop_forming_candle(df: pd.DataFrame, features_df: pd.DataFrame=None):
26
- if df is None or len(df) == 0:
27
- return (df, features_df)
28
- last = df.index[-1]
29
- now = pd.Timestamp.now(tz='UTC')
30
- if last.tzinfo is None:
31
- now = now.tz_localize(None)
32
- if now - last >= infer_step(df.index):
33
- return (df, features_df)
34
- df = df.iloc[:-1]
35
- if features_df is not None:
36
- features_df = features_df.iloc[:-1].reset_index(drop=True)
37
- return (df, features_df)