3VVM commited on
Commit
dc148a0
·
verified ·
1 Parent(s): 9fd1364

Deploy MYTHOS application

Browse files
README.md ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.11"
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
+ - Forecast trades use a strict cost-aware execution gate: the predicted target
48
+ must be the actual target used for the selected horizon, clear at least 0.20
49
+ ATR of noise, clear a 1.20 R:R, and exceed 1.25× the configured round-trip
50
+ cost. A forecast that does not clear those hurdles is reported as a valid
51
+ forecast but is not traded. This intentionally produces fewer trades and
52
+ avoids converting tiny mean forecasts into distant swing-level timeout losses.
53
+ - MAYTHOS execution is restricted to fully confirmed `strong` states; weak and
54
+ moderate heuristic states remain visible for research but are not treated as
55
+ profitable trade recommendations.
56
+ - Every result can be downloaded as a ZIP containing candles, results, config,
57
+ package versions, SHA-256 snapshot hash, and evaluation policy.
58
+
59
+ ## Windows setup
60
+
61
+ 1. Install **Python 3.11** from python.org and enable the Python launcher.
62
+ 2. Extract `mythos.zip` to a normal folder.
63
+ 3. Run `setup_windows.bat`.
64
+ 4. Run `start_windows.bat`; open the local Gradio address shown in the console.
65
+ 5. Optional: run `install_foundation_windows.bat` for real Moirai and TimesFM
66
+ inference. Their Hugging Face weights download on first use and need internet,
67
+ disk space, and adequate RAM. If these packages/weights are unavailable, the
68
+ dashboard reports that model as failed rather than showing a substitute.
69
+
70
+ The core install is sufficient for ARIMA, Auto-ARIMA, and ARIMA-GARCH.
71
+
72
+ ## Provider notes
73
+
74
+ Provider retention, plan limits, exchange maintenance, market closures, and
75
+ network access affect the available history. The dashboard displays the exact
76
+ provider, UTC range, candle count, freshness, and gap count. Forex does not have
77
+ a single consolidated traded-volume feed; zero/unavailable volume is explicitly
78
+ identified and MAYTHOS uses its documented price-based proxy.
79
+
80
+ Yahoo's free intraday history can be limited. Binance is crypto-only. Twelve Data
81
+ requires an API key in the Windows environment before launching:
82
+
83
+ ```text
84
+ set TWELVE_DATA_API_KEY=your_key_here
85
+ start_windows.bat
86
+ ```
87
+
88
+ Do not put API keys into source files or ZIP archives.
89
+
90
+ ## Command-line logger
91
+
92
+ The logger uses the same data/model pipeline and verifies predictions only after
93
+ the target closed candle arrives:
94
+
95
+ ```text
96
+ python live_runner.py --provider "Yahoo Finance" --market Crypto --symbol BTC-USD --timeframe 5m --models ARIMA-GARCH --poll-seconds 60
97
+ python live_runner.py --provider "Binance Spot" --market Crypto --symbol BTCUSDT --timeframe 2m --models ARIMA TimesFM --poll-seconds 120
98
+ ```
99
+
100
+ ## Verification
101
+
102
+ Core tests use deterministic synthetic fixtures for alignment and contract
103
+ checks. Foundation models are not silently tested with fake forecasts: their
104
+ optional tests require downloading the real weights and can take a long time.
105
+ Run the installed suite with:
106
+
107
+ ```text
108
+ python -m pytest -q
109
+ ```
110
+
111
+ Backtest accuracy is a historical measurement of this configuration and sample;
112
+ it is not a promise of future accuracy, profitability, or execution quality.
STRATEGY_NOTES.md ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MYTHOS strategy audit and execution policy
2
+
3
+ ## What the supplied result showed
4
+
5
+ The supplied EURUSD 5-minute report had directional accuracy around 37–47%,
6
+ but the trade layer was materially worse than the forecast layer. Forecasts
7
+ were usually only a few fractions of a pip away from the current close. The
8
+ old level builder nevertheless selected a farther prior-swing target whenever
9
+ it was farther than the model forecast. With a one-candle horizon, those
10
+ orders could not realistically reach that target and became timeout trades.
11
+ MAYTHOS also allowed weak and moderate heuristic states into the actionable
12
+ trade denominator; those 16 trades had zero wins in the supplied sample.
13
+
14
+ ## Changes in this build
15
+
16
+ 1. A forecast target is now used as the target for a forecast trade. It is not
17
+ silently extended to a more distant swing level.
18
+ 2. Forecast execution requires a positive-risk target, at least `1.20R`, at
19
+ least `0.20 ATR` of predicted reward, and at least `1.25x` the configured
20
+ round-trip spread/fee/slippage cost.
21
+ 3. Forecast trades use an ATR stop without an oversized structural stop. The
22
+ prior structure remains available for causal analysis and for trades that
23
+ do not have a model target.
24
+ 4. MAYTHOS backtest execution is limited to `strong`, unblocked states with
25
+ confidence at or above 0.69. Weak and moderate states remain visible but
26
+ cannot be counted as executable trades.
27
+ 5. Existing candle-path rules remain conservative: if both stop and target
28
+ are touched in one OHLC candle, stop loss is resolved first.
29
+
30
+ ## Recommended validation workflow
31
+
32
+ - Use at least 90 days of data where the provider permits it, and evaluate
33
+ several symbols and time periods rather than relying on one 100-origin run.
34
+ - For 5-minute Forex data, use a horizon of several candles and realistic
35
+ broker costs. A one-candle forecast whose expected move is below costs is a
36
+ no-trade condition, not a profitable opportunity.
37
+ - Require positive expectancy after costs and a stable profit factor in
38
+ separate walk-forward periods before considering paper execution.
39
+ - Treat an empty trade set as a diagnostic result: lower-cost execution,
40
+ longer horizon, deeper history, or a better forecast is needed. Do not
41
+ disable the execution gate merely to increase trade count.
42
+
43
+ Historical backtest results cannot guarantee future profitability or execution
44
+ quality. This project remains an educational/research tool.
app.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 server_port():
25
+ raw = os.getenv('PORT', os.getenv('MYTHOS_PORT', '7860'))
26
+ try:
27
+ port = int(raw)
28
+ except (TypeError, ValueError):
29
+ raise RuntimeError('PORT/MYTHOS_PORT must be an integer between 1 and 65535.') from None
30
+ if not 1 <= port <= 65535:
31
+ raise RuntimeError('PORT/MYTHOS_PORT must be an integer between 1 and 65535.')
32
+ return port
33
+
34
+
35
+ def ui_config(provider, market, symbol, timeframe, mode, models, days, window, horizon, step, evaluations, features, spread_bps, fee_bps, slippage_bps):
36
+ return RunConfig(provider=provider, market=market, symbol=symbol, timeframe=timeframe,
37
+ mode=mode, models=tuple(models or ()), lookback_days=days, window=window,
38
+ horizon=horizon, step=step, max_evaluations=evaluations, use_features=features,
39
+ spread_bps=spread_bps, fee_bps=fee_bps, slippage_bps=slippage_bps)
40
+
41
+
42
+ def metadata_text(metadata):
43
+ freshness = '**STALE / market closed — historical analysis only; live execution blocked.**' if metadata['stale'] else '**Recent closed-candle data.**'
44
+ details = metadata['provider_details']
45
+ effective = details.get('effective_lookback_days')
46
+ coverage = f' Provider lookback used: **{effective} calendar days** (provider retention cap applies).' if effective else ''
47
+ volume_note = details.get('volume_note', '')
48
+ if metadata['market'] == 'Forex' and not volume_note:
49
+ volume_note = 'Forex volume may be unavailable/zero; MAYTHOS then uses its price-based proxy.'
50
+ return (
51
+ f"**{metadata['provider']} · {metadata['symbol']} · {metadata['timeframe']}** — {metadata['closed_candles']:,} closed candles. {freshness}\n\n"
52
+ f"UTC range: `{metadata['first_candle_open_utc']}` → `{metadata['last_candle_open_utc']}` (candle-open timestamps). "
53
+ f"Last candle closed: `{metadata['last_candle_close_utc']}`. "
54
+ f"Noncontinuous intervals: **{metadata['noncontinuous_intervals']}**; gaps are never filled."
55
+ + coverage + ('\n\n' + volume_note if volume_note else '')
56
+ )
57
+
58
+
59
+ def make_candlestick_figure(result):
60
+ frame = result['frame'].tail(200)
61
+ config = result['config']
62
+ figure = go.Figure(go.Candlestick(x=frame.index, open=frame['Open'], high=frame['High'],
63
+ low=frame['Low'], close=frame['Close'], name=config.symbol))
64
+ for forecast in result['forecasts']:
65
+ if not forecast['forecast_path']:
66
+ continue
67
+ times = [frame.index[-1]] + [frame.index[-1] + config.candle_step * (i + 1) for i in range(config.horizon)]
68
+ figure.add_trace(go.Scatter(x=times, y=[float(frame['Close'].iloc[-1])] + forecast['forecast_path'],
69
+ name=forecast['Model'], mode='lines+markers'))
70
+ figure.update_layout(title=f'{config.symbol} · {config.timeframe} · {config.provider}',
71
+ xaxis_rangeslider_visible=False, height=510,
72
+ margin=dict(l=12, r=12, t=50, b=12), template='plotly_dark',
73
+ legend=dict(orientation='h'), xaxis_title='Candle open (UTC)', yaxis_title='Price in pair quote currency')
74
+ return figure
75
+
76
+
77
+ def run_live_signal(*args):
78
+ empty = pd.DataFrame()
79
+ try:
80
+ config = ui_config(*args)
81
+ result = live_result(config)
82
+ signal = result['signal']
83
+ if 'error' in signal:
84
+ signal_rows = [('Engine status', 'FAILED'), ('Error', signal['error'])]
85
+ else:
86
+ signal_rows = [
87
+ ('Direction', signal['direction']), ('Execution suitability', signal['execution_suitability']),
88
+ ('Live actionable', str(signal['live_actionable'])),
89
+ ('Forecast coverage', f"{signal.get('forecast_models_completed', 0)}/{signal.get('forecast_models_requested', len(config.models))} models"),
90
+ ('Forecast consensus', signal.get('forecast_consensus', 'UNAVAILABLE')),
91
+ ('Forecast agrees with MAYTHOS', str(signal.get('forecast_agrees_with_signal', False))),
92
+ ('Heuristic confidence (not calibrated win probability)', f"{signal['confidence']:.2%}"),
93
+ ('Market state', signal['market_state']), ('Regime', signal['regime_label']),
94
+ ('Reason', signal['reason_summary']),
95
+ ]
96
+ if signal.get('live_actionable') and signal.get('trade_levels'):
97
+ levels = signal['trade_levels']
98
+ signal_rows.extend([
99
+ ('Entry', levels['entry']),
100
+ ('Stop Loss', levels['stop_loss']),
101
+ ('Take Profit', levels['take_profit']),
102
+ ('Risk:Reward', f"1:{levels['risk_reward']:.3f}"),
103
+ ('ATR', levels['atr']),
104
+ ('Level basis', levels['level_basis']),
105
+ ])
106
+ successful = sum(bool(item['forecast_path']) for item in result['forecasts'])
107
+ summary = (
108
+ f"### {successful}/{len(config.models)} forecast models completed\n\n"
109
+ f"Forecast horizon: **{config.horizon} candle(s)** · training window: **{config.window} candles** · "
110
+ f"features: **{'enabled' if config.use_features else 'disabled'}**. Each model is reported individually.\n\n"
111
+ + metadata_text(result['metadata'])
112
+ + 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."
113
+ )
114
+ artifact = export_run(result, 'live')
115
+ table = pd.DataFrame(result['forecasts']).drop(columns=['forecast_path'])
116
+ return make_candlestick_figure(result), summary, table, pd.DataFrame(signal_rows, columns=['MAYTHOS metric', 'Value']), artifact
117
+ except Exception as error:
118
+ return None, f'**Live signal failed:** {error}', empty, empty, None
119
+
120
+
121
+ def run_backtest_ui(*args):
122
+ empty = pd.DataFrame()
123
+ try:
124
+ config = ui_config(*args)
125
+ result = backtest_result(config)
126
+ failed = int(result['forecast_summary']['failed_predictions'].sum())
127
+ status = (
128
+ f"### Walk-forward evaluation completed — {failed} failed/skipped model evaluations\n\n"
129
+ f"History window: **{config.window}** · horizon: **{config.horizon}** · origin stride: **{config.step}** · "
130
+ f"at most **{config.max_evaluations} most-recent evaluation origins per model**. "
131
+ 'Orders are reselected using only each training window; no future target prices/features are supplied to models. '
132
+ 'Targets crossing gaps/market closures are explicitly skipped. Flat prices are classified as FLAT.\n\n'
133
+ '**Accuracy denominator:** successful forecast evaluations; failures and coverage are shown separately. '
134
+ '`persistence_mae` is the last-close naive baseline on the same valid targets. '
135
+ '**Trade execution is selective:** forecast targets must clear volatility noise, minimum 1.20R reward/risk, and 1.25× the configured round-trip costs; otherwise the forecast remains visible but no trade is opened. MAYTHOS uses only strong, unblocked signals. Candle-path resolution checks SL before TP when both touch; costs are explicit spread/fee/slippage assumptions, not provider-measured quotes.**\n\n'
136
+ + metadata_text(result['metadata'])
137
+ )
138
+ artifact = export_run(result, 'backtest')
139
+ return result['forecast_summary'], result['signal_summary'], result['forecast_details'], result['signal_details'], status, artifact
140
+ except Exception as error:
141
+ return empty, empty, empty, empty, f'**Backtest failed:** {error}', None
142
+
143
+
144
+ def default_symbols(provider, market):
145
+ if provider == 'Binance Spot':
146
+ if market != 'Crypto':
147
+ return gr.Dropdown(choices=[], value=None, allow_custom_value=True), 'Binance Spot requires Crypto. Select another provider for Forex.'
148
+ 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.'
149
+ if provider == 'Twelve Data':
150
+ pairs = [s.removesuffix('=X')[:3] + '/' + s.removesuffix('=X')[3:] for s in DEFAULT_FOREX_SYMBOLS]
151
+ 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.'
152
+ choices = DEFAULT_FOREX_SYMBOLS if market == 'Forex' else DEFAULT_CRYPTO_SYMBOLS
153
+ 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.'
154
+
155
+
156
+ def search_symbols(provider, market, query, current):
157
+ try:
158
+ symbols, note = catalog(provider, market, query)
159
+ if current and current not in symbols:
160
+ symbols = [current] + symbols
161
+ return gr.Dropdown(choices=symbols, value=current if current else (symbols[0] if symbols else None), allow_custom_value=True), note
162
+ except Exception as error:
163
+ return gr.skip(), f'**Catalog/search failed:** {error}'
164
+
165
+
166
+ def update_mode(mode, selected):
167
+ selected = selected or ['ARIMA-GARCH']
168
+ return gr.CheckboxGroup(value=selected[-1:] if mode == 'Single model' else selected)
169
+
170
+
171
+ def enforce_single(mode, selected):
172
+ if mode == 'Single model' and selected and len(selected) > 1:
173
+ return gr.CheckboxGroup(value=selected[-1:])
174
+ return gr.skip()
175
+
176
+
177
+ THEME = gr.themes.Soft(primary_hue='violet', neutral_hue='slate')
178
+
179
+ with gr.Blocks(title='MYTHOS · MAYTHOS × RINKING', theme=THEME, css=CSS) as demo:
180
+ gr.Markdown('# MYTHOS · MAYTHOS × RINKING\nReal market data. Selectable forecasting. Auditable walk-forward evaluation.\n\n' + DISCLAIMER, elem_id='brand')
181
+ with gr.Group(elem_classes=['settings-panel']):
182
+ gr.Markdown('### Market & model configuration\nThese settings apply to both Live Signal and Backtesting.')
183
+ with gr.Row():
184
+ provider = gr.Dropdown(PROVIDERS, value='Yahoo Finance', label='Data provider')
185
+ market = gr.Radio(['Forex', 'Crypto'], value='Crypto', label='Market')
186
+ symbol = gr.Dropdown(DEFAULT_CRYPTO_SYMBOLS, value='BTC-USD', allow_custom_value=True, filterable=True, label='Symbol — choose or type')
187
+ timeframe = gr.Dropdown(TIMEFRAMES, value='5m', label='Timeframe')
188
+ with gr.Row():
189
+ query = gr.Textbox(label='Symbol search / catalog filter', placeholder='EUR, BTC, USDT…')
190
+ search_button = gr.Button('Load / search symbol catalog')
191
+ catalog_note = gr.Markdown('Yahoo common pairs shown. Search or type any provider-supported symbol; intraday coverage is checked on fetch.')
192
+ with gr.Row():
193
+ mode = gr.Radio(['Single model', 'Parallel models'], value='Single model', label='Model execution')
194
+ models = gr.CheckboxGroup(MODEL_NAMES, value=['ARIMA-GARCH'], label='Forecast models — select freely')
195
+ with gr.Accordion('History, forecast & backtest settings', open=True):
196
+ with gr.Row():
197
+ days = gr.Number(value=30, minimum=1, maximum=730, precision=0, label='Lookback (calendar days)')
198
+ window = gr.Number(value=200, minimum=32, maximum=2048, precision=0, label='Training window (candles)')
199
+ horizon = gr.Number(value=1, minimum=1, maximum=64, precision=0, label='Forecast horizon (candles)')
200
+ features = gr.Checkbox(value=False, label='Use 14 causal forecast features')
201
+ with gr.Row():
202
+ step = gr.Number(value=30, minimum=1, maximum=10000, precision=0, label='Backtest origin stride (candles)')
203
+ evaluations = gr.Number(value=30, minimum=1, maximum=1000, precision=0, label='Maximum recent evaluations / model')
204
+ with gr.Row():
205
+ spread_bps = gr.Number(value=1.0, minimum=0, maximum=1000, label='Assumed spread (bps)')
206
+ fee_bps = gr.Number(value=1.0, minimum=0, maximum=1000, label='Assumed fees (bps, round trip)')
207
+ slippage_bps = gr.Number(value=1.0, minimum=0, maximum=1000, label='Assumed slippage (bps)')
208
+ 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.')
209
+ with gr.Accordion('Model dependency status', open=False):
210
+ gr.Dataframe(value=model_readiness(), interactive=False)
211
+ gr.Markdown('“Packages detected” is not an inference or accuracy certification. See README for installing optional foundation models.')
212
+
213
+ inputs = [provider, market, symbol, timeframe, mode, models, days, window, horizon, step, evaluations, features, spread_bps, fee_bps, slippage_bps]
214
+ with gr.Tab('Live Signal'):
215
+ live_button = gr.Button('Refresh live signal', variant='primary')
216
+ live_summary = gr.Markdown('Choose your configuration and refresh to fetch real closed candles.')
217
+ live_plot = gr.Plot(label='Closed candles & model forecasts')
218
+ live_table = gr.Dataframe(label='Per-model forecast results and errors', interactive=False)
219
+ signal_table = gr.Dataframe(label='MAYTHOS signal engine', interactive=False)
220
+ live_file = gr.File(label='Download live data, results & reproducibility manifest', interactive=False)
221
+ with gr.Tab('Backtesting'):
222
+ backtest_button = gr.Button('Run walk-forward backtest', variant='primary')
223
+ backtest_status = gr.Markdown('Use the same market/model configuration above. Download the exact data snapshot with every completed run.')
224
+ forecast_summary = gr.Dataframe(label='Forecast metrics — each model separately', interactive=False)
225
+ maythos_summary = gr.Dataframe(label='MAYTHOS metrics — unblocked signals only', interactive=False)
226
+ forecast_details = gr.Dataframe(label='Forecast per-evaluation details (including failures)', interactive=False)
227
+ maythos_details = gr.Dataframe(label='MAYTHOS per-evaluation details', interactive=False)
228
+ backtest_file = gr.File(label='Download backtest data, results & reproducibility manifest', interactive=False)
229
+
230
+ provider.change(default_symbols, inputs=[provider, market], outputs=[symbol, catalog_note], queue=False)
231
+ market.change(default_symbols, inputs=[provider, market], outputs=[symbol, catalog_note], queue=False)
232
+ search_button.click(search_symbols, inputs=[provider, market, query, symbol], outputs=[symbol, catalog_note], api_name='symbol_catalog')
233
+ mode.change(update_mode, inputs=[mode, models], outputs=models, queue=False)
234
+ models.change(enforce_single, inputs=[mode, models], outputs=models, queue=False)
235
+ 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')
236
+ 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')
237
+
238
+
239
+ if __name__ == '__main__':
240
+ demo.queue(max_size=16).launch(
241
+ server_name=os.getenv('GRADIO_SERVER_NAME', '0.0.0.0'),
242
+ server_port=server_port(),
243
+ inbrowser=False,
244
+ share=False,
245
+ )
backtest/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ pass
backtest/backtester.py ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(isinstance(value, (bool, np.bool_)) or 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 max_evaluations is not None and (isinstance(max_evaluations, (bool, np.bool_)) or not isinstance(max_evaluations, (int, np.integer)) or max_evaluations < 1):
32
+ raise ValueError('max_evaluations must be a positive integer.')
33
+ if features_df is not None and len(features_df) != len(df):
34
+ raise ValueError('Features must align with every OHLCV row.')
35
+ if df.empty or len(df) < window + horizon or not df.index.is_monotonic_increasing or df.index.duplicated().any():
36
+ raise ValueError('Backtest candles must be non-empty, chronologically ordered, and unique.')
37
+ required = {'Open', 'High', 'Low', 'Close'}
38
+ if not required.issubset(df.columns):
39
+ raise ValueError(f'Backtest candles are missing columns: {sorted(required - set(df.columns))}.')
40
+ ohlc = df[['Open', 'High', 'Low', 'Close']].apply(pd.to_numeric, errors='coerce')
41
+ if (not np.isfinite(ohlc.to_numpy(dtype=float)).all() or (ohlc <= 0).any().any() or
42
+ (ohlc['High'] < ohlc[['Open', 'Close', 'Low']].max(axis=1)).any() or
43
+ (ohlc['Low'] > ohlc[['Open', 'Close', 'High']].min(axis=1)).any()):
44
+ raise ValueError('Backtest candles contain invalid/non-finite OHLC observations.')
45
+ df = df.copy()
46
+ df[['Open', 'High', 'Low', 'Close']] = ohlc.astype(float)
47
+ if not np.isfinite(pd.to_numeric(df['Close'], errors='coerce').to_numpy(dtype=float)).all():
48
+ raise ValueError('Backtest Close prices must be finite real observations.')
49
+ close = df["Close"]
50
+ costs = costs or CostModel()
51
+ timestamps = [
52
+ ts.isoformat() if hasattr(ts, "isoformat") else ts for ts in df.index
53
+ ]
54
+ last_i = len(close) - horizon
55
+ rows = []
56
+ positions = list(range(window, last_i + 1, step))
57
+ if max_evaluations is not None:
58
+ if max_evaluations < 1:
59
+ raise ValueError('max_evaluations must be positive.')
60
+ positions = positions[-max_evaluations:]
61
+ for i in positions:
62
+ history = close.iloc[i - window : i]
63
+ feature_window = (
64
+ slice_features(features_df, i - window, i)
65
+ if features_df is not None
66
+ else None
67
+ )
68
+ current_close = float(close.iloc[i - 1])
69
+ target_idx = i + horizon - 1
70
+ actual_next_close = float(close.iloc[target_idx])
71
+ try:
72
+ continuous = True
73
+ if timeframe:
74
+ from utils.helpers import MINUTES_PER_CANDLE
75
+ expected_step = pd.Timedelta(minutes=MINUTES_PER_CANDLE[timeframe])
76
+ target_times = df.index[i - 1:target_idx + 1]
77
+ continuous = bool((target_times.to_series().diff().dropna() == expected_step).all())
78
+ if not continuous:
79
+ raise ValueError('Target crosses a missing candle or market closure; not a continuous timeframe horizon.')
80
+ forecast = model.predict(history, horizon=horizon, features=feature_window)
81
+ if len(forecast) != horizon:
82
+ raise ValueError('Model returned an incorrect forecast horizon.')
83
+ forecast_values = np.asarray(forecast, dtype=float)
84
+ if not np.isfinite(forecast_values).all() or (forecast_values <= 0).any():
85
+ raise ValueError(
86
+ f"forecast contains a non-finite or non-positive value: {forecast!r}"
87
+ )
88
+ predicted_next_close = float(forecast_values[-1])
89
+ except Exception as e:
90
+ rows.append(
91
+ {
92
+ "datetime": timestamps[target_idx],
93
+ "current_close": current_close,
94
+ "predicted_close": np.nan,
95
+ "actual_close": actual_next_close,
96
+ "predicted_direction": None,
97
+ "actual_direction": None,
98
+ "correct": None,
99
+ "continuous_target": continuous,
100
+ "trade_taken": False,
101
+ "entry": np.nan, "stop_loss": np.nan, "take_profit": np.nan,
102
+ "risk_reward": np.nan, "atr": np.nan, "outcome": 'FORECAST_FAILED',
103
+ "exit_price": np.nan, "net_r": np.nan, "bars_held": 0,
104
+ "error": str(e),
105
+ }
106
+ )
107
+ continue
108
+ predicted_direction = price_direction(predicted_next_close, current_close)
109
+ actual_direction = price_direction(actual_next_close, current_close)
110
+ correct = predicted_direction == actual_direction
111
+ trade_direction = {'up': 'BUY', 'down': 'SELL'}.get(predicted_direction)
112
+ try:
113
+ levels = build_trade_levels(
114
+ df.iloc[:i], trade_direction, market, symbol,
115
+ forecast_target=predicted_next_close,
116
+ # Only trade a forecast that clears volatility noise and has a
117
+ # target the model actually predicted. Previously a tiny forecast
118
+ # was replaced by a distant swing target, creating timeout losses
119
+ # unrelated to the forecast signal.
120
+ forecast_only=True,
121
+ minimum_edge_atr=0.20,
122
+ minimum_risk_reward=1.20,
123
+ use_structure_stop=False,
124
+ minimum_edge_bps=(
125
+ costs.spread_bps + costs.fee_bps + 2.0 * costs.slippage_bps
126
+ ) * 1.25,
127
+ )
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, df.iloc[i:target_idx + 1], costs)}
131
+ trade_error = None
132
+ except Exception as error:
133
+ levels = None
134
+ trade = {'outcome': 'TRADE_FAILED', 'exit_price': None, 'net_r': None, 'bars_held': 0}
135
+ trade_error = f'trade execution failed: {error}'
136
+ rows.append(
137
+ {
138
+ "datetime": timestamps[target_idx],
139
+ "current_close": current_close,
140
+ "predicted_close": predicted_next_close,
141
+ "actual_close": actual_next_close,
142
+ "predicted_direction": predicted_direction,
143
+ "actual_direction": actual_direction,
144
+ "correct": correct,
145
+ "continuous_target": continuous,
146
+ "trade_taken": levels is not None,
147
+ "entry": levels.entry if levels else np.nan,
148
+ "stop_loss": levels.stop_loss if levels else np.nan,
149
+ "take_profit": levels.take_profit if levels else np.nan,
150
+ "risk_reward": levels.risk_reward if levels else np.nan,
151
+ "atr": levels.atr if levels else np.nan,
152
+ "swing_support": levels.swing_support if levels else np.nan,
153
+ "swing_resistance": levels.swing_resistance if levels else np.nan,
154
+ "outcome": trade.get('outcome'),
155
+ "exit_price": trade.get('exit_price'),
156
+ "net_r": trade.get('net_r'),
157
+ "bars_held": trade.get('bars_held', 0),
158
+ "error": trade_error,
159
+ }
160
+ )
161
+ return pd.DataFrame(rows)
162
+
163
+
164
+ def summarize_backtest(results: pd.DataFrame, model_name: str) -> dict:
165
+ if results.empty:
166
+ return {
167
+ "model": model_name,
168
+ "total_predictions": 0,
169
+ "correct": 0,
170
+ "incorrect": 0,
171
+ "accuracy_pct": None,
172
+ "mae": None,
173
+ "rmse": None,
174
+ "failed_predictions": 0,
175
+ "gap_skipped": 0,
176
+ "nonflat_accuracy_pct": None,
177
+ "persistence_accuracy_pct": None,
178
+ **summarize_trades(results),
179
+ }
180
+ valid = results.dropna(subset=["correct"])
181
+ total = len(valid)
182
+ correct = int(valid["correct"].sum()) if total else 0
183
+ incorrect = total - correct
184
+ accuracy = round(100 * correct / total, 2) if total else None
185
+ price_valid = results.dropna(subset=["predicted_close", "actual_close"])
186
+ if len(price_valid):
187
+ errors = price_valid["predicted_close"] - price_valid["actual_close"]
188
+ mae = float(np.mean(np.abs(errors)))
189
+ rmse = float(np.sqrt(np.mean(errors**2)))
190
+ else:
191
+ mae = rmse = None
192
+ failed = int(results["error"].notna().sum())
193
+ nonflat = valid[valid['actual_direction'] != 'flat']
194
+ nonflat_accuracy = round(100 * float(nonflat['correct'].mean()), 2) if len(nonflat) else None
195
+ # A last-close persistence forecast predicts no change. Its direction is
196
+ # therefore FLAT, rather than the direction that the actual target took.
197
+ persistence_direction = np.full(len(valid), 'flat', dtype=object)
198
+ persistence_accuracy = round(100 * float(np.mean(persistence_direction == valid['actual_direction'].to_numpy())), 2) if len(valid) else None
199
+ return {
200
+ "model": model_name,
201
+ "total_predictions": total,
202
+ "correct": correct,
203
+ "incorrect": incorrect,
204
+ "accuracy_pct": accuracy,
205
+ "mae": _round_sig(mae) if mae is not None else None,
206
+ "rmse": _round_sig(rmse) if rmse is not None else None,
207
+ "failed_predictions": failed,
208
+ "gap_skipped": int(results['error'].fillna('').str.contains('gap|closure', case=False, regex=True).sum()),
209
+ "nonflat_accuracy_pct": nonflat_accuracy,
210
+ "persistence_accuracy_pct": persistence_accuracy,
211
+ **summarize_trades(results),
212
+ }
213
+
214
+
215
+ def run_all_models_backtest(
216
+ df: pd.DataFrame,
217
+ models: dict,
218
+ window: int = 100,
219
+ horizon: int = 1,
220
+ step: int = 1,
221
+ features_df: pd.DataFrame = None,
222
+ ):
223
+ per_model_results = {}
224
+ per_model_summary = {}
225
+ for name, model in models.items():
226
+ results = backtest_model(
227
+ df, model, window=window, horizon=horizon, step=step, features_df=features_df
228
+ )
229
+ per_model_results[name] = results
230
+ per_model_summary[name] = summarize_backtest(results, name)
231
+ return per_model_results, per_model_summary
232
+
233
+
234
+ BASELINE_LABEL = "Close-only"
235
+ FEATURED_LABEL = "+14 Features"
236
+
237
+
238
+ def run_comparison_backtest(
239
+ df: pd.DataFrame,
240
+ models: dict,
241
+ features_df: pd.DataFrame,
242
+ window: int = 100,
243
+ horizon: int = 1,
244
+ step: int = 1,
245
+ ):
246
+ per_model_results = {}
247
+ per_model_summary = {}
248
+ for name, model_pair in models.items():
249
+ baseline_model, featured_model = model_pair
250
+ results_list, summary_list = [], []
251
+ for label, model, feats in (
252
+ (BASELINE_LABEL, baseline_model, None),
253
+ (FEATURED_LABEL, featured_model, features_df),
254
+ ):
255
+ results = backtest_model(
256
+ df, model, window=window, horizon=horizon, step=step, features_df=feats
257
+ )
258
+ summary = summarize_backtest(results, name)
259
+ summary["mode"] = label
260
+ results = results.copy()
261
+ results.insert(0, "mode", label)
262
+ results_list.append(results)
263
+ summary_list.append(summary)
264
+ per_model_results[name] = pd.concat(results_list, ignore_index=True)
265
+ per_model_summary[name] = summary_list
266
+ return per_model_results, per_model_summary
config.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
4
+ # Creation is lazy: importing the application must also work in a read-only
5
+ # checkout. Operators can point runtime artifacts at a persistent writable mount.
6
+ OUTPUT_DIR = os.path.abspath(os.path.expanduser(os.environ.get('MYTHOS_OUTPUT_DIR', os.path.join(BASE_DIR, 'outputs'))))
7
+
8
+ DEFAULT_FOREX_SYMBOLS = [
9
+ 'EURUSD=X', 'GBPUSD=X', 'USDJPY=X', 'USDCHF=X', 'AUDUSD=X',
10
+ 'USDCAD=X', 'NZDUSD=X', 'EURJPY=X', 'EURGBP=X', 'GBPJPY=X',
11
+ 'AUDJPY=X', 'CADJPY=X', 'CHFJPY=X', 'NZDJPY=X', 'EURCHF=X',
12
+ 'EURCAD=X', 'EURAUD=X', 'EURNZD=X', 'GBPCHF=X', 'GBPCAD=X',
13
+ 'GBPAUD=X', 'GBPNZD=X', 'AUDCAD=X', 'AUDCHF=X', 'AUDNZD=X',
14
+ 'CADCHF=X', 'NZDCAD=X', 'NZDCHF=X', 'USDINR=X', 'USDCNH=X',
15
+ 'USDHKD=X', 'USDSGD=X', 'USDZAR=X', 'USDMXN=X', 'USDTRY=X',
16
+ 'USDSEK=X', 'USDNOK=X', 'USDDKK=X', 'USDPLN=X', 'USDTHB=X',
17
+ ]
18
+ DEFAULT_CRYPTO_SYMBOLS = [
19
+ 'BTC-USD', 'ETH-USD', 'BNB-USD', 'XRP-USD', 'SOL-USD',
20
+ 'ADA-USD', 'DOGE-USD', 'DOT-USD', 'LTC-USD', 'LINK-USD',
21
+ ]
22
+ ALL_DEFAULT_SYMBOLS = DEFAULT_FOREX_SYMBOLS + DEFAULT_CRYPTO_SYMBOLS
23
+ YF_NATIVE_INTERVALS = {
24
+ '1m': {'max_days': 30, 'max_request_days': 7},
25
+ '2m': {'max_days': 60, 'max_request_days': 60},
26
+ '5m': {'max_days': 60, 'max_request_days': 60},
27
+ '15m': {'max_days': 60, 'max_request_days': 60},
28
+ '30m': {'max_days': 60, 'max_request_days': 60},
29
+ '60m': {'max_days': 730, 'max_request_days': 730},
30
+ '90m': {'max_days': 60, 'max_request_days': 60},
31
+ '1d': {'max_days': None, 'max_request_days': None},
32
+ '1wk': {'max_days': None, 'max_request_days': None},
33
+ '1mo': {'max_days': None, 'max_request_days': None},
34
+ }
35
+ CUSTOM_TIMEFRAMES = {
36
+ '1m': {'source': '1m', 'rule': None},
37
+ '2m': {'source': '2m', 'rule': None},
38
+ '5m': {'source': '5m', 'rule': None},
39
+ '10m': {'source': '5m', 'rule': '10min'},
40
+ '15m': {'source': '15m', 'rule': None},
41
+ '30m': {'source': '30m', 'rule': None},
42
+ '1h': {'source': '60m', 'rule': None},
43
+ '2h': {'source': '60m', 'rule': '2h'},
44
+ '4h': {'source': '60m', 'rule': '4h'},
45
+ '1d': {'source': '1d', 'rule': None},
46
+ }
47
+ TIMEFRAMES = ['1m', '2m', '5m', '10m', '15m', '30m', '1h', '2h']
48
+ MODEL_NAMES = ['ARIMA', 'Auto-ARIMA', 'ARIMA-GARCH', 'Moirai', 'TimesFM']
49
+ PROVIDERS = ['Yahoo Finance', 'Binance Spot', 'Twelve Data']
50
+ MOIRAI_CHECKPOINT = 'Salesforce/moirai-1.1-R-small'
51
+ TIMESFM_CHECKPOINT = 'google/timesfm-2.5-200m-pytorch'
52
+ DEFAULT_SYMBOL = 'BTC-USD'
53
+ DEFAULT_TIMEFRAME = '5m'
54
+ DEFAULT_MODEL = 'ARIMA-GARCH'
55
+ DEFAULT_LOOKBACK_DAYS = 30
56
+ DEFAULT_HISTORY_WINDOW = 200
57
+ DEFAULT_HORIZON = 1
58
+ DEFAULT_BACKTEST_STEP = 30
59
+ DEFAULT_MAX_EVALUATIONS = 30
60
+ RANDOM_SEED = 42
data/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ pass
data/historical.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from data.validation import DataFetchError, validate_ohlcv, positive_integer
9
+
10
+ _YF_TIMEOUT_SECONDS = 30
11
+ OHLCV_COLUMNS = ['Open', 'High', 'Low', 'Close', 'Volume']
12
+
13
+ def _fetch_yf(symbol: str, interval: str, start: datetime, end: datetime) -> pd.DataFrame:
14
+ import yfinance as yf
15
+ last_error = None
16
+ for attempt in range(3):
17
+ try:
18
+ return yf.Ticker(symbol).history(
19
+ start=start, end=end, interval=interval, auto_adjust=False,
20
+ actions=False, timeout=_YF_TIMEOUT_SECONDS, raise_errors=True,
21
+ )
22
+ except Exception as error:
23
+ last_error = error
24
+ if attempt < 2:
25
+ time.sleep(0.5 * (attempt + 1) + random.uniform(0.0, 0.25))
26
+ raise DataFetchError(f"Yahoo Finance could not fetch {symbol} ({interval}) after 3 attempts ({type(last_error).__name__}).") from None
27
+
28
+ def _download_window(symbol: str, interval: str, start: datetime, end: datetime) -> pd.DataFrame:
29
+ df = _fetch_yf(symbol, interval, start, end)
30
+ if df is None or df.empty:
31
+ return pd.DataFrame()
32
+ if isinstance(df.columns, pd.MultiIndex):
33
+ # yfinance can return either (field, ticker) or (ticker, field),
34
+ # depending on version and whether the ticker is cached.
35
+ fields = set(OHLCV_COLUMNS).intersection(set(df.columns.get_level_values(0)))
36
+ if fields:
37
+ df = df.droplevel(1, axis=1)
38
+ else:
39
+ fields = set(OHLCV_COLUMNS).intersection(set(df.columns.get_level_values(-1)))
40
+ if fields:
41
+ df = df.droplevel(0, axis=1)
42
+ df = df.rename(columns=lambda value: str(value).title())
43
+ df.index.name = 'Datetime'
44
+ keep = [c for c in OHLCV_COLUMNS if c in df.columns]
45
+ return df[keep]
46
+
47
+ def _finalize_ohlcv(df: pd.DataFrame) -> pd.DataFrame:
48
+ if df is None or df.empty:
49
+ return df
50
+ return validate_ohlcv(df, normalize=True)
51
+
52
+ def _native_fetch(symbol: str, interval: str, days: int) -> pd.DataFrame:
53
+ days = positive_integer(days, 'lookback_days', 10000)
54
+ if interval not in YF_NATIVE_INTERVALS:
55
+ raise DataFetchError(f"'{interval}' is not a native yfinance interval.")
56
+ limits = YF_NATIVE_INTERVALS[interval]
57
+ max_days, request_days = (limits['max_days'], limits['max_request_days'])
58
+ if max_days is not None:
59
+ days = min(days, max_days)
60
+ if request_days is None:
61
+ request_days = days
62
+ frames, empty_windows = [], 0
63
+ window_end = datetime.now(timezone.utc)
64
+ remaining = days
65
+ while remaining > 0:
66
+ window_days = min(request_days, remaining)
67
+ window_start = window_end - timedelta(days=window_days)
68
+ frame = _download_window(symbol, interval, window_start, window_end)
69
+ if not frame.empty:
70
+ frames.append(frame)
71
+ else:
72
+ empty_windows += 1
73
+ window_end = window_start
74
+ remaining -= window_days
75
+ if remaining > 0:
76
+ time.sleep(0.1 + random.uniform(0, 0.2))
77
+ df = _finalize_ohlcv(pd.concat(frames)) if frames else pd.DataFrame()
78
+ if df.empty or 'Close' not in df.columns:
79
+ limit_note = f" Yahoo keeps at most {max_days} days of '{interval}' history." if max_days is not None else ''
80
+ 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)
81
+ df.attrs['effective_lookback_days'] = days
82
+ df.attrs['source_interval'] = interval
83
+ df.attrs['empty_request_windows'] = empty_windows
84
+ return df
85
+
86
+ def resample_ohlc(df: pd.DataFrame, rule: str, source_step=None) -> pd.DataFrame:
87
+ if df.empty:
88
+ return df.copy()
89
+ if not isinstance(df.index, pd.DatetimeIndex) or df.index.isna().any() or df.index.duplicated().any() or not df.index.is_monotonic_increasing:
90
+ raise DataFetchError('Resampling needs ordered, unique datetime candles.')
91
+ agg = {'Open': 'first', 'High': 'max', 'Low': 'min', 'Close': 'last', 'Volume': 'sum'}
92
+ agg = {k: v for k, v in agg.items() if k in df.columns}
93
+ price_cols = [c for c in ('Open', 'High', 'Low', 'Close') if c in agg]
94
+ grouped = df.resample(rule, origin='epoch', label='left', closed='left')
95
+ result = grouped.agg(agg).dropna(subset=price_cols)
96
+ if source_step is not None:
97
+ source_step = pd.Timedelta(source_step)
98
+ target_step = pd.Timedelta(rule)
99
+ if source_step <= pd.Timedelta(0) or target_step < source_step or target_step % source_step != pd.Timedelta(0):
100
+ raise ValueError('Resampling requires a positive integral multiple of the source interval.')
101
+ expected = int(target_step / source_step)
102
+ counts = grouped['Close'].count()
103
+ times = pd.Series(df.index, index=df.index)
104
+ first = times.resample(rule, origin='epoch').first()
105
+ last = times.resample(rule, origin='epoch').last()
106
+ # Count + endpoints alone do not prove that interior source candles are
107
+ # on-grid (two shifted candles could previously form an invented bar).
108
+ on_grid = pd.Series((df.index.asi8 % source_step.value) == 0, index=df.index)
109
+ grid_ok = on_grid.astype('int8').resample(rule, origin='epoch').min().eq(1)
110
+ complete = (counts == expected) & grid_ok & (first == counts.index) & (last == counts.index + target_step - source_step)
111
+ result = result.loc[complete.reindex(result.index, fill_value=False)]
112
+ result.attrs.update(df.attrs)
113
+ return result
114
+
115
+ def get_historical(symbol: str, timeframe: str, lookback_days: int=None) -> pd.DataFrame:
116
+ if not symbol or not symbol.strip():
117
+ raise DataFetchError('No symbol given.')
118
+ symbol = symbol.strip()
119
+ if timeframe in CUSTOM_TIMEFRAMES:
120
+ spec = CUSTOM_TIMEFRAMES[timeframe]
121
+ source_interval, rule = (spec['source'], spec['rule'])
122
+ elif timeframe in YF_NATIVE_INTERVALS:
123
+ source_interval, rule = (timeframe, None)
124
+ else:
125
+ raise DataFetchError(f"Unknown timeframe '{timeframe}'. Valid options: {', '.join(CUSTOM_TIMEFRAMES.keys())}")
126
+ max_days = YF_NATIVE_INTERVALS[source_interval]['max_days']
127
+ requested = positive_integer(lookback_days, 'lookback_days', 10000) if lookback_days is not None else 0
128
+ days = requested if requested > 0 else max_days if max_days else 730
129
+ df = _native_fetch(symbol, source_interval, days)
130
+ if rule:
131
+ source_minutes = 60 if source_interval == '60m' else int(source_interval.rstrip('m'))
132
+ df = resample_ohlc(df, rule, source_step=pd.Timedelta(minutes=source_minutes))
133
+ if df.empty:
134
+ raise DataFetchError(f"Not enough '{source_interval}' candles to build '{timeframe}' bars. Try a longer lookback.")
135
+ return df
136
+
137
+ def get_historical_with_features(symbol: str, timeframe: str, lookback_days: int, use_features: bool):
138
+ if not use_features:
139
+ return (get_historical(symbol, timeframe, lookback_days=int(lookback_days)), None)
140
+ padding_days = feature_warmup_days(timeframe, minimum_days=0)
141
+ df_padded = get_historical(symbol, timeframe, lookback_days=int(lookback_days) + padding_days)
142
+ return compute_feature_frame_for_period(df_padded, keep_days=int(lookback_days))
data/providers.py ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 pandas as pd
10
+ import requests
11
+
12
+ from config import DEFAULT_CRYPTO_SYMBOLS, DEFAULT_FOREX_SYMBOLS, TIMEFRAMES
13
+ from data.historical import get_historical, resample_ohlc
14
+ from data.validation import DataFetchError, validate_ohlcv, positive_integer
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
+ last_error = None
30
+ for attempt in range(3):
31
+ try:
32
+ response = requests.get(url, params=params, timeout=(10, 30))
33
+ if response.status_code == 429 or response.status_code >= 500:
34
+ last_error = DataFetchError(f'HTTP {response.status_code}')
35
+ if attempt < 2:
36
+ time.sleep(0.5 * (attempt + 1))
37
+ continue
38
+ if response.status_code != 200:
39
+ raise DataFetchError(f'Market-data provider returned HTTP {response.status_code}. Check availability, regional access, and your plan/rate limits.')
40
+ result = response.json()
41
+ break
42
+ except (requests.RequestException, ValueError) as error:
43
+ last_error = error
44
+ if attempt < 2:
45
+ time.sleep(0.5 * (attempt + 1))
46
+ continue
47
+ raise DataFetchError(f'Market-data request failed ({type(error).__name__}). Check your network and provider availability.') from None
48
+ else:
49
+ raise DataFetchError(f'Market-data request failed ({last_error}). Check your network and provider availability.') from None
50
+ if isinstance(result, dict) and (result.get('status') == 'error' or result.get('code') not in (None, 200)):
51
+ message = str(result.get('message') or result.get('msg') or 'Unknown provider error')
52
+ key = os.environ.get('TWELVE_DATA_API_KEY', '')
53
+ if key:
54
+ message = message.replace(key, '[redacted]')
55
+ label = 'Twelve Data' if 'twelvedata.com' in str(url) else 'Market-data provider'
56
+ raise DataFetchError(f'{label}: {message}')
57
+ return result
58
+
59
+
60
+ def _binance_get(path, params=None):
61
+ """Use Binance's public data mirrors when a hosting region blocks api.binance.com."""
62
+ last_error = None
63
+ for base in (BINANCE_URL, *BINANCE_FALLBACK_URLS):
64
+ try:
65
+ return _json_get(base + path, params)
66
+ except DataFetchError as error:
67
+ last_error = error
68
+ raise last_error or DataFetchError('Binance request failed without a provider response.')
69
+
70
+
71
+ def normalize_symbol(symbol, provider, market):
72
+ symbol = str(symbol or '').strip().upper()
73
+ if not symbol or len(symbol) > 80 or not re.fullmatch(r'[A-Z0-9=./:_-]+', symbol):
74
+ raise ValueError('Enter a valid provider symbol (for example EURUSD=X, EUR/USD, BTC-USD, or BTCUSDT).')
75
+ if market not in ('Forex', 'Crypto'):
76
+ raise ValueError('Market must be Forex or Crypto.')
77
+ if provider == 'Binance Spot':
78
+ if market != 'Crypto':
79
+ raise ValueError('Binance Spot provides crypto pairs; select Yahoo Finance or Twelve Data for Forex.')
80
+ return symbol.replace('/', '').replace('-', '')
81
+ if provider == 'Twelve Data':
82
+ if market != 'Forex':
83
+ raise ValueError('This Twelve Data adapter is for Forex. Select Binance Spot or Yahoo Finance for Crypto.')
84
+ symbol = symbol.removesuffix('=X').replace('/', '')
85
+ if len(symbol) != 6 or not symbol.isalpha():
86
+ raise ValueError('Use a Forex pair such as EUR/USD for Twelve Data.')
87
+ return symbol[:3] + '/' + symbol[3:]
88
+ if provider != 'Yahoo Finance':
89
+ raise ValueError(f'Unknown provider: {provider}')
90
+ if market == 'Forex':
91
+ pair = symbol.removesuffix('=X').replace('/', '')
92
+ if len(pair) != 6 or not pair.isalpha():
93
+ raise ValueError('Use a Forex pair such as EURUSD=X or EUR/USD.')
94
+ return pair + '=X'
95
+ return symbol.replace('/', '-')
96
+
97
+
98
+ def catalog(provider, market, query=''):
99
+ """Return supported catalog entries; custom Yahoo entries are validated on fetch."""
100
+ query = str(query or '').strip().upper()
101
+ if provider == 'Yahoo Finance':
102
+ defaults = DEFAULT_FOREX_SYMBOLS if market == 'Forex' else DEFAULT_CRYPTO_SYMBOLS
103
+ if not query:
104
+ return list(defaults), 'Common Yahoo symbols. Search or type any provider-supported pair; intraday coverage varies by symbol.'
105
+ import yfinance as yf
106
+ try:
107
+ quotes = yf.Search(query, max_results=50, news_count=0, timeout=20).quotes
108
+ except Exception as error:
109
+ raise DataFetchError(f'Yahoo symbol search failed: {type(error).__name__}. You can still type a known symbol.') from None
110
+ if not isinstance(quotes, list):
111
+ raise DataFetchError('Yahoo symbol search returned an invalid catalog response.')
112
+ wanted = 'CURRENCY' if market == 'Forex' else 'CRYPTOCURRENCY'
113
+ symbols = [item['symbol'] for item in quotes if isinstance(item, dict) and item.get('quoteType') == wanted and item.get('symbol')]
114
+ symbols += [s for s in defaults if query in s]
115
+ return sorted(set(symbols)), 'Yahoo search results; a listing does not guarantee intraday history.'
116
+ if provider == 'Binance Spot' and market != 'Crypto':
117
+ raise ValueError('Binance Spot is available for Crypto only.')
118
+ if provider == 'Twelve Data' and market != 'Forex':
119
+ raise ValueError('Select Forex for the Twelve Data adapter.')
120
+ key = (provider, market)
121
+ with _CATALOG_LOCK:
122
+ cached = _CATALOG_CACHE.get(key)
123
+ if not cached or time.monotonic() - cached[0] > 900:
124
+ if provider == 'Binance Spot':
125
+ payload = _binance_get('/exchangeInfo')
126
+ if not isinstance(payload, dict) or not isinstance(payload.get('symbols'), list):
127
+ raise DataFetchError('Binance returned an invalid exchange catalog.')
128
+ symbols = sorted(item['symbol'] for item in payload['symbols'] if isinstance(item, dict) and item.get('symbol') and item.get('status') == 'TRADING' and item.get('isSpotTradingAllowed', True))
129
+ elif provider == 'Twelve Data':
130
+ payload = _json_get(TWELVE_URL + '/forex_pairs')
131
+ if not isinstance(payload, dict) or not isinstance(payload.get('data'), list):
132
+ raise DataFetchError('Twelve Data returned an invalid Forex catalog.')
133
+ symbols = sorted({item['symbol'] for item in payload['data'] if isinstance(item, dict) and isinstance(item.get('symbol'), str) and item['symbol'].strip()})
134
+ else:
135
+ raise ValueError('Unknown market-data provider.')
136
+ if not symbols:
137
+ raise DataFetchError('Provider returned an empty symbol catalog.')
138
+ cached = (time.monotonic(), symbols)
139
+ with _CATALOG_LOCK:
140
+ _CATALOG_CACHE[key] = cached
141
+ matches = [symbol for symbol in cached[1] if query in symbol]
142
+ return matches, f'{len(cached[1])} provider-listed pairs; {len(matches)} match your search. Trading/history availability depends on the provider.'
143
+
144
+
145
+ def _validate_frame(frame):
146
+ return validate_ohlcv(frame, normalize=True)
147
+
148
+
149
+ def _binance_history(symbol, timeframe, days):
150
+ days = positive_integer(days, 'lookback_days', 10000)
151
+ source = {'2m': '1m', '10m': '5m'}.get(timeframe, timeframe)
152
+ if source not in MINUTES_PER_CANDLE:
153
+ raise DataFetchError(f"Binance does not support source timeframe '{source}'.")
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
+ try:
159
+ end_ms = int(clock['serverTime'])
160
+ except (KeyError, TypeError, ValueError):
161
+ raise DataFetchError('Binance returned an invalid server clock response.') from None
162
+ start_ms = end_ms - int(days * 86_400_000)
163
+ cursor = start_ms
164
+ rows = []
165
+ while cursor < end_ms:
166
+ batch = _binance_get('/klines', {
167
+ 'symbol': symbol, 'interval': source, 'startTime': cursor,
168
+ 'endTime': end_ms - 1, 'limit': 1000,
169
+ })
170
+ if not isinstance(batch, list):
171
+ raise DataFetchError('Binance returned an unexpected candle response.')
172
+ if not batch:
173
+ break
174
+ if any(not isinstance(row, (list, tuple)) or len(row) < 6 for row in batch):
175
+ raise DataFetchError('Binance returned a malformed candle row.')
176
+ rows.extend(batch)
177
+ try:
178
+ next_cursor = int(batch[-1][0]) + source_minutes * 60_000
179
+ except (TypeError, ValueError):
180
+ raise DataFetchError('Binance returned an invalid candle timestamp.') from None
181
+ if next_cursor <= cursor:
182
+ raise DataFetchError('Binance pagination did not advance.')
183
+ cursor = next_cursor
184
+ if len(rows) > MAX_SOURCE_CANDLES:
185
+ raise DataFetchError('Provider request exceeded the source candle limit.')
186
+ if len(batch) < 1000:
187
+ break
188
+ time.sleep(0.05)
189
+ if not rows:
190
+ raise DataFetchError(f'Binance has no candles for {symbol}. Use an active exchange symbol such as BTCUSDT (USDT is not USD).')
191
+ try:
192
+ frame = pd.DataFrame([
193
+ [r[1], r[2], r[3], r[4], r[5]] for r in rows
194
+ ], columns=['Open', 'High', 'Low', 'Close', 'Volume'], index=pd.to_datetime([int(r[0]) for r in rows], unit='ms', utc=True)).astype(float)
195
+ except (TypeError, ValueError, OverflowError):
196
+ raise DataFetchError('Binance returned malformed OHLCV values.') from None
197
+ frame.attrs['provider_now'] = pd.Timestamp(end_ms, unit='ms', tz='UTC').isoformat()
198
+ frame.attrs['source_interval'] = source
199
+ try:
200
+ exchange_info = _binance_get('/exchangeInfo', {'symbol': symbol})
201
+ symbol_info = (exchange_info.get('symbols') or [{}])[0]
202
+ price_filter = next((item for item in symbol_info.get('filters', []) if item.get('filterType') == 'PRICE_FILTER'), None)
203
+ if price_filter and float(price_filter.get('tickSize', 0)) > 0:
204
+ frame.attrs['price_tick_size'] = float(price_filter['tickSize'])
205
+ frame.attrs['price_tick_source'] = 'Binance PRICE_FILTER tickSize'
206
+ except Exception:
207
+ # Precision inference from observed real closes remains available when
208
+ # an exchange-info request is temporarily rate-limited.
209
+ pass
210
+ frame = _validate_frame(frame)
211
+ if source != timeframe:
212
+ frame = resample_ohlc(frame, f'{MINUTES_PER_CANDLE[timeframe]}min', pd.Timedelta(minutes=source_minutes))
213
+ return frame
214
+
215
+
216
+ def _twelve_history(symbol, timeframe, days):
217
+ days = positive_integer(days, 'lookback_days', 10000)
218
+ api_key = os.environ.get('TWELVE_DATA_API_KEY', '').strip()
219
+ if not api_key:
220
+ 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.')
221
+ source = {'2m': '1m', '10m': '5m', '2h': '1h'}.get(timeframe, timeframe)
222
+ source_minutes = MINUTES_PER_CANDLE[source]
223
+ if days * 1440 / source_minutes > MAX_SOURCE_CANDLES:
224
+ raise DataFetchError('Reduce lookback: request exceeds 100,000 source candles.')
225
+ interval = {'1m': '1min', '5m': '5min', '15m': '15min', '30m': '30min', '1h': '1h'}[source]
226
+ end = pd.Timestamp.now(tz='UTC')
227
+ request_end = end
228
+ cutoff = end - pd.Timedelta(days=days)
229
+ records = []
230
+ while end > cutoff:
231
+ payload = _json_get(TWELVE_URL + '/time_series', {
232
+ 'symbol': symbol, 'interval': interval, 'apikey': api_key,
233
+ 'timezone': 'UTC', 'order': 'DESC', 'outputsize': 5000,
234
+ 'start_date': cutoff.strftime('%Y-%m-%d %H:%M:%S'),
235
+ 'end_date': end.strftime('%Y-%m-%d %H:%M:%S'),
236
+ })
237
+ if not isinstance(payload, dict):
238
+ raise DataFetchError('Twelve Data returned an invalid time-series response.')
239
+ batch = payload.get('values', [])
240
+ if not isinstance(batch, list):
241
+ raise DataFetchError('Twelve Data returned malformed time-series values.')
242
+ if not batch:
243
+ break
244
+ if any(not isinstance(row, dict) or 'datetime' not in row for row in batch):
245
+ raise DataFetchError('Twelve Data returned a malformed candle row.')
246
+ records.extend(batch)
247
+ parsed_times = pd.to_datetime([row['datetime'] for row in batch], utc=True, errors='coerce')
248
+ if parsed_times.isna().any():
249
+ raise DataFetchError('Twelve Data returned an invalid candle timestamp.')
250
+ oldest = parsed_times.min()
251
+ next_end = oldest - pd.Timedelta(seconds=1)
252
+ if next_end >= end:
253
+ raise DataFetchError('Twelve Data pagination did not advance.')
254
+ end = next_end
255
+ if len(records) > MAX_SOURCE_CANDLES:
256
+ raise DataFetchError('Too many source candles returned.')
257
+ if len(batch) < 5000:
258
+ break
259
+ time.sleep(1)
260
+ if not records:
261
+ raise DataFetchError('Twelve Data returned no Forex candles for this request.')
262
+ raw = pd.DataFrame(records)
263
+ required = {'datetime', 'open', 'high', 'low', 'close'}
264
+ if not required.issubset(raw.columns):
265
+ raise DataFetchError(f'Twelve Data response is missing fields: {sorted(required - set(raw.columns))}.')
266
+ try:
267
+ frame = pd.DataFrame({
268
+ 'Open': pd.to_numeric(raw['open'], errors='raise'), 'High': pd.to_numeric(raw['high'], errors='raise'),
269
+ 'Low': pd.to_numeric(raw['low'], errors='raise'), 'Close': pd.to_numeric(raw['close'], errors='raise'),
270
+ 'Volume': pd.to_numeric(raw['volume'], errors='raise') if 'volume' in raw else 0.0,
271
+ })
272
+ frame.index = pd.to_datetime(raw['datetime'], utc=True, errors='raise')
273
+ except (KeyError, TypeError, ValueError):
274
+ raise DataFetchError('Twelve Data returned malformed OHLCV values or timestamps.') from None
275
+ frame = _validate_frame(frame)
276
+ frame = frame.loc[(frame.index >= cutoff) & (frame.index <= request_end)]
277
+ if frame.empty:
278
+ raise DataFetchError('Twelve Data returned no candles inside the requested UTC range.')
279
+ frame.attrs['volume_note'] = 'Forex has no consolidated traded volume. Zero means unavailable; MAYTHOS uses its price-based proxy.'
280
+ frame.attrs['source_interval'] = source
281
+ if source != timeframe:
282
+ frame = resample_ohlc(frame, f'{MINUTES_PER_CANDLE[timeframe]}min', pd.Timedelta(minutes=source_minutes))
283
+ return frame
284
+
285
+
286
+ def fetch_market_history(provider, market, symbol, timeframe, lookback_days):
287
+ if timeframe not in TIMEFRAMES:
288
+ raise ValueError('Select one of: ' + ', '.join(TIMEFRAMES))
289
+ # The UI caps user history at 730 days; feature warm-up may add a small
290
+ # provider request padding before the requested period is trimmed.
291
+ lookback_days = positive_integer(lookback_days, 'lookback_days', 10000)
292
+ symbol = normalize_symbol(symbol, provider, market)
293
+ if provider == 'Yahoo Finance':
294
+ frame = get_historical(symbol, timeframe, int(lookback_days))
295
+ elif provider == 'Binance Spot':
296
+ frame = _binance_history(symbol, timeframe, int(lookback_days))
297
+ else:
298
+ frame = _twelve_history(symbol, timeframe, int(lookback_days))
299
+ frame = _validate_frame(frame)
300
+ attrs = dict(frame.attrs)
301
+ reference_now = attrs.get('provider_now') or pd.Timestamp.now(tz='UTC')
302
+ try:
303
+ reference_now = pd.Timestamp(reference_now)
304
+ if reference_now.tzinfo is None:
305
+ reference_now = reference_now.tz_localize('UTC')
306
+ else:
307
+ reference_now = reference_now.tz_convert('UTC')
308
+ except (TypeError, ValueError):
309
+ raise DataFetchError('Provider returned an invalid reference clock.') from None
310
+ if frame.index[-1] > reference_now + pd.Timedelta(minutes=MINUTES_PER_CANDLE[timeframe]):
311
+ raise DataFetchError('Provider returned a candle timestamp in the future; data rejected.')
312
+ frame, _ = drop_forming_candle(frame, timeframe=timeframe, now=reference_now)
313
+ if frame.empty:
314
+ raise DataFetchError('No complete, closed candles are available for this request.')
315
+ frame.attrs.update(attrs)
316
+ frame.attrs.update(provider=provider, market=market, symbol=symbol, timeframe=timeframe)
317
+ return frame
data/validation.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared OHLCV contracts; never impute or silently discard invalid observations."""
2
+ import numbers
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+
7
+
8
+ OHLCV_COLUMNS = ['Open', 'High', 'Low', 'Close', 'Volume']
9
+
10
+
11
+ class DataFetchError(ValueError):
12
+ pass
13
+
14
+
15
+ def validate_ohlcv(frame, *, normalize=False):
16
+ if not isinstance(frame, pd.DataFrame) or frame.empty:
17
+ raise DataFetchError('No real OHLCV candles were returned.')
18
+ if frame.columns.duplicated().any():
19
+ raise DataFetchError('OHLCV column names must be unique.')
20
+ missing = [column for column in OHLCV_COLUMNS if column not in frame]
21
+ if missing:
22
+ raise DataFetchError(f'Provider data is missing columns: {missing}.')
23
+ if not isinstance(frame.index, pd.DatetimeIndex):
24
+ raise DataFetchError('Candle timestamps need an explicit DatetimeIndex, not row IDs or ambiguous strings.')
25
+ attrs = dict(frame.attrs)
26
+ out = frame[OHLCV_COLUMNS].copy()
27
+ out.index = pd.to_datetime(out.index, utc=True, errors='coerce')
28
+ if out.index.isna().any():
29
+ raise DataFetchError('Invalid candle timestamps; data has not been repaired or filled.')
30
+ for column in OHLCV_COLUMNS:
31
+ out[column] = pd.to_numeric(out[column], errors='coerce').astype(float)
32
+ if not np.isfinite(out.to_numpy()).all():
33
+ raise DataFetchError('Missing/non-finite OHLCV values; data has not been filled or fabricated.')
34
+ bad = (out[['Open', 'High', 'Low', 'Close']] <= 0).any(axis=1)
35
+ bad |= out['Volume'] < 0
36
+ bad |= out['High'] < out[['Open', 'Close', 'Low']].max(axis=1)
37
+ bad |= out['Low'] > out[['Open', 'Close', 'High']].min(axis=1)
38
+ if bad.any():
39
+ raise DataFetchError(f'Provider returned {int(bad.sum())} invalid OHLCV candle(s).')
40
+ if normalize:
41
+ duplicates = int(out.index.duplicated().sum())
42
+ if duplicates:
43
+ repeated = out.loc[out.index.duplicated(keep=False)]
44
+ if (repeated.groupby(level=0).nunique() > 1).any().any():
45
+ raise DataFetchError('Conflicting duplicate candles were returned; no revision was chosen silently.')
46
+ out = out.loc[~out.index.duplicated(keep='last')]
47
+ attrs['identical_duplicates_removed'] = attrs.get('identical_duplicates_removed', 0) + duplicates
48
+ out = out.sort_index()
49
+ elif not out.index.is_monotonic_increasing or out.index.duplicated().any():
50
+ raise DataFetchError('Candles must be chronologically ordered and unique.')
51
+ out.attrs.update(attrs)
52
+ return out
53
+
54
+
55
+ def positive_integer(value, name, maximum=None):
56
+ if isinstance(value, (bool, np.bool_)) or not isinstance(value, numbers.Integral) or value < 1:
57
+ raise ValueError(f'{name} must be a positive integer.')
58
+ if maximum is not None and value > maximum:
59
+ raise ValueError(f'{name} must be at most {maximum}.')
60
+ return int(value)
features/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ pass
features/feature_pipeline.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pandas as pd
3
+ from indicators.indicators import add_all_indicators
4
+ from data.validation import validate_ohlcv, positive_integer
5
+ from utils.helpers import MINUTES_PER_CANDLE, infer_step
6
+ REQUIRED_OHLCV_COLUMNS = ['Open', 'High', 'Low', 'Close', 'Volume']
7
+ 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']
8
+
9
+ def compute_feature_frame(df: pd.DataFrame, timeframe=None) -> pd.DataFrame:
10
+ missing = [c for c in REQUIRED_OHLCV_COLUMNS if c not in df.columns]
11
+ if missing:
12
+ raise ValueError(f'compute_feature_frame needs an OHLCV DataFrame; missing column(s): {missing}.')
13
+ df = validate_ohlcv(df)
14
+ expected = pd.Timedelta(minutes=MINUTES_PER_CANDLE[timeframe]) if timeframe else infer_step(df.index)
15
+ groups = (df.index.to_series().diff() != expected).cumsum()
16
+ # Gaps never become adjacent equally spaced observations. Indicators restart
17
+ # on each real continuous segment; their genuine warm-up NaNs remain visible.
18
+ with_indicators = pd.concat([add_all_indicators(segment) for _, segment in df.groupby(groups)])
19
+ return with_indicators[FEATURE_COLUMNS].reset_index(drop=True).astype(float)
20
+
21
+ def slice_features(features: pd.DataFrame, start: int, end: int) -> pd.DataFrame:
22
+ return features.iloc[start:end].reset_index(drop=True)
23
+
24
+ def compute_feature_frame_for_period(df_padded: pd.DataFrame, keep_days: float, timeframe=None):
25
+ try:
26
+ keep_days = float(keep_days)
27
+ except (TypeError, ValueError):
28
+ keep_days = float('nan')
29
+ if isinstance(keep_days, bool) or not np.isfinite(keep_days) or keep_days <= 0:
30
+ raise ValueError('keep_days must be finite and positive.')
31
+ features_padded = compute_feature_frame(df_padded, timeframe=timeframe)
32
+ cutoff = df_padded.index[-1] - pd.Timedelta(days=keep_days)
33
+ positions = np.flatnonzero(df_padded.index >= cutoff)
34
+ complete = ~features_padded.isna().any(axis=1).to_numpy()
35
+ positions = positions[complete[positions]] if len(positions) else positions
36
+ if len(positions) == 0:
37
+ 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).')
38
+ return (df_padded.iloc[positions], features_padded.iloc[positions].reset_index(drop=True))
39
+
40
+ def validate_features(features: pd.DataFrame, expected_len: int, model_name: str, check_nan: bool=True) -> None:
41
+ if not isinstance(features, pd.DataFrame) or features.shape[1] == 0 or features.columns.duplicated().any():
42
+ raise ValueError(f'{model_name}: features require a DataFrame with unique numeric columns.')
43
+ if len(features) != expected_len:
44
+ raise ValueError(f'{model_name}: feature window has {len(features)} rows, expected {expected_len} to match the price history it must line up with.')
45
+ numeric = features.apply(pd.to_numeric, errors='coerce')
46
+ non_numeric = features.notna() & numeric.isna()
47
+ if non_numeric.any().any():
48
+ bad_cols = non_numeric.columns[non_numeric.any()].tolist()
49
+ raise ValueError(f'{model_name}: features contain non-numeric values in {bad_cols}.')
50
+ if np.isinf(numeric.to_numpy(dtype=float)).any():
51
+ raise ValueError(f'{model_name}: features contain infinite values.')
52
+ if check_nan and numeric.isna().any().any():
53
+ bad_cols = features.columns[features.isna().any()].tolist()
54
+ 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.")
55
+
56
+ def carry_forward_future(feature_window: pd.DataFrame, horizon: int) -> pd.DataFrame:
57
+ horizon = positive_integer(horizon, 'carry_forward_future horizon')
58
+ if len(feature_window) == 0:
59
+ raise ValueError('carry_forward_future: feature_window is empty.')
60
+ last_row = feature_window.iloc[[-1]]
61
+ 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,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ if isinstance(window, bool) or not isinstance(window, (int, np.integer)) or window < 1:
12
+ raise ValueError('Wilder period must be a positive integer.')
13
+ arr = values.to_numpy(dtype=float)
14
+ n = len(arr)
15
+ out = np.full(n, np.nan)
16
+ seed, prev = [], None
17
+ for i, value in enumerate(arr):
18
+ if not np.isfinite(value):
19
+ seed, prev = [], None
20
+ continue
21
+ if prev is None:
22
+ seed.append(value)
23
+ if len(seed) == window:
24
+ prev = float(np.mean(seed))
25
+ out[i] = prev
26
+ else:
27
+ prev = (prev * (window - 1) + value) / window
28
+ out[i] = prev
29
+ return pd.Series(out, index=values.index)
30
+
31
+ def rsi(series: pd.Series, window: int=14) -> pd.Series:
32
+ delta = series.diff()
33
+ gain = delta.clip(lower=0)
34
+ loss = -delta.clip(upper=0)
35
+ avg_gain = _wilder_rma(gain, window)
36
+ avg_loss = _wilder_rma(loss, window)
37
+ rs = avg_gain / avg_loss.replace(0, np.nan)
38
+ out = 100 - 100 / (1 + rs)
39
+ out = out.mask((avg_loss == 0) & (avg_gain > 0), 100.0)
40
+ return out.mask((avg_loss == 0) & (avg_gain == 0), 50.0)
41
+
42
+ def macd(series: pd.Series, fast: int=12, slow: int=26, signal: int=9):
43
+ fast_ema = ema(series, fast)
44
+ slow_ema = ema(series, slow)
45
+ macd_line = fast_ema - slow_ema
46
+ signal_line = ema(macd_line, signal)
47
+ histogram = macd_line - signal_line
48
+ return (macd_line, signal_line, histogram)
49
+
50
+ def bollinger_bands(series: pd.Series, window: int=20, num_std: float=2.0):
51
+ mid = sma(series, window)
52
+ std = series.rolling(window=window).std(ddof=0)
53
+ return (mid + num_std * std, mid, mid - num_std * std)
54
+
55
+ def stochastic_oscillator(high: pd.Series, low: pd.Series, close: pd.Series, k_window: int=14, d_window: int=3):
56
+ lowest_low = low.rolling(window=k_window).min()
57
+ highest_high = high.rolling(window=k_window).max()
58
+ span = highest_high - lowest_low
59
+ percent_k = 100 * (close - lowest_low) / span.replace(0, np.nan)
60
+ percent_k = percent_k.mask(span == 0, 50.0)
61
+ percent_d = percent_k.rolling(window=d_window).mean()
62
+ return (percent_k, percent_d)
63
+
64
+ def add_all_indicators(df: pd.DataFrame) -> pd.DataFrame:
65
+ out = df.copy()
66
+ out['SMA_20'] = sma(out['Close'], 20)
67
+ out['EMA_20'] = ema(out['Close'], 20)
68
+ out['RSI_14'] = rsi(out['Close'], 14)
69
+ macd_line, signal_line, hist = macd(out['Close'])
70
+ out['MACD'] = macd_line
71
+ out['MACD_Signal'] = signal_line
72
+ out['MACD_Hist'] = hist
73
+ upper, mid, lower = bollinger_bands(out['Close'])
74
+ out['BB_Upper'] = upper
75
+ out['BB_Mid'] = mid
76
+ out['BB_Lower'] = lower
77
+ k, d = stochastic_oscillator(out['High'], out['Low'], out['Close'])
78
+ out['Stoch_%K'] = k
79
+ out['Stoch_%D'] = d
80
+ return out
81
+
82
+ def _round(value, digits):
83
+ return round(float(value), digits) if pd.notna(value) else 'n/a'
84
+
85
+ def latest_signals(df_with_indicators: pd.DataFrame) -> dict:
86
+ if df_with_indicators.empty:
87
+ raise ValueError('No candles available for latest indicators.')
88
+ last = df_with_indicators.iloc[-1]
89
+ signals = {}
90
+ rsi_value = last['RSI_14']
91
+ 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'}
92
+ macd_ready = pd.notna(last['MACD']) and pd.notna(last['MACD_Signal'])
93
+ 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)' if last['MACD'] < last['MACD_Signal'] else 'Neutral'}
94
+ bb_ready = pd.notna(last['BB_Upper']) and pd.notna(last['BB_Lower'])
95
+ 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'}
96
+ sma_ready = pd.notna(last['SMA_20'])
97
+ 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)' if last['Close'] < last['SMA_20'] else 'Neutral'}
98
+ stoch_value = last['Stoch_%K']
99
+ 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'}
100
+ return signals
install_foundation_windows.bat ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ if errorlevel 1 (
16
+ echo Foundation dependency consistency check failed.
17
+ pause
18
+ exit /b 1
19
+ )
20
+ echo Pretrained model weights download on first inference. Internet and sufficient disk/RAM are required.
21
+ pause
live_runner.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(OUTPUT_DIR).mkdir(parents=True, exist_ok=True)
36
+ path = Path(OUTPUT_DIR) / f'live_logger_{run_id}.csv'
37
+ path.with_suffix('.json').write_text(json.dumps(asdict(config), indent=2), encoding='utf-8')
38
+ fields = ['model', 'predicted_at_utc', 'origin_open_utc', 'target_open_utc', 'current_close',
39
+ 'predicted_close', 'predicted_direction', 'entry', 'stop_loss', 'take_profit',
40
+ 'risk_reward', 'actual_close', 'actual_direction', 'correct', 'status']
41
+
42
+ def log(row):
43
+ new_file = not path.exists()
44
+ with path.open('a', newline='', encoding='utf-8') as file:
45
+ writer = csv.DictWriter(file, fieldnames=fields)
46
+ if new_file:
47
+ writer.writeheader()
48
+ writer.writerow(row)
49
+
50
+ pending = []
51
+ last_origin = None
52
+ print(f'MYTHOS {config.provider} · {config.symbol} · {config.timeframe} · {config.models}. Logging: {path}')
53
+ try:
54
+ while True:
55
+ try:
56
+ frame, features = prepare_history(config)
57
+ still_pending = []
58
+ for row in pending:
59
+ target = pd.Timestamp(row['target_open_utc'])
60
+ if target in frame.index:
61
+ actual = float(frame.loc[target, 'Close'])
62
+ direction = price_direction(actual, row['current_close'])
63
+ interval = frame.loc[pd.Timestamp(row['origin_open_utc']):target].index.to_series().diff().dropna()
64
+ continuous = len(interval) == config.horizon and (interval == config.candle_step).all()
65
+ verified = {**row, 'actual_close': actual, 'actual_direction': direction,
66
+ 'correct': direction == row['predicted_direction'] if continuous else '',
67
+ 'status': 'VERIFIED' if continuous else 'GAP — not scored'}
68
+ log(verified)
69
+ print(verified)
70
+ elif frame.index[-1] > target:
71
+ log({**row, 'status': 'MISSING TARGET — not scored'})
72
+ else:
73
+ still_pending.append(row)
74
+ pending = still_pending
75
+ if last_origin != frame.index[-1]:
76
+ result = live_result(config, frame, features)
77
+ last_origin = frame.index[-1]
78
+ for forecast in result['forecasts']:
79
+ if not forecast['forecast_path'] or result['metadata']['stale']:
80
+ print(f"{forecast['Model']}: {forecast['Status']} {forecast['Error']}")
81
+ continue
82
+ pending.append({
83
+ 'model': forecast['Model'], 'predicted_at_utc': result['metadata']['fetched_at_utc'],
84
+ 'origin_open_utc': last_origin.isoformat(),
85
+ 'target_open_utc': (last_origin + config.candle_step * config.horizon).isoformat(),
86
+ 'current_close': forecast['Current close'], 'predicted_close': forecast['Predicted close'],
87
+ 'predicted_direction': forecast['Direction'].lower(),
88
+ 'entry': result['signal'].get('trade_levels', {}).get('entry'),
89
+ 'stop_loss': result['signal'].get('trade_levels', {}).get('stop_loss'),
90
+ 'take_profit': result['signal'].get('trade_levels', {}).get('take_profit'),
91
+ 'risk_reward': result['signal'].get('trade_levels', {}).get('risk_reward'),
92
+ })
93
+ print(f"{forecast['Model']}: {forecast['Direction']} → {forecast['Predicted close']:.8f}")
94
+ except Exception as error:
95
+ print(f'Pipeline error: {error}')
96
+ time.sleep(max(1, args.poll_seconds))
97
+ except KeyboardInterrupt:
98
+ for row in pending:
99
+ log({**row, 'status': 'STOPPED BEFORE TARGET — not scored'})
100
+ print('Stopped.')
101
+
102
+
103
+ if __name__ == '__main__':
104
+ main()
maythos.py ADDED
The diff for this file is too large to render. See raw diff
 
maythos_integration.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from data.validation import validate_ohlcv, positive_integer
12
+
13
+
14
+ OHLCV_COLUMNS = ('Open', 'High', 'Low', 'Close', 'Volume')
15
+
16
+
17
+ def _normalise_frame(df: pd.DataFrame) -> pd.DataFrame:
18
+ if not isinstance(df, pd.DataFrame):
19
+ raise TypeError('MAYTHOS integration needs a pandas DataFrame.')
20
+ frame = validate_ohlcv(df, normalize=True)
21
+ if frame.index[0] <= pd.Timestamp('1970-01-01', tz='UTC'):
22
+ raise ValueError('MAYTHOS integration needs positive epoch timestamps.')
23
+ return frame
24
+
25
+
26
+ def _timestamp_seconds(value: Any) -> float:
27
+ timestamp = pd.Timestamp(value)
28
+ if timestamp.tzinfo is None:
29
+ timestamp = timestamp.tz_localize('UTC')
30
+ else:
31
+ timestamp = timestamp.tz_convert('UTC')
32
+ return float(timestamp.timestamp())
33
+
34
+
35
+ def candle_from_row(timestamp: Any, row: pd.Series, source_id: str = 'historical') -> Candle:
36
+ return Candle(
37
+ timestamp=_timestamp_seconds(timestamp),
38
+ open=float(row['Open']),
39
+ high=float(row['High']),
40
+ low=float(row['Low']),
41
+ close=float(row['Close']),
42
+ volume=float(row['Volume']),
43
+ source_id=source_id,
44
+ is_closed=True,
45
+ )
46
+
47
+
48
+ def _run_engine(frame: pd.DataFrame) -> List[Dict[str, Any]]:
49
+ engine = MAYTHOS()
50
+ outputs: List[Dict[str, Any]] = []
51
+ for timestamp, row in frame.iterrows():
52
+ candle = candle_from_row(timestamp, row)
53
+ output = engine.tick(candle)
54
+ errors = engine.validate(output)
55
+ if errors:
56
+ raise RuntimeError(f'MAYTHOS output contract failed: {errors}')
57
+ outputs.append(output)
58
+ return outputs
59
+
60
+
61
+ def run_maythos_history(df: pd.DataFrame) -> pd.DataFrame:
62
+ """Run every MAYTHOS layer once per closed candle without lookahead."""
63
+ frame = _normalise_frame(df)
64
+ outputs = _run_engine(frame)
65
+ rows = []
66
+ for timestamp, row, output in zip(frame.index, frame.itertuples(), outputs):
67
+ rows.append({
68
+ 'timestamp': timestamp.isoformat(),
69
+ 'close': float(row.Close),
70
+ 'direction': output['direction'],
71
+ 'confidence': float(output['confidence']),
72
+ 'internal_trust': float(output['internal_trust']),
73
+ 'execution_suitability': output['execution_suitability'],
74
+ 'blocked': bool(output['blocked_flag']),
75
+ 'market_state': output['market_state'],
76
+ 'regime_label': output['regime_label'],
77
+ 'timeframe_alignment': float(output['timeframe_alignment']),
78
+ 'reason_summary': output['reason_summary'],
79
+ })
80
+ return pd.DataFrame(rows)
81
+
82
+
83
+ def backtest_maythos(
84
+ df: pd.DataFrame,
85
+ window: int,
86
+ horizon: int = 1,
87
+ step: int = 1,
88
+ max_evaluations: int = None,
89
+ timeframe: str = None,
90
+ market: str = 'Crypto',
91
+ symbol: str = '',
92
+ costs: CostModel = None,
93
+ ) -> Tuple[pd.DataFrame, Dict[str, Any]]:
94
+ """Evaluate MAYTHOS direction only when its signal is execution-ready."""
95
+ window = positive_integer(window, 'MAYTHOS backtest window')
96
+ horizon = positive_integer(horizon, 'MAYTHOS backtest horizon')
97
+ step = positive_integer(step, 'MAYTHOS backtest step')
98
+ frame = _normalise_frame(df)
99
+ costs = costs or CostModel()
100
+ outputs = _run_engine(frame)
101
+ last_i = len(frame) - horizon
102
+ rows = []
103
+ positions = list(range(window, last_i + 1, step))
104
+ if max_evaluations is not None:
105
+ max_evaluations = positive_integer(max_evaluations, 'MAYTHOS max_evaluations')
106
+ positions = positions[-max_evaluations:]
107
+ for i in positions:
108
+ current_index = i - 1
109
+ target_index = i + horizon - 1
110
+ output = outputs[current_index]
111
+ current_close = float(frame['Close'].iloc[current_index])
112
+ actual_close = float(frame['Close'].iloc[target_index])
113
+ continuous = True
114
+ if timeframe:
115
+ times = frame.index[current_index:target_index + 1]
116
+ continuous = (times.to_series().diff().dropna() == pd.Timedelta(minutes=MINUTES_PER_CANDLE[timeframe])).all()
117
+ # Confidence is heuristic, not a calibrated win probability. Weak and
118
+ # moderate states produced many marginal trades in the prior run, so
119
+ # only a fully confirmed strong state can enter the execution path.
120
+ actionable = (
121
+ continuous and output['direction'] in ('BUY', 'SELL') and
122
+ not bool(output['blocked_flag']) and
123
+ output['execution_suitability'] == 'strong' and
124
+ float(output.get('confidence', 0.0)) >= 0.69
125
+ )
126
+ levels = (
127
+ build_trade_levels(
128
+ frame.iloc[:i], output['direction'], market, symbol,
129
+ minimum_edge_atr=0.20, minimum_risk_reward=1.20,
130
+ minimum_edge_bps=(
131
+ costs.spread_bps + costs.fee_bps + 2.0 * costs.slippage_bps
132
+ ) * 1.25,
133
+ ) if actionable else None
134
+ )
135
+ trade = {'outcome': 'NO_LEVELS', 'exit_price': None, 'net_r': None, 'bars_held': 0}
136
+ if levels is not None:
137
+ trade = {**trade, **simulate_trade(levels, frame.iloc[i:target_index + 1], costs)}
138
+ actionable = actionable and levels is not None
139
+ predicted_direction = None
140
+ if actionable:
141
+ predicted_direction = 'up' if output['direction'] == 'BUY' else 'down'
142
+ actual_direction = price_direction(actual_close, current_close)
143
+ rows.append({
144
+ 'datetime': frame.index[target_index].isoformat(),
145
+ 'current_close': current_close,
146
+ 'predicted_close': np.nan,
147
+ 'actual_close': actual_close,
148
+ 'predicted_direction': predicted_direction,
149
+ 'actual_direction': actual_direction if actionable else None,
150
+ 'correct': bool(predicted_direction == actual_direction) if actionable else None,
151
+ 'signal_available': actionable,
152
+ 'trade_taken': levels is not None,
153
+ 'entry': levels.entry if levels else np.nan,
154
+ 'stop_loss': levels.stop_loss if levels else np.nan,
155
+ 'take_profit': levels.take_profit if levels else np.nan,
156
+ 'risk_reward': levels.risk_reward if levels else np.nan,
157
+ 'atr': levels.atr if levels else np.nan,
158
+ 'swing_support': levels.swing_support if levels else np.nan,
159
+ 'swing_resistance': levels.swing_resistance if levels else np.nan,
160
+ 'outcome': trade.get('outcome'),
161
+ 'exit_price': trade.get('exit_price'),
162
+ 'net_r': trade.get('net_r'),
163
+ 'bars_held': trade.get('bars_held', 0),
164
+ 'confidence': float(output['confidence']),
165
+ 'execution_suitability': output['execution_suitability'],
166
+ 'reason_summary': output['reason_summary'],
167
+ 'error': None if continuous else 'Target crosses a missing candle or market closure.',
168
+ })
169
+ results = pd.DataFrame(rows)
170
+ summary = summarize_maythos(results)
171
+ return results, summary
172
+
173
+
174
+ def summarize_maythos(results: pd.DataFrame) -> Dict[str, Any]:
175
+ if results.empty:
176
+ return {
177
+ 'engine': 'MAYTHOS',
178
+ 'evaluations': 0,
179
+ 'actionable_signals': 0,
180
+ 'blocked_signals': 0,
181
+ 'correct': 0,
182
+ 'incorrect': 0,
183
+ 'accuracy_pct': None,
184
+ 'mean_confidence': 0.0,
185
+ **summarize_trades(results),
186
+ }
187
+ actionable = results[results['signal_available']]
188
+ total = len(actionable)
189
+ correct = int(actionable['correct'].sum()) if total else 0
190
+ return {
191
+ 'engine': 'MAYTHOS',
192
+ 'evaluations': int(len(results)),
193
+ 'actionable_signals': total,
194
+ 'blocked_signals': int((~results['signal_available']).sum()),
195
+ 'correct': correct,
196
+ 'incorrect': total - correct,
197
+ 'accuracy_pct': round(100.0 * correct / total, 2) if total else None,
198
+ 'mean_confidence': round(float(actionable['confidence'].mean()), 6) if total else 0.0,
199
+ **summarize_trades(results),
200
+ }
201
+
202
+
203
+ def latest_maythos_output(df: pd.DataFrame) -> Dict[str, Any]:
204
+ """Return the signal produced using only the final closed candle."""
205
+ frame = _normalise_frame(df)
206
+ return _run_engine(frame)[-1]
models/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ pass
models/arima_garch_model.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import threading
2
+ import numpy as np
3
+ import pandas as pd
4
+ from models.base_model import BaseForecastModel, causal_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 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
+ self.last_diagnostics = {}
22
+
23
+ def _best_order(self, returns: pd.Series, exog: pd.DataFrame=None):
24
+ arima = import_arima(self.name)
25
+ best_aic = np.inf
26
+ best_order = None
27
+ with quiet_fit():
28
+ for p, q in [(0, 0), (1, 0), (0, 1), (1, 1), (2, 0), (0, 2), (2, 1), (1, 2)]:
29
+ if p > self.max_p or q > self.max_q:
30
+ continue
31
+ try:
32
+ fit = fit_arima(arima(returns, exog=exog, order=(p, 0, q)))
33
+ except Exception:
34
+ continue
35
+ if fit.aic < best_aic:
36
+ best_aic = fit.aic
37
+ best_order = (p, 0, q)
38
+ if best_order is None:
39
+ raise ValueError('No ARIMA-GARCH mean candidate converged on this training window.')
40
+ return best_order
41
+
42
+ def predict(self, history: pd.Series, horizon: int=1, features: pd.DataFrame=None) -> list:
43
+ with self._lock:
44
+ validate_history_and_horizon(history, horizon, self.name)
45
+ self.last_volatility_forecast = None
46
+ self.last_diagnostics = {}
47
+ prices = pd.Series(history).astype(float).reset_index(drop=True)
48
+ validate_history_and_horizon(prices, horizon, self.name)
49
+ if len(prices) < 30:
50
+ raise ValueError('ARIMA-GARCH needs at least 30 candles of history.')
51
+ if (prices <= 0).any():
52
+ raise ValueError('ARIMA-GARCH needs strictly positive prices (it uses log returns).')
53
+ arima = import_arima(self.name)
54
+ arch_model = import_arch_model(self.name)
55
+ # Fit returns in percentage units for a numerically well-scaled
56
+ # optimizer. Undo this scale exactly once for prices and volatility.
57
+ scale = 100.0
58
+ log_returns = np.log(prices / prices.shift(1)).dropna().reset_index(drop=True) * scale
59
+ exog = future_exog = None
60
+ if features is not None:
61
+ validate_features(features, expected_len=len(prices), model_name='ARIMA-GARCH')
62
+ exog, future_exog = causal_exogenous(features, horizon, self.name)
63
+ need_search = self._cached_order is None
64
+ if self.refit_order_every and self._calls_since_search >= self.refit_order_every:
65
+ need_search = True
66
+ if need_search:
67
+ self._cached_order = self._best_order(log_returns, exog=exog)
68
+ self._calls_since_search = 0
69
+ self._calls_since_search += 1
70
+ with quiet_fit():
71
+ mean_fit = fit_arima(arima(log_returns, exog=exog, order=self._cached_order))
72
+ if exog is not None:
73
+ mean_forecast = np.asarray(mean_fit.forecast(steps=horizon, exog=future_exog), dtype=float)
74
+ else:
75
+ mean_forecast = np.asarray(mean_fit.forecast(steps=horizon), dtype=float)
76
+ resid = pd.Series(mean_fit.resid).dropna()
77
+ try:
78
+ garch_fit = arch_model(resid, mean='Zero', vol='GARCH', p=self.garch_p, q=self.garch_q, dist='t', rescale=False).fit(disp='off', options={'maxiter': 120})
79
+ if garch_fit.convergence_flag != 0:
80
+ raise ValueError('GARCH optimizer did not converge.')
81
+ garch_forecast = garch_fit.forecast(horizon=horizon, reindex=False)
82
+ variance = np.asarray(garch_forecast.variance.values[-1], dtype=float) / scale ** 2
83
+ if variance.shape != (horizon,) or not np.isfinite(variance).all() or (variance < 0).any():
84
+ raise ValueError('GARCH produced an invalid variance forecast.')
85
+ self.last_diagnostics = {'converged': True, 'volatility_method': 'GARCH', 'garch_fallback': False}
86
+ except Exception as error:
87
+ # The point forecast remains the fitted ARIMA mean. For a
88
+ # short/degenerate sample, use the observed residual
89
+ # variance as the honest unconditional volatility estimate
90
+ # rather than returning a dummy price forecast.
91
+ variance = np.full(horizon, float(resid.var(ddof=1) / scale ** 2) if len(resid) > 1 else 0.0)
92
+ self.last_diagnostics = {'converged': True, 'volatility_method': 'unconditional residual variance', 'garch_fallback': True, 'garch_error': type(error).__name__}
93
+ if not np.isfinite(variance).all() or (variance < 0).any():
94
+ raise ValueError('No finite volatility estimate is available.')
95
+ self.last_volatility_forecast = [float(x) for x in np.sqrt(variance)]
96
+ last_price = float(prices.iloc[-1])
97
+ prices_out = []
98
+ cum_log_return = 0.0
99
+ for r in mean_forecast:
100
+ cum_log_return += float(r) / scale
101
+ prices_out.append(last_price * float(np.exp(cum_log_return)))
102
+ if len(prices_out) != horizon or not all(np.isfinite(x) and x > 0 for x in prices_out):
103
+ raise ValueError(f'ARIMA-GARCH produced a non-finite forecast for order {self._cached_order}.')
104
+ return prices_out
models/arima_model.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import threading
2
+ import numpy as np
3
+ import pandas as pd
4
+ from models.base_model import BaseForecastModel, causal_exogenous, validate_history_and_horizon
5
+ from models.dependencies import fit_arima, import_arima, quiet_fit
6
+ from features.feature_pipeline import 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
+ self.last_diagnostics = {}
20
+
21
+ def _best_order(self, series: pd.Series, exog: pd.DataFrame=None):
22
+ arima = import_arima(self.name)
23
+ best_aic = np.inf
24
+ best_order = None
25
+ # AIC values from different integration orders have different likelihood
26
+ # bases. Select d on the training sample, then compare only p/q at that d.
27
+ from models.auto_arima_model import AutoArimaModel
28
+ selected_d = AutoArimaModel(max_d=self.max_d)._find_d(series)
29
+ with quiet_fit():
30
+ candidates = [(p, d, q)
31
+ for d in [selected_d]
32
+ for p in range(self.max_p + 1)
33
+ for q in range(self.max_q + 1)
34
+ if p + q <= 2]
35
+ for order in candidates:
36
+ try:
37
+ fit = fit_arima(arima(series, exog=exog, order=order))
38
+ except Exception:
39
+ continue
40
+ if fit.aic < best_aic:
41
+ best_aic = fit.aic
42
+ best_order = order
43
+ if best_order is None:
44
+ raise ValueError('No ARIMA candidate converged on this training window.')
45
+ return best_order
46
+
47
+ def _fit_forecast(self, arima, series, order, horizon, exog, future_exog):
48
+ """Fit a positive-price ARIMA with deterministic, real fallbacks."""
49
+ candidates = [order, (0, 1, 0), (1, 1, 0), (0, 1, 1), (1, 1, 1)]
50
+ seen = set()
51
+ last_error = None
52
+ for candidate in candidates:
53
+ if candidate in seen:
54
+ continue
55
+ seen.add(candidate)
56
+ try:
57
+ with quiet_fit():
58
+ fit = fit_arima(arima(series, exog=exog, order=candidate))
59
+ forecast = fit.forecast(steps=horizon, exog=future_exog) if exog is not None else fit.forecast(steps=horizon)
60
+ values = np.exp(np.asarray(forecast, dtype=float))
61
+ if values.shape == (horizon,) and np.isfinite(values).all() and (values > 0).all():
62
+ self._cached_order = candidate
63
+ self.last_diagnostics = {'converged': True, 'selected_order': order, 'executed_order': candidate, 'order_fallback': candidate != order, 'feature_policy': 'lagged one candle; unknown future covariates persist'}
64
+ return values
65
+ except Exception as error:
66
+ last_error = error
67
+ raise ValueError(f'ARIMA could not fit a stable positive-price model: {last_error}') from None
68
+
69
+ def predict(self, history: pd.Series, horizon: int=1, features: pd.DataFrame=None) -> list:
70
+ with self._lock:
71
+ validate_history_and_horizon(history, horizon, self.name)
72
+ self.last_diagnostics = {}
73
+ series = pd.Series(history).astype(float).reset_index(drop=True)
74
+ validate_history_and_horizon(series, horizon, self.name)
75
+ if len(series) < 20:
76
+ raise ValueError('ARIMA needs at least 20 candles of history.')
77
+ arima = import_arima(self.name)
78
+ exog = future_exog = None
79
+ if features is not None:
80
+ validate_features(features, expected_len=len(series), model_name='ARIMA')
81
+ exog, future_exog = causal_exogenous(features, horizon, self.name)
82
+ series = series.iloc[1:].reset_index(drop=True)
83
+ if (series <= 0).any():
84
+ raise ValueError('ARIMA needs strictly positive prices for its log-price model.')
85
+ log_series = np.log(series)
86
+ need_search = self._cached_order is None
87
+ if self.refit_order_every and self._calls_since_search >= self.refit_order_every:
88
+ need_search = True
89
+ if need_search:
90
+ self._cached_order = self._best_order(log_series, exog=exog)
91
+ self._calls_since_search = 0
92
+ self._calls_since_search += 1
93
+ forecast = self._fit_forecast(arima, log_series, self._cached_order, horizon, exog, future_exog)
94
+ return [float(x) for x in forecast]
models/auto_arima_model.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import threading
2
+ import numpy as np
3
+ import pandas as pd
4
+ from models.base_model import BaseForecastModel, causal_exogenous, validate_history_and_horizon
5
+ from models.dependencies import fit_arima, import_arima, import_kpss, quiet_fit
6
+ from features.feature_pipeline import 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
+ self.last_diagnostics = {}
20
+
21
+ def _find_d(self, series: pd.Series) -> int:
22
+ kpss = import_kpss(self.name)
23
+ s = series.copy()
24
+ d = 0
25
+ if float(s.std()) < 1e-12:
26
+ return 0
27
+ with quiet_fit():
28
+ while d < self.max_d:
29
+ try:
30
+ _, p_value, _, _ = kpss(s, regression='c', nlags='auto')
31
+ except Exception as error:
32
+ raise ValueError(f'Auto-ARIMA stationarity test failed ({type(error).__name__}).') from None
33
+ if p_value >= 0.05:
34
+ break
35
+ s = s.diff().dropna()
36
+ d += 1
37
+ return d
38
+
39
+ def _aic(self, series: pd.Series, order, exog: pd.DataFrame=None) -> float:
40
+ arima = import_arima(self.name)
41
+ try:
42
+ return fit_arima(arima(series, exog=exog, order=order)).aic
43
+ except Exception:
44
+ return np.inf
45
+
46
+ def _stepwise_search(self, series: pd.Series, d: int, exog: pd.DataFrame=None):
47
+ # A compact Hyndman-style candidate set is more stable for noisy
48
+ # intraday data than repeatedly walking a large grid at every origin.
49
+ candidates = [(0, 0), (1, 0), (0, 1), (1, 1), (2, 0), (0, 2), (2, 1), (1, 2)]
50
+ candidates = [(p, q) for p, q in candidates if p <= self.max_p and q <= self.max_q]
51
+ best_order, best_aic = (None, np.inf)
52
+ with quiet_fit():
53
+ for p, q in candidates:
54
+ aic = self._aic(series, (p, d, q), exog=exog)
55
+ if aic < best_aic:
56
+ best_aic, best_order = (aic, (p, q))
57
+ if best_order is None:
58
+ raise ValueError('No Auto-ARIMA candidate converged on this training window.')
59
+ p, q = best_order
60
+ return (p, d, q)
61
+
62
+ def predict(self, history: pd.Series, horizon: int=1, features: pd.DataFrame=None) -> list:
63
+ with self._lock:
64
+ validate_history_and_horizon(history, horizon, self.name)
65
+ self.last_diagnostics = {}
66
+ series = pd.Series(history).astype(float).reset_index(drop=True)
67
+ validate_history_and_horizon(series, horizon, self.name)
68
+ if len(series) < 20:
69
+ raise ValueError('Auto-ARIMA needs at least 20 candles of history.')
70
+ arima = import_arima(self.name)
71
+ if (series <= 0).any():
72
+ raise ValueError('Auto-ARIMA needs strictly positive prices for its log-price model.')
73
+ exog = future_exog = None
74
+ if features is not None:
75
+ validate_features(features, expected_len=len(series), model_name='Auto-ARIMA')
76
+ exog, future_exog = causal_exogenous(features, horizon, self.name)
77
+ series = series.iloc[1:].reset_index(drop=True)
78
+ series = np.log(series)
79
+ need_search = self._cached_order is None
80
+ if self.refit_order_every and self._calls_since_search >= self.refit_order_every:
81
+ need_search = True
82
+ if need_search:
83
+ d = self._find_d(series)
84
+ self._cached_order = self._stepwise_search(series, d, exog=exog)
85
+ self._calls_since_search = 0
86
+ self._calls_since_search += 1
87
+ candidates = [self._cached_order, (0, 1, 0), (1, 1, 0), (0, 1, 1), (1, 1, 1)]
88
+ last_error = None
89
+ for order in dict.fromkeys(candidates):
90
+ try:
91
+ with quiet_fit():
92
+ fit = fit_arima(arima(series, exog=exog, order=order))
93
+ raw = fit.forecast(steps=horizon, exog=future_exog) if exog is not None else fit.forecast(steps=horizon)
94
+ forecast = np.exp(np.asarray(raw, dtype=float))
95
+ if forecast.shape == (horizon,) and np.isfinite(forecast).all() and (forecast > 0).all():
96
+ selected_order = self._cached_order
97
+ self._cached_order = order
98
+ self.last_diagnostics = {'converged': True, 'selected_order': selected_order, 'executed_order': order, 'order_fallback': order != selected_order, 'feature_policy': 'lagged one candle; unknown future covariates persist'}
99
+ return [float(x) for x in forecast]
100
+ except Exception as error:
101
+ last_error = error
102
+ 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,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ if (values <= 0).any():
14
+ raise ValueError(f'{model_name}: price history must be strictly positive.')
15
+ if isinstance(history, pd.Series) and isinstance(history.index, pd.DatetimeIndex):
16
+ if history.index.isna().any() or history.index.duplicated().any() or not history.index.is_monotonic_increasing:
17
+ raise ValueError(f'{model_name}: history timestamps must be ordered and unique.')
18
+
19
+
20
+ def standardize_exogenous(features: pd.DataFrame, model_name: str) -> pd.DataFrame:
21
+ """Scale causal regressors using statistics from the supplied history only.
22
+
23
+ OHLC prices and oscillator values have very different magnitudes. Passing
24
+ them unscaled to statsmodels frequently creates singular matrices on short
25
+ windows, especially for FX. Scaling here is causal (the future is never
26
+ included) and keeps the same columns so the feature contract remains
27
+ auditable.
28
+ """
29
+ if not isinstance(features, pd.DataFrame) or features.empty:
30
+ raise ValueError(f'{model_name}: features must be a non-empty DataFrame.')
31
+ numeric = features.apply(pd.to_numeric, errors='coerce').astype(float)
32
+ if not np.isfinite(numeric.to_numpy()).all():
33
+ raise ValueError(f'{model_name}: features contain non-finite values.')
34
+ mean = numeric.mean(axis=0)
35
+ scale = numeric.std(axis=0, ddof=0).replace(0.0, 1.0).fillna(1.0)
36
+ scaled = (numeric - mean) / scale
37
+ # A single outlier should not make the optimizer overflow. This is a
38
+ # bounded transform, not an imputation or a fabricated observation.
39
+ return scaled.clip(-12.0, 12.0)
40
+
41
+
42
+ def causal_exogenous(features, horizon, model_name):
43
+ """Pair target at t with covariates at t-1, using training-only scaling.
44
+
45
+ OHLC(t) and indicators(t) contain information about Close(t). They cannot
46
+ be used as exogenous predictors of that same target. The last observed
47
+ covariate is instead the legitimate first future regressor; later unknown
48
+ covariates explicitly use persistence, not realized future candles.
49
+ """
50
+ numeric = features.apply(pd.to_numeric, errors='coerce').astype(float).reset_index(drop=True)
51
+ if len(numeric) < 2 or not np.isfinite(numeric.to_numpy()).all():
52
+ raise ValueError(f'{model_name}: causal features need at least two finite rows.')
53
+ train = numeric.iloc[:-1].reset_index(drop=True)
54
+ mean = train.mean()
55
+ scale = train.std(ddof=0).replace(0, 1).fillna(1)
56
+ # Constant columns cannot identify a regression coefficient and may conflict
57
+ # with the ARIMA trend. Their future value cannot affect a learned fit.
58
+ columns = train.columns[train.nunique() > 1]
59
+ if len(columns) == 0:
60
+ return None, None
61
+ scaled = ((train[columns] - mean[columns]) / scale[columns]).clip(-12, 12)
62
+ future_row = ((numeric.iloc[[-1]][columns] - mean[columns]) / scale[columns]).clip(-12, 12)
63
+ future = pd.concat([future_row] * horizon, ignore_index=True)
64
+ return scaled, future
65
+
66
+ class BaseForecastModel(ABC):
67
+ name = 'base'
68
+
69
+ @abstractmethod
70
+ def predict(self, history: pd.Series, horizon: int=1, features: pd.DataFrame=None) -> list:
71
+ raise NotImplementedError
models/dependencies.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ fit = model.fit(method_kwargs={'maxiter': 120, 'disp': False})
80
+ if not getattr(fit, 'mle_retvals', {}).get('converged', True):
81
+ raise ValueError('ARIMA optimizer did not converge within the bounded iteration budget.')
82
+ if not __import__('numpy').isfinite(fit.params).all():
83
+ raise ValueError('ARIMA fit produced non-finite parameters.')
84
+ return fit
85
+
86
+ @contextlib.contextmanager
87
+ def quiet_fit():
88
+ with warnings.catch_warnings():
89
+ # Expected candidate-selection warnings only. Convergence is checked by
90
+ # fit_arima; unrelated warnings are not blanket-suppressed.
91
+ warnings.filterwarnings('ignore', message='Non-stationary starting autoregressive parameters.*')
92
+ warnings.filterwarnings('ignore', message='Non-invertible starting MA parameters.*')
93
+ warnings.filterwarnings('ignore', message='Maximum Likelihood optimization failed to converge.*')
94
+ warnings.filterwarnings('ignore', message='The test statistic is outside of the range.*')
95
+ yield
models/moirai_model.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ self.last_diagnostics = {}
36
+
37
+ def _get_module(self):
38
+ from uni2ts.model.moirai import MoiraiModule
39
+
40
+ if self._module is None:
41
+ with _MODULE_LOCK:
42
+ self._module = _MODULE_CACHE.get(self.checkpoint)
43
+ if self._module is None:
44
+ self._module = MoiraiModule.from_pretrained(self.checkpoint)
45
+ self._module.eval()
46
+ _MODULE_CACHE[self.checkpoint] = self._module
47
+ return self._module
48
+
49
+ def predict(
50
+ self, history: pd.Series, horizon: int = 1, features: pd.DataFrame = None
51
+ ) -> list:
52
+ validate_history_and_horizon(history, horizon, self.name)
53
+ try:
54
+ import torch
55
+ except ImportError as error:
56
+ raise ImportError('Moirai requires the optional foundation-model packages. Install requirements-foundation.txt.') from error
57
+ with _RNG_LOCK, torch.random.fork_rng(devices=[]), torch.inference_mode():
58
+ torch.manual_seed(RANDOM_SEED)
59
+ return self._predict(history, horizon, features)
60
+
61
+ def _predict(self, history, horizon, features):
62
+ values = np.asarray(history, dtype=np.float32)
63
+ if len(values) < 10:
64
+ raise ValueError("Moirai needs at least 10 candles of history.")
65
+ validate_history_and_horizon(values, horizon, self.name)
66
+ try:
67
+ from uni2ts.model.moirai import MoiraiForecast
68
+ from gluonts.dataset.pandas import PandasDataset
69
+ except ImportError as e:
70
+ raise ImportError(
71
+ f"Moirai needs the 'uni2ts' and 'gluonts' packages. Install with:\n pip install uni2ts torch\n(original error: {e})"
72
+ )
73
+ if features is not None:
74
+ validate_features(
75
+ features,
76
+ expected_len=len(values),
77
+ model_name="Moirai",
78
+ check_nan=False,
79
+ )
80
+ # Bound the context for the small checkpoint so a large dashboard
81
+ # window cannot cause an avoidable CPU-memory spike.
82
+ context_length = min(max(int(self.context_length), 32), 512)
83
+ context = values[-context_length:] if len(values) > context_length else values
84
+ step = _step_from_history(history)
85
+ end_ts = pd.Timestamp(history.index[-1]) if isinstance(history.index, pd.DatetimeIndex) else pd.Timestamp('2000-01-01')
86
+ if end_ts.tzinfo is not None:
87
+ end_ts = end_ts.tz_convert('UTC').tz_localize(None)
88
+ idx = pd.date_range(end=end_ts, periods=len(context), freq=step)
89
+ data = {"target": context}
90
+ feat_cols = []
91
+ if features is not None:
92
+ feat_context = features.iloc[-len(context) :].reset_index(drop=True)
93
+ validate_features(
94
+ feat_context, expected_len=len(context), model_name="Moirai"
95
+ )
96
+ for col in feat_context.columns:
97
+ data[col] = feat_context[col].to_numpy(dtype=np.float32)
98
+ feat_cols = list(feat_context.columns)
99
+ series_df = pd.DataFrame(data, index=idx)
100
+ dataset = PandasDataset(
101
+ series_df,
102
+ target="target",
103
+ past_feat_dynamic_real=feat_cols if feat_cols else None,
104
+ )
105
+ module = self._get_module()
106
+ forecaster = MoiraiForecast(
107
+ module=module,
108
+ prediction_length=horizon,
109
+ context_length=len(context),
110
+ patch_size="auto",
111
+ num_samples=self.num_samples,
112
+ target_dim=1,
113
+ feat_dynamic_real_dim=0,
114
+ past_feat_dynamic_real_dim=len(feat_cols),
115
+ )
116
+ predictor = forecaster.create_predictor(batch_size=1)
117
+ forecast = next(iter(predictor.predict(dataset)))
118
+ samples = np.asarray(forecast.samples, dtype=float)
119
+ if samples.ndim == 3 and samples.shape[-1] == 1:
120
+ samples = samples[..., 0]
121
+ if samples.ndim != 2 or samples.shape != (self.num_samples, horizon):
122
+ raise ValueError('Moirai returned an unexpected univariate sample/horizon shape.')
123
+ out = np.median(samples, axis=0).tolist()
124
+ if not all(np.isfinite(x) and x > 0 for x in out):
125
+ raise ValueError(
126
+ "Moirai produced a non-finite forecast (NaN/inf) for this window."
127
+ )
128
+ self.last_diagnostics = {'checkpoint': self.checkpoint, 'context_candles': len(context), 'point_statistic': 'sample median', 'seed': RANDOM_SEED, 'fallback': False}
129
+ return out
models/registry.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import threading
2
+ from collections import OrderedDict
3
+ from models.base_model import BaseForecastModel
4
+ _ORDER_SEARCH_MODELS = {'ARIMA', 'Auto-ARIMA', 'ARIMA-GARCH'}
5
+ _CACHE = OrderedDict()
6
+ _MAX_CACHE = 32
7
+ _CACHE_LOCK = threading.Lock()
8
+
9
+ def _build_model(name: str) -> BaseForecastModel:
10
+ if name == 'ARIMA':
11
+ from models.arima_model import ArimaModel
12
+ return ArimaModel()
13
+ elif name == 'Auto-ARIMA':
14
+ from models.auto_arima_model import AutoArimaModel
15
+ return AutoArimaModel()
16
+ elif name == 'ARIMA-GARCH':
17
+ from models.arima_garch_model import ArimaGarchModel
18
+ return ArimaGarchModel()
19
+ elif name == 'Moirai':
20
+ from models.moirai_model import MoiraiModel
21
+ return MoiraiModel()
22
+ elif name == 'TimesFM':
23
+ from models.timesfm_model import TimesFMModel
24
+ return TimesFMModel()
25
+ raise ValueError(f"Unknown model '{name}'. Expected one of: ARIMA, Auto-ARIMA, ARIMA-GARCH, Moirai, TimesFM.")
26
+
27
+ def get_model(name: str, symbol: str=None, timeframe: str=None, use_features: bool=False) -> BaseForecastModel:
28
+ if name in _ORDER_SEARCH_MODELS:
29
+ variant = 'featured' if use_features else 'base'
30
+ key = (name, symbol, timeframe, variant)
31
+ else:
32
+ key = name
33
+ with _CACHE_LOCK:
34
+ if key in _CACHE:
35
+ _CACHE.move_to_end(key)
36
+ return _CACHE[key]
37
+ model = _build_model(name)
38
+ if len(_CACHE) >= _MAX_CACHE:
39
+ _CACHE.popitem(last=False)
40
+ _CACHE[key] = model
41
+ return model
42
+
43
+ def fresh_model(name: str) -> BaseForecastModel:
44
+ return _build_model(name)
models/timesfm_model.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.RLock()
10
+ _COMPILED_CONTEXT = {}
11
+
12
+ class TimesFMModel(BaseForecastModel):
13
+ name = 'TimesFM'
14
+
15
+ def __init__(self, checkpoint: str=TIMESFM_CHECKPOINT, max_context: int=512):
16
+ self.checkpoint = checkpoint
17
+ self.max_context = ((min(max(int(max_context), 32), 512) + 31) // 32) * 32
18
+ self._model = None
19
+ self._compiled_context = None
20
+ self._lock = threading.Lock()
21
+ self.last_diagnostics = {}
22
+
23
+ @staticmethod
24
+ def _forecast_config(max_context: int):
25
+ import timesfm
26
+ 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)
27
+
28
+ def _load(self):
29
+ import torch
30
+ import timesfm
31
+ if self._model is None:
32
+ with _MODEL_CACHE_LOCK:
33
+ self._model = _MODEL_CACHE.get(self.checkpoint)
34
+ if self._model is None:
35
+ torch.set_float32_matmul_precision('high')
36
+ model_class = getattr(timesfm, 'TimesFM_2p5_200M_torch', None)
37
+ if model_class is None:
38
+ raise ImportError('Installed timesfm package does not expose the TimesFM 2.5 PyTorch model.')
39
+ model = model_class.from_pretrained(self.checkpoint, torch_compile=False)
40
+ model.compile(self._forecast_config(self.max_context))
41
+ self._model = model
42
+ _MODEL_CACHE[self.checkpoint] = model
43
+ _COMPILED_CONTEXT[self.checkpoint] = self.max_context
44
+ self._compiled_context = _COMPILED_CONTEXT[self.checkpoint]
45
+ return self._model
46
+
47
+ def _ensure_compiled_for(self, context_len: int):
48
+ model = self._load()
49
+ patch = getattr(model.model, 'p', 32)
50
+ limit = min(512, max(32, int(self.max_context)))
51
+ limit = min(512, ((limit + patch - 1) // patch) * patch)
52
+ self.max_context = limit
53
+ desired = min(limit, max(patch, (context_len + patch - 1) // patch * patch))
54
+ if desired != _COMPILED_CONTEXT[self.checkpoint]:
55
+ model.compile(self._forecast_config(desired))
56
+ _COMPILED_CONTEXT[self.checkpoint] = desired
57
+ self._compiled_context = desired
58
+ return model
59
+
60
+ def predict(self, history: pd.Series, horizon: int=1, features: pd.DataFrame=None) -> list:
61
+ # Cached model weights AND compilation state are shared by wrappers.
62
+ # Per-wrapper locks cannot protect that shared mutable runtime.
63
+ with self._lock, _MODEL_CACHE_LOCK:
64
+ return self._predict(history, horizon, features)
65
+
66
+ def _predict(self, history, horizon, features):
67
+ validate_history_and_horizon(history, horizon, self.name)
68
+ values = np.asarray(history, dtype=np.float32)
69
+ validate_history_and_horizon(values, horizon, self.name)
70
+ if len(values) < 32:
71
+ raise ValueError('TimesFM needs at least 32 candles of history.')
72
+ try:
73
+ __import__('timesfm')
74
+ except ImportError as e:
75
+ raise ImportError(f"TimesFM needs the 'timesfm' package. Install with:\n pip install timesfm[torch]\n(original error: {e})")
76
+ if horizon > _MAX_HORIZON:
77
+ raise ValueError(f'This TimesFM wrapper supports at most {_MAX_HORIZON} candles ahead.')
78
+ if features is not None:
79
+ validate_features(features, expected_len=len(values), model_name='TimesFM', check_nan=False)
80
+ context = values[-self.max_context:] if len(values) > self.max_context else values
81
+ model = self._ensure_compiled_for(len(context))
82
+ if features is None:
83
+ point_forecast, _quantiles = model.forecast(horizon=horizon, inputs=[context])
84
+ point_values = np.asarray(point_forecast[0], dtype=float)
85
+ else:
86
+ feat_context = features.iloc[-len(context):].reset_index(drop=True)
87
+ validate_features(feat_context, expected_len=len(context), model_name='TimesFM')
88
+ feat_future = carry_forward_future(feat_context, horizon)
89
+ dynamic_numerical_covariates = {}
90
+ for col in feat_context.columns:
91
+ # xreg is a fitted regression, so contemporaneous OHLC/indicator
92
+ # covariates leak the training target. Align features(t-1) to
93
+ # prices(t), followed by the last observed feature at the origin.
94
+ full_array = np.concatenate([feat_context[col].iloc[:-1].to_numpy(dtype=np.float32), feat_future[col].to_numpy(dtype=np.float32)])
95
+ dynamic_numerical_covariates[col] = [full_array]
96
+ try:
97
+ point_forecast, _quantiles = model.forecast_with_covariates(inputs=[context[1:]], dynamic_numerical_covariates=dynamic_numerical_covariates, xreg_mode='xreg + timesfm')
98
+ point_values = np.asarray(point_forecast[0], dtype=float)
99
+ except (ImportError, AttributeError) as e:
100
+ 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})")
101
+ if point_values.shape != (horizon,):
102
+ raise ValueError('TimesFM returned an incorrect forecast horizon/shape.')
103
+ out = point_values.tolist()
104
+ if not all(np.isfinite(x) and x > 0 for x in out):
105
+ 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.')
106
+ self.last_diagnostics = {'checkpoint': self.checkpoint, 'context_candles': len(context), 'feature_policy': 'lagged xreg; unknown future covariates persist', 'fallback': False}
107
+ return out
pipeline.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ if isinstance(self.models, str):
52
+ raise ValueError('models must be a sequence of model names, not a string.')
53
+ models = tuple(dict.fromkeys(self.models or ()))
54
+ if not models or any(name not in MODEL_NAMES for name in models):
55
+ raise ValueError('Select at least one supported model.')
56
+ if self.mode == 'Single model' and len(models) != 1:
57
+ raise ValueError('Single model mode requires exactly one model. Select Parallel models to run several.')
58
+ for name, minimum, maximum in (
59
+ ('lookback_days', 1, 730), ('window', 32, 2048), ('horizon', 1, 64),
60
+ ('step', 1, 10000), ('max_evaluations', 1, 1000),
61
+ ):
62
+ value = getattr(self, name)
63
+ try:
64
+ integral = int(value)
65
+ valid_number = np.isfinite(float(value))
66
+ except (TypeError, ValueError, OverflowError):
67
+ integral, valid_number = 0, False
68
+ if isinstance(value, (bool, np.bool_)) or not valid_number or integral != value or not minimum <= value <= maximum:
69
+ raise ValueError(f'{name} must be an integer between {minimum} and {maximum}.')
70
+ object.__setattr__(self, name, int(value))
71
+ for name in ('spread_bps', 'fee_bps', 'slippage_bps'):
72
+ value = getattr(self, name)
73
+ try:
74
+ finite = np.isfinite(value)
75
+ except TypeError:
76
+ finite = False
77
+ if isinstance(value, bool) or not finite or value < 0 or value > 1000:
78
+ raise ValueError(f'{name} must be a finite number between 0 and 1000 bps.')
79
+ object.__setattr__(self, name, float(value))
80
+ object.__setattr__(self, 'models', models)
81
+ object.__setattr__(self, 'symbol', normalize_symbol(self.symbol, self.provider, self.market))
82
+
83
+ @property
84
+ def candle_step(self):
85
+ return pd.Timedelta(minutes=MINUTES_PER_CANDLE[self.timeframe])
86
+
87
+ @property
88
+ def costs(self):
89
+ return CostModel(self.spread_bps, self.fee_bps, self.slippage_bps)
90
+
91
+
92
+ def model_readiness():
93
+ required = {
94
+ 'ARIMA': ['statsmodels'], 'Auto-ARIMA': ['statsmodels'],
95
+ 'ARIMA-GARCH': ['statsmodels', 'arch'],
96
+ 'Moirai': ['uni2ts', 'gluonts', 'torch'], 'TimesFM': ['timesfm', 'torch'],
97
+ }
98
+ rows = []
99
+ for name, packages in required.items():
100
+ missing = [package for package in packages if importlib.util.find_spec(package) is None]
101
+ rows.append({
102
+ 'Model': name,
103
+ 'Dependency status': 'Missing: ' + ', '.join(missing) if missing else 'Packages detected',
104
+ 'Runtime requirement': 'Pretrained weights download on first use; inference must succeed' if name in ('Moirai', 'TimesFM') else 'Fits actual selected history',
105
+ })
106
+ return pd.DataFrame(rows)
107
+
108
+
109
+ def prepare_history(config):
110
+ padding = feature_warmup_days(config.timeframe, minimum_days=0) if config.use_features else 0
111
+ frame = fetch_market_history(config.provider, config.market, config.symbol, config.timeframe, config.lookback_days + padding)
112
+ attrs = dict(frame.attrs)
113
+ features = None
114
+ if config.use_features:
115
+ frame, features = compute_feature_frame_for_period(frame, keep_days=config.lookback_days, timeframe=config.timeframe)
116
+ frame.attrs.update(attrs)
117
+ if len(frame) < config.window:
118
+ 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.')
119
+ return frame, features
120
+
121
+
122
+ def new_configured_model(name, config):
123
+ model = fresh_model(name)
124
+ if hasattr(model, 'refit_order_every'):
125
+ model.refit_order_every = 1
126
+ if hasattr(model, 'context_length'):
127
+ model.context_length = min(config.window, 512)
128
+ if hasattr(model, 'max_context'):
129
+ model.max_context = min(config.window, 512)
130
+ return model
131
+
132
+
133
+ def data_metadata(frame, config):
134
+ if frame is None or frame.empty:
135
+ raise ValueError('Cannot create run metadata without closed candles.')
136
+ now = pd.Timestamp.now(tz='UTC')
137
+ last = pd.Timestamp(frame.index[-1])
138
+ if last.tzinfo is None:
139
+ last = last.tz_localize('UTC')
140
+ else:
141
+ last = last.tz_convert('UTC')
142
+ if last > now + config.candle_step:
143
+ raise ValueError('The latest candle is in the future relative to the runtime clock.')
144
+ first = pd.Timestamp(frame.index[0])
145
+ if first.tzinfo is None:
146
+ first = first.tz_localize('UTC')
147
+ else:
148
+ first = first.tz_convert('UTC')
149
+ age_seconds = max(0.0, (now - (last + config.candle_step)).total_seconds())
150
+ freshness_limit = config.candle_step.total_seconds() * 3
151
+ stale = age_seconds > freshness_limit
152
+ return {
153
+ 'provider': config.provider, 'market': config.market, 'symbol': config.symbol,
154
+ 'timeframe': config.timeframe, 'closed_candles': len(frame),
155
+ 'first_candle_open_utc': first.isoformat(),
156
+ 'last_candle_open_utc': last.isoformat(),
157
+ 'last_candle_close_utc': (last + config.candle_step).isoformat(),
158
+ 'fetched_at_utc': now.isoformat(), 'seconds_since_last_candle_close': round(age_seconds, 3),
159
+ 'stale': stale,
160
+ 'freshness_status': 'STALE' if stale else 'FRESH',
161
+ 'freshness_policy': f'latest closed candle must be no older than {freshness_limit:g} seconds',
162
+ 'noncontinuous_intervals': int((frame.index.to_series().diff().dropna() != config.candle_step).sum()),
163
+ 'provider_details': dict(frame.attrs),
164
+ }
165
+
166
+
167
+ def _map_models(config, worker):
168
+ if config.mode == 'Parallel models' and len(config.models) > 1:
169
+ # Loading both foundation checkpoints at once can exhaust a free CPU
170
+ # Space. They remain independent model executions, but are scheduled
171
+ # serially when either heavyweight model is selected.
172
+ foundation = {'Moirai', 'TimesFM'}
173
+ workers = 1 if foundation.intersection(config.models) else min(2, len(config.models))
174
+ with ThreadPoolExecutor(max_workers=workers) as pool:
175
+ return list(pool.map(worker, config.models))
176
+ return [worker(name) for name in config.models]
177
+
178
+
179
+ def live_result(config, frame=None, features=None):
180
+ if frame is None:
181
+ frame, features = prepare_history(config)
182
+ elif config.use_features and features is None:
183
+ raise ValueError('This configuration requires an aligned feature frame; pass features or fetch history through prepare_history.')
184
+ elif not config.use_features:
185
+ features = None
186
+ history = frame['Close'].iloc[-config.window:]
187
+ feature_window = features.iloc[-config.window:].reset_index(drop=True) if features is not None else None
188
+ current = float(history.iloc[-1])
189
+ metadata = data_metadata(frame, config)
190
+
191
+ def forecast_one(name):
192
+ try:
193
+ model = new_configured_model(name, config)
194
+ values = model.predict(history, horizon=config.horizon, features=feature_window)
195
+ if len(values) != config.horizon or not np.isfinite(values).all() or any(value <= 0 for value in values):
196
+ raise ValueError('Model did not return a positive, finite forecast for the full horizon.')
197
+ predicted = float(values[-1])
198
+ return {
199
+ 'Model': name, 'Status': 'STALE — historical forecast only' if metadata['stale'] else 'OK',
200
+ 'Current close': current, 'Predicted close': predicted,
201
+ 'Change (%)': (predicted / current - 1) * 100,
202
+ 'Direction': price_direction(predicted, current).upper(),
203
+ 'Order': str(getattr(model, '_cached_order', 'pretrained')),
204
+ 'Error': '', 'forecast_path': [float(value) for value in values],
205
+ }
206
+ except Exception as error:
207
+ return {'Model': name, 'Status': 'FAILED', 'Current close': current, 'Predicted close': None,
208
+ 'Change (%)': None, 'Direction': None, 'Order': '', 'Error': str(error), 'forecast_path': []}
209
+
210
+ forecasts = _map_models(config, forecast_one)
211
+ successful_forecasts = [item for item in forecasts if item.get('forecast_path')]
212
+ if successful_forecasts:
213
+ directions = [item['Direction'] for item in successful_forecasts]
214
+ consensus = directions[0] if all(direction == directions[0] for direction in directions) else 'MIXED'
215
+ else:
216
+ consensus = 'UNAVAILABLE'
217
+ try:
218
+ signal = latest_maythos_output(frame)
219
+ signal = {
220
+ **signal,
221
+ 'forecast_models_completed': len(successful_forecasts),
222
+ 'forecast_models_requested': len(config.models),
223
+ 'forecast_consensus': consensus,
224
+ 'forecast_agrees_with_signal': (
225
+ consensus == {'BUY': 'UP', 'SELL': 'DOWN'}.get(signal.get('direction'))
226
+ ),
227
+ 'forecast_coverage_pct': round(100.0 * len(successful_forecasts) / len(config.models), 2),
228
+ 'live_actionable': (
229
+ not metadata['stale'] and len(successful_forecasts) == len(config.models) and
230
+ consensus in ('UP', 'DOWN') and
231
+ consensus == {'BUY': 'UP', 'SELL': 'DOWN'}.get(signal.get('direction')) and
232
+ not signal['blocked_flag'] and
233
+ signal['execution_suitability'] == 'strong' and
234
+ float(signal.get('confidence', 0.0)) > 0.69
235
+ ),
236
+ }
237
+ if signal['live_actionable']:
238
+ targets = [float(item['Predicted close']) for item in successful_forecasts if item.get('Predicted close') is not None]
239
+ forecast_target = float(np.median(targets)) if targets else None
240
+ levels = build_trade_levels(
241
+ frame, signal['direction'], config.market, config.symbol,
242
+ forecast_target=forecast_target,
243
+ forecast_only=True,
244
+ minimum_edge_atr=0.20,
245
+ minimum_risk_reward=1.20,
246
+ use_structure_stop=False,
247
+ minimum_edge_bps=(
248
+ config.spread_bps + config.fee_bps + 2.0 * config.slippage_bps
249
+ ) * 1.25,
250
+ )
251
+ if levels is None:
252
+ signal['live_actionable'] = False
253
+ signal['trade_levels_error'] = 'No causal ATR/swing structure offered a valid positive-risk target.'
254
+ else:
255
+ signal['trade_levels'] = levels.as_dict()
256
+ except Exception as error:
257
+ signal = {'error': str(error), 'live_actionable': False}
258
+ return {'config': config, 'frame': frame, 'features': features, 'metadata': metadata, 'forecasts': forecasts, 'signal': signal}
259
+
260
+
261
+ def backtest_result(config, frame=None, features=None):
262
+ if frame is None:
263
+ frame, features = prepare_history(config)
264
+ elif config.use_features and features is None:
265
+ raise ValueError('This configuration requires an aligned feature frame; pass features or fetch history through prepare_history.')
266
+ elif not config.use_features:
267
+ features = None
268
+ if len(frame) < config.window + config.horizon:
269
+ raise ValueError(f'Backtesting needs at least {config.window + config.horizon} closed candles; provider returned {len(frame)}.')
270
+
271
+ def evaluate_one(name):
272
+ try:
273
+ model = new_configured_model(name, config)
274
+ details = backtest_model(
275
+ frame, model, window=config.window, horizon=config.horizon,
276
+ step=config.step, features_df=features,
277
+ max_evaluations=config.max_evaluations, timeframe=config.timeframe,
278
+ market=config.market, symbol=config.symbol, costs=config.costs,
279
+ )
280
+ summary = summarize_backtest(details, name)
281
+ except Exception as error:
282
+ details = pd.DataFrame([{
283
+ 'datetime': None, 'current_close': np.nan,
284
+ 'predicted_close': np.nan, 'actual_close': np.nan,
285
+ 'predicted_direction': None, 'actual_direction': None, 'correct': None,
286
+ 'continuous_target': False, 'trade_taken': False, 'entry': np.nan,
287
+ 'stop_loss': np.nan, 'take_profit': np.nan, 'risk_reward': np.nan,
288
+ 'atr': np.nan, 'outcome': 'MODEL_FAILED', 'exit_price': np.nan,
289
+ 'net_r': np.nan, 'bars_held': 0, 'error': str(error),
290
+ }])
291
+ summary = summarize_backtest(details, name)
292
+ summary['error'] = str(error)
293
+ summary['Model'] = summary.pop('model')
294
+ valid = details.dropna(subset=['correct'])
295
+ summary['evaluations_attempted'] = len(details)
296
+ summary['coverage_pct'] = round(100 * len(valid) / len(details), 2) if len(details) else None
297
+ summary['persistence_mae'] = float(np.abs(valid['current_close'] - valid['actual_close']).mean()) if len(valid) else None
298
+ summary['cost_spread_bps'] = config.spread_bps
299
+ summary['cost_fee_bps'] = config.fee_bps
300
+ summary['cost_slippage_bps'] = config.slippage_bps
301
+ summary['status'] = 'OK' if summary['failed_predictions'] == 0 else ('PARTIAL' if len(valid) else 'FAILED')
302
+ details.insert(0, 'Model', name)
303
+ return summary, details
304
+
305
+ evaluated = _map_models(config, evaluate_one)
306
+ try:
307
+ signal_details, signal_summary = backtest_maythos(
308
+ frame, window=config.window, horizon=config.horizon, step=config.step,
309
+ max_evaluations=config.max_evaluations, timeframe=config.timeframe,
310
+ market=config.market, symbol=config.symbol, costs=config.costs,
311
+ )
312
+ except Exception as error:
313
+ signal_details = pd.DataFrame()
314
+ signal_summary = {'engine': 'MAYTHOS', 'error': str(error)}
315
+ return {
316
+ 'config': config, 'frame': frame, 'features': features,
317
+ 'metadata': data_metadata(frame, config),
318
+ 'forecast_summary': pd.DataFrame([summary for summary, _ in evaluated]),
319
+ 'forecast_details': pd.concat([details for _, details in evaluated], ignore_index=True),
320
+ 'signal_summary': pd.DataFrame([signal_summary]), 'signal_details': signal_details,
321
+ }
322
+
323
+
324
+ def export_run(result, kind):
325
+ """Export the exact data, parameters, results and environment for auditing."""
326
+ run_id = pd.Timestamp.now(tz='UTC').strftime('%Y%m%dT%H%M%SZ') + '_' + uuid.uuid4().hex[:8]
327
+ directory = Path(OUTPUT_DIR) / f'{kind}_{run_id}'
328
+ Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True)
329
+ directory.mkdir(parents=True, exist_ok=False)
330
+ snapshot = result['frame'].to_csv(index_label='candle_open_utc', float_format='%.17g')
331
+ (directory / 'candles.csv').write_text(snapshot, encoding='utf-8')
332
+ if result['features'] is not None:
333
+ result['features'].to_csv(directory / 'features.csv', index=False, float_format='%.17g')
334
+ packages = {}
335
+ for name in ('numpy', 'pandas', 'scipy', 'statsmodels', 'arch', 'yfinance', 'gradio', 'torch', 'uni2ts', 'timesfm'):
336
+ try:
337
+ packages[name] = importlib.metadata.version(name)
338
+ except importlib.metadata.PackageNotFoundError:
339
+ packages[name] = None
340
+ manifest = {
341
+ 'kind': kind, 'config': asdict(result['config']), 'data': result['metadata'],
342
+ 'candles_sha256': hashlib.sha256(snapshot.encode('utf-8')).hexdigest(),
343
+ 'random_seed': RANDOM_SEED, 'python': platform.python_version(), 'packages': packages,
344
+ 'order_policy': 'ARIMA-family reselects order at each origin from training-window data only',
345
+ 'feature_future_policy': 'Last observed covariates carried forward, never future actual indicators',
346
+ 'evaluation': 'Walk-forward direction/price error plus causal candle-path SL/TP trade simulation; costs are explicit assumptions',
347
+ 'trade_costs': result['config'].costs.as_dict(),
348
+ }
349
+ (directory / 'manifest.json').write_text(json.dumps(manifest, indent=2, default=str, allow_nan=False), encoding='utf-8')
350
+ if kind == 'live':
351
+ pd.DataFrame(result['forecasts']).to_csv(directory / 'forecasts.csv', index=False)
352
+ (directory / 'maythos.json').write_text(json.dumps(result['signal'], indent=2, default=str), encoding='utf-8')
353
+ else:
354
+ for key in ('forecast_summary', 'forecast_details', 'signal_summary', 'signal_details'):
355
+ result[key].to_csv(directory / f'{key}.csv', index=False)
356
+ path = directory.with_suffix('.zip')
357
+ with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as archive:
358
+ for file in sorted(directory.iterdir()):
359
+ archive.write(file, file.name)
360
+ 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,4 @@
 
 
 
 
 
1
+ # Hugging Face Spaces/core runtime. Foundation-model dependencies are
2
+ # deliberately opt-in via requirements-foundation.txt; a missing optional
3
+ # model is reported as failed instead of being replaced by fake output.
4
+ -r requirements-core.txt
risk_management.py ADDED
@@ -0,0 +1,374 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ try:
42
+ value = float(value)
43
+ except (TypeError, ValueError):
44
+ value = float('nan')
45
+ if isinstance(getattr(self, name), (bool, np.bool_)) or not np.isfinite(value) or value < 0 or value > 1000:
46
+ raise ValueError(f'{name} must be a finite number between 0 and 1000 bps.')
47
+ object.__setattr__(self, name, value)
48
+
49
+ def as_dict(self):
50
+ return asdict(self)
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class TradeLevels:
55
+ direction: str
56
+ entry: float
57
+ stop_loss: float
58
+ take_profit: float
59
+ risk_per_unit: float
60
+ reward_per_unit: float
61
+ risk_reward: float
62
+ atr: float
63
+ swing_support: float
64
+ swing_resistance: float
65
+ tick_size: float
66
+ precision_digits: int
67
+ level_basis: str
68
+
69
+ def as_dict(self):
70
+ return asdict(self)
71
+
72
+
73
+ def _finite_positive(value) -> bool:
74
+ return np.isfinite(value) and float(value) > 0
75
+
76
+
77
+ def infer_tick_size(frame: pd.DataFrame, market: str, symbol: str) -> tuple[float, int]:
78
+ """Infer the quote increment from provider metadata or real observations.
79
+
80
+ Binance adapters can provide an exact exchange filter through
81
+ ``frame.attrs['price_tick_size']``. Forex uses the conventional quoted
82
+ pip precision (five decimals, three for JPY quotes). For other feeds the
83
+ most precise decimal scale present in recent real closes is retained.
84
+ """
85
+ if market not in ('Forex', 'Crypto'):
86
+ raise ValueError('market must be Forex or Crypto.')
87
+ if frame is None or 'Close' not in frame or frame.empty:
88
+ raise ValueError('Cannot infer tick size without close observations.')
89
+ exact = frame.attrs.get('price_tick_size')
90
+ if exact is not None and _finite_positive(float(exact)):
91
+ tick = float(exact)
92
+ digits = max(0, min(12, int(math.ceil(-math.log10(tick)))))
93
+ return tick, digits
94
+ if market == 'Forex':
95
+ clean = str(symbol).upper().replace('=X', '').replace('/', '')
96
+ digits = 3 if clean.endswith('JPY') else 5
97
+ return 10.0 ** (-digits), digits
98
+ decimals = 0
99
+ for value in pd.to_numeric(frame['Close'].tail(100), errors='coerce').dropna():
100
+ exponent = Decimal(str(float(value))).as_tuple().exponent
101
+ decimals = max(decimals, min(8, max(0, -exponent)))
102
+ decimals = min(decimals, 8)
103
+ tick = 10.0 ** (-decimals)
104
+ return tick, decimals
105
+
106
+
107
+ def _round_to_tick(value: float, tick: float, direction: str) -> float:
108
+ if direction not in ('down', 'up') or not _finite_positive(tick) or not np.isfinite(value):
109
+ raise ValueError('Tick rounding needs a finite value, positive tick, and up/down direction.')
110
+ scaled = float(value) / tick
111
+ if direction == 'down':
112
+ rounded = math.floor(scaled + 1e-12) * tick
113
+ else:
114
+ rounded = math.ceil(scaled - 1e-12) * tick
115
+ return float(round(rounded, max(0, int(round(-math.log10(tick)))) + 2))
116
+
117
+
118
+ def _true_range(frame: pd.DataFrame) -> pd.Series:
119
+ previous_close = frame['Close'].shift(1)
120
+ return pd.concat(
121
+ [frame['High'] - frame['Low'],
122
+ (frame['High'] - previous_close).abs(),
123
+ (frame['Low'] - previous_close).abs()], axis=1,
124
+ ).max(axis=1)
125
+
126
+
127
+ def average_true_range(frame: pd.DataFrame, period: int = ATR_PERIOD) -> float:
128
+ if len(frame) < period:
129
+ return float('nan')
130
+ tr = _true_range(frame)
131
+ # Wilder's ATR seeds with the arithmetic mean of the first `period` true
132
+ # ranges, then applies the recursive moving-average update. pandas' EWM
133
+ # ``adjust=False`` starts from the first observation, which is a different
134
+ # estimator and materially changes short causal trade levels.
135
+ values = tr.to_numpy(dtype=float)
136
+ if not np.isfinite(values[:period]).all():
137
+ return float('nan')
138
+ atr = float(values[:period].mean())
139
+ for value in values[period:]:
140
+ if not np.isfinite(value):
141
+ return float('nan')
142
+ atr = (atr * (period - 1) + float(value)) / period
143
+ value = atr
144
+ return value if _finite_positive(value) else float('nan')
145
+
146
+
147
+ def build_trade_levels(
148
+ frame: pd.DataFrame,
149
+ direction: str,
150
+ market: str,
151
+ symbol: str,
152
+ forecast_target: float | None = None,
153
+ atr_period: int = ATR_PERIOD,
154
+ swing_lookback: int = SWING_LOOKBACK,
155
+ forecast_only: bool = False,
156
+ minimum_edge_atr: float = 0.0,
157
+ minimum_risk_reward: float = MIN_RISK_REWARD,
158
+ use_structure_stop: bool = True,
159
+ minimum_edge_bps: float = 0.0,
160
+ ) -> TradeLevels | None:
161
+ """Build causal, volatility-aware levels for one closed-candle signal.
162
+
163
+ The stop is the farther of an ATR noise stop and a structure stop. The
164
+ target must be a real opposite-side structure level or a compatible real
165
+ model forecast, and signals without at least 1R structural reward are
166
+ rejected instead of receiving an arbitrary target.
167
+ """
168
+ direction = str(direction or '').upper()
169
+ try:
170
+ forecast_value = None if forecast_target is None else float(forecast_target)
171
+ except (TypeError, ValueError):
172
+ forecast_value = None
173
+ if (not np.isfinite(minimum_edge_atr) or minimum_edge_atr < 0 or
174
+ not np.isfinite(minimum_risk_reward) or minimum_risk_reward <= 0 or
175
+ not np.isfinite(minimum_edge_bps) or minimum_edge_bps < 0):
176
+ raise ValueError('minimum_edge_atr/minimum_edge_bps must be non-negative and minimum_risk_reward must be positive.')
177
+ if (isinstance(atr_period, bool) or not isinstance(atr_period, int) or atr_period < 1 or
178
+ isinstance(swing_lookback, bool) or not isinstance(swing_lookback, int) or swing_lookback < 1):
179
+ raise ValueError('atr_period and swing_lookback must be positive integers.')
180
+ if direction not in ('BUY', 'SELL') or frame is None or len(frame) < max(atr_period, swing_lookback) + 1:
181
+ return None
182
+ required = {'High', 'Low', 'Close'}
183
+ if not required.issubset(frame.columns):
184
+ return None
185
+ numeric = frame[list(required)].apply(pd.to_numeric, errors='coerce')
186
+ if not np.isfinite(numeric.to_numpy(dtype=float)).all() or (numeric <= 0).any().any():
187
+ return None
188
+ entry = float(frame['Close'].iloc[-1])
189
+ atr = average_true_range(frame, atr_period)
190
+ if not _finite_positive(atr) or not _finite_positive(entry):
191
+ return None
192
+ tick, precision_digits = infer_tick_size(frame, market, symbol)
193
+ forecast_used = False
194
+ # Use only candles before the signal candle for structural levels. This
195
+ # makes the swing level stable at the exact point the order is created.
196
+ structure = frame.iloc[:-1].tail(swing_lookback)
197
+ support = float(structure['Low'].min())
198
+ resistance = float(structure['High'].max())
199
+ buffer = max(ATR_STRUCTURE_BUFFER * atr, 2.0 * tick)
200
+ if direction == 'BUY':
201
+ atr_stop = entry - ATR_STOP_MULTIPLIER * atr
202
+ stop_raw = min(atr_stop, support - buffer) if use_structure_stop else atr_stop
203
+ forecast_valid = (forecast_value is not None and
204
+ np.isfinite(forecast_value) and forecast_value > 0 and forecast_value > entry)
205
+ if forecast_only and not forecast_valid:
206
+ return None
207
+ # A model target is an explicit horizon hypothesis. Do not silently
208
+ # extend it to a farther swing level, which turns a one-bar forecast
209
+ # into a timeout-prone structural trade.
210
+ target_raw = forecast_value if forecast_valid else resistance + buffer
211
+ forecast_used = forecast_valid
212
+ stop = _round_to_tick(stop_raw, tick, 'down')
213
+ target = _round_to_tick(target_raw, tick, 'up')
214
+ risk = entry - stop
215
+ reward = target - entry
216
+ else:
217
+ atr_stop = entry + ATR_STOP_MULTIPLIER * atr
218
+ stop_raw = max(atr_stop, resistance + buffer) if use_structure_stop else atr_stop
219
+ forecast_valid = (forecast_value is not None and
220
+ np.isfinite(forecast_value) and forecast_value > 0 and forecast_value < entry)
221
+ if forecast_only and not forecast_valid:
222
+ return None
223
+ target_raw = forecast_value if forecast_valid else support - buffer
224
+ forecast_used = forecast_valid
225
+ stop = _round_to_tick(stop_raw, tick, 'up')
226
+ target = _round_to_tick(target_raw, tick, 'down')
227
+ risk = stop - entry
228
+ reward = entry - target
229
+ if not (_finite_positive(stop) and _finite_positive(target) and
230
+ _finite_positive(risk) and _finite_positive(reward)):
231
+ return None
232
+ rr = reward / risk
233
+ edge_atr = reward / atr
234
+ edge_bps = reward / entry * 10000.0
235
+ if (not np.isfinite(rr) or rr < minimum_risk_reward or
236
+ not np.isfinite(edge_atr) or edge_atr < minimum_edge_atr or
237
+ not np.isfinite(edge_bps) or edge_bps < minimum_edge_bps):
238
+ return None
239
+ return TradeLevels(
240
+ direction=direction, entry=entry, stop_loss=stop, take_profit=target,
241
+ risk_per_unit=float(risk), reward_per_unit=float(reward),
242
+ risk_reward=float(rr), atr=float(atr), swing_support=support,
243
+ swing_resistance=resistance, tick_size=tick,
244
+ precision_digits=precision_digits,
245
+ level_basis=(
246
+ 'Wilder ATR + prior closed-candle structure + forecast-compatible target'
247
+ if forecast_used else
248
+ 'Wilder ATR + prior closed-candle swing structure'
249
+ ),
250
+ )
251
+
252
+
253
+ def _execution_price(raw_price: float, direction: str, is_entry: bool, costs: CostModel) -> float:
254
+ half_spread = costs.spread_bps / 2.0
255
+ adjustment = (half_spread + costs.slippage_bps) / 10000.0
256
+ sign = 1.0 if direction == 'BUY' else -1.0
257
+ if is_entry:
258
+ return raw_price * (1.0 + sign * adjustment)
259
+ return raw_price * (1.0 - sign * adjustment)
260
+
261
+
262
+ def _net_r(entry: float, exit_price: float, direction: str, risk: float, costs: CostModel) -> float:
263
+ entry_fill = _execution_price(entry, direction, True, costs)
264
+ exit_fill = _execution_price(exit_price, direction, False, costs)
265
+ sign = 1.0 if direction == 'BUY' else -1.0
266
+ gross = sign * (exit_fill - entry_fill)
267
+ # CostModel.fee_bps is explicitly a round-trip assumption. Apply it once
268
+ # to the average filled notional rather than charging the same round-trip
269
+ # rate independently to both entry and exit.
270
+ fees = (costs.fee_bps / 10000.0) * ((entry_fill + exit_fill) / 2.0)
271
+ return float((gross - fees) / risk)
272
+
273
+
274
+ def simulate_trade(levels: TradeLevels, path: pd.DataFrame, costs: CostModel) -> dict:
275
+ """Resolve SL/TP from actual future candle high/low data.
276
+
277
+ If both levels are touched within one OHLC candle, SL is deliberately
278
+ chosen first because intrabar ordering is unavailable and that is the
279
+ conservative policy requested by the dashboard.
280
+ """
281
+ if not isinstance(levels, TradeLevels):
282
+ raise TypeError('simulate_trade needs TradeLevels from build_trade_levels.')
283
+ if not isinstance(costs, CostModel):
284
+ raise TypeError('simulate_trade needs a CostModel.')
285
+ if path is None or path.empty:
286
+ return {'outcome': 'NO_PATH', 'exit_price': None, 'net_r': None, 'bars_held': 0}
287
+ required = {'High', 'Low', 'Close'}
288
+ if not required.issubset(path.columns):
289
+ raise ValueError(f'Trade path is missing columns: {sorted(required - set(path.columns))}.')
290
+ path_required = set(required)
291
+ if 'Open' in path.columns:
292
+ path_required.add('Open')
293
+ numeric = path[sorted(path_required)].apply(pd.to_numeric, errors='coerce')
294
+ if not np.isfinite(numeric.to_numpy(dtype=float)).all():
295
+ raise ValueError('Trade path contains non-finite OHLC values.')
296
+ if (numeric['High'] < numeric[['Low', 'Close']].max(axis=1)).any() or (numeric['Low'] > numeric[['High', 'Close']].min(axis=1)).any():
297
+ raise ValueError('Trade path contains invalid OHLC geometry.')
298
+ if 'Open' in numeric and ((numeric['Open'] > numeric['High']) | (numeric['Open'] < numeric['Low'])).any():
299
+ raise ValueError('Trade path contains an open outside its candle range.')
300
+ direction = levels.direction
301
+ for offset, (timestamp, candle) in enumerate(path.iterrows(), start=1):
302
+ high = float(candle['High'])
303
+ low = float(candle['Low'])
304
+ opening = float(candle['Open']) if 'Open' in candle else None
305
+ if direction == 'BUY':
306
+ if opening is not None and opening <= levels.stop_loss:
307
+ exit_price, outcome = opening, 'SL'
308
+ elif opening is not None and opening >= levels.take_profit:
309
+ exit_price, outcome = opening, 'TP'
310
+ elif low <= levels.stop_loss:
311
+ exit_price, outcome = levels.stop_loss, 'SL'
312
+ elif high >= levels.take_profit:
313
+ exit_price, outcome = levels.take_profit, 'TP'
314
+ else:
315
+ continue
316
+ else:
317
+ if opening is not None and opening >= levels.stop_loss:
318
+ exit_price, outcome = opening, 'SL'
319
+ elif opening is not None and opening <= levels.take_profit:
320
+ exit_price, outcome = opening, 'TP'
321
+ elif high >= levels.stop_loss:
322
+ exit_price, outcome = levels.stop_loss, 'SL'
323
+ elif low <= levels.take_profit:
324
+ exit_price, outcome = levels.take_profit, 'TP'
325
+ else:
326
+ continue
327
+ return {
328
+ 'outcome': outcome, 'exit_price': float(exit_price),
329
+ 'exit_timestamp': timestamp.isoformat() if hasattr(timestamp, 'isoformat') else str(timestamp),
330
+ 'net_r': _net_r(levels.entry, exit_price, direction, levels.risk_per_unit, costs),
331
+ 'bars_held': offset,
332
+ }
333
+ timestamp = path.index[-1]
334
+ exit_price = float(path['Close'].iloc[-1])
335
+ return {
336
+ 'outcome': 'TIMEOUT', 'exit_price': exit_price,
337
+ 'exit_timestamp': timestamp.isoformat() if hasattr(timestamp, 'isoformat') else str(timestamp),
338
+ 'net_r': _net_r(levels.entry, exit_price, direction, levels.risk_per_unit, costs),
339
+ 'bars_held': len(path),
340
+ }
341
+
342
+
343
+ def summarize_trades(details: pd.DataFrame) -> dict:
344
+ if details is None or details.empty or 'trade_taken' not in details:
345
+ return {
346
+ 'trades': 0, 'wins': 0, 'losses': 0, 'timeouts': 0,
347
+ 'win_rate_pct': None, 'profit_factor': None,
348
+ 'expectancy_r': None, 'max_drawdown_r': None, 'average_r': None,
349
+ }
350
+ trades = details[details['trade_taken'] & details['net_r'].notna()].copy()
351
+ if trades.empty:
352
+ return {
353
+ 'trades': 0, 'wins': 0, 'losses': 0, 'timeouts': 0,
354
+ 'win_rate_pct': None, 'profit_factor': None,
355
+ 'expectancy_r': None, 'max_drawdown_r': None, 'average_r': None,
356
+ }
357
+ returns = trades['net_r'].astype(float).to_numpy()
358
+ wins = int((trades['outcome'] == 'TP').sum())
359
+ losses = int((trades['outcome'] == 'SL').sum())
360
+ timeouts = int((trades['outcome'] == 'TIMEOUT').sum())
361
+ gross_profit = float(returns[returns > 0].sum())
362
+ gross_loss = float(-returns[returns < 0].sum())
363
+ profit_factor = gross_profit / gross_loss if gross_loss > 0 else (float('inf') if gross_profit > 0 else None)
364
+ equity = np.cumsum(returns)
365
+ peaks = np.maximum.accumulate(np.r_[0.0, equity])[1:]
366
+ drawdown = peaks - equity
367
+ return {
368
+ 'trades': int(len(trades)), 'wins': wins, 'losses': losses, 'timeouts': timeouts,
369
+ 'win_rate_pct': round(100.0 * wins / len(trades), 2),
370
+ 'profit_factor': round(profit_factor, 6) if profit_factor is not None and np.isfinite(profit_factor) else profit_factor,
371
+ 'expectancy_r': round(float(returns.mean()), 6),
372
+ 'max_drawdown_r': round(float(drawdown.max()) if len(drawdown) else 0.0, 6),
373
+ 'average_r': round(float(returns.mean()), 6),
374
+ }
setup_windows.bat ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ".venv\Scripts\python.exe" -m pip check
20
+ if errorlevel 1 (
21
+ echo Dependency consistency check failed.
22
+ pause
23
+ exit /b 1
24
+ )
25
+ echo Core setup complete. Use start_windows.bat. For Moirai and TimesFM, also run install_foundation_windows.bat.
26
+ 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,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from unittest.mock import patch
10
+
11
+ from backtest.backtester import backtest_model
12
+ from maythos import AdaptiveScoringEngine, BollingerEngine, Candle, MAYTHOS, RSIEngine, SupportResistanceEngine
13
+ from data.providers import _validate_frame
14
+ from data.validation import DataFetchError
15
+ from models.base_model import causal_exogenous
16
+ from pipeline import RunConfig, backtest_result
17
+ from risk_management import (
18
+ CostModel, TradeLevels, average_true_range, simulate_trade, summarize_trades,
19
+ )
20
+ from tests.test_risk_management import _history
21
+
22
+
23
+ @pytest.mark.parametrize('sign', [1, -1])
24
+ def test_forecast_direction_reaches_trade_execution(sign):
25
+ class AnalyticalForecast:
26
+ def predict(self, history, horizon=1, features=None):
27
+ return [float(history.iloc[-1]) + sign * 10] * horizon
28
+
29
+ rows = backtest_model(_history(80), AnalyticalForecast(), window=40,
30
+ horizon=2, step=10, timeframe='5m')
31
+ assert rows['trade_taken'].all()
32
+ assert rows['net_r'].notna().all()
33
+
34
+
35
+ def levels(direction='BUY'):
36
+ return TradeLevels(direction, 100, 90 if direction == 'BUY' else 110,
37
+ 120 if direction == 'BUY' else 80, 10, 20, 2,
38
+ 2, 90, 120, 0.01, 2, 'analytical fixture')
39
+
40
+
41
+ def test_round_trip_fee_is_not_charged_twice():
42
+ path = pd.DataFrame({'Open': [100], 'High': [101], 'Low': [99], 'Close': [100]},
43
+ index=pd.date_range('2026-01-01', periods=1, tz='UTC'))
44
+ result = simulate_trade(levels(), path, CostModel(0, 10, 0))
45
+ # A 10 bps ROUND TRIP fee on a 100-unit notional is 0.1, or 0.01R.
46
+ assert result['net_r'] == pytest.approx(-0.01)
47
+
48
+
49
+ def test_atr_uses_arithmetic_wilder_seed():
50
+ frame = pd.DataFrame({'High': [101, 103, 105, 109],
51
+ 'Low': [99, 97, 95, 91], 'Close': [100] * 4})
52
+ # TR = 2,6,10,18. Seed ATR(3)=6, then (2*6+18)/3=10.
53
+ assert average_true_range(frame, 3) == pytest.approx(10)
54
+
55
+
56
+ @pytest.mark.parametrize('direction,opening', [('BUY', 85), ('SELL', 115)])
57
+ def test_stop_gap_exits_at_available_open(direction, opening):
58
+ path = pd.DataFrame({'Open': [opening], 'High': [opening+1],
59
+ 'Low': [opening-1], 'Close': [opening]},
60
+ index=pd.date_range('2026-01-01', periods=1, tz='UTC'))
61
+ result = simulate_trade(levels(direction), path, CostModel(0, 0, 0))
62
+ assert result['outcome'] == 'SL'
63
+ assert result['exit_price'] == opening
64
+ assert result['net_r'] == pytest.approx(-1.5)
65
+
66
+
67
+ def test_win_loss_counts_reconcile_with_net_profit():
68
+ frame = pd.DataFrame({'trade_taken': [True]*3, 'net_r': [-0.1, 0.3, -1.0],
69
+ 'outcome': ['TP', 'TIMEOUT', 'SL']})
70
+ summary = summarize_trades(frame)
71
+ assert summary['wins'] == 1
72
+ assert summary['losses'] == 1
73
+ assert summary['win_rate_pct'] == pytest.approx(33.33)
74
+
75
+
76
+ def test_invalid_intermediate_forecast_is_not_scored():
77
+ class InvalidPath:
78
+ def predict(self, history, horizon=1, features=None):
79
+ return [np.nan, float(history.iloc[-1]) + 10]
80
+
81
+ rows = backtest_model(_history(80), InvalidPath(), window=40, horizon=2, step=10)
82
+ assert rows['correct'].isna().all()
83
+ assert rows['error'].notna().all()
84
+
85
+
86
+ def test_flat_maythos_rsi_is_neutral():
87
+ engine = RSIEngine()
88
+ for i in range(30):
89
+ result = engine.update(Candle(1_700_000_000+i*60, 100, 100, 100, 100), {})
90
+ assert result['rsi'] == 50
91
+ assert not result['rsi_ob_flag']
92
+
93
+
94
+ def test_bollinger_upper_touch_is_bearish_confirmation():
95
+ engine = BollingerEngine()
96
+ for i in range(20):
97
+ close = 100 if i < 19 else 110
98
+ result = engine.update(Candle(1_700_000_000+i*60, close, close+1, close-1, close))
99
+ assert result['upper_touch']
100
+ assert result['bb_confirmation'] < 0
101
+
102
+
103
+ def test_resistance_confirmation_preserves_bearish_sign():
104
+ engine = SupportResistanceEngine()
105
+ engine._levels = {110.0: {'strength': 0.8, 'type': 'resistance', 'age': 0}}
106
+ result = engine.update(Candle(1_700_000_000, 109.5, 110, 109, 109.5), {})
107
+ assert result['at_resistance']
108
+ assert result['sr_confirmation'] < 0
109
+
110
+
111
+ def test_causal_exogenous_lags_target_features_and_persists_origin_row():
112
+ features = pd.DataFrame({'indicator': [10.0, 20.0, 30.0, 40.0]})
113
+ train, future = causal_exogenous(features, horizon=2, model_name='fixture')
114
+ assert len(train) == 3
115
+ assert len(future) == 2
116
+ assert train.iloc[0, 0] != train.iloc[-1, 0]
117
+ assert future.iloc[0, 0] == future.iloc[1, 0]
118
+ # The first future regressor is derived from the last observed row, not a
119
+ # realized future feature.
120
+ assert future.iloc[0, 0] > train.iloc[-1, 0]
121
+
122
+
123
+ def test_provider_rejects_invalid_ohlcv_without_repairing_it():
124
+ frame = pd.DataFrame(
125
+ {'Open': [100.0], 'High': [99.0], 'Low': [98.0], 'Close': [98.5], 'Volume': [1.0]},
126
+ index=pd.date_range('2026-01-01', periods=1, tz='UTC'),
127
+ )
128
+ with pytest.raises(DataFetchError):
129
+ _validate_frame(frame)
130
+
131
+
132
+ def test_open_and_duplicate_candles_do_not_advance_maythos_warmup():
133
+ engine = MAYTHOS()
134
+ first = Candle(1_700_000_000, 100, 101, 99, 100)
135
+ assert engine.validate(engine.tick(first)) == []
136
+ count = engine.tick_count
137
+ forming = Candle(1_700_000_060, 100, 101, 99, 100, is_closed=False)
138
+ assert engine.tick(forming)['blocked_flag']
139
+ assert engine.tick_count == count
140
+ assert engine.tick(first)['blocked_flag']
141
+ assert engine.tick_count == count
142
+
143
+
144
+ def test_candle_with_non_positive_quote_is_invalid_and_blocked():
145
+ candle = Candle(1_700_000_000, 0, 1, 0, 1)
146
+ assert not candle.valid
147
+ output = MAYTHOS().tick(candle)
148
+ assert output['blocked_flag']
149
+
150
+
151
+ def test_neutral_maythos_direction_is_blocked_not_treated_as_buy():
152
+ score = AdaptiveScoringEngine().score(
153
+ {}, {}, {}, {}, {}, {}, {}, {}, tick_count=100,
154
+ )
155
+ assert score['raw_direction'] == 0.0
156
+ assert score['execution_suitability'] == 'blocked'
157
+
158
+
159
+ def test_pipeline_reports_model_construction_failure_without_corrupting_details():
160
+ index = pd.date_range('2026-01-01', periods=45, freq='5min', tz='UTC')
161
+ close = np.linspace(100.0, 101.0, len(index))
162
+ frame = pd.DataFrame(
163
+ {'Open': close, 'High': close + .1, 'Low': close - .1, 'Close': close, 'Volume': 100.0},
164
+ index=index,
165
+ )
166
+ config = RunConfig(models=('ARIMA',), window=32, horizon=1, max_evaluations=1, lookback_days=1)
167
+ with patch('pipeline.new_configured_model', side_effect=RuntimeError('fixture construction failure')):
168
+ result = backtest_result(config, frame=frame)
169
+ assert result['forecast_summary'].iloc[0]['status'] == 'FAILED'
170
+ assert list(result['forecast_details'].columns).count('Model') == 1
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,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ def test_forecast_target_is_not_extended_to_distant_swing(self):
78
+ frame = _history()
79
+ levels = build_trade_levels(
80
+ frame, 'BUY', 'Crypto', 'BTC-USD', forecast_target=100.5,
81
+ forecast_only=True, use_structure_stop=False, minimum_risk_reward=0.1,
82
+ )
83
+ self.assertIsNotNone(levels)
84
+ self.assertEqual(levels.take_profit, 100.5)
85
+
86
+ def test_forecast_execution_rejects_target_below_cost_edge(self):
87
+ frame = _history()
88
+ levels = build_trade_levels(
89
+ frame, 'BUY', 'Crypto', 'BTC-USD', forecast_target=100.01,
90
+ forecast_only=True, use_structure_stop=False,
91
+ minimum_edge_bps=10.0,
92
+ )
93
+ self.assertIsNone(levels)
94
+
95
+
96
+ class TestBacktestTradeMetrics(unittest.TestCase):
97
+ def test_backtest_contains_candle_path_trade_fields_and_summary(self):
98
+ frame = _history(80)
99
+ results = backtest_model(frame, _UpModel(), window=40, horizon=2, step=10, timeframe='5m', market='Crypto', symbol='BTC-USD', costs=CostModel())
100
+ self.assertIn('stop_loss', results.columns)
101
+ self.assertIn('take_profit', results.columns)
102
+ self.assertIn('outcome', results.columns)
103
+ summary = summarize_backtest(results, 'UpModel')
104
+ self.assertIn('trades', summary)
105
+ self.assertIn('profit_factor', summary)
106
+ self.assertIn('max_drawdown_r', summary)
107
+
108
+
109
+ if __name__ == '__main__':
110
+ unittest.main()
utils/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ pass
utils/helpers.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import numbers
3
+ import numpy as np
4
+ import pandas as pd
5
+ MINUTES_PER_CANDLE = {'1m': 1, '2m': 2, '5m': 5, '10m': 10, '15m': 15, '30m': 30, '1h': 60, '2h': 120, '4h': 240, '1d': 1440}
6
+ INDICATOR_WARMUP_CANDLES = 30
7
+ PREDICTION_HISTORY_CANDLES = 200
8
+
9
+ def infer_step(index: pd.DatetimeIndex):
10
+ if not isinstance(index, pd.DatetimeIndex) or index.isna().any() or index.duplicated().any() or not index.is_monotonic_increasing:
11
+ raise ValueError('infer_step needs ordered unique datetime timestamps.')
12
+ if len(index) < 2:
13
+ return pd.Timedelta(minutes=1)
14
+ diffs = index.to_series().diff().dropna()
15
+ if diffs.empty:
16
+ return pd.Timedelta(minutes=1)
17
+ return diffs.median()
18
+
19
+ def future_index(last_timestamp, step, n):
20
+ if isinstance(n, (bool, np.bool_)) or not isinstance(n, numbers.Integral) or n < 0 or pd.Timedelta(step) <= pd.Timedelta(0):
21
+ raise ValueError('Future index requires a non-negative integer horizon and positive step.')
22
+ return [last_timestamp + step * (i + 1) for i in range(n)]
23
+
24
+ def history_days_for_candles(timeframe: str, candles: int, minimum_days: int=1) -> int:
25
+ minutes = MINUTES_PER_CANDLE.get(timeframe, MINUTES_PER_CANDLE['1h']) * max(int(candles), 0)
26
+ return max(minimum_days, math.ceil(minutes / (24 * 60)))
27
+
28
+ def feature_warmup_days(timeframe: str, minimum_days: int=2) -> int:
29
+ return history_days_for_candles(timeframe, INDICATOR_WARMUP_CANDLES, minimum_days=minimum_days)
30
+
31
+ def drop_forming_candle(df: pd.DataFrame, features_df: pd.DataFrame=None, timeframe: str=None, now=None):
32
+ if df is None or len(df) == 0:
33
+ return (df, features_df)
34
+ if not isinstance(df.index, pd.DatetimeIndex) or df.index.isna().any():
35
+ raise ValueError('Closed-candle filtering requires valid datetime timestamps.')
36
+ if features_df is not None and len(features_df) != len(df):
37
+ raise ValueError('Feature rows must align with candles before closed-candle filtering.')
38
+ now = pd.Timestamp(now) if now is not None else pd.Timestamp.now(tz='UTC')
39
+ if df.index.tz is None:
40
+ if now.tzinfo is not None:
41
+ now = now.tz_convert('UTC').tz_localize(None)
42
+ elif now.tzinfo is None:
43
+ now = now.tz_localize('UTC')
44
+ step = pd.Timedelta(minutes=MINUTES_PER_CANDLE[timeframe]) if timeframe else infer_step(df.index)
45
+ mask = df.index + step <= now
46
+ df = df.loc[mask]
47
+ if features_df is not None:
48
+ features_df = features_df.loc[mask].reset_index(drop=True)
49
+ return (df, features_df)
50
+
51
+ def price_direction(predicted: float, current: float) -> str:
52
+ # Treat optimizer-scale numerical noise as flat. Without this guard a
53
+ # persistence forecast can become DOWN solely because exp/log round-trip
54
+ # error is a few ulps, which makes live consensus and accuracy tables look
55
+ # more certain than the underlying prices justify.
56
+ if not math.isfinite(float(predicted)) or not math.isfinite(float(current)):
57
+ raise ValueError('price_direction needs finite numeric prices.')
58
+ predicted, current = float(predicted), float(current)
59
+ if math.isclose(float(predicted), float(current), rel_tol=1e-9, abs_tol=1e-12):
60
+ return 'flat'
61
+ if predicted > current:
62
+ return 'up'
63
+ if predicted < current:
64
+ return 'down'
65
+ return 'flat'