feat: train forecasts on trade outcomes
This commit is contained in:
@@ -28,6 +28,7 @@ from crypto_spot_bot.indicators import add_indicators
|
||||
from crypto_spot_bot.models import Candle
|
||||
from crypto_spot_bot.time_series import (
|
||||
DEFAULT_TORCH_FEATURES,
|
||||
_barrier_outcome,
|
||||
_current_volatility_scale,
|
||||
_entry_horizon,
|
||||
_entry_output_layout,
|
||||
@@ -64,6 +65,7 @@ class ForecastRecord:
|
||||
future_net_percent: float
|
||||
benchmark_entry: bool
|
||||
benchmark_exit: bool
|
||||
take_profit_first: bool | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -385,7 +387,22 @@ def _forecast_records(
|
||||
next_open = float(candles[index + 1].open)
|
||||
if next_open <= 0:
|
||||
continue
|
||||
future_log_return = math.log(closes[index + decision_horizon] / next_open) - round_trip_cost
|
||||
take_profit_first: bool | None = None
|
||||
if str(entry.get("target_transform", "")) == "barrier_net_return":
|
||||
outcome = _barrier_outcome(
|
||||
candles,
|
||||
end_index=index,
|
||||
horizon=decision_horizon,
|
||||
stop_loss_percent=_float_entry(entry, "target_stop_loss_percent", 0.04),
|
||||
take_profit_percent=_float_entry(entry, "target_take_profit_percent", 0.035),
|
||||
round_trip_cost=round_trip_cost,
|
||||
)
|
||||
if outcome is None:
|
||||
continue
|
||||
future_log_return, event = outcome
|
||||
take_profit_first = event >= 0.5
|
||||
else:
|
||||
future_log_return = math.log(closes[index + decision_horizon] / next_open) - round_trip_cost
|
||||
future_net_percent = (math.exp(future_log_return) - 1.0) * 100.0
|
||||
records.append(
|
||||
ForecastRecord(
|
||||
@@ -407,6 +424,7 @@ def _forecast_records(
|
||||
future_net_percent=future_net_percent,
|
||||
benchmark_entry=_benchmark_entry_signal(candles, trend_candles, index),
|
||||
benchmark_exit=_benchmark_exit_signal(candles, index),
|
||||
take_profit_first=take_profit_first,
|
||||
)
|
||||
)
|
||||
return records
|
||||
@@ -497,7 +515,22 @@ def _batch_forecast_records(
|
||||
next_open = float(candles[index + 1].open)
|
||||
if next_open <= 0:
|
||||
continue
|
||||
future_log_return = math.log(closes[index + decision_horizon] / next_open) - round_trip_cost
|
||||
take_profit_first: bool | None = None
|
||||
if str(entry.get("target_transform", "")) == "barrier_net_return":
|
||||
outcome = _barrier_outcome(
|
||||
candles,
|
||||
end_index=index,
|
||||
horizon=decision_horizon,
|
||||
stop_loss_percent=_float_entry(entry, "target_stop_loss_percent", 0.04),
|
||||
take_profit_percent=_float_entry(entry, "target_take_profit_percent", 0.035),
|
||||
round_trip_cost=round_trip_cost,
|
||||
)
|
||||
if outcome is None:
|
||||
continue
|
||||
future_log_return, event = outcome
|
||||
take_profit_first = event >= 0.5
|
||||
else:
|
||||
future_log_return = math.log(closes[index + decision_horizon] / next_open) - round_trip_cost
|
||||
future_net_percent = (math.exp(future_log_return) - 1.0) * 100.0
|
||||
records.append(
|
||||
ForecastRecord(
|
||||
@@ -519,6 +552,7 @@ def _batch_forecast_records(
|
||||
future_net_percent=future_net_percent,
|
||||
benchmark_entry=_benchmark_entry_signal(candles, trend_candles, index),
|
||||
benchmark_exit=_benchmark_exit_signal(candles, index),
|
||||
take_profit_first=take_profit_first,
|
||||
)
|
||||
)
|
||||
return records
|
||||
@@ -557,6 +591,10 @@ def _build_torch_model(entry: dict[str, Any], model_name: str) -> Any | None:
|
||||
output_size=output_size,
|
||||
attention_pooling=bool(entry.get("attention_pooling")),
|
||||
context_norm=bool(entry.get("context_norm")),
|
||||
multitask_head=bool(entry.get("multitask_head")),
|
||||
head_hidden_size=int(
|
||||
_clamp(_float_entry(entry, "head_hidden_size", float(hidden_size)), 8.0, 1024.0)
|
||||
),
|
||||
)
|
||||
raw_state = entry.get("state_dict")
|
||||
if not isinstance(raw_state, dict):
|
||||
@@ -566,12 +604,26 @@ def _build_torch_model(entry: dict[str, Any], model_name: str) -> Any | None:
|
||||
for key, value in raw_state.items()
|
||||
if isinstance(value, list)
|
||||
}
|
||||
head_weight = entry.get("head_weight")
|
||||
head_bias = entry.get("head_bias")
|
||||
if not isinstance(head_weight, list) or not isinstance(head_bias, list):
|
||||
return None
|
||||
state["head.weight"] = torch.tensor(head_weight, dtype=torch.float32)
|
||||
state["head.bias"] = torch.tensor(head_bias, dtype=torch.float32)
|
||||
if bool(entry.get("multitask_head")):
|
||||
for artifact_name, state_name in (
|
||||
("head_hidden_weight", "head_hidden.weight"),
|
||||
("head_hidden_bias", "head_hidden.bias"),
|
||||
("return_head_weight", "return_head.weight"),
|
||||
("return_head_bias", "return_head.bias"),
|
||||
("event_head_weight", "event_head.weight"),
|
||||
("event_head_bias", "event_head.bias"),
|
||||
):
|
||||
value = entry.get(artifact_name)
|
||||
if not isinstance(value, list):
|
||||
return None
|
||||
state[state_name] = torch.tensor(value, dtype=torch.float32)
|
||||
else:
|
||||
head_weight = entry.get("head_weight")
|
||||
head_bias = entry.get("head_bias")
|
||||
if not isinstance(head_weight, list) or not isinstance(head_bias, list):
|
||||
return None
|
||||
state["head.weight"] = torch.tensor(head_weight, dtype=torch.float32)
|
||||
state["head.bias"] = torch.tensor(head_bias, dtype=torch.float32)
|
||||
if bool(entry.get("attention_pooling")):
|
||||
attention_weight = entry.get("attention_weight")
|
||||
if not isinstance(attention_weight, list):
|
||||
@@ -639,10 +691,20 @@ def _decode_selected_output(
|
||||
expected = decode("mean")
|
||||
q_values = sorted([decode("q10", expected), decode("q50", expected), decode("q90", expected)])
|
||||
cap = _prediction_cap(history_closes, selected_horizon, round_trip_cost)
|
||||
if str(entry.get("target_transform", "")) == "barrier_net_return":
|
||||
stop_percent = _clamp(_float_entry(entry, "target_stop_loss_percent", 0.04), 0.003, 0.08)
|
||||
take_percent = _clamp(_float_entry(entry, "target_take_profit_percent", 0.035), 0.003, 0.20)
|
||||
cap = max(
|
||||
cap,
|
||||
abs(math.log(1.0 - stop_percent) - round_trip_cost),
|
||||
abs(math.log(1.0 + take_percent) - round_trip_cost),
|
||||
)
|
||||
return {
|
||||
"expected_return": _clamp(expected, -cap, cap),
|
||||
"q50": _clamp(q_values[1], -cap, cap),
|
||||
"probability_up": _sigmoid(float(values.get("logit_up", 0.0))),
|
||||
"probability_up": _sigmoid(
|
||||
float(values.get("logit_tp_first", values.get("logit_up", 0.0)))
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -1422,7 +1484,7 @@ def _fit_platt_calibration(records: list[ForecastRecord]) -> dict[str, float]:
|
||||
samples = [
|
||||
(
|
||||
math.log(_clamp(record.probability_up, 1e-5, 1.0 - 1e-5) / (1.0 - _clamp(record.probability_up, 1e-5, 1.0 - 1e-5))),
|
||||
1.0 if record.future_net_percent > 0 else 0.0,
|
||||
_record_event_target(record),
|
||||
)
|
||||
for record in records
|
||||
]
|
||||
@@ -1446,6 +1508,12 @@ def _fit_platt_calibration(records: list[ForecastRecord]) -> dict[str, float]:
|
||||
return {"slope": round(slope, 8), "intercept": round(intercept, 8), "samples": float(len(samples))}
|
||||
|
||||
|
||||
def _record_event_target(record: ForecastRecord) -> float:
|
||||
if record.take_profit_first is not None:
|
||||
return 1.0 if record.take_profit_first else 0.0
|
||||
return 1.0 if record.future_net_percent > 0 else 0.0
|
||||
|
||||
|
||||
def _apply_platt_calibration(
|
||||
records: list[ForecastRecord], calibration: dict[str, float]
|
||||
) -> list[ForecastRecord]:
|
||||
@@ -1544,6 +1612,9 @@ def _artifact_summary(artifact: dict[str, Any]) -> dict[str, Any]:
|
||||
"target_horizon": artifact.get("target_horizon"),
|
||||
"target_horizons": artifact.get("target_horizons"),
|
||||
"target_transform": artifact.get("target_transform"),
|
||||
"event_target": artifact.get("event_target"),
|
||||
"target_stop_loss_percent": artifact.get("target_stop_loss_percent"),
|
||||
"target_take_profit_percent": artifact.get("target_take_profit_percent"),
|
||||
"symbols": {
|
||||
symbol: {
|
||||
"model": row.get("model"),
|
||||
|
||||
@@ -18,6 +18,7 @@ param(
|
||||
[double]$WeightDecay = 0,
|
||||
[int]$Epochs = 0,
|
||||
[int]$Patience = 0,
|
||||
[int]$ValidationWindow = 0,
|
||||
[int]$HoldoutWindow = 0,
|
||||
[string]$Interval = "",
|
||||
[string]$EnvFile = "",
|
||||
@@ -155,6 +156,7 @@ if ($LearningRate -le 0) { $LearningRate = if ($env:TORCH_RETRAIN_LEARNING_RATE)
|
||||
if ($WeightDecay -le 0) { $WeightDecay = if ($env:TORCH_RETRAIN_WEIGHT_DECAY) { [double]$env:TORCH_RETRAIN_WEIGHT_DECAY } else { 0.0005 } }
|
||||
if ($Epochs -le 0) { $Epochs = if ($env:TORCH_RETRAIN_EPOCHS) { [int]$env:TORCH_RETRAIN_EPOCHS } else { 70 } }
|
||||
if ($Patience -le 0) { $Patience = if ($env:TORCH_RETRAIN_PATIENCE) { [int]$env:TORCH_RETRAIN_PATIENCE } else { 8 } }
|
||||
if ($ValidationWindow -le 0) { $ValidationWindow = if ($env:TORCH_RETRAIN_VALIDATION_WINDOW) { [int]$env:TORCH_RETRAIN_VALIDATION_WINDOW } else { 720 } }
|
||||
if ($HoldoutWindow -le 0) { $HoldoutWindow = if ($env:TORCH_RETRAIN_HOLDOUT_WINDOW) { [int]$env:TORCH_RETRAIN_HOLDOUT_WINDOW } else { 1000 } }
|
||||
if (-not $Interval -and $env:TORCH_RETRAIN_INTERVAL) { $Interval = $env:TORCH_RETRAIN_INTERVAL }
|
||||
if (-not $EnvFile -and $env:TORCH_RETRAIN_ENV) { $EnvFile = $env:TORCH_RETRAIN_ENV }
|
||||
@@ -190,6 +192,7 @@ try {
|
||||
"--dropouts", $Dropouts,
|
||||
"--epochs", $Epochs.ToString(),
|
||||
"--patience", $Patience.ToString(),
|
||||
"--validation-window", $ValidationWindow.ToString(),
|
||||
"--holdout-window", $HoldoutWindow.ToString(),
|
||||
"--ensemble-seeds", $EnsembleSeeds,
|
||||
"--selection-folds", $SelectionFolds.ToString(),
|
||||
@@ -242,6 +245,7 @@ try {
|
||||
"-u",
|
||||
"tools\calibrate_torch_thresholds.py",
|
||||
"--limit", $Limit.ToString(),
|
||||
"--horizon", $Horizon.ToString(),
|
||||
"--calibration-window", ([Math]::Min(2400, [Math]::Max(1200, [int]($Limit / 2)))).ToString(),
|
||||
"--min-trades", "24",
|
||||
"--walk-forward-folds", "8",
|
||||
|
||||
@@ -28,10 +28,18 @@ from crypto_spot_bot.bybit import BybitClient
|
||||
from crypto_spot_bot.config import load_settings
|
||||
from crypto_spot_bot.indicators import add_indicators
|
||||
from crypto_spot_bot.models import Candle
|
||||
from crypto_spot_bot.time_series import DEFAULT_TORCH_FEATURES, _feature_matrix, _log_returns
|
||||
from crypto_spot_bot.time_series import (
|
||||
DEFAULT_TORCH_FEATURES,
|
||||
_barrier_outcome,
|
||||
_feature_matrix,
|
||||
_log_returns,
|
||||
)
|
||||
|
||||
|
||||
OUTPUT_LAYOUT = ("mean", "q10", "q50", "q90", "logit_up")
|
||||
RETURN_OUTPUT_LAYOUT = ("mean", "q10", "q50", "q90")
|
||||
EVENT_OUTPUT_NAME = "logit_tp_first"
|
||||
OUTPUT_LAYOUT = (*RETURN_OUTPUT_LAYOUT, EVENT_OUTPUT_NAME)
|
||||
TARGET_TRANSFORM = "barrier_net_return"
|
||||
QUANTILES = {"q10": 0.10, "q50": 0.50, "q90": 0.90}
|
||||
|
||||
|
||||
@@ -44,11 +52,13 @@ class PreparedData:
|
||||
validation_y: torch.Tensor
|
||||
validation_up: torch.Tensor
|
||||
validation_targets: list[list[float]]
|
||||
validation_event_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_event_targets: list[list[float]]
|
||||
holdout_volatility_scales: list[list[float]]
|
||||
holdout_start_timestamp: int
|
||||
feature_names: list[str]
|
||||
@@ -69,6 +79,7 @@ class TrainingSample:
|
||||
window: list[list[float]]
|
||||
normalized_targets: list[float]
|
||||
raw_targets: list[float]
|
||||
event_targets: list[float]
|
||||
volatility_scales: list[float]
|
||||
timestamp: int
|
||||
|
||||
@@ -85,6 +96,8 @@ class RecurrentReturnModel(nn.Module):
|
||||
output_size: int,
|
||||
attention_pooling: bool,
|
||||
context_norm: bool,
|
||||
multitask_head: bool = False,
|
||||
head_hidden_size: int = 0,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
recurrent_cls = nn.LSTM if architecture == "lstm" else nn.GRU
|
||||
@@ -97,7 +110,30 @@ class RecurrentReturnModel(nn.Module):
|
||||
)
|
||||
self.attention = nn.Linear(hidden_size, 1) if attention_pooling else None
|
||||
self.context_norm = nn.LayerNorm(hidden_size) if context_norm else nn.Identity()
|
||||
self.head = nn.Linear(hidden_size, output_size)
|
||||
self.multitask_head = bool(multitask_head)
|
||||
self.output_size = output_size
|
||||
if self.multitask_head:
|
||||
if output_size % len(OUTPUT_LAYOUT) != 0:
|
||||
raise ValueError("multitask output size must align with OUTPUT_LAYOUT")
|
||||
self.horizon_count = output_size // len(OUTPUT_LAYOUT)
|
||||
projected_size = max(8, int(head_hidden_size or hidden_size))
|
||||
self.head_hidden = nn.Linear(hidden_size, projected_size)
|
||||
self.head_activation = nn.GELU()
|
||||
self.head_dropout = nn.Dropout(dropout)
|
||||
self.return_head = nn.Linear(
|
||||
projected_size,
|
||||
self.horizon_count * len(RETURN_OUTPUT_LAYOUT),
|
||||
)
|
||||
self.event_head = nn.Linear(projected_size, self.horizon_count)
|
||||
self.head = None
|
||||
else:
|
||||
self.horizon_count = 0
|
||||
self.head_hidden = None
|
||||
self.head_activation = None
|
||||
self.head_dropout = None
|
||||
self.return_head = None
|
||||
self.event_head = None
|
||||
self.head = nn.Linear(hidden_size, output_size)
|
||||
|
||||
def forward(self, values: torch.Tensor) -> torch.Tensor:
|
||||
output, _state = self.rnn(values)
|
||||
@@ -107,7 +143,21 @@ class RecurrentReturnModel(nn.Module):
|
||||
context = (output * weights).sum(dim=1)
|
||||
else:
|
||||
context = output[:, -1, :]
|
||||
return self.head(self.context_norm(context))
|
||||
context = self.context_norm(context)
|
||||
if not self.multitask_head:
|
||||
assert self.head is not None
|
||||
return self.head(context)
|
||||
assert self.head_hidden is not None
|
||||
assert self.head_activation is not None
|
||||
assert self.head_dropout is not None
|
||||
assert self.return_head is not None
|
||||
assert self.event_head is not None
|
||||
shared = self.head_dropout(self.head_activation(self.head_hidden(context)))
|
||||
returns = self.return_head(shared).view(
|
||||
values.shape[0], self.horizon_count, len(RETURN_OUTPUT_LAYOUT)
|
||||
)
|
||||
events = self.event_head(shared).view(values.shape[0], self.horizon_count, 1)
|
||||
return torch.cat((returns, events), dim=2).reshape(values.shape[0], self.output_size)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -129,13 +179,15 @@ def main() -> None:
|
||||
feature_names.extend(f"symbol_is_{symbol}" for symbol in symbols)
|
||||
ensemble_seeds = _ints(args.ensemble_seeds) or [args.seed]
|
||||
round_trip_cost = max(0.0, 2.0 * (float(settings.taker_fee_rate) + float(settings.slippage_rate)))
|
||||
stop_loss_percent = _clamp(float(settings.stop_loss_percent), 0.003, 0.08)
|
||||
take_profit_percent = _clamp(float(settings.take_profit_percent), 0.003, 0.20)
|
||||
_progress(
|
||||
f"training started: symbols={len(symbols)} interval={interval} "
|
||||
f"limit={args.limit} epochs={args.epochs}"
|
||||
)
|
||||
|
||||
artifact: dict[str, Any] = {
|
||||
"version": 4,
|
||||
"version": 6,
|
||||
"type": "pytorch_recurrent_forecaster",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"trainer": Path(__file__).name,
|
||||
@@ -146,8 +198,12 @@ def main() -> None:
|
||||
"target_horizon": decision_horizon,
|
||||
"target_horizons": target_horizons,
|
||||
"direct_horizon": True,
|
||||
"target_transform": "net_return_over_volatility",
|
||||
"target_return": "round_trip_after_cost_log_return",
|
||||
"target_transform": TARGET_TRANSFORM,
|
||||
"target_return": "first_barrier_or_horizon_after_cost_log_return",
|
||||
"event_target": "take_profit_before_stop_loss",
|
||||
"target_stop_loss_percent": round(stop_loss_percent, 8),
|
||||
"target_take_profit_percent": round(take_profit_percent, 8),
|
||||
"barrier_tie_policy": "stop_loss_first",
|
||||
"round_trip_cost": round(round_trip_cost, 10),
|
||||
"output_layout": list(OUTPUT_LAYOUT),
|
||||
"quantiles": list(QUANTILES.values()),
|
||||
@@ -160,7 +216,7 @@ def main() -> None:
|
||||
}
|
||||
|
||||
if args.pooled:
|
||||
artifact["version"] = 5
|
||||
artifact["version"] = 7
|
||||
artifact["pooled_multi_asset"] = True
|
||||
artifact["symbol_embedding"] = "learned_one_hot_projection"
|
||||
artifact["symbols"] = _train_pooled_symbols(
|
||||
@@ -174,6 +230,8 @@ def main() -> None:
|
||||
decision_horizon=decision_horizon,
|
||||
feature_names=feature_names,
|
||||
round_trip_cost=round_trip_cost,
|
||||
stop_loss_percent=stop_loss_percent,
|
||||
take_profit_percent=take_profit_percent,
|
||||
context_symbols=_strings(args.context_symbols),
|
||||
architectures=_strings(args.architectures),
|
||||
lookbacks=_ints(args.lookbacks),
|
||||
@@ -202,6 +260,8 @@ def main() -> None:
|
||||
decision_horizon=decision_horizon,
|
||||
feature_names=feature_names,
|
||||
round_trip_cost=round_trip_cost,
|
||||
stop_loss_percent=stop_loss_percent,
|
||||
take_profit_percent=take_profit_percent,
|
||||
device=device,
|
||||
ensemble_seeds=ensemble_seeds,
|
||||
)
|
||||
@@ -213,8 +273,8 @@ def main() -> None:
|
||||
f"layers={result['num_layers']} horizons={','.join(map(str, result['target_horizons']))} "
|
||||
f"mae={result['validation_mae_percent']:.5f}% "
|
||||
f"baseline={result['baseline_mae_percent']:.5f}% "
|
||||
f"skill={result['skill']:.4f} dir={result['directional_accuracy']:.3f} "
|
||||
f"p_brier={result['probability_brier']:.4f}"
|
||||
f"skill={result['skill']:.4f} tp_precision={result.get('take_profit_first_precision', 0.0):.3f} "
|
||||
f"tp_brier={result['probability_brier']:.4f}"
|
||||
)
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -227,7 +287,8 @@ def main() -> None:
|
||||
def _train_independent_symbols(
|
||||
*, client: BybitClient, symbols: list[str], interval: str, args: argparse.Namespace,
|
||||
target_horizons: list[int], decision_horizon: int, feature_names: list[str],
|
||||
round_trip_cost: float, device: torch.device, ensemble_seeds: list[int],
|
||||
round_trip_cost: float, stop_loss_percent: float, take_profit_percent: float,
|
||||
device: torch.device, ensemble_seeds: list[int],
|
||||
) -> dict[str, Any]:
|
||||
results: dict[str, Any] = {}
|
||||
total_symbols = len(symbols)
|
||||
@@ -244,6 +305,8 @@ def _train_independent_symbols(
|
||||
decision_horizon=decision_horizon,
|
||||
feature_names=feature_names,
|
||||
round_trip_cost=round_trip_cost,
|
||||
stop_loss_percent=stop_loss_percent,
|
||||
take_profit_percent=take_profit_percent,
|
||||
context_symbols=_strings(args.context_symbols),
|
||||
architectures=_strings(args.architectures),
|
||||
lookbacks=_ints(args.lookbacks),
|
||||
@@ -273,6 +336,7 @@ def _train_pooled_symbols(
|
||||
*, client: BybitClient, symbols: list[str], interval: str, limit: int,
|
||||
validation_window: int, holdout_window: int, target_horizons: list[int],
|
||||
decision_horizon: int, feature_names: list[str], round_trip_cost: float,
|
||||
stop_loss_percent: float, take_profit_percent: float,
|
||||
context_symbols: list[str], architectures: list[str], lookbacks: list[int],
|
||||
hidden_sizes: list[int], layers_values: list[int], dropouts: list[float],
|
||||
epochs: int, patience: int, batch_size: int, learning_rate: float,
|
||||
@@ -303,6 +367,8 @@ def _train_pooled_symbols(
|
||||
target_horizons=target_horizons,
|
||||
decision_horizon=decision_horizon,
|
||||
round_trip_cost=round_trip_cost,
|
||||
stop_loss_percent=stop_loss_percent,
|
||||
take_profit_percent=take_profit_percent,
|
||||
market_candles=market_candles,
|
||||
trend_candles=trend_by_symbol[symbol],
|
||||
validation_window=validation_window,
|
||||
@@ -356,6 +422,7 @@ def _train_pooled_symbols(
|
||||
dropout=dropout if num_layers > 1 else 0.0,
|
||||
attention_pooling=attention_pooling, context_norm=context_norm,
|
||||
input_size=len(feature_names), output_size=len(target_horizons) * len(OUTPUT_LAYOUT),
|
||||
multitask_head=True, head_hidden_size=hidden_size,
|
||||
)
|
||||
if best is None or _candidate_score(candidate) < _candidate_score(best):
|
||||
best = candidate
|
||||
@@ -376,7 +443,10 @@ def _train_pooled_symbols(
|
||||
"target_horizon": prepared.decision_horizon,
|
||||
"target_horizons": prepared.target_horizons,
|
||||
"direct_horizon": True,
|
||||
"target_transform": "net_return_over_volatility",
|
||||
"target_transform": TARGET_TRANSFORM,
|
||||
"event_target": "take_profit_before_stop_loss",
|
||||
"target_stop_loss_percent": stop_loss_percent,
|
||||
"target_take_profit_percent": take_profit_percent,
|
||||
"round_trip_cost": round(round_trip_cost, 10),
|
||||
"output_layout": list(OUTPUT_LAYOUT),
|
||||
"feature_names": feature_names,
|
||||
@@ -413,6 +483,7 @@ def _fit_pooled_candidate(
|
||||
architecture=architecture, input_size=input_size, hidden_size=hidden_size,
|
||||
num_layers=num_layers, dropout=dropout, output_size=output_size,
|
||||
attention_pooling=attention_pooling, context_norm=context_norm,
|
||||
multitask_head=True, head_hidden_size=hidden_size,
|
||||
).to(device)
|
||||
optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate, weight_decay=weight_decay)
|
||||
loader = DataLoader(
|
||||
@@ -440,7 +511,7 @@ def _fit_pooled_candidate(
|
||||
symbol: _validation_metrics(model, prepared, clip)
|
||||
for symbol, prepared in prepared_by_symbol.items()
|
||||
}
|
||||
score = sum(float(row["validation_mae"]) for row in symbol_rows.values()) / len(symbol_rows)
|
||||
score = sum(_candidate_score(row) for row in symbol_rows.values()) / len(symbol_rows)
|
||||
if score + 1e-12 < best_score:
|
||||
best_score = score
|
||||
best_epoch = epoch
|
||||
@@ -462,15 +533,15 @@ def _fit_pooled_candidate(
|
||||
for name in (
|
||||
"validation_mae", "directional_accuracy", "buy_precision", "probability_brier",
|
||||
"holdout_skill", "validation_fold_mae_std", "validation_trade_mean",
|
||||
"validation_trade_win_rate",
|
||||
"validation_trade_win_rate", "take_profit_first_accuracy",
|
||||
"take_profit_first_precision", "take_profit_first_brier",
|
||||
):
|
||||
values = [float(row[name]) for row in per_symbol.values() if isinstance(row.get(name), (int, float))]
|
||||
aggregate[name] = sum(values) / len(values) if values else 0.0
|
||||
aggregate.update(
|
||||
best_epoch=best_epoch, epochs_trained=best_epoch + stale,
|
||||
state_dict=_export_recurrent_state(model),
|
||||
head_weight=_round_nested(model.head.weight.detach().cpu().tolist()),
|
||||
head_bias=_round_list(model.head.bias.detach().cpu().tolist()),
|
||||
**_export_head_state(model),
|
||||
**_export_context_state(model),
|
||||
)
|
||||
return aggregate
|
||||
@@ -535,6 +606,8 @@ def _train_symbol(
|
||||
decision_horizon: int,
|
||||
feature_names: list[str],
|
||||
round_trip_cost: float,
|
||||
stop_loss_percent: float,
|
||||
take_profit_percent: float,
|
||||
context_symbols: list[str],
|
||||
architectures: list[str],
|
||||
lookbacks: list[int],
|
||||
@@ -587,6 +660,8 @@ def _train_symbol(
|
||||
target_horizons=target_horizons,
|
||||
decision_horizon=decision_horizon,
|
||||
round_trip_cost=round_trip_cost,
|
||||
stop_loss_percent=stop_loss_percent,
|
||||
take_profit_percent=take_profit_percent,
|
||||
market_candles=market_candles,
|
||||
trend_candles=trend_candles,
|
||||
validation_window=validation_window,
|
||||
@@ -630,6 +705,8 @@ def _train_symbol(
|
||||
clip=clip,
|
||||
attention_pooling=attention_pooling,
|
||||
context_norm=context_norm,
|
||||
multitask_head=True,
|
||||
head_hidden_size=hidden_size,
|
||||
device=device,
|
||||
seed=member_seed,
|
||||
selection_folds=selection_folds,
|
||||
@@ -647,8 +724,12 @@ def _train_symbol(
|
||||
"target_horizon": prepared.decision_horizon,
|
||||
"target_horizons": prepared.target_horizons,
|
||||
"direct_horizon": True,
|
||||
"target_transform": "net_return_over_volatility",
|
||||
"target_return": "round_trip_after_cost_log_return",
|
||||
"target_transform": TARGET_TRANSFORM,
|
||||
"target_return": "first_barrier_or_horizon_after_cost_log_return",
|
||||
"event_target": "take_profit_before_stop_loss",
|
||||
"target_stop_loss_percent": stop_loss_percent,
|
||||
"target_take_profit_percent": take_profit_percent,
|
||||
"barrier_tie_policy": "stop_loss_first",
|
||||
"round_trip_cost": round(round_trip_cost, 10),
|
||||
"output_layout": list(OUTPUT_LAYOUT),
|
||||
"quantiles": list(QUANTILES.values()),
|
||||
@@ -668,6 +749,8 @@ def _train_symbol(
|
||||
"dropout": dropout if num_layers > 1 else 0.0,
|
||||
"attention_pooling": attention_pooling,
|
||||
"context_norm": context_norm,
|
||||
"multitask_head": True,
|
||||
"head_hidden_size": hidden_size,
|
||||
"clip": clip,
|
||||
"validation_mae_percent": validation_mae * 100,
|
||||
"baseline_mae_percent": baseline_mae * 100,
|
||||
@@ -700,6 +783,8 @@ def _prepare_data(
|
||||
target_horizons: list[int],
|
||||
decision_horizon: int,
|
||||
round_trip_cost: float,
|
||||
stop_loss_percent: float,
|
||||
take_profit_percent: float,
|
||||
market_candles: dict[str, list[Candle]],
|
||||
trend_candles: list[Candle],
|
||||
validation_window: int,
|
||||
@@ -724,25 +809,35 @@ def _prepare_data(
|
||||
if len(window) != lookback:
|
||||
continue
|
||||
raw_targets: list[float] = []
|
||||
event_targets: list[float] = []
|
||||
volatility_scales: list[float] = []
|
||||
normalized_targets: list[float] = []
|
||||
valid = True
|
||||
for horizon in target_horizons:
|
||||
future = closes[end_index + horizon]
|
||||
if future <= 0:
|
||||
outcome = _barrier_outcome(
|
||||
candles,
|
||||
end_index=end_index,
|
||||
horizon=horizon,
|
||||
stop_loss_percent=stop_loss_percent,
|
||||
take_profit_percent=take_profit_percent,
|
||||
round_trip_cost=round_trip_cost,
|
||||
)
|
||||
if outcome is None:
|
||||
valid = False
|
||||
break
|
||||
net_return = math.log(future / current) - round_trip_cost
|
||||
volatility_scale = _target_volatility_scale(candles, closes, end_index, horizon)
|
||||
net_return, take_profit_first = outcome
|
||||
volatility_scale = 1.0
|
||||
raw_targets.append(net_return)
|
||||
event_targets.append(take_profit_first)
|
||||
volatility_scales.append(volatility_scale)
|
||||
normalized_targets.append(net_return / max(volatility_scale, 1e-8))
|
||||
normalized_targets.append(net_return)
|
||||
if valid:
|
||||
samples.append(
|
||||
TrainingSample(
|
||||
window,
|
||||
normalized_targets,
|
||||
raw_targets,
|
||||
event_targets,
|
||||
volatility_scales,
|
||||
candles[end_index].timestamp,
|
||||
)
|
||||
@@ -803,11 +898,13 @@ def _prepare_data(
|
||||
validation_y=torch.tensor(validation_y, dtype=torch.float32, device=device),
|
||||
validation_up=torch.tensor(validation_up, dtype=torch.float32, device=device),
|
||||
validation_targets=[sample.raw_targets for sample in validation_samples],
|
||||
validation_event_targets=[sample.event_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_event_targets=[sample.event_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,
|
||||
@@ -902,7 +999,7 @@ def _normalize_samples(
|
||||
for index, target in enumerate(sample.normalized_targets)
|
||||
]
|
||||
)
|
||||
up_values.append([1.0 if target > 0 else 0.0 for target in sample.raw_targets])
|
||||
up_values.append(list(sample.event_targets))
|
||||
return x_values, y_values, up_values
|
||||
|
||||
|
||||
@@ -923,6 +1020,8 @@ def _fit_candidate(
|
||||
clip: float,
|
||||
attention_pooling: bool,
|
||||
context_norm: bool,
|
||||
multitask_head: bool,
|
||||
head_hidden_size: int,
|
||||
device: torch.device,
|
||||
seed: int,
|
||||
selection_folds: int,
|
||||
@@ -937,6 +1036,8 @@ def _fit_candidate(
|
||||
output_size=output_size,
|
||||
attention_pooling=attention_pooling,
|
||||
context_norm=context_norm,
|
||||
multitask_head=multitask_head,
|
||||
head_hidden_size=head_hidden_size,
|
||||
).to(device)
|
||||
optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate, weight_decay=weight_decay)
|
||||
generator = torch.Generator(device="cpu").manual_seed(seed)
|
||||
@@ -948,7 +1049,14 @@ def _fit_candidate(
|
||||
)
|
||||
|
||||
best_state: dict[str, torch.Tensor] | None = None
|
||||
best_metrics: dict[str, float] = {"validation_mae": math.inf, "directional_accuracy": 0.0, "buy_precision": 0.0}
|
||||
best_metrics: dict[str, float] = {
|
||||
"validation_mae": math.inf,
|
||||
"directional_accuracy": 0.0,
|
||||
"buy_precision": 0.0,
|
||||
"probability_brier": 1.0,
|
||||
"validation_trade_mean": -math.inf,
|
||||
}
|
||||
best_score = math.inf
|
||||
best_epoch = 0
|
||||
stale_epochs = 0
|
||||
for epoch in range(1, max(1, epochs) + 1):
|
||||
@@ -962,8 +1070,10 @@ def _fit_candidate(
|
||||
|
||||
metrics = _validation_metrics(model, prepared, clip)
|
||||
metrics.update(_validation_stability_metrics(model, prepared, clip, selection_folds))
|
||||
if metrics["validation_mae"] + 1e-12 < best_metrics["validation_mae"]:
|
||||
score = _candidate_score(metrics)
|
||||
if score + 1e-12 < best_score:
|
||||
best_metrics = metrics
|
||||
best_score = score
|
||||
best_epoch = epoch
|
||||
best_state = {key: value.detach().cpu().clone() for key, value in model.state_dict().items()}
|
||||
stale_epochs = 0
|
||||
@@ -981,8 +1091,7 @@ def _fit_candidate(
|
||||
"best_epoch": best_epoch,
|
||||
"epochs_trained": best_epoch + stale_epochs,
|
||||
"state_dict": _export_recurrent_state(model),
|
||||
"head_weight": _round_nested(model.head.weight.detach().cpu().tolist()),
|
||||
"head_bias": _round_list(model.head.bias.detach().cpu().tolist()),
|
||||
**_export_head_state(model),
|
||||
**_export_context_state(model),
|
||||
}
|
||||
|
||||
@@ -1006,6 +1115,12 @@ def _ensemble_candidate(members: list[dict[str, Any]], seeds: list[int]) -> dict
|
||||
"validation_fold_mae_worst",
|
||||
"validation_trade_mean",
|
||||
"validation_trade_win_rate",
|
||||
"take_profit_first_accuracy",
|
||||
"take_profit_first_precision",
|
||||
"take_profit_first_brier",
|
||||
"holdout_take_profit_first_accuracy",
|
||||
"holdout_take_profit_first_precision",
|
||||
"holdout_take_profit_first_brier",
|
||||
)
|
||||
for name in metric_names:
|
||||
values = [float(member[name]) for member in members if isinstance(member.get(name), (int, float))]
|
||||
@@ -1015,6 +1130,12 @@ def _ensemble_candidate(members: list[dict[str, Any]], seeds: list[int]) -> dict
|
||||
"state_dict",
|
||||
"head_weight",
|
||||
"head_bias",
|
||||
"head_hidden_weight",
|
||||
"head_hidden_bias",
|
||||
"return_head_weight",
|
||||
"return_head_bias",
|
||||
"event_head_weight",
|
||||
"event_head_bias",
|
||||
"attention_weight",
|
||||
"attention_bias",
|
||||
"context_norm_weight",
|
||||
@@ -1062,6 +1183,7 @@ def _validation_metrics(model: nn.Module, prepared: PreparedData, clip: float) -
|
||||
model,
|
||||
values=prepared.validation_x,
|
||||
targets=prepared.validation_targets,
|
||||
event_targets=prepared.validation_event_targets,
|
||||
volatility_scales=prepared.validation_volatility_scales,
|
||||
prepared=prepared,
|
||||
clip=clip,
|
||||
@@ -1086,6 +1208,7 @@ def _validation_stability_metrics(
|
||||
model,
|
||||
values=prepared.validation_x[start:end],
|
||||
targets=prepared.validation_targets[start:end],
|
||||
event_targets=prepared.validation_event_targets[start:end],
|
||||
volatility_scales=prepared.validation_volatility_scales[start:end],
|
||||
prepared=prepared,
|
||||
clip=clip,
|
||||
@@ -1106,6 +1229,7 @@ def _holdout_metrics(model: nn.Module, prepared: PreparedData, clip: float) -> d
|
||||
model,
|
||||
values=prepared.holdout_x,
|
||||
targets=prepared.holdout_targets,
|
||||
event_targets=prepared.holdout_event_targets,
|
||||
volatility_scales=prepared.holdout_volatility_scales,
|
||||
prepared=prepared,
|
||||
clip=clip,
|
||||
@@ -1126,6 +1250,9 @@ def _holdout_metrics(model: nn.Module, prepared: PreparedData, clip: float) -> d
|
||||
"holdout_directional_accuracy": metrics["directional_accuracy"],
|
||||
"holdout_buy_precision": metrics["buy_precision"],
|
||||
"holdout_probability_brier": metrics["probability_brier"],
|
||||
"holdout_take_profit_first_accuracy": metrics["take_profit_first_accuracy"],
|
||||
"holdout_take_profit_first_precision": metrics["take_profit_first_precision"],
|
||||
"holdout_take_profit_first_brier": metrics["take_profit_first_brier"],
|
||||
}
|
||||
|
||||
|
||||
@@ -1134,6 +1261,7 @@ def _evaluation_metrics(
|
||||
*,
|
||||
values: torch.Tensor,
|
||||
targets: list[list[float]],
|
||||
event_targets: list[list[float]],
|
||||
volatility_scales: list[list[float]],
|
||||
prepared: PreparedData,
|
||||
clip: float,
|
||||
@@ -1173,12 +1301,13 @@ def _evaluation_metrics(
|
||||
for prediction, actual in zip(decision_predictions, decision_targets)
|
||||
if prediction != 0 and actual != 0
|
||||
]
|
||||
decision_events = [row[decision] for row in event_targets]
|
||||
buy_predictions = [
|
||||
actual
|
||||
for prediction, actual in zip(decision_predictions, decision_targets)
|
||||
event
|
||||
for prediction, event in zip(decision_predictions, decision_events)
|
||||
if prediction > 0
|
||||
]
|
||||
buy_wins = [actual for actual in buy_predictions if actual > 0]
|
||||
buy_wins = [event for event in buy_predictions if event >= 0.5]
|
||||
ranked = sorted(
|
||||
zip(decision_predictions, [row[decision] for row in probabilities], decision_targets),
|
||||
key=lambda item: item[0] * max(0.0, item[1] - 0.5),
|
||||
@@ -1201,16 +1330,36 @@ def _evaluation_metrics(
|
||||
else math.inf
|
||||
)
|
||||
probability_errors = [
|
||||
(probabilities[row_index][decision] - (1.0 if target > 0 else 0.0)) ** 2
|
||||
for row_index, target in enumerate(decision_targets)
|
||||
(probabilities[row_index][decision] - decision_events[row_index]) ** 2
|
||||
for row_index in range(len(decision_events))
|
||||
]
|
||||
event_predictions = [1.0 if row[decision] >= 0.5 else 0.0 for row in probabilities]
|
||||
event_correct = sum(
|
||||
1 for prediction, actual in zip(event_predictions, decision_events) if prediction == actual
|
||||
)
|
||||
event_precision_denominator = sum(1 for value in event_predictions if value >= 0.5)
|
||||
event_true_positives = sum(
|
||||
1
|
||||
for prediction, actual in zip(event_predictions, decision_events)
|
||||
if prediction >= 0.5 and actual >= 0.5
|
||||
)
|
||||
event_accuracy = event_correct / len(decision_events) if decision_events else 0.0
|
||||
event_precision = (
|
||||
event_true_positives / event_precision_denominator
|
||||
if event_precision_denominator
|
||||
else 0.0
|
||||
)
|
||||
event_brier = sum(probability_errors) / len(probability_errors) if probability_errors else 1.0
|
||||
return {
|
||||
"validation_mae": sum(errors) / len(errors) if errors else math.inf,
|
||||
"validation_mae_by_horizon": by_horizon,
|
||||
"baseline_mae_by_horizon": baseline_by_horizon,
|
||||
"directional_accuracy": len(correct) / len(non_zero) if non_zero else 0.0,
|
||||
"buy_precision": len(buy_wins) / len(buy_predictions) if buy_predictions else 0.0,
|
||||
"probability_brier": sum(probability_errors) / len(probability_errors) if probability_errors else 1.0,
|
||||
"probability_brier": event_brier,
|
||||
"take_profit_first_accuracy": event_accuracy,
|
||||
"take_profit_first_precision": event_precision,
|
||||
"take_profit_first_brier": event_brier,
|
||||
"validation_trade_mean": sum(selected_targets) / len(selected_targets) if selected_targets else 0.0,
|
||||
"validation_trade_win_rate": (
|
||||
sum(1 for value in selected_targets if value > 0) / len(selected_targets)
|
||||
@@ -1229,10 +1378,19 @@ def _candidate_score(row: dict[str, Any]) -> float:
|
||||
fold_std = max(0.0, float(row.get("validation_fold_mae_std", 0.0)))
|
||||
stability_penalty = 1.0 + min(1.0, fold_std / max(mae, 1e-9)) * 0.25
|
||||
trade_mean = float(row.get("validation_trade_mean", 0.0))
|
||||
trade_penalty = max(0.0, -trade_mean) * 2.0 - max(0.0, trade_mean) * 0.5
|
||||
return mae * stability_penalty * (1.0 - max(0.0, skill) * 0.05) * (1.0 - max(0.0, directional - 0.5) * 0.03) * (
|
||||
1.0 - max(0.0, buy_precision - 0.5) * 0.02
|
||||
) * (1.0 + max(0.0, probability_brier - 0.25) * 0.02) + trade_penalty
|
||||
event_precision = float(row.get("take_profit_first_precision", buy_precision))
|
||||
trade_penalty = max(0.0, -trade_mean) * 4.0 - max(0.0, trade_mean) * 0.75
|
||||
probability_penalty = mae * max(0.0, probability_brier - 0.20) * 0.5
|
||||
event_bonus = mae * max(0.0, event_precision - 0.5) * 0.10
|
||||
return (
|
||||
mae
|
||||
* stability_penalty
|
||||
* (1.0 - max(0.0, skill) * 0.05)
|
||||
* (1.0 - max(0.0, directional - 0.5) * 0.03)
|
||||
+ probability_penalty
|
||||
+ trade_penalty
|
||||
- event_bonus
|
||||
)
|
||||
|
||||
|
||||
def _forecast_loss(outputs: torch.Tensor, targets: torch.Tensor, up_targets: torch.Tensor, horizon_count: int) -> torch.Tensor:
|
||||
@@ -1244,7 +1402,14 @@ def _forecast_loss(outputs: torch.Tensor, targets: torch.Tensor, up_targets: tor
|
||||
errors = targets - values[:, :, offset]
|
||||
quantile_losses.append(torch.maximum((quantile - 1.0) * errors, quantile * errors).mean())
|
||||
logits = values[:, :, 4]
|
||||
bce = nn.functional.binary_cross_entropy_with_logits(logits, up_targets, reduction="none")
|
||||
positive_rate = up_targets.mean().detach().clamp(0.05, 0.95)
|
||||
positive_weight = ((1.0 - positive_rate) / positive_rate).clamp(0.5, 5.0)
|
||||
bce = nn.functional.binary_cross_entropy_with_logits(
|
||||
logits,
|
||||
up_targets,
|
||||
reduction="none",
|
||||
pos_weight=positive_weight,
|
||||
)
|
||||
probabilities = torch.sigmoid(logits)
|
||||
pt = probabilities * up_targets + (1.0 - probabilities) * (1.0 - up_targets)
|
||||
focal = ((1.0 - pt) ** 2.0 * bce).mean()
|
||||
@@ -1257,8 +1422,8 @@ def _forecast_loss(outputs: torch.Tensor, targets: torch.Tensor, up_targets: tor
|
||||
return (
|
||||
mean_loss
|
||||
+ 0.35 * sum(quantile_losses) / len(quantile_losses)
|
||||
+ 0.15 * focal
|
||||
+ 0.10 * after_cost_utility
|
||||
+ 0.35 * focal
|
||||
+ 0.20 * after_cost_utility
|
||||
+ 0.05 * ranking_loss
|
||||
)
|
||||
|
||||
@@ -1270,6 +1435,26 @@ def _export_recurrent_state(model: RecurrentReturnModel) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _export_head_state(model: RecurrentReturnModel) -> dict[str, Any]:
|
||||
if not model.multitask_head:
|
||||
assert model.head is not None
|
||||
return {
|
||||
"head_weight": _round_nested(model.head.weight.detach().cpu().tolist()),
|
||||
"head_bias": _round_list(model.head.bias.detach().cpu().tolist()),
|
||||
}
|
||||
assert model.head_hidden is not None
|
||||
assert model.return_head is not None
|
||||
assert model.event_head is not None
|
||||
return {
|
||||
"head_hidden_weight": _round_nested(model.head_hidden.weight.detach().cpu().tolist()),
|
||||
"head_hidden_bias": _round_list(model.head_hidden.bias.detach().cpu().tolist()),
|
||||
"return_head_weight": _round_nested(model.return_head.weight.detach().cpu().tolist()),
|
||||
"return_head_bias": _round_list(model.return_head.bias.detach().cpu().tolist()),
|
||||
"event_head_weight": _round_nested(model.event_head.weight.detach().cpu().tolist()),
|
||||
"event_head_bias": _round_list(model.event_head.bias.detach().cpu().tolist()),
|
||||
}
|
||||
|
||||
|
||||
def _export_context_state(model: RecurrentReturnModel) -> dict[str, Any]:
|
||||
exported: dict[str, Any] = {}
|
||||
if model.attention is not None:
|
||||
|
||||
@@ -108,11 +108,19 @@ def run_retrain(args: argparse.Namespace, job_id: str, job: dict[str, Any], repo
|
||||
"layers": "-Layers",
|
||||
"dropouts": "-Dropouts",
|
||||
"epochs": "-Epochs",
|
||||
"validation_window": "-ValidationWindow",
|
||||
"holdout_window": "-HoldoutWindow",
|
||||
"ensemble_seeds": "-EnsembleSeeds",
|
||||
"selection_folds": "-SelectionFolds",
|
||||
"learning_rate": "-LearningRate",
|
||||
"weight_decay": "-WeightDecay",
|
||||
"horizon": "-Horizon",
|
||||
"horizons": "-Horizons",
|
||||
"patience": "-Patience",
|
||||
"context_symbols": "-ContextSymbols",
|
||||
"features": "-Features",
|
||||
"seed": "-Seed",
|
||||
"interval": "-Interval",
|
||||
}
|
||||
for key, ps_arg in arg_map.items():
|
||||
value = parameters.get(key)
|
||||
|
||||
Reference in New Issue
Block a user