fix: remove leakage from legacy ML evaluation
This commit is contained in:
@@ -27,6 +27,11 @@
|
|||||||
0.3,
|
0.3,
|
||||||
0.5
|
0.5
|
||||||
],
|
],
|
||||||
|
"return_scales_pct": [
|
||||||
|
10.0,
|
||||||
|
30.0,
|
||||||
|
60.0
|
||||||
|
],
|
||||||
"score_range": [
|
"score_range": [
|
||||||
0,
|
0,
|
||||||
100
|
100
|
||||||
@@ -61,7 +66,7 @@
|
|||||||
"rolling_test_size": 300,
|
"rolling_test_size": 300,
|
||||||
"walk_forward_windows": 5,
|
"walk_forward_windows": 5,
|
||||||
"train_pct": 0.7,
|
"train_pct": 0.7,
|
||||||
"validation_pct": 0.15,
|
"validation_pct": 0.3,
|
||||||
"test_pct": 0.15
|
"test_pct": 0.15
|
||||||
},
|
},
|
||||||
"timeframe": "4h"
|
"timeframe": "4h"
|
||||||
|
|||||||
@@ -27,6 +27,11 @@
|
|||||||
0.3,
|
0.3,
|
||||||
0.5
|
0.5
|
||||||
],
|
],
|
||||||
|
"return_scales_pct": [
|
||||||
|
10.0,
|
||||||
|
30.0,
|
||||||
|
60.0
|
||||||
|
],
|
||||||
"score_range": [
|
"score_range": [
|
||||||
0,
|
0,
|
||||||
100
|
100
|
||||||
@@ -61,7 +66,7 @@
|
|||||||
"rolling_test_size": 300,
|
"rolling_test_size": 300,
|
||||||
"walk_forward_windows": 5,
|
"walk_forward_windows": 5,
|
||||||
"train_pct": 0.7,
|
"train_pct": 0.7,
|
||||||
"validation_pct": 0.15,
|
"validation_pct": 0.3,
|
||||||
"test_pct": 0.15
|
"test_pct": 0.15
|
||||||
},
|
},
|
||||||
"timeframe": "4h"
|
"timeframe": "4h"
|
||||||
|
|||||||
@@ -27,6 +27,11 @@
|
|||||||
0.3,
|
0.3,
|
||||||
0.5
|
0.5
|
||||||
],
|
],
|
||||||
|
"return_scales_pct": [
|
||||||
|
10.0,
|
||||||
|
30.0,
|
||||||
|
60.0
|
||||||
|
],
|
||||||
"score_range": [
|
"score_range": [
|
||||||
0,
|
0,
|
||||||
100
|
100
|
||||||
@@ -61,7 +66,7 @@
|
|||||||
"rolling_test_size": 300,
|
"rolling_test_size": 300,
|
||||||
"walk_forward_windows": 5,
|
"walk_forward_windows": 5,
|
||||||
"train_pct": 0.7,
|
"train_pct": 0.7,
|
||||||
"validation_pct": 0.15,
|
"validation_pct": 0.3,
|
||||||
"test_pct": 0.15
|
"test_pct": 0.15
|
||||||
},
|
},
|
||||||
"timeframe": "4h"
|
"timeframe": "4h"
|
||||||
|
|||||||
+104
-24
@@ -190,30 +190,22 @@ def create_accumulation_target(df, config):
|
|||||||
fwd[i] = (close[i + period] - close[i]) / close[i] * 100
|
fwd[i] = (close[i + period] - close[i]) / close[i] * 100
|
||||||
forward_returns.append(fwd)
|
forward_returns.append(fwd)
|
||||||
|
|
||||||
# Rank each forward return (percentile rank, 0-1)
|
# Convert each return to a deterministic 0-100 quality score. Global
|
||||||
# Higher rank = better buy point (higher future return)
|
# percentile ranks leak the distribution of future validation/test rows into
|
||||||
ranked = []
|
# earlier training labels; a fixed tanh transform is invariant to rows added
|
||||||
for fwd in forward_returns:
|
# outside the observation's own forward horizons.
|
||||||
valid_mask = ~np.isnan(fwd)
|
scales = tgt.get("return_scales_pct", [10.0, 30.0, 60.0])
|
||||||
ranks = np.full(n, np.nan)
|
if len(scales) != len(forward_periods) or any(scale <= 0 for scale in scales):
|
||||||
valid_vals = fwd[valid_mask]
|
raise ValueError("target.return_scales_pct must contain one positive scale per forward period")
|
||||||
if len(valid_vals) > 0:
|
|
||||||
from scipy.stats import rankdata
|
|
||||||
r = rankdata(valid_vals, method="average") / len(valid_vals)
|
|
||||||
ranks[valid_mask] = r
|
|
||||||
ranked.append(ranks)
|
|
||||||
|
|
||||||
# Weighted combination of ranks -> accumulation score (0-100)
|
|
||||||
score = np.zeros(n)
|
score = np.zeros(n)
|
||||||
valid = np.ones(n, dtype=bool)
|
valid = np.ones(n, dtype=bool)
|
||||||
for r, w in zip(ranked, weights):
|
for fwd, weight, scale in zip(forward_returns, weights, scales):
|
||||||
nan_mask = np.isnan(r)
|
nan_mask = np.isnan(fwd)
|
||||||
valid &= ~nan_mask
|
valid &= ~nan_mask
|
||||||
r_filled = np.where(nan_mask, 0, r)
|
quality = 50.0 + 50.0 * np.tanh(np.where(nan_mask, 0.0, fwd) / scale)
|
||||||
score += w * r_filled
|
score += weight * quality
|
||||||
|
|
||||||
# Scale to 0-100
|
|
||||||
score = score * 100
|
|
||||||
score[~valid] = np.nan
|
score[~valid] = np.nan
|
||||||
|
|
||||||
return pd.Series(score, index=df.index, name="target")
|
return pd.Series(score, index=df.index, name="target")
|
||||||
@@ -488,6 +480,27 @@ def apply_scaling_pca(X_train, X_val, X_test, config):
|
|||||||
return X_train, X_val, X_test, scaler, pca
|
return X_train, X_val, X_test, scaler, pca
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Walk-forward split helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _max_forward_horizon(config):
|
||||||
|
"""Return the longest forward-label horizon in candles."""
|
||||||
|
target = config.get("target", {})
|
||||||
|
key = "forward_periods_1h" if config.get("timeframe", "4h") == "1h" else "forward_periods_4h"
|
||||||
|
periods = target.get(key, [168, 720, 2160] if key.endswith("1h") else [42, 180, 540])
|
||||||
|
return max(int(period) for period in periods)
|
||||||
|
|
||||||
|
|
||||||
|
def _purge_label_overlap(frame, horizon):
|
||||||
|
"""Remove rows whose forward-return labels cross the next split boundary."""
|
||||||
|
if horizon <= 0:
|
||||||
|
return frame
|
||||||
|
if len(frame) <= horizon:
|
||||||
|
return frame.iloc[0:0]
|
||||||
|
return frame.iloc[:-horizon]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Rolling Window Validation
|
# Rolling Window Validation
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -499,6 +512,7 @@ def rolling_window_train_test(df, feature_cols, config):
|
|||||||
test_size = training_cfg.get("rolling_test_size", 300)
|
test_size = training_cfg.get("rolling_test_size", 300)
|
||||||
val_pct = training_cfg.get("validation_pct", 0.15)
|
val_pct = training_cfg.get("validation_pct", 0.15)
|
||||||
model_type = config.get("model_type", "xgboost")
|
model_type = config.get("model_type", "xgboost")
|
||||||
|
purge_horizon = _max_forward_horizon(config)
|
||||||
|
|
||||||
n = len(df)
|
n = len(df)
|
||||||
all_predictions = [] # list of (predicted_score, actual_score, close_price)
|
all_predictions = [] # list of (predicted_score, actual_score, close_price)
|
||||||
@@ -527,10 +541,16 @@ def rolling_window_train_test(df, feature_cols, config):
|
|||||||
start += test_size
|
start += test_size
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Split train into train/val
|
# Purge labels whose longest forward-return horizon overlaps the next
|
||||||
|
# split. Without this embargo, training and validation targets consume
|
||||||
|
# prices from the following validation/test partition.
|
||||||
val_split = int(len(train_full) * (1.0 - val_pct))
|
val_split = int(len(train_full) * (1.0 - val_pct))
|
||||||
train_df = train_full.iloc[:val_split]
|
train_df = _purge_label_overlap(train_full.iloc[:val_split], purge_horizon)
|
||||||
val_df = train_full.iloc[val_split:]
|
val_df = _purge_label_overlap(train_full.iloc[val_split:], purge_horizon)
|
||||||
|
|
||||||
|
if len(train_df) < 10 or len(val_df) < 1:
|
||||||
|
start += test_size
|
||||||
|
continue
|
||||||
|
|
||||||
X_train = train_df[feature_cols].values
|
X_train = train_df[feature_cols].values
|
||||||
y_train = train_df["target"].values
|
y_train = train_df["target"].values
|
||||||
@@ -632,6 +652,7 @@ def walk_forward_train_test(df, feature_cols, config):
|
|||||||
n_windows = training_cfg.get("walk_forward_windows", 5)
|
n_windows = training_cfg.get("walk_forward_windows", 5)
|
||||||
train_pct = training_cfg.get("train_pct", 0.7)
|
train_pct = training_cfg.get("train_pct", 0.7)
|
||||||
val_pct = training_cfg.get("validation_pct", 0.15)
|
val_pct = training_cfg.get("validation_pct", 0.15)
|
||||||
|
purge_horizon = _max_forward_horizon(config)
|
||||||
|
|
||||||
n = len(df)
|
n = len(df)
|
||||||
window_size = n // n_windows
|
window_size = n // n_windows
|
||||||
@@ -655,8 +676,8 @@ def walk_forward_train_test(df, feature_cols, config):
|
|||||||
train_end = int(wn * train_pct)
|
train_end = int(wn * train_pct)
|
||||||
val_end = int(wn * (train_pct + val_pct))
|
val_end = int(wn * (train_pct + val_pct))
|
||||||
|
|
||||||
train_df = window_data.iloc[:train_end]
|
train_df = _purge_label_overlap(window_data.iloc[:train_end], purge_horizon)
|
||||||
val_df = window_data.iloc[train_end:val_end]
|
val_df = _purge_label_overlap(window_data.iloc[train_end:val_end], purge_horizon)
|
||||||
test_df = window_data.iloc[val_end:]
|
test_df = window_data.iloc[val_end:]
|
||||||
|
|
||||||
if len(test_df) < 10:
|
if len(test_df) < 10:
|
||||||
@@ -842,6 +863,46 @@ def _extract_feature_importances(model, n_features):
|
|||||||
# Results Compilation
|
# Results Compilation
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def simulate_periodic_accumulation(predicted_scores, close_prices, buy_threshold, contribution=1.0):
|
||||||
|
"""Compare DCA and signal strategies with equal periodic contributions.
|
||||||
|
|
||||||
|
Both strategies receive the same cash on every observation. DCA invests the
|
||||||
|
contribution immediately; the signal strategy retains cash until a buy
|
||||||
|
signal, then deploys its available balance. Terminal wealth includes cash.
|
||||||
|
"""
|
||||||
|
scores = np.asarray(predicted_scores, dtype=float)
|
||||||
|
prices = np.asarray(close_prices, dtype=float)
|
||||||
|
if len(scores) != len(prices):
|
||||||
|
raise ValueError("predicted_scores and close_prices must have equal length")
|
||||||
|
if len(prices) == 0 or contribution <= 0 or np.any(prices <= 0):
|
||||||
|
raise ValueError("prices must be positive and contribution must be greater than zero")
|
||||||
|
|
||||||
|
dca_btc = float(np.sum(contribution / prices))
|
||||||
|
model_btc = 0.0
|
||||||
|
model_cash = 0.0
|
||||||
|
for score, price in zip(scores, prices):
|
||||||
|
model_cash += contribution
|
||||||
|
if score >= buy_threshold:
|
||||||
|
model_btc += model_cash / price
|
||||||
|
model_cash = 0.0
|
||||||
|
|
||||||
|
contributed = float(len(prices) * contribution)
|
||||||
|
terminal_price = float(prices[-1])
|
||||||
|
dca_terminal = dca_btc * terminal_price
|
||||||
|
model_terminal = model_btc * terminal_price + model_cash
|
||||||
|
improvement = (model_terminal - dca_terminal) / dca_terminal * 100 if dca_terminal else 0.0
|
||||||
|
return {
|
||||||
|
"dca_contributed": contributed,
|
||||||
|
"model_contributed": contributed,
|
||||||
|
"dca_btc": dca_btc,
|
||||||
|
"model_btc": model_btc,
|
||||||
|
"model_cash": model_cash,
|
||||||
|
"dca_terminal_value": dca_terminal,
|
||||||
|
"model_terminal_value": model_terminal,
|
||||||
|
"terminal_wealth_improvement_pct": improvement,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def compile_results(predictions, per_window_cost_improvement,
|
def compile_results(predictions, per_window_cost_improvement,
|
||||||
fi_sum, fi_count, feature_cols, config):
|
fi_sum, fi_count, feature_cols, config):
|
||||||
"""Compile accumulation signal results into output JSON."""
|
"""Compile accumulation signal results into output JSON."""
|
||||||
@@ -896,6 +957,13 @@ def compile_results(predictions, per_window_cost_improvement,
|
|||||||
model_avg = dca_avg
|
model_avg = dca_avg
|
||||||
cost_basis_improvement = 0.0
|
cost_basis_improvement = 0.0
|
||||||
|
|
||||||
|
portfolio = simulate_periodic_accumulation(
|
||||||
|
pred_scores,
|
||||||
|
close_prices,
|
||||||
|
buy_threshold=good_threshold,
|
||||||
|
contribution=1.0,
|
||||||
|
)
|
||||||
|
|
||||||
# --- Signal Frequency ---
|
# --- Signal Frequency ---
|
||||||
signal_frequency = strong_buy_count / total_candles * 100 if total_candles > 0 else 0
|
signal_frequency = strong_buy_count / total_candles * 100 if total_candles > 0 else 0
|
||||||
|
|
||||||
@@ -946,7 +1014,14 @@ def compile_results(predictions, per_window_cost_improvement,
|
|||||||
quality_good = False
|
quality_good = False
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
# Retained for backward compatibility; model selection uses the equal-
|
||||||
|
# capital terminal wealth metric below.
|
||||||
"cost_basis_improvement_pct": round(cost_basis_improvement, 2),
|
"cost_basis_improvement_pct": round(cost_basis_improvement, 2),
|
||||||
|
"terminal_wealth_improvement_pct": round(portfolio["terminal_wealth_improvement_pct"], 2),
|
||||||
|
"model_terminal_value": round(portfolio["model_terminal_value"], 6),
|
||||||
|
"dca_terminal_value": round(portfolio["dca_terminal_value"], 6),
|
||||||
|
"model_cash": round(portfolio["model_cash"], 6),
|
||||||
|
"backtest_objective": "equal_periodic_contribution_terminal_wealth",
|
||||||
"avg_cost_basis_model": round(model_avg, 2),
|
"avg_cost_basis_model": round(model_avg, 2),
|
||||||
"avg_cost_basis_dca": round(dca_avg, 2),
|
"avg_cost_basis_dca": round(dca_avg, 2),
|
||||||
"strong_buy_signal_count": strong_buy_count,
|
"strong_buy_signal_count": strong_buy_count,
|
||||||
@@ -968,6 +1043,11 @@ def compile_results(predictions, per_window_cost_improvement,
|
|||||||
def _empty_results(per_window):
|
def _empty_results(per_window):
|
||||||
return {
|
return {
|
||||||
"cost_basis_improvement_pct": 0.0,
|
"cost_basis_improvement_pct": 0.0,
|
||||||
|
"terminal_wealth_improvement_pct": 0.0,
|
||||||
|
"model_terminal_value": 0.0,
|
||||||
|
"dca_terminal_value": 0.0,
|
||||||
|
"model_cash": 0.0,
|
||||||
|
"backtest_objective": "equal_periodic_contribution_terminal_wealth",
|
||||||
"avg_cost_basis_model": 0.0,
|
"avg_cost_basis_model": 0.0,
|
||||||
"avg_cost_basis_dca": 0.0,
|
"avg_cost_basis_dca": 0.0,
|
||||||
"strong_buy_signal_count": 0,
|
"strong_buy_signal_count": 0,
|
||||||
|
|||||||
+15
-7
@@ -28,7 +28,7 @@ MAC_MINI_HOST = "bizzle@bizzles-mac-mini-1"
|
|||||||
MAX_ITERATIONS = 50
|
MAX_ITERATIONS = 50
|
||||||
CONVERGENCE_WINDOW = 5
|
CONVERGENCE_WINDOW = 5
|
||||||
CONVERGENCE_THRESHOLD = 0.01 # 1% improvement
|
CONVERGENCE_THRESHOLD = 0.01 # 1% improvement
|
||||||
TARGET_COST_IMPROVEMENT = 20.0 # 20% cost basis improvement = exceptional
|
TARGET_COST_IMPROVEMENT = 20.0 # Backward-compatible name: terminal wealth objective
|
||||||
MIN_SIGNAL_COUNT = 30 # Minimum strong buy signals for valid results
|
MIN_SIGNAL_COUNT = 30 # Minimum strong buy signals for valid results
|
||||||
ML_TIMEOUT = 600 # 10 minutes
|
ML_TIMEOUT = 600 # 10 minutes
|
||||||
|
|
||||||
@@ -49,6 +49,11 @@ def log(msg, color=""):
|
|||||||
print(f"{C.DIM}[{ts}]{C.RESET} {color}{msg}{C.RESET}")
|
print(f"{C.DIM}[{ts}]{C.RESET} {color}{msg}{C.RESET}")
|
||||||
|
|
||||||
|
|
||||||
|
def objective_score(results):
|
||||||
|
"""Return the equal-capital portfolio objective used for model selection."""
|
||||||
|
return float(results.get("terminal_wealth_improvement_pct", 0.0))
|
||||||
|
|
||||||
|
|
||||||
def run_cmd(cmd, timeout=120, check=True):
|
def run_cmd(cmd, timeout=120, check=True):
|
||||||
"""Run a shell command and return stdout."""
|
"""Run a shell command and return stdout."""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -160,11 +165,12 @@ def print_header():
|
|||||||
|
|
||||||
|
|
||||||
def print_results(results, iteration):
|
def print_results(results, iteration):
|
||||||
cost_imp = results.get("cost_basis_improvement_pct", 0)
|
objective = objective_score(results)
|
||||||
color = C.GREEN if cost_imp > 15 else C.YELLOW if cost_imp > 10 else C.RED
|
color = C.GREEN if objective > 15 else C.YELLOW if objective > 10 else C.RED
|
||||||
print(f"""
|
print(f"""
|
||||||
{C.BOLD}--- Iteration {iteration} Results ---{C.RESET}
|
{C.BOLD}--- Iteration {iteration} Results ---{C.RESET}
|
||||||
Cost Improvement: {color}{C.BOLD}{cost_imp:.1f}%{C.RESET}
|
Terminal Wealth vs DCA: {color}{C.BOLD}{objective:.1f}%{C.RESET}
|
||||||
|
Legacy Cost Basis Delta: {results.get('cost_basis_improvement_pct', 0):.1f}%
|
||||||
Avg Cost (Model): ${results.get('avg_cost_basis_model', 0):,.2f}
|
Avg Cost (Model): ${results.get('avg_cost_basis_model', 0):,.2f}
|
||||||
Avg Cost (DCA): ${results.get('avg_cost_basis_dca', 0):,.2f}
|
Avg Cost (DCA): ${results.get('avg_cost_basis_dca', 0):,.2f}
|
||||||
Strong Signals: {results.get('strong_buy_signal_count', 0)}
|
Strong Signals: {results.get('strong_buy_signal_count', 0)}
|
||||||
@@ -244,7 +250,7 @@ def main():
|
|||||||
|
|
||||||
print_results(results, iteration)
|
print_results(results, iteration)
|
||||||
|
|
||||||
current_score = results.get("cost_basis_improvement_pct", 0)
|
current_score = objective_score(results)
|
||||||
signal_count = results.get("strong_buy_signal_count", 0)
|
signal_count = results.get("strong_buy_signal_count", 0)
|
||||||
is_best = current_score > best_score and signal_count >= MIN_SIGNAL_COUNT
|
is_best = current_score > best_score and signal_count >= MIN_SIGNAL_COUNT
|
||||||
|
|
||||||
@@ -252,12 +258,14 @@ def main():
|
|||||||
best_score = current_score
|
best_score = current_score
|
||||||
with open(best_config_path, "w") as f:
|
with open(best_config_path, "w") as f:
|
||||||
json.dump(config, f, indent=2)
|
json.dump(config, f, indent=2)
|
||||||
log(f"NEW BEST! Cost Improvement: {best_score:.1f}%", f"{C.BOLD}{C.GREEN}")
|
log(f"NEW BEST! Terminal Wealth Improvement: {best_score:.1f}%", f"{C.BOLD}{C.GREEN}")
|
||||||
|
|
||||||
iter_data = {
|
iter_data = {
|
||||||
"iteration": iteration,
|
"iteration": iteration,
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
"cost_improvement": current_score,
|
"cost_improvement": current_score,
|
||||||
|
"objective_improvement": current_score,
|
||||||
|
"objective": "equal_periodic_contribution_terminal_wealth",
|
||||||
"avg_30d_return": results.get("avg_quality_score_strong_buy", 0),
|
"avg_30d_return": results.get("avg_quality_score_strong_buy", 0),
|
||||||
"avg_90d_return": results.get("pct_quality_strong_buy", 0),
|
"avg_90d_return": results.get("pct_quality_strong_buy", 0),
|
||||||
"signal_count": signal_count,
|
"signal_count": signal_count,
|
||||||
@@ -312,7 +320,7 @@ def main():
|
|||||||
========================================================{C.RESET}
|
========================================================{C.RESET}
|
||||||
|
|
||||||
Total Iterations: {len(history)}
|
Total Iterations: {len(history)}
|
||||||
Best Cost Improvement: {C.BOLD}{best_score:.1f}%{C.RESET}
|
Best Terminal Wealth Improvement: {C.BOLD}{best_score:.1f}%{C.RESET}
|
||||||
Best Config: {best_config_path}
|
Best Config: {best_config_path}
|
||||||
Iteration Log: {ITERATIONS_LOG}
|
Iteration Log: {ITERATIONS_LOG}
|
||||||
""")
|
""")
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
import orchestrator
|
||||||
|
from ml_engine import train_and_backtest as legacy
|
||||||
|
from ml_engine.train_and_backtest import create_accumulation_target
|
||||||
|
|
||||||
|
|
||||||
|
def _frame(prices):
|
||||||
|
return pd.DataFrame({"close": prices})
|
||||||
|
|
||||||
|
|
||||||
|
def test_accumulation_target_for_existing_row_is_invariant_to_unrelated_future_rows():
|
||||||
|
config = {
|
||||||
|
"timeframe": "4h",
|
||||||
|
"target": {
|
||||||
|
"forward_periods_4h": [1, 2, 3],
|
||||||
|
"weights": [0.2, 0.3, 0.5],
|
||||||
|
"return_scales_pct": [5, 10, 20],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
base = _frame([100, 102, 104, 106, 108, 110, 112, 114])
|
||||||
|
extended = _frame([100, 102, 104, 106, 108, 110, 112, 114, 1000, 1, 2000])
|
||||||
|
|
||||||
|
base_target = create_accumulation_target(base, config)
|
||||||
|
extended_target = create_accumulation_target(extended, config)
|
||||||
|
|
||||||
|
assert np.isclose(base_target.iloc[0], extended_target.iloc[0])
|
||||||
|
assert 0 <= base_target.iloc[0] <= 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_rolling_validation_purges_forward_label_horizon_at_train_boundaries(monkeypatch):
|
||||||
|
rows = 200
|
||||||
|
frame = pd.DataFrame({
|
||||||
|
"feature": np.linspace(0, 1, rows),
|
||||||
|
"target": np.arange(rows, dtype=float) % 100,
|
||||||
|
"close": np.linspace(10_000, 20_000, rows),
|
||||||
|
})
|
||||||
|
observed = []
|
||||||
|
|
||||||
|
def fake_train(X_train, y_train, X_val, y_val, X_test, *args):
|
||||||
|
observed.append((len(X_train), len(X_val), len(X_test)))
|
||||||
|
return np.full(len(X_test), 50.0), np.array([1.0])
|
||||||
|
|
||||||
|
monkeypatch.setattr(legacy, "_train_and_predict_window", fake_train)
|
||||||
|
config = {
|
||||||
|
"model_type": "xgboost",
|
||||||
|
"target": {"forward_periods_4h": [1, 2, 3]},
|
||||||
|
"training": {
|
||||||
|
"rolling_train_size": 120,
|
||||||
|
"rolling_test_size": 40,
|
||||||
|
"validation_pct": 0.25,
|
||||||
|
},
|
||||||
|
"features": {"use_scaler": False, "use_pca": False},
|
||||||
|
"strategy": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
legacy.rolling_window_train_test(frame, ["feature"], config)
|
||||||
|
|
||||||
|
assert observed[0] == (87, 27, 40)
|
||||||
|
|
||||||
|
|
||||||
|
def test_periodic_accumulation_compares_equal_contributions_and_retains_cash():
|
||||||
|
result = legacy.simulate_periodic_accumulation(
|
||||||
|
predicted_scores=np.array([90, 10, 90, 10], dtype=float),
|
||||||
|
close_prices=np.array([100, 300, 100, 200], dtype=float),
|
||||||
|
buy_threshold=70,
|
||||||
|
contribution=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert np.isclose(result["dca_contributed"], 400)
|
||||||
|
assert np.isclose(result["model_contributed"], 400)
|
||||||
|
assert np.isclose(result["model_cash"], 100)
|
||||||
|
assert np.isclose(result["model_btc"], 3)
|
||||||
|
assert result["model_terminal_value"] > result["dca_terminal_value"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_compiled_results_publish_equal_capital_terminal_wealth_metric():
|
||||||
|
predictions = [
|
||||||
|
{"predicted": score, "actual": 50.0, "close": price}
|
||||||
|
for score, price in zip([90, 10, 90, 10], [100, 300, 100, 200])
|
||||||
|
]
|
||||||
|
|
||||||
|
result = legacy.compile_results(
|
||||||
|
predictions,
|
||||||
|
per_window_cost_improvement=[],
|
||||||
|
fi_sum=np.array([1.0]),
|
||||||
|
fi_count=1,
|
||||||
|
feature_cols=["feature"],
|
||||||
|
config={"model_type": "xgboost", "strategy": {"good_buy_threshold": 70}},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["terminal_wealth_improvement_pct"] > 0
|
||||||
|
assert result["backtest_objective"] == "equal_periodic_contribution_terminal_wealth"
|
||||||
|
|
||||||
|
|
||||||
|
def test_orchestrator_selects_models_by_terminal_wealth_not_cost_basis():
|
||||||
|
results = {
|
||||||
|
"terminal_wealth_improvement_pct": 4.5,
|
||||||
|
"cost_basis_improvement_pct": 99.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
assert orchestrator.objective_score(results) == 4.5
|
||||||
Reference in New Issue
Block a user