667 lines
24 KiB
Python
667 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
ML Optimizer for Bitcoin Accumulation Zone Scoring.
|
|
|
|
Trains a gradient boosted tree model on historical on-chain metrics to find
|
|
optimal metric weights for identifying the best long-term buying opportunities.
|
|
|
|
Output: config/ml_weights.json with optimized weights and feature importances.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
from datetime import datetime, timedelta
|
|
|
|
import numpy as np
|
|
from sklearn.ensemble import GradientBoostingClassifier
|
|
from sklearn.metrics import (
|
|
classification_report,
|
|
f1_score,
|
|
precision_score,
|
|
recall_score,
|
|
roc_auc_score,
|
|
)
|
|
from sklearn.model_selection import TimeSeriesSplit
|
|
from sklearn.preprocessing import StandardScaler
|
|
|
|
from scoring.policy import SCORE_BRACKETS, SCORE_VERSION, score_in_bracket
|
|
from ml.artifacts import ML_ARTIFACT_SCHEMA_VERSION
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
|
)
|
|
log = logging.getLogger("ml-optimizer")
|
|
|
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
HISTORY_PATH = os.path.join(BASE_DIR, "data", "history.json")
|
|
OUTPUT_PATH = os.path.join(BASE_DIR, "config", "ml_weights.json")
|
|
THRESHOLDS_PATH = os.path.join(BASE_DIR, "config", "thresholds.json")
|
|
|
|
# Date range: 2018-02-01 onward (when all 8 metrics + fear_greed available)
|
|
START_DATE = "2018-02-01"
|
|
# Training cutoff: need 1yr forward data for labels
|
|
TRAIN_CUTOFF_DAYS = 365
|
|
# Target: forward 365d return > 30% = "good time to buy"
|
|
GOOD_BUY_THRESHOLD = 30.0
|
|
# Validation embargo/purge horizon: labels use 365-day forward returns.
|
|
LABEL_HORIZON_DAYS = 365
|
|
VALIDATION_SPLITS = 5
|
|
|
|
# The 8 core metrics we score
|
|
METRIC_KEYS = [
|
|
"puell_multiple",
|
|
"mvrv_zscore",
|
|
"reserve_risk",
|
|
"rhodl_ratio",
|
|
"nupl",
|
|
"fear_greed",
|
|
]
|
|
# Ratio-based metrics (derived from price vs reference)
|
|
RATIO_METRICS = {
|
|
"pct_above_200w_sma": {"price_key": "btc_price", "ref_key": "200w_sma"},
|
|
"pct_above_lth_rp": {"price_key": "btc_price", "ref_key": "lth_realized_price"},
|
|
}
|
|
|
|
|
|
def load_history():
|
|
"""Load historical data and build date-aligned lookup."""
|
|
with open(HISTORY_PATH) as f:
|
|
raw = json.load(f)
|
|
|
|
index = {}
|
|
for key, data in raw.items():
|
|
if not isinstance(data, dict) or "dates" not in data:
|
|
continue
|
|
lookup = {}
|
|
for d, v in zip(data["dates"], data["values"]):
|
|
if v is not None:
|
|
lookup[d] = v
|
|
index[key] = lookup
|
|
return index
|
|
|
|
|
|
def load_thresholds():
|
|
"""Load scoring thresholds for converting raw values to 0-10 scores."""
|
|
with open(THRESHOLDS_PATH) as f:
|
|
return json.load(f)
|
|
|
|
|
|
def score_range(value, ranges):
|
|
"""Score a value using range-based thresholds (same logic as scoring/engine.py)."""
|
|
if value is None:
|
|
return None
|
|
for low, high, score in ranges:
|
|
low_ok = low is None or value >= low
|
|
high_ok = high is None or value < high
|
|
if low_ok and high_ok:
|
|
return score
|
|
return 0
|
|
|
|
|
|
SCORE_KEYS = [
|
|
"puell_multiple", "mvrv_zscore", "reserve_risk", "rhodl_ratio",
|
|
"nupl", "fear_greed", "drawdown", "pct_above_200w_sma", "pct_above_lth_rp",
|
|
]
|
|
|
|
SCORE_FEATURES = [f"score_{k}" for k in SCORE_KEYS]
|
|
RAW_FEATURES = [
|
|
"raw_puell_multiple", "raw_mvrv_zscore", "raw_reserve_risk",
|
|
"raw_rhodl_ratio", "raw_nupl", "raw_fear_greed",
|
|
"raw_pct_above_200w_sma", "raw_pct_above_lth_rp", "raw_drawdown",
|
|
]
|
|
DELTA_FEATURES = [
|
|
"delta_30d_mvrv_zscore", "delta_30d_nupl",
|
|
"delta_30d_puell_multiple", "delta_30d_reserve_risk",
|
|
]
|
|
INTERACTION_FEATURES = ["mvrv_x_nupl", "puell_x_reserve"]
|
|
CYCLE_FEATURES = ["days_since_ath"]
|
|
FEATURE_COLS = SCORE_FEATURES + RAW_FEATURES + DELTA_FEATURES + INTERACTION_FEATURES + CYCLE_FEATURES
|
|
|
|
BRACKETS = SCORE_BRACKETS
|
|
|
|
|
|
def _row_date(row):
|
|
return datetime.strptime(row["date"], "%Y-%m-%d")
|
|
|
|
|
|
def purged_time_series_splits(rows, n_splits=VALIDATION_SPLITS,
|
|
label_horizon_days=LABEL_HORIZON_DAYS,
|
|
embargo_days=0):
|
|
"""Yield expanding-window splits with overlapping forward-label windows removed.
|
|
|
|
A row dated T with a 365-day forward-return label consumes information up to
|
|
T+365. For validation beginning at V, any training row whose label window
|
|
reaches V is removed. This keeps validation metrics out-of-sample for the
|
|
forward-return label, not just for features.
|
|
"""
|
|
base_splitter = TimeSeriesSplit(n_splits=n_splits)
|
|
row_dates = [_row_date(r) for r in rows]
|
|
horizon = timedelta(days=label_horizon_days)
|
|
embargo = timedelta(days=embargo_days)
|
|
|
|
for train_idx, val_idx in base_splitter.split(np.arange(len(rows))):
|
|
val_start = row_dates[val_idx[0]]
|
|
val_end = row_dates[val_idx[-1]]
|
|
purged_train = []
|
|
for idx in train_idx:
|
|
label_end = row_dates[idx] + horizon
|
|
before_validation_label_window = label_end <= val_start - embargo
|
|
after_validation_embargo = row_dates[idx] > val_end + embargo
|
|
if before_validation_label_window or after_validation_embargo:
|
|
purged_train.append(idx)
|
|
if purged_train:
|
|
yield np.array(purged_train, dtype=int), np.array(val_idx, dtype=int)
|
|
|
|
|
|
def _build_model():
|
|
return GradientBoostingClassifier(
|
|
n_estimators=300,
|
|
learning_rate=0.05,
|
|
max_depth=4,
|
|
subsample=0.8,
|
|
min_samples_leaf=20,
|
|
random_state=42,
|
|
)
|
|
|
|
|
|
def derive_metric_weights(feature_cols, importances):
|
|
"""Aggregate feature importances back to transparent score metric weights."""
|
|
metric_names = list(SCORE_KEYS)
|
|
feature_to_metric = {}
|
|
for m in metric_names:
|
|
feature_to_metric[f"score_{m}"] = m
|
|
feature_to_metric[f"raw_{m}"] = m
|
|
feature_to_metric["delta_30d_mvrv_zscore"] = "mvrv_zscore"
|
|
feature_to_metric["delta_30d_nupl"] = "nupl"
|
|
feature_to_metric["delta_30d_puell_multiple"] = "puell_multiple"
|
|
feature_to_metric["delta_30d_reserve_risk"] = "reserve_risk"
|
|
|
|
metric_importances = {m: 0.0 for m in metric_names}
|
|
for name, imp in zip(feature_cols, importances):
|
|
if name in feature_to_metric:
|
|
metric_importances[feature_to_metric[name]] += float(imp)
|
|
elif name == "mvrv_x_nupl":
|
|
metric_importances["mvrv_zscore"] += float(imp) / 2
|
|
metric_importances["nupl"] += float(imp) / 2
|
|
elif name == "puell_x_reserve":
|
|
metric_importances["puell_multiple"] += float(imp) / 2
|
|
metric_importances["reserve_risk"] += float(imp) / 2
|
|
elif name == "days_since_ath":
|
|
metric_importances["drawdown"] += float(imp)
|
|
|
|
total_imp = sum(metric_importances.values())
|
|
if total_imp > 0:
|
|
weights = {k: round(v / total_imp, 4) for k, v in metric_importances.items()}
|
|
else:
|
|
weights = {k: round(1 / len(metric_importances), 4) for k in metric_importances}
|
|
return dict(sorted(weights.items(), key=lambda x: x[1], reverse=True))
|
|
|
|
|
|
def build_dataset(index, thresholds):
|
|
"""Build aligned training dataset: metric scores + forward returns."""
|
|
# Get all dates from 2018-02-01 onward
|
|
all_dates = set()
|
|
for lookup in index.values():
|
|
all_dates.update(lookup.keys())
|
|
dates = sorted(d for d in all_dates if d >= START_DATE)
|
|
|
|
# Build price lookup for forward returns
|
|
price_lookup = {}
|
|
for pk in ["btc_price", "btc_price_sma", "btc_price_lth"]:
|
|
if pk in index:
|
|
for d, v in index[pk].items():
|
|
if d not in price_lookup:
|
|
price_lookup[d] = v
|
|
|
|
# Compute ATH series for drawdown
|
|
all_dates_sorted = sorted(all_dates)
|
|
ath = 0
|
|
drawdowns = {}
|
|
for d in all_dates_sorted:
|
|
p = price_lookup.get(d)
|
|
if p is None:
|
|
continue
|
|
if p > ath:
|
|
ath = p
|
|
if ath > 0:
|
|
drawdowns[d] = ((ath - p) / ath) * 100
|
|
|
|
# Get threshold ranges for scoring raw values
|
|
metric_ranges = {
|
|
"puell_multiple": thresholds.get("puell_multiple", {}).get("ranges", []),
|
|
"mvrv_zscore": thresholds.get("mvrv_zscore", {}).get("ranges", []),
|
|
"reserve_risk": thresholds.get("reserve_risk", {}).get("ranges", []),
|
|
"rhodl_ratio": thresholds.get("rhodl_ratio", {}).get("ranges", []),
|
|
"nupl": thresholds.get("nupl", {}).get("ranges", []),
|
|
"fear_greed": thresholds.get("fear_greed", {}).get("ranges", []),
|
|
"drawdown": thresholds.get("drawdown", {}).get("ranges", []),
|
|
"price_vs_200w_sma": thresholds.get("price_vs_200w_sma", {}).get("ranges", []),
|
|
"lth_realized_price": thresholds.get("lth_realized_price", {}).get("ranges", []),
|
|
}
|
|
|
|
log.info("Building dataset from %d dates (%s to %s)", len(dates), dates[0], dates[-1])
|
|
|
|
rows = []
|
|
for d in dates:
|
|
# Get raw metric values
|
|
vals = {}
|
|
skip = False
|
|
for key in METRIC_KEYS:
|
|
v = index.get(key, {}).get(d)
|
|
if v is None:
|
|
skip = True
|
|
break
|
|
vals[key] = v
|
|
if skip:
|
|
continue
|
|
|
|
# Compute ratio metrics
|
|
price = price_lookup.get(d)
|
|
sma_200w = index.get("200w_sma", {}).get(d)
|
|
lth_rp = index.get("lth_realized_price", {}).get(d)
|
|
|
|
if price is None or sma_200w is None or lth_rp is None:
|
|
continue
|
|
if sma_200w == 0 or lth_rp == 0:
|
|
continue
|
|
|
|
pct_200w = ((price - sma_200w) / sma_200w) * 100
|
|
pct_lth = ((price - lth_rp) / lth_rp) * 100
|
|
dd = drawdowns.get(d, 0)
|
|
|
|
vals["pct_above_200w_sma"] = pct_200w
|
|
vals["pct_above_lth_rp"] = pct_lth
|
|
vals["drawdown"] = dd
|
|
|
|
# Score each metric (0-10) using existing thresholds
|
|
scores = {}
|
|
scores["puell_multiple"] = score_range(vals["puell_multiple"], metric_ranges["puell_multiple"])
|
|
scores["mvrv_zscore"] = score_range(vals["mvrv_zscore"], metric_ranges["mvrv_zscore"])
|
|
scores["reserve_risk"] = score_range(vals["reserve_risk"], metric_ranges["reserve_risk"])
|
|
scores["rhodl_ratio"] = score_range(vals["rhodl_ratio"], metric_ranges["rhodl_ratio"])
|
|
scores["nupl"] = score_range(vals["nupl"], metric_ranges["nupl"])
|
|
scores["fear_greed"] = score_range(vals["fear_greed"], metric_ranges["fear_greed"])
|
|
scores["drawdown"] = score_range(dd, metric_ranges["drawdown"])
|
|
scores["pct_above_200w_sma"] = score_range(pct_200w, metric_ranges["price_vs_200w_sma"])
|
|
scores["pct_above_lth_rp"] = score_range(pct_lth, metric_ranges["lth_realized_price"])
|
|
|
|
if any(s is None for s in scores.values()):
|
|
continue
|
|
|
|
# Forward returns
|
|
dt = datetime.strptime(d, "%Y-%m-%d")
|
|
fwd = {}
|
|
for days in [30, 90, 180, 365]:
|
|
future_d = (dt + timedelta(days=days)).strftime("%Y-%m-%d")
|
|
fp = price_lookup.get(future_d)
|
|
if fp is not None and price > 0:
|
|
fwd[f"fwd_{days}d"] = ((fp - price) / price) * 100
|
|
|
|
# Compute rate-of-change features (30d deltas)
|
|
deltas = {}
|
|
d_30ago = (dt - timedelta(days=30)).strftime("%Y-%m-%d")
|
|
for key in ["mvrv_zscore", "nupl", "puell_multiple", "reserve_risk"]:
|
|
v_now = vals[key]
|
|
v_prev = index.get(key, {}).get(d_30ago)
|
|
if v_prev is not None and v_prev != 0:
|
|
deltas[f"delta_30d_{key}"] = v_now - v_prev
|
|
else:
|
|
deltas[f"delta_30d_{key}"] = 0.0
|
|
|
|
# Interaction terms
|
|
interactions = {
|
|
"mvrv_x_nupl": vals["mvrv_zscore"] * vals["nupl"],
|
|
"puell_x_reserve": vals["puell_multiple"] * vals["reserve_risk"],
|
|
}
|
|
|
|
# Days since last ATH
|
|
days_since_ath = 0
|
|
for i in range(1, 2000):
|
|
check_d = (dt - timedelta(days=i)).strftime("%Y-%m-%d")
|
|
check_dd = drawdowns.get(check_d, 100)
|
|
if check_dd < 0.1: # essentially at ATH
|
|
days_since_ath = i
|
|
break
|
|
else:
|
|
days_since_ath = 2000
|
|
|
|
row = {
|
|
"date": d,
|
|
"price": price,
|
|
**{f"score_{k}": v for k, v in scores.items()},
|
|
**{f"raw_{k}": v for k, v in vals.items()},
|
|
**deltas,
|
|
**interactions,
|
|
"days_since_ath": days_since_ath,
|
|
**fwd,
|
|
}
|
|
rows.append(row)
|
|
|
|
log.info("Built %d complete data rows", len(rows))
|
|
return rows
|
|
|
|
|
|
def train_model(rows):
|
|
"""Train gradient boosted classifier to identify good buying opportunities."""
|
|
# Filter to rows that have 365d forward return (for labeling)
|
|
labeled = [r for r in rows if "fwd_365d" in r]
|
|
log.info("Rows with 365d forward data: %d", len(labeled))
|
|
|
|
if len(labeled) < 100:
|
|
log.error("Not enough labeled data. Need at least 100 rows, got %d", len(labeled))
|
|
return None
|
|
|
|
# Create binary target: forward 365d return > threshold
|
|
for r in labeled:
|
|
r["target"] = 1 if r["fwd_365d"] > GOOD_BUY_THRESHOLD else 0
|
|
|
|
positive = sum(r["target"] for r in labeled)
|
|
log.info("Target distribution: %d positive (%.1f%%), %d negative",
|
|
positive, positive / len(labeled) * 100, len(labeled) - positive)
|
|
|
|
feature_cols = FEATURE_COLS
|
|
|
|
X = np.array([[r[f] for f in feature_cols] for r in labeled])
|
|
y = np.array([r["target"] for r in labeled])
|
|
|
|
log.info("Feature matrix: %d samples x %d features", X.shape[0], X.shape[1])
|
|
|
|
# Purged time-series cross-validation. Standard TimeSeriesSplit is not
|
|
# enough here because each label consumes the next 365 days of returns.
|
|
cv_scores = []
|
|
cv_f1 = []
|
|
cv_precision = []
|
|
cv_recall = []
|
|
fold_results = []
|
|
|
|
splits = list(purged_time_series_splits(
|
|
labeled,
|
|
n_splits=VALIDATION_SPLITS,
|
|
label_horizon_days=LABEL_HORIZON_DAYS,
|
|
embargo_days=0,
|
|
))
|
|
if not splits:
|
|
log.error("No viable purged validation splits. Need more history for %dd label horizon.",
|
|
LABEL_HORIZON_DAYS)
|
|
return None
|
|
|
|
for fold, (train_idx, val_idx) in enumerate(splits):
|
|
X_train, X_val = X[train_idx], X[val_idx]
|
|
y_train, y_val = y[train_idx], y[val_idx]
|
|
|
|
scaler = StandardScaler()
|
|
X_train_s = scaler.fit_transform(X_train)
|
|
X_val_s = scaler.transform(X_val)
|
|
|
|
model = _build_model()
|
|
model.fit(X_train_s, y_train)
|
|
|
|
y_pred = model.predict(X_val_s)
|
|
y_prob = model.predict_proba(X_val_s)[:, 1]
|
|
|
|
auc = roc_auc_score(y_val, y_prob) if len(np.unique(y_val)) > 1 else 0
|
|
f1 = f1_score(y_val, y_pred, zero_division=0)
|
|
prec = precision_score(y_val, y_pred, zero_division=0)
|
|
rec = recall_score(y_val, y_pred, zero_division=0)
|
|
|
|
cv_scores.append(auc)
|
|
cv_f1.append(f1)
|
|
cv_precision.append(prec)
|
|
cv_recall.append(rec)
|
|
|
|
fold_weights = derive_metric_weights(feature_cols, model.feature_importances_)
|
|
fold_results.append({
|
|
"fold": fold + 1,
|
|
"train_idx": train_idx.tolist(),
|
|
"val_idx": val_idx.tolist(),
|
|
"weights": fold_weights,
|
|
"metrics": {
|
|
"auc": round(float(auc), 4),
|
|
"f1": round(float(f1), 4),
|
|
"precision": round(float(prec), 4),
|
|
"recall": round(float(rec), 4),
|
|
},
|
|
"date_ranges": {
|
|
"train": f"{labeled[train_idx[0]]['date']} to {labeled[train_idx[-1]]['date']}",
|
|
"validation": f"{labeled[val_idx[0]]['date']} to {labeled[val_idx[-1]]['date']}",
|
|
},
|
|
"n_train": len(train_idx),
|
|
"n_validation": len(val_idx),
|
|
})
|
|
|
|
train_dates = fold_results[-1]["date_ranges"]["train"]
|
|
val_dates = fold_results[-1]["date_ranges"]["validation"]
|
|
log.info("Fold %d: Train %s | Val %s | AUC=%.3f F1=%.3f P=%.3f R=%.3f",
|
|
fold + 1, train_dates, val_dates, auc, f1, prec, rec)
|
|
|
|
log.info("Purged CV Mean AUC: %.3f (+/- %.3f)", np.mean(cv_scores), np.std(cv_scores))
|
|
log.info("Purged CV Mean F1: %.3f (+/- %.3f)", np.mean(cv_f1), np.std(cv_f1))
|
|
|
|
# Train final model on all labeled data
|
|
log.info("Training final model on all %d labeled samples...", len(labeled))
|
|
scaler = StandardScaler()
|
|
X_scaled = scaler.fit_transform(X)
|
|
|
|
final_model = _build_model()
|
|
final_model.fit(X_scaled, y)
|
|
|
|
# Feature importances
|
|
importances = final_model.feature_importances_
|
|
feat_imp = sorted(
|
|
zip(feature_cols, importances),
|
|
key=lambda x: x[1],
|
|
reverse=True,
|
|
)
|
|
|
|
log.info("\nFeature Importance Ranking:")
|
|
log.info("-" * 50)
|
|
for name, imp in feat_imp:
|
|
bar = "#" * int(imp * 200)
|
|
log.info(" %-30s %.4f %s", name, imp, bar)
|
|
|
|
weights = derive_metric_weights(feature_cols, importances)
|
|
|
|
log.info("\nOptimal Metric Weights:")
|
|
log.info("-" * 50)
|
|
equal_weight = round(1 / len(weights), 4)
|
|
for metric, w in weights.items():
|
|
change = "+" if w > equal_weight else ""
|
|
diff = (w - equal_weight) / equal_weight * 100
|
|
log.info(" %-25s %.4f (%s%.0f%% vs equal)", metric, w, change, diff)
|
|
|
|
# Run comparison backtest: ML-weighted vs equal-weight
|
|
log.info("\n" + "=" * 60)
|
|
log.info("COMPARISON BACKTEST: ML-Weighted vs Equal-Weight")
|
|
log.info("=" * 60)
|
|
comparison = run_comparison(rows, weights)
|
|
out_of_sample_comparison = run_out_of_sample_comparison(labeled, fold_results)
|
|
|
|
# Build output. Final weights are fitted on all labeled history for live use;
|
|
# only the fold weights below are valid for OOS comparisons.
|
|
trained_at = datetime.now(tz=__import__('datetime').timezone.utc).isoformat()
|
|
result = {
|
|
"artifact_schema_version": ML_ARTIFACT_SCHEMA_VERSION,
|
|
"score_version": SCORE_VERSION,
|
|
"weights": weights,
|
|
"feature_importances": {name: round(float(imp), 6) for name, imp in feat_imp},
|
|
"cv_results": {
|
|
"mean_auc": round(float(np.mean(cv_scores)), 4),
|
|
"std_auc": round(float(np.std(cv_scores)), 4),
|
|
"mean_f1": round(float(np.mean(cv_f1)), 4),
|
|
"mean_precision": round(float(np.mean(cv_precision)), 4),
|
|
"mean_recall": round(float(np.mean(cv_recall)), 4),
|
|
"validation_method": "purged_expanding_window",
|
|
"label_horizon_days": LABEL_HORIZON_DAYS,
|
|
"folds": fold_results,
|
|
},
|
|
"training_info": {
|
|
"n_samples": len(labeled),
|
|
"n_positive": int(positive),
|
|
"positive_rate": round(positive / len(labeled), 4),
|
|
"n_features": len(feature_cols),
|
|
"target_threshold": GOOD_BUY_THRESHOLD,
|
|
"date_range": f"{labeled[0]['date']} to {labeled[-1]['date']}",
|
|
"model": "GradientBoostingClassifier",
|
|
},
|
|
"provenance": {
|
|
"validation_method": "purged_expanding_window",
|
|
"label_horizon_days": LABEL_HORIZON_DAYS,
|
|
"weight_scope": "full_history_fit",
|
|
"training_date_range": {
|
|
"start": labeled[0]["date"],
|
|
"end": labeled[-1]["date"],
|
|
},
|
|
"trained_at": trained_at,
|
|
},
|
|
"comparison": comparison,
|
|
"out_of_sample_comparison": out_of_sample_comparison,
|
|
"trained_at": trained_at,
|
|
}
|
|
|
|
return result
|
|
|
|
|
|
def _composite_score(row, mode, ml_weights=None):
|
|
scores = [row[f"score_{k}"] for k in SCORE_KEYS]
|
|
if mode == "equal_weight" or not ml_weights:
|
|
return sum(scores) / len(SCORE_KEYS) * 10
|
|
equal_weight = 1.0 / len(SCORE_KEYS)
|
|
weighted_sum = sum(row[f"score_{k}"] * ml_weights.get(k, equal_weight) for k in SCORE_KEYS)
|
|
return weighted_sum * 10
|
|
|
|
|
|
def _summarize_brackets(scored_rows, score_key):
|
|
results = []
|
|
for low, high, label in BRACKETS:
|
|
days_in = [r for r in scored_rows if score_in_bracket(r[score_key], (low, high, label))]
|
|
if not days_in:
|
|
results.append({
|
|
"range": f"{low}-{high}", "label": label,
|
|
"days": 0, "avg_365d": None,
|
|
})
|
|
continue
|
|
returns_365 = [r["fwd_365d"] for r in days_in]
|
|
returns_sorted = sorted(returns_365)
|
|
win_rate = len([r for r in returns_365 if r > 0]) / len(returns_365) * 100
|
|
results.append({
|
|
"range": f"{low}-{high}",
|
|
"label": label,
|
|
"days": len(days_in),
|
|
"avg_365d": round(sum(returns_365) / len(returns_365), 2),
|
|
"median_365d": round(returns_sorted[len(returns_sorted) // 2], 2),
|
|
"win_rate_365d": round(win_rate, 1),
|
|
})
|
|
return results
|
|
|
|
|
|
def _log_comparison_table(results):
|
|
log.info("\n%-18s | %-8s %-8s %-8s | %-8s %-8s %-8s",
|
|
"Bracket", "EQ Avg", "EQ Med", "EQ Win%", "ML Avg", "ML Med", "ML Win%")
|
|
log.info("-" * 80)
|
|
for eq, ml in zip(results["equal_weight"], results["ml_weighted"]):
|
|
eq_avg = f"{eq['avg_365d']:.1f}%" if eq["avg_365d"] is not None else "--"
|
|
eq_med = f"{eq['median_365d']:.1f}%" if eq.get("median_365d") is not None else "--"
|
|
eq_win = f"{eq['win_rate_365d']:.0f}%" if eq.get("win_rate_365d") is not None else "--"
|
|
ml_avg = f"{ml['avg_365d']:.1f}%" if ml["avg_365d"] is not None else "--"
|
|
ml_med = f"{ml['median_365d']:.1f}%" if ml.get("median_365d") is not None else "--"
|
|
ml_win = f"{ml['win_rate_365d']:.0f}%" if ml.get("win_rate_365d") is not None else "--"
|
|
log.info("%-18s | %-8s %-8s %-8s | %-8s %-8s %-8s",
|
|
eq["label"], eq_avg, eq_med, eq_win, ml_avg, ml_med, ml_win)
|
|
|
|
|
|
def run_comparison(rows, ml_weights):
|
|
"""Compare final ML-weighted scoring vs equal-weight scoring across all labeled rows.
|
|
|
|
This is retained for backwards compatibility with existing output. It is an
|
|
in-sample/full-history comparison; prefer out_of_sample_comparison for model
|
|
selection decisions.
|
|
"""
|
|
scored_rows = [dict(r) for r in rows if "fwd_365d" in r]
|
|
for r in scored_rows:
|
|
r["composite_equal_weight"] = _composite_score(r, "equal_weight")
|
|
r["composite_ml_weighted"] = _composite_score(r, "ml_weighted", ml_weights)
|
|
|
|
results = {
|
|
"equal_weight": _summarize_brackets(scored_rows, "composite_equal_weight"),
|
|
"ml_weighted": _summarize_brackets(scored_rows, "composite_ml_weighted"),
|
|
}
|
|
_log_comparison_table(results)
|
|
return results
|
|
|
|
|
|
def run_out_of_sample_comparison(rows, fold_results):
|
|
"""Compare fold-specific ML weights on validation rows only."""
|
|
validation_rows = []
|
|
for fold in fold_results:
|
|
weights = fold.get("weights", {})
|
|
for idx in fold.get("val_idx", []):
|
|
if idx >= len(rows) or "fwd_365d" not in rows[idx]:
|
|
continue
|
|
r = dict(rows[idx])
|
|
r["fold"] = fold.get("fold")
|
|
r["composite_equal_weight"] = _composite_score(r, "equal_weight")
|
|
r["composite_ml_weighted"] = _composite_score(r, "ml_weighted", weights)
|
|
validation_rows.append(r)
|
|
|
|
results = {
|
|
"folds": len(fold_results),
|
|
"validation_days": len(validation_rows),
|
|
"equal_weight": _summarize_brackets(validation_rows, "composite_equal_weight"),
|
|
"ml_weighted": _summarize_brackets(validation_rows, "composite_ml_weighted"),
|
|
}
|
|
return results
|
|
|
|
|
|
def main():
|
|
log.info("=" * 60)
|
|
log.info("Bitcoin Accumulation Zone ML Optimizer")
|
|
log.info("=" * 60)
|
|
|
|
if not os.path.exists(HISTORY_PATH):
|
|
log.error("No historical data at %s. Run history collector first.", HISTORY_PATH)
|
|
sys.exit(1)
|
|
|
|
# Load data
|
|
log.info("Loading historical data...")
|
|
index = load_history()
|
|
thresholds = load_thresholds()
|
|
|
|
# Build dataset
|
|
log.info("Building training dataset...")
|
|
rows = build_dataset(index, thresholds)
|
|
|
|
# Train model
|
|
log.info("Training ML model...")
|
|
result = train_model(rows)
|
|
|
|
if result is None:
|
|
log.error("Training failed.")
|
|
sys.exit(1)
|
|
|
|
# Save weights
|
|
with open(OUTPUT_PATH, "w") as f:
|
|
json.dump(result, f, indent=2)
|
|
log.info("\nSaved ML weights to %s", OUTPUT_PATH)
|
|
|
|
# Print summary
|
|
log.info("\n" + "=" * 60)
|
|
log.info("SUMMARY")
|
|
log.info("=" * 60)
|
|
log.info("Model: %s", result["training_info"]["model"])
|
|
log.info("Samples: %d (%d positive)", result["training_info"]["n_samples"], result["training_info"]["n_positive"])
|
|
log.info("CV AUC: %.3f (+/- %.3f)", result["cv_results"]["mean_auc"], result["cv_results"]["std_auc"])
|
|
log.info("CV F1: %.3f", result["cv_results"]["mean_f1"])
|
|
log.info("\nTop 5 Feature Importances:")
|
|
for name, imp in list(result["feature_importances"].items())[:5]:
|
|
log.info(" %-30s %.4f", name, imp)
|
|
log.info("\nMetric Weights (ML-Optimized):")
|
|
for metric, weight in result["weights"].items():
|
|
log.info(" %-25s %.1f%%", metric, weight * 100)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|