kevinkal commited on
Commit
ee9727e
·
verified ·
1 Parent(s): ccd025a

Upload forecast.py

Browse files
Files changed (1) hide show
  1. schemas/forecast.py +329 -0
schemas/forecast.py ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Typed request/response models for the Timestral forecasting endpoints.
2
+
3
+ Every series is a univariate chunk of timestamp + amounts. Timestamps are
4
+ normalized to UTC (naive values are treated as UTC). Quantile levels must come
5
+ from Timestral's native 21-level grid so responses map 1:1 to model output.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import datetime
11
+ from typing import Literal
12
+
13
+ from pydantic import BaseModel, Field, field_validator, model_validator
14
+
15
+ Frequency = Literal["auto", "hourly", "daily", "business_daily", "weekly", "monthly"]
16
+ ModelSize = Literal["5M", "28M", "123M", "209M", "ensemble"]
17
+ Transform = Literal["levels", "log_returns"]
18
+
19
+ DEFAULT_QUANTILES: tuple[float, ...] = (0.1, 0.5, 0.9)
20
+
21
+ # Timestral heads emit 21 quantiles: fixed 0.05 steps inside 0.05..0.95 plus the
22
+ # 0.01/0.99 tails. Levels outside this grid cannot be sliced from model output.
23
+ NATIVE_QUANTILE_LEVELS: tuple[float, ...] = tuple(
24
+ sorted({round(0.05 * i, 2) for i in range(1, 20)} | {0.01, 0.99})
25
+ )
26
+
27
+
28
+ class SeriesPoint(BaseModel):
29
+ """One observed amount at one timestamp."""
30
+
31
+ timestamp: datetime = Field(
32
+ description="ISO-8601 timestamp. Values without timezone are treated as UTC."
33
+ )
34
+ value: float = Field(
35
+ description="Observed amount (price, portfolio value, ...). Must be finite.",
36
+ )
37
+
38
+
39
+ class SeriesInput(BaseModel):
40
+ """A named univariate series of timestamp + amount pairs."""
41
+
42
+ name: str = Field(
43
+ min_length=1,
44
+ max_length=64,
45
+ description="Series label, echoed in the response and used in summaries.",
46
+ )
47
+ points: list[SeriesPoint] = Field(
48
+ min_length=2,
49
+ max_length=20000,
50
+ description="Historical observations. Duplicated timestamps are averaged; "
51
+ "gaps are resampled away and read as missing values by the model.",
52
+ )
53
+
54
+
55
+ class ForecastRequest(BaseModel):
56
+ """Forecast one or more univariate series with a Timestral checkpoint."""
57
+
58
+ series: list[SeriesInput] = Field(
59
+ min_length=1,
60
+ max_length=12,
61
+ description="Each series is forecast independently (one variate per row).",
62
+ )
63
+ horizon: int = Field(
64
+ default=30,
65
+ ge=1,
66
+ le=1024,
67
+ description="Steps ahead to predict, in grid steps of the resolved frequency.",
68
+ )
69
+ frequency: Frequency = Field(
70
+ default="auto",
71
+ description="'auto' infers the grid from timestamp spacing. 'business_daily' "
72
+ "collapses to Mon-Fri steps (weekend points merge into the next business day).",
73
+ )
74
+ quantiles: list[float] = Field(
75
+ default_factory=lambda: list(DEFAULT_QUANTILES),
76
+ description="Levels to return per step, from Timestral's native 21-level grid.",
77
+ )
78
+ model_size: ModelSize = Field(
79
+ default="28M",
80
+ description="Checkpoint size; 'ensemble' averages the quantile paths of "
81
+ "28M/123M/209M (see TIMESTRAL_ENSEMBLE_SIZES).",
82
+ )
83
+ transform: Transform = Field(
84
+ default="levels",
85
+ description="'log_returns' forecasts log-returns and compounds each quantile "
86
+ "path back to amount space (requires strictly positive values).",
87
+ )
88
+ context_length: int | None = Field(
89
+ default=None,
90
+ ge=32,
91
+ le=32768,
92
+ description="Cap the history fed to the model to the most recent N grid steps.",
93
+ )
94
+ symmetric_averaging: bool = Field(
95
+ default=True,
96
+ description="Forecaster's symmetric-averaging test-time augmentation. "
97
+ "Disable to halve inference time at some accuracy cost.",
98
+ )
99
+ clip_nonnegative: bool = Field(
100
+ default=True,
101
+ description="Clamp forecasts at 0 for series whose history is non-negative.",
102
+ )
103
+
104
+ @field_validator("quantiles")
105
+ @classmethod
106
+ def _check_quantiles(cls, levels: list[float]) -> list[float]:
107
+ if not levels:
108
+ raise ValueError(
109
+ "quantiles must not be empty; pick from Timestral's native levels"
110
+ )
111
+ allowed = set(NATIVE_QUANTILE_LEVELS)
112
+ unknown = sorted(set(levels) - allowed)
113
+ if unknown:
114
+ raise ValueError(
115
+ f"quantiles must come from Timestral's 21 native levels, e.g. 0.1/0.5/0.9; "
116
+ f"got {unknown!r}. Native levels: {sorted(allowed)}"
117
+ )
118
+ return sorted(set(levels))
119
+
120
+ @model_validator(mode="after")
121
+ def _check_unique_names(self) -> "ForecastRequest":
122
+ names = [s.name for s in self.series]
123
+ if len(names) != len(set(names)):
124
+ raise ValueError("series names must be unique")
125
+ return self
126
+
127
+
128
+ class ForecastStep(BaseModel):
129
+ """One forecast step: timestamp, median, and quantile band."""
130
+
131
+ timestamp: datetime
132
+ median: float
133
+ quantiles: dict[str, float] = Field(
134
+ default_factory=dict,
135
+ description="Requested levels keyed as '0.10' style, e.g. {'0.10': ..., '0.90': ...}",
136
+ )
137
+
138
+
139
+ class ForecastSummary(BaseModel):
140
+ """Finance-oriented reading of one forecast at the horizon end."""
141
+
142
+ last_observed: float = Field(description="Last observed amount (series anchor).")
143
+ horizon_end_median: float
144
+ expected_change_pct: float | None = Field(
145
+ default=None,
146
+ description="Percent change of the median path at the horizon end vs the last observed amount.",
147
+ )
148
+ prob_below_start: float | None = Field(
149
+ default=None,
150
+ description="Probability the amount ends below its last observed value, "
151
+ "interpolated from the marginal quantiles at the horizon end.",
152
+ )
153
+ var_95_abs: float | None = Field(
154
+ default=None,
155
+ description="Value-at-risk at 95% vs the last observed amount (0 = no downside).",
156
+ )
157
+ var_95_pct: float | None = None
158
+ p10_horizon_end: float | None = Field(
159
+ default=None,
160
+ description="10th-percentile amount at the horizon end (band floor).",
161
+ )
162
+ p90_horizon_end: float | None = None
163
+
164
+
165
+ class SeriesForecast(BaseModel):
166
+ """Forecast for one named series."""
167
+
168
+ name: str
169
+ context_steps: int = Field(description="Grid steps fed to the model.")
170
+ context_start: datetime = Field(description="First grid timestamp fed to the model.")
171
+ context_end: datetime = Field(
172
+ description="Grid timestamp of the last observed step (forecast anchor)."
173
+ )
174
+ points: list[ForecastStep]
175
+ summary: ForecastSummary
176
+
177
+
178
+ class ForecastResponse(BaseModel):
179
+ """Probabilistic forecast for every requested series."""
180
+
181
+ model: str = Field(description="Timestral repo ids used (joined for ensembles).")
182
+ model_size: str = Field(description="Requested size ('5M'..'209M' or 'ensemble').")
183
+ frequency: str = Field(description="Frequency actually used (never 'auto').")
184
+ transform: str
185
+ horizon: int
186
+ context_steps: int = Field(description="Largest per-series context fed to the model.")
187
+ generated_at: datetime
188
+ warnings: list[str] = Field(default_factory=list)
189
+ items: list[SeriesForecast]
190
+
191
+
192
+ class PortfolioSummary(BaseModel):
193
+ """The single-series finance summary for the portfolio projection."""
194
+
195
+ last_value: float
196
+ horizon_steps: int
197
+ steps_per_year: float = Field(description="Horizon steps per year of the frequency.")
198
+ cagr_percent: float | None = Field(
199
+ default=None,
200
+ description="Median-path growth annualized over the horizon (None for non-positive values).",
201
+ )
202
+ horizon_end_median: float
203
+ expected_change_pct: float | None = None
204
+ prob_below_start: float | None = None
205
+ var_95_abs: float | None = None
206
+ var_95_pct: float | None = None
207
+ p10_horizon_end: float | None = None
208
+ p90_horizon_end: float | None = None
209
+
210
+
211
+ class PortfolioForecastResponse(ForecastResponse):
212
+ """Portfolio projection: one series forecast plus a finance summary block."""
213
+
214
+ portfolio: PortfolioSummary
215
+
216
+
217
+ class BacktestRequest(BaseModel):
218
+ """Walk-forward validation request (same knobs as the forecast)."""
219
+
220
+ series: list[SeriesInput] = Field(min_length=1, max_length=12)
221
+ frequency: Frequency = "auto"
222
+ model_size: ModelSize = "28M"
223
+ transform: Transform = "levels"
224
+ context_length: int | None = Field(default=None, ge=32, le=32768)
225
+ symmetric_averaging: bool = True
226
+ clip_nonnegative: bool = True
227
+ folds: int = Field(
228
+ default=4,
229
+ ge=1,
230
+ le=12,
231
+ description="Number of walk-forward folds; reduced automatically when the "
232
+ "history cannot support them.",
233
+ )
234
+ fold_horizon: int | None = Field(
235
+ default=None,
236
+ ge=1,
237
+ le=365,
238
+ description="Validation length per fold; defaults to min(30, history/8) with a floor of 4.",
239
+ )
240
+
241
+ @model_validator(mode="after")
242
+ def _check_unique_names(self) -> "BacktestRequest":
243
+ names = [s.name for s in self.series]
244
+ if len(names) != len(set(names)):
245
+ raise ValueError("series names must be unique")
246
+ return self
247
+
248
+
249
+ class BacktestFoldMetrics(BaseModel):
250
+ """Point and probabilistic quality of one fold's median/band forecast."""
251
+
252
+ origin: datetime = Field(description="First holdout step of the fold.")
253
+ horizon: int
254
+ mae: float | None = None
255
+ mase: float | None = Field(
256
+ default=None,
257
+ description="MAE scaled by the mean absolute 1-step naive difference of the fold's training slice.",
258
+ )
259
+ smape: float | None = None
260
+ crps: float | None = Field(
261
+ default=None,
262
+ description="Probabilistic error approximated as twice the mean pinball loss "
263
+ "over Timestral's native quantile levels.",
264
+ )
265
+ coverage_80: float | None = Field(
266
+ default=None,
267
+ description="Fraction of holdout steps inside the 10-90% band.",
268
+ )
269
+
270
+
271
+ class BacktestMeanMetrics(BaseModel):
272
+ """Fold means (None where a metric was never computable)."""
273
+
274
+ mae: float | None = None
275
+ mase: float | None = None
276
+ smape: float | None = None
277
+ crps: float | None = None
278
+ coverage_80: float | None = None
279
+
280
+
281
+ class BacktestSeriesResult(BaseModel):
282
+ """Backtest for one series: per-fold metrics plus fold means."""
283
+
284
+ name: str
285
+ folds: list[BacktestFoldMetrics]
286
+ mean: BacktestMeanMetrics
287
+
288
+
289
+ class BacktestResponse(BaseModel):
290
+ """Walk-forward validation for every requested series."""
291
+
292
+ model: str
293
+ model_size: str
294
+ frequency: str
295
+ transform: str
296
+ folds: int
297
+ fold_horizon: int
298
+ generated_at: datetime
299
+ warnings: list[str] = Field(default_factory=list)
300
+ items: list[BacktestSeriesResult]
301
+
302
+
303
+ class ModelInfo(BaseModel):
304
+ """One selectable Timestral checkpoint."""
305
+
306
+ size: str
307
+ repo_id: str
308
+ loaded: bool
309
+
310
+
311
+ class ModelsResponse(BaseModel):
312
+ """Available checkpoints and loader configuration."""
313
+
314
+ default_size: str
315
+ device: str
316
+ max_context: int
317
+ ensemble_sizes: list[str]
318
+ models: list[ModelInfo]
319
+
320
+
321
+ class ForecastHealth(BaseModel):
322
+ """Loader state of the forecasting service."""
323
+
324
+ status: str
325
+ device: str
326
+ default_size: str
327
+ loaded_sizes: list[str]
328
+ context_cap: int
329
+ timeout_seconds: float