3VVM commited on
Commit
ceca5ef
·
verified ·
1 Parent(s): a9150e7

Audit execution and add frozen research phases

Browse files
Files changed (8) hide show
  1. app.py +30 -8
  2. backtest/backtester.py +202 -13
  3. config.py +1 -0
  4. data/providers.py +18 -4
  5. models/timesfm_model.py +27 -0
  6. pipeline.py +133 -55
  7. research.py +359 -0
  8. 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 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,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
- 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.')
@@ -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
- minimum_edge_atr=0.20,
146
- minimum_risk_reward=1.20,
147
- use_structure_stop=False,
 
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
- "trade_taken": levels is not None,
171
- "entry": levels.entry if levels else np.nan,
172
- "stop_loss": levels.stop_loss if levels else np.nan,
173
- "take_profit": levels.take_profit if levels else np.nan,
174
- "risk_reward": levels.risk_reward if levels else np.nan,
175
- "atr": levels.atr if levels else np.nan,
176
- "swing_support": levels.swing_support if levels else np.nan,
177
- "swing_resistance": levels.swing_resistance if levels else np.nan,
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(end_ms, unit='ms', tz='UTC').isoformat()
 
 
 
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 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
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=0.20,
252
- minimum_risk_reward=1.20,
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
- # 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:
@@ -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
- 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(
@@ -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': '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 {
 
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
- raise ValueError('minimum_edge_atr/minimum_edge_bps must be non-negative and minimum_risk_reward must be positive.')
 
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 - ATR_STOP_MULTIPLIER * atr
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 + ATR_STOP_MULTIPLIER * atr
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((trades['outcome'] == 'TP').sum())
443
- losses = int((trades['outcome'] == 'SL').sum())
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())