Taylor1998 commited on
Commit
2053767
·
verified ·
1 Parent(s): 745c264

Upload 3 files

Browse files
Files changed (3) hide show
  1. Dockerfile +2 -1
  2. requirements.txt +4 -0
  3. service.py +230 -129
Dockerfile CHANGED
@@ -6,10 +6,11 @@ ENV PORT=7860
6
  ENV CUDA_VISIBLE_DEVICES=""
7
  ENV TRANSFORMERS_NO_ADVISORY_WARNINGS=1
8
  ENV HF_HUB_DISABLE_TELEMETRY=1
9
- ENV TIMESFM_BACKEND=baseline_cpu
10
  ENV TIMESFM_MAX_CONTEXT_LENGTH=512
11
  ENV TIMESFM_MAX_HORIZON_STEP=288
12
  ENV TIMESFM_MIN_REQUIRED_POINTS=32
 
13
  ENV UV_SYSTEM_PYTHON=1
14
 
15
  WORKDIR /app
 
6
  ENV CUDA_VISIBLE_DEVICES=""
7
  ENV TRANSFORMERS_NO_ADVISORY_WARNINGS=1
8
  ENV HF_HUB_DISABLE_TELEMETRY=1
9
+ ENV TIMESFM_BACKEND=hf_cpu
10
  ENV TIMESFM_MAX_CONTEXT_LENGTH=512
11
  ENV TIMESFM_MAX_HORIZON_STEP=288
12
  ENV TIMESFM_MIN_REQUIRED_POINTS=32
13
+ ENV TIMESFM_ALLOW_BASELINE_FALLBACK=false
14
  ENV UV_SYSTEM_PYTHON=1
15
 
16
  WORKDIR /app
requirements.txt CHANGED
@@ -1,3 +1,7 @@
1
  fastapi==0.115.12
2
  uvicorn==0.34.0
3
  pydantic==2.11.3
 
 
 
 
 
1
  fastapi==0.115.12
2
  uvicorn==0.34.0
3
  pydantic==2.11.3
4
+ numpy>=2.2.0
5
+ torch>=2.6.0
6
+ transformers>=4.56.0
7
+ safetensors>=0.5.3
service.py CHANGED
@@ -1,129 +1,230 @@
1
- from __future__ import annotations
2
-
3
- import math
4
- import os
5
- from statistics import mean
6
- from typing import Any
7
-
8
- from schemas import HealthResponse, PredictRequest, PredictResponse, PredictionItem
9
-
10
-
11
- class TimesFmService:
12
- """CPU-first HF Space service wrapper.
13
-
14
- HuggingFace free Spaces are CPU-only in our rollout, so this service keeps
15
- the HTTP contract stable and intentionally avoids any GPU assumption. The
16
- default backend is a deterministic CPU baseline that is cheap to start,
17
- predictable for contract tests, and compatible with `tsf-bridge`.
18
- """
19
-
20
- def __init__(self) -> None:
21
- self.model_id = "timesfm"
22
- self.model_name = os.getenv(
23
- "TIMESFM_MODEL_NAME",
24
- "google/timesfm-2.5-200m-transformers",
25
- )
26
- self.backend = os.getenv("TIMESFM_BACKEND", "baseline_cpu").strip() or "baseline_cpu"
27
- self.device = "cpu"
28
- self.ready = True
29
- self.max_context_length = int(os.getenv("TIMESFM_MAX_CONTEXT_LENGTH", "512"))
30
- self.max_horizon_step = int(os.getenv("TIMESFM_MAX_HORIZON_STEP", "288"))
31
- self.confidence_floor = float(os.getenv("TIMESFM_CONFIDENCE_FLOOR", "0.20"))
32
- self.confidence_ceiling = float(os.getenv("TIMESFM_CONFIDENCE_CEILING", "0.85"))
33
- self.min_required_points = int(os.getenv("TIMESFM_MIN_REQUIRED_POINTS", "32"))
34
-
35
- def health(self) -> HealthResponse:
36
- return HealthResponse(
37
- status="ok",
38
- model=self.model_name,
39
- model_id=self.model_id,
40
- backend=self.backend,
41
- device=self.device,
42
- ready=self.ready,
43
- max_context_length=self.max_context_length,
44
- max_horizon_step=self.max_horizon_step,
45
- )
46
-
47
- def predict(self, payload: PredictRequest) -> PredictResponse:
48
- self._validate_request(payload)
49
- closes = payload.close_prices[-payload.context_length :]
50
-
51
- predictions = self._predict_with_baseline(closes, payload.horizons)
52
- return PredictResponse(model_id=self.model_id, predictions=predictions)
53
-
54
- def _validate_request(self, payload: PredictRequest) -> None:
55
- if payload.context_length > self.max_context_length:
56
- raise ValueError(
57
- f"context_length {payload.context_length} exceeds "
58
- f"TIMESFM_MAX_CONTEXT_LENGTH={self.max_context_length}"
59
- )
60
- if payload.context_length > len(payload.close_prices):
61
- raise ValueError("context_length must not exceed len(close_prices)")
62
- if len(payload.close_prices) < self.min_required_points:
63
- raise ValueError(
64
- f"at least {self.min_required_points} close prices are required "
65
- "for CPU baseline stability"
66
- )
67
- if any(step > self.max_horizon_step for step in payload.horizons):
68
- raise ValueError(
69
- f"horizons contain values above TIMESFM_MAX_HORIZON_STEP={self.max_horizon_step}"
70
- )
71
-
72
- def _predict_with_baseline(
73
- self, close_prices: list[float], horizons: list[int]
74
- ) -> list[PredictionItem]:
75
- last_price = close_prices[-1]
76
- short_window = close_prices[-min(8, len(close_prices)) :]
77
- long_window = close_prices[-min(32, len(close_prices)) :]
78
-
79
- short_mean = mean(short_window)
80
- long_mean = mean(long_window)
81
- momentum = 0.0 if short_mean == 0 else (last_price - short_mean) / short_mean
82
- regime_bias = 0.0 if long_mean == 0 else (short_mean - long_mean) / long_mean
83
-
84
- predictions: list[PredictionItem] = []
85
- for step in horizons:
86
- damped_step = math.log(step + 1.0)
87
- expected_return = momentum * 0.55 + regime_bias * 0.45
88
- expected_return *= min(1.0, damped_step / 3.5)
89
-
90
- pred_price = max(0.00000001, last_price * (1.0 + expected_return))
91
- confidence = self._confidence(close_prices, step, abs(expected_return))
92
- predictions.append(
93
- PredictionItem(
94
- step=step,
95
- pred_price=round(pred_price, 8),
96
- pred_confidence=round(confidence, 4),
97
- )
98
- )
99
- return predictions
100
-
101
- def _confidence(
102
- self, close_prices: list[float], step: int, expected_move_abs: float
103
- ) -> float:
104
- if len(close_prices) < 3:
105
- return self.confidence_floor
106
-
107
- changes: list[float] = []
108
- for previous, current in zip(close_prices[:-1], close_prices[1:]):
109
- if previous <= 0:
110
- continue
111
- changes.append(abs((current - previous) / previous))
112
-
113
- realized_vol = mean(changes[-min(32, len(changes)) :]) if changes else 0.0
114
- signal_to_noise = expected_move_abs / (realized_vol + 1e-9)
115
- horizon_decay = 1.0 / (1.0 + math.log(step + 1.0))
116
- raw = 0.25 + min(signal_to_noise, 2.0) * 0.25 + horizon_decay * 0.35
117
- return max(self.confidence_floor, min(self.confidence_ceiling, raw))
118
-
119
- def describe_runtime(self) -> dict[str, Any]:
120
- return {
121
- "model_id": self.model_id,
122
- "model_name": self.model_name,
123
- "backend": self.backend,
124
- "device": self.device,
125
- "ready": self.ready,
126
- "max_context_length": self.max_context_length,
127
- "max_horizon_step": self.max_horizon_step,
128
- "min_required_points": self.min_required_points,
129
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import os
5
+ from statistics import mean
6
+ from typing import Any
7
+
8
+ from schemas import HealthResponse, PredictRequest, PredictResponse, PredictionItem
9
+
10
+
11
+ class TimesFmService:
12
+ """HF Space service wrapper for TimesFM.
13
+
14
+ By default this service attempts real HuggingFace CPU inference. If model
15
+ loading fails and `TIMESFM_ALLOW_BASELINE_FALLBACK=true`, it falls back to
16
+ the deterministic baseline implementation.
17
+ """
18
+
19
+ def __init__(self) -> None:
20
+ self.model_id = "timesfm"
21
+ self.model_name = os.getenv(
22
+ "TIMESFM_MODEL_NAME",
23
+ "google/timesfm-2.5-200m-transformers",
24
+ )
25
+ self.backend = os.getenv("TIMESFM_BACKEND", "hf_cpu").strip() or "hf_cpu"
26
+ self.device = "cpu"
27
+ self.max_context_length = int(os.getenv("TIMESFM_MAX_CONTEXT_LENGTH", "512"))
28
+ self.max_horizon_step = int(os.getenv("TIMESFM_MAX_HORIZON_STEP", "288"))
29
+ self.confidence_floor = float(os.getenv("TIMESFM_CONFIDENCE_FLOOR", "0.20"))
30
+ self.confidence_ceiling = float(os.getenv("TIMESFM_CONFIDENCE_CEILING", "0.85"))
31
+ self.min_required_points = int(os.getenv("TIMESFM_MIN_REQUIRED_POINTS", "32"))
32
+ self.allow_baseline_fallback = os.getenv("TIMESFM_ALLOW_BASELINE_FALLBACK", "false").lower() == "true"
33
+
34
+ self.ready = False
35
+ self.load_error = ""
36
+ self._torch = None
37
+ self._model = None
38
+
39
+ self._initialize_backend()
40
+
41
+ def health(self) -> HealthResponse:
42
+ return HealthResponse(
43
+ status="ok" if self.ready else "degraded",
44
+ model=self.model_name,
45
+ model_id=self.model_id,
46
+ backend=self.backend,
47
+ device=self.device,
48
+ ready=self.ready,
49
+ max_context_length=self.max_context_length,
50
+ max_horizon_step=self.max_horizon_step,
51
+ )
52
+
53
+ def predict(self, payload: PredictRequest) -> PredictResponse:
54
+ self._validate_request(payload)
55
+ closes = payload.close_prices[-payload.context_length :]
56
+
57
+ if self.backend == "hf_cpu":
58
+ if not self.ready:
59
+ raise RuntimeError(self.load_error or "timesfm backend not ready")
60
+ predictions = self._predict_with_hf(closes, payload.horizons)
61
+ else:
62
+ predictions = self._predict_with_baseline(closes, payload.horizons)
63
+
64
+ return PredictResponse(model_id=self.model_id, predictions=predictions)
65
+
66
+ def _initialize_backend(self) -> None:
67
+ if self.backend == "baseline_cpu":
68
+ self.ready = True
69
+ return
70
+
71
+ if self.backend != "hf_cpu":
72
+ raise ValueError(f"unsupported TIMESFM_BACKEND={self.backend}")
73
+
74
+ try:
75
+ self._load_hf_model()
76
+ self.ready = True
77
+ except Exception as exc:
78
+ self.load_error = f"timesfm hf load failed: {exc}"
79
+ if self.allow_baseline_fallback:
80
+ self.backend = "baseline_cpu"
81
+ self.ready = True
82
+ else:
83
+ self.ready = False
84
+
85
+ def _load_hf_model(self) -> None:
86
+ import torch
87
+ from transformers import TimesFm2_5ModelForPrediction
88
+
89
+ self._torch = torch
90
+ torch.set_num_threads(max(1, int(os.getenv("TIMESFM_TORCH_THREADS", "2"))))
91
+ self._model = TimesFm2_5ModelForPrediction.from_pretrained(
92
+ self.model_name,
93
+ horizon_length=self.max_horizon_step,
94
+ torch_dtype=torch.float32,
95
+ )
96
+ self._model.to("cpu")
97
+ self._model.eval()
98
+
99
+ def _predict_with_hf(
100
+ self, close_prices: list[float], horizons: list[int]
101
+ ) -> list[PredictionItem]:
102
+ assert self._torch is not None
103
+ assert self._model is not None
104
+
105
+ torch = self._torch
106
+ context = close_prices[-self.max_context_length :]
107
+ past_values = [torch.tensor(context, dtype=torch.float32)]
108
+ freq = torch.tensor([0], dtype=torch.long)
109
+
110
+ with torch.inference_mode():
111
+ outputs = self._model(
112
+ past_values=past_values,
113
+ freq=freq,
114
+ forecast_context_len=len(context),
115
+ return_forecast_on_context=False,
116
+ )
117
+
118
+ dense_mean = outputs.mean_predictions[0].detach().cpu().tolist()
119
+ if len(dense_mean) < max(horizons):
120
+ raise RuntimeError(
121
+ f"TimesFM output horizon {len(dense_mean)} is shorter than requested {max(horizons)}"
122
+ )
123
+
124
+ dense_conf = self._timesfm_confidence(outputs, dense_mean)
125
+ predictions: list[PredictionItem] = []
126
+ for step in horizons:
127
+ predictions.append(
128
+ PredictionItem(
129
+ step=step,
130
+ pred_price=round(max(0.00000001, float(dense_mean[step - 1])), 8),
131
+ pred_confidence=round(dense_conf[step - 1], 4),
132
+ )
133
+ )
134
+ return predictions
135
+
136
+ def _timesfm_confidence(self, outputs: Any, dense_mean: list[float]) -> list[float]:
137
+ full_predictions = getattr(outputs, "full_predictions", None)
138
+ if full_predictions is None:
139
+ return [self.confidence_floor for _ in dense_mean]
140
+
141
+ quantiles = full_predictions[0].detach().cpu()
142
+ confidence: list[float] = []
143
+ for idx, pred in enumerate(dense_mean):
144
+ if quantiles.ndim != 2 or idx >= quantiles.shape[0]:
145
+ confidence.append(self.confidence_floor)
146
+ continue
147
+ lower = float(quantiles[idx][0])
148
+ upper = float(quantiles[idx][-1])
149
+ band = abs(upper - lower) / max(abs(pred), 1e-6)
150
+ raw = 1.0 / (1.0 + band)
151
+ confidence.append(max(self.confidence_floor, min(self.confidence_ceiling, raw)))
152
+ return confidence
153
+
154
+ def _validate_request(self, payload: PredictRequest) -> None:
155
+ if payload.context_length > self.max_context_length:
156
+ raise ValueError(
157
+ f"context_length {payload.context_length} exceeds "
158
+ f"TIMESFM_MAX_CONTEXT_LENGTH={self.max_context_length}"
159
+ )
160
+ if payload.context_length > len(payload.close_prices):
161
+ raise ValueError("context_length must not exceed len(close_prices)")
162
+ if len(payload.close_prices) < self.min_required_points:
163
+ raise ValueError(
164
+ f"at least {self.min_required_points} close prices are required "
165
+ "for TimesFM stability"
166
+ )
167
+ if any(step > self.max_horizon_step for step in payload.horizons):
168
+ raise ValueError(
169
+ f"horizons contain values above TIMESFM_MAX_HORIZON_STEP={self.max_horizon_step}"
170
+ )
171
+
172
+ def _predict_with_baseline(
173
+ self, close_prices: list[float], horizons: list[int]
174
+ ) -> list[PredictionItem]:
175
+ last_price = close_prices[-1]
176
+ short_window = close_prices[-min(8, len(close_prices)) :]
177
+ long_window = close_prices[-min(32, len(close_prices)) :]
178
+
179
+ short_mean = mean(short_window)
180
+ long_mean = mean(long_window)
181
+ momentum = 0.0 if short_mean == 0 else (last_price - short_mean) / short_mean
182
+ regime_bias = 0.0 if long_mean == 0 else (short_mean - long_mean) / long_mean
183
+
184
+ predictions: list[PredictionItem] = []
185
+ for step in horizons:
186
+ damped_step = math.log(step + 1.0)
187
+ expected_return = momentum * 0.55 + regime_bias * 0.45
188
+ expected_return *= min(1.0, damped_step / 3.5)
189
+
190
+ pred_price = max(0.00000001, last_price * (1.0 + expected_return))
191
+ confidence = self._baseline_confidence(close_prices, step, abs(expected_return))
192
+ predictions.append(
193
+ PredictionItem(
194
+ step=step,
195
+ pred_price=round(pred_price, 8),
196
+ pred_confidence=round(confidence, 4),
197
+ )
198
+ )
199
+ return predictions
200
+
201
+ def _baseline_confidence(
202
+ self, close_prices: list[float], step: int, expected_move_abs: float
203
+ ) -> float:
204
+ if len(close_prices) < 3:
205
+ return self.confidence_floor
206
+
207
+ changes: list[float] = []
208
+ for previous, current in zip(close_prices[:-1], close_prices[1:]):
209
+ if previous <= 0:
210
+ continue
211
+ changes.append(abs((current - previous) / previous))
212
+
213
+ realized_vol = mean(changes[-min(32, len(changes)) :]) if changes else 0.0
214
+ signal_to_noise = expected_move_abs / (realized_vol + 1e-9)
215
+ horizon_decay = 1.0 / (1.0 + math.log(step + 1.0))
216
+ raw = 0.25 + min(signal_to_noise, 2.0) * 0.25 + horizon_decay * 0.35
217
+ return max(self.confidence_floor, min(self.confidence_ceiling, raw))
218
+
219
+ def describe_runtime(self) -> dict[str, Any]:
220
+ return {
221
+ "model_id": self.model_id,
222
+ "model_name": self.model_name,
223
+ "backend": self.backend,
224
+ "device": self.device,
225
+ "ready": self.ready,
226
+ "load_error": self.load_error,
227
+ "max_context_length": self.max_context_length,
228
+ "max_horizon_step": self.max_horizon_step,
229
+ "min_required_points": self.min_required_points,
230
+ }