Spaces:
Sleeping
Sleeping
Space Deployer commited on
Commit ·
e44569b
1
Parent(s): 367602c
Deploy MYTHOSLIVE Space
Browse files- README.md +103 -0
- app.py +232 -0
- backtest/__init__.py +1 -0
- backtest/backtester.py +237 -0
- config.py +59 -0
- data/__init__.py +1 -0
- data/historical.py +132 -0
- data/providers.py +275 -0
- features/__init__.py +1 -0
- features/feature_pipeline.py +43 -0
- indicators/__init__.py +1 -0
- indicators/indicators.py +94 -0
- install_foundation_windows.bat +16 -0
- live_runner.py +103 -0
- maythos.py +0 -0
- maythos_integration.py +199 -0
- models/__init__.py +1 -0
- models/arima_garch_model.py +89 -0
- models/arima_model.py +84 -0
- models/auto_arima_model.py +95 -0
- models/base_model.py +41 -0
- models/dependencies.py +85 -0
- models/moirai_model.py +122 -0
- models/registry.py +39 -0
- models/timesfm_model.py +91 -0
- pipeline.py +314 -0
- requirements-core.txt +9 -0
- requirements-dev.txt +2 -0
- requirements-foundation.txt +14 -0
- requirements.txt +21 -0
- risk_management.py +310 -0
- setup_windows.bat +20 -0
- start_windows.bat +10 -0
- tests/__init__.py +1 -0
- tests/test_audit_regressions.py +103 -0
- tests/test_backtester_alignment.py +175 -0
- tests/test_data_layer.py +191 -0
- tests/test_feature_pipeline.py +167 -0
- tests/test_helpers.py +113 -0
- tests/test_indicators.py +144 -0
- tests/test_maythos_integration.py +70 -0
- tests/test_models_with_features.py +176 -0
- tests/test_risk_management.py +92 -0
- utils/__init__.py +1 -0
- utils/helpers.py +54 -0
README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: MYTHOS · MAYTHOS × RINKING
|
| 3 |
+
emoji: 🚀
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 5.50.0
|
| 8 |
+
python_version: "3.10"
|
| 9 |
+
app_file: app.py
|
| 10 |
+
pinned: false
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# MYTHOS · MAYTHOS × RINKING
|
| 14 |
+
|
| 15 |
+
Selectable, reproducible forecast-and-signal dashboard with **Live Signal** and
|
| 16 |
+
**Backtesting** tabs. It fetches real closed OHLCV candles, never fabricates a
|
| 17 |
+
missing candle, and writes a data snapshot plus manifest for every completed run.
|
| 18 |
+
|
| 19 |
+
> Educational / research project — not financial advice. Trading carries
|
| 20 |
+
> substantial risk of loss. No model can guarantee correct predictions.
|
| 21 |
+
|
| 22 |
+
## What changed
|
| 23 |
+
|
| 24 |
+
- Timeframes: **1m, 2m, 5m, 10m, 15m, 30m, 1h, 2h**.
|
| 25 |
+
- Market and symbol selection for **Forex and Crypto**.
|
| 26 |
+
- Data adapters:
|
| 27 |
+
- Yahoo Finance (`EURUSD=X`, `BTC-USD`, and other Yahoo tickers).
|
| 28 |
+
- Binance Spot crypto catalog and klines (`BTCUSDT` means USDT, not USD).
|
| 29 |
+
- Twelve Data Forex catalog/history when `TWELVE_DATA_API_KEY` is set.
|
| 30 |
+
- Models: **ARIMA, Auto-ARIMA, ARIMA-GARCH, Moirai, TimesFM**.
|
| 31 |
+
- **Single model** mode or **Parallel models** mode. Every model keeps its own
|
| 32 |
+
result and error; a failed model is never replaced by another model's output.
|
| 33 |
+
- One configuration is shared by Live Signal and Backtesting.
|
| 34 |
+
- Walk-forward backtest uses training data only at each origin, checks the
|
| 35 |
+
selected candle duration, skips gap-crossing targets, reports failures and
|
| 36 |
+
coverage, and includes a last-close persistence baseline.
|
| 37 |
+
- Live results are blocked when the latest closed candle is stale. The MAYTHOS
|
| 38 |
+
heuristic and forecast metrics are shown separately.
|
| 39 |
+
- Actionable live signals include causal Entry, Stop Loss, Take Profit and
|
| 40 |
+
Risk:Reward levels. Stop Loss uses Wilder ATR plus prior closed-candle
|
| 41 |
+
structure; Take Profit uses the opposite structure and a compatible real
|
| 42 |
+
forecast target when available.
|
| 43 |
+
- Backtesting resolves those same levels against each future candle's high/low,
|
| 44 |
+
treats a same-candle SL/TP collision as SL-first, and reports trades, win
|
| 45 |
+
rate, profit factor, expectancy in R, maximum drawdown in R and average R.
|
| 46 |
+
Spread, fee and slippage assumptions are explicit in the UI and manifest.
|
| 47 |
+
- Every result can be downloaded as a ZIP containing candles, results, config,
|
| 48 |
+
package versions, SHA-256 snapshot hash, and evaluation policy.
|
| 49 |
+
|
| 50 |
+
## Windows setup
|
| 51 |
+
|
| 52 |
+
1. Install **Python 3.11** from python.org and enable the Python launcher.
|
| 53 |
+
2. Extract `mythos.zip` to a normal folder.
|
| 54 |
+
3. Run `setup_windows.bat`.
|
| 55 |
+
4. Run `start_windows.bat`; open the local Gradio address shown in the console.
|
| 56 |
+
5. Optional: run `install_foundation_windows.bat` for real Moirai and TimesFM
|
| 57 |
+
inference. Their Hugging Face weights download on first use and need internet,
|
| 58 |
+
disk space, and adequate RAM. If these packages/weights are unavailable, the
|
| 59 |
+
dashboard reports that model as failed rather than showing a substitute.
|
| 60 |
+
|
| 61 |
+
The core install is sufficient for ARIMA, Auto-ARIMA, and ARIMA-GARCH.
|
| 62 |
+
|
| 63 |
+
## Provider notes
|
| 64 |
+
|
| 65 |
+
Provider retention, plan limits, exchange maintenance, market closures, and
|
| 66 |
+
network access affect the available history. The dashboard displays the exact
|
| 67 |
+
provider, UTC range, candle count, freshness, and gap count. Forex does not have
|
| 68 |
+
a single consolidated traded-volume feed; zero/unavailable volume is explicitly
|
| 69 |
+
identified and MAYTHOS uses its documented price-based proxy.
|
| 70 |
+
|
| 71 |
+
Yahoo's free intraday history can be limited. Binance is crypto-only. Twelve Data
|
| 72 |
+
requires an API key in the Windows environment before launching:
|
| 73 |
+
|
| 74 |
+
```text
|
| 75 |
+
set TWELVE_DATA_API_KEY=your_key_here
|
| 76 |
+
start_windows.bat
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
Do not put API keys into source files or ZIP archives.
|
| 80 |
+
|
| 81 |
+
## Command-line logger
|
| 82 |
+
|
| 83 |
+
The logger uses the same data/model pipeline and verifies predictions only after
|
| 84 |
+
the target closed candle arrives:
|
| 85 |
+
|
| 86 |
+
```text
|
| 87 |
+
python live_runner.py --provider "Yahoo Finance" --market Crypto --symbol BTC-USD --timeframe 5m --models ARIMA-GARCH --poll-seconds 60
|
| 88 |
+
python live_runner.py --provider "Binance Spot" --market Crypto --symbol BTCUSDT --timeframe 2m --models ARIMA TimesFM --poll-seconds 120
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
## Verification
|
| 92 |
+
|
| 93 |
+
Core tests use deterministic synthetic fixtures for alignment and contract
|
| 94 |
+
checks. Foundation models are not silently tested with fake forecasts: their
|
| 95 |
+
optional tests require downloading the real weights and can take a long time.
|
| 96 |
+
Run the installed suite with:
|
| 97 |
+
|
| 98 |
+
```text
|
| 99 |
+
python -m pytest -q
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
Backtest accuracy is a historical measurement of this configuration and sample;
|
| 103 |
+
it is not a promise of future accuracy, profitability, or execution quality.
|
app.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
import gradio as gr
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import plotly.graph_objects as go
|
| 8 |
+
|
| 9 |
+
from config import DEFAULT_CRYPTO_SYMBOLS, DEFAULT_FOREX_SYMBOLS, MODEL_NAMES, PROVIDERS, TIMEFRAMES
|
| 10 |
+
from data.providers import catalog
|
| 11 |
+
from pipeline import RunConfig, backtest_result, export_run, live_result, model_readiness
|
| 12 |
+
|
| 13 |
+
DISCLAIMER = '**Educational / research project — not financial advice.** Trading carries substantial risk of loss.'
|
| 14 |
+
CSS = """
|
| 15 |
+
.gradio-container { max-width: 1440px !important; }
|
| 16 |
+
#brand { padding: 18px 6px 12px; }
|
| 17 |
+
#brand h1 { letter-spacing: .05em; font-size: 2.1rem; }
|
| 18 |
+
#brand p { max-width: 1000px; }
|
| 19 |
+
.settings-panel { border-radius: 14px; }
|
| 20 |
+
footer { display: none !important; }
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def ui_config(provider, market, symbol, timeframe, mode, models, days, window, horizon, step, evaluations, features, spread_bps, fee_bps, slippage_bps):
|
| 25 |
+
return RunConfig(provider=provider, market=market, symbol=symbol, timeframe=timeframe,
|
| 26 |
+
mode=mode, models=tuple(models or ()), lookback_days=days, window=window,
|
| 27 |
+
horizon=horizon, step=step, max_evaluations=evaluations, use_features=features,
|
| 28 |
+
spread_bps=spread_bps, fee_bps=fee_bps, slippage_bps=slippage_bps)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def metadata_text(metadata):
|
| 32 |
+
freshness = '**STALE / market closed — historical analysis only; live execution blocked.**' if metadata['stale'] else '**Recent closed-candle data.**'
|
| 33 |
+
details = metadata['provider_details']
|
| 34 |
+
effective = details.get('effective_lookback_days')
|
| 35 |
+
coverage = f' Provider lookback used: **{effective} calendar days** (provider retention cap applies).' if effective else ''
|
| 36 |
+
volume_note = details.get('volume_note', '')
|
| 37 |
+
if metadata['market'] == 'Forex' and not volume_note:
|
| 38 |
+
volume_note = 'Forex volume may be unavailable/zero; MAYTHOS then uses its price-based proxy.'
|
| 39 |
+
return (
|
| 40 |
+
f"**{metadata['provider']} · {metadata['symbol']} · {metadata['timeframe']}** — {metadata['closed_candles']:,} closed candles. {freshness}\n\n"
|
| 41 |
+
f"UTC range: `{metadata['first_candle_open_utc']}` → `{metadata['last_candle_open_utc']}` (candle-open timestamps). "
|
| 42 |
+
f"Last candle closed: `{metadata['last_candle_close_utc']}`. "
|
| 43 |
+
f"Noncontinuous intervals: **{metadata['noncontinuous_intervals']}**; gaps are never filled."
|
| 44 |
+
+ coverage + ('\n\n' + volume_note if volume_note else '')
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def make_candlestick_figure(result):
|
| 49 |
+
frame = result['frame'].tail(200)
|
| 50 |
+
config = result['config']
|
| 51 |
+
figure = go.Figure(go.Candlestick(x=frame.index, open=frame['Open'], high=frame['High'],
|
| 52 |
+
low=frame['Low'], close=frame['Close'], name=config.symbol))
|
| 53 |
+
for forecast in result['forecasts']:
|
| 54 |
+
if not forecast['forecast_path']:
|
| 55 |
+
continue
|
| 56 |
+
times = [frame.index[-1]] + [frame.index[-1] + config.candle_step * (i + 1) for i in range(config.horizon)]
|
| 57 |
+
figure.add_trace(go.Scatter(x=times, y=[float(frame['Close'].iloc[-1])] + forecast['forecast_path'],
|
| 58 |
+
name=forecast['Model'], mode='lines+markers'))
|
| 59 |
+
figure.update_layout(title=f'{config.symbol} · {config.timeframe} · {config.provider}',
|
| 60 |
+
xaxis_rangeslider_visible=False, height=510,
|
| 61 |
+
margin=dict(l=12, r=12, t=50, b=12), template='plotly_dark',
|
| 62 |
+
legend=dict(orientation='h'), xaxis_title='Candle open (UTC)', yaxis_title='Price in pair quote currency')
|
| 63 |
+
return figure
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def run_live_signal(*args):
|
| 67 |
+
empty = pd.DataFrame()
|
| 68 |
+
try:
|
| 69 |
+
config = ui_config(*args)
|
| 70 |
+
result = live_result(config)
|
| 71 |
+
signal = result['signal']
|
| 72 |
+
if 'error' in signal:
|
| 73 |
+
signal_rows = [('Engine status', 'FAILED'), ('Error', signal['error'])]
|
| 74 |
+
else:
|
| 75 |
+
signal_rows = [
|
| 76 |
+
('Direction', signal['direction']), ('Execution suitability', signal['execution_suitability']),
|
| 77 |
+
('Live actionable', str(signal['live_actionable'])),
|
| 78 |
+
('Forecast coverage', f"{signal.get('forecast_models_completed', 0)}/{signal.get('forecast_models_requested', len(config.models))} models"),
|
| 79 |
+
('Forecast consensus', signal.get('forecast_consensus', 'UNAVAILABLE')),
|
| 80 |
+
('Forecast agrees with MAYTHOS', str(signal.get('forecast_agrees_with_signal', False))),
|
| 81 |
+
('Heuristic confidence (not calibrated win probability)', f"{signal['confidence']:.2%}"),
|
| 82 |
+
('Market state', signal['market_state']), ('Regime', signal['regime_label']),
|
| 83 |
+
('Reason', signal['reason_summary']),
|
| 84 |
+
]
|
| 85 |
+
if signal.get('live_actionable') and signal.get('trade_levels'):
|
| 86 |
+
levels = signal['trade_levels']
|
| 87 |
+
signal_rows.extend([
|
| 88 |
+
('Entry', levels['entry']),
|
| 89 |
+
('Stop Loss', levels['stop_loss']),
|
| 90 |
+
('Take Profit', levels['take_profit']),
|
| 91 |
+
('Risk:Reward', f"1:{levels['risk_reward']:.3f}"),
|
| 92 |
+
('ATR', levels['atr']),
|
| 93 |
+
('Level basis', levels['level_basis']),
|
| 94 |
+
])
|
| 95 |
+
successful = sum(bool(item['forecast_path']) for item in result['forecasts'])
|
| 96 |
+
summary = (
|
| 97 |
+
f"### {successful}/{len(config.models)} forecast models completed\n\n"
|
| 98 |
+
f"Forecast horizon: **{config.horizon} candle(s)** · training window: **{config.window} candles** · "
|
| 99 |
+
f"features: **{'enabled' if config.use_features else 'disabled'}**. Each model is reported individually.\n\n"
|
| 100 |
+
+ metadata_text(result['metadata'])
|
| 101 |
+
+ f"\n\nForecast coverage is **{result['signal'].get('forecast_coverage_pct', 0):.2f}%**; a live signal is actionable only when data is fresh, MAYTHOS is sufficiently strong, selected models agree with MAYTHOS, and every required forecast condition is met."
|
| 102 |
+
)
|
| 103 |
+
artifact = export_run(result, 'live')
|
| 104 |
+
table = pd.DataFrame(result['forecasts']).drop(columns=['forecast_path'])
|
| 105 |
+
return make_candlestick_figure(result), summary, table, pd.DataFrame(signal_rows, columns=['MAYTHOS metric', 'Value']), artifact
|
| 106 |
+
except Exception as error:
|
| 107 |
+
return None, f'**Live signal failed:** {error}', empty, empty, None
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def run_backtest_ui(*args):
|
| 111 |
+
empty = pd.DataFrame()
|
| 112 |
+
try:
|
| 113 |
+
config = ui_config(*args)
|
| 114 |
+
result = backtest_result(config)
|
| 115 |
+
failed = int(result['forecast_summary']['failed_predictions'].sum())
|
| 116 |
+
status = (
|
| 117 |
+
f"### Walk-forward evaluation completed — {failed} failed/skipped model evaluations\n\n"
|
| 118 |
+
f"History window: **{config.window}** · horizon: **{config.horizon}** · origin stride: **{config.step}** · "
|
| 119 |
+
f"at most **{config.max_evaluations} most-recent evaluation origins per model**. "
|
| 120 |
+
'Orders are reselected using only each training window; no future target prices/features are supplied to models. '
|
| 121 |
+
'Targets crossing gaps/market closures are explicitly skipped. Flat prices are classified as FLAT.\n\n'
|
| 122 |
+
'**Accuracy denominator:** successful forecast evaluations; failures and coverage are shown separately. '
|
| 123 |
+
'MAYTHOS accuracy uses only unblocked signals. `persistence_mae` is the last-close naive baseline on the same valid targets. '
|
| 124 |
+
'**SL/TP trade metrics use the same causal ATR + prior swing rules on every origin. Candle-path resolution checks SL before TP when both touch; costs are explicit spread/fee/slippage assumptions, not provider-measured quotes.**\n\n'
|
| 125 |
+
+ metadata_text(result['metadata'])
|
| 126 |
+
)
|
| 127 |
+
artifact = export_run(result, 'backtest')
|
| 128 |
+
return result['forecast_summary'], result['signal_summary'], result['forecast_details'], result['signal_details'], status, artifact
|
| 129 |
+
except Exception as error:
|
| 130 |
+
return empty, empty, empty, empty, f'**Backtest failed:** {error}', None
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def default_symbols(provider, market):
|
| 134 |
+
if provider == 'Binance Spot':
|
| 135 |
+
if market != 'Crypto':
|
| 136 |
+
return gr.Dropdown(choices=[], value=None, allow_custom_value=True), 'Binance Spot requires Crypto. Select another provider for Forex.'
|
| 137 |
+
return gr.Dropdown(choices=['BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'BTCUSDC', 'ETHUSDC'], value='BTCUSDT', allow_custom_value=True), 'Binance Spot: load the full active exchange catalog. BTCUSDT is quoted in USDT, not USD.'
|
| 138 |
+
if provider == 'Twelve Data':
|
| 139 |
+
pairs = [s.removesuffix('=X')[:3] + '/' + s.removesuffix('=X')[3:] for s in DEFAULT_FOREX_SYMBOLS]
|
| 140 |
+
return gr.Dropdown(choices=pairs if market == 'Forex' else [], value='EUR/USD' if market == 'Forex' else None, allow_custom_value=True), 'Twelve Data Forex: set TWELVE_DATA_API_KEY before starting. Data access and historical depth depend on your plan.'
|
| 141 |
+
choices = DEFAULT_FOREX_SYMBOLS if market == 'Forex' else DEFAULT_CRYPTO_SYMBOLS
|
| 142 |
+
return gr.Dropdown(choices=choices, value=choices[0], allow_custom_value=True), 'Yahoo: search or type any supported Forex/Crypto symbol. Free intraday data can be delayed, sparse, or unavailable; use timestamps to assess freshness.'
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def search_symbols(provider, market, query, current):
|
| 146 |
+
try:
|
| 147 |
+
symbols, note = catalog(provider, market, query)
|
| 148 |
+
if current and current not in symbols:
|
| 149 |
+
symbols = [current] + symbols
|
| 150 |
+
return gr.Dropdown(choices=symbols, value=current if current else (symbols[0] if symbols else None), allow_custom_value=True), note
|
| 151 |
+
except Exception as error:
|
| 152 |
+
return gr.skip(), f'**Catalog/search failed:** {error}'
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def update_mode(mode, selected):
|
| 156 |
+
selected = selected or ['ARIMA-GARCH']
|
| 157 |
+
return gr.CheckboxGroup(value=selected[-1:] if mode == 'Single model' else selected)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def enforce_single(mode, selected):
|
| 161 |
+
if mode == 'Single model' and selected and len(selected) > 1:
|
| 162 |
+
return gr.CheckboxGroup(value=selected[-1:])
|
| 163 |
+
return gr.skip()
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
with gr.Blocks(title='MYTHOS · MAYTHOS × RINKING', theme=gr.themes.Soft(primary_hue='violet', neutral_hue='slate'), css=CSS) as demo:
|
| 167 |
+
gr.Markdown('# MYTHOS · MAYTHOS × RINKING\nReal market data. Selectable forecasting. Auditable walk-forward evaluation.\n\n' + DISCLAIMER, elem_id='brand')
|
| 168 |
+
with gr.Group(elem_classes=['settings-panel']):
|
| 169 |
+
gr.Markdown('### Market & model configuration\nThese settings apply to both Live Signal and Backtesting.')
|
| 170 |
+
with gr.Row():
|
| 171 |
+
provider = gr.Dropdown(PROVIDERS, value='Yahoo Finance', label='Data provider')
|
| 172 |
+
market = gr.Radio(['Forex', 'Crypto'], value='Crypto', label='Market')
|
| 173 |
+
symbol = gr.Dropdown(DEFAULT_CRYPTO_SYMBOLS, value='BTC-USD', allow_custom_value=True, filterable=True, label='Symbol — choose or type')
|
| 174 |
+
timeframe = gr.Dropdown(TIMEFRAMES, value='5m', label='Timeframe')
|
| 175 |
+
with gr.Row():
|
| 176 |
+
query = gr.Textbox(label='Symbol search / catalog filter', placeholder='EUR, BTC, USDT…')
|
| 177 |
+
search_button = gr.Button('Load / search symbol catalog')
|
| 178 |
+
catalog_note = gr.Markdown('Yahoo common pairs shown. Search or type any provider-supported symbol; intraday coverage is checked on fetch.')
|
| 179 |
+
with gr.Row():
|
| 180 |
+
mode = gr.Radio(['Single model', 'Parallel models'], value='Single model', label='Model execution')
|
| 181 |
+
models = gr.CheckboxGroup(MODEL_NAMES, value=['ARIMA-GARCH'], label='Forecast models — select freely')
|
| 182 |
+
with gr.Accordion('History, forecast & backtest settings', open=True):
|
| 183 |
+
with gr.Row():
|
| 184 |
+
days = gr.Number(value=30, minimum=1, maximum=730, precision=0, label='Lookback (calendar days)')
|
| 185 |
+
window = gr.Number(value=200, minimum=32, maximum=2048, precision=0, label='Training window (candles)')
|
| 186 |
+
horizon = gr.Number(value=1, minimum=1, maximum=64, precision=0, label='Forecast horizon (candles)')
|
| 187 |
+
features = gr.Checkbox(value=False, label='Use 14 causal forecast features')
|
| 188 |
+
with gr.Row():
|
| 189 |
+
step = gr.Number(value=30, minimum=1, maximum=10000, precision=0, label='Backtest origin stride (candles)')
|
| 190 |
+
evaluations = gr.Number(value=30, minimum=1, maximum=1000, precision=0, label='Maximum recent evaluations / model')
|
| 191 |
+
with gr.Row():
|
| 192 |
+
spread_bps = gr.Number(value=1.0, minimum=0, maximum=1000, label='Assumed spread (bps)')
|
| 193 |
+
fee_bps = gr.Number(value=1.0, minimum=0, maximum=1000, label='Assumed fees (bps, round trip)')
|
| 194 |
+
slippage_bps = gr.Number(value=1.0, minimum=0, maximum=1000, label='Assumed slippage (bps)')
|
| 195 |
+
gr.Markdown('ARIMA-family order selection runs at every evaluation origin and may take several minutes. SL/TP uses closed-candle Wilder ATR plus prior swing structure; levels are omitted when no valid positive-risk target exists. Parallel mode runs independent models concurrently (two workers). Future indicator covariates use the last observed values. Moirai/TimesFM require their real pretrained weights.')
|
| 196 |
+
with gr.Accordion('Model dependency status', open=False):
|
| 197 |
+
gr.Dataframe(value=model_readiness(), interactive=False)
|
| 198 |
+
gr.Markdown('“Packages detected” is not an inference or accuracy certification. See README for installing optional foundation models.')
|
| 199 |
+
|
| 200 |
+
inputs = [provider, market, symbol, timeframe, mode, models, days, window, horizon, step, evaluations, features, spread_bps, fee_bps, slippage_bps]
|
| 201 |
+
with gr.Tab('Live Signal'):
|
| 202 |
+
live_button = gr.Button('Refresh live signal', variant='primary')
|
| 203 |
+
live_summary = gr.Markdown('Choose your configuration and refresh to fetch real closed candles.')
|
| 204 |
+
live_plot = gr.Plot(label='Closed candles & model forecasts')
|
| 205 |
+
live_table = gr.Dataframe(label='Per-model forecast results and errors', interactive=False)
|
| 206 |
+
signal_table = gr.Dataframe(label='MAYTHOS signal engine', interactive=False)
|
| 207 |
+
live_file = gr.File(label='Download live data, results & reproducibility manifest', interactive=False)
|
| 208 |
+
with gr.Tab('Backtesting'):
|
| 209 |
+
backtest_button = gr.Button('Run walk-forward backtest', variant='primary')
|
| 210 |
+
backtest_status = gr.Markdown('Use the same market/model configuration above. Download the exact data snapshot with every completed run.')
|
| 211 |
+
forecast_summary = gr.Dataframe(label='Forecast metrics — each model separately', interactive=False)
|
| 212 |
+
maythos_summary = gr.Dataframe(label='MAYTHOS metrics — unblocked signals only', interactive=False)
|
| 213 |
+
forecast_details = gr.Dataframe(label='Forecast per-evaluation details (including failures)', interactive=False)
|
| 214 |
+
maythos_details = gr.Dataframe(label='MAYTHOS per-evaluation details', interactive=False)
|
| 215 |
+
backtest_file = gr.File(label='Download backtest data, results & reproducibility manifest', interactive=False)
|
| 216 |
+
|
| 217 |
+
provider.change(default_symbols, inputs=[provider, market], outputs=[symbol, catalog_note], queue=False)
|
| 218 |
+
market.change(default_symbols, inputs=[provider, market], outputs=[symbol, catalog_note], queue=False)
|
| 219 |
+
search_button.click(search_symbols, inputs=[provider, market, query, symbol], outputs=[symbol, catalog_note], api_name='symbol_catalog')
|
| 220 |
+
mode.change(update_mode, inputs=[mode, models], outputs=models, queue=False)
|
| 221 |
+
models.change(enforce_single, inputs=[mode, models], outputs=models, queue=False)
|
| 222 |
+
live_button.click(run_live_signal, inputs=inputs, outputs=[live_plot, live_summary, live_table, signal_table, live_file], concurrency_limit=1, concurrency_id='analysis', api_name='live_signal')
|
| 223 |
+
backtest_button.click(run_backtest_ui, inputs=inputs, outputs=[forecast_summary, maythos_summary, forecast_details, maythos_details, backtest_status, backtest_file], concurrency_limit=1, concurrency_id='analysis', api_name='backtest')
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
if __name__ == '__main__':
|
| 227 |
+
demo.queue(max_size=16).launch(
|
| 228 |
+
server_name=os.getenv('GRADIO_SERVER_NAME', '0.0.0.0'),
|
| 229 |
+
server_port=int(os.getenv('PORT', os.getenv('MYTHOS_PORT', '7860'))),
|
| 230 |
+
inbrowser=False,
|
| 231 |
+
share=False,
|
| 232 |
+
)
|
backtest/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
pass
|
backtest/backtester.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
from risk_management import CostModel, build_trade_levels, simulate_trade, summarize_trades
|
| 6 |
+
from utils.helpers import price_direction
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _round_sig(x, sig: int = 6):
|
| 10 |
+
if x is None or not np.isfinite(x) or x == 0:
|
| 11 |
+
return x
|
| 12 |
+
decimals = sig - 1 - floor(log10(abs(x)))
|
| 13 |
+
return round(x, decimals)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def backtest_model(
|
| 17 |
+
df: pd.DataFrame,
|
| 18 |
+
model,
|
| 19 |
+
window: int = 100,
|
| 20 |
+
horizon: int = 1,
|
| 21 |
+
step: int = 1,
|
| 22 |
+
features_df: pd.DataFrame = None,
|
| 23 |
+
max_evaluations: int = None,
|
| 24 |
+
timeframe: str = None,
|
| 25 |
+
market: str = 'Crypto',
|
| 26 |
+
symbol: str = '',
|
| 27 |
+
costs: CostModel = None,
|
| 28 |
+
) -> pd.DataFrame:
|
| 29 |
+
if any(not isinstance(value, (int, np.integer)) or value < 1 for value in (window, horizon, step)):
|
| 30 |
+
raise ValueError('Window, horizon, and step must be positive integers.')
|
| 31 |
+
if features_df is not None and len(features_df) != len(df):
|
| 32 |
+
raise ValueError('Features must align with every OHLCV row.')
|
| 33 |
+
if df.empty or not df.index.is_monotonic_increasing or df.index.duplicated().any():
|
| 34 |
+
raise ValueError('Backtest candles must be non-empty, chronologically ordered, and unique.')
|
| 35 |
+
if not np.isfinite(pd.to_numeric(df['Close'], errors='coerce').to_numpy(dtype=float)).all():
|
| 36 |
+
raise ValueError('Backtest Close prices must be finite real observations.')
|
| 37 |
+
close = df["Close"]
|
| 38 |
+
costs = costs or CostModel()
|
| 39 |
+
timestamps = [
|
| 40 |
+
ts.isoformat() if hasattr(ts, "isoformat") else ts for ts in df.index
|
| 41 |
+
]
|
| 42 |
+
last_i = len(close) - horizon
|
| 43 |
+
rows = []
|
| 44 |
+
positions = list(range(window, last_i + 1, step))
|
| 45 |
+
if max_evaluations is not None:
|
| 46 |
+
if max_evaluations < 1:
|
| 47 |
+
raise ValueError('max_evaluations must be positive.')
|
| 48 |
+
positions = positions[-max_evaluations:]
|
| 49 |
+
for i in positions:
|
| 50 |
+
history = close.iloc[i - window : i]
|
| 51 |
+
feature_window = (
|
| 52 |
+
slice_features(features_df, i - window, i)
|
| 53 |
+
if features_df is not None
|
| 54 |
+
else None
|
| 55 |
+
)
|
| 56 |
+
current_close = float(close.iloc[i - 1])
|
| 57 |
+
target_idx = i + horizon - 1
|
| 58 |
+
actual_next_close = float(close.iloc[target_idx])
|
| 59 |
+
try:
|
| 60 |
+
continuous = True
|
| 61 |
+
if timeframe:
|
| 62 |
+
from utils.helpers import MINUTES_PER_CANDLE
|
| 63 |
+
expected_step = pd.Timedelta(minutes=MINUTES_PER_CANDLE[timeframe])
|
| 64 |
+
target_times = df.index[i - 1:target_idx + 1]
|
| 65 |
+
continuous = bool((target_times.to_series().diff().dropna() == expected_step).all())
|
| 66 |
+
if not continuous:
|
| 67 |
+
raise ValueError('Target crosses a missing candle or market closure; not a continuous timeframe horizon.')
|
| 68 |
+
forecast = model.predict(history, horizon=horizon, features=feature_window)
|
| 69 |
+
if len(forecast) != horizon:
|
| 70 |
+
raise ValueError('Model returned an incorrect forecast horizon.')
|
| 71 |
+
forecast_values = np.asarray(forecast, dtype=float)
|
| 72 |
+
if not np.isfinite(forecast_values).all() or (forecast_values <= 0).any():
|
| 73 |
+
raise ValueError(
|
| 74 |
+
f"forecast contains a non-finite or non-positive value: {forecast!r}"
|
| 75 |
+
)
|
| 76 |
+
predicted_next_close = float(forecast_values[-1])
|
| 77 |
+
except Exception as e:
|
| 78 |
+
rows.append(
|
| 79 |
+
{
|
| 80 |
+
"datetime": timestamps[target_idx],
|
| 81 |
+
"current_close": current_close,
|
| 82 |
+
"predicted_close": np.nan,
|
| 83 |
+
"actual_close": actual_next_close,
|
| 84 |
+
"predicted_direction": None,
|
| 85 |
+
"actual_direction": None,
|
| 86 |
+
"correct": None,
|
| 87 |
+
"continuous_target": continuous,
|
| 88 |
+
"trade_taken": False,
|
| 89 |
+
"entry": np.nan, "stop_loss": np.nan, "take_profit": np.nan,
|
| 90 |
+
"risk_reward": np.nan, "atr": np.nan, "outcome": 'FORECAST_FAILED',
|
| 91 |
+
"exit_price": np.nan, "net_r": np.nan, "bars_held": 0,
|
| 92 |
+
"error": str(e),
|
| 93 |
+
}
|
| 94 |
+
)
|
| 95 |
+
continue
|
| 96 |
+
predicted_direction = price_direction(predicted_next_close, current_close)
|
| 97 |
+
actual_direction = price_direction(actual_next_close, current_close)
|
| 98 |
+
correct = predicted_direction == actual_direction
|
| 99 |
+
trade_direction = {'up': 'BUY', 'down': 'SELL'}.get(predicted_direction)
|
| 100 |
+
levels = build_trade_levels(
|
| 101 |
+
df.iloc[:i], trade_direction, market, symbol,
|
| 102 |
+
forecast_target=predicted_next_close,
|
| 103 |
+
)
|
| 104 |
+
trade = {'outcome': 'NO_LEVELS', 'exit_price': None, 'net_r': None, 'bars_held': 0}
|
| 105 |
+
if levels is not None:
|
| 106 |
+
trade = {**trade, **simulate_trade(levels, df.iloc[i:target_idx + 1], costs)}
|
| 107 |
+
rows.append(
|
| 108 |
+
{
|
| 109 |
+
"datetime": timestamps[target_idx],
|
| 110 |
+
"current_close": current_close,
|
| 111 |
+
"predicted_close": predicted_next_close,
|
| 112 |
+
"actual_close": actual_next_close,
|
| 113 |
+
"predicted_direction": predicted_direction,
|
| 114 |
+
"actual_direction": actual_direction,
|
| 115 |
+
"correct": correct,
|
| 116 |
+
"continuous_target": continuous,
|
| 117 |
+
"trade_taken": levels is not None,
|
| 118 |
+
"entry": levels.entry if levels else np.nan,
|
| 119 |
+
"stop_loss": levels.stop_loss if levels else np.nan,
|
| 120 |
+
"take_profit": levels.take_profit if levels else np.nan,
|
| 121 |
+
"risk_reward": levels.risk_reward if levels else np.nan,
|
| 122 |
+
"atr": levels.atr if levels else np.nan,
|
| 123 |
+
"swing_support": levels.swing_support if levels else np.nan,
|
| 124 |
+
"swing_resistance": levels.swing_resistance if levels else np.nan,
|
| 125 |
+
"outcome": trade.get('outcome'),
|
| 126 |
+
"exit_price": trade.get('exit_price'),
|
| 127 |
+
"net_r": trade.get('net_r'),
|
| 128 |
+
"bars_held": trade.get('bars_held', 0),
|
| 129 |
+
"error": None,
|
| 130 |
+
}
|
| 131 |
+
)
|
| 132 |
+
return pd.DataFrame(rows)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def summarize_backtest(results: pd.DataFrame, model_name: str) -> dict:
|
| 136 |
+
if results.empty:
|
| 137 |
+
return {
|
| 138 |
+
"model": model_name,
|
| 139 |
+
"total_predictions": 0,
|
| 140 |
+
"correct": 0,
|
| 141 |
+
"incorrect": 0,
|
| 142 |
+
"accuracy_pct": None,
|
| 143 |
+
"mae": None,
|
| 144 |
+
"rmse": None,
|
| 145 |
+
"failed_predictions": 0,
|
| 146 |
+
"gap_skipped": 0,
|
| 147 |
+
"nonflat_accuracy_pct": None,
|
| 148 |
+
"persistence_accuracy_pct": None,
|
| 149 |
+
**summarize_trades(results),
|
| 150 |
+
}
|
| 151 |
+
valid = results.dropna(subset=["correct"])
|
| 152 |
+
total = len(valid)
|
| 153 |
+
correct = int(valid["correct"].sum()) if total else 0
|
| 154 |
+
incorrect = total - correct
|
| 155 |
+
accuracy = round(100 * correct / total, 2) if total else None
|
| 156 |
+
price_valid = results.dropna(subset=["predicted_close", "actual_close"])
|
| 157 |
+
if len(price_valid):
|
| 158 |
+
errors = price_valid["predicted_close"] - price_valid["actual_close"]
|
| 159 |
+
mae = float(np.mean(np.abs(errors)))
|
| 160 |
+
rmse = float(np.sqrt(np.mean(errors**2)))
|
| 161 |
+
else:
|
| 162 |
+
mae = rmse = None
|
| 163 |
+
failed = int(results["error"].notna().sum())
|
| 164 |
+
nonflat = valid[valid['actual_direction'] != 'flat']
|
| 165 |
+
nonflat_accuracy = round(100 * float(nonflat['correct'].mean()), 2) if len(nonflat) else None
|
| 166 |
+
# A last-close persistence forecast predicts no change. Its direction is
|
| 167 |
+
# therefore FLAT, rather than the direction that the actual target took.
|
| 168 |
+
persistence_direction = np.full(len(valid), 'flat', dtype=object)
|
| 169 |
+
persistence_accuracy = round(100 * float(np.mean(persistence_direction == valid['actual_direction'].to_numpy())), 2) if len(valid) else None
|
| 170 |
+
return {
|
| 171 |
+
"model": model_name,
|
| 172 |
+
"total_predictions": total,
|
| 173 |
+
"correct": correct,
|
| 174 |
+
"incorrect": incorrect,
|
| 175 |
+
"accuracy_pct": accuracy,
|
| 176 |
+
"mae": _round_sig(mae) if mae is not None else None,
|
| 177 |
+
"rmse": _round_sig(rmse) if rmse is not None else None,
|
| 178 |
+
"failed_predictions": failed,
|
| 179 |
+
"gap_skipped": int(results['error'].fillna('').str.contains('gap|closure', case=False, regex=True).sum()),
|
| 180 |
+
"nonflat_accuracy_pct": nonflat_accuracy,
|
| 181 |
+
"persistence_accuracy_pct": persistence_accuracy,
|
| 182 |
+
**summarize_trades(results),
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def run_all_models_backtest(
|
| 187 |
+
df: pd.DataFrame,
|
| 188 |
+
models: dict,
|
| 189 |
+
window: int = 100,
|
| 190 |
+
horizon: int = 1,
|
| 191 |
+
step: int = 1,
|
| 192 |
+
features_df: pd.DataFrame = None,
|
| 193 |
+
):
|
| 194 |
+
per_model_results = {}
|
| 195 |
+
per_model_summary = {}
|
| 196 |
+
for name, model in models.items():
|
| 197 |
+
results = backtest_model(
|
| 198 |
+
df, model, window=window, horizon=horizon, step=step, features_df=features_df
|
| 199 |
+
)
|
| 200 |
+
per_model_results[name] = results
|
| 201 |
+
per_model_summary[name] = summarize_backtest(results, name)
|
| 202 |
+
return per_model_results, per_model_summary
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
BASELINE_LABEL = "Close-only"
|
| 206 |
+
FEATURED_LABEL = "+14 Features"
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def run_comparison_backtest(
|
| 210 |
+
df: pd.DataFrame,
|
| 211 |
+
models: dict,
|
| 212 |
+
features_df: pd.DataFrame,
|
| 213 |
+
window: int = 100,
|
| 214 |
+
horizon: int = 1,
|
| 215 |
+
step: int = 1,
|
| 216 |
+
):
|
| 217 |
+
per_model_results = {}
|
| 218 |
+
per_model_summary = {}
|
| 219 |
+
for name, model_pair in models.items():
|
| 220 |
+
baseline_model, featured_model = model_pair
|
| 221 |
+
results_list, summary_list = [], []
|
| 222 |
+
for label, model, feats in (
|
| 223 |
+
(BASELINE_LABEL, baseline_model, None),
|
| 224 |
+
(FEATURED_LABEL, featured_model, features_df),
|
| 225 |
+
):
|
| 226 |
+
results = backtest_model(
|
| 227 |
+
df, model, window=window, horizon=horizon, step=step, features_df=feats
|
| 228 |
+
)
|
| 229 |
+
summary = summarize_backtest(results, name)
|
| 230 |
+
summary["mode"] = label
|
| 231 |
+
results = results.copy()
|
| 232 |
+
results.insert(0, "mode", label)
|
| 233 |
+
results_list.append(results)
|
| 234 |
+
summary_list.append(summary)
|
| 235 |
+
per_model_results[name] = pd.concat(results_list, ignore_index=True)
|
| 236 |
+
per_model_summary[name] = summary_list
|
| 237 |
+
return per_model_results, per_model_summary
|
config.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 4 |
+
OUTPUT_DIR = os.path.join(BASE_DIR, 'outputs')
|
| 5 |
+
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
| 6 |
+
|
| 7 |
+
DEFAULT_FOREX_SYMBOLS = [
|
| 8 |
+
'EURUSD=X', 'GBPUSD=X', 'USDJPY=X', 'USDCHF=X', 'AUDUSD=X',
|
| 9 |
+
'USDCAD=X', 'NZDUSD=X', 'EURJPY=X', 'EURGBP=X', 'GBPJPY=X',
|
| 10 |
+
'AUDJPY=X', 'CADJPY=X', 'CHFJPY=X', 'NZDJPY=X', 'EURCHF=X',
|
| 11 |
+
'EURCAD=X', 'EURAUD=X', 'EURNZD=X', 'GBPCHF=X', 'GBPCAD=X',
|
| 12 |
+
'GBPAUD=X', 'GBPNZD=X', 'AUDCAD=X', 'AUDCHF=X', 'AUDNZD=X',
|
| 13 |
+
'CADCHF=X', 'NZDCAD=X', 'NZDCHF=X', 'USDINR=X', 'USDCNH=X',
|
| 14 |
+
'USDHKD=X', 'USDSGD=X', 'USDZAR=X', 'USDMXN=X', 'USDTRY=X',
|
| 15 |
+
'USDSEK=X', 'USDNOK=X', 'USDDKK=X', 'USDPLN=X', 'USDTHB=X',
|
| 16 |
+
]
|
| 17 |
+
DEFAULT_CRYPTO_SYMBOLS = [
|
| 18 |
+
'BTC-USD', 'ETH-USD', 'BNB-USD', 'XRP-USD', 'SOL-USD',
|
| 19 |
+
'ADA-USD', 'DOGE-USD', 'DOT-USD', 'LTC-USD', 'LINK-USD',
|
| 20 |
+
]
|
| 21 |
+
ALL_DEFAULT_SYMBOLS = DEFAULT_FOREX_SYMBOLS + DEFAULT_CRYPTO_SYMBOLS
|
| 22 |
+
YF_NATIVE_INTERVALS = {
|
| 23 |
+
'1m': {'max_days': 30, 'max_request_days': 7},
|
| 24 |
+
'2m': {'max_days': 60, 'max_request_days': 60},
|
| 25 |
+
'5m': {'max_days': 60, 'max_request_days': 60},
|
| 26 |
+
'15m': {'max_days': 60, 'max_request_days': 60},
|
| 27 |
+
'30m': {'max_days': 60, 'max_request_days': 60},
|
| 28 |
+
'60m': {'max_days': 730, 'max_request_days': 730},
|
| 29 |
+
'90m': {'max_days': 60, 'max_request_days': 60},
|
| 30 |
+
'1d': {'max_days': None, 'max_request_days': None},
|
| 31 |
+
'1wk': {'max_days': None, 'max_request_days': None},
|
| 32 |
+
'1mo': {'max_days': None, 'max_request_days': None},
|
| 33 |
+
}
|
| 34 |
+
CUSTOM_TIMEFRAMES = {
|
| 35 |
+
'1m': {'source': '1m', 'rule': None},
|
| 36 |
+
'2m': {'source': '2m', 'rule': None},
|
| 37 |
+
'5m': {'source': '5m', 'rule': None},
|
| 38 |
+
'10m': {'source': '5m', 'rule': '10min'},
|
| 39 |
+
'15m': {'source': '15m', 'rule': None},
|
| 40 |
+
'30m': {'source': '30m', 'rule': None},
|
| 41 |
+
'1h': {'source': '60m', 'rule': None},
|
| 42 |
+
'2h': {'source': '60m', 'rule': '2h'},
|
| 43 |
+
'4h': {'source': '60m', 'rule': '4h'},
|
| 44 |
+
'1d': {'source': '1d', 'rule': None},
|
| 45 |
+
}
|
| 46 |
+
TIMEFRAMES = ['1m', '2m', '5m', '10m', '15m', '30m', '1h', '2h']
|
| 47 |
+
MODEL_NAMES = ['ARIMA', 'Auto-ARIMA', 'ARIMA-GARCH', 'Moirai', 'TimesFM']
|
| 48 |
+
PROVIDERS = ['Yahoo Finance', 'Binance Spot', 'Twelve Data']
|
| 49 |
+
MOIRAI_CHECKPOINT = 'Salesforce/moirai-1.1-R-small'
|
| 50 |
+
TIMESFM_CHECKPOINT = 'google/timesfm-2.5-200m-pytorch'
|
| 51 |
+
DEFAULT_SYMBOL = 'BTC-USD'
|
| 52 |
+
DEFAULT_TIMEFRAME = '5m'
|
| 53 |
+
DEFAULT_MODEL = 'ARIMA-GARCH'
|
| 54 |
+
DEFAULT_LOOKBACK_DAYS = 30
|
| 55 |
+
DEFAULT_HISTORY_WINDOW = 200
|
| 56 |
+
DEFAULT_HORIZON = 1
|
| 57 |
+
DEFAULT_BACKTEST_STEP = 30
|
| 58 |
+
DEFAULT_MAX_EVALUATIONS = 30
|
| 59 |
+
RANDOM_SEED = 42
|
data/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
pass
|
data/historical.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
import time
|
| 3 |
+
from datetime import datetime, timedelta, timezone
|
| 4 |
+
import pandas as pd
|
| 5 |
+
from config import YF_NATIVE_INTERVALS, CUSTOM_TIMEFRAMES
|
| 6 |
+
from features.feature_pipeline import compute_feature_frame_for_period
|
| 7 |
+
from utils.helpers import feature_warmup_days
|
| 8 |
+
|
| 9 |
+
_YF_TIMEOUT_SECONDS = 30
|
| 10 |
+
OHLCV_COLUMNS = ['Open', 'High', 'Low', 'Close', 'Volume']
|
| 11 |
+
|
| 12 |
+
class DataFetchError(Exception):
|
| 13 |
+
pass
|
| 14 |
+
|
| 15 |
+
def _fetch_yf(symbol: str, interval: str, start: datetime, end: datetime) -> pd.DataFrame:
|
| 16 |
+
import yfinance as yf
|
| 17 |
+
last_error = None
|
| 18 |
+
for attempt in range(3):
|
| 19 |
+
try:
|
| 20 |
+
return yf.Ticker(symbol).history(
|
| 21 |
+
start=start, end=end, interval=interval, auto_adjust=False,
|
| 22 |
+
actions=False, timeout=_YF_TIMEOUT_SECONDS, raise_errors=True,
|
| 23 |
+
)
|
| 24 |
+
except Exception as error:
|
| 25 |
+
last_error = error
|
| 26 |
+
if attempt < 2:
|
| 27 |
+
time.sleep(0.5 * (attempt + 1) + random.uniform(0.0, 0.25))
|
| 28 |
+
raise DataFetchError(f"Yahoo Finance could not fetch {symbol} ({interval}) after 3 attempts: {last_error}") from None
|
| 29 |
+
|
| 30 |
+
def _download_window(symbol: str, interval: str, start: datetime, end: datetime) -> pd.DataFrame:
|
| 31 |
+
df = _fetch_yf(symbol, interval, start, end)
|
| 32 |
+
if df is None or df.empty:
|
| 33 |
+
return pd.DataFrame()
|
| 34 |
+
if isinstance(df.columns, pd.MultiIndex):
|
| 35 |
+
# yfinance can return either (field, ticker) or (ticker, field),
|
| 36 |
+
# depending on version and whether the ticker is cached.
|
| 37 |
+
fields = set(OHLCV_COLUMNS).intersection(set(df.columns.get_level_values(0)))
|
| 38 |
+
if fields:
|
| 39 |
+
df = df.droplevel(1, axis=1)
|
| 40 |
+
else:
|
| 41 |
+
fields = set(OHLCV_COLUMNS).intersection(set(df.columns.get_level_values(-1)))
|
| 42 |
+
if fields:
|
| 43 |
+
df = df.droplevel(0, axis=1)
|
| 44 |
+
df = df.rename(columns=lambda value: str(value).title())
|
| 45 |
+
df.index.name = 'Datetime'
|
| 46 |
+
keep = [c for c in OHLCV_COLUMNS if c in df.columns]
|
| 47 |
+
return df[keep]
|
| 48 |
+
|
| 49 |
+
def _finalize_ohlcv(df: pd.DataFrame) -> pd.DataFrame:
|
| 50 |
+
if df is None or df.empty:
|
| 51 |
+
return df
|
| 52 |
+
df.index = pd.to_datetime(df.index, utc=True)
|
| 53 |
+
df = df[~df.index.isna()].sort_index()
|
| 54 |
+
return df[~df.index.duplicated(keep='last')]
|
| 55 |
+
|
| 56 |
+
def _native_fetch(symbol: str, interval: str, days: int) -> pd.DataFrame:
|
| 57 |
+
if interval not in YF_NATIVE_INTERVALS:
|
| 58 |
+
raise DataFetchError(f"'{interval}' is not a native yfinance interval.")
|
| 59 |
+
limits = YF_NATIVE_INTERVALS[interval]
|
| 60 |
+
max_days, request_days = (limits['max_days'], limits['max_request_days'])
|
| 61 |
+
if max_days is not None:
|
| 62 |
+
days = min(days, max_days)
|
| 63 |
+
if request_days is None:
|
| 64 |
+
request_days = days
|
| 65 |
+
frames = []
|
| 66 |
+
window_end = datetime.now(timezone.utc)
|
| 67 |
+
remaining = days
|
| 68 |
+
while remaining > 0:
|
| 69 |
+
window_days = min(request_days, remaining)
|
| 70 |
+
window_start = window_end - timedelta(days=window_days)
|
| 71 |
+
frame = _download_window(symbol, interval, window_start, window_end)
|
| 72 |
+
if not frame.empty:
|
| 73 |
+
frames.append(frame)
|
| 74 |
+
window_end = window_start
|
| 75 |
+
remaining -= window_days
|
| 76 |
+
if remaining > 0:
|
| 77 |
+
time.sleep(0.1 + random.uniform(0, 0.2))
|
| 78 |
+
df = _finalize_ohlcv(pd.concat(frames)) if frames else pd.DataFrame()
|
| 79 |
+
if df.empty or 'Close' not in df.columns:
|
| 80 |
+
limit_note = f" Yahoo keeps at most {max_days} days of '{interval}' history." if max_days is not None else ''
|
| 81 |
+
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)
|
| 82 |
+
df.attrs['effective_lookback_days'] = days
|
| 83 |
+
df.attrs['source_interval'] = interval
|
| 84 |
+
return df
|
| 85 |
+
|
| 86 |
+
def resample_ohlc(df: pd.DataFrame, rule: str, source_step=None) -> pd.DataFrame:
|
| 87 |
+
agg = {'Open': 'first', 'High': 'max', 'Low': 'min', 'Close': 'last', 'Volume': 'sum'}
|
| 88 |
+
agg = {k: v for k, v in agg.items() if k in df.columns}
|
| 89 |
+
price_cols = [c for c in ('Open', 'High', 'Low', 'Close') if c in agg]
|
| 90 |
+
grouped = df.resample(rule, origin='epoch', label='left', closed='left')
|
| 91 |
+
result = grouped.agg(agg).dropna(subset=price_cols)
|
| 92 |
+
if source_step is not None:
|
| 93 |
+
source_step = pd.Timedelta(source_step)
|
| 94 |
+
target_step = pd.Timedelta(rule)
|
| 95 |
+
expected = int(target_step / source_step)
|
| 96 |
+
counts = grouped['Close'].count()
|
| 97 |
+
times = pd.Series(df.index, index=df.index)
|
| 98 |
+
first = times.resample(rule, origin='epoch').first()
|
| 99 |
+
last = times.resample(rule, origin='epoch').last()
|
| 100 |
+
complete = (counts == expected) & (first == counts.index) & (last == counts.index + target_step - source_step)
|
| 101 |
+
result = result.loc[complete.reindex(result.index, fill_value=False)]
|
| 102 |
+
result.attrs.update(df.attrs)
|
| 103 |
+
return result
|
| 104 |
+
|
| 105 |
+
def get_historical(symbol: str, timeframe: str, lookback_days: int=None) -> pd.DataFrame:
|
| 106 |
+
if not symbol or not symbol.strip():
|
| 107 |
+
raise DataFetchError('No symbol given.')
|
| 108 |
+
symbol = symbol.strip()
|
| 109 |
+
if timeframe in CUSTOM_TIMEFRAMES:
|
| 110 |
+
spec = CUSTOM_TIMEFRAMES[timeframe]
|
| 111 |
+
source_interval, rule = (spec['source'], spec['rule'])
|
| 112 |
+
elif timeframe in YF_NATIVE_INTERVALS:
|
| 113 |
+
source_interval, rule = (timeframe, None)
|
| 114 |
+
else:
|
| 115 |
+
raise DataFetchError(f"Unknown timeframe '{timeframe}'. Valid options: {', '.join(CUSTOM_TIMEFRAMES.keys())}")
|
| 116 |
+
max_days = YF_NATIVE_INTERVALS[source_interval]['max_days']
|
| 117 |
+
requested = int(lookback_days) if lookback_days is not None else 0
|
| 118 |
+
days = requested if requested > 0 else max_days if max_days else 730
|
| 119 |
+
df = _native_fetch(symbol, source_interval, days)
|
| 120 |
+
if rule:
|
| 121 |
+
source_minutes = 60 if source_interval == '60m' else int(source_interval.rstrip('m'))
|
| 122 |
+
df = resample_ohlc(df, rule, source_step=pd.Timedelta(minutes=source_minutes))
|
| 123 |
+
if df.empty:
|
| 124 |
+
raise DataFetchError(f"Not enough '{source_interval}' candles to build '{timeframe}' bars. Try a longer lookback.")
|
| 125 |
+
return df
|
| 126 |
+
|
| 127 |
+
def get_historical_with_features(symbol: str, timeframe: str, lookback_days: int, use_features: bool):
|
| 128 |
+
if not use_features:
|
| 129 |
+
return (get_historical(symbol, timeframe, lookback_days=int(lookback_days)), None)
|
| 130 |
+
padding_days = feature_warmup_days(timeframe, minimum_days=0)
|
| 131 |
+
df_padded = get_historical(symbol, timeframe, lookback_days=int(lookback_days) + padding_days)
|
| 132 |
+
return compute_feature_frame_for_period(df_padded, keep_days=int(lookback_days))
|
data/providers.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Real public market-data adapters. Provider identities never change silently."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
import re
|
| 6 |
+
import threading
|
| 7 |
+
import time
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import pandas as pd
|
| 11 |
+
import requests
|
| 12 |
+
|
| 13 |
+
from config import DEFAULT_CRYPTO_SYMBOLS, DEFAULT_FOREX_SYMBOLS, TIMEFRAMES
|
| 14 |
+
from data.historical import DataFetchError, get_historical, resample_ohlc
|
| 15 |
+
from utils.helpers import MINUTES_PER_CANDLE, drop_forming_candle
|
| 16 |
+
|
| 17 |
+
BINANCE_URL = 'https://api.binance.com/api/v3'
|
| 18 |
+
BINANCE_FALLBACK_URLS = (
|
| 19 |
+
'https://data-api.binance.vision/api/v3',
|
| 20 |
+
'https://api1.binance.com/api/v3',
|
| 21 |
+
)
|
| 22 |
+
TWELVE_URL = 'https://api.twelvedata.com'
|
| 23 |
+
MAX_SOURCE_CANDLES = 100_000
|
| 24 |
+
_CATALOG_CACHE = {}
|
| 25 |
+
_CATALOG_LOCK = threading.Lock()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _json_get(url, params=None):
|
| 29 |
+
try:
|
| 30 |
+
response = requests.get(url, params=params, timeout=(10, 30))
|
| 31 |
+
if response.status_code != 200:
|
| 32 |
+
raise DataFetchError(f'Market-data provider returned HTTP {response.status_code}. Check availability, regional access, and your plan/rate limits.')
|
| 33 |
+
result = response.json()
|
| 34 |
+
except (requests.RequestException, ValueError) as error:
|
| 35 |
+
raise DataFetchError(f'Market-data request failed ({type(error).__name__}). Check your network and provider availability.') from None
|
| 36 |
+
if isinstance(result, dict) and result.get('status') == 'error':
|
| 37 |
+
message = str(result.get('message', 'Unknown provider error'))
|
| 38 |
+
key = os.environ.get('TWELVE_DATA_API_KEY', '')
|
| 39 |
+
if key:
|
| 40 |
+
message = message.replace(key, '[redacted]')
|
| 41 |
+
raise DataFetchError(f'Twelve Data: {message}')
|
| 42 |
+
return result
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _binance_get(path, params=None):
|
| 46 |
+
"""Use Binance's public data mirrors when a hosting region blocks api.binance.com."""
|
| 47 |
+
last_error = None
|
| 48 |
+
for base in (BINANCE_URL, *BINANCE_FALLBACK_URLS):
|
| 49 |
+
try:
|
| 50 |
+
return _json_get(base + path, params)
|
| 51 |
+
except DataFetchError as error:
|
| 52 |
+
last_error = error
|
| 53 |
+
raise last_error
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def normalize_symbol(symbol, provider, market):
|
| 57 |
+
symbol = str(symbol or '').strip().upper()
|
| 58 |
+
if not symbol or len(symbol) > 80 or not re.fullmatch(r'[A-Z0-9=./:_-]+', symbol):
|
| 59 |
+
raise ValueError('Enter a valid provider symbol (for example EURUSD=X, EUR/USD, BTC-USD, or BTCUSDT).')
|
| 60 |
+
if market not in ('Forex', 'Crypto'):
|
| 61 |
+
raise ValueError('Market must be Forex or Crypto.')
|
| 62 |
+
if provider == 'Binance Spot':
|
| 63 |
+
if market != 'Crypto':
|
| 64 |
+
raise ValueError('Binance Spot provides crypto pairs; select Yahoo Finance or Twelve Data for Forex.')
|
| 65 |
+
return symbol.replace('/', '').replace('-', '')
|
| 66 |
+
if provider == 'Twelve Data':
|
| 67 |
+
if market != 'Forex':
|
| 68 |
+
raise ValueError('This Twelve Data adapter is for Forex. Select Binance Spot or Yahoo Finance for Crypto.')
|
| 69 |
+
symbol = symbol.removesuffix('=X').replace('/', '')
|
| 70 |
+
if len(symbol) != 6 or not symbol.isalpha():
|
| 71 |
+
raise ValueError('Use a Forex pair such as EUR/USD for Twelve Data.')
|
| 72 |
+
return symbol[:3] + '/' + symbol[3:]
|
| 73 |
+
if provider != 'Yahoo Finance':
|
| 74 |
+
raise ValueError(f'Unknown provider: {provider}')
|
| 75 |
+
if market == 'Forex':
|
| 76 |
+
pair = symbol.removesuffix('=X').replace('/', '')
|
| 77 |
+
if len(pair) != 6 or not pair.isalpha():
|
| 78 |
+
raise ValueError('Use a Forex pair such as EURUSD=X or EUR/USD.')
|
| 79 |
+
return pair + '=X'
|
| 80 |
+
return symbol.replace('/', '-')
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def catalog(provider, market, query=''):
|
| 84 |
+
"""Return supported catalog entries; custom Yahoo entries are validated on fetch."""
|
| 85 |
+
query = str(query or '').strip().upper()
|
| 86 |
+
if provider == 'Yahoo Finance':
|
| 87 |
+
defaults = DEFAULT_FOREX_SYMBOLS if market == 'Forex' else DEFAULT_CRYPTO_SYMBOLS
|
| 88 |
+
if not query:
|
| 89 |
+
return list(defaults), 'Common Yahoo symbols. Search or type any provider-supported pair; intraday coverage varies by symbol.'
|
| 90 |
+
import yfinance as yf
|
| 91 |
+
try:
|
| 92 |
+
quotes = yf.Search(query, max_results=50, news_count=0, timeout=20).quotes
|
| 93 |
+
except Exception as error:
|
| 94 |
+
raise DataFetchError(f'Yahoo symbol search failed: {type(error).__name__}. You can still type a known symbol.') from None
|
| 95 |
+
wanted = 'CURRENCY' if market == 'Forex' else 'CRYPTOCURRENCY'
|
| 96 |
+
symbols = [item['symbol'] for item in quotes if item.get('quoteType') == wanted and item.get('symbol')]
|
| 97 |
+
symbols += [s for s in defaults if query in s]
|
| 98 |
+
return sorted(set(symbols)), 'Yahoo search results; a listing does not guarantee intraday history.'
|
| 99 |
+
if provider == 'Binance Spot' and market != 'Crypto':
|
| 100 |
+
raise ValueError('Binance Spot is available for Crypto only.')
|
| 101 |
+
if provider == 'Twelve Data' and market != 'Forex':
|
| 102 |
+
raise ValueError('Select Forex for the Twelve Data adapter.')
|
| 103 |
+
key = (provider, market)
|
| 104 |
+
with _CATALOG_LOCK:
|
| 105 |
+
cached = _CATALOG_CACHE.get(key)
|
| 106 |
+
if not cached or time.monotonic() - cached[0] > 900:
|
| 107 |
+
if provider == 'Binance Spot':
|
| 108 |
+
payload = _binance_get('/exchangeInfo')
|
| 109 |
+
symbols = sorted(item['symbol'] for item in payload['symbols'] if item.get('status') == 'TRADING' and item.get('isSpotTradingAllowed', True))
|
| 110 |
+
elif provider == 'Twelve Data':
|
| 111 |
+
payload = _json_get(TWELVE_URL + '/forex_pairs')
|
| 112 |
+
symbols = sorted({item['symbol'] for item in payload.get('data', [])})
|
| 113 |
+
else:
|
| 114 |
+
raise ValueError('Unknown market-data provider.')
|
| 115 |
+
if not symbols:
|
| 116 |
+
raise DataFetchError('Provider returned an empty symbol catalog.')
|
| 117 |
+
cached = (time.monotonic(), symbols)
|
| 118 |
+
with _CATALOG_LOCK:
|
| 119 |
+
_CATALOG_CACHE[key] = cached
|
| 120 |
+
matches = [symbol for symbol in cached[1] if query in symbol]
|
| 121 |
+
return matches, f'{len(cached[1])} provider-listed pairs; {len(matches)} match your search. Trading/history availability depends on the provider.'
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _validate_frame(frame):
|
| 125 |
+
columns = ['Open', 'High', 'Low', 'Close', 'Volume']
|
| 126 |
+
if frame is None or frame.empty:
|
| 127 |
+
raise DataFetchError('No real OHLCV candles were returned.')
|
| 128 |
+
missing = [column for column in columns if column not in frame]
|
| 129 |
+
if missing:
|
| 130 |
+
raise DataFetchError(f'Provider data is missing columns: {missing}.')
|
| 131 |
+
attrs = dict(frame.attrs)
|
| 132 |
+
frame = frame[columns].copy()
|
| 133 |
+
frame.index = pd.to_datetime(frame.index, utc=True, errors='coerce')
|
| 134 |
+
if frame.index.isna().any():
|
| 135 |
+
raise DataFetchError('Provider returned invalid candle timestamps; data has not been repaired or filled.')
|
| 136 |
+
frame = frame.sort_index().loc[lambda x: ~x.index.duplicated(keep='last')]
|
| 137 |
+
for column in columns:
|
| 138 |
+
frame[column] = pd.to_numeric(frame[column], errors='coerce')
|
| 139 |
+
values = frame.to_numpy(dtype=float)
|
| 140 |
+
if not np.isfinite(values).all():
|
| 141 |
+
raise DataFetchError('Provider returned missing/non-finite OHLCV values; data has not been filled or fabricated.')
|
| 142 |
+
bad = (frame[['Open', 'High', 'Low', 'Close']] <= 0).any(axis=1)
|
| 143 |
+
bad |= frame['Volume'] < 0
|
| 144 |
+
bad |= frame['High'] < frame[['Open', 'Close', 'Low']].max(axis=1)
|
| 145 |
+
bad |= frame['Low'] > frame[['Open', 'Close', 'High']].min(axis=1)
|
| 146 |
+
if bad.any():
|
| 147 |
+
raise DataFetchError(f'Provider returned {int(bad.sum())} invalid OHLCV candle(s).')
|
| 148 |
+
frame.attrs.update(attrs)
|
| 149 |
+
return frame
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _binance_history(symbol, timeframe, days):
|
| 153 |
+
source = {'2m': '1m', '10m': '5m'}.get(timeframe, timeframe)
|
| 154 |
+
source_minutes = MINUTES_PER_CANDLE[source]
|
| 155 |
+
if days * 1440 / source_minutes > MAX_SOURCE_CANDLES:
|
| 156 |
+
raise DataFetchError(f'This request exceeds {MAX_SOURCE_CANDLES:,} source candles. Reduce lookback days or use a larger timeframe.')
|
| 157 |
+
clock = _binance_get('/time')
|
| 158 |
+
end_ms = int(clock['serverTime'])
|
| 159 |
+
start_ms = end_ms - int(days * 86_400_000)
|
| 160 |
+
cursor = start_ms
|
| 161 |
+
rows = []
|
| 162 |
+
while cursor < end_ms:
|
| 163 |
+
batch = _binance_get('/klines', {
|
| 164 |
+
'symbol': symbol, 'interval': source, 'startTime': cursor,
|
| 165 |
+
'endTime': end_ms - 1, 'limit': 1000,
|
| 166 |
+
})
|
| 167 |
+
if not isinstance(batch, list):
|
| 168 |
+
raise DataFetchError('Binance returned an unexpected candle response.')
|
| 169 |
+
if not batch:
|
| 170 |
+
break
|
| 171 |
+
rows.extend(batch)
|
| 172 |
+
next_cursor = int(batch[-1][0]) + source_minutes * 60_000
|
| 173 |
+
if next_cursor <= cursor:
|
| 174 |
+
raise DataFetchError('Binance pagination did not advance.')
|
| 175 |
+
cursor = next_cursor
|
| 176 |
+
if len(rows) > MAX_SOURCE_CANDLES:
|
| 177 |
+
raise DataFetchError('Provider request exceeded the source candle limit.')
|
| 178 |
+
if len(batch) < 1000:
|
| 179 |
+
break
|
| 180 |
+
time.sleep(0.05)
|
| 181 |
+
if not rows:
|
| 182 |
+
raise DataFetchError(f'Binance has no candles for {symbol}. Use an active exchange symbol such as BTCUSDT (USDT is not USD).')
|
| 183 |
+
frame = pd.DataFrame([
|
| 184 |
+
[r[1], r[2], r[3], r[4], r[5]] for r in rows
|
| 185 |
+
], columns=['Open', 'High', 'Low', 'Close', 'Volume'], index=pd.to_datetime([int(r[0]) for r in rows], unit='ms', utc=True)).astype(float)
|
| 186 |
+
frame.attrs['provider_now'] = pd.Timestamp(end_ms, unit='ms', tz='UTC').isoformat()
|
| 187 |
+
frame.attrs['source_interval'] = source
|
| 188 |
+
try:
|
| 189 |
+
exchange_info = _binance_get('/exchangeInfo', {'symbol': symbol})
|
| 190 |
+
symbol_info = (exchange_info.get('symbols') or [{}])[0]
|
| 191 |
+
price_filter = next((item for item in symbol_info.get('filters', []) if item.get('filterType') == 'PRICE_FILTER'), None)
|
| 192 |
+
if price_filter and float(price_filter.get('tickSize', 0)) > 0:
|
| 193 |
+
frame.attrs['price_tick_size'] = float(price_filter['tickSize'])
|
| 194 |
+
frame.attrs['price_tick_source'] = 'Binance PRICE_FILTER tickSize'
|
| 195 |
+
except Exception:
|
| 196 |
+
# Precision inference from observed real closes remains available when
|
| 197 |
+
# an exchange-info request is temporarily rate-limited.
|
| 198 |
+
pass
|
| 199 |
+
frame = _validate_frame(frame)
|
| 200 |
+
if source != timeframe:
|
| 201 |
+
frame = resample_ohlc(frame, f'{MINUTES_PER_CANDLE[timeframe]}min', pd.Timedelta(minutes=source_minutes))
|
| 202 |
+
return frame
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def _twelve_history(symbol, timeframe, days):
|
| 206 |
+
api_key = os.environ.get('TWELVE_DATA_API_KEY', '').strip()
|
| 207 |
+
if not api_key:
|
| 208 |
+
raise DataFetchError('Twelve Data Forex history requires TWELVE_DATA_API_KEY in your environment. Your plan must cover the selected pair, interval, and historical depth.')
|
| 209 |
+
source = {'2m': '1m', '10m': '5m', '2h': '1h'}.get(timeframe, timeframe)
|
| 210 |
+
source_minutes = MINUTES_PER_CANDLE[source]
|
| 211 |
+
if days * 1440 / source_minutes > MAX_SOURCE_CANDLES:
|
| 212 |
+
raise DataFetchError('Reduce lookback: request exceeds 100,000 source candles.')
|
| 213 |
+
interval = {'1m': '1min', '5m': '5min', '15m': '15min', '30m': '30min', '1h': '1h'}[source]
|
| 214 |
+
end = pd.Timestamp.now(tz='UTC')
|
| 215 |
+
cutoff = end - pd.Timedelta(days=days)
|
| 216 |
+
records = []
|
| 217 |
+
while end > cutoff:
|
| 218 |
+
payload = _json_get(TWELVE_URL + '/time_series', {
|
| 219 |
+
'symbol': symbol, 'interval': interval, 'apikey': api_key,
|
| 220 |
+
'timezone': 'UTC', 'order': 'DESC', 'outputsize': 5000,
|
| 221 |
+
'start_date': cutoff.strftime('%Y-%m-%d %H:%M:%S'),
|
| 222 |
+
'end_date': end.strftime('%Y-%m-%d %H:%M:%S'),
|
| 223 |
+
})
|
| 224 |
+
batch = payload.get('values', [])
|
| 225 |
+
if not batch:
|
| 226 |
+
break
|
| 227 |
+
records.extend(batch)
|
| 228 |
+
parsed_times = pd.to_datetime([row['datetime'] for row in batch], utc=True, errors='coerce')
|
| 229 |
+
if parsed_times.isna().any():
|
| 230 |
+
raise DataFetchError('Twelve Data returned an invalid candle timestamp.')
|
| 231 |
+
oldest = parsed_times.min()
|
| 232 |
+
next_end = oldest - pd.Timedelta(seconds=1)
|
| 233 |
+
if next_end >= end:
|
| 234 |
+
raise DataFetchError('Twelve Data pagination did not advance.')
|
| 235 |
+
end = next_end
|
| 236 |
+
if len(records) > MAX_SOURCE_CANDLES:
|
| 237 |
+
raise DataFetchError('Too many source candles returned.')
|
| 238 |
+
if len(batch) < 5000:
|
| 239 |
+
break
|
| 240 |
+
time.sleep(1)
|
| 241 |
+
if not records:
|
| 242 |
+
raise DataFetchError('Twelve Data returned no Forex candles for this request.')
|
| 243 |
+
raw = pd.DataFrame(records)
|
| 244 |
+
frame = pd.DataFrame({
|
| 245 |
+
'Open': pd.to_numeric(raw['open']), 'High': pd.to_numeric(raw['high']),
|
| 246 |
+
'Low': pd.to_numeric(raw['low']), 'Close': pd.to_numeric(raw['close']),
|
| 247 |
+
'Volume': pd.to_numeric(raw['volume']) if 'volume' in raw else 0.0,
|
| 248 |
+
})
|
| 249 |
+
frame.index = pd.to_datetime(raw['datetime'], utc=True)
|
| 250 |
+
frame = _validate_frame(frame)
|
| 251 |
+
frame.attrs['volume_note'] = 'Forex has no consolidated traded volume. Zero means unavailable; MAYTHOS uses its price-based proxy.'
|
| 252 |
+
frame.attrs['source_interval'] = source
|
| 253 |
+
if source != timeframe:
|
| 254 |
+
frame = resample_ohlc(frame, f'{MINUTES_PER_CANDLE[timeframe]}min', pd.Timedelta(minutes=source_minutes))
|
| 255 |
+
return frame
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def fetch_market_history(provider, market, symbol, timeframe, lookback_days):
|
| 259 |
+
if timeframe not in TIMEFRAMES:
|
| 260 |
+
raise ValueError('Select one of: ' + ', '.join(TIMEFRAMES))
|
| 261 |
+
symbol = normalize_symbol(symbol, provider, market)
|
| 262 |
+
if provider == 'Yahoo Finance':
|
| 263 |
+
frame = get_historical(symbol, timeframe, int(lookback_days))
|
| 264 |
+
elif provider == 'Binance Spot':
|
| 265 |
+
frame = _binance_history(symbol, timeframe, int(lookback_days))
|
| 266 |
+
else:
|
| 267 |
+
frame = _twelve_history(symbol, timeframe, int(lookback_days))
|
| 268 |
+
frame = _validate_frame(frame)
|
| 269 |
+
attrs = dict(frame.attrs)
|
| 270 |
+
frame, _ = drop_forming_candle(frame, timeframe=timeframe, now=attrs.get('provider_now'))
|
| 271 |
+
if frame.empty:
|
| 272 |
+
raise DataFetchError('No complete, closed candles are available for this request.')
|
| 273 |
+
frame.attrs.update(attrs)
|
| 274 |
+
frame.attrs.update(provider=provider, market=market, symbol=symbol, timeframe=timeframe)
|
| 275 |
+
return frame
|
features/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
pass
|
features/feature_pipeline.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
pass
|
indicators/indicators.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import pandas as pd
|
| 3 |
+
|
| 4 |
+
def sma(series: pd.Series, window: int) -> pd.Series:
|
| 5 |
+
return series.rolling(window=window).mean()
|
| 6 |
+
|
| 7 |
+
def ema(series: pd.Series, window: int) -> pd.Series:
|
| 8 |
+
return series.ewm(span=window, adjust=False).mean()
|
| 9 |
+
|
| 10 |
+
def _wilder_rma(values: pd.Series, window: int) -> pd.Series:
|
| 11 |
+
arr = values.to_numpy(dtype=float)
|
| 12 |
+
n = len(arr)
|
| 13 |
+
out = np.full(n, np.nan)
|
| 14 |
+
first_valid_pos = 0
|
| 15 |
+
while first_valid_pos < n and np.isnan(arr[first_valid_pos]):
|
| 16 |
+
first_valid_pos += 1
|
| 17 |
+
seed_end = first_valid_pos + window
|
| 18 |
+
if seed_end > n:
|
| 19 |
+
return pd.Series(out, index=values.index)
|
| 20 |
+
prev = arr[first_valid_pos:seed_end].mean()
|
| 21 |
+
out[seed_end - 1] = prev
|
| 22 |
+
for i in range(seed_end, n):
|
| 23 |
+
prev = (prev * (window - 1) + arr[i]) / window
|
| 24 |
+
out[i] = prev
|
| 25 |
+
return pd.Series(out, index=values.index)
|
| 26 |
+
|
| 27 |
+
def rsi(series: pd.Series, window: int=14) -> pd.Series:
|
| 28 |
+
delta = series.diff()
|
| 29 |
+
gain = delta.clip(lower=0)
|
| 30 |
+
loss = -delta.clip(upper=0)
|
| 31 |
+
avg_gain = _wilder_rma(gain, window)
|
| 32 |
+
avg_loss = _wilder_rma(loss, window)
|
| 33 |
+
rs = avg_gain / avg_loss.replace(0, np.nan)
|
| 34 |
+
out = 100 - 100 / (1 + rs)
|
| 35 |
+
out = out.mask((avg_loss == 0) & (avg_gain > 0), 100.0)
|
| 36 |
+
return out.mask((avg_loss == 0) & (avg_gain == 0), 50.0)
|
| 37 |
+
|
| 38 |
+
def macd(series: pd.Series, fast: int=12, slow: int=26, signal: int=9):
|
| 39 |
+
fast_ema = ema(series, fast)
|
| 40 |
+
slow_ema = ema(series, slow)
|
| 41 |
+
macd_line = fast_ema - slow_ema
|
| 42 |
+
signal_line = ema(macd_line, signal)
|
| 43 |
+
histogram = macd_line - signal_line
|
| 44 |
+
return (macd_line, signal_line, histogram)
|
| 45 |
+
|
| 46 |
+
def bollinger_bands(series: pd.Series, window: int=20, num_std: float=2.0):
|
| 47 |
+
mid = sma(series, window)
|
| 48 |
+
std = series.rolling(window=window).std(ddof=0)
|
| 49 |
+
return (mid + num_std * std, mid, mid - num_std * std)
|
| 50 |
+
|
| 51 |
+
def stochastic_oscillator(high: pd.Series, low: pd.Series, close: pd.Series, k_window: int=14, d_window: int=3):
|
| 52 |
+
lowest_low = low.rolling(window=k_window).min()
|
| 53 |
+
highest_high = high.rolling(window=k_window).max()
|
| 54 |
+
span = highest_high - lowest_low
|
| 55 |
+
percent_k = 100 * (close - lowest_low) / span.replace(0, np.nan)
|
| 56 |
+
percent_k = percent_k.mask(span == 0, 50.0)
|
| 57 |
+
percent_d = percent_k.rolling(window=d_window).mean()
|
| 58 |
+
return (percent_k, percent_d)
|
| 59 |
+
|
| 60 |
+
def add_all_indicators(df: pd.DataFrame) -> pd.DataFrame:
|
| 61 |
+
out = df.copy()
|
| 62 |
+
out['SMA_20'] = sma(out['Close'], 20)
|
| 63 |
+
out['EMA_20'] = ema(out['Close'], 20)
|
| 64 |
+
out['RSI_14'] = rsi(out['Close'], 14)
|
| 65 |
+
macd_line, signal_line, hist = macd(out['Close'])
|
| 66 |
+
out['MACD'] = macd_line
|
| 67 |
+
out['MACD_Signal'] = signal_line
|
| 68 |
+
out['MACD_Hist'] = hist
|
| 69 |
+
upper, mid, lower = bollinger_bands(out['Close'])
|
| 70 |
+
out['BB_Upper'] = upper
|
| 71 |
+
out['BB_Mid'] = mid
|
| 72 |
+
out['BB_Lower'] = lower
|
| 73 |
+
k, d = stochastic_oscillator(out['High'], out['Low'], out['Close'])
|
| 74 |
+
out['Stoch_%K'] = k
|
| 75 |
+
out['Stoch_%D'] = d
|
| 76 |
+
return out
|
| 77 |
+
|
| 78 |
+
def _round(value, digits):
|
| 79 |
+
return round(float(value), digits) if pd.notna(value) else 'n/a'
|
| 80 |
+
|
| 81 |
+
def latest_signals(df_with_indicators: pd.DataFrame) -> dict:
|
| 82 |
+
last = df_with_indicators.iloc[-1]
|
| 83 |
+
signals = {}
|
| 84 |
+
rsi_value = last['RSI_14']
|
| 85 |
+
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'}
|
| 86 |
+
macd_ready = pd.notna(last['MACD']) and pd.notna(last['MACD_Signal'])
|
| 87 |
+
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)'}
|
| 88 |
+
bb_ready = pd.notna(last['BB_Upper']) and pd.notna(last['BB_Lower'])
|
| 89 |
+
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'}
|
| 90 |
+
sma_ready = pd.notna(last['SMA_20'])
|
| 91 |
+
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)'}
|
| 92 |
+
stoch_value = last['Stoch_%K']
|
| 93 |
+
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'}
|
| 94 |
+
return signals
|
install_foundation_windows.bat
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
setlocal
|
| 3 |
+
cd /d "%~dp0"
|
| 4 |
+
if not exist ".venv\Scripts\python.exe" (
|
| 5 |
+
echo Run setup_windows.bat first.
|
| 6 |
+
pause
|
| 7 |
+
exit /b 1
|
| 8 |
+
)
|
| 9 |
+
".venv\Scripts\python.exe" -m pip install -r requirements-foundation.txt
|
| 10 |
+
if errorlevel 1 (
|
| 11 |
+
pause
|
| 12 |
+
exit /b 1
|
| 13 |
+
)
|
| 14 |
+
".venv\Scripts\python.exe" -m pip check
|
| 15 |
+
echo Pretrained model weights download on first inference. Internet and sufficient disk/RAM are required.
|
| 16 |
+
pause
|
live_runner.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Poll real closed candles with the same selectable dashboard pipeline."""
|
| 2 |
+
import argparse
|
| 3 |
+
import csv
|
| 4 |
+
from dataclasses import asdict
|
| 5 |
+
import json
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
import time
|
| 8 |
+
|
| 9 |
+
import pandas as pd
|
| 10 |
+
|
| 11 |
+
from config import MODEL_NAMES, OUTPUT_DIR, PROVIDERS, TIMEFRAMES
|
| 12 |
+
from pipeline import RunConfig, live_result, prepare_history
|
| 13 |
+
from utils.helpers import price_direction
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def main():
|
| 17 |
+
parser = argparse.ArgumentParser(description='MYTHOS selectable closed-candle forecast logger.')
|
| 18 |
+
parser.add_argument('--provider', choices=PROVIDERS, default='Yahoo Finance')
|
| 19 |
+
parser.add_argument('--market', choices=['Forex', 'Crypto'], default='Crypto')
|
| 20 |
+
parser.add_argument('--symbol', default='BTC-USD')
|
| 21 |
+
parser.add_argument('--timeframe', choices=TIMEFRAMES, default='5m')
|
| 22 |
+
parser.add_argument('--models', nargs='+', choices=MODEL_NAMES, default=['ARIMA-GARCH'])
|
| 23 |
+
parser.add_argument('--lookback-days', type=int, default=30)
|
| 24 |
+
parser.add_argument('--window', type=int, default=200)
|
| 25 |
+
parser.add_argument('--horizon', type=int, default=1)
|
| 26 |
+
parser.add_argument('--features', action='store_true')
|
| 27 |
+
parser.add_argument('--poll-seconds', type=int, default=60)
|
| 28 |
+
args = parser.parse_args()
|
| 29 |
+
config = RunConfig(provider=args.provider, market=args.market, symbol=args.symbol,
|
| 30 |
+
timeframe=args.timeframe, models=tuple(args.models),
|
| 31 |
+
mode='Parallel models' if len(args.models) > 1 else 'Single model',
|
| 32 |
+
lookback_days=args.lookback_days, window=args.window,
|
| 33 |
+
horizon=args.horizon, use_features=args.features)
|
| 34 |
+
run_id = pd.Timestamp.now(tz='UTC').strftime('%Y%m%dT%H%M%SZ')
|
| 35 |
+
path = Path(OUTPUT_DIR) / f'live_logger_{run_id}.csv'
|
| 36 |
+
path.with_suffix('.json').write_text(json.dumps(asdict(config), indent=2), encoding='utf-8')
|
| 37 |
+
fields = ['model', 'predicted_at_utc', 'origin_open_utc', 'target_open_utc', 'current_close',
|
| 38 |
+
'predicted_close', 'predicted_direction', 'entry', 'stop_loss', 'take_profit',
|
| 39 |
+
'risk_reward', 'actual_close', 'actual_direction', 'correct', 'status']
|
| 40 |
+
|
| 41 |
+
def log(row):
|
| 42 |
+
new_file = not path.exists()
|
| 43 |
+
with path.open('a', newline='', encoding='utf-8') as file:
|
| 44 |
+
writer = csv.DictWriter(file, fieldnames=fields)
|
| 45 |
+
if new_file:
|
| 46 |
+
writer.writeheader()
|
| 47 |
+
writer.writerow(row)
|
| 48 |
+
|
| 49 |
+
pending = []
|
| 50 |
+
last_origin = None
|
| 51 |
+
print(f'MYTHOS {config.provider} · {config.symbol} · {config.timeframe} · {config.models}. Logging: {path}')
|
| 52 |
+
try:
|
| 53 |
+
while True:
|
| 54 |
+
try:
|
| 55 |
+
frame, features = prepare_history(config)
|
| 56 |
+
still_pending = []
|
| 57 |
+
for row in pending:
|
| 58 |
+
target = pd.Timestamp(row['target_open_utc'])
|
| 59 |
+
if target in frame.index:
|
| 60 |
+
actual = float(frame.loc[target, 'Close'])
|
| 61 |
+
direction = price_direction(actual, row['current_close'])
|
| 62 |
+
interval = frame.loc[pd.Timestamp(row['origin_open_utc']):target].index.to_series().diff().dropna()
|
| 63 |
+
continuous = len(interval) == config.horizon and (interval == config.candle_step).all()
|
| 64 |
+
verified = {**row, 'actual_close': actual, 'actual_direction': direction,
|
| 65 |
+
'correct': direction == row['predicted_direction'] if continuous else '',
|
| 66 |
+
'status': 'VERIFIED' if continuous else 'GAP — not scored'}
|
| 67 |
+
log(verified)
|
| 68 |
+
print(verified)
|
| 69 |
+
elif frame.index[-1] > target:
|
| 70 |
+
log({**row, 'status': 'MISSING TARGET — not scored'})
|
| 71 |
+
else:
|
| 72 |
+
still_pending.append(row)
|
| 73 |
+
pending = still_pending
|
| 74 |
+
if last_origin != frame.index[-1]:
|
| 75 |
+
result = live_result(config, frame, features)
|
| 76 |
+
last_origin = frame.index[-1]
|
| 77 |
+
for forecast in result['forecasts']:
|
| 78 |
+
if not forecast['forecast_path'] or result['metadata']['stale']:
|
| 79 |
+
print(f"{forecast['Model']}: {forecast['Status']} {forecast['Error']}")
|
| 80 |
+
continue
|
| 81 |
+
pending.append({
|
| 82 |
+
'model': forecast['Model'], 'predicted_at_utc': result['metadata']['fetched_at_utc'],
|
| 83 |
+
'origin_open_utc': last_origin.isoformat(),
|
| 84 |
+
'target_open_utc': (last_origin + config.candle_step * config.horizon).isoformat(),
|
| 85 |
+
'current_close': forecast['Current close'], 'predicted_close': forecast['Predicted close'],
|
| 86 |
+
'predicted_direction': forecast['Direction'].lower(),
|
| 87 |
+
'entry': result['signal'].get('trade_levels', {}).get('entry'),
|
| 88 |
+
'stop_loss': result['signal'].get('trade_levels', {}).get('stop_loss'),
|
| 89 |
+
'take_profit': result['signal'].get('trade_levels', {}).get('take_profit'),
|
| 90 |
+
'risk_reward': result['signal'].get('trade_levels', {}).get('risk_reward'),
|
| 91 |
+
})
|
| 92 |
+
print(f"{forecast['Model']}: {forecast['Direction']} → {forecast['Predicted close']:.8f}")
|
| 93 |
+
except Exception as error:
|
| 94 |
+
print(f'Pipeline error: {error}')
|
| 95 |
+
time.sleep(max(1, args.poll_seconds))
|
| 96 |
+
except KeyboardInterrupt:
|
| 97 |
+
for row in pending:
|
| 98 |
+
log({**row, 'status': 'STOPPED BEFORE TARGET — not scored'})
|
| 99 |
+
print('Stopped.')
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
if __name__ == '__main__':
|
| 103 |
+
main()
|
maythos.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
maythos_integration.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any, Dict, List, Tuple
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pandas as pd
|
| 7 |
+
|
| 8 |
+
from maythos import Candle, MAYTHOS
|
| 9 |
+
from risk_management import CostModel, build_trade_levels, simulate_trade, summarize_trades
|
| 10 |
+
from utils.helpers import MINUTES_PER_CANDLE, price_direction
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
OHLCV_COLUMNS = ('Open', 'High', 'Low', 'Close', 'Volume')
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _normalise_frame(df: pd.DataFrame) -> pd.DataFrame:
|
| 17 |
+
if not isinstance(df, pd.DataFrame):
|
| 18 |
+
raise TypeError('MAYTHOS integration needs a pandas DataFrame.')
|
| 19 |
+
missing = [column for column in OHLCV_COLUMNS if column not in df.columns]
|
| 20 |
+
if missing:
|
| 21 |
+
raise ValueError(f'MAYTHOS integration needs OHLCV columns; missing {missing}.')
|
| 22 |
+
frame = df.loc[:, list(OHLCV_COLUMNS)].copy()
|
| 23 |
+
frame.index = pd.to_datetime(frame.index)
|
| 24 |
+
frame = frame.sort_index()
|
| 25 |
+
frame = frame[~frame.index.duplicated(keep='last')]
|
| 26 |
+
for column in OHLCV_COLUMNS:
|
| 27 |
+
frame[column] = pd.to_numeric(frame[column], errors='coerce')
|
| 28 |
+
if frame.empty or frame[list(OHLCV_COLUMNS)].isna().any().any():
|
| 29 |
+
raise ValueError('MAYTHOS integration received empty or non-finite OHLCV data.')
|
| 30 |
+
if not np.isfinite(frame[list(OHLCV_COLUMNS)].to_numpy(dtype=float)).all():
|
| 31 |
+
raise ValueError('MAYTHOS integration received non-finite OHLCV data.')
|
| 32 |
+
return frame
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _timestamp_seconds(value: Any) -> float:
|
| 36 |
+
timestamp = pd.Timestamp(value)
|
| 37 |
+
if timestamp.tzinfo is None:
|
| 38 |
+
timestamp = timestamp.tz_localize('UTC')
|
| 39 |
+
else:
|
| 40 |
+
timestamp = timestamp.tz_convert('UTC')
|
| 41 |
+
return float(timestamp.timestamp())
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def candle_from_row(timestamp: Any, row: pd.Series, source_id: str = 'historical') -> Candle:
|
| 45 |
+
return Candle(
|
| 46 |
+
timestamp=_timestamp_seconds(timestamp),
|
| 47 |
+
open=float(row['Open']),
|
| 48 |
+
high=float(row['High']),
|
| 49 |
+
low=float(row['Low']),
|
| 50 |
+
close=float(row['Close']),
|
| 51 |
+
volume=float(row['Volume']),
|
| 52 |
+
source_id=source_id,
|
| 53 |
+
is_closed=True,
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _run_engine(frame: pd.DataFrame) -> List[Dict[str, Any]]:
|
| 58 |
+
engine = MAYTHOS()
|
| 59 |
+
outputs: List[Dict[str, Any]] = []
|
| 60 |
+
for timestamp, row in frame.iterrows():
|
| 61 |
+
candle = candle_from_row(timestamp, row)
|
| 62 |
+
output = engine.tick(candle)
|
| 63 |
+
errors = engine.validate(output)
|
| 64 |
+
if errors:
|
| 65 |
+
raise RuntimeError(f'MAYTHOS output contract failed: {errors}')
|
| 66 |
+
outputs.append(output)
|
| 67 |
+
return outputs
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def run_maythos_history(df: pd.DataFrame) -> pd.DataFrame:
|
| 71 |
+
"""Run every MAYTHOS layer once per closed candle without lookahead."""
|
| 72 |
+
frame = _normalise_frame(df)
|
| 73 |
+
outputs = _run_engine(frame)
|
| 74 |
+
rows = []
|
| 75 |
+
for timestamp, row, output in zip(frame.index, frame.itertuples(), outputs):
|
| 76 |
+
rows.append({
|
| 77 |
+
'timestamp': timestamp.isoformat(),
|
| 78 |
+
'close': float(row.Close),
|
| 79 |
+
'direction': output['direction'],
|
| 80 |
+
'confidence': float(output['confidence']),
|
| 81 |
+
'internal_trust': float(output['internal_trust']),
|
| 82 |
+
'execution_suitability': output['execution_suitability'],
|
| 83 |
+
'blocked': bool(output['blocked_flag']),
|
| 84 |
+
'market_state': output['market_state'],
|
| 85 |
+
'regime_label': output['regime_label'],
|
| 86 |
+
'timeframe_alignment': float(output['timeframe_alignment']),
|
| 87 |
+
'reason_summary': output['reason_summary'],
|
| 88 |
+
})
|
| 89 |
+
return pd.DataFrame(rows)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def backtest_maythos(
|
| 93 |
+
df: pd.DataFrame,
|
| 94 |
+
window: int,
|
| 95 |
+
horizon: int = 1,
|
| 96 |
+
step: int = 1,
|
| 97 |
+
max_evaluations: int = None,
|
| 98 |
+
timeframe: str = None,
|
| 99 |
+
market: str = 'Crypto',
|
| 100 |
+
symbol: str = '',
|
| 101 |
+
costs: CostModel = None,
|
| 102 |
+
) -> Tuple[pd.DataFrame, Dict[str, Any]]:
|
| 103 |
+
"""Evaluate MAYTHOS direction only when its signal is execution-ready."""
|
| 104 |
+
if window < 1 or horizon < 1 or step < 1:
|
| 105 |
+
raise ValueError('MAYTHOS backtest window, horizon, and step must be positive.')
|
| 106 |
+
frame = _normalise_frame(df)
|
| 107 |
+
costs = costs or CostModel()
|
| 108 |
+
outputs = _run_engine(frame)
|
| 109 |
+
last_i = len(frame) - horizon
|
| 110 |
+
rows = []
|
| 111 |
+
positions = list(range(window, last_i + 1, step))
|
| 112 |
+
if max_evaluations is not None:
|
| 113 |
+
if max_evaluations < 1:
|
| 114 |
+
raise ValueError('max_evaluations must be positive.')
|
| 115 |
+
positions = positions[-max_evaluations:]
|
| 116 |
+
for i in positions:
|
| 117 |
+
current_index = i - 1
|
| 118 |
+
target_index = i + horizon - 1
|
| 119 |
+
output = outputs[current_index]
|
| 120 |
+
current_close = float(frame['Close'].iloc[current_index])
|
| 121 |
+
actual_close = float(frame['Close'].iloc[target_index])
|
| 122 |
+
continuous = True
|
| 123 |
+
if timeframe:
|
| 124 |
+
times = frame.index[current_index:target_index + 1]
|
| 125 |
+
continuous = (times.to_series().diff().dropna() == pd.Timedelta(minutes=MINUTES_PER_CANDLE[timeframe])).all()
|
| 126 |
+
actionable = continuous and output['direction'] in ('BUY', 'SELL') and not bool(output['blocked_flag']) and output['execution_suitability'] != 'blocked'
|
| 127 |
+
levels = build_trade_levels(frame.iloc[:i], output['direction'], market, symbol) if actionable else None
|
| 128 |
+
trade = {'outcome': 'NO_LEVELS', 'exit_price': None, 'net_r': None, 'bars_held': 0}
|
| 129 |
+
if levels is not None:
|
| 130 |
+
trade = {**trade, **simulate_trade(levels, frame.iloc[i:target_index + 1], costs)}
|
| 131 |
+
actionable = actionable and levels is not None
|
| 132 |
+
predicted_direction = None
|
| 133 |
+
if actionable:
|
| 134 |
+
predicted_direction = 'up' if output['direction'] == 'BUY' else 'down'
|
| 135 |
+
actual_direction = price_direction(actual_close, current_close)
|
| 136 |
+
rows.append({
|
| 137 |
+
'datetime': frame.index[target_index].isoformat(),
|
| 138 |
+
'current_close': current_close,
|
| 139 |
+
'predicted_close': np.nan,
|
| 140 |
+
'actual_close': actual_close,
|
| 141 |
+
'predicted_direction': predicted_direction,
|
| 142 |
+
'actual_direction': actual_direction if actionable else None,
|
| 143 |
+
'correct': bool(predicted_direction == actual_direction) if actionable else None,
|
| 144 |
+
'signal_available': actionable,
|
| 145 |
+
'trade_taken': levels is not None,
|
| 146 |
+
'entry': levels.entry if levels else np.nan,
|
| 147 |
+
'stop_loss': levels.stop_loss if levels else np.nan,
|
| 148 |
+
'take_profit': levels.take_profit if levels else np.nan,
|
| 149 |
+
'risk_reward': levels.risk_reward if levels else np.nan,
|
| 150 |
+
'atr': levels.atr if levels else np.nan,
|
| 151 |
+
'swing_support': levels.swing_support if levels else np.nan,
|
| 152 |
+
'swing_resistance': levels.swing_resistance if levels else np.nan,
|
| 153 |
+
'outcome': trade.get('outcome'),
|
| 154 |
+
'exit_price': trade.get('exit_price'),
|
| 155 |
+
'net_r': trade.get('net_r'),
|
| 156 |
+
'bars_held': trade.get('bars_held', 0),
|
| 157 |
+
'confidence': float(output['confidence']),
|
| 158 |
+
'execution_suitability': output['execution_suitability'],
|
| 159 |
+
'reason_summary': output['reason_summary'],
|
| 160 |
+
'error': None if continuous else 'Target crosses a missing candle or market closure.',
|
| 161 |
+
})
|
| 162 |
+
results = pd.DataFrame(rows)
|
| 163 |
+
summary = summarize_maythos(results)
|
| 164 |
+
return results, summary
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def summarize_maythos(results: pd.DataFrame) -> Dict[str, Any]:
|
| 168 |
+
if results.empty:
|
| 169 |
+
return {
|
| 170 |
+
'engine': 'MAYTHOS',
|
| 171 |
+
'evaluations': 0,
|
| 172 |
+
'actionable_signals': 0,
|
| 173 |
+
'blocked_signals': 0,
|
| 174 |
+
'correct': 0,
|
| 175 |
+
'incorrect': 0,
|
| 176 |
+
'accuracy_pct': None,
|
| 177 |
+
'mean_confidence': 0.0,
|
| 178 |
+
**summarize_trades(results),
|
| 179 |
+
}
|
| 180 |
+
actionable = results[results['signal_available']]
|
| 181 |
+
total = len(actionable)
|
| 182 |
+
correct = int(actionable['correct'].sum()) if total else 0
|
| 183 |
+
return {
|
| 184 |
+
'engine': 'MAYTHOS',
|
| 185 |
+
'evaluations': int(len(results)),
|
| 186 |
+
'actionable_signals': total,
|
| 187 |
+
'blocked_signals': int((~results['signal_available']).sum()),
|
| 188 |
+
'correct': correct,
|
| 189 |
+
'incorrect': total - correct,
|
| 190 |
+
'accuracy_pct': round(100.0 * correct / total, 2) if total else None,
|
| 191 |
+
'mean_confidence': round(float(actionable['confidence'].mean()), 6) if total else 0.0,
|
| 192 |
+
**summarize_trades(results),
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def latest_maythos_output(df: pd.DataFrame) -> Dict[str, Any]:
|
| 197 |
+
"""Return the signal produced using only the final closed candle."""
|
| 198 |
+
frame = _normalise_frame(df)
|
| 199 |
+
return _run_engine(frame)[-1]
|
models/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
pass
|
models/arima_garch_model.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import threading
|
| 2 |
+
import numpy as np
|
| 3 |
+
import pandas as pd
|
| 4 |
+
from models.base_model import BaseForecastModel, standardize_exogenous, validate_history_and_horizon
|
| 5 |
+
from models.dependencies import fit_arima, 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, q in [(0, 0), (1, 0), (0, 1), (1, 1), (2, 0), (0, 2), (2, 1), (1, 2)]:
|
| 28 |
+
if p > self.max_p or q > self.max_q:
|
| 29 |
+
continue
|
| 30 |
+
try:
|
| 31 |
+
fit = fit_arima(arima(returns, exog=exog, order=(p, 0, q)))
|
| 32 |
+
except Exception:
|
| 33 |
+
continue
|
| 34 |
+
if fit.aic < best_aic:
|
| 35 |
+
best_aic = fit.aic
|
| 36 |
+
best_order = (p, 0, 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 |
+
prices = pd.Series(history).astype(float).reset_index(drop=True)
|
| 42 |
+
validate_history_and_horizon(prices, horizon, self.name)
|
| 43 |
+
if len(prices) < 30:
|
| 44 |
+
raise ValueError('ARIMA-GARCH needs at least 30 candles of history.')
|
| 45 |
+
if (prices <= 0).any():
|
| 46 |
+
raise ValueError('ARIMA-GARCH needs strictly positive prices (it uses log returns).')
|
| 47 |
+
arima = import_arima(self.name)
|
| 48 |
+
arch_model = import_arch_model(self.name)
|
| 49 |
+
log_returns = np.log(prices / prices.shift(1)).dropna().reset_index(drop=True)
|
| 50 |
+
exog = None
|
| 51 |
+
if features is not None:
|
| 52 |
+
validate_features(features, expected_len=len(prices), model_name='ARIMA-GARCH')
|
| 53 |
+
exog = standardize_exogenous(features.iloc[1:].reset_index(drop=True), self.name)
|
| 54 |
+
need_search = self._cached_order is None
|
| 55 |
+
if self.refit_order_every and self._calls_since_search >= self.refit_order_every:
|
| 56 |
+
need_search = True
|
| 57 |
+
if need_search:
|
| 58 |
+
self._cached_order = self._best_order(log_returns, exog=exog)
|
| 59 |
+
self._calls_since_search = 0
|
| 60 |
+
self._calls_since_search += 1
|
| 61 |
+
with quiet_fit():
|
| 62 |
+
mean_fit = fit_arima(arima(log_returns, exog=exog, order=self._cached_order))
|
| 63 |
+
if exog is not None:
|
| 64 |
+
future_exog = carry_forward_future(exog, horizon)
|
| 65 |
+
mean_forecast = np.asarray(mean_fit.forecast(steps=horizon, exog=future_exog), dtype=float)
|
| 66 |
+
else:
|
| 67 |
+
mean_forecast = np.asarray(mean_fit.forecast(steps=horizon), dtype=float)
|
| 68 |
+
resid = pd.Series(mean_fit.resid).dropna()
|
| 69 |
+
scale = 100.0
|
| 70 |
+
try:
|
| 71 |
+
garch_fit = arch_model(resid * scale, mean='Zero', vol='GARCH', p=self.garch_p, q=self.garch_q, dist='t', 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 |
+
except Exception:
|
| 75 |
+
# The point forecast remains the fitted ARIMA mean. For a
|
| 76 |
+
# short/degenerate sample, use the observed residual
|
| 77 |
+
# variance as the honest unconditional volatility estimate
|
| 78 |
+
# rather than returning a dummy price forecast.
|
| 79 |
+
variance = np.full(horizon, float(resid.var(ddof=1) / scale ** 2) if len(resid) > 1 else 0.0)
|
| 80 |
+
self.last_volatility_forecast = [float(x) for x in np.sqrt(variance)]
|
| 81 |
+
last_price = float(prices.iloc[-1])
|
| 82 |
+
prices_out = []
|
| 83 |
+
cum_log_return = 0.0
|
| 84 |
+
for r in mean_forecast:
|
| 85 |
+
cum_log_return += float(r)
|
| 86 |
+
prices_out.append(last_price * float(np.exp(cum_log_return)))
|
| 87 |
+
if not all((np.isfinite(x) for x in prices_out)):
|
| 88 |
+
raise ValueError(f'ARIMA-GARCH produced a non-finite forecast for order {self._cached_order}.')
|
| 89 |
+
return prices_out
|
models/arima_model.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import threading
|
| 2 |
+
import numpy as np
|
| 3 |
+
import pandas as pd
|
| 4 |
+
from models.base_model import BaseForecastModel, standardize_exogenous, validate_history_and_horizon
|
| 5 |
+
from models.dependencies import fit_arima, 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 = (0, 1, 0)
|
| 24 |
+
with quiet_fit():
|
| 25 |
+
candidates = [(p, d, q)
|
| 26 |
+
for d in range(self.max_d + 1)
|
| 27 |
+
for p in range(self.max_p + 1)
|
| 28 |
+
for q in range(self.max_q + 1)
|
| 29 |
+
if p + q <= 2]
|
| 30 |
+
for order in candidates:
|
| 31 |
+
try:
|
| 32 |
+
fit = fit_arima(arima(series, exog=exog, order=order))
|
| 33 |
+
except Exception:
|
| 34 |
+
continue
|
| 35 |
+
if fit.aic < best_aic:
|
| 36 |
+
best_aic = fit.aic
|
| 37 |
+
best_order = order
|
| 38 |
+
return best_order
|
| 39 |
+
|
| 40 |
+
def _fit_forecast(self, arima, series, order, horizon, exog):
|
| 41 |
+
"""Fit a positive-price ARIMA with deterministic, real fallbacks."""
|
| 42 |
+
candidates = [order, (0, 1, 0), (1, 1, 0), (0, 1, 1), (1, 1, 1)]
|
| 43 |
+
seen = set()
|
| 44 |
+
last_error = None
|
| 45 |
+
for candidate in candidates:
|
| 46 |
+
if candidate in seen:
|
| 47 |
+
continue
|
| 48 |
+
seen.add(candidate)
|
| 49 |
+
try:
|
| 50 |
+
with quiet_fit():
|
| 51 |
+
fit = fit_arima(arima(series, exog=exog, order=candidate))
|
| 52 |
+
future_exog = carry_forward_future(exog, horizon) if exog is not None else None
|
| 53 |
+
forecast = fit.forecast(steps=horizon, exog=future_exog) if exog is not None else fit.forecast(steps=horizon)
|
| 54 |
+
values = np.exp(np.asarray(forecast, dtype=float))
|
| 55 |
+
if np.isfinite(values).all() and (values > 0).all():
|
| 56 |
+
self._cached_order = candidate
|
| 57 |
+
return values
|
| 58 |
+
except Exception as error:
|
| 59 |
+
last_error = error
|
| 60 |
+
raise ValueError(f'ARIMA could not fit a stable positive-price model: {last_error}') from None
|
| 61 |
+
|
| 62 |
+
def predict(self, history: pd.Series, horizon: int=1, features: pd.DataFrame=None) -> list:
|
| 63 |
+
with self._lock:
|
| 64 |
+
series = pd.Series(history).astype(float).reset_index(drop=True)
|
| 65 |
+
validate_history_and_horizon(series, horizon, self.name)
|
| 66 |
+
if len(series) < 20:
|
| 67 |
+
raise ValueError('ARIMA needs at least 20 candles of history.')
|
| 68 |
+
arima = import_arima(self.name)
|
| 69 |
+
exog = None
|
| 70 |
+
if features is not None:
|
| 71 |
+
validate_features(features, expected_len=len(series), model_name='ARIMA')
|
| 72 |
+
exog = standardize_exogenous(features.reset_index(drop=True), self.name)
|
| 73 |
+
if (series <= 0).any():
|
| 74 |
+
raise ValueError('ARIMA needs strictly positive prices for its log-price model.')
|
| 75 |
+
log_series = np.log(series)
|
| 76 |
+
need_search = self._cached_order is None
|
| 77 |
+
if self.refit_order_every and self._calls_since_search >= self.refit_order_every:
|
| 78 |
+
need_search = True
|
| 79 |
+
if need_search:
|
| 80 |
+
self._cached_order = self._best_order(log_series, exog=exog)
|
| 81 |
+
self._calls_since_search = 0
|
| 82 |
+
self._calls_since_search += 1
|
| 83 |
+
forecast = self._fit_forecast(arima, log_series, self._cached_order, horizon, exog)
|
| 84 |
+
return [float(x) for x in forecast]
|
models/auto_arima_model.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import threading
|
| 2 |
+
import numpy as np
|
| 3 |
+
import pandas as pd
|
| 4 |
+
from models.base_model import BaseForecastModel, standardize_exogenous, validate_history_and_horizon
|
| 5 |
+
from models.dependencies import fit_arima, 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 fit_arima(arima(series, exog=exog, order=order)).aic
|
| 40 |
+
except Exception:
|
| 41 |
+
return np.inf
|
| 42 |
+
|
| 43 |
+
def _stepwise_search(self, series: pd.Series, d: int, exog: pd.DataFrame=None):
|
| 44 |
+
# A compact Hyndman-style candidate set is more stable for noisy
|
| 45 |
+
# intraday data than repeatedly walking a large grid at every origin.
|
| 46 |
+
candidates = [(0, 0), (1, 0), (0, 1), (1, 1), (2, 0), (0, 2), (2, 1), (1, 2)]
|
| 47 |
+
candidates = [(p, q) for p, q in candidates if p <= self.max_p and q <= self.max_q]
|
| 48 |
+
best_order, best_aic = (None, np.inf)
|
| 49 |
+
with quiet_fit():
|
| 50 |
+
for p, q in candidates:
|
| 51 |
+
aic = self._aic(series, (p, d, q), exog=exog)
|
| 52 |
+
if aic < best_aic:
|
| 53 |
+
best_aic, best_order = (aic, (p, q))
|
| 54 |
+
if best_order is None:
|
| 55 |
+
best_order, best_aic = ((0, 0), self._aic(series, (0, d, 0), exog=exog))
|
| 56 |
+
p, q = best_order
|
| 57 |
+
return (p, d, q)
|
| 58 |
+
|
| 59 |
+
def predict(self, history: pd.Series, horizon: int=1, features: pd.DataFrame=None) -> list:
|
| 60 |
+
with self._lock:
|
| 61 |
+
series = pd.Series(history).astype(float).reset_index(drop=True)
|
| 62 |
+
validate_history_and_horizon(series, horizon, self.name)
|
| 63 |
+
if len(series) < 20:
|
| 64 |
+
raise ValueError('Auto-ARIMA needs at least 20 candles of history.')
|
| 65 |
+
arima = import_arima(self.name)
|
| 66 |
+
if (series <= 0).any():
|
| 67 |
+
raise ValueError('Auto-ARIMA needs strictly positive prices for its log-price model.')
|
| 68 |
+
series = np.log(series)
|
| 69 |
+
exog = None
|
| 70 |
+
if features is not None:
|
| 71 |
+
validate_features(features, expected_len=len(series), model_name='Auto-ARIMA')
|
| 72 |
+
exog = standardize_exogenous(features.reset_index(drop=True), self.name)
|
| 73 |
+
need_search = self._cached_order is None
|
| 74 |
+
if self.refit_order_every and self._calls_since_search >= self.refit_order_every:
|
| 75 |
+
need_search = True
|
| 76 |
+
if need_search:
|
| 77 |
+
d = self._find_d(series)
|
| 78 |
+
self._cached_order = self._stepwise_search(series, d, exog=exog)
|
| 79 |
+
self._calls_since_search = 0
|
| 80 |
+
self._calls_since_search += 1
|
| 81 |
+
candidates = [self._cached_order, (0, 1, 0), (1, 1, 0), (0, 1, 1), (1, 1, 1)]
|
| 82 |
+
last_error = None
|
| 83 |
+
for order in dict.fromkeys(candidates):
|
| 84 |
+
try:
|
| 85 |
+
with quiet_fit():
|
| 86 |
+
fit = fit_arima(arima(series, exog=exog, order=order))
|
| 87 |
+
future_exog = carry_forward_future(exog, horizon) if exog is not None else None
|
| 88 |
+
raw = fit.forecast(steps=horizon, exog=future_exog) if exog is not None else fit.forecast(steps=horizon)
|
| 89 |
+
forecast = np.exp(np.asarray(raw, dtype=float))
|
| 90 |
+
if np.isfinite(forecast).all() and (forecast > 0).all():
|
| 91 |
+
self._cached_order = order
|
| 92 |
+
return [float(x) for x in forecast]
|
| 93 |
+
except Exception as error:
|
| 94 |
+
last_error = error
|
| 95 |
+
raise ValueError(f'Auto-ARIMA could not fit a stable positive-price model: {last_error}') from None
|
models/base_model.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import pandas as pd
|
| 3 |
+
from abc import ABC, abstractmethod
|
| 4 |
+
|
| 5 |
+
def validate_history_and_horizon(history, horizon: int, model_name: str) -> None:
|
| 6 |
+
if isinstance(horizon, bool) or not isinstance(horizon, (int, np.integer)) or horizon < 1:
|
| 7 |
+
raise ValueError(f'{model_name}: horizon must be >= 1, got {horizon}.')
|
| 8 |
+
values = np.asarray(pd.Series(history).astype(float), dtype=float)
|
| 9 |
+
if values.ndim != 1 or values.size == 0:
|
| 10 |
+
raise ValueError(f'{model_name}: price history must contain at least one candle.')
|
| 11 |
+
if not np.all(np.isfinite(values)):
|
| 12 |
+
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.')
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def standardize_exogenous(features: pd.DataFrame, model_name: str) -> pd.DataFrame:
|
| 16 |
+
"""Scale causal regressors using statistics from the supplied history only.
|
| 17 |
+
|
| 18 |
+
OHLC prices and oscillator values have very different magnitudes. Passing
|
| 19 |
+
them unscaled to statsmodels frequently creates singular matrices on short
|
| 20 |
+
windows, especially for FX. Scaling here is causal (the future is never
|
| 21 |
+
included) and keeps the same columns so the feature contract remains
|
| 22 |
+
auditable.
|
| 23 |
+
"""
|
| 24 |
+
if not isinstance(features, pd.DataFrame) or features.empty:
|
| 25 |
+
raise ValueError(f'{model_name}: features must be a non-empty DataFrame.')
|
| 26 |
+
numeric = features.apply(pd.to_numeric, errors='coerce').astype(float)
|
| 27 |
+
if not np.isfinite(numeric.to_numpy()).all():
|
| 28 |
+
raise ValueError(f'{model_name}: features contain non-finite values.')
|
| 29 |
+
mean = numeric.mean(axis=0)
|
| 30 |
+
scale = numeric.std(axis=0, ddof=0).replace(0.0, 1.0).fillna(1.0)
|
| 31 |
+
scaled = (numeric - mean) / scale
|
| 32 |
+
# A single outlier should not make the optimizer overflow. This is a
|
| 33 |
+
# bounded transform, not an imputation or a fabricated observation.
|
| 34 |
+
return scaled.clip(-12.0, 12.0)
|
| 35 |
+
|
| 36 |
+
class BaseForecastModel(ABC):
|
| 37 |
+
name = 'base'
|
| 38 |
+
|
| 39 |
+
@abstractmethod
|
| 40 |
+
def predict(self, history: pd.Series, horizon: int=1, features: pd.DataFrame=None) -> list:
|
| 41 |
+
raise NotImplementedError
|
models/dependencies.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import contextlib
|
| 2 |
+
import inspect
|
| 3 |
+
import warnings
|
| 4 |
+
|
| 5 |
+
def _missing(package: str, model: str, error: Exception) -> ImportError:
|
| 6 |
+
return ImportError(f"{model} needs the '{package}' package. Install with:\n pip install {package}\n(original error: {error})")
|
| 7 |
+
|
| 8 |
+
def _patch_statsmodels_pandas_compat() -> None:
|
| 9 |
+
"""Bridge the legacy decorator call used by statsmodels 0.14.x."""
|
| 10 |
+
try:
|
| 11 |
+
import pandas.util._decorators as pandas_decorators
|
| 12 |
+
pandas_original = pandas_decorators.deprecate_kwarg
|
| 13 |
+
pandas_parameters = list(inspect.signature(pandas_original).parameters.values())
|
| 14 |
+
if (
|
| 15 |
+
pandas_parameters
|
| 16 |
+
and pandas_parameters[0].name == 'klass'
|
| 17 |
+
and not getattr(pandas_original, "_rinking_compat", False)
|
| 18 |
+
):
|
| 19 |
+
def pandas_compatible_deprecate_kwarg(*args, **kwargs):
|
| 20 |
+
if len(args) >= 2 and all(isinstance(value, str) for value in args[:2]):
|
| 21 |
+
return pandas_original(FutureWarning, args[0], args[1], *args[2:], **kwargs)
|
| 22 |
+
return pandas_original(*args, **kwargs)
|
| 23 |
+
|
| 24 |
+
pandas_compatible_deprecate_kwarg._rinking_compat = True
|
| 25 |
+
pandas_decorators.deprecate_kwarg = pandas_compatible_deprecate_kwarg
|
| 26 |
+
except ImportError:
|
| 27 |
+
pass
|
| 28 |
+
try:
|
| 29 |
+
import statsmodels.compat.pandas as compat
|
| 30 |
+
except ImportError:
|
| 31 |
+
return
|
| 32 |
+
signature = inspect.signature(compat.deprecate_kwarg)
|
| 33 |
+
if len(signature.parameters) != 5:
|
| 34 |
+
return
|
| 35 |
+
original = compat.deprecate_kwarg
|
| 36 |
+
if getattr(original, "_rinking_compat", False):
|
| 37 |
+
return
|
| 38 |
+
|
| 39 |
+
def compatible_deprecate_kwarg(*args, **kwargs):
|
| 40 |
+
if len(args) >= 2 and all(isinstance(value, str) for value in args[:2]):
|
| 41 |
+
return original(FutureWarning, args[0], args[1], *args[2:], **kwargs)
|
| 42 |
+
return original(*args, **kwargs)
|
| 43 |
+
|
| 44 |
+
compatible_deprecate_kwarg._rinking_compat = True
|
| 45 |
+
compat.deprecate_kwarg = compatible_deprecate_kwarg
|
| 46 |
+
|
| 47 |
+
def import_arima(model_name: str):
|
| 48 |
+
try:
|
| 49 |
+
_patch_statsmodels_pandas_compat()
|
| 50 |
+
from statsmodels.tsa.arima.model import ARIMA
|
| 51 |
+
except ImportError as e:
|
| 52 |
+
raise _missing('statsmodels', model_name, e) from e
|
| 53 |
+
return ARIMA
|
| 54 |
+
|
| 55 |
+
def import_kpss(model_name: str):
|
| 56 |
+
try:
|
| 57 |
+
_patch_statsmodels_pandas_compat()
|
| 58 |
+
from statsmodels.tsa.stattools import kpss
|
| 59 |
+
except ImportError as e:
|
| 60 |
+
raise _missing('statsmodels', model_name, e) from e
|
| 61 |
+
return kpss
|
| 62 |
+
|
| 63 |
+
def import_arch_model(model_name: str):
|
| 64 |
+
try:
|
| 65 |
+
_patch_statsmodels_pandas_compat()
|
| 66 |
+
from arch import arch_model
|
| 67 |
+
except ImportError as e:
|
| 68 |
+
raise _missing('arch', model_name, e) from e
|
| 69 |
+
return arch_model
|
| 70 |
+
|
| 71 |
+
def fit_arima(model):
|
| 72 |
+
"""Fit statsmodels ARIMA with a bounded optimizer budget.
|
| 73 |
+
|
| 74 |
+
Walk-forward evaluation can fit hundreds of models. An unbounded BFGS
|
| 75 |
+
call makes a free Space look hung while adding little value to a short
|
| 76 |
+
intraday window; the selected order is still re-estimated from each
|
| 77 |
+
training window.
|
| 78 |
+
"""
|
| 79 |
+
return model.fit(method_kwargs={'maxiter': 80, 'disp': False})
|
| 80 |
+
|
| 81 |
+
@contextlib.contextmanager
|
| 82 |
+
def quiet_fit():
|
| 83 |
+
with warnings.catch_warnings():
|
| 84 |
+
warnings.simplefilter('ignore')
|
| 85 |
+
yield
|
models/moirai_model.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import threading
|
| 4 |
+
from models.base_model import BaseForecastModel, validate_history_and_horizon
|
| 5 |
+
from config import MOIRAI_CHECKPOINT, RANDOM_SEED
|
| 6 |
+
from features.feature_pipeline import validate_features
|
| 7 |
+
_RNG_LOCK = threading.Lock()
|
| 8 |
+
_MODULE_CACHE = {}
|
| 9 |
+
_MODULE_LOCK = threading.Lock()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _step_from_history(history: pd.Series) -> pd.Timedelta:
|
| 13 |
+
if isinstance(history.index, pd.DatetimeIndex) and len(history.index) >= 2:
|
| 14 |
+
diffs = history.index.to_series().diff().dropna()
|
| 15 |
+
if not diffs.empty:
|
| 16 |
+
step = diffs.median()
|
| 17 |
+
if step > pd.Timedelta(0):
|
| 18 |
+
return step
|
| 19 |
+
return pd.Timedelta(minutes=1)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class MoiraiModel(BaseForecastModel):
|
| 23 |
+
name = "Moirai"
|
| 24 |
+
|
| 25 |
+
def __init__(
|
| 26 |
+
self,
|
| 27 |
+
checkpoint: str = MOIRAI_CHECKPOINT,
|
| 28 |
+
context_length: int = 256,
|
| 29 |
+
num_samples: int = 20,
|
| 30 |
+
):
|
| 31 |
+
self.checkpoint = checkpoint
|
| 32 |
+
self.context_length = context_length
|
| 33 |
+
self.num_samples = num_samples
|
| 34 |
+
self._module = None
|
| 35 |
+
|
| 36 |
+
def _get_module(self):
|
| 37 |
+
from uni2ts.model.moirai import MoiraiModule
|
| 38 |
+
|
| 39 |
+
if self._module is None:
|
| 40 |
+
with _MODULE_LOCK:
|
| 41 |
+
self._module = _MODULE_CACHE.get(self.checkpoint)
|
| 42 |
+
if self._module is None:
|
| 43 |
+
self._module = MoiraiModule.from_pretrained(self.checkpoint)
|
| 44 |
+
_MODULE_CACHE[self.checkpoint] = self._module
|
| 45 |
+
return self._module
|
| 46 |
+
|
| 47 |
+
def predict(
|
| 48 |
+
self, history: pd.Series, horizon: int = 1, features: pd.DataFrame = None
|
| 49 |
+
) -> list:
|
| 50 |
+
validate_history_and_horizon(history, horizon, self.name)
|
| 51 |
+
try:
|
| 52 |
+
import torch
|
| 53 |
+
except ImportError as error:
|
| 54 |
+
raise ImportError('Moirai requires the optional foundation-model packages. Install requirements-foundation.txt.') from error
|
| 55 |
+
with _RNG_LOCK, torch.random.fork_rng(devices=[]), torch.inference_mode():
|
| 56 |
+
torch.manual_seed(RANDOM_SEED)
|
| 57 |
+
return self._predict(history, horizon, features)
|
| 58 |
+
|
| 59 |
+
def _predict(self, history, horizon, features):
|
| 60 |
+
values = np.asarray(history, dtype=np.float32)
|
| 61 |
+
if len(values) < 10:
|
| 62 |
+
raise ValueError("Moirai needs at least 10 candles of history.")
|
| 63 |
+
validate_history_and_horizon(values, horizon, self.name)
|
| 64 |
+
try:
|
| 65 |
+
from uni2ts.model.moirai import MoiraiForecast
|
| 66 |
+
from gluonts.dataset.pandas import PandasDataset
|
| 67 |
+
except ImportError as e:
|
| 68 |
+
raise ImportError(
|
| 69 |
+
f"Moirai needs the 'uni2ts' and 'gluonts' packages. Install with:\n pip install uni2ts torch\n(original error: {e})"
|
| 70 |
+
)
|
| 71 |
+
if features is not None:
|
| 72 |
+
validate_features(
|
| 73 |
+
features,
|
| 74 |
+
expected_len=len(values),
|
| 75 |
+
model_name="Moirai",
|
| 76 |
+
check_nan=False,
|
| 77 |
+
)
|
| 78 |
+
# Bound the context for the small checkpoint so a large dashboard
|
| 79 |
+
# window cannot cause an avoidable CPU-memory spike.
|
| 80 |
+
context_length = min(max(int(self.context_length), 32), 512)
|
| 81 |
+
context = values[-context_length:] if len(values) > context_length else values
|
| 82 |
+
step = _step_from_history(history)
|
| 83 |
+
end_ts = pd.Timestamp(history.index[-1]) if isinstance(history.index, pd.DatetimeIndex) else pd.Timestamp('2000-01-01')
|
| 84 |
+
if end_ts.tzinfo is not None:
|
| 85 |
+
end_ts = end_ts.tz_convert('UTC').tz_localize(None)
|
| 86 |
+
idx = pd.date_range(end=end_ts, periods=len(context), freq=step)
|
| 87 |
+
data = {"target": context}
|
| 88 |
+
feat_cols = []
|
| 89 |
+
if features is not None:
|
| 90 |
+
feat_context = features.iloc[-len(context) :].reset_index(drop=True)
|
| 91 |
+
validate_features(
|
| 92 |
+
feat_context, expected_len=len(context), model_name="Moirai"
|
| 93 |
+
)
|
| 94 |
+
for col in feat_context.columns:
|
| 95 |
+
data[col] = feat_context[col].to_numpy(dtype=np.float32)
|
| 96 |
+
feat_cols = list(feat_context.columns)
|
| 97 |
+
series_df = pd.DataFrame(data, index=idx)
|
| 98 |
+
dataset = PandasDataset(
|
| 99 |
+
series_df,
|
| 100 |
+
target="target",
|
| 101 |
+
past_feat_dynamic_real=feat_cols if feat_cols else None,
|
| 102 |
+
)
|
| 103 |
+
module = self._get_module()
|
| 104 |
+
forecaster = MoiraiForecast(
|
| 105 |
+
module=module,
|
| 106 |
+
prediction_length=horizon,
|
| 107 |
+
context_length=len(context),
|
| 108 |
+
patch_size="auto",
|
| 109 |
+
num_samples=self.num_samples,
|
| 110 |
+
target_dim=1,
|
| 111 |
+
feat_dynamic_real_dim=0,
|
| 112 |
+
past_feat_dynamic_real_dim=len(feat_cols),
|
| 113 |
+
)
|
| 114 |
+
predictor = forecaster.create_predictor(batch_size=1)
|
| 115 |
+
forecast = next(iter(predictor.predict(dataset)))
|
| 116 |
+
point_forecast = np.median(forecast.samples, axis=0)
|
| 117 |
+
out = [float(x) for x in point_forecast]
|
| 118 |
+
if not all(np.isfinite(x) for x in out):
|
| 119 |
+
raise ValueError(
|
| 120 |
+
"Moirai produced a non-finite forecast (NaN/inf) for this window."
|
| 121 |
+
)
|
| 122 |
+
return out
|
models/registry.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import threading
|
| 2 |
+
from models.base_model import BaseForecastModel
|
| 3 |
+
_ORDER_SEARCH_MODELS = {'ARIMA', 'Auto-ARIMA', 'ARIMA-GARCH'}
|
| 4 |
+
_CACHE: dict = {}
|
| 5 |
+
_CACHE_LOCK = threading.Lock()
|
| 6 |
+
|
| 7 |
+
def _build_model(name: str) -> BaseForecastModel:
|
| 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 |
+
raise ValueError(f"Unknown model '{name}'. Expected one of: ARIMA, Auto-ARIMA, ARIMA-GARCH, Moirai, TimesFM.")
|
| 24 |
+
|
| 25 |
+
def get_model(name: str, symbol: str=None, timeframe: str=None, use_features: bool=False) -> BaseForecastModel:
|
| 26 |
+
if name in _ORDER_SEARCH_MODELS:
|
| 27 |
+
variant = 'featured' if use_features else 'base'
|
| 28 |
+
key = (name, symbol, timeframe, variant)
|
| 29 |
+
else:
|
| 30 |
+
key = name
|
| 31 |
+
with _CACHE_LOCK:
|
| 32 |
+
if key in _CACHE:
|
| 33 |
+
return _CACHE[key]
|
| 34 |
+
model = _build_model(name)
|
| 35 |
+
_CACHE[key] = model
|
| 36 |
+
return model
|
| 37 |
+
|
| 38 |
+
def fresh_model(name: str) -> BaseForecastModel:
|
| 39 |
+
return _build_model(name)
|
models/timesfm_model.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import threading
|
| 4 |
+
from models.base_model import BaseForecastModel, validate_history_and_horizon
|
| 5 |
+
from config import TIMESFM_CHECKPOINT
|
| 6 |
+
from features.feature_pipeline import carry_forward_future, validate_features
|
| 7 |
+
_MAX_HORIZON = 64
|
| 8 |
+
_MODEL_CACHE = {}
|
| 9 |
+
_MODEL_CACHE_LOCK = threading.Lock()
|
| 10 |
+
|
| 11 |
+
class TimesFMModel(BaseForecastModel):
|
| 12 |
+
name = 'TimesFM'
|
| 13 |
+
|
| 14 |
+
def __init__(self, checkpoint: str=TIMESFM_CHECKPOINT, max_context: int=512):
|
| 15 |
+
self.checkpoint = checkpoint
|
| 16 |
+
self.max_context = min(max(int(max_context), 32), 512)
|
| 17 |
+
self._model = None
|
| 18 |
+
self._compiled_context = None
|
| 19 |
+
self._lock = threading.Lock()
|
| 20 |
+
|
| 21 |
+
@staticmethod
|
| 22 |
+
def _forecast_config(max_context: int):
|
| 23 |
+
import timesfm
|
| 24 |
+
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)
|
| 25 |
+
|
| 26 |
+
def _load(self):
|
| 27 |
+
import torch
|
| 28 |
+
import timesfm
|
| 29 |
+
if self._model is None:
|
| 30 |
+
with _MODEL_CACHE_LOCK:
|
| 31 |
+
self._model = _MODEL_CACHE.get(self.checkpoint)
|
| 32 |
+
if self._model is None:
|
| 33 |
+
torch.set_float32_matmul_precision('high')
|
| 34 |
+
model_class = getattr(timesfm, 'TimesFM_2p5_200M_torch', None)
|
| 35 |
+
if model_class is None:
|
| 36 |
+
raise ImportError('Installed timesfm package does not expose the TimesFM 2.5 PyTorch model.')
|
| 37 |
+
model = model_class.from_pretrained(self.checkpoint, torch_compile=False)
|
| 38 |
+
model.compile(self._forecast_config(self.max_context))
|
| 39 |
+
self._model = model
|
| 40 |
+
_MODEL_CACHE[self.checkpoint] = model
|
| 41 |
+
self._compiled_context = self.max_context
|
| 42 |
+
return self._model
|
| 43 |
+
|
| 44 |
+
def _ensure_compiled_for(self, context_len: int):
|
| 45 |
+
model = self._load()
|
| 46 |
+
patch = getattr(model.model, 'p', 32)
|
| 47 |
+
desired = min(self.max_context, max(patch, (context_len + patch - 1) // patch * patch))
|
| 48 |
+
if desired != self._compiled_context:
|
| 49 |
+
model.compile(self._forecast_config(desired))
|
| 50 |
+
self._compiled_context = desired
|
| 51 |
+
return model
|
| 52 |
+
|
| 53 |
+
def predict(self, history: pd.Series, horizon: int=1, features: pd.DataFrame=None) -> list:
|
| 54 |
+
with self._lock:
|
| 55 |
+
return self._predict(history, horizon, features)
|
| 56 |
+
|
| 57 |
+
def _predict(self, history, horizon, features):
|
| 58 |
+
values = np.asarray(history, dtype=np.float32)
|
| 59 |
+
validate_history_and_horizon(values, horizon, self.name)
|
| 60 |
+
if len(values) < 32:
|
| 61 |
+
raise ValueError('TimesFM needs at least 32 candles of history.')
|
| 62 |
+
try:
|
| 63 |
+
__import__('timesfm')
|
| 64 |
+
except ImportError as e:
|
| 65 |
+
raise ImportError(f"TimesFM needs the 'timesfm' package. Install with:\n pip install timesfm[torch]\n(original error: {e})")
|
| 66 |
+
if horizon > _MAX_HORIZON:
|
| 67 |
+
raise ValueError(f'This TimesFM wrapper supports at most {_MAX_HORIZON} candles ahead.')
|
| 68 |
+
if features is not None:
|
| 69 |
+
validate_features(features, expected_len=len(values), model_name='TimesFM', check_nan=False)
|
| 70 |
+
context = values[-self.max_context:] if len(values) > self.max_context else values
|
| 71 |
+
model = self._ensure_compiled_for(len(context))
|
| 72 |
+
if features is None:
|
| 73 |
+
point_forecast, _quantiles = model.forecast(horizon=horizon, inputs=[context])
|
| 74 |
+
point_values = np.asarray(point_forecast[0])[-horizon:]
|
| 75 |
+
else:
|
| 76 |
+
feat_context = features.iloc[-len(context):].reset_index(drop=True)
|
| 77 |
+
validate_features(feat_context, expected_len=len(context), model_name='TimesFM')
|
| 78 |
+
feat_future = carry_forward_future(feat_context, horizon)
|
| 79 |
+
dynamic_numerical_covariates = {}
|
| 80 |
+
for col in feat_context.columns:
|
| 81 |
+
full_array = np.concatenate([feat_context[col].to_numpy(dtype=np.float32), feat_future[col].to_numpy(dtype=np.float32)])
|
| 82 |
+
dynamic_numerical_covariates[col] = [full_array]
|
| 83 |
+
try:
|
| 84 |
+
point_forecast, _quantiles = model.forecast_with_covariates(inputs=[context], dynamic_numerical_covariates=dynamic_numerical_covariates, xreg_mode='xreg + timesfm')
|
| 85 |
+
point_values = np.asarray(point_forecast[0])[:horizon]
|
| 86 |
+
except (ImportError, AttributeError, TypeError, ValueError) as e:
|
| 87 |
+
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})")
|
| 88 |
+
out = [float(x) for x in point_values]
|
| 89 |
+
if not all((np.isfinite(x) for x in out)):
|
| 90 |
+
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.')
|
| 91 |
+
return out
|
pipeline.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared, explicit pipeline used by both dashboard tabs and the live logger."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 5 |
+
from dataclasses import asdict, dataclass
|
| 6 |
+
import hashlib
|
| 7 |
+
import importlib.metadata
|
| 8 |
+
import importlib.util
|
| 9 |
+
import json
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
import platform
|
| 12 |
+
import uuid
|
| 13 |
+
import zipfile
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import pandas as pd
|
| 17 |
+
|
| 18 |
+
from backtest.backtester import backtest_model, summarize_backtest
|
| 19 |
+
from config import MODEL_NAMES, OUTPUT_DIR, PROVIDERS, RANDOM_SEED, TIMEFRAMES
|
| 20 |
+
from data.providers import fetch_market_history, normalize_symbol
|
| 21 |
+
from features.feature_pipeline import compute_feature_frame_for_period
|
| 22 |
+
from maythos_integration import backtest_maythos, latest_maythos_output
|
| 23 |
+
from models.registry import fresh_model
|
| 24 |
+
from risk_management import CostModel, build_trade_levels
|
| 25 |
+
from utils.helpers import MINUTES_PER_CANDLE, feature_warmup_days, price_direction
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass(frozen=True)
|
| 29 |
+
class RunConfig:
|
| 30 |
+
provider: str = 'Yahoo Finance'
|
| 31 |
+
market: str = 'Crypto'
|
| 32 |
+
symbol: str = 'BTC-USD'
|
| 33 |
+
timeframe: str = '5m'
|
| 34 |
+
mode: str = 'Single model'
|
| 35 |
+
models: tuple = ('ARIMA-GARCH',)
|
| 36 |
+
lookback_days: int = 30
|
| 37 |
+
window: int = 200
|
| 38 |
+
horizon: int = 1
|
| 39 |
+
step: int = 30
|
| 40 |
+
max_evaluations: int = 30
|
| 41 |
+
use_features: bool = False
|
| 42 |
+
spread_bps: float = 1.0
|
| 43 |
+
fee_bps: float = 1.0
|
| 44 |
+
slippage_bps: float = 1.0
|
| 45 |
+
|
| 46 |
+
def __post_init__(self):
|
| 47 |
+
if self.provider not in PROVIDERS or self.timeframe not in TIMEFRAMES:
|
| 48 |
+
raise ValueError('Choose a supported provider and timeframe.')
|
| 49 |
+
if self.mode not in ('Single model', 'Parallel models'):
|
| 50 |
+
raise ValueError('Choose Single model or Parallel models.')
|
| 51 |
+
models = tuple(dict.fromkeys(self.models or ()))
|
| 52 |
+
if not models or any(name not in MODEL_NAMES for name in models):
|
| 53 |
+
raise ValueError('Select at least one supported model.')
|
| 54 |
+
if self.mode == 'Single model' and len(models) != 1:
|
| 55 |
+
raise ValueError('Single model mode requires exactly one model. Select Parallel models to run several.')
|
| 56 |
+
for name, minimum, maximum in (
|
| 57 |
+
('lookback_days', 1, 730), ('window', 32, 2048), ('horizon', 1, 64),
|
| 58 |
+
('step', 1, 10000), ('max_evaluations', 1, 1000),
|
| 59 |
+
):
|
| 60 |
+
value = getattr(self, name)
|
| 61 |
+
if isinstance(value, bool) or int(value) != value or not minimum <= value <= maximum:
|
| 62 |
+
raise ValueError(f'{name} must be an integer between {minimum} and {maximum}.')
|
| 63 |
+
object.__setattr__(self, name, int(value))
|
| 64 |
+
for name in ('spread_bps', 'fee_bps', 'slippage_bps'):
|
| 65 |
+
value = getattr(self, name)
|
| 66 |
+
if isinstance(value, bool) or not np.isfinite(value) or value < 0 or value > 1000:
|
| 67 |
+
raise ValueError(f'{name} must be a finite number between 0 and 1000 bps.')
|
| 68 |
+
object.__setattr__(self, name, float(value))
|
| 69 |
+
object.__setattr__(self, 'models', models)
|
| 70 |
+
object.__setattr__(self, 'symbol', normalize_symbol(self.symbol, self.provider, self.market))
|
| 71 |
+
|
| 72 |
+
@property
|
| 73 |
+
def candle_step(self):
|
| 74 |
+
return pd.Timedelta(minutes=MINUTES_PER_CANDLE[self.timeframe])
|
| 75 |
+
|
| 76 |
+
@property
|
| 77 |
+
def costs(self):
|
| 78 |
+
return CostModel(self.spread_bps, self.fee_bps, self.slippage_bps)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def model_readiness():
|
| 82 |
+
required = {
|
| 83 |
+
'ARIMA': ['statsmodels'], 'Auto-ARIMA': ['statsmodels'],
|
| 84 |
+
'ARIMA-GARCH': ['statsmodels', 'arch'],
|
| 85 |
+
'Moirai': ['uni2ts', 'gluonts', 'torch'], 'TimesFM': ['timesfm', 'torch'],
|
| 86 |
+
}
|
| 87 |
+
rows = []
|
| 88 |
+
for name, packages in required.items():
|
| 89 |
+
missing = [package for package in packages if importlib.util.find_spec(package) is None]
|
| 90 |
+
rows.append({
|
| 91 |
+
'Model': name,
|
| 92 |
+
'Dependency status': 'Missing: ' + ', '.join(missing) if missing else 'Packages detected',
|
| 93 |
+
'Runtime requirement': 'Pretrained weights download on first use; inference must succeed' if name in ('Moirai', 'TimesFM') else 'Fits actual selected history',
|
| 94 |
+
})
|
| 95 |
+
return pd.DataFrame(rows)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def prepare_history(config):
|
| 99 |
+
padding = feature_warmup_days(config.timeframe, minimum_days=0) if config.use_features else 0
|
| 100 |
+
frame = fetch_market_history(config.provider, config.market, config.symbol, config.timeframe, config.lookback_days + padding)
|
| 101 |
+
attrs = dict(frame.attrs)
|
| 102 |
+
features = None
|
| 103 |
+
if config.use_features:
|
| 104 |
+
frame, features = compute_feature_frame_for_period(frame, keep_days=config.lookback_days)
|
| 105 |
+
frame.attrs.update(attrs)
|
| 106 |
+
if len(frame) < config.window:
|
| 107 |
+
raise ValueError(f'Only {len(frame)} closed candles are available; the selected history window needs {config.window}. Increase lookback, reduce the window, or use a provider with deeper history.')
|
| 108 |
+
return frame, features
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def new_configured_model(name, config):
|
| 112 |
+
model = fresh_model(name)
|
| 113 |
+
if hasattr(model, 'refit_order_every'):
|
| 114 |
+
model.refit_order_every = 1
|
| 115 |
+
if hasattr(model, 'context_length'):
|
| 116 |
+
model.context_length = config.window
|
| 117 |
+
if hasattr(model, 'max_context'):
|
| 118 |
+
model.max_context = config.window
|
| 119 |
+
return model
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def data_metadata(frame, config):
|
| 123 |
+
now = pd.Timestamp.now(tz='UTC')
|
| 124 |
+
last = pd.Timestamp(frame.index[-1])
|
| 125 |
+
if last.tzinfo is None:
|
| 126 |
+
last = last.tz_localize('UTC')
|
| 127 |
+
else:
|
| 128 |
+
last = last.tz_convert('UTC')
|
| 129 |
+
first = pd.Timestamp(frame.index[0])
|
| 130 |
+
if first.tzinfo is None:
|
| 131 |
+
first = first.tz_localize('UTC')
|
| 132 |
+
else:
|
| 133 |
+
first = first.tz_convert('UTC')
|
| 134 |
+
age_seconds = max(0.0, (now - (last + config.candle_step)).total_seconds())
|
| 135 |
+
freshness_limit = config.candle_step.total_seconds() * 3
|
| 136 |
+
stale = age_seconds > freshness_limit
|
| 137 |
+
return {
|
| 138 |
+
'provider': config.provider, 'market': config.market, 'symbol': config.symbol,
|
| 139 |
+
'timeframe': config.timeframe, 'closed_candles': len(frame),
|
| 140 |
+
'first_candle_open_utc': first.isoformat(),
|
| 141 |
+
'last_candle_open_utc': last.isoformat(),
|
| 142 |
+
'last_candle_close_utc': (last + config.candle_step).isoformat(),
|
| 143 |
+
'fetched_at_utc': now.isoformat(), 'seconds_since_last_candle_close': round(age_seconds, 3),
|
| 144 |
+
'stale': stale,
|
| 145 |
+
'freshness_status': 'STALE' if stale else 'FRESH',
|
| 146 |
+
'freshness_policy': f'latest closed candle must be no older than {freshness_limit:g} seconds',
|
| 147 |
+
'noncontinuous_intervals': int((frame.index.to_series().diff().dropna() != config.candle_step).sum()),
|
| 148 |
+
'provider_details': dict(frame.attrs),
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _map_models(config, worker):
|
| 153 |
+
if config.mode == 'Parallel models' and len(config.models) > 1:
|
| 154 |
+
# Loading both foundation checkpoints at once can exhaust a free CPU
|
| 155 |
+
# Space. They remain independent model executions, but are scheduled
|
| 156 |
+
# serially when either heavyweight model is selected.
|
| 157 |
+
foundation = {'Moirai', 'TimesFM'}
|
| 158 |
+
workers = 1 if foundation.intersection(config.models) else min(2, len(config.models))
|
| 159 |
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
| 160 |
+
return list(pool.map(worker, config.models))
|
| 161 |
+
return [worker(name) for name in config.models]
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def live_result(config, frame=None, features=None):
|
| 165 |
+
if frame is None:
|
| 166 |
+
frame, features = prepare_history(config)
|
| 167 |
+
history = frame['Close'].iloc[-config.window:]
|
| 168 |
+
feature_window = features.iloc[-config.window:].reset_index(drop=True) if features is not None else None
|
| 169 |
+
current = float(history.iloc[-1])
|
| 170 |
+
metadata = data_metadata(frame, config)
|
| 171 |
+
|
| 172 |
+
def forecast_one(name):
|
| 173 |
+
try:
|
| 174 |
+
model = new_configured_model(name, config)
|
| 175 |
+
values = model.predict(history, horizon=config.horizon, features=feature_window)
|
| 176 |
+
if len(values) != config.horizon or not np.isfinite(values).all() or any(value <= 0 for value in values):
|
| 177 |
+
raise ValueError('Model did not return a positive, finite forecast for the full horizon.')
|
| 178 |
+
predicted = float(values[-1])
|
| 179 |
+
return {
|
| 180 |
+
'Model': name, 'Status': 'STALE — historical forecast only' if metadata['stale'] else 'OK',
|
| 181 |
+
'Current close': current, 'Predicted close': predicted,
|
| 182 |
+
'Change (%)': (predicted / current - 1) * 100,
|
| 183 |
+
'Direction': price_direction(predicted, current).upper(),
|
| 184 |
+
'Order': str(getattr(model, '_cached_order', 'pretrained')),
|
| 185 |
+
'Error': '', 'forecast_path': [float(value) for value in values],
|
| 186 |
+
}
|
| 187 |
+
except Exception as error:
|
| 188 |
+
return {'Model': name, 'Status': 'FAILED', 'Current close': current, 'Predicted close': None,
|
| 189 |
+
'Change (%)': None, 'Direction': None, 'Order': '', 'Error': str(error), 'forecast_path': []}
|
| 190 |
+
|
| 191 |
+
forecasts = _map_models(config, forecast_one)
|
| 192 |
+
successful_forecasts = [item for item in forecasts if item.get('forecast_path')]
|
| 193 |
+
if successful_forecasts:
|
| 194 |
+
directions = [item['Direction'] for item in successful_forecasts]
|
| 195 |
+
consensus = directions[0] if all(direction == directions[0] for direction in directions) else 'MIXED'
|
| 196 |
+
else:
|
| 197 |
+
consensus = 'UNAVAILABLE'
|
| 198 |
+
try:
|
| 199 |
+
signal = latest_maythos_output(frame)
|
| 200 |
+
signal = {
|
| 201 |
+
**signal,
|
| 202 |
+
'forecast_models_completed': len(successful_forecasts),
|
| 203 |
+
'forecast_models_requested': len(config.models),
|
| 204 |
+
'forecast_consensus': consensus,
|
| 205 |
+
'forecast_agrees_with_signal': (
|
| 206 |
+
consensus == {'BUY': 'UP', 'SELL': 'DOWN'}.get(signal.get('direction'))
|
| 207 |
+
),
|
| 208 |
+
'forecast_coverage_pct': round(100.0 * len(successful_forecasts) / len(config.models), 2),
|
| 209 |
+
'live_actionable': (
|
| 210 |
+
not metadata['stale'] and bool(successful_forecasts) and
|
| 211 |
+
consensus in ('UP', 'DOWN') and
|
| 212 |
+
consensus == {'BUY': 'UP', 'SELL': 'DOWN'}.get(signal.get('direction')) and
|
| 213 |
+
not signal['blocked_flag'] and signal['execution_suitability'] in ('moderate', 'strong')
|
| 214 |
+
),
|
| 215 |
+
}
|
| 216 |
+
if signal['live_actionable']:
|
| 217 |
+
targets = [float(item['Predicted close']) for item in successful_forecasts if item.get('Predicted close') is not None]
|
| 218 |
+
forecast_target = float(np.median(targets)) if targets else None
|
| 219 |
+
levels = build_trade_levels(
|
| 220 |
+
frame, signal['direction'], config.market, config.symbol,
|
| 221 |
+
forecast_target=forecast_target,
|
| 222 |
+
)
|
| 223 |
+
if levels is None:
|
| 224 |
+
signal['live_actionable'] = False
|
| 225 |
+
signal['trade_levels_error'] = 'No causal ATR/swing structure offered a valid positive-risk target.'
|
| 226 |
+
else:
|
| 227 |
+
signal['trade_levels'] = levels.as_dict()
|
| 228 |
+
except Exception as error:
|
| 229 |
+
signal = {'error': str(error), 'live_actionable': False}
|
| 230 |
+
return {'config': config, 'frame': frame, 'features': features, 'metadata': metadata, 'forecasts': forecasts, 'signal': signal}
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def backtest_result(config, frame=None, features=None):
|
| 234 |
+
if frame is None:
|
| 235 |
+
frame, features = prepare_history(config)
|
| 236 |
+
if len(frame) < config.window + config.horizon:
|
| 237 |
+
raise ValueError(f'Backtesting needs at least {config.window + config.horizon} closed candles; provider returned {len(frame)}.')
|
| 238 |
+
|
| 239 |
+
def evaluate_one(name):
|
| 240 |
+
model = new_configured_model(name, config)
|
| 241 |
+
details = backtest_model(
|
| 242 |
+
frame, model, window=config.window, horizon=config.horizon,
|
| 243 |
+
step=config.step, features_df=features,
|
| 244 |
+
max_evaluations=config.max_evaluations, timeframe=config.timeframe,
|
| 245 |
+
market=config.market, symbol=config.symbol, costs=config.costs,
|
| 246 |
+
)
|
| 247 |
+
summary = summarize_backtest(details, name)
|
| 248 |
+
summary['Model'] = summary.pop('model')
|
| 249 |
+
valid = details.dropna(subset=['correct'])
|
| 250 |
+
summary['evaluations_attempted'] = len(details)
|
| 251 |
+
summary['coverage_pct'] = round(100 * len(valid) / len(details), 2) if len(details) else None
|
| 252 |
+
summary['persistence_mae'] = float(np.abs(valid['current_close'] - valid['actual_close']).mean()) if len(valid) else None
|
| 253 |
+
summary['cost_spread_bps'] = config.spread_bps
|
| 254 |
+
summary['cost_fee_bps'] = config.fee_bps
|
| 255 |
+
summary['cost_slippage_bps'] = config.slippage_bps
|
| 256 |
+
summary['status'] = 'OK' if summary['failed_predictions'] == 0 else ('PARTIAL' if len(valid) else 'FAILED')
|
| 257 |
+
details.insert(0, 'Model', name)
|
| 258 |
+
return summary, details
|
| 259 |
+
|
| 260 |
+
evaluated = _map_models(config, evaluate_one)
|
| 261 |
+
try:
|
| 262 |
+
signal_details, signal_summary = backtest_maythos(
|
| 263 |
+
frame, window=config.window, horizon=config.horizon, step=config.step,
|
| 264 |
+
max_evaluations=config.max_evaluations, timeframe=config.timeframe,
|
| 265 |
+
market=config.market, symbol=config.symbol, costs=config.costs,
|
| 266 |
+
)
|
| 267 |
+
except Exception as error:
|
| 268 |
+
signal_details = pd.DataFrame()
|
| 269 |
+
signal_summary = {'engine': 'MAYTHOS', 'error': str(error)}
|
| 270 |
+
return {
|
| 271 |
+
'config': config, 'frame': frame, 'features': features,
|
| 272 |
+
'metadata': data_metadata(frame, config),
|
| 273 |
+
'forecast_summary': pd.DataFrame([summary for summary, _ in evaluated]),
|
| 274 |
+
'forecast_details': pd.concat([details for _, details in evaluated], ignore_index=True),
|
| 275 |
+
'signal_summary': pd.DataFrame([signal_summary]), 'signal_details': signal_details,
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def export_run(result, kind):
|
| 280 |
+
"""Export the exact data, parameters, results and environment for auditing."""
|
| 281 |
+
run_id = pd.Timestamp.now(tz='UTC').strftime('%Y%m%dT%H%M%SZ') + '_' + uuid.uuid4().hex[:8]
|
| 282 |
+
directory = Path(OUTPUT_DIR) / f'{kind}_{run_id}'
|
| 283 |
+
directory.mkdir(parents=True, exist_ok=False)
|
| 284 |
+
snapshot = result['frame'].to_csv(index_label='candle_open_utc', float_format='%.17g')
|
| 285 |
+
(directory / 'candles.csv').write_text(snapshot, encoding='utf-8')
|
| 286 |
+
if result['features'] is not None:
|
| 287 |
+
result['features'].to_csv(directory / 'features.csv', index=False, float_format='%.17g')
|
| 288 |
+
packages = {}
|
| 289 |
+
for name in ('numpy', 'pandas', 'scipy', 'statsmodels', 'arch', 'yfinance', 'gradio', 'torch', 'uni2ts', 'timesfm'):
|
| 290 |
+
try:
|
| 291 |
+
packages[name] = importlib.metadata.version(name)
|
| 292 |
+
except importlib.metadata.PackageNotFoundError:
|
| 293 |
+
packages[name] = None
|
| 294 |
+
manifest = {
|
| 295 |
+
'kind': kind, 'config': asdict(result['config']), 'data': result['metadata'],
|
| 296 |
+
'candles_sha256': hashlib.sha256(snapshot.encode('utf-8')).hexdigest(),
|
| 297 |
+
'random_seed': RANDOM_SEED, 'python': platform.python_version(), 'packages': packages,
|
| 298 |
+
'order_policy': 'ARIMA-family reselects order at each origin from training-window data only',
|
| 299 |
+
'feature_future_policy': 'Last observed covariates carried forward, never future actual indicators',
|
| 300 |
+
'evaluation': 'Walk-forward direction/price error plus causal candle-path SL/TP trade simulation; costs are explicit assumptions',
|
| 301 |
+
'trade_costs': result['config'].costs.as_dict(),
|
| 302 |
+
}
|
| 303 |
+
(directory / 'manifest.json').write_text(json.dumps(manifest, indent=2, default=str, allow_nan=False), encoding='utf-8')
|
| 304 |
+
if kind == 'live':
|
| 305 |
+
pd.DataFrame(result['forecasts']).to_csv(directory / 'forecasts.csv', index=False)
|
| 306 |
+
(directory / 'maythos.json').write_text(json.dumps(result['signal'], indent=2, default=str), encoding='utf-8')
|
| 307 |
+
else:
|
| 308 |
+
for key in ('forecast_summary', 'forecast_details', 'signal_summary', 'signal_details'):
|
| 309 |
+
result[key].to_csv(directory / f'{key}.csv', index=False)
|
| 310 |
+
path = directory.with_suffix('.zip')
|
| 311 |
+
with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as archive:
|
| 312 |
+
for file in sorted(directory.iterdir()):
|
| 313 |
+
archive.write(file, file.name)
|
| 314 |
+
return str(path)
|
requirements-core.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
numpy==1.26.4
|
| 2 |
+
pandas==2.1.4
|
| 3 |
+
scipy==1.11.4
|
| 4 |
+
statsmodels==0.14.6
|
| 5 |
+
arch==8.0.0
|
| 6 |
+
yfinance==1.7.0
|
| 7 |
+
gradio==5.50.0
|
| 8 |
+
plotly==6.9.0
|
| 9 |
+
requests==2.34.2
|
requirements-dev.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-r requirements.txt
|
| 2 |
+
pytest==9.1.1
|
requirements-foundation.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Optional real Moirai + TimesFM inference (CPU). Install after requirements.txt.
|
| 2 |
+
--extra-index-url https://download.pytorch.org/whl/cpu
|
| 3 |
+
torch==2.4.1+cpu
|
| 4 |
+
uni2ts==2.0.0
|
| 5 |
+
timesfm[torch]==3.0.2
|
| 6 |
+
gluonts==0.14.4
|
| 7 |
+
lightning==2.4.0
|
| 8 |
+
pytorch-lightning==2.4.0
|
| 9 |
+
torchmetrics==1.4.3
|
| 10 |
+
huggingface_hub==0.36.0
|
| 11 |
+
pyarrow==16.1.0
|
| 12 |
+
jax[cpu]==0.4.38
|
| 13 |
+
scikit-learn==1.5.2
|
| 14 |
+
safetensors>=0.5.3
|
requirements.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Spaces runtime dependencies. The Space builder adds its own
|
| 2 |
+
# Gradio/uvicorn packages; these are the application and model dependencies.
|
| 3 |
+
--extra-index-url https://download.pytorch.org/whl/cpu
|
| 4 |
+
numpy>=1.26.0,<2.0
|
| 5 |
+
pandas>=2.1,<2.3
|
| 6 |
+
scipy>=1.10,<1.16
|
| 7 |
+
statsmodels>=0.14
|
| 8 |
+
arch>=6.3
|
| 9 |
+
yfinance>=0.2.40
|
| 10 |
+
gradio>=5.50,<6
|
| 11 |
+
plotly>=5.20
|
| 12 |
+
requests>=2.32
|
| 13 |
+
uni2ts==2.0.0
|
| 14 |
+
gluonts==0.14.4
|
| 15 |
+
timesfm[torch]==3.0.2
|
| 16 |
+
torch
|
| 17 |
+
einops
|
| 18 |
+
safetensors
|
| 19 |
+
jax[cpu]
|
| 20 |
+
scikit-learn
|
| 21 |
+
huggingface_hub
|
risk_management.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Causal trade-level construction and candle-path execution accounting.
|
| 2 |
+
|
| 3 |
+
The functions in this module are deliberately shared by Live Signal and
|
| 4 |
+
Backtesting. A level is only built from candles available at the signal
|
| 5 |
+
origin. No future candle is used to choose ATR, structure, precision, SL or
|
| 6 |
+
TP.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from dataclasses import dataclass, asdict
|
| 11 |
+
from decimal import Decimal
|
| 12 |
+
import math
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
import pandas as pd
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
ATR_PERIOD = 14
|
| 19 |
+
SWING_LOOKBACK = 20
|
| 20 |
+
ATR_STOP_MULTIPLIER = 1.25
|
| 21 |
+
ATR_STRUCTURE_BUFFER = 0.25
|
| 22 |
+
MIN_RISK_REWARD = 1.0
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass(frozen=True)
|
| 26 |
+
class CostModel:
|
| 27 |
+
"""Explicit round-trip execution assumptions, in basis points.
|
| 28 |
+
|
| 29 |
+
OHLC feeds do not contain bid/ask or account fee schedules. These values
|
| 30 |
+
are therefore exposed as assumptions and written into every manifest,
|
| 31 |
+
rather than being presented as provider-measured spreads.
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
spread_bps: float = 1.0
|
| 35 |
+
fee_bps: float = 1.0
|
| 36 |
+
slippage_bps: float = 1.0
|
| 37 |
+
|
| 38 |
+
def __post_init__(self):
|
| 39 |
+
for name in ('spread_bps', 'fee_bps', 'slippage_bps'):
|
| 40 |
+
value = getattr(self, name)
|
| 41 |
+
if isinstance(value, bool) or not np.isfinite(value) or value < 0 or value > 1000:
|
| 42 |
+
raise ValueError(f'{name} must be a finite number between 0 and 1000 bps.')
|
| 43 |
+
|
| 44 |
+
def as_dict(self):
|
| 45 |
+
return asdict(self)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@dataclass(frozen=True)
|
| 49 |
+
class TradeLevels:
|
| 50 |
+
direction: str
|
| 51 |
+
entry: float
|
| 52 |
+
stop_loss: float
|
| 53 |
+
take_profit: float
|
| 54 |
+
risk_per_unit: float
|
| 55 |
+
reward_per_unit: float
|
| 56 |
+
risk_reward: float
|
| 57 |
+
atr: float
|
| 58 |
+
swing_support: float
|
| 59 |
+
swing_resistance: float
|
| 60 |
+
tick_size: float
|
| 61 |
+
precision_digits: int
|
| 62 |
+
level_basis: str
|
| 63 |
+
|
| 64 |
+
def as_dict(self):
|
| 65 |
+
return asdict(self)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _finite_positive(value) -> bool:
|
| 69 |
+
return np.isfinite(value) and float(value) > 0
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def infer_tick_size(frame: pd.DataFrame, market: str, symbol: str) -> tuple[float, int]:
|
| 73 |
+
"""Infer the quote increment from provider metadata or real observations.
|
| 74 |
+
|
| 75 |
+
Binance adapters can provide an exact exchange filter through
|
| 76 |
+
``frame.attrs['price_tick_size']``. Forex uses the conventional quoted
|
| 77 |
+
pip precision (five decimals, three for JPY quotes). For other feeds the
|
| 78 |
+
most precise decimal scale present in recent real closes is retained.
|
| 79 |
+
"""
|
| 80 |
+
exact = frame.attrs.get('price_tick_size')
|
| 81 |
+
if exact is not None and _finite_positive(float(exact)):
|
| 82 |
+
tick = float(exact)
|
| 83 |
+
return tick, max(0, int(round(-math.log10(tick))))
|
| 84 |
+
if market == 'Forex':
|
| 85 |
+
clean = str(symbol).upper().replace('=X', '').replace('/', '')
|
| 86 |
+
digits = 3 if clean.endswith('JPY') else 5
|
| 87 |
+
return 10.0 ** (-digits), digits
|
| 88 |
+
decimals = 0
|
| 89 |
+
for value in pd.to_numeric(frame['Close'].tail(100), errors='coerce').dropna():
|
| 90 |
+
exponent = Decimal(str(float(value))).as_tuple().exponent
|
| 91 |
+
decimals = max(decimals, min(8, max(0, -exponent)))
|
| 92 |
+
decimals = min(decimals, 8)
|
| 93 |
+
tick = 10.0 ** (-decimals)
|
| 94 |
+
return tick, decimals
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _round_to_tick(value: float, tick: float, direction: str) -> float:
|
| 98 |
+
scaled = float(value) / tick
|
| 99 |
+
if direction == 'down':
|
| 100 |
+
rounded = math.floor(scaled + 1e-12) * tick
|
| 101 |
+
else:
|
| 102 |
+
rounded = math.ceil(scaled - 1e-12) * tick
|
| 103 |
+
return float(round(rounded, max(0, int(round(-math.log10(tick)))) + 2))
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _true_range(frame: pd.DataFrame) -> pd.Series:
|
| 107 |
+
previous_close = frame['Close'].shift(1)
|
| 108 |
+
return pd.concat(
|
| 109 |
+
[frame['High'] - frame['Low'],
|
| 110 |
+
(frame['High'] - previous_close).abs(),
|
| 111 |
+
(frame['Low'] - previous_close).abs()], axis=1,
|
| 112 |
+
).max(axis=1)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def average_true_range(frame: pd.DataFrame, period: int = ATR_PERIOD) -> float:
|
| 116 |
+
if len(frame) < period:
|
| 117 |
+
return float('nan')
|
| 118 |
+
tr = _true_range(frame)
|
| 119 |
+
# Wilder's ATR seeds with the arithmetic mean of the first `period` true
|
| 120 |
+
# ranges, then applies the recursive moving-average update. pandas' EWM
|
| 121 |
+
# ``adjust=False`` starts from the first observation, which is a different
|
| 122 |
+
# estimator and materially changes short causal trade levels.
|
| 123 |
+
values = tr.to_numpy(dtype=float)
|
| 124 |
+
if not np.isfinite(values[:period]).all():
|
| 125 |
+
return float('nan')
|
| 126 |
+
atr = float(values[:period].mean())
|
| 127 |
+
for value in values[period:]:
|
| 128 |
+
if not np.isfinite(value):
|
| 129 |
+
return float('nan')
|
| 130 |
+
atr = (atr * (period - 1) + float(value)) / period
|
| 131 |
+
value = atr
|
| 132 |
+
return value if _finite_positive(value) else float('nan')
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def build_trade_levels(
|
| 136 |
+
frame: pd.DataFrame,
|
| 137 |
+
direction: str,
|
| 138 |
+
market: str,
|
| 139 |
+
symbol: str,
|
| 140 |
+
forecast_target: float | None = None,
|
| 141 |
+
atr_period: int = ATR_PERIOD,
|
| 142 |
+
swing_lookback: int = SWING_LOOKBACK,
|
| 143 |
+
) -> TradeLevels | None:
|
| 144 |
+
"""Build causal, volatility-aware levels for one closed-candle signal.
|
| 145 |
+
|
| 146 |
+
The stop is the farther of an ATR noise stop and a structure stop. The
|
| 147 |
+
target must be a real opposite-side structure level or a compatible real
|
| 148 |
+
model forecast, and signals without at least 1R structural reward are
|
| 149 |
+
rejected instead of receiving an arbitrary target.
|
| 150 |
+
"""
|
| 151 |
+
direction = str(direction or '').upper()
|
| 152 |
+
if direction not in ('BUY', 'SELL') or frame is None or len(frame) < max(atr_period, swing_lookback) + 1:
|
| 153 |
+
return None
|
| 154 |
+
required = {'High', 'Low', 'Close'}
|
| 155 |
+
if not required.issubset(frame.columns):
|
| 156 |
+
return None
|
| 157 |
+
numeric = frame[list(required)].apply(pd.to_numeric, errors='coerce')
|
| 158 |
+
if not np.isfinite(numeric.to_numpy(dtype=float)).all() or (numeric <= 0).any().any():
|
| 159 |
+
return None
|
| 160 |
+
entry = float(frame['Close'].iloc[-1])
|
| 161 |
+
atr = average_true_range(frame, atr_period)
|
| 162 |
+
if not _finite_positive(atr) or not _finite_positive(entry):
|
| 163 |
+
return None
|
| 164 |
+
tick, precision_digits = infer_tick_size(frame, market, symbol)
|
| 165 |
+
# Use only candles before the signal candle for structural levels. This
|
| 166 |
+
# makes the swing level stable at the exact point the order is created.
|
| 167 |
+
structure = frame.iloc[:-1].tail(swing_lookback)
|
| 168 |
+
support = float(structure['Low'].min())
|
| 169 |
+
resistance = float(structure['High'].max())
|
| 170 |
+
buffer = max(ATR_STRUCTURE_BUFFER * atr, 2.0 * tick)
|
| 171 |
+
if direction == 'BUY':
|
| 172 |
+
stop_raw = min(entry - ATR_STOP_MULTIPLIER * atr, support - buffer)
|
| 173 |
+
target_candidates = [resistance + buffer]
|
| 174 |
+
if forecast_target is not None and np.isfinite(forecast_target) and forecast_target > entry:
|
| 175 |
+
target_candidates.append(float(forecast_target))
|
| 176 |
+
target_raw = max(target_candidates)
|
| 177 |
+
stop = _round_to_tick(stop_raw, tick, 'down')
|
| 178 |
+
target = _round_to_tick(target_raw, tick, 'up')
|
| 179 |
+
risk = entry - stop
|
| 180 |
+
reward = target - entry
|
| 181 |
+
else:
|
| 182 |
+
stop_raw = max(entry + ATR_STOP_MULTIPLIER * atr, resistance + buffer)
|
| 183 |
+
target_candidates = [support - buffer]
|
| 184 |
+
if forecast_target is not None and np.isfinite(forecast_target) and forecast_target < entry:
|
| 185 |
+
target_candidates.append(float(forecast_target))
|
| 186 |
+
target_raw = min(target_candidates)
|
| 187 |
+
stop = _round_to_tick(stop_raw, tick, 'up')
|
| 188 |
+
target = _round_to_tick(target_raw, tick, 'down')
|
| 189 |
+
risk = stop - entry
|
| 190 |
+
reward = entry - target
|
| 191 |
+
if not (_finite_positive(risk) and _finite_positive(reward)):
|
| 192 |
+
return None
|
| 193 |
+
rr = reward / risk
|
| 194 |
+
if not np.isfinite(rr) or rr < MIN_RISK_REWARD:
|
| 195 |
+
return None
|
| 196 |
+
return TradeLevels(
|
| 197 |
+
direction=direction, entry=entry, stop_loss=stop, take_profit=target,
|
| 198 |
+
risk_per_unit=float(risk), reward_per_unit=float(reward),
|
| 199 |
+
risk_reward=float(rr), atr=float(atr), swing_support=support,
|
| 200 |
+
swing_resistance=resistance, tick_size=tick,
|
| 201 |
+
precision_digits=precision_digits,
|
| 202 |
+
level_basis='Wilder ATR + prior closed-candle swing structure + real forecast target when compatible',
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def _execution_price(raw_price: float, direction: str, is_entry: bool, costs: CostModel) -> float:
|
| 207 |
+
half_spread = costs.spread_bps / 2.0
|
| 208 |
+
adjustment = (half_spread + costs.slippage_bps) / 10000.0
|
| 209 |
+
sign = 1.0 if direction == 'BUY' else -1.0
|
| 210 |
+
if is_entry:
|
| 211 |
+
return raw_price * (1.0 + sign * adjustment)
|
| 212 |
+
return raw_price * (1.0 - sign * adjustment)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def _net_r(entry: float, exit_price: float, direction: str, risk: float, costs: CostModel) -> float:
|
| 216 |
+
entry_fill = _execution_price(entry, direction, True, costs)
|
| 217 |
+
exit_fill = _execution_price(exit_price, direction, False, costs)
|
| 218 |
+
sign = 1.0 if direction == 'BUY' else -1.0
|
| 219 |
+
gross = sign * (exit_fill - entry_fill)
|
| 220 |
+
# CostModel.fee_bps is explicitly a round-trip assumption. Apply it once
|
| 221 |
+
# to the average filled notional rather than charging the same round-trip
|
| 222 |
+
# rate independently to both entry and exit.
|
| 223 |
+
fees = (costs.fee_bps / 10000.0) * ((entry_fill + exit_fill) / 2.0)
|
| 224 |
+
return float((gross - fees) / risk)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def simulate_trade(levels: TradeLevels, path: pd.DataFrame, costs: CostModel) -> dict:
|
| 228 |
+
"""Resolve SL/TP from actual future candle high/low data.
|
| 229 |
+
|
| 230 |
+
If both levels are touched within one OHLC candle, SL is deliberately
|
| 231 |
+
chosen first because intrabar ordering is unavailable and that is the
|
| 232 |
+
conservative policy requested by the dashboard.
|
| 233 |
+
"""
|
| 234 |
+
if path is None or path.empty:
|
| 235 |
+
return {'outcome': 'NO_PATH', 'exit_price': None, 'net_r': None, 'bars_held': 0}
|
| 236 |
+
direction = levels.direction
|
| 237 |
+
for offset, (timestamp, candle) in enumerate(path.iterrows(), start=1):
|
| 238 |
+
high = float(candle['High'])
|
| 239 |
+
low = float(candle['Low'])
|
| 240 |
+
opening = float(candle['Open']) if 'Open' in candle else None
|
| 241 |
+
if direction == 'BUY':
|
| 242 |
+
if opening is not None and opening <= levels.stop_loss:
|
| 243 |
+
exit_price, outcome = opening, 'SL'
|
| 244 |
+
elif opening is not None and opening >= levels.take_profit:
|
| 245 |
+
exit_price, outcome = opening, 'TP'
|
| 246 |
+
elif low <= levels.stop_loss:
|
| 247 |
+
exit_price, outcome = levels.stop_loss, 'SL'
|
| 248 |
+
elif high >= levels.take_profit:
|
| 249 |
+
exit_price, outcome = levels.take_profit, 'TP'
|
| 250 |
+
else:
|
| 251 |
+
continue
|
| 252 |
+
else:
|
| 253 |
+
if opening is not None and opening >= levels.stop_loss:
|
| 254 |
+
exit_price, outcome = opening, 'SL'
|
| 255 |
+
elif opening is not None and opening <= levels.take_profit:
|
| 256 |
+
exit_price, outcome = opening, 'TP'
|
| 257 |
+
elif high >= levels.stop_loss:
|
| 258 |
+
exit_price, outcome = levels.stop_loss, 'SL'
|
| 259 |
+
elif low <= levels.take_profit:
|
| 260 |
+
exit_price, outcome = levels.take_profit, 'TP'
|
| 261 |
+
else:
|
| 262 |
+
continue
|
| 263 |
+
return {
|
| 264 |
+
'outcome': outcome, 'exit_price': float(exit_price),
|
| 265 |
+
'exit_timestamp': timestamp.isoformat() if hasattr(timestamp, 'isoformat') else str(timestamp),
|
| 266 |
+
'net_r': _net_r(levels.entry, exit_price, direction, levels.risk_per_unit, costs),
|
| 267 |
+
'bars_held': offset,
|
| 268 |
+
}
|
| 269 |
+
timestamp = path.index[-1]
|
| 270 |
+
exit_price = float(path['Close'].iloc[-1])
|
| 271 |
+
return {
|
| 272 |
+
'outcome': 'TIMEOUT', 'exit_price': exit_price,
|
| 273 |
+
'exit_timestamp': timestamp.isoformat() if hasattr(timestamp, 'isoformat') else str(timestamp),
|
| 274 |
+
'net_r': _net_r(levels.entry, exit_price, direction, levels.risk_per_unit, costs),
|
| 275 |
+
'bars_held': len(path),
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def summarize_trades(details: pd.DataFrame) -> dict:
|
| 280 |
+
if details is None or details.empty or 'trade_taken' not in details:
|
| 281 |
+
return {
|
| 282 |
+
'trades': 0, 'wins': 0, 'losses': 0, 'timeouts': 0,
|
| 283 |
+
'win_rate_pct': None, 'profit_factor': None,
|
| 284 |
+
'expectancy_r': None, 'max_drawdown_r': None, 'average_r': None,
|
| 285 |
+
}
|
| 286 |
+
trades = details[details['trade_taken'] & details['net_r'].notna()].copy()
|
| 287 |
+
if trades.empty:
|
| 288 |
+
return {
|
| 289 |
+
'trades': 0, 'wins': 0, 'losses': 0, 'timeouts': 0,
|
| 290 |
+
'win_rate_pct': None, 'profit_factor': None,
|
| 291 |
+
'expectancy_r': None, 'max_drawdown_r': None, 'average_r': None,
|
| 292 |
+
}
|
| 293 |
+
returns = trades['net_r'].astype(float).to_numpy()
|
| 294 |
+
wins = int((trades['outcome'] == 'TP').sum())
|
| 295 |
+
losses = int((trades['outcome'] == 'SL').sum())
|
| 296 |
+
timeouts = int((trades['outcome'] == 'TIMEOUT').sum())
|
| 297 |
+
gross_profit = float(returns[returns > 0].sum())
|
| 298 |
+
gross_loss = float(-returns[returns < 0].sum())
|
| 299 |
+
profit_factor = gross_profit / gross_loss if gross_loss > 0 else (float('inf') if gross_profit > 0 else None)
|
| 300 |
+
equity = np.cumsum(returns)
|
| 301 |
+
peaks = np.maximum.accumulate(np.r_[0.0, equity])[1:]
|
| 302 |
+
drawdown = peaks - equity
|
| 303 |
+
return {
|
| 304 |
+
'trades': int(len(trades)), 'wins': wins, 'losses': losses, 'timeouts': timeouts,
|
| 305 |
+
'win_rate_pct': round(100.0 * wins / len(trades), 2),
|
| 306 |
+
'profit_factor': round(profit_factor, 6) if profit_factor is not None and np.isfinite(profit_factor) else profit_factor,
|
| 307 |
+
'expectancy_r': round(float(returns.mean()), 6),
|
| 308 |
+
'max_drawdown_r': round(float(drawdown.max()) if len(drawdown) else 0.0, 6),
|
| 309 |
+
'average_r': round(float(returns.mean()), 6),
|
| 310 |
+
}
|
setup_windows.bat
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
setlocal
|
| 3 |
+
cd /d "%~dp0"
|
| 4 |
+
py -3.11 --version >nul 2>&1
|
| 5 |
+
if errorlevel 1 (
|
| 6 |
+
echo Install Python 3.11 from python.org, including the Python launcher, then rerun this file.
|
| 7 |
+
pause
|
| 8 |
+
exit /b 1
|
| 9 |
+
)
|
| 10 |
+
if not exist ".venv\Scripts\python.exe" py -3.11 -m venv .venv
|
| 11 |
+
if errorlevel 1 exit /b 1
|
| 12 |
+
".venv\Scripts\python.exe" -m pip install --upgrade pip
|
| 13 |
+
if errorlevel 1 exit /b 1
|
| 14 |
+
".venv\Scripts\python.exe" -m pip install -r requirements-core.txt
|
| 15 |
+
if errorlevel 1 (
|
| 16 |
+
pause
|
| 17 |
+
exit /b 1
|
| 18 |
+
)
|
| 19 |
+
echo Core setup complete. Use start_windows.bat. For Moirai and TimesFM, also run install_foundation_windows.bat.
|
| 20 |
+
pause
|
start_windows.bat
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
setlocal
|
| 3 |
+
cd /d "%~dp0"
|
| 4 |
+
if not exist ".venv\Scripts\python.exe" (
|
| 5 |
+
echo Run setup_windows.bat first.
|
| 6 |
+
pause
|
| 7 |
+
exit /b 1
|
| 8 |
+
)
|
| 9 |
+
".venv\Scripts\python.exe" app.py
|
| 10 |
+
pause
|
tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
pass
|
tests/test_audit_regressions.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Analytical contract fixtures, not production/provider verification.
|
| 2 |
+
|
| 3 |
+
Deliberately controlled prices/forecasts make execution arithmetic independently
|
| 4 |
+
checkable. No provider or real-model runtime is replaced by these fixtures.
|
| 5 |
+
"""
|
| 6 |
+
import numpy as np
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import pytest
|
| 9 |
+
|
| 10 |
+
from backtest.backtester import backtest_model
|
| 11 |
+
from maythos import BollingerEngine, Candle, RSIEngine, SupportResistanceEngine
|
| 12 |
+
from risk_management import (
|
| 13 |
+
CostModel, TradeLevels, average_true_range, simulate_trade, summarize_trades,
|
| 14 |
+
)
|
| 15 |
+
from tests.test_risk_management import _history
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@pytest.mark.parametrize('sign', [1, -1])
|
| 19 |
+
def test_forecast_direction_reaches_trade_execution(sign):
|
| 20 |
+
class AnalyticalForecast:
|
| 21 |
+
def predict(self, history, horizon=1, features=None):
|
| 22 |
+
return [float(history.iloc[-1]) + sign * 10] * horizon
|
| 23 |
+
|
| 24 |
+
rows = backtest_model(_history(80), AnalyticalForecast(), window=40,
|
| 25 |
+
horizon=2, step=10, timeframe='5m')
|
| 26 |
+
assert rows['trade_taken'].all()
|
| 27 |
+
assert rows['net_r'].notna().all()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def levels(direction='BUY'):
|
| 31 |
+
return TradeLevels(direction, 100, 90 if direction == 'BUY' else 110,
|
| 32 |
+
120 if direction == 'BUY' else 80, 10, 20, 2,
|
| 33 |
+
2, 90, 120, 0.01, 2, 'analytical fixture')
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_round_trip_fee_is_not_charged_twice():
|
| 37 |
+
path = pd.DataFrame({'Open': [100], 'High': [101], 'Low': [99], 'Close': [100]},
|
| 38 |
+
index=pd.date_range('2026-01-01', periods=1, tz='UTC'))
|
| 39 |
+
result = simulate_trade(levels(), path, CostModel(0, 10, 0))
|
| 40 |
+
# A 10 bps ROUND TRIP fee on a 100-unit notional is 0.1, or 0.01R.
|
| 41 |
+
assert result['net_r'] == pytest.approx(-0.01)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_atr_uses_arithmetic_wilder_seed():
|
| 45 |
+
frame = pd.DataFrame({'High': [101, 103, 105, 109],
|
| 46 |
+
'Low': [99, 97, 95, 91], 'Close': [100] * 4})
|
| 47 |
+
# TR = 2,6,10,18. Seed ATR(3)=6, then (2*6+18)/3=10.
|
| 48 |
+
assert average_true_range(frame, 3) == pytest.approx(10)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@pytest.mark.parametrize('direction,opening', [('BUY', 85), ('SELL', 115)])
|
| 52 |
+
def test_stop_gap_exits_at_available_open(direction, opening):
|
| 53 |
+
path = pd.DataFrame({'Open': [opening], 'High': [opening+1],
|
| 54 |
+
'Low': [opening-1], 'Close': [opening]},
|
| 55 |
+
index=pd.date_range('2026-01-01', periods=1, tz='UTC'))
|
| 56 |
+
result = simulate_trade(levels(direction), path, CostModel(0, 0, 0))
|
| 57 |
+
assert result['outcome'] == 'SL'
|
| 58 |
+
assert result['exit_price'] == opening
|
| 59 |
+
assert result['net_r'] == pytest.approx(-1.5)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_win_loss_counts_reconcile_with_net_profit():
|
| 63 |
+
frame = pd.DataFrame({'trade_taken': [True]*3, 'net_r': [-0.1, 0.3, -1.0],
|
| 64 |
+
'outcome': ['TP', 'TIMEOUT', 'SL']})
|
| 65 |
+
summary = summarize_trades(frame)
|
| 66 |
+
assert summary['wins'] == 1
|
| 67 |
+
assert summary['losses'] == 1
|
| 68 |
+
assert summary['win_rate_pct'] == pytest.approx(33.33)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_invalid_intermediate_forecast_is_not_scored():
|
| 72 |
+
class InvalidPath:
|
| 73 |
+
def predict(self, history, horizon=1, features=None):
|
| 74 |
+
return [np.nan, float(history.iloc[-1]) + 10]
|
| 75 |
+
|
| 76 |
+
rows = backtest_model(_history(80), InvalidPath(), window=40, horizon=2, step=10)
|
| 77 |
+
assert rows['correct'].isna().all()
|
| 78 |
+
assert rows['error'].notna().all()
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_flat_maythos_rsi_is_neutral():
|
| 82 |
+
engine = RSIEngine()
|
| 83 |
+
for i in range(30):
|
| 84 |
+
result = engine.update(Candle(1_700_000_000+i*60, 100, 100, 100, 100), {})
|
| 85 |
+
assert result['rsi'] == 50
|
| 86 |
+
assert not result['rsi_ob_flag']
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_bollinger_upper_touch_is_bearish_confirmation():
|
| 90 |
+
engine = BollingerEngine()
|
| 91 |
+
for i in range(20):
|
| 92 |
+
close = 100 if i < 19 else 110
|
| 93 |
+
result = engine.update(Candle(1_700_000_000+i*60, close, close+1, close-1, close))
|
| 94 |
+
assert result['upper_touch']
|
| 95 |
+
assert result['bb_confirmation'] < 0
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def test_resistance_confirmation_preserves_bearish_sign():
|
| 99 |
+
engine = SupportResistanceEngine()
|
| 100 |
+
engine._levels = {110.0: {'strength': 0.8, 'type': 'resistance', 'age': 0}}
|
| 101 |
+
result = engine.update(Candle(1_700_000_000, 109.5, 110, 109, 109.5), {})
|
| 102 |
+
assert result['at_resistance']
|
| 103 |
+
assert result['sr_confirmation'] < 0
|
tests/test_backtester_alignment.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=pd.Timestamp(start).ceil(freq), 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
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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_integration.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import ast
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import unittest
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pandas as pd
|
| 7 |
+
|
| 8 |
+
from maythos_integration import backtest_maythos, run_maythos_history
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _synthetic_ohlcv(count=120):
|
| 12 |
+
index = pd.date_range('2026-01-01', periods=count, freq='5min', tz='UTC')
|
| 13 |
+
close = 100.0 + np.arange(count, dtype=float) * 0.05
|
| 14 |
+
opening = np.concatenate(([close[0]], close[:-1]))
|
| 15 |
+
return pd.DataFrame(
|
| 16 |
+
{
|
| 17 |
+
'Open': opening,
|
| 18 |
+
'High': close + 0.1,
|
| 19 |
+
'Low': opening - 0.1,
|
| 20 |
+
'Close': close,
|
| 21 |
+
'Volume': np.full(count, 100.0),
|
| 22 |
+
},
|
| 23 |
+
index=index,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class TestMaythosIntegration(unittest.TestCase):
|
| 28 |
+
def test_every_closed_candle_produces_a_valid_maythos_output(self):
|
| 29 |
+
history = _synthetic_ohlcv()
|
| 30 |
+
rows = run_maythos_history(history)
|
| 31 |
+
|
| 32 |
+
self.assertEqual(len(rows), len(history))
|
| 33 |
+
self.assertEqual(rows.iloc[0]['blocked'], True)
|
| 34 |
+
self.assertIn(rows.iloc[-1]['direction'], ('BUY', 'SELL'))
|
| 35 |
+
self.assertTrue(rows.iloc[-1]['confidence'] >= 0.0)
|
| 36 |
+
self.assertIn(rows.iloc[-1]['execution_suitability'], ('blocked', 'weak', 'moderate', 'strong'))
|
| 37 |
+
|
| 38 |
+
def test_backtest_uses_signal_before_the_target_candle(self):
|
| 39 |
+
history = _synthetic_ohlcv()
|
| 40 |
+
signal_rows = run_maythos_history(history)
|
| 41 |
+
results, summary = backtest_maythos(history, window=60, horizon=1, step=5)
|
| 42 |
+
|
| 43 |
+
self.assertEqual(results.iloc[0]['current_close'], history['Close'].iloc[59])
|
| 44 |
+
self.assertEqual(results.iloc[0]['actual_close'], history['Close'].iloc[60])
|
| 45 |
+
self.assertEqual(results.iloc[0]['confidence'], signal_rows.iloc[59]['confidence'])
|
| 46 |
+
self.assertEqual(summary['engine'], 'MAYTHOS')
|
| 47 |
+
self.assertEqual(summary['evaluations'], len(results))
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class TestDashboardLayout(unittest.TestCase):
|
| 51 |
+
def test_dashboard_has_two_required_tabs_and_market_model_selectors(self):
|
| 52 |
+
source = Path(__file__).parents[1].joinpath('app.py').read_text(encoding='utf-8')
|
| 53 |
+
tree = ast.parse(source)
|
| 54 |
+
tab_calls = [
|
| 55 |
+
node for node in ast.walk(tree)
|
| 56 |
+
if isinstance(node, ast.Call)
|
| 57 |
+
and isinstance(node.func, ast.Attribute)
|
| 58 |
+
and node.func.attr == 'Tab'
|
| 59 |
+
]
|
| 60 |
+
self.assertEqual(len(tab_calls), 2)
|
| 61 |
+
constructor_names = {
|
| 62 |
+
node.func.attr
|
| 63 |
+
for node in ast.walk(tree)
|
| 64 |
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
| 65 |
+
}
|
| 66 |
+
self.assertTrue({'Dropdown', 'Checkbox', 'CheckboxGroup', 'Radio'}.issubset(constructor_names))
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
if __name__ == '__main__':
|
| 70 |
+
unittest.main()
|
tests/test_models_with_features.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
df = _synthetic_ohlcv(60, seed=99)
|
| 132 |
+
close = 40000 + df['Close'].reset_index(drop=True)
|
| 133 |
+
m = TimesFMModel(max_context=512)
|
| 134 |
+
out = m.predict(close, horizon=2)
|
| 135 |
+
self.assertEqual(len(out), 2)
|
| 136 |
+
self.assertTrue(all(np.isfinite(out)), f'TimesFM NaN on short window: {out}')
|
| 137 |
+
|
| 138 |
+
def test_repeated_window_lengths_stay_finite(self):
|
| 139 |
+
from models.timesfm_model import TimesFMModel
|
| 140 |
+
df = _synthetic_ohlcv(120, seed=7)
|
| 141 |
+
close = 40000 + df['Close'].reset_index(drop=True)
|
| 142 |
+
m = TimesFMModel(max_context=512)
|
| 143 |
+
for w in (40, 100, 40):
|
| 144 |
+
out = m.predict(close.iloc[-w:], horizon=1)
|
| 145 |
+
self.assertEqual(len(out), 1)
|
| 146 |
+
self.assertTrue(np.isfinite(out[0]), f'TimesFM NaN at w={w}: {out[0]}')
|
| 147 |
+
|
| 148 |
+
class TestInputContractGuards(unittest.TestCase):
|
| 149 |
+
|
| 150 |
+
def test_nan_history_raises_for_all_models(self):
|
| 151 |
+
from models.registry import fresh_model
|
| 152 |
+
rng = np.random.default_rng(3)
|
| 153 |
+
good = pd.Series(100 + np.cumsum(rng.normal(0, 0.5, 200)))
|
| 154 |
+
for name in ('ARIMA', 'Auto-ARIMA', 'ARIMA-GARCH', 'Moirai'):
|
| 155 |
+
m = fresh_model(name)
|
| 156 |
+
h = good.copy()
|
| 157 |
+
h.iloc[50] = np.nan
|
| 158 |
+
with self.assertRaises(ValueError, msg=name):
|
| 159 |
+
m.predict(h, horizon=1)
|
| 160 |
+
m = fresh_model('TimesFM')
|
| 161 |
+
h = good.copy()
|
| 162 |
+
h.iloc[50] = np.nan
|
| 163 |
+
with self.assertRaises(ValueError):
|
| 164 |
+
m.predict(h, horizon=1)
|
| 165 |
+
|
| 166 |
+
def test_horizon_below_one_raises_for_all_models(self):
|
| 167 |
+
from models.registry import fresh_model
|
| 168 |
+
rng = np.random.default_rng(3)
|
| 169 |
+
good = pd.Series(100 + np.cumsum(rng.normal(0, 0.5, 200)))
|
| 170 |
+
for name in ('ARIMA', 'Auto-ARIMA', 'ARIMA-GARCH', 'Moirai', 'TimesFM'):
|
| 171 |
+
m = fresh_model(name)
|
| 172 |
+
for bad in (0, -1):
|
| 173 |
+
with self.assertRaises(ValueError, msg=f'{name} horizon={bad}'):
|
| 174 |
+
m.predict(good, horizon=bad)
|
| 175 |
+
if __name__ == '__main__':
|
| 176 |
+
unittest.main()
|
tests/test_risk_management.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
import pandas as pd
|
| 5 |
+
|
| 6 |
+
from backtest.backtester import backtest_model, summarize_backtest
|
| 7 |
+
from risk_management import CostModel, build_trade_levels, infer_tick_size, simulate_trade
|
| 8 |
+
from models.base_model import BaseForecastModel
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _history(count=60):
|
| 12 |
+
index = pd.date_range('2026-01-01', periods=count, freq='5min', tz='UTC')
|
| 13 |
+
close = np.linspace(98.0, 100.0, count)
|
| 14 |
+
close[-1] = 100.0
|
| 15 |
+
opening = close - 0.05
|
| 16 |
+
return pd.DataFrame({
|
| 17 |
+
'Open': opening,
|
| 18 |
+
'High': close + 0.8,
|
| 19 |
+
'Low': close - 0.8,
|
| 20 |
+
'Close': close,
|
| 21 |
+
'Volume': 1000.0,
|
| 22 |
+
}, index=index)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class _UpModel(BaseForecastModel):
|
| 26 |
+
name = 'UpModel'
|
| 27 |
+
|
| 28 |
+
def predict(self, history, horizon=1, features=None):
|
| 29 |
+
current = float(pd.Series(history).iloc[-1])
|
| 30 |
+
return [current + 3.0] * horizon
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class TestTradeLevels(unittest.TestCase):
|
| 34 |
+
def test_levels_use_atr_and_prior_structure_with_quote_precision(self):
|
| 35 |
+
frame = _history()
|
| 36 |
+
levels = build_trade_levels(frame, 'BUY', 'Crypto', 'BTC-USD', forecast_target=103.0)
|
| 37 |
+
self.assertIsNotNone(levels)
|
| 38 |
+
self.assertGreater(levels.atr, 0)
|
| 39 |
+
self.assertLess(levels.stop_loss, levels.entry)
|
| 40 |
+
self.assertGreater(levels.take_profit, levels.entry)
|
| 41 |
+
self.assertGreaterEqual(levels.risk_reward, 1.0)
|
| 42 |
+
self.assertEqual(round(levels.stop_loss, 2), levels.stop_loss)
|
| 43 |
+
self.assertEqual(round(levels.take_profit, 2), levels.take_profit)
|
| 44 |
+
|
| 45 |
+
def test_future_rows_do_not_change_origin_levels(self):
|
| 46 |
+
frame = _history()
|
| 47 |
+
origin = frame.iloc[:50]
|
| 48 |
+
changed_future = frame.copy()
|
| 49 |
+
changed_future.iloc[50:, changed_future.columns.get_loc('High')] = 10000.0
|
| 50 |
+
changed_future.iloc[50:, changed_future.columns.get_loc('Low')] = 1.0
|
| 51 |
+
first = build_trade_levels(origin, 'BUY', 'Crypto', 'BTC-USD', forecast_target=103.0)
|
| 52 |
+
second = build_trade_levels(changed_future.iloc[:50], 'BUY', 'Crypto', 'BTC-USD', forecast_target=103.0)
|
| 53 |
+
self.assertEqual(first, second)
|
| 54 |
+
|
| 55 |
+
def test_same_candle_sl_is_conservatively_first(self):
|
| 56 |
+
frame = _history()
|
| 57 |
+
levels = build_trade_levels(frame, 'BUY', 'Crypto', 'BTC-USD', forecast_target=103.0)
|
| 58 |
+
path = pd.DataFrame({'High': [levels.take_profit + 1], 'Low': [levels.stop_loss - 1], 'Close': [levels.take_profit]}, index=frame.index[-1:])
|
| 59 |
+
trade = simulate_trade(levels, path, CostModel(spread_bps=0, fee_bps=0, slippage_bps=0))
|
| 60 |
+
self.assertEqual(trade['outcome'], 'SL')
|
| 61 |
+
|
| 62 |
+
def test_costs_reduce_realized_r(self):
|
| 63 |
+
frame = _history()
|
| 64 |
+
levels = build_trade_levels(frame, 'BUY', 'Crypto', 'BTC-USD', forecast_target=103.0)
|
| 65 |
+
path = pd.DataFrame({'High': [levels.take_profit], 'Low': [levels.entry], 'Close': [levels.take_profit]}, index=frame.index[-1:])
|
| 66 |
+
free = simulate_trade(levels, path, CostModel(spread_bps=0, fee_bps=0, slippage_bps=0))
|
| 67 |
+
costly = simulate_trade(levels, path, CostModel(spread_bps=10, fee_bps=10, slippage_bps=10))
|
| 68 |
+
self.assertGreater(free['net_r'], costly['net_r'])
|
| 69 |
+
|
| 70 |
+
def test_forex_quote_precision_handles_pips_and_jpy(self):
|
| 71 |
+
frame = _history()
|
| 72 |
+
eur_tick, eur_digits = infer_tick_size(frame, 'Forex', 'EURUSD=X')
|
| 73 |
+
jpy_tick, jpy_digits = infer_tick_size(frame, 'Forex', 'USDJPY=X')
|
| 74 |
+
self.assertEqual((eur_tick, eur_digits), (0.00001, 5))
|
| 75 |
+
self.assertEqual((jpy_tick, jpy_digits), (0.001, 3))
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class TestBacktestTradeMetrics(unittest.TestCase):
|
| 79 |
+
def test_backtest_contains_candle_path_trade_fields_and_summary(self):
|
| 80 |
+
frame = _history(80)
|
| 81 |
+
results = backtest_model(frame, _UpModel(), window=40, horizon=2, step=10, timeframe='5m', market='Crypto', symbol='BTC-USD', costs=CostModel())
|
| 82 |
+
self.assertIn('stop_loss', results.columns)
|
| 83 |
+
self.assertIn('take_profit', results.columns)
|
| 84 |
+
self.assertIn('outcome', results.columns)
|
| 85 |
+
summary = summarize_backtest(results, 'UpModel')
|
| 86 |
+
self.assertIn('trades', summary)
|
| 87 |
+
self.assertIn('profit_factor', summary)
|
| 88 |
+
self.assertIn('max_drawdown_r', summary)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
if __name__ == '__main__':
|
| 92 |
+
unittest.main()
|
utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
pass
|
utils/helpers.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import math
|
| 2 |
+
import pandas as pd
|
| 3 |
+
MINUTES_PER_CANDLE = {'1m': 1, '2m': 2, '5m': 5, '10m': 10, '15m': 15, '30m': 30, '1h': 60, '2h': 120, '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, timeframe: str=None, now=None):
|
| 26 |
+
if df is None or len(df) == 0:
|
| 27 |
+
return (df, features_df)
|
| 28 |
+
now = pd.Timestamp(now) if now is not None else pd.Timestamp.now(tz='UTC')
|
| 29 |
+
if df.index.tz is None:
|
| 30 |
+
if now.tzinfo is not None:
|
| 31 |
+
now = now.tz_convert('UTC').tz_localize(None)
|
| 32 |
+
elif now.tzinfo is None:
|
| 33 |
+
now = now.tz_localize('UTC')
|
| 34 |
+
step = pd.Timedelta(minutes=MINUTES_PER_CANDLE[timeframe]) if timeframe else infer_step(df.index)
|
| 35 |
+
mask = df.index + step <= now
|
| 36 |
+
df = df.loc[mask]
|
| 37 |
+
if features_df is not None:
|
| 38 |
+
features_df = features_df.loc[mask].reset_index(drop=True)
|
| 39 |
+
return (df, features_df)
|
| 40 |
+
|
| 41 |
+
def price_direction(predicted: float, current: float) -> str:
|
| 42 |
+
# Treat optimizer-scale numerical noise as flat. Without this guard a
|
| 43 |
+
# persistence forecast can become DOWN solely because exp/log round-trip
|
| 44 |
+
# error is a few ulps, which makes live consensus and accuracy tables look
|
| 45 |
+
# more certain than the underlying prices justify.
|
| 46 |
+
if pd.isna(predicted) or pd.isna(current):
|
| 47 |
+
raise ValueError('price_direction needs finite numeric prices.')
|
| 48 |
+
if math.isclose(float(predicted), float(current), rel_tol=1e-9, abs_tol=1e-12):
|
| 49 |
+
return 'flat'
|
| 50 |
+
if predicted > current:
|
| 51 |
+
return 'up'
|
| 52 |
+
if predicted < current:
|
| 53 |
+
return 'down'
|
| 54 |
+
return 'flat'
|