Space Deployer commited on
Commit
e44569b
·
1 Parent(s): 367602c

Deploy MYTHOSLIVE Space

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