Spaces:
Running
Running
Make Space backtests causal and auditable
Browse files- README.md +5 -0
- app.py +8 -5
- backtest/backtester.py +164 -3
- maythos_integration.py +16 -0
- pipeline.py +108 -15
- requirements.txt +16 -5
- risk_management.py +17 -1
- tests/test_risk_management.py +13 -0
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.
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 307 |
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
|
|
|
|
|
|
|
|
|
| 314 |
)
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
return {
|
| 319 |
'config': config, 'frame': frame, 'features': features,
|
| 320 |
'metadata': data_metadata(frame, config),
|
| 321 |
-
'
|
| 322 |
-
'
|
| 323 |
-
'
|
|
|
|
|
|
|
|
|
|
| 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
|
| 2 |
-
#
|
| 3 |
-
#
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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())
|