3VVM commited on
Commit
aa4554f
·
1 Parent(s): 9831ced

Make Space backtests causal and auditable

Browse files
README.md CHANGED
@@ -31,9 +31,14 @@ missing candle, and writes a data snapshot plus manifest for every completed run
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
 
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
+ - The Space installs the real TimesFM/Moirai runtime dependencies; foundation
35
+ model failures are surfaced rather than replaced by a fallback forecast.
36
  - Walk-forward backtest uses training data only at each origin, checks the
37
  selected candle duration, skips gap-crossing targets, reports failures and
38
  coverage, and includes a last-close persistence baseline.
39
+ - Backtests use the next candle's open as the entry, keep costs explicit, and
40
+ report separate 65% tuning and 35% final-check phases. The optional,
41
+ default-off random-signal control is available for leakage sanity checks.
42
  - Live results are blocked when the latest closed candle is stale. The MAYTHOS
43
  heuristic and forecast metrics are shown separately.
44
  - Actionable live signals include causal Entry, Stop Loss, Take Profit and
app.py CHANGED
@@ -32,11 +32,12 @@ 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):
36
  return RunConfig(provider=provider, market=market, symbol=symbol, timeframe=timeframe,
37
  mode=mode, models=tuple(models or ()), lookback_days=days, window=window,
38
  horizon=horizon, step=step, max_evaluations=evaluations, use_features=features,
39
- spread_bps=spread_bps, fee_bps=fee_bps, slippage_bps=slippage_bps)
 
40
 
41
 
42
  def metadata_text(metadata):
@@ -127,7 +128,8 @@ def run_backtest_ui(*args):
127
  status = (
128
  f"### Walk-forward evaluation completed — {failed} failed/skipped model evaluations\n\n"
129
  f"History window: **{config.window}** · horizon: **{config.horizon}** · origin stride: **{config.step}** · "
130
- f"at most **{config.max_evaluations} most-recent evaluation origins per model**. "
 
131
  'Orders are reselected using only each training window; no future target prices/features are supplied to models. '
132
  'Targets crossing gaps/market closures are explicitly skipped. Flat prices are classified as FLAT.\n\n'
133
  '**Accuracy denominator:** successful forecast evaluations; failures and coverage are shown separately. '
@@ -205,12 +207,13 @@ with gr.Blocks(title='MYTHOS · MAYTHOS × RINKING', theme=THEME, css=CSS) as de
205
  spread_bps = gr.Number(value=1.0, minimum=0, maximum=1000, label='Assumed spread (bps)')
206
  fee_bps = gr.Number(value=1.0, minimum=0, maximum=1000, label='Assumed fees (bps, round trip)')
207
  slippage_bps = gr.Number(value=1.0, minimum=0, maximum=1000, label='Assumed slippage (bps)')
208
- gr.Markdown('ARIMA-family order selection runs at every evaluation origin and may take several minutes. SL/TP uses closed-candle Wilder ATR plus prior swing structure; levels are omitted when no valid positive-risk target exists. Parallel mode runs independent models concurrently (two workers). Future indicator covariates use the last observed values. Moirai/TimesFM require their real pretrained weights.')
 
209
  with gr.Accordion('Model dependency status', open=False):
210
  gr.Dataframe(value=model_readiness(), interactive=False)
211
  gr.Markdown('“Packages detected” is not an inference or accuracy certification. See README for installing optional foundation models.')
212
 
213
- inputs = [provider, market, symbol, timeframe, mode, models, days, window, horizon, step, evaluations, features, spread_bps, fee_bps, slippage_bps]
214
  with gr.Tab('Live Signal'):
215
  live_button = gr.Button('Refresh live signal', variant='primary')
216
  live_summary = gr.Markdown('Choose your configuration and refresh to fetch real closed candles.')
 
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):
 
128
  status = (
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 contains separate **TUNING (65%)** and **FINAL_CHECK (35%)** rows; final-check target candles are not used during tuning. "
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
  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
+ random_baseline = gr.Checkbox(value=False, label='Random-signal baseline (audit mode; default off)')
211
+ 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. The backtest report splits ordered candles into 65% tuning and a final 35% check; final targets are never used during tuning. Parallel mode runs independent models concurrently (two workers). Future indicator covariates use the last observed values. Moirai/TimesFM require their real pretrained weights.')
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.')
backtest/backtester.py CHANGED
@@ -2,7 +2,10 @@ 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 MINUTES_PER_CANDLE, price_direction
7
 
8
 
@@ -25,6 +28,8 @@ def backtest_model(
25
  market: str = 'Crypto',
26
  symbol: str = '',
27
  costs: CostModel = None,
 
 
28
  ) -> pd.DataFrame:
29
  if not isinstance(df, pd.DataFrame):
30
  raise TypeError('Backtest candles must be provided as a pandas DataFrame.')
@@ -32,6 +37,9 @@ def backtest_model(
32
  raise ValueError('Window, horizon, and step must be positive integers.')
33
  if max_evaluations is not None and (isinstance(max_evaluations, (bool, np.bool_)) or not isinstance(max_evaluations, (int, np.integer)) or max_evaluations < 1):
34
  raise ValueError('max_evaluations must be a positive integer.')
 
 
 
35
  if features_df is not None and len(features_df) != len(df):
36
  raise ValueError('Features must align with every OHLCV row.')
37
  if features_df is not None and not isinstance(features_df, pd.DataFrame):
@@ -63,6 +71,10 @@ def backtest_model(
63
  last_i = len(close) - horizon
64
  rows = []
65
  positions = list(range(window, last_i + 1, step))
 
 
 
 
66
  if max_evaluations is not None:
67
  if max_evaluations < 1:
68
  raise ValueError('max_evaluations must be positive.')
@@ -121,6 +133,10 @@ def backtest_model(
121
  levels = build_trade_levels(
122
  df.iloc[:i], trade_direction, market, symbol,
123
  forecast_target=predicted_next_close,
 
 
 
 
124
  # Only trade a forecast that clears volatility noise and has a
125
  # target the model actually predicted. Previously a tiny forecast
126
  # was replaced by a distant swing target, creating timeout losses
@@ -169,6 +185,131 @@ def backtest_model(
169
  return pd.DataFrame(rows)
170
 
171
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  def summarize_backtest(results: pd.DataFrame, model_name: str) -> dict:
173
  if results.empty:
174
  return {
@@ -227,12 +368,22 @@ def run_all_models_backtest(
227
  horizon: int = 1,
228
  step: int = 1,
229
  features_df: pd.DataFrame = None,
 
 
 
 
 
 
 
230
  ):
231
  per_model_results = {}
232
  per_model_summary = {}
233
  for name, model in models.items():
234
  results = backtest_model(
235
- df, model, window=window, horizon=horizon, step=step, features_df=features_df
 
 
 
236
  )
237
  per_model_results[name] = results
238
  per_model_summary[name] = summarize_backtest(results, name)
@@ -250,6 +401,13 @@ def run_comparison_backtest(
250
  window: int = 100,
251
  horizon: int = 1,
252
  step: int = 1,
 
 
 
 
 
 
 
253
  ):
254
  per_model_results = {}
255
  per_model_summary = {}
@@ -261,7 +419,10 @@ def run_comparison_backtest(
261
  (FEATURED_LABEL, featured_model, features_df),
262
  ):
263
  results = backtest_model(
264
- df, model, window=window, horizon=horizon, step=step, features_df=feats
 
 
 
265
  )
266
  summary = summarize_backtest(results, name)
267
  summary["mode"] = label
 
2
  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
10
 
11
 
 
28
  market: str = 'Crypto',
29
  symbol: str = '',
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.')
 
37
  raise ValueError('Window, horizon, and step must be positive integers.')
38
  if max_evaluations is not None and (isinstance(max_evaluations, (bool, np.bool_)) or not isinstance(max_evaluations, (int, np.integer)) or max_evaluations < 1):
39
  raise ValueError('max_evaluations must be a positive integer.')
40
+ for name, value in (('origin_start', origin_start), ('origin_end', origin_end)):
41
+ if value is not None and (isinstance(value, (bool, np.bool_)) or not isinstance(value, (int, np.integer)) or value < 0):
42
+ raise ValueError(f'{name} must be a non-negative integer when supplied.')
43
  if features_df is not None and len(features_df) != len(df):
44
  raise ValueError('Features must align with every OHLCV row.')
45
  if features_df is not None and not isinstance(features_df, pd.DataFrame):
 
71
  last_i = len(close) - horizon
72
  rows = []
73
  positions = list(range(window, last_i + 1, step))
74
+ if origin_start is not None:
75
+ positions = [position for position in positions if position >= origin_start]
76
+ if origin_end is not None:
77
+ positions = [position for position in positions if position < origin_end]
78
  if max_evaluations is not None:
79
  if max_evaluations < 1:
80
  raise ValueError('max_evaluations must be positive.')
 
133
  levels = build_trade_levels(
134
  df.iloc[:i], trade_direction, market, symbol,
135
  forecast_target=predicted_next_close,
136
+ # The signal is formed on close[i - 1]. The order is filled on
137
+ # the next candle's real open, while all levels still use only
138
+ # history through the closed signal candle.
139
+ entry_price=float(df['Open'].iloc[i]),
140
  # Only trade a forecast that clears volatility noise and has a
141
  # target the model actually predicted. Previously a tiny forecast
142
  # was replaced by a distant swing target, creating timeout losses
 
185
  return pd.DataFrame(rows)
186
 
187
 
188
+ def backtest_random_signals(
189
+ df: pd.DataFrame,
190
+ window: int = 100,
191
+ horizon: int = 1,
192
+ step: int = 1,
193
+ max_evaluations: int = None,
194
+ timeframe: str = None,
195
+ market: str = 'Crypto',
196
+ symbol: str = '',
197
+ costs: CostModel = None,
198
+ seed: int = 42,
199
+ origin_start: int = None,
200
+ origin_end: int = None,
201
+ ) -> pd.DataFrame:
202
+ """Run a deterministic random-direction control using only causal levels.
203
+
204
+ The direction is random and independently reproducible for each origin.
205
+ Its target is two historical ATRs from the signal close, so the control
206
+ has the same conservative level construction and explicit costs as model
207
+ trades without using a realized future price.
208
+ """
209
+ if not isinstance(df, pd.DataFrame):
210
+ raise TypeError('Backtest candles must be provided as a pandas DataFrame.')
211
+ if any(isinstance(value, (bool, np.bool_)) or not isinstance(value, (int, np.integer)) or value < 1 for value in (window, horizon, step)):
212
+ raise ValueError('Window, horizon, and step must be positive integers.')
213
+ if max_evaluations is not None and (isinstance(max_evaluations, (bool, np.bool_)) or not isinstance(max_evaluations, (int, np.integer)) or max_evaluations < 1):
214
+ raise ValueError('max_evaluations must be a positive integer.')
215
+ if timeframe is not None and timeframe not in MINUTES_PER_CANDLE:
216
+ raise ValueError(f'Unknown timeframe {timeframe!r}.')
217
+ for name, value in (('origin_start', origin_start), ('origin_end', origin_end)):
218
+ if value is not None and (isinstance(value, (bool, np.bool_)) or not isinstance(value, (int, np.integer)) or value < 0):
219
+ raise ValueError(f'{name} must be a non-negative integer when supplied.')
220
+ if df.empty or len(df) < window + horizon or not df.index.is_monotonic_increasing or df.index.duplicated().any():
221
+ raise ValueError('Backtest candles must be non-empty, chronologically ordered, and unique.')
222
+ required = {'Open', 'High', 'Low', 'Close'}
223
+ if not required.issubset(df.columns):
224
+ raise ValueError(f'Backtest candles are missing columns: {sorted(required - set(df.columns))}.')
225
+ ohlc = df[['Open', 'High', 'Low', 'Close']].apply(pd.to_numeric, errors='coerce')
226
+ if (not np.isfinite(ohlc.to_numpy(dtype=float)).all() or (ohlc <= 0).any().any() or
227
+ (ohlc['High'] < ohlc[['Open', 'Close', 'Low']].max(axis=1)).any() or
228
+ (ohlc['Low'] > ohlc[['Open', 'Close', 'High']].min(axis=1)).any()):
229
+ raise ValueError('Backtest candles contain invalid/non-finite OHLC observations.')
230
+ if costs is None:
231
+ costs = CostModel()
232
+ elif not isinstance(costs, CostModel):
233
+ raise TypeError('costs must be a CostModel instance.')
234
+ df = df.copy()
235
+ df[['Open', 'High', 'Low', 'Close']] = ohlc.astype(float)
236
+ close = df['Close']
237
+ timestamps = [ts.isoformat() if hasattr(ts, 'isoformat') else ts for ts in df.index]
238
+ last_i = len(close) - horizon
239
+ positions = list(range(window, last_i + 1, step))
240
+ if origin_start is not None:
241
+ positions = [position for position in positions if position >= origin_start]
242
+ if origin_end is not None:
243
+ positions = [position for position in positions if position < origin_end]
244
+ if max_evaluations is not None:
245
+ positions = positions[-max_evaluations:]
246
+ rows = []
247
+ for i in positions:
248
+ current_close = float(close.iloc[i - 1])
249
+ target_idx = i + horizon - 1
250
+ actual_next_close = float(close.iloc[target_idx])
251
+ continuous = True
252
+ try:
253
+ if timeframe:
254
+ expected_step = pd.Timedelta(minutes=MINUTES_PER_CANDLE[timeframe])
255
+ target_times = df.index[i - 1:target_idx + 1]
256
+ continuous = bool((target_times.to_series().diff().dropna() == expected_step).all())
257
+ if not continuous:
258
+ raise ValueError('Target crosses a missing candle or market closure; not a continuous timeframe horizon.')
259
+ atr = average_true_range(df.iloc[:i][['High', 'Low', 'Close']])
260
+ if not np.isfinite(atr) or atr <= 0:
261
+ raise ValueError('Causal ATR is unavailable for the random control.')
262
+ direction = 'BUY' if np.random.default_rng(int(seed) + i * 1009).integers(0, 2) == 1 else 'SELL'
263
+ sign = 1.0 if direction == 'BUY' else -1.0
264
+ random_target = current_close + sign * 2.0 * float(atr)
265
+ levels = build_trade_levels(
266
+ df.iloc[:i], direction, market, symbol,
267
+ forecast_target=random_target,
268
+ entry_price=float(df['Open'].iloc[i]),
269
+ forecast_only=True,
270
+ minimum_edge_atr=0.20,
271
+ minimum_risk_reward=1.20,
272
+ use_structure_stop=False,
273
+ minimum_edge_bps=(
274
+ costs.spread_bps + costs.fee_bps + 2.0 * costs.slippage_bps
275
+ ) * 1.25,
276
+ )
277
+ if levels is None:
278
+ raise ValueError('Causal random-control levels did not clear the execution gate.')
279
+ trade = simulate_trade(levels, df.iloc[i:target_idx + 1], costs)
280
+ error = None
281
+ except Exception as exc:
282
+ direction = None
283
+ random_target = np.nan
284
+ levels = None
285
+ trade = {'outcome': 'RANDOM_FAILED', 'exit_price': np.nan, 'net_r': np.nan, 'bars_held': 0}
286
+ error = str(exc)
287
+ actual_direction = price_direction(actual_next_close, current_close) if direction else None
288
+ predicted_direction = {'BUY': 'up', 'SELL': 'down'}.get(direction)
289
+ rows.append({
290
+ 'datetime': timestamps[target_idx],
291
+ 'current_close': current_close,
292
+ 'predicted_close': random_target,
293
+ 'actual_close': actual_next_close,
294
+ 'predicted_direction': predicted_direction,
295
+ 'actual_direction': actual_direction,
296
+ 'correct': predicted_direction == actual_direction if predicted_direction else None,
297
+ 'continuous_target': continuous,
298
+ 'trade_taken': levels is not None,
299
+ 'entry': levels.entry if levels else np.nan,
300
+ 'stop_loss': levels.stop_loss if levels else np.nan,
301
+ 'take_profit': levels.take_profit if levels else np.nan,
302
+ 'risk_reward': levels.risk_reward if levels else np.nan,
303
+ 'atr': levels.atr if levels else np.nan,
304
+ 'outcome': trade.get('outcome'),
305
+ 'exit_price': trade.get('exit_price'),
306
+ 'net_r': trade.get('net_r'),
307
+ 'bars_held': trade.get('bars_held', 0),
308
+ 'error': error,
309
+ })
310
+ return pd.DataFrame(rows)
311
+
312
+
313
  def summarize_backtest(results: pd.DataFrame, model_name: str) -> dict:
314
  if results.empty:
315
  return {
 
368
  horizon: int = 1,
369
  step: int = 1,
370
  features_df: pd.DataFrame = None,
371
+ origin_start: int = None,
372
+ origin_end: int = None,
373
+ max_evaluations: int = None,
374
+ timeframe: str = None,
375
+ market: str = 'Crypto',
376
+ symbol: str = '',
377
+ costs: CostModel = None,
378
  ):
379
  per_model_results = {}
380
  per_model_summary = {}
381
  for name, model in models.items():
382
  results = backtest_model(
383
+ df, model, window=window, horizon=horizon, step=step, features_df=features_df,
384
+ origin_start=origin_start, origin_end=origin_end,
385
+ max_evaluations=max_evaluations, timeframe=timeframe,
386
+ market=market, symbol=symbol, costs=costs,
387
  )
388
  per_model_results[name] = results
389
  per_model_summary[name] = summarize_backtest(results, name)
 
401
  window: int = 100,
402
  horizon: int = 1,
403
  step: int = 1,
404
+ origin_start: int = None,
405
+ origin_end: int = None,
406
+ max_evaluations: int = None,
407
+ timeframe: str = None,
408
+ market: str = 'Crypto',
409
+ symbol: str = '',
410
+ costs: CostModel = None,
411
  ):
412
  per_model_results = {}
413
  per_model_summary = {}
 
419
  (FEATURED_LABEL, featured_model, features_df),
420
  ):
421
  results = backtest_model(
422
+ df, model, window=window, horizon=horizon, step=step, features_df=feats,
423
+ origin_start=origin_start, origin_end=origin_end,
424
+ max_evaluations=max_evaluations, timeframe=timeframe,
425
+ market=market, symbol=symbol, costs=costs,
426
  )
427
  summary = summarize_backtest(results, name)
428
  summary["mode"] = label
maythos_integration.py CHANGED
@@ -90,17 +90,32 @@ def backtest_maythos(
90
  market: str = 'Crypto',
91
  symbol: str = '',
92
  costs: CostModel = None,
 
 
93
  ) -> Tuple[pd.DataFrame, Dict[str, Any]]:
94
  """Evaluate MAYTHOS direction only when its signal is execution-ready."""
95
  window = positive_integer(window, 'MAYTHOS backtest window')
96
  horizon = positive_integer(horizon, 'MAYTHOS backtest horizon')
97
  step = positive_integer(step, 'MAYTHOS backtest step')
 
 
 
 
 
 
 
 
 
98
  frame = _normalise_frame(df)
99
  costs = costs or CostModel()
100
  outputs = _run_engine(frame)
101
  last_i = len(frame) - horizon
102
  rows = []
103
  positions = list(range(window, last_i + 1, step))
 
 
 
 
104
  if max_evaluations is not None:
105
  max_evaluations = positive_integer(max_evaluations, 'MAYTHOS max_evaluations')
106
  positions = positions[-max_evaluations:]
@@ -129,6 +144,7 @@ def backtest_maythos(
129
  levels = (
130
  build_trade_levels(
131
  frame.iloc[:i], output['direction'], market, symbol,
 
132
  minimum_edge_atr=0.20, minimum_risk_reward=1.20,
133
  minimum_edge_bps=(
134
  costs.spread_bps + costs.fee_bps + 2.0 * costs.slippage_bps
 
90
  market: str = 'Crypto',
91
  symbol: str = '',
92
  costs: CostModel = None,
93
+ origin_start: int = None,
94
+ origin_end: int = None,
95
  ) -> Tuple[pd.DataFrame, Dict[str, Any]]:
96
  """Evaluate MAYTHOS direction only when its signal is execution-ready."""
97
  window = positive_integer(window, 'MAYTHOS backtest window')
98
  horizon = positive_integer(horizon, 'MAYTHOS backtest horizon')
99
  step = positive_integer(step, 'MAYTHOS backtest step')
100
+ for name, value in (('origin_start', origin_start), ('origin_end', origin_end)):
101
+ if value is not None:
102
+ if isinstance(value, (bool, np.bool_)) or not isinstance(value, (int, np.integer)) or value < 0:
103
+ raise ValueError(f'MAYTHOS {name} must be a non-negative integer.')
104
+ value = int(value)
105
+ if name == 'origin_start':
106
+ origin_start = value
107
+ else:
108
+ origin_end = value
109
  frame = _normalise_frame(df)
110
  costs = costs or CostModel()
111
  outputs = _run_engine(frame)
112
  last_i = len(frame) - horizon
113
  rows = []
114
  positions = list(range(window, last_i + 1, step))
115
+ if origin_start is not None:
116
+ positions = [position for position in positions if position >= origin_start]
117
+ if origin_end is not None:
118
+ positions = [position for position in positions if position < origin_end]
119
  if max_evaluations is not None:
120
  max_evaluations = positive_integer(max_evaluations, 'MAYTHOS max_evaluations')
121
  positions = positions[-max_evaluations:]
 
144
  levels = (
145
  build_trade_levels(
146
  frame.iloc[:i], output['direction'], market, symbol,
147
+ entry_price=float(frame['Open'].iloc[i]),
148
  minimum_edge_atr=0.20, minimum_risk_reward=1.20,
149
  minimum_edge_bps=(
150
  costs.spread_bps + costs.fee_bps + 2.0 * costs.slippage_bps
pipeline.py CHANGED
@@ -15,7 +15,7 @@ import zipfile
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
@@ -42,6 +42,7 @@ class RunConfig:
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:
@@ -77,6 +78,9 @@ class RunConfig:
77
  if isinstance(value, bool) or not finite or value < 0 or value > 1000:
78
  raise ValueError(f'{name} must be a finite number between 0 and 1000 bps.')
79
  object.__setattr__(self, name, float(value))
 
 
 
80
  object.__setattr__(self, 'models', models)
81
  object.__setattr__(self, 'symbol', normalize_symbol(self.symbol, self.provider, self.market))
82
 
@@ -271,7 +275,24 @@ def backtest_result(config, frame=None, features=None):
271
  if len(frame) < config.window + config.horizon:
272
  raise ValueError(f'Backtesting needs at least {config.window + config.horizon} closed candles; provider returned {len(frame)}.')
273
 
274
- def evaluate_one(name):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  try:
276
  model = new_configured_model(name, config)
277
  details = backtest_model(
@@ -279,8 +300,10 @@ def backtest_result(config, frame=None, features=None):
279
  step=config.step, features_df=features,
280
  max_evaluations=config.max_evaluations, timeframe=config.timeframe,
281
  market=config.market, symbol=config.symbol, costs=config.costs,
 
282
  )
283
  summary = summarize_backtest(details, name)
 
284
  except Exception as error:
285
  details = pd.DataFrame([{
286
  'datetime': None, 'current_close': np.nan,
@@ -293,7 +316,9 @@ def backtest_result(config, frame=None, features=None):
293
  }])
294
  summary = summarize_backtest(details, name)
295
  summary['error'] = str(error)
 
296
  summary['Model'] = summary.pop('model')
 
297
  valid = details.dropna(subset=['correct'])
298
  summary['evaluations_attempted'] = len(details)
299
  summary['coverage_pct'] = round(100 * len(valid) / len(details), 2) if len(details) else None
@@ -303,24 +328,86 @@ def backtest_result(config, frame=None, features=None):
303
  summary['cost_slippage_bps'] = config.slippage_bps
304
  summary['status'] = 'OK' if summary['failed_predictions'] == 0 else ('PARTIAL' if len(valid) else 'FAILED')
305
  details.insert(0, 'Model', name)
306
- return summary, details
 
307
 
308
- evaluated = _map_models(config, evaluate_one)
309
- try:
310
- signal_details, signal_summary = backtest_maythos(
311
- frame, window=config.window, horizon=config.horizon, step=config.step,
312
- max_evaluations=config.max_evaluations, timeframe=config.timeframe,
313
- market=config.market, symbol=config.symbol, costs=config.costs,
 
 
 
314
  )
315
- except Exception as error:
316
- signal_details = pd.DataFrame()
317
- signal_summary = {'engine': 'MAYTHOS', 'error': str(error)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  return {
319
  'config': config, 'frame': frame, 'features': features,
320
  'metadata': data_metadata(frame, config),
321
- 'forecast_summary': pd.DataFrame([summary for summary, _ in evaluated]),
322
- 'forecast_details': pd.concat([details for _, details in evaluated], ignore_index=True),
323
- 'signal_summary': pd.DataFrame([signal_summary]), 'signal_details': signal_details,
 
 
 
324
  }
325
 
326
 
@@ -348,6 +435,8 @@ def export_run(result, kind):
348
  'feature_future_policy': 'Last observed covariates carried forward, never future actual indicators',
349
  'evaluation': 'Walk-forward direction/price error plus causal candle-path SL/TP trade simulation; costs are explicit assumptions',
350
  'trade_costs': result['config'].costs.as_dict(),
 
 
351
  }
352
  (directory / 'manifest.json').write_text(json.dumps(manifest, indent=2, default=str, allow_nan=False), encoding='utf-8')
353
  if kind == 'live':
@@ -356,6 +445,10 @@ def export_run(result, kind):
356
  else:
357
  for key in ('forecast_summary', 'forecast_details', 'signal_summary', 'signal_details'):
358
  result[key].to_csv(directory / f'{key}.csv', index=False)
 
 
 
 
359
  path = directory.with_suffix('.zip')
360
  with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as archive:
361
  for file in sorted(directory.iterdir()):
 
15
  import numpy as np
16
  import pandas as pd
17
 
18
+ from backtest.backtester import backtest_model, backtest_random_signals, 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
 
42
  spread_bps: float = 1.0
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:
 
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
 
 
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
+ # The first portion is available for configuration research. The final
279
+ # portion is addressed by origin, not by slicing away its training
280
+ # history: a final origin may train on older candles, but no final target
281
+ # candle can enter tuning or model fitting before that origin.
282
+ split_index = int(len(frame) * 0.65)
283
+ tuning_end = split_index - config.horizon + 1
284
+ final_start = split_index
285
+ final_end = len(frame) - config.horizon + 1
286
+ if tuning_end <= config.window:
287
+ raise ValueError('The fetched history is too short to provide a 65% tuning segment after the selected training window and horizon.')
288
+ if final_end <= max(config.window, final_start):
289
+ raise ValueError('The fetched history is too short to provide a 35% final-check segment after the selected training window and horizon.')
290
+ phases = (
291
+ ('TUNING', 0, tuning_end),
292
+ ('FINAL_CHECK', final_start, final_end),
293
+ )
294
+
295
+ def evaluate_one(name, origin_start, origin_end, phase):
296
  try:
297
  model = new_configured_model(name, config)
298
  details = backtest_model(
 
300
  step=config.step, features_df=features,
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 {})
307
  except Exception as error:
308
  details = pd.DataFrame([{
309
  'datetime': None, 'current_close': np.nan,
 
316
  }])
317
  summary = summarize_backtest(details, name)
318
  summary['error'] = str(error)
319
+ diagnostics = {}
320
  summary['Model'] = summary.pop('model')
321
+ summary['Phase'] = phase
322
  valid = details.dropna(subset=['correct'])
323
  summary['evaluations_attempted'] = len(details)
324
  summary['coverage_pct'] = round(100 * len(valid) / len(details), 2) if len(details) else None
 
328
  summary['cost_slippage_bps'] = config.slippage_bps
329
  summary['status'] = 'OK' if summary['failed_predictions'] == 0 else ('PARTIAL' if len(valid) else 'FAILED')
330
  details.insert(0, 'Model', name)
331
+ details.insert(1, 'Phase', phase)
332
+ return summary, details, diagnostics
333
 
334
+ forecast_summaries = []
335
+ forecast_details = []
336
+ model_diagnostics = {}
337
+ signal_summaries = []
338
+ signal_details = []
339
+ for phase, origin_start, origin_end in phases:
340
+ evaluated = _map_models(
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
+ random_details = backtest_random_signals(
351
+ frame, window=config.window, horizon=config.horizon, step=config.step,
352
+ max_evaluations=config.max_evaluations, timeframe=config.timeframe,
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(
378
+ frame, window=config.window, horizon=config.horizon, step=config.step,
379
+ max_evaluations=config.max_evaluations, timeframe=config.timeframe,
380
+ market=config.market, symbol=config.symbol, costs=config.costs,
381
+ origin_start=origin_start, origin_end=origin_end,
382
+ )
383
+ except Exception as error:
384
+ phase_signal_details = pd.DataFrame()
385
+ phase_signal_summary = {'engine': 'MAYTHOS', 'error': str(error)}
386
+ phase_signal_summary['Phase'] = phase
387
+ phase_signal_details.insert(0, 'Phase', phase)
388
+ signal_summaries.append(phase_signal_summary)
389
+ signal_details.append(phase_signal_details)
390
+
391
+ split_metadata = {
392
+ 'policy': 'first 65% of ordered closed candles for tuning; last 35% for final check',
393
+ 'fraction_tuning': 0.65,
394
+ 'split_index': split_index,
395
+ 'tuning_candles': split_index,
396
+ 'final_check_candles': len(frame) - split_index,
397
+ 'tuning_origin_end_exclusive': tuning_end,
398
+ 'final_origin_start_inclusive': final_start,
399
+ 'final_origin_end_exclusive': final_end,
400
+ 'final_targets_are_unseen_during_tuning': True,
401
+ }
402
  return {
403
  'config': config, 'frame': frame, 'features': features,
404
  'metadata': data_metadata(frame, config),
405
+ 'split': split_metadata,
406
+ 'model_diagnostics': model_diagnostics,
407
+ 'forecast_summary': pd.DataFrame(forecast_summaries),
408
+ 'forecast_details': pd.concat(forecast_details, ignore_index=True),
409
+ 'signal_summary': pd.DataFrame(signal_summaries),
410
+ 'signal_details': pd.concat(signal_details, ignore_index=True),
411
  }
412
 
413
 
 
435
  'feature_future_policy': 'Last observed covariates carried forward, never future actual indicators',
436
  'evaluation': 'Walk-forward direction/price error plus causal candle-path SL/TP trade simulation; costs are explicit assumptions',
437
  'trade_costs': result['config'].costs.as_dict(),
438
+ 'split': result.get('split'),
439
+ 'model_diagnostics': result.get('model_diagnostics', {}),
440
  }
441
  (directory / 'manifest.json').write_text(json.dumps(manifest, indent=2, default=str, allow_nan=False), encoding='utf-8')
442
  if kind == 'live':
 
445
  else:
446
  for key in ('forecast_summary', 'forecast_details', 'signal_summary', 'signal_details'):
447
  result[key].to_csv(directory / f'{key}.csv', index=False)
448
+ (directory / 'model_diagnostics.json').write_text(
449
+ json.dumps(result.get('model_diagnostics', {}), indent=2, default=str),
450
+ encoding='utf-8',
451
+ )
452
  path = directory.with_suffix('.zip')
453
  with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as archive:
454
  for file in sorted(directory.iterdir()):
requirements.txt CHANGED
@@ -1,8 +1,6 @@
1
- # Hugging Face Spaces/core runtime. Foundation-model dependencies are
2
- # deliberately opt-in via requirements-foundation.txt; a missing optional
3
- # model is reported as failed instead of being replaced by fake output.
4
- # Spaces mounts only this file during dependency installation, so keep the
5
- # core dependencies inline and in sync with requirements-core.txt.
6
  numpy==1.26.4
7
  pandas==2.1.4
8
  scipy==1.11.4
@@ -12,3 +10,16 @@ yfinance==1.7.0
12
  gradio==5.50.0
13
  plotly==6.9.0
14
  requests==2.34.2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Spaces runtime. Foundation models are installed here rather
2
+ # than left opt-in: a TimesFM result is only valid when the real package and
3
+ # checkpoint can load in the deployed Space.
 
 
4
  numpy==1.26.4
5
  pandas==2.1.4
6
  scipy==1.11.4
 
10
  gradio==5.50.0
11
  plotly==6.9.0
12
  requests==2.34.2
13
+ --extra-index-url https://download.pytorch.org/whl/cpu
14
+ torch==2.4.1+cpu
15
+ uni2ts==2.0.0
16
+ timesfm[torch]==3.0.2
17
+ gluonts==0.14.4
18
+ lightning==2.4.0
19
+ pytorch-lightning==2.4.0
20
+ torchmetrics==1.4.3
21
+ huggingface_hub==0.36.0
22
+ pyarrow==16.1.0
23
+ jax[cpu]==0.4.38
24
+ scikit-learn==1.5.2
25
+ safetensors>=0.5.3
risk_management.py CHANGED
@@ -197,6 +197,7 @@ def build_trade_levels(
197
  market: str,
198
  symbol: str,
199
  forecast_target: float | None = None,
 
200
  atr_period: int = ATR_PERIOD,
201
  swing_lookback: int = SWING_LOOKBACK,
202
  forecast_only: bool = False,
@@ -251,7 +252,15 @@ def build_trade_levels(
251
  if (numeric['High'] < numeric[['Low', 'Close'] + (['Open'] if 'Open' in numeric else [])].max(axis=1)).any() or \
252
  (numeric['Low'] > numeric[['High', 'Close'] + (['Open'] if 'Open' in numeric else [])].min(axis=1)).any():
253
  return None
254
- entry = float(frame['Close'].iloc[-1])
 
 
 
 
 
 
 
 
255
  atr = average_true_range(numeric, atr_period)
256
  if not _finite_positive(atr) or not _finite_positive(entry):
257
  return None
@@ -419,6 +428,7 @@ def summarize_trades(details: pd.DataFrame) -> dict:
419
  'trades': 0, 'wins': 0, 'losses': 0, 'timeouts': 0,
420
  'win_rate_pct': None, 'profit_factor': None,
421
  'expectancy_r': None, 'max_drawdown_r': None, 'average_r': None,
 
422
  }
423
  trades = details[details['trade_taken'] & details['net_r'].notna()].copy()
424
  if trades.empty:
@@ -426,6 +436,7 @@ def summarize_trades(details: pd.DataFrame) -> dict:
426
  'trades': 0, 'wins': 0, 'losses': 0, 'timeouts': 0,
427
  'win_rate_pct': None, 'profit_factor': None,
428
  'expectancy_r': None, 'max_drawdown_r': None, 'average_r': None,
 
429
  }
430
  returns = trades['net_r'].astype(float).to_numpy()
431
  wins = int((trades['outcome'] == 'TP').sum())
@@ -437,6 +448,9 @@ def summarize_trades(details: pd.DataFrame) -> dict:
437
  equity = np.cumsum(returns)
438
  peaks = np.maximum.accumulate(np.r_[0.0, equity])[1:]
439
  drawdown = peaks - equity
 
 
 
440
  return {
441
  'trades': int(len(trades)), 'wins': wins, 'losses': losses, 'timeouts': timeouts,
442
  'win_rate_pct': round(100.0 * wins / len(trades), 2),
@@ -444,4 +458,6 @@ def summarize_trades(details: pd.DataFrame) -> dict:
444
  'expectancy_r': round(float(returns.mean()), 6),
445
  'max_drawdown_r': round(float(drawdown.max()) if len(drawdown) else 0.0, 6),
446
  'average_r': round(float(returns.mean()), 6),
 
 
447
  }
 
197
  market: str,
198
  symbol: str,
199
  forecast_target: float | None = None,
200
+ entry_price: float | None = None,
201
  atr_period: int = ATR_PERIOD,
202
  swing_lookback: int = SWING_LOOKBACK,
203
  forecast_only: bool = False,
 
252
  if (numeric['High'] < numeric[['Low', 'Close'] + (['Open'] if 'Open' in numeric else [])].max(axis=1)).any() or \
253
  (numeric['Low'] > numeric[['High', 'Close'] + (['Open'] if 'Open' in numeric else [])].min(axis=1)).any():
254
  return None
255
+ if entry_price is None:
256
+ entry = float(frame['Close'].iloc[-1])
257
+ else:
258
+ try:
259
+ entry = float(entry_price)
260
+ except (TypeError, ValueError):
261
+ return None
262
+ if not _finite_positive(entry):
263
+ return None
264
  atr = average_true_range(numeric, atr_period)
265
  if not _finite_positive(atr) or not _finite_positive(entry):
266
  return None
 
428
  'trades': 0, 'wins': 0, 'losses': 0, 'timeouts': 0,
429
  'win_rate_pct': None, 'profit_factor': None,
430
  'expectancy_r': None, 'max_drawdown_r': None, 'average_r': None,
431
+ 'net_profit_r': None, 'top3_removed_profit_r': None,
432
  }
433
  trades = details[details['trade_taken'] & details['net_r'].notna()].copy()
434
  if trades.empty:
 
436
  'trades': 0, 'wins': 0, 'losses': 0, 'timeouts': 0,
437
  'win_rate_pct': None, 'profit_factor': None,
438
  'expectancy_r': None, 'max_drawdown_r': None, 'average_r': None,
439
+ 'net_profit_r': None, 'top3_removed_profit_r': None,
440
  }
441
  returns = trades['net_r'].astype(float).to_numpy()
442
  wins = int((trades['outcome'] == 'TP').sum())
 
448
  equity = np.cumsum(returns)
449
  peaks = np.maximum.accumulate(np.r_[0.0, equity])[1:]
450
  drawdown = peaks - equity
451
+ net_profit = float(returns.sum())
452
+ top_count = min(3, len(returns))
453
+ top3_removed = float(net_profit - np.sort(returns)[-top_count:].sum())
454
  return {
455
  'trades': int(len(trades)), 'wins': wins, 'losses': losses, 'timeouts': timeouts,
456
  'win_rate_pct': round(100.0 * wins / len(trades), 2),
 
458
  'expectancy_r': round(float(returns.mean()), 6),
459
  'max_drawdown_r': round(float(drawdown.max()) if len(drawdown) else 0.0, 6),
460
  'average_r': round(float(returns.mean()), 6),
461
+ 'net_profit_r': round(net_profit, 6),
462
+ 'top3_removed_profit_r': round(top3_removed, 6),
463
  }
tests/test_risk_management.py CHANGED
@@ -94,6 +94,19 @@ class TestTradeLevels(unittest.TestCase):
94
 
95
 
96
  class TestBacktestTradeMetrics(unittest.TestCase):
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  def test_backtest_contains_candle_path_trade_fields_and_summary(self):
98
  frame = _history(80)
99
  results = backtest_model(frame, _UpModel(), window=40, horizon=2, step=10, timeframe='5m', market='Crypto', symbol='BTC-USD', costs=CostModel())
 
94
 
95
 
96
  class TestBacktestTradeMetrics(unittest.TestCase):
97
+ def test_backtest_entry_is_next_candle_open(self):
98
+ frame = _history(80)
99
+ results = backtest_model(
100
+ frame, _UpModel(), window=40, horizon=1, step=10,
101
+ timeframe='5m', market='Crypto', symbol='BTC-USD', costs=CostModel(),
102
+ )
103
+ traded = results[results['trade_taken']]
104
+ self.assertGreater(len(traded), 0)
105
+ first = traded.iloc[0]
106
+ target_position = frame.index.get_loc(pd.Timestamp(first['datetime']))
107
+ entry_position = target_position
108
+ self.assertAlmostEqual(first['entry'], float(frame['Open'].iloc[entry_position]))
109
+
110
  def test_backtest_contains_candle_path_trade_fields_and_summary(self):
111
  frame = _history(80)
112
  results = backtest_model(frame, _UpModel(), window=40, horizon=2, step=10, timeframe='5m', market='Crypto', symbol='BTC-USD', costs=CostModel())