Spaces:
Running
Running
Delete files * with huggingface_hub
Browse files- MERGE_NOTES.md +0 -115
- README.md +0 -10
- app.py +0 -413
- backtest/__init__.py +0 -1
- backtest/backtester.py +0 -178
- config.py +0 -29
- data/__init__.py +0 -1
- data/historical.py +0 -106
- features/__init__.py +0 -1
- features/feature_pipeline.py +0 -43
- indicators/__init__.py +0 -1
- indicators/indicators.py +0 -107
- live/__init__.py +0 -1
- live/engine.py +0 -182
- live_runner.py +0 -41
- models/__init__.py +0 -1
- models/arima_garch_model.py +0 -83
- models/arima_model.py +0 -66
- models/auto_arima_model.py +0 -97
- models/base_model.py +0 -103
- models/dependencies.py +0 -32
- models/maythos/__init__.py +0 -10
- models/maythos/engine.py +0 -0
- models/maythos/model.py +0 -98
- models/moirai_model.py +0 -103
- models/registry.py +0 -42
- models/timesfm_model.py +0 -76
- requirements.txt +0 -14
- tests/__init__.py +0 -1
- tests/test_backtester_alignment.py +0 -175
- tests/test_data_layer.py +0 -191
- tests/test_feature_pipeline.py +0 -167
- tests/test_helpers.py +0 -113
- tests/test_indicators.py +0 -144
- tests/test_maythos_engine.py +0 -71
- tests/test_maythos_model.py +0 -79
- tests/test_models_with_features.py +0 -177
- tests/test_signal_contract.py +0 -96
- utils/__init__.py +0 -1
- utils/helpers.py +0 -37
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|