Spaces:
Running
Running
Audit execution and add frozen research phases
Browse files- app.py +30 -8
- backtest/backtester.py +202 -13
- config.py +1 -0
- data/providers.py +18 -4
- models/timesfm_model.py +27 -0
- pipeline.py +133 -55
- research.py +359 -0
- risk_management.py +16 -6
app.py
CHANGED
|
@@ -6,9 +6,10 @@ 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 = """
|
|
@@ -32,12 +33,16 @@ def server_port():
|
|
| 32 |
return port
|
| 33 |
|
| 34 |
|
| 35 |
-
def ui_config(provider, market, symbol, timeframe, mode, models, days, window, horizon, step, evaluations, features, spread_bps, fee_bps, slippage_bps, random_baseline):
|
| 36 |
return RunConfig(provider=provider, market=market, symbol=symbol, timeframe=timeframe,
|
| 37 |
mode=mode, models=tuple(models or ()), lookback_days=days, window=window,
|
| 38 |
horizon=horizon, step=step, max_evaluations=evaluations, use_features=features,
|
| 39 |
spread_bps=spread_bps, fee_bps=fee_bps, slippage_bps=slippage_bps,
|
| 40 |
-
random_baseline=random_baseline
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
|
| 43 |
def metadata_text(metadata):
|
|
@@ -129,7 +134,7 @@ def run_backtest_ui(*args):
|
|
| 129 |
f"### Walk-forward evaluation completed — {failed} failed/skipped model evaluations\n\n"
|
| 130 |
f"History window: **{config.window}** · horizon: **{config.horizon}** · origin stride: **{config.step}** · "
|
| 131 |
f"at most **{config.max_evaluations} most-recent evaluation origins per phase/model**. "
|
| 132 |
-
"The report
|
| 133 |
'Orders are reselected using only each training window; no future target prices/features are supplied to models. '
|
| 134 |
'Targets crossing gaps/market closures are explicitly skipped. Flat prices are classified as FLAT.\n\n'
|
| 135 |
'**Accuracy denominator:** successful forecast evaluations; failures and coverage are shown separately. '
|
|
@@ -207,13 +212,21 @@ with gr.Blocks(title='MYTHOS · MAYTHOS × RINKING', theme=THEME, css=CSS) as de
|
|
| 207 |
spread_bps = gr.Number(value=1.0, minimum=0, maximum=1000, label='Assumed spread (bps)')
|
| 208 |
fee_bps = gr.Number(value=1.0, minimum=0, maximum=1000, label='Assumed fees (bps, round trip)')
|
| 209 |
slippage_bps = gr.Number(value=1.0, minimum=0, maximum=1000, label='Assumed slippage (bps)')
|
| 210 |
-
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
with gr.Accordion('Model dependency status', open=False):
|
| 213 |
-
gr.Dataframe(value=model_readiness(), interactive=False)
|
|
|
|
| 214 |
gr.Markdown('“Packages detected” is not an inference or accuracy certification. See README for installing optional foundation models.')
|
| 215 |
|
| 216 |
-
inputs = [provider, market, symbol, timeframe, mode, models, days, window, horizon, step, evaluations, features, spread_bps, fee_bps, slippage_bps, random_baseline]
|
| 217 |
with gr.Tab('Live Signal'):
|
| 218 |
live_button = gr.Button('Refresh live signal', variant='primary')
|
| 219 |
live_summary = gr.Markdown('Choose your configuration and refresh to fetch real closed candles.')
|
|
@@ -230,11 +243,20 @@ with gr.Blocks(title='MYTHOS · MAYTHOS × RINKING', theme=THEME, css=CSS) as de
|
|
| 230 |
maythos_details = gr.Dataframe(label='MAYTHOS per-evaluation details', interactive=False)
|
| 231 |
backtest_file = gr.File(label='Download backtest data, results & reproducibility manifest', interactive=False)
|
| 232 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
provider.change(default_symbols, inputs=[provider, market], outputs=[symbol, catalog_note], queue=False)
|
| 234 |
market.change(default_symbols, inputs=[provider, market], outputs=[symbol, catalog_note], queue=False)
|
| 235 |
search_button.click(search_symbols, inputs=[provider, market, query, symbol], outputs=[symbol, catalog_note], api_name='symbol_catalog')
|
| 236 |
mode.change(update_mode, inputs=[mode, models], outputs=models, queue=False)
|
| 237 |
models.change(enforce_single, inputs=[mode, models], outputs=models, queue=False)
|
|
|
|
|
|
|
| 238 |
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')
|
| 239 |
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')
|
| 240 |
|
|
|
|
| 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, RESEARCH_PHASES, TIMEFRAMES
|
| 10 |
from data.providers import catalog
|
| 11 |
from pipeline import RunConfig, backtest_result, export_run, live_result, model_readiness
|
| 12 |
+
from research import research_run
|
| 13 |
|
| 14 |
DISCLAIMER = '**Educational / research project — not financial advice.** Trading carries substantial risk of loss.'
|
| 15 |
CSS = """
|
|
|
|
| 33 |
return port
|
| 34 |
|
| 35 |
|
| 36 |
+
def ui_config(provider, market, symbol, timeframe, mode, models, days, window, horizon, step, evaluations, features, spread_bps, fee_bps, slippage_bps, random_baseline, inverted_signals=False, research_phase='Tune + Validate', minimum_edge_atr=0.20, minimum_risk_reward=1.20, atr_stop_multiplier=1.25):
|
| 37 |
return RunConfig(provider=provider, market=market, symbol=symbol, timeframe=timeframe,
|
| 38 |
mode=mode, models=tuple(models or ()), lookback_days=days, window=window,
|
| 39 |
horizon=horizon, step=step, max_evaluations=evaluations, use_features=features,
|
| 40 |
spread_bps=spread_bps, fee_bps=fee_bps, slippage_bps=slippage_bps,
|
| 41 |
+
random_baseline=random_baseline,
|
| 42 |
+
inverted_signals=inverted_signals, research_phase=research_phase,
|
| 43 |
+
minimum_edge_atr=minimum_edge_atr,
|
| 44 |
+
minimum_risk_reward=minimum_risk_reward,
|
| 45 |
+
atr_stop_multiplier=atr_stop_multiplier)
|
| 46 |
|
| 47 |
|
| 48 |
def metadata_text(metadata):
|
|
|
|
| 134 |
f"### Walk-forward evaluation completed — {failed} failed/skipped model evaluations\n\n"
|
| 135 |
f"History window: **{config.window}** · horizon: **{config.horizon}** · origin stride: **{config.step}** · "
|
| 136 |
f"at most **{config.max_evaluations} most-recent evaluation origins per phase/model**. "
|
| 137 |
+
f"The report uses the selected **{config.research_phase}** phases from a chronological **TUNE (60%) / VALIDATE (20%) / VAULT (20%)** split; later-phase target candles are not used during tuning. "
|
| 138 |
'Orders are reselected using only each training window; no future target prices/features are supplied to models. '
|
| 139 |
'Targets crossing gaps/market closures are explicitly skipped. Flat prices are classified as FLAT.\n\n'
|
| 140 |
'**Accuracy denominator:** successful forecast evaluations; failures and coverage are shown separately. '
|
|
|
|
| 212 |
spread_bps = gr.Number(value=1.0, minimum=0, maximum=1000, label='Assumed spread (bps)')
|
| 213 |
fee_bps = gr.Number(value=1.0, minimum=0, maximum=1000, label='Assumed fees (bps, round trip)')
|
| 214 |
slippage_bps = gr.Number(value=1.0, minimum=0, maximum=1000, label='Assumed slippage (bps)')
|
| 215 |
+
with gr.Row():
|
| 216 |
+
random_baseline = gr.Checkbox(value=False, label='Matched random-signal control (200 runs; default off)')
|
| 217 |
+
inverted_signals = gr.Checkbox(value=False, label='Inverted-signal control (audit mode; default off)')
|
| 218 |
+
research_phase = gr.Dropdown(RESEARCH_PHASES, value='Tune + Validate', label='Research phase — Vault requires a frozen candidate')
|
| 219 |
+
with gr.Row():
|
| 220 |
+
minimum_edge_atr = gr.Number(value=0.20, minimum=0, maximum=100, label='Minimum forecast edge (ATR)')
|
| 221 |
+
minimum_risk_reward = gr.Number(value=1.20, minimum=0.01, maximum=100, label='Minimum reward/risk')
|
| 222 |
+
atr_stop_multiplier = gr.Number(value=1.25, minimum=0.05, maximum=100, label='Stop distance (ATR multiple)')
|
| 223 |
+
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. Backtests enter at the next candle open and one position may be open at a time. The ordered-candle split is 60% TUNE, 20% VALIDATE, 20% VAULT; targets are horizon-trimmed at boundaries, and only the selected research phase is evaluated. Future indicator covariates use the last observed values. Moirai/TimesFM require their real pretrained weights.')
|
| 224 |
with gr.Accordion('Model dependency status', open=False):
|
| 225 |
+
dependency_table = gr.Dataframe(value=model_readiness(), interactive=False)
|
| 226 |
+
dependency_button = gr.Button('Refresh dependency status')
|
| 227 |
gr.Markdown('“Packages detected” is not an inference or accuracy certification. See README for installing optional foundation models.')
|
| 228 |
|
| 229 |
+
inputs = [provider, market, symbol, timeframe, mode, models, days, window, horizon, step, evaluations, features, spread_bps, fee_bps, slippage_bps, random_baseline, inverted_signals, research_phase, minimum_edge_atr, minimum_risk_reward, atr_stop_multiplier]
|
| 230 |
with gr.Tab('Live Signal'):
|
| 231 |
live_button = gr.Button('Refresh live signal', variant='primary')
|
| 232 |
live_summary = gr.Markdown('Choose your configuration and refresh to fetch real closed candles.')
|
|
|
|
| 243 |
maythos_details = gr.Dataframe(label='MAYTHOS per-evaluation details', interactive=False)
|
| 244 |
backtest_file = gr.File(label='Download backtest data, results & reproducibility manifest', interactive=False)
|
| 245 |
|
| 246 |
+
with gr.Tab('Frozen research'):
|
| 247 |
+
gr.Markdown('One immutable historical dataset, separate TUNE / VALIDATE / VAULT phases, fixed per-trade risk, explicit costs and matched controls. Settings are sent as JSON; only the requested phase is evaluated.')
|
| 248 |
+
research_settings = gr.JSON(value={'snapshot_only': True}, label='Research settings')
|
| 249 |
+
research_button = gr.Button('Run selected frozen research phase')
|
| 250 |
+
research_summary = gr.JSON(label='Auditable metrics')
|
| 251 |
+
research_file = gr.File(label='Download phase snapshot, trade rows and manifest', interactive=False)
|
| 252 |
+
|
| 253 |
provider.change(default_symbols, inputs=[provider, market], outputs=[symbol, catalog_note], queue=False)
|
| 254 |
market.change(default_symbols, inputs=[provider, market], outputs=[symbol, catalog_note], queue=False)
|
| 255 |
search_button.click(search_symbols, inputs=[provider, market, query, symbol], outputs=[symbol, catalog_note], api_name='symbol_catalog')
|
| 256 |
mode.change(update_mode, inputs=[mode, models], outputs=models, queue=False)
|
| 257 |
models.change(enforce_single, inputs=[mode, models], outputs=models, queue=False)
|
| 258 |
+
dependency_button.click(model_readiness, outputs=dependency_table, api_name='dependency_status')
|
| 259 |
+
research_button.click(research_run, inputs=research_settings, outputs=[research_summary,research_file], concurrency_limit=1, concurrency_id='analysis', api_name='research')
|
| 260 |
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')
|
| 261 |
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')
|
| 262 |
|
backtest/backtester.py
CHANGED
|
@@ -3,7 +3,7 @@ import numpy as np
|
|
| 3 |
import pandas as pd
|
| 4 |
from features.feature_pipeline import slice_features
|
| 5 |
from risk_management import (
|
| 6 |
-
CostModel, average_true_range, build_trade_levels, simulate_trade,
|
| 7 |
summarize_trades,
|
| 8 |
)
|
| 9 |
from utils.helpers import MINUTES_PER_CANDLE, price_direction
|
|
@@ -30,6 +30,10 @@ def backtest_model(
|
|
| 30 |
costs: CostModel = None,
|
| 31 |
origin_start: int = None,
|
| 32 |
origin_end: int = None,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
) -> pd.DataFrame:
|
| 34 |
if not isinstance(df, pd.DataFrame):
|
| 35 |
raise TypeError('Backtest candles must be provided as a pandas DataFrame.')
|
|
@@ -79,6 +83,7 @@ def backtest_model(
|
|
| 79 |
if max_evaluations < 1:
|
| 80 |
raise ValueError('max_evaluations must be positive.')
|
| 81 |
positions = positions[-max_evaluations:]
|
|
|
|
| 82 |
for i in positions:
|
| 83 |
history = close.iloc[i - window : i]
|
| 84 |
feature_window = (
|
|
@@ -129,6 +134,7 @@ def backtest_model(
|
|
| 129 |
actual_direction = price_direction(actual_next_close, current_close)
|
| 130 |
correct = predicted_direction == actual_direction
|
| 131 |
trade_direction = {'up': 'BUY', 'down': 'SELL'}.get(predicted_direction)
|
|
|
|
| 132 |
try:
|
| 133 |
levels = build_trade_levels(
|
| 134 |
df.iloc[:i], trade_direction, market, symbol,
|
|
@@ -142,16 +148,22 @@ def backtest_model(
|
|
| 142 |
# was replaced by a distant swing target, creating timeout losses
|
| 143 |
# unrelated to the forecast signal.
|
| 144 |
forecast_only=True,
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
|
|
|
| 148 |
minimum_edge_bps=(
|
| 149 |
costs.spread_bps + costs.fee_bps + 2.0 * costs.slippage_bps
|
| 150 |
) * 1.25,
|
| 151 |
)
|
|
|
|
| 152 |
trade = {'outcome': 'NO_LEVELS', 'exit_price': None, 'net_r': None, 'bars_held': 0}
|
| 153 |
-
if levels is not None:
|
|
|
|
|
|
|
| 154 |
trade = {**trade, **simulate_trade(levels, df.iloc[i:target_idx + 1], costs)}
|
|
|
|
|
|
|
| 155 |
trade_error = None
|
| 156 |
except Exception as error:
|
| 157 |
levels = None
|
|
@@ -167,14 +179,14 @@ def backtest_model(
|
|
| 167 |
"actual_direction": actual_direction,
|
| 168 |
"correct": correct,
|
| 169 |
"continuous_target": continuous,
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
"outcome": trade.get('outcome'),
|
| 179 |
"exit_price": trade.get('exit_price'),
|
| 180 |
"net_r": trade.get('net_r'),
|
|
@@ -310,6 +322,183 @@ def backtest_random_signals(
|
|
| 310 |
return pd.DataFrame(rows)
|
| 311 |
|
| 312 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
def summarize_backtest(results: pd.DataFrame, model_name: str) -> dict:
|
| 314 |
if results.empty:
|
| 315 |
return {
|
|
|
|
| 3 |
import pandas as pd
|
| 4 |
from features.feature_pipeline import slice_features
|
| 5 |
from risk_management import (
|
| 6 |
+
CostModel, TradeLevels, average_true_range, build_trade_levels, simulate_trade,
|
| 7 |
summarize_trades,
|
| 8 |
)
|
| 9 |
from utils.helpers import MINUTES_PER_CANDLE, price_direction
|
|
|
|
| 30 |
costs: CostModel = None,
|
| 31 |
origin_start: int = None,
|
| 32 |
origin_end: int = None,
|
| 33 |
+
minimum_edge_atr: float = 0.20,
|
| 34 |
+
minimum_risk_reward: float = 1.20,
|
| 35 |
+
atr_stop_multiplier: float = 1.25,
|
| 36 |
+
prevent_overlap: bool = True,
|
| 37 |
) -> pd.DataFrame:
|
| 38 |
if not isinstance(df, pd.DataFrame):
|
| 39 |
raise TypeError('Backtest candles must be provided as a pandas DataFrame.')
|
|
|
|
| 83 |
if max_evaluations < 1:
|
| 84 |
raise ValueError('max_evaluations must be positive.')
|
| 85 |
positions = positions[-max_evaluations:]
|
| 86 |
+
next_entry_index = window
|
| 87 |
for i in positions:
|
| 88 |
history = close.iloc[i - window : i]
|
| 89 |
feature_window = (
|
|
|
|
| 134 |
actual_direction = price_direction(actual_next_close, current_close)
|
| 135 |
correct = predicted_direction == actual_direction
|
| 136 |
trade_direction = {'up': 'BUY', 'down': 'SELL'}.get(predicted_direction)
|
| 137 |
+
trade_taken = False
|
| 138 |
try:
|
| 139 |
levels = build_trade_levels(
|
| 140 |
df.iloc[:i], trade_direction, market, symbol,
|
|
|
|
| 148 |
# was replaced by a distant swing target, creating timeout losses
|
| 149 |
# unrelated to the forecast signal.
|
| 150 |
forecast_only=True,
|
| 151 |
+
minimum_edge_atr=minimum_edge_atr,
|
| 152 |
+
minimum_risk_reward=minimum_risk_reward,
|
| 153 |
+
use_structure_stop=False,
|
| 154 |
+
atr_stop_multiplier=atr_stop_multiplier,
|
| 155 |
minimum_edge_bps=(
|
| 156 |
costs.spread_bps + costs.fee_bps + 2.0 * costs.slippage_bps
|
| 157 |
) * 1.25,
|
| 158 |
)
|
| 159 |
+
trade_taken = levels is not None and (not prevent_overlap or i >= next_entry_index)
|
| 160 |
trade = {'outcome': 'NO_LEVELS', 'exit_price': None, 'net_r': None, 'bars_held': 0}
|
| 161 |
+
if levels is not None and not trade_taken:
|
| 162 |
+
trade = {**trade, 'outcome': 'OVERLAP_SKIPPED'}
|
| 163 |
+
elif levels is not None:
|
| 164 |
trade = {**trade, **simulate_trade(levels, df.iloc[i:target_idx + 1], costs)}
|
| 165 |
+
if prevent_overlap:
|
| 166 |
+
next_entry_index = i + max(1, int(trade.get('bars_held', 0) or 0))
|
| 167 |
trade_error = None
|
| 168 |
except Exception as error:
|
| 169 |
levels = None
|
|
|
|
| 179 |
"actual_direction": actual_direction,
|
| 180 |
"correct": correct,
|
| 181 |
"continuous_target": continuous,
|
| 182 |
+
"trade_taken": trade_taken,
|
| 183 |
+
"entry": levels.entry if levels and trade_taken else np.nan,
|
| 184 |
+
"stop_loss": levels.stop_loss if levels and trade_taken else np.nan,
|
| 185 |
+
"take_profit": levels.take_profit if levels and trade_taken else np.nan,
|
| 186 |
+
"risk_reward": levels.risk_reward if levels and trade_taken else np.nan,
|
| 187 |
+
"atr": levels.atr if levels and trade_taken else np.nan,
|
| 188 |
+
"swing_support": levels.swing_support if levels and trade_taken else np.nan,
|
| 189 |
+
"swing_resistance": levels.swing_resistance if levels and trade_taken else np.nan,
|
| 190 |
"outcome": trade.get('outcome'),
|
| 191 |
"exit_price": trade.get('exit_price'),
|
| 192 |
"net_r": trade.get('net_r'),
|
|
|
|
| 322 |
return pd.DataFrame(rows)
|
| 323 |
|
| 324 |
|
| 325 |
+
MATCHED_RANDOM_RUNS = 200
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def _matched_levels(row, direction: str) -> TradeLevels | None:
|
| 329 |
+
"""Reuse a model trade's causal distances for a direction control.
|
| 330 |
+
|
| 331 |
+
The control never inspects a future price. It receives only the model's
|
| 332 |
+
already-eligible entry, stop/target distances, and the same future path
|
| 333 |
+
length used by the model trade. Random directions are then permuted while
|
| 334 |
+
preserving the model trade count and long/short ratio.
|
| 335 |
+
"""
|
| 336 |
+
try:
|
| 337 |
+
entry = float(row['entry'])
|
| 338 |
+
stop = float(row['stop_loss'])
|
| 339 |
+
target = float(row['take_profit'])
|
| 340 |
+
risk = abs(entry - stop)
|
| 341 |
+
reward = abs(target - entry)
|
| 342 |
+
atr = float(row.get('atr', 1.0))
|
| 343 |
+
support = float(row.get('swing_support', entry))
|
| 344 |
+
resistance = float(row.get('swing_resistance', entry))
|
| 345 |
+
except (TypeError, ValueError, KeyError):
|
| 346 |
+
return None
|
| 347 |
+
if any(not np.isfinite(value) or value <= 0 for value in (entry, risk, reward, atr, support, resistance)):
|
| 348 |
+
return None
|
| 349 |
+
if direction == 'BUY':
|
| 350 |
+
stop_loss = entry - risk
|
| 351 |
+
take_profit = entry + reward
|
| 352 |
+
elif direction == 'SELL':
|
| 353 |
+
stop_loss = entry + risk
|
| 354 |
+
take_profit = entry - reward
|
| 355 |
+
else:
|
| 356 |
+
return None
|
| 357 |
+
if stop_loss <= 0 or take_profit <= 0:
|
| 358 |
+
return None
|
| 359 |
+
return TradeLevels(
|
| 360 |
+
direction=direction,
|
| 361 |
+
entry=entry,
|
| 362 |
+
stop_loss=stop_loss,
|
| 363 |
+
take_profit=take_profit,
|
| 364 |
+
risk_per_unit=risk,
|
| 365 |
+
reward_per_unit=reward,
|
| 366 |
+
risk_reward=reward / risk,
|
| 367 |
+
atr=atr,
|
| 368 |
+
swing_support=support,
|
| 369 |
+
swing_resistance=resistance,
|
| 370 |
+
tick_size=1.0,
|
| 371 |
+
precision_digits=0,
|
| 372 |
+
level_basis='matched causal model-trade distances; control direction only',
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def matched_direction_control(
|
| 377 |
+
model_results: pd.DataFrame,
|
| 378 |
+
df: pd.DataFrame,
|
| 379 |
+
horizon: int,
|
| 380 |
+
costs: CostModel,
|
| 381 |
+
*,
|
| 382 |
+
inverted: bool = False,
|
| 383 |
+
runs: int = MATCHED_RANDOM_RUNS,
|
| 384 |
+
seed: int = 42,
|
| 385 |
+
):
|
| 386 |
+
"""Evaluate matched random or inverted directions without changing exits.
|
| 387 |
+
|
| 388 |
+
Only rows that the model actually traded are used. Each random run is a
|
| 389 |
+
permutation of the model's BUY/SELL labels, so the trade count and the
|
| 390 |
+
long/short ratio are identical. The original level distances and the
|
| 391 |
+
model's bars-held path length are retained; only direction is changed.
|
| 392 |
+
"""
|
| 393 |
+
if not isinstance(model_results, pd.DataFrame) or model_results.empty:
|
| 394 |
+
empty = pd.DataFrame()
|
| 395 |
+
return {
|
| 396 |
+
'model': 'Inverted signals' if inverted else 'Random baseline',
|
| 397 |
+
'trades': 0,
|
| 398 |
+
'matched_trade_count': 0,
|
| 399 |
+
'control_runs': 1 if inverted else int(runs),
|
| 400 |
+
'net_profit_r': None,
|
| 401 |
+
'random_95pct_net_profit_r': None,
|
| 402 |
+
'random_5pct_net_profit_r': None,
|
| 403 |
+
'status': 'NO_MODEL_TRADES',
|
| 404 |
+
}, empty
|
| 405 |
+
eligible = model_results[
|
| 406 |
+
model_results.get('trade_taken', False).astype(bool) &
|
| 407 |
+
model_results['net_r'].notna()
|
| 408 |
+
].copy()
|
| 409 |
+
eligible = eligible[eligible['predicted_direction'].isin(('up', 'down'))]
|
| 410 |
+
if eligible.empty:
|
| 411 |
+
empty = pd.DataFrame()
|
| 412 |
+
return {
|
| 413 |
+
'model': 'Inverted signals' if inverted else 'Random baseline',
|
| 414 |
+
'trades': 0,
|
| 415 |
+
'matched_trade_count': 0,
|
| 416 |
+
'control_runs': 1 if inverted else int(runs),
|
| 417 |
+
'net_profit_r': None,
|
| 418 |
+
'random_95pct_net_profit_r': None,
|
| 419 |
+
'random_5pct_net_profit_r': None,
|
| 420 |
+
'status': 'NO_MODEL_TRADES',
|
| 421 |
+
}, empty
|
| 422 |
+
if isinstance(runs, (bool, np.bool_)) or int(runs) < 1:
|
| 423 |
+
raise ValueError('Matched control runs must be a positive integer.')
|
| 424 |
+
runs = int(runs)
|
| 425 |
+
index_lookup = {str(timestamp): position for position, timestamp in enumerate(df.index)}
|
| 426 |
+
original = np.array(['BUY' if value == 'up' else 'SELL' for value in eligible['predicted_direction']], dtype=object)
|
| 427 |
+
control_summaries = []
|
| 428 |
+
representative_rows = []
|
| 429 |
+
actual_runs = 1 if inverted else runs
|
| 430 |
+
for run_number in range(actual_runs):
|
| 431 |
+
if inverted:
|
| 432 |
+
assigned = np.array(['SELL' if value == 'BUY' else 'BUY' for value in original], dtype=object)
|
| 433 |
+
else:
|
| 434 |
+
rng = np.random.default_rng(int(seed) + run_number)
|
| 435 |
+
assigned = rng.permutation(original)
|
| 436 |
+
trade_rows = []
|
| 437 |
+
for row_number, (_, source_row) in enumerate(eligible.iterrows()):
|
| 438 |
+
timestamp = str(source_row['datetime'])
|
| 439 |
+
target_idx = index_lookup.get(timestamp)
|
| 440 |
+
if target_idx is None:
|
| 441 |
+
try:
|
| 442 |
+
target_idx = df.index.get_loc(pd.Timestamp(timestamp))
|
| 443 |
+
except (KeyError, TypeError, ValueError):
|
| 444 |
+
continue
|
| 445 |
+
start_idx = int(target_idx) - int(horizon) + 1
|
| 446 |
+
bars_held = max(1, int(source_row.get('bars_held', horizon) or horizon))
|
| 447 |
+
if start_idx < 0 or start_idx >= len(df):
|
| 448 |
+
continue
|
| 449 |
+
path = df.iloc[start_idx:min(len(df), start_idx + min(bars_held, int(horizon)))]
|
| 450 |
+
levels = _matched_levels(source_row, str(assigned[row_number]))
|
| 451 |
+
if levels is None or path.empty:
|
| 452 |
+
continue
|
| 453 |
+
trade = simulate_trade(levels, path, costs)
|
| 454 |
+
trade_rows.append({
|
| 455 |
+
'trade_taken': True,
|
| 456 |
+
'net_r': trade.get('net_r'),
|
| 457 |
+
'outcome': trade.get('outcome'),
|
| 458 |
+
'predicted_direction': 'up' if levels.direction == 'BUY' else 'down',
|
| 459 |
+
'actual_direction': source_row.get('actual_direction'),
|
| 460 |
+
'datetime': timestamp,
|
| 461 |
+
'control_run': run_number + 1,
|
| 462 |
+
'entry': levels.entry,
|
| 463 |
+
'stop_loss': levels.stop_loss,
|
| 464 |
+
'take_profit': levels.take_profit,
|
| 465 |
+
'exit_price': trade.get('exit_price'),
|
| 466 |
+
'bars_held': trade.get('bars_held', 0),
|
| 467 |
+
'error': None,
|
| 468 |
+
})
|
| 469 |
+
details = pd.DataFrame(trade_rows)
|
| 470 |
+
control_summaries.append(summarize_trades(details))
|
| 471 |
+
if run_number == 0:
|
| 472 |
+
representative_rows = trade_rows
|
| 473 |
+
net_values = np.array([
|
| 474 |
+
float(summary['net_profit_r']) for summary in control_summaries
|
| 475 |
+
if summary.get('net_profit_r') is not None and np.isfinite(summary['net_profit_r'])
|
| 476 |
+
], dtype=float)
|
| 477 |
+
if not len(net_values):
|
| 478 |
+
net_values = np.array([np.nan])
|
| 479 |
+
aggregate = {
|
| 480 |
+
'model': 'Inverted signals' if inverted else 'Random baseline',
|
| 481 |
+
'trades': int(len(eligible)),
|
| 482 |
+
'matched_trade_count': int(len(eligible)),
|
| 483 |
+
'control_runs': actual_runs,
|
| 484 |
+
'wins': round(float(np.mean([s['wins'] for s in control_summaries])), 6),
|
| 485 |
+
'losses': round(float(np.mean([s['losses'] for s in control_summaries])), 6),
|
| 486 |
+
'timeouts': round(float(np.mean([s['timeouts'] for s in control_summaries])), 6),
|
| 487 |
+
'win_rate_pct': round(float(np.mean([s['win_rate_pct'] for s in control_summaries])), 6),
|
| 488 |
+
'profit_factor': round(float(np.mean([s['profit_factor'] for s in control_summaries if s['profit_factor'] is not None and np.isfinite(s['profit_factor'])])), 6) if any(s['profit_factor'] is not None and np.isfinite(s['profit_factor']) for s in control_summaries) else None,
|
| 489 |
+
'expectancy_r': round(float(np.nanmean([s['expectancy_r'] for s in control_summaries])), 6),
|
| 490 |
+
'max_drawdown_r': round(float(np.nanmean([s['max_drawdown_r'] for s in control_summaries])), 6),
|
| 491 |
+
'average_r': round(float(np.nanmean([s['average_r'] for s in control_summaries])), 6),
|
| 492 |
+
'net_profit_r': round(float(np.nanmean(net_values)), 6),
|
| 493 |
+
'top3_removed_profit_r': round(float(np.nanmean([s['top3_removed_profit_r'] for s in control_summaries])), 6),
|
| 494 |
+
'random_mean_net_profit_r': round(float(np.nanmean(net_values)), 6),
|
| 495 |
+
'random_5pct_net_profit_r': round(float(np.nanquantile(net_values, 0.05)), 6),
|
| 496 |
+
'random_95pct_net_profit_r': round(float(np.nanquantile(net_values, 0.95)), 6),
|
| 497 |
+
'status': 'OK',
|
| 498 |
+
}
|
| 499 |
+
return aggregate, pd.DataFrame(representative_rows)
|
| 500 |
+
|
| 501 |
+
|
| 502 |
def summarize_backtest(results: pd.DataFrame, model_name: str) -> dict:
|
| 503 |
if results.empty:
|
| 504 |
return {
|
config.py
CHANGED
|
@@ -47,6 +47,7 @@ CUSTOM_TIMEFRAMES = {
|
|
| 47 |
TIMEFRAMES = ['1m', '2m', '5m', '10m', '15m', '30m', '1h', '2h']
|
| 48 |
MODEL_NAMES = ['ARIMA', 'Auto-ARIMA', 'ARIMA-GARCH', 'Moirai', 'TimesFM']
|
| 49 |
PROVIDERS = ['Yahoo Finance', 'Binance Spot', 'Twelve Data']
|
|
|
|
| 50 |
MOIRAI_CHECKPOINT = 'Salesforce/moirai-1.1-R-small'
|
| 51 |
TIMESFM_CHECKPOINT = 'google/timesfm-2.5-200m-pytorch'
|
| 52 |
DEFAULT_SYMBOL = 'BTC-USD'
|
|
|
|
| 47 |
TIMEFRAMES = ['1m', '2m', '5m', '10m', '15m', '30m', '1h', '2h']
|
| 48 |
MODEL_NAMES = ['ARIMA', 'Auto-ARIMA', 'ARIMA-GARCH', 'Moirai', 'TimesFM']
|
| 49 |
PROVIDERS = ['Yahoo Finance', 'Binance Spot', 'Twelve Data']
|
| 50 |
+
RESEARCH_PHASES = ['Tune only', 'Tune + Validate', 'Vault only']
|
| 51 |
MOIRAI_CHECKPOINT = 'Salesforce/moirai-1.1-R-small'
|
| 52 |
TIMESFM_CHECKPOINT = 'google/timesfm-2.5-200m-pytorch'
|
| 53 |
DEFAULT_SYMBOL = 'BTC-USD'
|
data/providers.py
CHANGED
|
@@ -146,7 +146,7 @@ def _validate_frame(frame):
|
|
| 146 |
return validate_ohlcv(frame, normalize=True)
|
| 147 |
|
| 148 |
|
| 149 |
-
def _binance_history(symbol, timeframe, days):
|
| 150 |
days = positive_integer(days, 'lookback_days', 10000)
|
| 151 |
source = {'2m': '1m', '10m': '5m'}.get(timeframe, timeframe)
|
| 152 |
if source not in MINUTES_PER_CANDLE:
|
|
@@ -159,6 +159,15 @@ def _binance_history(symbol, timeframe, days):
|
|
| 159 |
end_ms = int(clock['serverTime'])
|
| 160 |
except (KeyError, TypeError, ValueError):
|
| 161 |
raise DataFetchError('Binance returned an invalid server clock response.') from None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
start_ms = end_ms - int(days * 86_400_000)
|
| 163 |
cursor = start_ms
|
| 164 |
rows = []
|
|
@@ -194,7 +203,10 @@ def _binance_history(symbol, timeframe, days):
|
|
| 194 |
], columns=['Open', 'High', 'Low', 'Close', 'Volume'], index=pd.to_datetime([int(r[0]) for r in rows], unit='ms', utc=True)).astype(float)
|
| 195 |
except (TypeError, ValueError, OverflowError):
|
| 196 |
raise DataFetchError('Binance returned malformed OHLCV values.') from None
|
| 197 |
-
frame.attrs['provider_now'] = pd.Timestamp(
|
|
|
|
|
|
|
|
|
|
| 198 |
frame.attrs['source_interval'] = source
|
| 199 |
try:
|
| 200 |
exchange_info = _binance_get('/exchangeInfo', {'symbol': symbol})
|
|
@@ -283,17 +295,19 @@ def _twelve_history(symbol, timeframe, days):
|
|
| 283 |
return frame
|
| 284 |
|
| 285 |
|
| 286 |
-
def fetch_market_history(provider, market, symbol, timeframe, lookback_days):
|
| 287 |
if timeframe not in TIMEFRAMES:
|
| 288 |
raise ValueError('Select one of: ' + ', '.join(TIMEFRAMES))
|
| 289 |
# The UI caps user history at 730 days; feature warm-up may add a small
|
| 290 |
# provider request padding before the requested period is trimmed.
|
| 291 |
lookback_days = positive_integer(lookback_days, 'lookback_days', 10000)
|
| 292 |
symbol = normalize_symbol(symbol, provider, market)
|
|
|
|
|
|
|
| 293 |
if provider == 'Yahoo Finance':
|
| 294 |
frame = get_historical(symbol, timeframe, int(lookback_days))
|
| 295 |
elif provider == 'Binance Spot':
|
| 296 |
-
frame = _binance_history(symbol, timeframe, int(lookback_days))
|
| 297 |
else:
|
| 298 |
frame = _twelve_history(symbol, timeframe, int(lookback_days))
|
| 299 |
frame = _validate_frame(frame)
|
|
|
|
| 146 |
return validate_ohlcv(frame, normalize=True)
|
| 147 |
|
| 148 |
|
| 149 |
+
def _binance_history(symbol, timeframe, days, end_utc=None):
|
| 150 |
days = positive_integer(days, 'lookback_days', 10000)
|
| 151 |
source = {'2m': '1m', '10m': '5m'}.get(timeframe, timeframe)
|
| 152 |
if source not in MINUTES_PER_CANDLE:
|
|
|
|
| 159 |
end_ms = int(clock['serverTime'])
|
| 160 |
except (KeyError, TypeError, ValueError):
|
| 161 |
raise DataFetchError('Binance returned an invalid server clock response.') from None
|
| 162 |
+
provider_end_ms = end_ms
|
| 163 |
+
if end_utc is not None:
|
| 164 |
+
requested_end = pd.Timestamp(end_utc)
|
| 165 |
+
if requested_end.tzinfo is None:
|
| 166 |
+
raise DataFetchError('Historical end_utc must explicitly specify UTC.')
|
| 167 |
+
requested_end = requested_end.tz_convert('UTC')
|
| 168 |
+
if requested_end > pd.Timestamp(provider_end_ms, unit='ms', tz='UTC'):
|
| 169 |
+
raise DataFetchError('Historical end_utc cannot be in the future.')
|
| 170 |
+
end_ms = int(requested_end.timestamp() * 1000)
|
| 171 |
start_ms = end_ms - int(days * 86_400_000)
|
| 172 |
cursor = start_ms
|
| 173 |
rows = []
|
|
|
|
| 203 |
], columns=['Open', 'High', 'Low', 'Close', 'Volume'], index=pd.to_datetime([int(r[0]) for r in rows], unit='ms', utc=True)).astype(float)
|
| 204 |
except (TypeError, ValueError, OverflowError):
|
| 205 |
raise DataFetchError('Binance returned malformed OHLCV values.') from None
|
| 206 |
+
frame.attrs['provider_now'] = pd.Timestamp(provider_end_ms, unit='ms', tz='UTC').isoformat()
|
| 207 |
+
frame.attrs['requested_start_utc'] = pd.Timestamp(start_ms, unit='ms', tz='UTC').isoformat()
|
| 208 |
+
frame.attrs['requested_end_utc_exclusive'] = pd.Timestamp(end_ms, unit='ms', tz='UTC').isoformat()
|
| 209 |
+
frame.attrs['effective_lookback_days'] = days
|
| 210 |
frame.attrs['source_interval'] = source
|
| 211 |
try:
|
| 212 |
exchange_info = _binance_get('/exchangeInfo', {'symbol': symbol})
|
|
|
|
| 295 |
return frame
|
| 296 |
|
| 297 |
|
| 298 |
+
def fetch_market_history(provider, market, symbol, timeframe, lookback_days, end_utc=None):
|
| 299 |
if timeframe not in TIMEFRAMES:
|
| 300 |
raise ValueError('Select one of: ' + ', '.join(TIMEFRAMES))
|
| 301 |
# The UI caps user history at 730 days; feature warm-up may add a small
|
| 302 |
# provider request padding before the requested period is trimmed.
|
| 303 |
lookback_days = positive_integer(lookback_days, 'lookback_days', 10000)
|
| 304 |
symbol = normalize_symbol(symbol, provider, market)
|
| 305 |
+
if end_utc is not None and provider != 'Binance Spot':
|
| 306 |
+
raise DataFetchError('Fixed historical endpoints are currently supported only by Binance Spot.')
|
| 307 |
if provider == 'Yahoo Finance':
|
| 308 |
frame = get_historical(symbol, timeframe, int(lookback_days))
|
| 309 |
elif provider == 'Binance Spot':
|
| 310 |
+
frame = _binance_history(symbol, timeframe, int(lookback_days), end_utc=end_utc)
|
| 311 |
else:
|
| 312 |
frame = _twelve_history(symbol, timeframe, int(lookback_days))
|
| 313 |
frame = _validate_frame(frame)
|
models/timesfm_model.py
CHANGED
|
@@ -63,6 +63,33 @@ class TimesFMModel(BaseForecastModel):
|
|
| 63 |
with self._lock, _MODEL_CACHE_LOCK:
|
| 64 |
return self._predict(history, horizon, features)
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
def _predict(self, history, horizon, features):
|
| 67 |
validate_history_and_horizon(history, horizon, self.name)
|
| 68 |
values = np.asarray(history, dtype=np.float32)
|
|
|
|
| 63 |
with self._lock, _MODEL_CACHE_LOCK:
|
| 64 |
return self._predict(history, horizon, features)
|
| 65 |
|
| 66 |
+
def predict_batch(self, histories, horizon=1):
|
| 67 |
+
"""Real pretrained inference on a bounded batch of closed histories."""
|
| 68 |
+
import torch
|
| 69 |
+
with self._lock, _MODEL_CACHE_LOCK:
|
| 70 |
+
torch.set_num_threads(2)
|
| 71 |
+
contexts=[]
|
| 72 |
+
for history in histories:
|
| 73 |
+
validate_history_and_horizon(history,horizon,self.name)
|
| 74 |
+
values=np.asarray(history,dtype=np.float32)[-self.max_context:]
|
| 75 |
+
if len(values)<32 or horizon>_MAX_HORIZON:
|
| 76 |
+
raise ValueError('Invalid TimesFM batch context/horizon.')
|
| 77 |
+
contexts.append(values)
|
| 78 |
+
if not contexts:
|
| 79 |
+
return []
|
| 80 |
+
model=self._ensure_compiled_for(max(map(len,contexts)))
|
| 81 |
+
point,_=model.forecast(horizon=horizon,inputs=contexts)
|
| 82 |
+
output=[]
|
| 83 |
+
for item in point:
|
| 84 |
+
values=np.asarray(item,dtype=float)[-horizon:]
|
| 85 |
+
if values.shape!=(horizon,) or not np.isfinite(values).all() or (values<=0).any():
|
| 86 |
+
raise ValueError('TimesFM batch returned invalid real-model forecasts.')
|
| 87 |
+
output.append(values.tolist())
|
| 88 |
+
self.last_diagnostics={'checkpoint':self.checkpoint,'context_candles':len(contexts[0]),
|
| 89 |
+
'backcast_trimmed':True,'batch_size':len(contexts),
|
| 90 |
+
'fallback':False,'weights_loaded':True}
|
| 91 |
+
return output
|
| 92 |
+
|
| 93 |
def _predict(self, history, horizon, features):
|
| 94 |
validate_history_and_horizon(history, horizon, self.name)
|
| 95 |
values = np.asarray(history, dtype=np.float32)
|
pipeline.py
CHANGED
|
@@ -15,8 +15,11 @@ import zipfile
|
|
| 15 |
import numpy as np
|
| 16 |
import pandas as pd
|
| 17 |
|
| 18 |
-
from backtest.backtester import
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -43,12 +46,20 @@ class RunConfig:
|
|
| 43 |
fee_bps: float = 1.0
|
| 44 |
slippage_bps: float = 1.0
|
| 45 |
random_baseline: bool = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
def __post_init__(self):
|
| 48 |
if self.provider not in PROVIDERS or self.timeframe not in TIMEFRAMES:
|
| 49 |
raise ValueError('Choose a supported provider and timeframe.')
|
| 50 |
if self.mode not in ('Single model', 'Parallel models'):
|
| 51 |
raise ValueError('Choose Single model or Parallel models.')
|
|
|
|
|
|
|
| 52 |
if isinstance(self.models, str):
|
| 53 |
raise ValueError('models must be a sequence of model names, not a string.')
|
| 54 |
models = tuple(dict.fromkeys(self.models or ()))
|
|
@@ -78,11 +89,31 @@ class RunConfig:
|
|
| 78 |
if isinstance(value, bool) or not finite or value < 0 or value > 1000:
|
| 79 |
raise ValueError(f'{name} must be a finite number between 0 and 1000 bps.')
|
| 80 |
object.__setattr__(self, name, float(value))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
if not isinstance(self.random_baseline, (bool, np.bool_)):
|
| 82 |
raise ValueError('random_baseline must be a boolean.')
|
|
|
|
|
|
|
| 83 |
object.__setattr__(self, 'random_baseline', bool(self.random_baseline))
|
|
|
|
| 84 |
object.__setattr__(self, 'models', models)
|
| 85 |
object.__setattr__(self, 'symbol', normalize_symbol(self.symbol, self.provider, self.market))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
@property
|
| 88 |
def candle_step(self):
|
|
@@ -100,19 +131,26 @@ def model_readiness():
|
|
| 100 |
'Moirai': ['uni2ts', 'gluonts', 'torch'], 'TimesFM': ['timesfm', 'torch'],
|
| 101 |
}
|
| 102 |
rows = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
for name, packages in required.items():
|
| 104 |
missing = [package for package in packages if importlib.util.find_spec(package) is None]
|
| 105 |
rows.append({
|
| 106 |
'Model': name,
|
| 107 |
'Dependency status': 'Missing: ' + ', '.join(missing) if missing else 'Packages detected',
|
| 108 |
-
'Runtime requirement': 'Pretrained weights download on first use; inference must succeed' if name in ('Moirai', 'TimesFM') else 'Fits actual selected history',
|
| 109 |
})
|
| 110 |
return pd.DataFrame(rows)
|
| 111 |
|
| 112 |
|
| 113 |
def prepare_history(config):
|
| 114 |
padding = feature_warmup_days(config.timeframe, minimum_days=0) if config.use_features else 0
|
| 115 |
-
frame = fetch_market_history(config.provider, config.market, config.symbol, config.timeframe, config.lookback_days + padding)
|
| 116 |
attrs = dict(frame.attrs)
|
| 117 |
features = None
|
| 118 |
if config.use_features:
|
|
@@ -248,9 +286,10 @@ def live_result(config, frame=None, features=None):
|
|
| 248 |
frame, signal['direction'], config.market, config.symbol,
|
| 249 |
forecast_target=forecast_target,
|
| 250 |
forecast_only=True,
|
| 251 |
-
minimum_edge_atr=
|
| 252 |
-
minimum_risk_reward=
|
| 253 |
use_structure_stop=False,
|
|
|
|
| 254 |
minimum_edge_bps=(
|
| 255 |
config.spread_bps + config.fee_bps + 2.0 * config.slippage_bps
|
| 256 |
) * 1.25,
|
|
@@ -275,22 +314,29 @@ def backtest_result(config, frame=None, features=None):
|
|
| 275 |
if len(frame) < config.window + config.horizon:
|
| 276 |
raise ValueError(f'Backtesting needs at least {config.window + config.horizon} closed candles; provider returned {len(frame)}.')
|
| 277 |
|
| 278 |
-
#
|
| 279 |
-
#
|
| 280 |
-
#
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
('
|
| 293 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
|
| 295 |
def evaluate_one(name, origin_start, origin_end, phase):
|
| 296 |
try:
|
|
@@ -301,6 +347,10 @@ def backtest_result(config, frame=None, features=None):
|
|
| 301 |
max_evaluations=config.max_evaluations, timeframe=config.timeframe,
|
| 302 |
market=config.market, symbol=config.symbol, costs=config.costs,
|
| 303 |
origin_start=origin_start, origin_end=origin_end,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
)
|
| 305 |
summary = summarize_backtest(details, name)
|
| 306 |
diagnostics = dict(getattr(model, 'last_diagnostics', {}) or {})
|
|
@@ -341,37 +391,48 @@ def backtest_result(config, frame=None, features=None):
|
|
| 341 |
config,
|
| 342 |
lambda name: evaluate_one(name, origin_start, origin_end, phase),
|
| 343 |
)
|
|
|
|
| 344 |
for summary, details, diagnostics in evaluated:
|
| 345 |
forecast_summaries.append(summary)
|
| 346 |
forecast_details.append(details)
|
|
|
|
| 347 |
model_diagnostics[f'{phase}:{summary["Model"]}'] = diagnostics
|
| 348 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
if config.random_baseline:
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
market=config.market, symbol=config.symbol, costs=config.costs,
|
| 354 |
-
seed=RANDOM_SEED, origin_start=origin_start, origin_end=origin_end,
|
| 355 |
-
)
|
| 356 |
-
random_summary = summarize_backtest(random_details, 'Random baseline')
|
| 357 |
-
random_summary['Model'] = random_summary.pop('model')
|
| 358 |
-
random_summary['Phase'] = phase
|
| 359 |
-
valid = random_details.dropna(subset=['correct'])
|
| 360 |
-
random_summary['evaluations_attempted'] = len(random_details)
|
| 361 |
-
random_summary['coverage_pct'] = round(100 * len(valid) / len(random_details), 2) if len(random_details) else None
|
| 362 |
-
random_summary['status'] = 'OK' if random_summary['failed_predictions'] == 0 else ('PARTIAL' if len(valid) else 'FAILED')
|
| 363 |
-
random_summary['cost_spread_bps'] = config.spread_bps
|
| 364 |
-
random_summary['cost_fee_bps'] = config.fee_bps
|
| 365 |
-
random_summary['cost_slippage_bps'] = config.slippage_bps
|
| 366 |
-
random_details.insert(0, 'Model', 'Random baseline')
|
| 367 |
-
random_details.insert(1, 'Phase', phase)
|
| 368 |
-
forecast_summaries.append(random_summary)
|
| 369 |
-
forecast_details.append(random_details)
|
| 370 |
-
model_diagnostics[f'{phase}:Random baseline'] = {
|
| 371 |
-
'type': 'deterministic random-direction control',
|
| 372 |
-
'seed': RANDOM_SEED,
|
| 373 |
-
'fallback': False,
|
| 374 |
-
}
|
| 375 |
|
| 376 |
try:
|
| 377 |
phase_signal_details, phase_signal_summary = backtest_maythos(
|
|
@@ -388,15 +449,32 @@ def backtest_result(config, frame=None, features=None):
|
|
| 388 |
signal_summaries.append(phase_signal_summary)
|
| 389 |
signal_details.append(phase_signal_details)
|
| 390 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
split_metadata = {
|
| 392 |
-
'policy': '
|
| 393 |
-
'
|
| 394 |
-
'
|
| 395 |
-
'
|
| 396 |
-
'
|
| 397 |
-
'
|
| 398 |
-
'
|
| 399 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
'final_targets_are_unseen_during_tuning': True,
|
| 401 |
}
|
| 402 |
return {
|
|
|
|
| 15 |
import numpy as np
|
| 16 |
import pandas as pd
|
| 17 |
|
| 18 |
+
from backtest.backtester import (
|
| 19 |
+
MATCHED_RANDOM_RUNS, backtest_model, matched_direction_control,
|
| 20 |
+
summarize_backtest,
|
| 21 |
+
)
|
| 22 |
+
from config import MODEL_NAMES, OUTPUT_DIR, PROVIDERS, RANDOM_SEED, RESEARCH_PHASES, TIMEFRAMES
|
| 23 |
from data.providers import fetch_market_history, normalize_symbol
|
| 24 |
from features.feature_pipeline import compute_feature_frame_for_period
|
| 25 |
from maythos_integration import backtest_maythos, latest_maythos_output
|
|
|
|
| 46 |
fee_bps: float = 1.0
|
| 47 |
slippage_bps: float = 1.0
|
| 48 |
random_baseline: bool = False
|
| 49 |
+
inverted_signals: bool = False
|
| 50 |
+
research_phase: str = 'Tune + Validate'
|
| 51 |
+
minimum_edge_atr: float = 0.20
|
| 52 |
+
minimum_risk_reward: float = 1.20
|
| 53 |
+
atr_stop_multiplier: float = 1.25
|
| 54 |
+
end_utc: str | None = None
|
| 55 |
|
| 56 |
def __post_init__(self):
|
| 57 |
if self.provider not in PROVIDERS or self.timeframe not in TIMEFRAMES:
|
| 58 |
raise ValueError('Choose a supported provider and timeframe.')
|
| 59 |
if self.mode not in ('Single model', 'Parallel models'):
|
| 60 |
raise ValueError('Choose Single model or Parallel models.')
|
| 61 |
+
if self.research_phase not in RESEARCH_PHASES:
|
| 62 |
+
raise ValueError('Choose a supported research phase.')
|
| 63 |
if isinstance(self.models, str):
|
| 64 |
raise ValueError('models must be a sequence of model names, not a string.')
|
| 65 |
models = tuple(dict.fromkeys(self.models or ()))
|
|
|
|
| 89 |
if isinstance(value, bool) or not finite or value < 0 or value > 1000:
|
| 90 |
raise ValueError(f'{name} must be a finite number between 0 and 1000 bps.')
|
| 91 |
object.__setattr__(self, name, float(value))
|
| 92 |
+
for name, minimum in (
|
| 93 |
+
('minimum_edge_atr', 0.0), ('minimum_risk_reward', 0.01),
|
| 94 |
+
('atr_stop_multiplier', 0.05),
|
| 95 |
+
):
|
| 96 |
+
value = getattr(self, name)
|
| 97 |
+
try:
|
| 98 |
+
finite = np.isfinite(value)
|
| 99 |
+
except TypeError:
|
| 100 |
+
finite = False
|
| 101 |
+
if isinstance(value, (bool, np.bool_)) or not finite or value < minimum or value > 100:
|
| 102 |
+
raise ValueError(f'{name} must be a finite number between {minimum} and 100.')
|
| 103 |
+
object.__setattr__(self, name, float(value))
|
| 104 |
if not isinstance(self.random_baseline, (bool, np.bool_)):
|
| 105 |
raise ValueError('random_baseline must be a boolean.')
|
| 106 |
+
if not isinstance(self.inverted_signals, (bool, np.bool_)):
|
| 107 |
+
raise ValueError('inverted_signals must be a boolean.')
|
| 108 |
object.__setattr__(self, 'random_baseline', bool(self.random_baseline))
|
| 109 |
+
object.__setattr__(self, 'inverted_signals', bool(self.inverted_signals))
|
| 110 |
object.__setattr__(self, 'models', models)
|
| 111 |
object.__setattr__(self, 'symbol', normalize_symbol(self.symbol, self.provider, self.market))
|
| 112 |
+
if self.end_utc is not None:
|
| 113 |
+
ending = pd.Timestamp(self.end_utc)
|
| 114 |
+
if ending.tzinfo is None or self.provider != 'Binance Spot':
|
| 115 |
+
raise ValueError('end_utc requires an explicit UTC timestamp and Binance Spot.')
|
| 116 |
+
object.__setattr__(self, 'end_utc', ending.tz_convert('UTC').isoformat())
|
| 117 |
|
| 118 |
@property
|
| 119 |
def candle_step(self):
|
|
|
|
| 131 |
'Moirai': ['uni2ts', 'gluonts', 'torch'], 'TimesFM': ['timesfm', 'torch'],
|
| 132 |
}
|
| 133 |
rows = []
|
| 134 |
+
timesfm_loaded = False
|
| 135 |
+
try:
|
| 136 |
+
from models.timesfm_model import _MODEL_CACHE
|
| 137 |
+
from config import TIMESFM_CHECKPOINT
|
| 138 |
+
timesfm_loaded = TIMESFM_CHECKPOINT in _MODEL_CACHE
|
| 139 |
+
except ImportError:
|
| 140 |
+
pass
|
| 141 |
for name, packages in required.items():
|
| 142 |
missing = [package for package in packages if importlib.util.find_spec(package) is None]
|
| 143 |
rows.append({
|
| 144 |
'Model': name,
|
| 145 |
'Dependency status': 'Missing: ' + ', '.join(missing) if missing else 'Packages detected',
|
| 146 |
+
'Runtime requirement': ('Real TimesFM pretrained weights loaded; no fallback' if name == 'TimesFM' and timesfm_loaded else 'Pretrained weights download on first use; inference must succeed') if name in ('Moirai', 'TimesFM') else 'Fits actual selected history',
|
| 147 |
})
|
| 148 |
return pd.DataFrame(rows)
|
| 149 |
|
| 150 |
|
| 151 |
def prepare_history(config):
|
| 152 |
padding = feature_warmup_days(config.timeframe, minimum_days=0) if config.use_features else 0
|
| 153 |
+
frame = fetch_market_history(config.provider, config.market, config.symbol, config.timeframe, config.lookback_days + padding, end_utc=config.end_utc)
|
| 154 |
attrs = dict(frame.attrs)
|
| 155 |
features = None
|
| 156 |
if config.use_features:
|
|
|
|
| 286 |
frame, signal['direction'], config.market, config.symbol,
|
| 287 |
forecast_target=forecast_target,
|
| 288 |
forecast_only=True,
|
| 289 |
+
minimum_edge_atr=config.minimum_edge_atr,
|
| 290 |
+
minimum_risk_reward=config.minimum_risk_reward,
|
| 291 |
use_structure_stop=False,
|
| 292 |
+
atr_stop_multiplier=config.atr_stop_multiplier,
|
| 293 |
minimum_edge_bps=(
|
| 294 |
config.spread_bps + config.fee_bps + 2.0 * config.slippage_bps
|
| 295 |
) * 1.25,
|
|
|
|
| 314 |
if len(frame) < config.window + config.horizon:
|
| 315 |
raise ValueError(f'Backtesting needs at least {config.window + config.horizon} closed candles; provider returned {len(frame)}.')
|
| 316 |
|
| 317 |
+
# Splits are chronological and expressed in origin coordinates. A phase's
|
| 318 |
+
# final origin is shortened by the horizon so no target candle from a later
|
| 319 |
+
# phase can enter an earlier phase's score.
|
| 320 |
+
tune_start = 0
|
| 321 |
+
validate_start = int(len(frame) * 0.60)
|
| 322 |
+
vault_start = int(len(frame) * 0.80)
|
| 323 |
+
phase_bounds = {
|
| 324 |
+
'TUNE': (tune_start, validate_start - config.horizon + 1),
|
| 325 |
+
'VALIDATE': (validate_start, vault_start - config.horizon + 1),
|
| 326 |
+
'VAULT': (vault_start, len(frame) - config.horizon + 1),
|
| 327 |
+
}
|
| 328 |
+
if phase_bounds['TUNE'][1] <= config.window:
|
| 329 |
+
raise ValueError('The fetched history is too short to provide a 60% TUNE segment after the selected training window and horizon.')
|
| 330 |
+
if phase_bounds['VALIDATE'][1] <= phase_bounds['VALIDATE'][0]:
|
| 331 |
+
raise ValueError('The fetched history is too short to provide a VALIDATE segment after the selected horizon.')
|
| 332 |
+
if phase_bounds['VAULT'][1] <= phase_bounds['VAULT'][0]:
|
| 333 |
+
raise ValueError('The fetched history is too short to provide a VAULT segment after the selected horizon.')
|
| 334 |
+
phase_names = {
|
| 335 |
+
'Tune only': ('TUNE',),
|
| 336 |
+
'Tune + Validate': ('TUNE', 'VALIDATE'),
|
| 337 |
+
'Vault only': ('VAULT',),
|
| 338 |
+
}
|
| 339 |
+
phases = tuple((phase, *phase_bounds[phase]) for phase in phase_names[config.research_phase])
|
| 340 |
|
| 341 |
def evaluate_one(name, origin_start, origin_end, phase):
|
| 342 |
try:
|
|
|
|
| 347 |
max_evaluations=config.max_evaluations, timeframe=config.timeframe,
|
| 348 |
market=config.market, symbol=config.symbol, costs=config.costs,
|
| 349 |
origin_start=origin_start, origin_end=origin_end,
|
| 350 |
+
minimum_edge_atr=config.minimum_edge_atr,
|
| 351 |
+
minimum_risk_reward=config.minimum_risk_reward,
|
| 352 |
+
atr_stop_multiplier=config.atr_stop_multiplier,
|
| 353 |
+
prevent_overlap=True,
|
| 354 |
)
|
| 355 |
summary = summarize_backtest(details, name)
|
| 356 |
diagnostics = dict(getattr(model, 'last_diagnostics', {}) or {})
|
|
|
|
| 391 |
config,
|
| 392 |
lambda name: evaluate_one(name, origin_start, origin_end, phase),
|
| 393 |
)
|
| 394 |
+
phase_model_details = {}
|
| 395 |
for summary, details, diagnostics in evaluated:
|
| 396 |
forecast_summaries.append(summary)
|
| 397 |
forecast_details.append(details)
|
| 398 |
+
phase_model_details[summary['Model']] = details
|
| 399 |
model_diagnostics[f'{phase}:{summary["Model"]}'] = diagnostics
|
| 400 |
|
| 401 |
+
def append_control(control_name, inverted=False):
|
| 402 |
+
for model_name, model_details in phase_model_details.items():
|
| 403 |
+
control_summary, control_details = matched_direction_control(
|
| 404 |
+
model_details, frame, config.horizon, config.costs,
|
| 405 |
+
inverted=inverted,
|
| 406 |
+
runs=1 if inverted else MATCHED_RANDOM_RUNS,
|
| 407 |
+
seed=RANDOM_SEED,
|
| 408 |
+
)
|
| 409 |
+
control_summary['Model'] = f'{control_summary.pop("model")} [{model_name}]'
|
| 410 |
+
control_summary['Phase'] = phase
|
| 411 |
+
control_summary['total_predictions'] = len(model_details)
|
| 412 |
+
control_summary['failed_predictions'] = 0
|
| 413 |
+
control_summary['evaluations_attempted'] = len(model_details)
|
| 414 |
+
control_summary['coverage_pct'] = 100.0 if len(model_details) else None
|
| 415 |
+
control_summary['cost_spread_bps'] = config.spread_bps
|
| 416 |
+
control_summary['cost_fee_bps'] = config.fee_bps
|
| 417 |
+
control_summary['cost_slippage_bps'] = config.slippage_bps
|
| 418 |
+
control_summary['control_type'] = 'inverted' if inverted else 'matched_random'
|
| 419 |
+
forecast_summaries.append(control_summary)
|
| 420 |
+
if not control_details.empty:
|
| 421 |
+
control_details.insert(0, 'Model', control_summary['Model'])
|
| 422 |
+
control_details.insert(1, 'Phase', phase)
|
| 423 |
+
forecast_details.append(control_details)
|
| 424 |
+
model_diagnostics[f'{phase}:{control_summary["Model"]}'] = {
|
| 425 |
+
'type': control_summary['control_type'],
|
| 426 |
+
'seed': RANDOM_SEED,
|
| 427 |
+
'runs': control_summary.get('control_runs'),
|
| 428 |
+
'matched_trade_count': control_summary.get('matched_trade_count'),
|
| 429 |
+
'fallback': False,
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
if config.random_baseline:
|
| 433 |
+
append_control('Random baseline', inverted=False)
|
| 434 |
+
if config.inverted_signals:
|
| 435 |
+
append_control('Inverted signals', inverted=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
|
| 437 |
try:
|
| 438 |
phase_signal_details, phase_signal_summary = backtest_maythos(
|
|
|
|
| 449 |
signal_summaries.append(phase_signal_summary)
|
| 450 |
signal_details.append(phase_signal_details)
|
| 451 |
|
| 452 |
+
def split_timestamp(position):
|
| 453 |
+
if position >= len(frame):
|
| 454 |
+
return None
|
| 455 |
+
return pd.Timestamp(frame.index[position]).isoformat()
|
| 456 |
+
|
| 457 |
split_metadata = {
|
| 458 |
+
'policy': 'oldest 60% TUNE, next 20% VALIDATE, newest 20% VAULT; origins are horizon-trimmed and each run selects only its requested phase',
|
| 459 |
+
'fraction_tune': 0.60,
|
| 460 |
+
'fraction_validate': 0.20,
|
| 461 |
+
'fraction_vault': 0.20,
|
| 462 |
+
'research_phase': config.research_phase,
|
| 463 |
+
'active_phases': [phase for phase, _, _ in phases],
|
| 464 |
+
'phase_bounds': {
|
| 465 |
+
phase: {
|
| 466 |
+
'start_index': start,
|
| 467 |
+
'end_index_exclusive': end,
|
| 468 |
+
'start_candle_open_utc': split_timestamp(start),
|
| 469 |
+
'end_candle_open_utc_exclusive': split_timestamp(
|
| 470 |
+
validate_start if phase == 'TUNE' else vault_start if phase == 'VALIDATE' else len(frame)
|
| 471 |
+
),
|
| 472 |
+
}
|
| 473 |
+
for phase, (start, end) in phase_bounds.items()
|
| 474 |
+
},
|
| 475 |
+
'tune_candles': validate_start,
|
| 476 |
+
'validate_candles': vault_start - validate_start,
|
| 477 |
+
'vault_candles': len(frame) - vault_start,
|
| 478 |
'final_targets_are_unseen_during_tuning': True,
|
| 479 |
}
|
| 480 |
return {
|
research.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Frozen-snapshot research API. All inference and execution run in the Space.
|
| 2 |
+
|
| 3 |
+
The API accepts ordinary JSON settings. No local simulator, fake market data,
|
| 4 |
+
model substitute, or pre-computed performance result is used.
|
| 5 |
+
"""
|
| 6 |
+
from dataclasses import asdict
|
| 7 |
+
import hashlib
|
| 8 |
+
import importlib.metadata
|
| 9 |
+
import json
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
import time
|
| 12 |
+
import zipfile
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
import pandas as pd
|
| 16 |
+
|
| 17 |
+
from config import OUTPUT_DIR
|
| 18 |
+
from data.providers import fetch_market_history
|
| 19 |
+
from features.feature_pipeline import compute_feature_frame
|
| 20 |
+
from pipeline import RunConfig, new_configured_model
|
| 21 |
+
from risk_management import CostModel, TradeLevels, simulate_trade, summarize_trades
|
| 22 |
+
from utils.helpers import MINUTES_PER_CANDLE
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
_FRAMES = {}
|
| 26 |
+
_FORECASTS = {}
|
| 27 |
+
ROOT = Path(OUTPUT_DIR) / 'research'
|
| 28 |
+
DEFAULT_DATA = {
|
| 29 |
+
'provider': 'Binance Spot', 'market': 'Crypto', 'symbol': 'BTCUSDT',
|
| 30 |
+
'timeframe': '15m', 'lookback_days': 270,
|
| 31 |
+
'end_utc': '2026-07-01T00:00:00+00:00',
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _clean(value):
|
| 36 |
+
if isinstance(value, dict):
|
| 37 |
+
return {key: _clean(item) for key, item in value.items()}
|
| 38 |
+
if isinstance(value, (list, tuple)):
|
| 39 |
+
return [_clean(item) for item in value]
|
| 40 |
+
if isinstance(value, (float, np.floating)) and not np.isfinite(value):
|
| 41 |
+
return None
|
| 42 |
+
if isinstance(value, np.generic):
|
| 43 |
+
return value.item()
|
| 44 |
+
return value
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _frame(settings):
|
| 48 |
+
data = {key: settings.get(key, default) for key, default in DEFAULT_DATA.items()}
|
| 49 |
+
key = json.dumps(data, sort_keys=True)
|
| 50 |
+
if key not in _FRAMES:
|
| 51 |
+
ROOT.mkdir(parents=True, exist_ok=True)
|
| 52 |
+
filename = ROOT / ('snapshot_' + hashlib.sha256(key.encode()).hexdigest()[:20] + '.csv')
|
| 53 |
+
metadata_filename = filename.with_suffix('.json')
|
| 54 |
+
if filename.exists() and metadata_filename.exists():
|
| 55 |
+
frame = pd.read_csv(filename, index_col=0, parse_dates=True, float_precision='round_trip')
|
| 56 |
+
frame.index = pd.to_datetime(frame.index, utc=True)
|
| 57 |
+
frame.attrs.update(json.loads(metadata_filename.read_text()))
|
| 58 |
+
else:
|
| 59 |
+
frame = fetch_market_history(**data)
|
| 60 |
+
frame.to_csv(filename, index_label='candle_open_utc', float_format='%.17g')
|
| 61 |
+
metadata_filename.write_text(json.dumps(frame.attrs, default=str, indent=2))
|
| 62 |
+
_FRAMES[key] = frame
|
| 63 |
+
frame = _FRAMES[key]
|
| 64 |
+
snapshot = frame.to_csv(index_label='candle_open_utc', float_format='%.17g')
|
| 65 |
+
digest = hashlib.sha256(snapshot.encode()).hexdigest()
|
| 66 |
+
if settings.get('expected_snapshot_sha256') and settings['expected_snapshot_sha256'] != digest:
|
| 67 |
+
raise ValueError('Frozen snapshot hash changed; no evaluation was run.')
|
| 68 |
+
return frame, data, digest
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def snapshot_info(settings):
|
| 72 |
+
frame, data, digest = _frame(settings)
|
| 73 |
+
n = len(frame)
|
| 74 |
+
a, b = int(n * .6), int(n * .8)
|
| 75 |
+
bounds = {'TUNE': (0, a), 'VALIDATE': (a, b), 'VAULT': (b, n)}
|
| 76 |
+
return {
|
| 77 |
+
'data_config': data, 'snapshot_sha256': digest, 'candles': n,
|
| 78 |
+
'first_utc': frame.index[0].isoformat(), 'last_utc': frame.index[-1].isoformat(),
|
| 79 |
+
'gaps': int((frame.index.to_series().diff().dropna() != pd.Timedelta(minutes=MINUTES_PER_CANDLE[data['timeframe']])).sum()),
|
| 80 |
+
'splits': {phase: {'start': start, 'end_exclusive': end,
|
| 81 |
+
'first_utc': frame.index[start].isoformat(),
|
| 82 |
+
'last_utc': frame.index[end-1].isoformat(),
|
| 83 |
+
'candles': end-start}
|
| 84 |
+
for phase, (start, end) in bounds.items()},
|
| 85 |
+
'provider_metadata': dict(frame.attrs),
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _atr_series(frame, period=14):
|
| 90 |
+
previous = frame.Close.shift(1)
|
| 91 |
+
tr = pd.concat([frame.High-frame.Low, (frame.High-previous).abs(), (frame.Low-previous).abs()], axis=1).max(axis=1).to_numpy()
|
| 92 |
+
result = np.full(len(frame), np.nan)
|
| 93 |
+
if len(frame) >= period:
|
| 94 |
+
result[period-1] = tr[:period].mean()
|
| 95 |
+
for i in range(period, len(frame)):
|
| 96 |
+
result[i] = (result[i-1]*(period-1)+tr[i])/period
|
| 97 |
+
return result
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _forecasts(frame, positions, settings, digest):
|
| 101 |
+
window = int(settings.get('window', 256))
|
| 102 |
+
horizon = int(settings.get('horizon', 12))
|
| 103 |
+
model_name = settings.get('model', 'TimesFM')
|
| 104 |
+
featured = bool(settings.get('use_features', False))
|
| 105 |
+
conf = RunConfig(**{key: settings.get(key, default) for key, default in DEFAULT_DATA.items()},
|
| 106 |
+
models=(model_name,), window=window, horizon=horizon,
|
| 107 |
+
use_features=featured)
|
| 108 |
+
model = new_configured_model(model_name, conf)
|
| 109 |
+
feature_frame = compute_feature_frame(frame) if featured else None
|
| 110 |
+
cache_key = json.dumps({'sha': digest, 'model': model_name, 'window': window,
|
| 111 |
+
'horizon': horizon, 'features': featured}, sort_keys=True)
|
| 112 |
+
if cache_key not in _FORECASTS:
|
| 113 |
+
path = ROOT / ('forecast_' + hashlib.sha256(cache_key.encode()).hexdigest()[:20] + '.json')
|
| 114 |
+
_FORECASTS[cache_key] = json.loads(path.read_text()) if path.exists() else {}
|
| 115 |
+
cache = _FORECASTS[cache_key]
|
| 116 |
+
path = ROOT / ('forecast_' + hashlib.sha256(cache_key.encode()).hexdigest()[:20] + '.json')
|
| 117 |
+
uncached = [i for i in positions if str(i) not in cache]
|
| 118 |
+
# Real TimesFM batched inference uses the identical history windows. Small
|
| 119 |
+
# batches keep the 200M checkpoint comfortably within free CPU memory.
|
| 120 |
+
if uncached and model_name == 'TimesFM' and not featured:
|
| 121 |
+
for start in range(0, len(uncached), 8):
|
| 122 |
+
batch = uncached[start:start+8]
|
| 123 |
+
histories = [frame.Close.iloc[i-window:i] for i in batch]
|
| 124 |
+
try:
|
| 125 |
+
predictions = model.predict_batch(histories, horizon)
|
| 126 |
+
for i, prediction in zip(batch, predictions):
|
| 127 |
+
cache[str(i)] = {'prediction': prediction, 'error': None}
|
| 128 |
+
except Exception as error:
|
| 129 |
+
for i in batch:
|
| 130 |
+
cache[str(i)] = {'prediction': None, 'error': str(error)}
|
| 131 |
+
if start % 256 == 0:
|
| 132 |
+
path.write_text(json.dumps(cache))
|
| 133 |
+
else:
|
| 134 |
+
for i in uncached:
|
| 135 |
+
try:
|
| 136 |
+
feats = feature_frame.iloc[i-window:i].reset_index(drop=True) if featured else None
|
| 137 |
+
prediction = model.predict(frame.Close.iloc[i-window:i], horizon=horizon, features=feats)
|
| 138 |
+
cache[str(i)] = {'prediction': prediction, 'error': None}
|
| 139 |
+
except Exception as error:
|
| 140 |
+
cache[str(i)] = {'prediction': None, 'error': str(error)}
|
| 141 |
+
if uncached:
|
| 142 |
+
path.write_text(json.dumps(cache))
|
| 143 |
+
return {i: cache[str(i)] for i in positions}, dict(getattr(model, 'last_diagnostics', {}) or {}), len(uncached)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _levels(entry, atr, sign, stop_atr, reward_atr):
|
| 147 |
+
risk, reward = atr*stop_atr, atr*reward_atr
|
| 148 |
+
stop, target = entry-sign*risk, entry+sign*reward
|
| 149 |
+
if min(entry, risk, reward, stop, target) <= 0:
|
| 150 |
+
raise ValueError('Trade has nonpositive risk/levels.')
|
| 151 |
+
return TradeLevels('BUY' if sign > 0 else 'SELL', entry, stop, target,
|
| 152 |
+
risk, reward, reward/risk, atr, entry, entry, .01, 2,
|
| 153 |
+
'closed-candle Wilder ATR; fixed-risk position; real next-open fill')
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _evaluate(frame, positions, forecast, settings, start, end, *, control=None):
|
| 157 |
+
horizon = int(settings.get('horizon', 12))
|
| 158 |
+
holding = int(settings.get('time_stop', horizon))
|
| 159 |
+
delay = int(settings.get('entry_delay', 0))
|
| 160 |
+
costs = CostModel(float(settings.get('spread_bps', 1)), float(settings.get('fee_bps', 1)), float(settings.get('slippage_bps', 1)))
|
| 161 |
+
if min(costs.spread_bps, costs.fee_bps, costs.slippage_bps) < 1:
|
| 162 |
+
raise ValueError('Research requires at least 1/1/1 bps costs.')
|
| 163 |
+
atrs = _atr_series(frame)
|
| 164 |
+
close = frame.Close.to_numpy()
|
| 165 |
+
trend_length = int(settings.get('trend_length', 48))
|
| 166 |
+
mean = frame.Close.rolling(trend_length).mean().to_numpy()
|
| 167 |
+
threshold = float(settings.get('threshold', .5))
|
| 168 |
+
stop_atr = float(settings.get('stop_atr', 1.25))
|
| 169 |
+
rr = float(settings.get('reward_risk', 1.5))
|
| 170 |
+
cost_multiple = float(settings.get('cost_multiple', 1.25))
|
| 171 |
+
strategy = settings.get('strategy', 'forecast')
|
| 172 |
+
next_available = start
|
| 173 |
+
daily = {}
|
| 174 |
+
rows = []
|
| 175 |
+
span = pd.Timedelta(minutes=MINUTES_PER_CANDLE[settings.get('timeframe', '15m')])
|
| 176 |
+
max_daily = int(settings.get('max_trades_per_day', 10000))
|
| 177 |
+
cooldown = int(settings.get('cooldown', 0))
|
| 178 |
+
for i in positions:
|
| 179 |
+
record = forecast[i]
|
| 180 |
+
row = {'origin_index': i, 'signal_utc': (frame.index[i-1]+span).isoformat(),
|
| 181 |
+
'trade_taken': False, 'net_r': np.nan, 'net_r_x2': np.nan,
|
| 182 |
+
'outcome': 'NO_TRADE', 'error': record['error'],
|
| 183 |
+
'entry': np.nan, 'stop_loss': np.nan, 'take_profit': np.nan,
|
| 184 |
+
'exit_price': np.nan, 'direction': None, 'bars_held': 0}
|
| 185 |
+
if record['error']:
|
| 186 |
+
row['outcome'] = 'FORECAST_FAILED'
|
| 187 |
+
rows.append(row)
|
| 188 |
+
continue
|
| 189 |
+
prediction = np.asarray(record['prediction'], dtype=float)
|
| 190 |
+
if len(prediction) != horizon or not np.isfinite(prediction).all() or (prediction <= 0).any():
|
| 191 |
+
raise ValueError('Real model returned an invalid forecast path.')
|
| 192 |
+
atr = atrs[i-1]
|
| 193 |
+
price = close[i-1]
|
| 194 |
+
move = float(prediction[-1]-price)
|
| 195 |
+
sign = 1 if move > 0 else -1 if move < 0 else 0
|
| 196 |
+
row.update(predicted_close=float(prediction[-1]), current_close=price,
|
| 197 |
+
forecast_edge_atr=move/atr, trend_z=(price-mean[i-1])/atr)
|
| 198 |
+
if not np.isfinite(atr) or atr <= 0 or not sign:
|
| 199 |
+
rows.append(row); continue
|
| 200 |
+
cost_price = price*(costs.spread_bps+costs.fee_bps+2*costs.slippage_bps)/10000
|
| 201 |
+
if control != 'naive' and abs(move) < max(threshold*atr, cost_multiple*cost_price):
|
| 202 |
+
rows.append(row); continue
|
| 203 |
+
trend_sign = np.sign(price-mean[i-1])
|
| 204 |
+
if control == 'naive':
|
| 205 |
+
sign = int(np.sign(close[i-1]-close[i-2]))
|
| 206 |
+
elif strategy == 'inverted_forecast':
|
| 207 |
+
sign = -sign
|
| 208 |
+
elif strategy == 'forecast_trend' and sign != trend_sign:
|
| 209 |
+
rows.append(row); continue
|
| 210 |
+
elif strategy == 'forecast_reversion' and sign == trend_sign:
|
| 211 |
+
rows.append(row); continue
|
| 212 |
+
elif strategy == 'trend':
|
| 213 |
+
sign = int(trend_sign)
|
| 214 |
+
elif strategy == 'reversion':
|
| 215 |
+
z = (price-mean[i-1])/atr
|
| 216 |
+
if abs(z) < float(settings.get('regime_threshold', 2)):
|
| 217 |
+
rows.append(row); continue
|
| 218 |
+
sign = -int(np.sign(z))
|
| 219 |
+
elif strategy not in ('forecast','forecast_trend','forecast_reversion','inverted_forecast'):
|
| 220 |
+
raise ValueError('Unknown research strategy.')
|
| 221 |
+
if not sign:
|
| 222 |
+
rows.append(row); continue
|
| 223 |
+
if control == 'inverted':
|
| 224 |
+
sign = -sign
|
| 225 |
+
entry_index = i+delay
|
| 226 |
+
if entry_index < next_available:
|
| 227 |
+
row['outcome'] = 'OVERLAP_SKIPPED'; rows.append(row); continue
|
| 228 |
+
if entry_index+holding > end or entry_index >= len(frame):
|
| 229 |
+
row['outcome'] = 'BOUNDARY_SKIPPED'; rows.append(row); continue
|
| 230 |
+
times = frame.index[i-1:entry_index+holding]
|
| 231 |
+
if not bool((times.to_series().diff().dropna() == span).all()):
|
| 232 |
+
row['outcome'] = 'GAP_SKIPPED'; row['error'] = 'Missing candle/market-closure path'; rows.append(row); continue
|
| 233 |
+
day = str(frame.index[entry_index].date())
|
| 234 |
+
if daily.get(day, 0) >= max_daily:
|
| 235 |
+
row['outcome'] = 'DAILY_LIMIT'; rows.append(row); continue
|
| 236 |
+
hour = frame.index[i-1].hour
|
| 237 |
+
if 'session_start' in settings and not (int(settings['session_start']) <= hour < int(settings.get('session_end', 24))):
|
| 238 |
+
row['outcome'] = 'SESSION_FILTER'; rows.append(row); continue
|
| 239 |
+
if control == 'naive' and not sign:
|
| 240 |
+
rows.append(row); continue
|
| 241 |
+
entry = float(frame.Open.iloc[entry_index])
|
| 242 |
+
# Causal level distances are fixed before the entry candle's high/low.
|
| 243 |
+
# Each trade is one R of fixed cash risk; equity never rescales risk.
|
| 244 |
+
levels = _levels(entry, atr, sign, stop_atr, stop_atr*rr)
|
| 245 |
+
path = frame.iloc[entry_index:entry_index+holding]
|
| 246 |
+
trade = simulate_trade(levels, path, costs)
|
| 247 |
+
stress = simulate_trade(levels, path, CostModel(2*costs.spread_bps,2*costs.fee_bps,2*costs.slippage_bps))
|
| 248 |
+
row.update(trade_taken=True, entry=entry, stop_loss=levels.stop_loss,
|
| 249 |
+
take_profit=levels.take_profit, risk_per_unit=levels.risk_per_unit,
|
| 250 |
+
atr=atr, direction=levels.direction,
|
| 251 |
+
entry_utc=frame.index[entry_index].isoformat(),
|
| 252 |
+
net_r_x2=stress['net_r'], **trade)
|
| 253 |
+
next_available = entry_index+int(trade['bars_held'])+cooldown
|
| 254 |
+
daily[day] = daily.get(day,0)+1
|
| 255 |
+
rows.append(row)
|
| 256 |
+
return pd.DataFrame(rows)
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def _controls(frame, rows, settings, runs=200):
|
| 260 |
+
"""Matched long/short permutations with the same SL/TP/time-stop rules.
|
| 261 |
+
|
| 262 |
+
A matched slot is capped by the following real trade entry, ensuring every
|
| 263 |
+
control is non-overlapping. Both original and control use this same cap.
|
| 264 |
+
"""
|
| 265 |
+
traded = rows[rows.trade_taken].copy()
|
| 266 |
+
if traded.empty:
|
| 267 |
+
return {'runs':runs, 'trades':0, 'mean_r':None, 'p95_mean_r':None, 'p99_mean_r':None}, pd.DataFrame()
|
| 268 |
+
directions = np.where(traded.direction.eq('BUY'),1,-1)
|
| 269 |
+
costs = CostModel(settings.get('spread_bps',1),settings.get('fee_bps',1),settings.get('slippage_bps',1))
|
| 270 |
+
holding = int(settings.get('time_stop',settings.get('horizon',12)))
|
| 271 |
+
stop_atr = float(settings.get('stop_atr',1.25)); rr = float(settings.get('reward_risk',1.5))
|
| 272 |
+
slots = []
|
| 273 |
+
records = traded.to_dict('records')
|
| 274 |
+
for j,row in enumerate(records):
|
| 275 |
+
ix = frame.index.get_loc(pd.Timestamp(row['entry_utc']))
|
| 276 |
+
limit = ix+holding
|
| 277 |
+
if j+1 < len(records):
|
| 278 |
+
limit = min(limit, frame.index.get_loc(pd.Timestamp(records[j+1]['entry_utc'])))
|
| 279 |
+
path = frame.iloc[ix:limit]
|
| 280 |
+
values = {}
|
| 281 |
+
for sign in (1,-1):
|
| 282 |
+
levels = _levels(row['entry'],row['atr'],sign,stop_atr,stop_atr*rr)
|
| 283 |
+
values[sign] = simulate_trade(levels,path,costs)['net_r']
|
| 284 |
+
slots.append(values)
|
| 285 |
+
totals = []
|
| 286 |
+
for seed in range(42,42+int(runs)):
|
| 287 |
+
permutation = np.random.default_rng(seed).permutation(directions)
|
| 288 |
+
returns = np.array([slot[int(sign)] for slot,sign in zip(slots,permutation)])
|
| 289 |
+
totals.append({'seed':seed,'trades':len(returns),'mean_r':float(returns.mean()),'net_profit_r':float(returns.sum())})
|
| 290 |
+
random = pd.DataFrame(totals)
|
| 291 |
+
inverted = np.array([slot[-int(sign)] for slot,sign in zip(slots,directions)])
|
| 292 |
+
original = np.array([slot[int(sign)] for slot,sign in zip(slots,directions)])
|
| 293 |
+
return {'runs':runs,'trades':len(traded),'longs':int((directions>0).sum()),
|
| 294 |
+
'shorts':int((directions<0).sum()),
|
| 295 |
+
'mean_r':float(random.mean_r.mean()),
|
| 296 |
+
'p95_mean_r':float(random.mean_r.quantile(.95)),
|
| 297 |
+
'p99_mean_r':float(random.mean_r.quantile(.99)),
|
| 298 |
+
'original_matched_mean_r':float(original.mean()),
|
| 299 |
+
'inverted_mean_r':float(inverted.mean()),
|
| 300 |
+
'inverted_profit_r':float(inverted.sum())}, random
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def research_run(settings):
|
| 304 |
+
if isinstance(settings,str):
|
| 305 |
+
settings = json.loads(settings)
|
| 306 |
+
settings = dict(settings)
|
| 307 |
+
if settings.pop('snapshot_only',False):
|
| 308 |
+
return _clean(snapshot_info(settings)), None
|
| 309 |
+
frame,data,digest = _frame(settings)
|
| 310 |
+
settings.update(data)
|
| 311 |
+
n=len(frame); a=int(n*.6); b=int(n*.8)
|
| 312 |
+
phase=settings.get('phase','TUNE')
|
| 313 |
+
if phase not in ('TUNE','VALIDATE','VAULT'):
|
| 314 |
+
raise ValueError('Choose exactly one TUNE, VALIDATE, or VAULT phase.')
|
| 315 |
+
start,end={'TUNE':(0,a),'VALIDATE':(a,b),'VAULT':(b,n)}[phase]
|
| 316 |
+
window=int(settings.get('window',256)); horizon=int(settings.get('horizon',12))
|
| 317 |
+
stride=int(settings.get('step',6)); max_eval=int(settings.get('max_evaluations',20000))
|
| 318 |
+
holding=int(settings.get('time_stop',horizon)); delay=int(settings.get('entry_delay',0))
|
| 319 |
+
if not (32<=window<=2048 and 1<=horizon<=64 and stride>=1 and 1<=holding<=512 and delay in (0,1)):
|
| 320 |
+
raise ValueError('Invalid window/horizon/stride/time-stop/entry-delay.')
|
| 321 |
+
positions=list(range(max(window,start),end-max(horizon,holding+delay)+1,stride))
|
| 322 |
+
if len(positions)>max_eval:
|
| 323 |
+
raise ValueError(f'{len(positions)} evaluation origins exceed max_evaluations; increase it to cover the full frozen phase.')
|
| 324 |
+
predictions,diagnostics,new_inferences=_forecasts(frame,positions,settings,digest)
|
| 325 |
+
rows=_evaluate(frame,positions,predictions,settings,start,end)
|
| 326 |
+
summary=summarize_trades(rows)
|
| 327 |
+
traded=rows[rows.trade_taken & rows.net_r.notna()]
|
| 328 |
+
summary.update(phase=phase,evaluations=len(rows),failed_evaluations=int(rows.error.notna().sum()),
|
| 329 |
+
failed_pct=100*float(rows.error.notna().mean()) if len(rows) else None,
|
| 330 |
+
net_profit_r_x2=float(traded.net_r_x2.sum()) if len(traded) else None,
|
| 331 |
+
win_rate_net_pct=100*float(traded.net_r.gt(0).mean()) if len(traded) else None,
|
| 332 |
+
fresh_model_inferences=new_inferences)
|
| 333 |
+
naive=_evaluate(frame,positions,predictions,settings,start,end,control='naive')
|
| 334 |
+
summary['naive']=summarize_trades(naive)
|
| 335 |
+
random_rows=pd.DataFrame()
|
| 336 |
+
if settings.get('audit',False):
|
| 337 |
+
summary['controls'],random_rows=_controls(frame,rows,settings,200)
|
| 338 |
+
run_id=hashlib.sha256(json.dumps(settings,sort_keys=True).encode()).hexdigest()[:12]+'_'+str(time.time_ns())
|
| 339 |
+
directory=ROOT/run_id; directory.mkdir(parents=True,exist_ok=False)
|
| 340 |
+
# Export only active phase plus its causal history, keeping the vault sealed
|
| 341 |
+
# in TUNE/VALIDATE artifacts. The complete snapshot hash identifies it.
|
| 342 |
+
phase_frame=frame.iloc[max(0,start-window):end]
|
| 343 |
+
phase_frame.to_csv(directory/'candles.csv',index_label='candle_open_utc',float_format='%.17g')
|
| 344 |
+
rows.to_csv(directory/'trades.csv',index=False,float_format='%.17g')
|
| 345 |
+
naive.to_csv(directory/'naive_trades.csv',index=False,float_format='%.17g')
|
| 346 |
+
random_rows.to_csv(directory/'random_runs.csv',index=False,float_format='%.17g')
|
| 347 |
+
clean_summary=_clean(summary)
|
| 348 |
+
(directory/'summary.json').write_text(json.dumps(clean_summary,indent=2))
|
| 349 |
+
manifest={'config':settings,'full_snapshot_sha256':digest,'phase':phase,
|
| 350 |
+
'phase_start_index':start,'phase_end_index_exclusive':end,
|
| 351 |
+
'snapshot':snapshot_info(settings),'diagnostics':diagnostics,
|
| 352 |
+
'execution_policy':'fixed 1R cash risk, next open, no overlap, SL first, gap paths rejected, 1/1/1 bps minimum, fees round-trip, slippage both sides',
|
| 353 |
+
'source_revision':__import__('os').getenv('SPACE_COMMIT_SHA',None),
|
| 354 |
+
'packages':{name:importlib.metadata.version(name) for name in ('numpy','pandas','torch','timesfm')}}
|
| 355 |
+
(directory/'manifest.json').write_text(json.dumps(_clean(manifest),indent=2,default=str))
|
| 356 |
+
zip_path=directory.with_suffix('.zip')
|
| 357 |
+
with zipfile.ZipFile(zip_path,'w',zipfile.ZIP_DEFLATED) as archive:
|
| 358 |
+
for file in directory.iterdir(): archive.write(file,file.name)
|
| 359 |
+
return clean_summary,str(zip_path)
|
risk_management.py
CHANGED
|
@@ -205,6 +205,7 @@ def build_trade_levels(
|
|
| 205 |
minimum_risk_reward: float = MIN_RISK_REWARD,
|
| 206 |
use_structure_stop: bool = True,
|
| 207 |
minimum_edge_bps: float = 0.0,
|
|
|
|
| 208 |
) -> TradeLevels | None:
|
| 209 |
"""Build causal, volatility-aware levels for one closed-candle signal.
|
| 210 |
|
|
@@ -227,10 +228,15 @@ def build_trade_levels(
|
|
| 227 |
minimum_edge_bps = float(minimum_edge_bps)
|
| 228 |
except (TypeError, ValueError):
|
| 229 |
raise ValueError('minimum_edge_atr/minimum_edge_bps must be numeric and minimum_risk_reward must be numeric.') from None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
if (isinstance(minimum_edge_atr, (bool, np.bool_)) or not np.isfinite(minimum_edge_atr) or minimum_edge_atr < 0 or
|
| 231 |
isinstance(minimum_risk_reward, (bool, np.bool_)) or not np.isfinite(minimum_risk_reward) or minimum_risk_reward <= 0 or
|
| 232 |
-
isinstance(minimum_edge_bps, (bool, np.bool_)) or not np.isfinite(minimum_edge_bps) or minimum_edge_bps < 0
|
| 233 |
-
|
|
|
|
| 234 |
if (isinstance(atr_period, (bool, np.bool_)) or not isinstance(atr_period, numbers.Integral) or atr_period < 1 or
|
| 235 |
isinstance(swing_lookback, (bool, np.bool_)) or not isinstance(swing_lookback, numbers.Integral) or swing_lookback < 1):
|
| 236 |
raise ValueError('atr_period and swing_lookback must be positive integers.')
|
|
@@ -273,7 +279,7 @@ def build_trade_levels(
|
|
| 273 |
resistance = float(pd.to_numeric(structure['High'], errors='coerce').max())
|
| 274 |
buffer = max(ATR_STRUCTURE_BUFFER * atr, 2.0 * tick)
|
| 275 |
if direction == 'BUY':
|
| 276 |
-
atr_stop = entry -
|
| 277 |
stop_raw = min(atr_stop, support - buffer) if use_structure_stop else atr_stop
|
| 278 |
forecast_valid = (forecast_value is not None and
|
| 279 |
np.isfinite(forecast_value) and forecast_value > 0 and forecast_value > entry)
|
|
@@ -289,7 +295,7 @@ def build_trade_levels(
|
|
| 289 |
risk = entry - stop
|
| 290 |
reward = target - entry
|
| 291 |
else:
|
| 292 |
-
atr_stop = entry +
|
| 293 |
stop_raw = max(atr_stop, resistance + buffer) if use_structure_stop else atr_stop
|
| 294 |
forecast_valid = (forecast_value is not None and
|
| 295 |
np.isfinite(forecast_value) and forecast_value > 0 and forecast_value < entry)
|
|
@@ -386,6 +392,8 @@ def simulate_trade(levels: TradeLevels, path: pd.DataFrame, costs: CostModel) ->
|
|
| 386 |
if direction == 'BUY':
|
| 387 |
if opening is not None and opening <= levels.stop_loss:
|
| 388 |
exit_price, outcome = opening, 'SL'
|
|
|
|
|
|
|
| 389 |
elif opening is not None and opening >= levels.take_profit:
|
| 390 |
exit_price, outcome = opening, 'TP'
|
| 391 |
elif low <= levels.stop_loss:
|
|
@@ -397,6 +405,8 @@ def simulate_trade(levels: TradeLevels, path: pd.DataFrame, costs: CostModel) ->
|
|
| 397 |
else:
|
| 398 |
if opening is not None and opening >= levels.stop_loss:
|
| 399 |
exit_price, outcome = opening, 'SL'
|
|
|
|
|
|
|
| 400 |
elif opening is not None and opening <= levels.take_profit:
|
| 401 |
exit_price, outcome = opening, 'TP'
|
| 402 |
elif high >= levels.stop_loss:
|
|
@@ -439,8 +449,8 @@ def summarize_trades(details: pd.DataFrame) -> dict:
|
|
| 439 |
'net_profit_r': None, 'top3_removed_profit_r': None,
|
| 440 |
}
|
| 441 |
returns = trades['net_r'].astype(float).to_numpy()
|
| 442 |
-
wins = int((
|
| 443 |
-
losses = int((
|
| 444 |
timeouts = int((trades['outcome'] == 'TIMEOUT').sum())
|
| 445 |
gross_profit = float(returns[returns > 0].sum())
|
| 446 |
gross_loss = float(-returns[returns < 0].sum())
|
|
|
|
| 205 |
minimum_risk_reward: float = MIN_RISK_REWARD,
|
| 206 |
use_structure_stop: bool = True,
|
| 207 |
minimum_edge_bps: float = 0.0,
|
| 208 |
+
atr_stop_multiplier: float = ATR_STOP_MULTIPLIER,
|
| 209 |
) -> TradeLevels | None:
|
| 210 |
"""Build causal, volatility-aware levels for one closed-candle signal.
|
| 211 |
|
|
|
|
| 228 |
minimum_edge_bps = float(minimum_edge_bps)
|
| 229 |
except (TypeError, ValueError):
|
| 230 |
raise ValueError('minimum_edge_atr/minimum_edge_bps must be numeric and minimum_risk_reward must be numeric.') from None
|
| 231 |
+
try:
|
| 232 |
+
atr_stop_multiplier = float(atr_stop_multiplier)
|
| 233 |
+
except (TypeError, ValueError):
|
| 234 |
+
raise ValueError('atr_stop_multiplier must be a finite positive number.') from None
|
| 235 |
if (isinstance(minimum_edge_atr, (bool, np.bool_)) or not np.isfinite(minimum_edge_atr) or minimum_edge_atr < 0 or
|
| 236 |
isinstance(minimum_risk_reward, (bool, np.bool_)) or not np.isfinite(minimum_risk_reward) or minimum_risk_reward <= 0 or
|
| 237 |
+
isinstance(minimum_edge_bps, (bool, np.bool_)) or not np.isfinite(minimum_edge_bps) or minimum_edge_bps < 0 or
|
| 238 |
+
not np.isfinite(atr_stop_multiplier) or atr_stop_multiplier <= 0):
|
| 239 |
+
raise ValueError('minimum_edge_atr/minimum_edge_bps must be non-negative, minimum_risk_reward and atr_stop_multiplier must be positive.')
|
| 240 |
if (isinstance(atr_period, (bool, np.bool_)) or not isinstance(atr_period, numbers.Integral) or atr_period < 1 or
|
| 241 |
isinstance(swing_lookback, (bool, np.bool_)) or not isinstance(swing_lookback, numbers.Integral) or swing_lookback < 1):
|
| 242 |
raise ValueError('atr_period and swing_lookback must be positive integers.')
|
|
|
|
| 279 |
resistance = float(pd.to_numeric(structure['High'], errors='coerce').max())
|
| 280 |
buffer = max(ATR_STRUCTURE_BUFFER * atr, 2.0 * tick)
|
| 281 |
if direction == 'BUY':
|
| 282 |
+
atr_stop = entry - atr_stop_multiplier * atr
|
| 283 |
stop_raw = min(atr_stop, support - buffer) if use_structure_stop else atr_stop
|
| 284 |
forecast_valid = (forecast_value is not None and
|
| 285 |
np.isfinite(forecast_value) and forecast_value > 0 and forecast_value > entry)
|
|
|
|
| 295 |
risk = entry - stop
|
| 296 |
reward = target - entry
|
| 297 |
else:
|
| 298 |
+
atr_stop = entry + atr_stop_multiplier * atr
|
| 299 |
stop_raw = max(atr_stop, resistance + buffer) if use_structure_stop else atr_stop
|
| 300 |
forecast_valid = (forecast_value is not None and
|
| 301 |
np.isfinite(forecast_value) and forecast_value > 0 and forecast_value < entry)
|
|
|
|
| 392 |
if direction == 'BUY':
|
| 393 |
if opening is not None and opening <= levels.stop_loss:
|
| 394 |
exit_price, outcome = opening, 'SL'
|
| 395 |
+
elif low <= levels.stop_loss and high >= levels.take_profit:
|
| 396 |
+
exit_price, outcome = levels.stop_loss, 'SL'
|
| 397 |
elif opening is not None and opening >= levels.take_profit:
|
| 398 |
exit_price, outcome = opening, 'TP'
|
| 399 |
elif low <= levels.stop_loss:
|
|
|
|
| 405 |
else:
|
| 406 |
if opening is not None and opening >= levels.stop_loss:
|
| 407 |
exit_price, outcome = opening, 'SL'
|
| 408 |
+
elif high >= levels.stop_loss and low <= levels.take_profit:
|
| 409 |
+
exit_price, outcome = levels.stop_loss, 'SL'
|
| 410 |
elif opening is not None and opening <= levels.take_profit:
|
| 411 |
exit_price, outcome = opening, 'TP'
|
| 412 |
elif high >= levels.stop_loss:
|
|
|
|
| 449 |
'net_profit_r': None, 'top3_removed_profit_r': None,
|
| 450 |
}
|
| 451 |
returns = trades['net_r'].astype(float).to_numpy()
|
| 452 |
+
wins = int((returns > 0).sum())
|
| 453 |
+
losses = int((returns < 0).sum())
|
| 454 |
timeouts = int((trades['outcome'] == 'TIMEOUT').sum())
|
| 455 |
gross_profit = float(returns[returns > 0].sum())
|
| 456 |
gross_loss = float(-returns[returns < 0].sum())
|