Harden trading, training, and monitoring

This commit is contained in:
Codex
2026-07-10 15:51:53 +03:00
parent 6fb79ee2a9
commit 069d75d2f2
55 changed files with 2658 additions and 2332049 deletions
+114 -12
View File
@@ -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)