Harden trading, training, and monitoring
This commit is contained in:
@@ -45,6 +45,12 @@ class PreparedData:
|
||||
validation_up: torch.Tensor
|
||||
validation_targets: list[list[float]]
|
||||
validation_volatility_scales: list[list[float]]
|
||||
holdout_x: torch.Tensor
|
||||
holdout_y: torch.Tensor
|
||||
holdout_up: torch.Tensor
|
||||
holdout_targets: list[list[float]]
|
||||
holdout_volatility_scales: list[list[float]]
|
||||
holdout_start_timestamp: int
|
||||
feature_names: list[str]
|
||||
feature_means: list[float]
|
||||
feature_scales: list[float]
|
||||
@@ -55,6 +61,7 @@ class PreparedData:
|
||||
decision_horizon_index: int
|
||||
train_samples: int
|
||||
validation_samples: int
|
||||
holdout_samples: int
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -63,6 +70,7 @@ class TrainingSample:
|
||||
normalized_targets: list[float]
|
||||
raw_targets: list[float]
|
||||
volatility_scales: list[float]
|
||||
timestamp: int
|
||||
|
||||
|
||||
class RecurrentReturnModel(nn.Module):
|
||||
@@ -131,6 +139,7 @@ def main() -> None:
|
||||
"interval": interval,
|
||||
"limit": args.limit,
|
||||
"validation_window": args.validation_window,
|
||||
"holdout_window": args.holdout_window,
|
||||
"target_horizon": decision_horizon,
|
||||
"target_horizons": target_horizons,
|
||||
"direct_horizon": True,
|
||||
@@ -154,6 +163,7 @@ def main() -> None:
|
||||
interval=interval,
|
||||
limit=args.limit,
|
||||
validation_window=args.validation_window,
|
||||
holdout_window=args.holdout_window,
|
||||
target_horizons=target_horizons,
|
||||
decision_horizon=decision_horizon,
|
||||
feature_names=feature_names,
|
||||
@@ -207,6 +217,7 @@ def _parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--interval", default="", help="Bybit kline interval. Defaults to BASE_INTERVAL.")
|
||||
parser.add_argument("--limit", type=int, default=1000, help="Kline limit per symbol.")
|
||||
parser.add_argument("--validation-window", type=int, default=120, help="Held-out tail targets used for validation.")
|
||||
parser.add_argument("--holdout-window", type=int, default=240, help="Final untouched samples reserved for model/threshold evaluation.")
|
||||
parser.add_argument("--horizon", type=int, default=0, help="Direct forecast horizon in candles. Defaults to TIME_SERIES_FORECAST_HORIZON.")
|
||||
parser.add_argument("--horizons", default="1,3,6,12", help="Comma-separated direct forecast horizons.")
|
||||
parser.add_argument("--features", default=",".join(DEFAULT_TORCH_FEATURES), help="Comma-separated feature names.")
|
||||
@@ -246,6 +257,7 @@ def _train_symbol(
|
||||
interval: str,
|
||||
limit: int,
|
||||
validation_window: int,
|
||||
holdout_window: int,
|
||||
target_horizons: list[int],
|
||||
decision_horizon: int,
|
||||
feature_names: list[str],
|
||||
@@ -272,7 +284,10 @@ def _train_symbol(
|
||||
closes = [float(candle.close) for candle in candles if candle.close > 0]
|
||||
returns = _log_returns(closes)
|
||||
max_horizon = max(target_horizons)
|
||||
if len(candles) < max(180, validation_window + max(lookbacks) + max_horizon + 16):
|
||||
if len(candles) < max(
|
||||
240,
|
||||
validation_window + holdout_window + max(lookbacks) + max_horizon * 2 + 32,
|
||||
):
|
||||
return None
|
||||
market_candles: dict[str, list[Candle]] = {symbol.upper(): candles}
|
||||
for context_symbol in context_symbols:
|
||||
@@ -301,6 +316,7 @@ def _train_symbol(
|
||||
market_candles=market_candles,
|
||||
trend_candles=trend_candles,
|
||||
validation_window=validation_window,
|
||||
holdout_window=holdout_window,
|
||||
clip=clip,
|
||||
device=device,
|
||||
)
|
||||
@@ -376,17 +392,23 @@ def _train_symbol(
|
||||
"clip": clip,
|
||||
"validation_mae_percent": validation_mae * 100,
|
||||
"baseline_mae_percent": baseline_mae * 100,
|
||||
"holdout_mae_percent": float(candidate.get("holdout_mae", 0.0)) * 100,
|
||||
"holdout_baseline_mae_percent": float(candidate.get("holdout_baseline_mae", 0.0)) * 100,
|
||||
"skill": skill,
|
||||
"candles": len(candles),
|
||||
"returns": len(returns),
|
||||
"train_samples": prepared.train_samples,
|
||||
"validation_samples": prepared.validation_samples,
|
||||
"holdout_samples": prepared.holdout_samples,
|
||||
"holdout_start_timestamp": prepared.holdout_start_timestamp,
|
||||
}
|
||||
score = _candidate_score(row)
|
||||
if best is None or score < _candidate_score(best):
|
||||
best = row
|
||||
if best is None:
|
||||
return None
|
||||
best["validation_skill"] = best.get("skill", 0.0)
|
||||
best["skill"] = best.get("holdout_skill", 0.0)
|
||||
best.pop("validation_mae", None)
|
||||
return best
|
||||
|
||||
@@ -402,6 +424,7 @@ def _prepare_data(
|
||||
market_candles: dict[str, list[Candle]],
|
||||
trend_candles: list[Candle],
|
||||
validation_window: int,
|
||||
holdout_window: int,
|
||||
clip: float,
|
||||
device: torch.device,
|
||||
) -> PreparedData | None:
|
||||
@@ -436,14 +459,29 @@ def _prepare_data(
|
||||
volatility_scales.append(volatility_scale)
|
||||
normalized_targets.append(net_return / max(volatility_scale, 1e-8))
|
||||
if valid:
|
||||
samples.append(TrainingSample(window, normalized_targets, raw_targets, volatility_scales))
|
||||
samples.append(
|
||||
TrainingSample(
|
||||
window,
|
||||
normalized_targets,
|
||||
raw_targets,
|
||||
volatility_scales,
|
||||
candles[end_index].timestamp,
|
||||
)
|
||||
)
|
||||
if len(samples) < 48:
|
||||
return None
|
||||
|
||||
validation_window = min(max(16, validation_window), max(16, len(samples) // 3))
|
||||
train_samples = samples[:-validation_window]
|
||||
validation_samples = samples[-validation_window:]
|
||||
if len(train_samples) < 24 or len(validation_samples) < 8:
|
||||
max_horizon = max(target_horizons)
|
||||
holdout_window = min(max(32, holdout_window), max(32, len(samples) // 4))
|
||||
holdout_start = len(samples) - holdout_window
|
||||
validation_end = holdout_start - max_horizon
|
||||
validation_window = min(max(16, validation_window), max(16, validation_end // 3))
|
||||
validation_start = validation_end - validation_window
|
||||
train_end = validation_start - max_horizon
|
||||
train_samples = samples[:train_end]
|
||||
validation_samples = samples[validation_start:validation_end]
|
||||
holdout_samples = samples[holdout_start:]
|
||||
if len(train_samples) < 24 or len(validation_samples) < 8 or len(holdout_samples) < 16:
|
||||
return None
|
||||
|
||||
feature_means, feature_scales = _feature_stats(train_samples, len(feature_names))
|
||||
@@ -470,6 +508,14 @@ def _prepare_data(
|
||||
target_scales=target_scales,
|
||||
clip=clip,
|
||||
)
|
||||
holdout_x, holdout_y, holdout_up = _normalize_samples(
|
||||
holdout_samples,
|
||||
feature_means=feature_means,
|
||||
feature_scales=feature_scales,
|
||||
target_means=target_means,
|
||||
target_scales=target_scales,
|
||||
clip=clip,
|
||||
)
|
||||
return PreparedData(
|
||||
train_x=torch.tensor(train_x, dtype=torch.float32, device=device),
|
||||
train_y=torch.tensor(train_y, dtype=torch.float32, device=device),
|
||||
@@ -479,6 +525,12 @@ def _prepare_data(
|
||||
validation_up=torch.tensor(validation_up, dtype=torch.float32, device=device),
|
||||
validation_targets=[sample.raw_targets for sample in validation_samples],
|
||||
validation_volatility_scales=[sample.volatility_scales for sample in validation_samples],
|
||||
holdout_x=torch.tensor(holdout_x, dtype=torch.float32, device=device),
|
||||
holdout_y=torch.tensor(holdout_y, dtype=torch.float32, device=device),
|
||||
holdout_up=torch.tensor(holdout_up, dtype=torch.float32, device=device),
|
||||
holdout_targets=[sample.raw_targets for sample in holdout_samples],
|
||||
holdout_volatility_scales=[sample.volatility_scales for sample in holdout_samples],
|
||||
holdout_start_timestamp=holdout_samples[0].timestamp,
|
||||
feature_names=feature_names,
|
||||
feature_means=feature_means,
|
||||
feature_scales=feature_scales,
|
||||
@@ -489,6 +541,7 @@ def _prepare_data(
|
||||
decision_horizon_index=decision_horizon_index,
|
||||
train_samples=len(train_x),
|
||||
validation_samples=len(validation_x),
|
||||
holdout_samples=len(holdout_x),
|
||||
)
|
||||
|
||||
|
||||
@@ -635,8 +688,10 @@ def _fit_candidate(
|
||||
|
||||
if best_state:
|
||||
model.load_state_dict(best_state)
|
||||
holdout_metrics = _holdout_metrics(model, prepared, clip)
|
||||
return {
|
||||
**best_metrics,
|
||||
**holdout_metrics,
|
||||
"best_epoch": best_epoch,
|
||||
"epochs_trained": best_epoch + stale_epochs,
|
||||
"state_dict": _export_recurrent_state(model),
|
||||
@@ -647,10 +702,57 @@ def _fit_candidate(
|
||||
|
||||
|
||||
def _validation_metrics(model: nn.Module, prepared: PreparedData, clip: float) -> dict[str, float]:
|
||||
return _evaluation_metrics(
|
||||
model,
|
||||
values=prepared.validation_x,
|
||||
targets=prepared.validation_targets,
|
||||
volatility_scales=prepared.validation_volatility_scales,
|
||||
prepared=prepared,
|
||||
clip=clip,
|
||||
)
|
||||
|
||||
|
||||
def _holdout_metrics(model: nn.Module, prepared: PreparedData, clip: float) -> dict[str, Any]:
|
||||
metrics = _evaluation_metrics(
|
||||
model,
|
||||
values=prepared.holdout_x,
|
||||
targets=prepared.holdout_targets,
|
||||
volatility_scales=prepared.holdout_volatility_scales,
|
||||
prepared=prepared,
|
||||
clip=clip,
|
||||
)
|
||||
baseline_by_horizon = metrics.get("baseline_mae_by_horizon", {})
|
||||
holdout_baseline = float(
|
||||
baseline_by_horizon.get(str(prepared.decision_horizon), metrics["validation_mae"])
|
||||
)
|
||||
holdout_mae = float(metrics["validation_mae"])
|
||||
return {
|
||||
"holdout_mae": holdout_mae,
|
||||
"holdout_baseline_mae": holdout_baseline,
|
||||
"holdout_skill": (
|
||||
(holdout_baseline - holdout_mae) / holdout_baseline
|
||||
if holdout_baseline > 0
|
||||
else 0.0
|
||||
),
|
||||
"holdout_directional_accuracy": metrics["directional_accuracy"],
|
||||
"holdout_buy_precision": metrics["buy_precision"],
|
||||
"holdout_probability_brier": metrics["probability_brier"],
|
||||
}
|
||||
|
||||
|
||||
def _evaluation_metrics(
|
||||
model: nn.Module,
|
||||
*,
|
||||
values: torch.Tensor,
|
||||
targets: list[list[float]],
|
||||
volatility_scales: list[list[float]],
|
||||
prepared: PreparedData,
|
||||
clip: float,
|
||||
) -> dict[str, float]:
|
||||
model.eval()
|
||||
with torch.no_grad():
|
||||
raw_outputs = model(prepared.validation_x).detach().cpu()
|
||||
outputs = raw_outputs.view(len(prepared.validation_targets), len(prepared.target_horizons), len(OUTPUT_LAYOUT))
|
||||
raw_outputs = model(values).detach().cpu()
|
||||
outputs = raw_outputs.view(len(targets), len(prepared.target_horizons), len(OUTPUT_LAYOUT))
|
||||
mean_predictions = outputs[:, :, 0].tolist()
|
||||
logit_predictions = outputs[:, :, 4].tolist()
|
||||
predictions: list[list[float]] = []
|
||||
@@ -664,13 +766,13 @@ def _validation_metrics(model: nn.Module, prepared: PreparedData, clip: float) -
|
||||
* prepared.target_scales[horizon_index]
|
||||
+ prepared.target_means[horizon_index]
|
||||
)
|
||||
predicted_row.append(transformed * prepared.validation_volatility_scales[row_index][horizon_index])
|
||||
predicted_row.append(transformed * volatility_scales[row_index][horizon_index])
|
||||
probability_row.append(_sigmoid(float(logit_predictions[row_index][horizon_index])))
|
||||
predictions.append(predicted_row)
|
||||
probabilities.append(probability_row)
|
||||
decision = prepared.decision_horizon_index
|
||||
decision_predictions = [row[decision] for row in predictions]
|
||||
decision_targets = [row[decision] for row in prepared.validation_targets]
|
||||
decision_targets = [row[decision] for row in targets]
|
||||
errors = [abs(prediction - actual) for prediction, actual in zip(decision_predictions, decision_targets)]
|
||||
correct = [
|
||||
1.0
|
||||
@@ -693,9 +795,9 @@ def _validation_metrics(model: nn.Module, prepared: PreparedData, clip: float) -
|
||||
for horizon_index, horizon in enumerate(prepared.target_horizons):
|
||||
horizon_errors = [
|
||||
abs(row[horizon_index] - actual[horizon_index])
|
||||
for row, actual in zip(predictions, prepared.validation_targets)
|
||||
for row, actual in zip(predictions, targets)
|
||||
]
|
||||
horizon_baseline = [abs(actual[horizon_index]) for actual in prepared.validation_targets]
|
||||
horizon_baseline = [abs(actual[horizon_index]) for actual in targets]
|
||||
by_horizon[str(horizon)] = sum(horizon_errors) / len(horizon_errors) if horizon_errors else math.inf
|
||||
baseline_by_horizon[str(horizon)] = (
|
||||
sum(horizon_baseline) / len(horizon_baseline)
|
||||
|
||||
Reference in New Issue
Block a user