Skip to content

Commit 20eb3ea

Browse files
enarjordclaude
andcommitted
Fix base scenario coin leak and aggregate method handling
Two bugs fixed: 1. apply_scenario fell back to master_coins (union of all scenarios) for scenarios without explicit coins. Now falls back to base_coins (the original approved_coins from config) via new optional base_coins/base_ignored parameters threaded through suite_runner, optimize_suite, and run_backtest_scenario. 2. calc_fitness always used _mean stats for scoring objectives, ignoring backtest.aggregate config (e.g. "max" for high_exposure_hours_max_long). SuiteEvaluator now overrides flat_stats with correctly aggregated values before calling calc_fitness. pareto_store.py script reads the aggregate config from entries, uses it in _suite_metrics_to_stats fallback paths, and applies ratio-based correction to stored objectives. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a20c643 commit 20eb3ea

File tree

6 files changed

+545
-6
lines changed

6 files changed

+545
-6
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ All notable user-facing changes will be documented in this file.
1313
- **Combined OHLCV normalization source selection** - Volume normalization in combined backtests now uses each coin's OHLCV source exchange (`ohlcv_source`) instead of the market-settings exchange when `backtest.market_settings_sources` differs from OHLCV routing.
1414
- **Config template/format preservation** - Added `live.enable_archive_candle_fetch` to the template defaults and ensured `backtest.market_settings_sources` is preserved during config formatting.
1515
- **Live no-fill minute EMA continuity** - When finalized 1m candles are missing because no trades occurred, live runtime now materializes synthetic zero-candles in memory (not on disk), preventing avoidable `MissingEma` loop errors on illiquid symbols. If real candles arrive later, they overwrite synthetic runtime candles and invalidate EMA cache automatically.
16+
- **Suite base scenario inherited all scenario coins** - Scenarios without explicit `coins` (e.g. the `"base"` scenario) fell back to `master_coins` — the union of every scenario's coin list — instead of the original `approved_coins` from the config. Now `apply_scenario` falls back to `base_coins` (the config's `approved_coins`) when a scenario omits its own coin list.
17+
- **Aggregate methods ignored in optimizer scoring and Pareto analysis** - `calc_fitness` always looked up the `_mean` stat for every scoring metric, ignoring the `backtest.aggregate` config (e.g. `"high_exposure_hours_max_long": "max"`). The optimizer now overrides `flat_stats` with correctly aggregated values before computing objectives. The standalone `pareto_store.py` script also reads the aggregate config from each entry and corrects stored objectives via a ratio adjustment, and uses the config when resolving limit filters.
1618

1719
### Fixed
1820
- **Backtest HLCV cache reuse across configs** - Configs that differ only in trading parameters (EMA spans, warmup ratio) now share the same HLCV cache slot. Previously, different EMA spans produced different `warmup_minutes`, which was included in the cache hash, causing unnecessary re-downloads. The cache now uses a ratchet-up strategy: warmup sufficiency is checked at load time, and the cache is overwritten only when a larger warmup is needed.

src/optimize.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1218,6 +1218,11 @@ def evaluate(self, individual, overrides_list):
12181218
aggregate_stats = aggregate_summary.get("stats", {})
12191219

12201220
flat_stats = flatten_metric_stats(aggregate_stats)
1221+
# Override _mean with correctly aggregated values so calc_fitness
1222+
# respects the aggregate config (e.g. "max" instead of "mean").
1223+
aggregated_values = aggregate_summary.get("aggregated", {})
1224+
for metric, agg_value in aggregated_values.items():
1225+
flat_stats[f"{metric}_mean"] = agg_value
12211226
objectives, total_penalty = self.base.calc_fitness(flat_stats)
12221227
objectives_map = {f"w_{i}": val for i, val in enumerate(objectives)}
12231228

src/optimize_suite.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,8 @@ def _build_lazy_mss_slice(
248248
available_exchanges=dataset_available_exchanges,
249249
available_coins=available_coins,
250250
base_coin_sources=suite_coin_sources,
251+
base_coins=base_coins_list,
252+
base_ignored=base_ignored_list,
251253
)
252254
except ValueError as exc:
253255
logging.warning("Skipping scenario %s: %s", scenario.label, exc)

src/pareto_store.py

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,23 @@ def _resolve_limit_value(
7272
return stats_flat.get(key)
7373

7474

75-
def _suite_metrics_to_stats(entry: Dict[str, Any]) -> Tuple[Dict[str, float], Dict[str, float]]:
75+
def _resolve_aggregate_mode(
76+
metric: str, aggregate_cfg: Optional[Dict[str, str]]
77+
) -> str:
78+
"""Return the aggregate mode for *metric* given an aggregate config dict."""
79+
if not aggregate_cfg:
80+
return "mean"
81+
mode = aggregate_cfg.get(metric)
82+
if mode is None and "_" in metric:
83+
base = metric.rsplit("_", 1)[0]
84+
mode = aggregate_cfg.get(base)
85+
return str(mode or aggregate_cfg.get("default", "mean")).lower()
86+
87+
88+
def _suite_metrics_to_stats(
89+
entry: Dict[str, Any],
90+
aggregate_cfg: Optional[Dict[str, str]] = None,
91+
) -> Tuple[Dict[str, float], Dict[str, float]]:
7692
aggregated_values: Dict[str, float] = {}
7793
stats_flat: Dict[str, float] = {}
7894
suite_metrics = entry.get("suite_metrics") or {}
@@ -83,13 +99,20 @@ def _suite_metrics_to_stats(entry: Dict[str, Any]) -> Tuple[Dict[str, float], Di
8399
stats_flat.update(flatten_metric_stats({metric: stats}))
84100
agg = payload.get("aggregated")
85101
if agg is None and stats:
86-
agg = stats.get("mean")
102+
mode = _resolve_aggregate_mode(metric, aggregate_cfg)
103+
agg = stats.get(mode, stats.get("mean"))
87104
if agg is not None:
88105
aggregated_values[metric] = agg
89106
elif "aggregate" in suite_metrics:
90107
aggregate = suite_metrics.get("aggregate") or {}
91108
agg_stats = aggregate.get("stats") or {}
92109
aggregated_values = aggregate.get("aggregated") or {}
110+
if not aggregated_values and agg_stats and aggregate_cfg:
111+
for metric, metric_stats in agg_stats.items():
112+
mode = _resolve_aggregate_mode(metric, aggregate_cfg)
113+
val = metric_stats.get(mode, metric_stats.get("mean"))
114+
if val is not None:
115+
aggregated_values[metric] = val
93116
stats_flat = flatten_metric_stats(agg_stats)
94117
return stats_flat, aggregated_values
95118

@@ -574,15 +597,37 @@ def parse_limit_expr(expr: str) -> LimitSpec:
574597
metric_names = entry.get("optimize", {}).get("scoring", [])
575598
metric_name_map = {f"w_{i}": name for i, name in enumerate(metric_names)}
576599
metrics_block = entry.get("metrics", {}) or {}
577-
objectives = metrics_block.get("objectives", metrics_block)
600+
objectives = dict(metrics_block.get("objectives", metrics_block))
601+
aggregate_cfg = entry.get("backtest", {}).get("aggregate")
578602
stats_flat: Dict[str, float] = {}
579603
aggregated_values: Dict[str, float] = {}
580604
if "stats" in metrics_block:
581605
stats_flat = flatten_metric_stats(metrics_block["stats"])
582606
if "suite_metrics" in entry:
583-
stats_flat_suite, aggregated_values_suite = _suite_metrics_to_stats(entry)
607+
stats_flat_suite, aggregated_values_suite = _suite_metrics_to_stats(
608+
entry, aggregate_cfg=aggregate_cfg,
609+
)
584610
stats_flat.update(stats_flat_suite)
585611
aggregated_values.update(aggregated_values_suite)
612+
# Correct objectives for scoring metrics whose aggregate method
613+
# is not "mean". The stored w_i was computed as metric_mean * weight;
614+
# the correct value is metric_agg * weight. We apply a ratio
615+
# correction (agg / mean) so we don't need the scoring weights.
616+
constraint_violation = metrics_block.get("constraint_violation", 0.0)
617+
if aggregate_cfg and not constraint_violation:
618+
scoring_keys = entry.get("optimize", {}).get("scoring", [])
619+
for idx, sk in enumerate(scoring_keys):
620+
mode = _resolve_aggregate_mode(sk, aggregate_cfg)
621+
if mode == "mean":
622+
continue
623+
w_key = f"w_{idx}"
624+
stored = objectives.get(w_key)
625+
if stored is None:
626+
continue
627+
agg_val = aggregated_values.get(sk)
628+
mean_val = stats_flat.get(f"{sk}_mean")
629+
if agg_val is not None and mean_val and mean_val != 0.0:
630+
objectives[w_key] = stored * (agg_val / mean_val)
586631
if not w_keys:
587632
all_w_keys = sorted(k for k in objectives if k.startswith("w_"))
588633

src/suite_runner.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,8 @@ def apply_scenario(
618618
available_coins: set[str],
619619
base_coin_sources: Optional[Dict[str, str]] = None,
620620
*,
621+
base_coins: Optional[List[str]] = None,
622+
base_ignored: Optional[List[str]] = None,
621623
quiet: bool = False,
622624
) -> Tuple[Dict[str, Any], List[str]]:
623625
cfg = deepcopy(base_config)
@@ -635,9 +637,11 @@ def apply_scenario(
635637
tracker.update(["backtest", "end_date"], backtest_section.get("end_date"), new_end)
636638
backtest_section["end_date"] = new_end
637639

638-
scenario_coins = list(scenario.coins) if scenario.coins is not None else list(master_coins)
640+
default_coins = base_coins if base_coins is not None else master_coins
641+
default_ignored = base_ignored if base_ignored is not None else master_ignored
642+
scenario_coins = list(scenario.coins) if scenario.coins is not None else list(default_coins)
639643
scenario_ignored = (
640-
list(scenario.ignored_coins) if scenario.ignored_coins is not None else list(master_ignored)
644+
list(scenario.ignored_coins) if scenario.ignored_coins is not None else list(default_ignored)
641645
)
642646

643647
filtered_coins = [coin for coin in scenario_coins if coin in available_coins]
@@ -834,6 +838,8 @@ async def run_backtest_scenario(
834838
results_root: Optional[Path],
835839
disable_plotting: bool,
836840
base_coin_sources: Optional[Dict[str, str]] = None,
841+
base_coins: Optional[List[str]] = None,
842+
base_ignored: Optional[List[str]] = None,
837843
) -> ScenarioResult:
838844
from backtest import (
839845
build_backtest_payload,
@@ -849,6 +855,8 @@ async def run_backtest_scenario(
849855
available_exchanges=available_exchanges,
850856
available_coins=available_coins,
851857
base_coin_sources=base_coin_sources,
858+
base_coins=base_coins,
859+
base_ignored=base_ignored,
852860
)
853861
scenario_config["disable_plotting"] = disable_plotting
854862

@@ -1474,6 +1482,8 @@ async def run_backtest_suite_async(
14741482
available_exchanges=dataset_available_exchanges,
14751483
available_coins=available_coins,
14761484
base_coin_sources=suite_coin_sources,
1485+
base_coins=base_coins,
1486+
base_ignored=base_ignored,
14771487
quiet=True,
14781488
)
14791489
coin_exchange = _compute_effective_coin_exchange(
@@ -1516,6 +1526,8 @@ async def run_backtest_suite_async(
15161526
suite_dir,
15171527
disable_plotting=disable_plotting,
15181528
base_coin_sources=suite_coin_sources,
1529+
base_coins=base_coins,
1530+
base_ignored=base_ignored,
15191531
)
15201532
results.append(result)
15211533
logging.info(

0 commit comments

Comments
 (0)