fengchen31 commited on
Commit
daa0d2f
·
verified ·
1 Parent(s): fa31828

Sync from GitHub refs/heads/main @ fa80e51

Browse files
Dockerfile CHANGED
@@ -38,11 +38,14 @@ ENV PORT=7860
38
  ENV HF_HOME=/tmp/hf_cache
39
  ENV TORCH_HOME=/tmp/torch_cache
40
  ENV TRANSFORMERS_OFFLINE=0
41
- # Production warm-start — preload chat (gemma-4-e4b) at lifespan boot so the
42
- # first /chat request doesn't pay ~30s cold-load. Default in main.py is lazy
43
- # (opt-in) because Apple Silicon dev hangs when Gemma + FashionSigLIP
44
- # cohabit MPS unified memory; HF Spaces L40S has no such constraint.
 
 
45
  ENV ML_PRELOAD_CHAT=1
 
46
 
47
  EXPOSE 7860
48
  USER appuser
 
38
  ENV HF_HOME=/tmp/hf_cache
39
  ENV TORCH_HOME=/tmp/torch_cache
40
  ENV TRANSFORMERS_OFFLINE=0
41
+ # Production warm-start — preload chat (gemma-4-e4b) + trend_predict (TimesFM 2.5)
42
+ # at lifespan boot so first /chat or /api/v1/ml/trends/predict request doesn't pay
43
+ # ~30s / ~10s cold-load. Defaults in main.py are lazy (opt-in) because Apple Silicon
44
+ # dev hangs when Gemma + FashionSigLIP cohabit MPS unified memory; HF Spaces L40S
45
+ # has no such constraint. TimesFM 2.5 also requires .compile(ForecastConfig) which
46
+ # runs at first load — preload absorbs that one-time JIT cost.
47
  ENV ML_PRELOAD_CHAT=1
48
+ ENV ML_PRELOAD_TIMESFM=1
49
 
50
  EXPOSE 7860
51
  USER appuser
config/models.yaml CHANGED
@@ -33,6 +33,13 @@ models:
33
  provider: huggingface
34
  model_id: "amazon/chronos-bolt-tiny"
35
 
 
 
 
 
 
 
 
36
  xgboost-valuation:
37
  provider: xgboost
38
  model_path: "models/valuation_xgb.joblib"
@@ -181,7 +188,8 @@ capabilities:
181
  min_samples: 50
182
 
183
  trend_predict:
184
- model: chronos-bolt
 
185
  dev_mode: "${TREND_DEV_MODE:-false}"
186
 
187
  size_recommend:
 
33
  provider: huggingface
34
  model_id: "amazon/chronos-bolt-tiny"
35
 
36
+ # TimesFM 2.5:Google 200M 參數,Apache 2.0,univariate forecasting
37
+ # 取代 chronos-bolt-tiny(9M)做主力時序預測。chronos-bolt 保留 entry
38
+ # 給 TREND_MODEL=chronos-bolt 回退(git-bisect / smoke prod issue)。
39
+ timesfm-2.5:
40
+ provider: huggingface
41
+ model_id: "google/timesfm-2.5-200m-pytorch"
42
+
43
  xgboost-valuation:
44
  provider: xgboost
45
  model_path: "models/valuation_xgb.joblib"
 
188
  min_samples: 50
189
 
190
  trend_predict:
191
+ # ${TREND_MODEL:-timesfm-2.5}:預設 TimesFM 2.5;設 TREND_MODEL=chronos-bolt 可回退舊 9M tiny。
192
+ model: "${TREND_MODEL:-timesfm-2.5}"
193
  dev_mode: "${TREND_DEV_MODE:-false}"
194
 
195
  size_recommend:
pyproject.toml CHANGED
@@ -19,6 +19,7 @@ dependencies = [
19
  "scikit-learn>=1.4.0",
20
  "joblib>=1.3.0",
21
  "chronos-forecasting>=2.0.0",
 
22
  "pytrends>=4.9.0",
23
  "timm>=1.0.25",
24
  "einops>=0.8.2",
 
19
  "scikit-learn>=1.4.0",
20
  "joblib>=1.3.0",
21
  "chronos-forecasting>=2.0.0",
22
+ "timesfm>=1.3.0",
23
  "pytrends>=4.9.0",
24
  "timm>=1.0.25",
25
  "einops>=0.8.2",
src/main.py CHANGED
@@ -74,7 +74,7 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
74
  except Exception:
75
  pass # Non-fatal: will lazy-load on first request
76
 
77
- # 3. 背景預載 chat capability (gemma-4-e4b),不阻塞 server 啟動
78
  # 載入完成前 health endpoint 仍可回應,model 相關 endpoint 會等 lock 釋放
79
  #
80
  # 預設 lazy(不預載)— Gemma 4 ~7GB 與 FashionSigLIP 共住在 Apple
@@ -83,15 +83,23 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
83
  # /chat 等請求才拉權重;只 SigLIP 工作流(backfill / paste / embed)
84
  # 永遠不會觸發 cohabitation。
85
  #
86
- # ML_PRELOAD_CHAT=1 預載 — 給 HF Spaces L40S production 用,避免
87
- # 第一個聊天請求 ~30s 冷啟
 
 
 
88
  if os.environ.get("ML_PRELOAD_CHAT") == "1":
 
 
 
 
 
89
 
90
  async def _preload_critical_models() -> None:
91
  from src.registry import get_registry
92
 
93
  reg = get_registry()
94
- for cap in ["chat"]:
95
  try:
96
  await reg.get(cap)
97
  # WARN level so HF Spaces / Render container logs surface
@@ -123,7 +131,7 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
123
  else:
124
  # WARN level — see Pre-loaded note above re: root logger default.
125
  _logger.warning(
126
- "ml-service startup: ML_PRELOAD_CHAT not set — chat capability lazy-loads on first request"
127
  )
128
 
129
  yield # 應用程式運行中
 
74
  except Exception:
75
  pass # Non-fatal: will lazy-load on first request
76
 
77
+ # 3. 背景預載 capability,不阻塞 server 啟動
78
  # 載入完成前 health endpoint 仍可回應,model 相關 endpoint 會等 lock 釋放
79
  #
80
  # 預設 lazy(不預載)— Gemma 4 ~7GB 與 FashionSigLIP 共住在 Apple
 
83
  # /chat 等請求才拉權重;只 SigLIP 工作流(backfill / paste / embed)
84
  # 永遠不會觸發 cohabitation。
85
  #
86
+ # Production 預載HF Spaces L40S):
87
+ # - ML_PRELOAD_CHAT=1 → 預載 chat (gemma-4-e4b),避免第一個聊天請求 ~30s 冷啟
88
+ # - ML_PRELOAD_TIMESFM=1 → 預載 trend_predict (TimesFM 2.5),避免第一個趨勢請求
89
+ # ~10s 冷啟 + ForecastConfig.compile() 開銷
90
+ _preload_caps: list[str] = []
91
  if os.environ.get("ML_PRELOAD_CHAT") == "1":
92
+ _preload_caps.append("chat")
93
+ if os.environ.get("ML_PRELOAD_TIMESFM") == "1":
94
+ _preload_caps.append("trend_predict")
95
+
96
+ if _preload_caps:
97
 
98
  async def _preload_critical_models() -> None:
99
  from src.registry import get_registry
100
 
101
  reg = get_registry()
102
+ for cap in _preload_caps:
103
  try:
104
  await reg.get(cap)
105
  # WARN level so HF Spaces / Render container logs surface
 
131
  else:
132
  # WARN level — see Pre-loaded note above re: root logger default.
133
  _logger.warning(
134
+ "ml-service startup: no ML_PRELOAD_* set — all capabilities lazy-load on first request"
135
  )
136
 
137
  yield # 應用程式運行中
src/providers/huggingface.py CHANGED
@@ -75,10 +75,14 @@ class HuggingFaceProvider(ModelProvider):
75
  if "florence" in model_id.lower():
76
  return await self._load_florence(parsed)
77
 
78
- # Chronos-Bolt(時序預測)
79
  if "chronos" in model_id.lower():
80
  return await self._load_chronos(parsed)
81
 
 
 
 
 
82
  # 預設:Causal LM(Qwen 系列)+ optional LoRA
83
  return await self._load_causal_lm(parsed)
84
 
@@ -359,6 +363,53 @@ class HuggingFaceProvider(ModelProvider):
359
  config={"model_id": config.model_id, "device": device},
360
  )
361
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
  @staticmethod
363
  def _resolve_device(device_str: str) -> str:
364
  """解析裝置字串,auto 會偵測 cuda > mps > cpu 順序。"""
 
75
  if "florence" in model_id.lower():
76
  return await self._load_florence(parsed)
77
 
78
+ # Chronos-Bolt(時序預測,舊;保留給 TREND_MODEL=chronos-bolt 回退
79
  if "chronos" in model_id.lower():
80
  return await self._load_chronos(parsed)
81
 
82
+ # TimesFM 2.5(Google 200M 時序基礎模型,Apache 2.0)
83
+ if "timesfm" in model_id.lower():
84
+ return await self._load_timesfm(parsed)
85
+
86
  # 預設:Causal LM(Qwen 系列)+ optional LoRA
87
  return await self._load_causal_lm(parsed)
88
 
 
363
  config={"model_id": config.model_id, "device": device},
364
  )
365
 
366
+ async def _load_timesfm(self, config: HFModelConfig) -> LoadedModel:
367
+ """載入 TimesFM 2.5 — 在 worker thread 中執行避免阻塞 event loop。"""
368
+ return await asyncio.to_thread(self._load_timesfm_sync, config)
369
+
370
+ def _load_timesfm_sync(self, config: HFModelConfig) -> LoadedModel:
371
+ """同步載入 Google TimesFM 2.5 200M(由 worker thread 呼叫)。
372
+
373
+ 【為什麼需要 .compile()?】
374
+ TimesFM 2.5 的 from_pretrained 只 load weights,inference 前必須先 compile
375
+ ForecastConfig — 否則 forecast() 會 RuntimeError。compile 一次後可重用。
376
+
377
+ 【ForecastConfig 參數選擇】
378
+ - max_horizon=128:90 天足夠,留 buffer 給未來 180 天 endpoint 不需重 compile
379
+ - max_context=1024:歷史價格序列上限,遠超 price_history 實際需求
380
+ - normalize_inputs:消除品牌間絕對價差影響
381
+ - use_continuous_quantile_head + fix_quantile_crossing:取得單調遞增 quantile
382
+ - infer_is_positive:價格非負強制 forecast≥0
383
+ - force_flip_invariance:抗 sign-flip pretraining artifact
384
+ """
385
+ import torch
386
+ import timesfm
387
+
388
+ device = self._resolve_device(config.device)
389
+ logger.info("Loading TimesFM 2.5 (%s) on %s", config.model_id, device)
390
+
391
+ torch.set_float32_matmul_precision("high")
392
+ model = timesfm.TimesFM_2p5_200M_torch.from_pretrained(config.model_id)
393
+ model.compile(
394
+ timesfm.ForecastConfig(
395
+ max_context=1024,
396
+ max_horizon=128,
397
+ normalize_inputs=True,
398
+ use_continuous_quantile_head=True,
399
+ force_flip_invariance=True,
400
+ infer_is_positive=True,
401
+ fix_quantile_crossing=True,
402
+ )
403
+ )
404
+ self._loaded = True
405
+ logger.info("TimesFM 2.5 loaded + compiled successfully")
406
+
407
+ return LoadedModel(
408
+ model=model,
409
+ tokenizer=None,
410
+ config={"model_id": config.model_id, "device": device},
411
+ )
412
+
413
  @staticmethod
414
  def _resolve_device(device_str: str) -> str:
415
  """解析裝置字串,auto 會偵測 cuda > mps > cpu 順序。"""
src/services/trend_predictor.py CHANGED
@@ -38,10 +38,30 @@ logger = logging.getLogger(__name__)
38
  # ============================================================================
39
  # 模式切換
40
  # ============================================================================
41
- # 預設 false = 生產模式(用真實 Chronos + Google Trends)
42
  # 設 true = 開發模式(回傳假資料,不打外部 API)
43
  DEV_MODE = os.environ.get("TREND_DEV_MODE", "false").lower() == "true"
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  # ============================================================================
46
  # ModelRegistry 整合
47
  # ============================================================================
@@ -99,6 +119,10 @@ class TrendPrediction(BaseModel):
99
  predicted_price_90d: float # 90 天後的預測均價
100
  google_trend_score: float # Google Trends 熱度分數(0-100)
101
  forecast_generated_at: str # 預測產生時間(ISO 格式)
 
 
 
 
102
 
103
 
104
  # ============================================================================
@@ -182,14 +206,22 @@ def predict_trend(
182
  參數:
183
  brand: 品牌名(如 "Gucci")
184
  price_history: 歷史成交紀錄,格式 [{"date": "2024-01-15", "price": 1200.0}, ...]
185
- 可選——沒有的話,Chronos 會 fallback 到 mock 價格 + 真實 Google Trends
186
 
187
  回傳:
188
- TrendPrediction — 含 30d/90d 預測價、趨勢方向、Google Trends 分數
 
 
 
 
 
 
189
  """
190
  if DEV_MODE:
191
  return _mock_predict(brand)
192
- return _chronos_predict(brand, price_history)
 
 
193
 
194
 
195
  # ============================================================================
@@ -197,15 +229,22 @@ def predict_trend(
197
  # ============================================================================
198
 
199
 
200
- def _mock_predict(brand: str) -> TrendPrediction:
 
 
 
 
201
  """
202
- 開發模式:用硬編碼的基準價 + 趨勢方向,模擬預測結果。
203
 
204
  不打任何外部 API,適合離線開發和 CI 測試。
205
  價格變化邏輯:
206
  - UP: 30d +3%, 90d +8%
207
  - DOWN: 30d -3%, 90d -8%
208
  - STABLE: 30d +0.5%, 90d +1%
 
 
 
209
  """
210
  baseline = _BRAND_BASELINES.get(brand, 500.0)
211
  direction = _MOCK_DIRECTIONS.get(brand, TrendDirection.STABLE)
@@ -228,6 +267,8 @@ def _mock_predict(brand: str) -> TrendPrediction:
228
  predicted_price_90d=price_90d,
229
  google_trend_score=trend_score,
230
  forecast_generated_at=datetime.now(tz=timezone.utc).isoformat(),
 
 
231
  )
232
 
233
 
@@ -393,4 +434,152 @@ def _chronos_predict(
393
  # 任何錯誤(模型載入失敗、tensor 格式錯誤等)都 fallback 到 mock
394
  # 確保 API 不會因為預測失敗而整個掛掉
395
  logger.exception("Chronos prediction failed for brand=%s", brand)
396
- return _mock_predict(brand)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  # ============================================================================
39
  # 模式切換
40
  # ============================================================================
41
+ # 預設 false = 生產模式(用真實 TimesFM + Google Trends)
42
  # 設 true = 開發模式(回傳假資料,不打外部 API)
43
  DEV_MODE = os.environ.get("TREND_DEV_MODE", "false").lower() == "true"
44
 
45
+ # 主力模型切換:預設 TimesFM 2.5(200M, Apache 2.0)
46
+ # 設 TREND_MODEL=chronos-bolt 可回退舊 chronos-bolt-tiny(9M)做 git-bisect
47
+ TREND_MODEL = os.environ.get("TREND_MODEL", "timesfm-2.5").lower()
48
+
49
+ # TimesFM 3-gate 信心門檻
50
+ # n<32: 純規則 fallback(mock + low_confidence)
51
+ # 32 ≤ n < 100: TimesFM + low_confidence flag
52
+ # n ≥ 100: TimesFM 全信心
53
+ TIMESFM_LOW_CONFIDENCE_MIN = 32
54
+ TIMESFM_FULL_CONFIDENCE_MIN = 100
55
+
56
+ _DISCLAIMER_INSUFFICIENT = (
57
+ "Insufficient price history (<32 records) — forecast uses brand baseline "
58
+ "rules, not the TimesFM model."
59
+ )
60
+ _DISCLAIMER_LIMITED = (
61
+ "Limited price history (32-100 records) — TimesFM forecast may have wider "
62
+ "uncertainty."
63
+ )
64
+
65
  # ============================================================================
66
  # ModelRegistry 整合
67
  # ============================================================================
 
119
  predicted_price_90d: float # 90 天後的預測均價
120
  google_trend_score: float # Google Trends 熱度分數(0-100)
121
  forecast_generated_at: str # 預測產生時間(ISO 格式)
122
+ # ── 新增:信心揭露(PR-2 TimesFM 2.5 swap,additive 不破壞既有 caller)──
123
+ # low_confidence=true 時前端應顯示 disclaimer(n<100 或 fallback)
124
+ low_confidence: bool = False
125
+ disclaimer: str = ""
126
 
127
 
128
  # ============================================================================
 
206
  參數:
207
  brand: 品牌名(如 "Gucci")
208
  price_history: 歷史成交紀錄,格式 [{"date": "2024-01-15", "price": 1200.0}, ...]
209
+ 可選——沒有的話,model 會 fallback 到 mock 價格 + 真實 Google Trends
210
 
211
  回傳:
212
+ TrendPrediction — 含 30d/90d 預測價、趨勢方向、Google Trends 分數
213
+ low_confidence flag、disclaimer
214
+
215
+ 分派邏輯:
216
+ - DEV_MODE=true → mock(不打外部 API)
217
+ - TREND_MODEL=chronos-bolt → 舊 Chronos-Bolt-Tiny(git-bisect rollback path)
218
+ - 預設 TREND_MODEL=timesfm-2.5 → Google TimesFM 2.5 200M(3-gate 信心邏輯)
219
  """
220
  if DEV_MODE:
221
  return _mock_predict(brand)
222
+ if TREND_MODEL == "chronos-bolt":
223
+ return _chronos_predict(brand, price_history)
224
+ return _timesfm_predict(brand, price_history)
225
 
226
 
227
  # ============================================================================
 
229
  # ============================================================================
230
 
231
 
232
+ def _mock_predict(
233
+ brand: str,
234
+ low_confidence: bool = False,
235
+ disclaimer: str = "",
236
+ ) -> TrendPrediction:
237
  """
238
+ 開發模式 / fallback:用硬編碼的基準價 + 趨勢方向,模擬預測結果。
239
 
240
  不打任何外部 API,適合離線開發和 CI 測試。
241
  價格變化邏輯:
242
  - UP: 30d +3%, 90d +8%
243
  - DOWN: 30d -3%, 90d -8%
244
  - STABLE: 30d +0.5%, 90d +1%
245
+
246
+ 當生產 path 因資料不足/模型失敗 fallback 到此函式時,caller 應傳
247
+ low_confidence=True + disclaimer 字串,讓前端能正確標示。
248
  """
249
  baseline = _BRAND_BASELINES.get(brand, 500.0)
250
  direction = _MOCK_DIRECTIONS.get(brand, TrendDirection.STABLE)
 
267
  predicted_price_90d=price_90d,
268
  google_trend_score=trend_score,
269
  forecast_generated_at=datetime.now(tz=timezone.utc).isoformat(),
270
+ low_confidence=low_confidence,
271
+ disclaimer=disclaimer,
272
  )
273
 
274
 
 
434
  # 任何錯誤(模型載入失敗、tensor 格式錯誤等)都 fallback 到 mock
435
  # 確保 API 不會因為預測失敗而整個掛掉
436
  logger.exception("Chronos prediction failed for brand=%s", brand)
437
+ return _mock_predict(
438
+ brand,
439
+ low_confidence=True,
440
+ disclaimer=_DISCLAIMER_INSUFFICIENT,
441
+ )
442
+
443
+
444
+ # ============================================================================
445
+ # 生���模式:TimesFM 2.5(主力)
446
+ # ============================================================================
447
+ # Google Research 200M 參數 decoder-only 時序基礎模型,Apache 2.0。
448
+ # 取代 chronos-bolt-tiny(9M)作為主力預測。
449
+
450
+ TIMESFM_MODEL_ID = "google/timesfm-2.5-200m-pytorch"
451
+
452
+ # Lazy loading:第一次呼叫載入 + compile,之後重複使用
453
+ _timesfm_model = None
454
+
455
+
456
+ def _load_timesfm():
457
+ """
458
+ 載入並 compile TimesFM 2.5。第一次呼叫後快取於全域變數。
459
+
460
+ 【為什麼要 .compile()?】
461
+ TimesFM 2.5 from_pretrained 只 load weights;inference 前必須先 compile
462
+ ForecastConfig — 否則 forecast() 會 RuntimeError。compile 一次後可重用。
463
+
464
+ 【ForecastConfig 參數】
465
+ max_horizon=128:90d 足夠,buffer 留 180d endpoint 不需重 compile
466
+ max_context=1024:歷史價格序列上限,遠超實際 use case
467
+ normalize_inputs:消除品牌間絕對價差影響
468
+ use_continuous_quantile_head + fix_quantile_crossing:取得單調遞增 quantile
469
+ infer_is_positive:價格非負強制 forecast≥0
470
+ force_flip_invariance:抗 sign-flip pretraining artifact
471
+ """
472
+ global _timesfm_model
473
+ if _timesfm_model is not None:
474
+ return _timesfm_model
475
+
476
+ import timesfm
477
+
478
+ logger.info("Loading TimesFM 2.5 model (%s)...", TIMESFM_MODEL_ID)
479
+ torch.set_float32_matmul_precision("high")
480
+ model = timesfm.TimesFM_2p5_200M_torch.from_pretrained(TIMESFM_MODEL_ID)
481
+ model.compile(
482
+ timesfm.ForecastConfig(
483
+ max_context=1024,
484
+ max_horizon=128,
485
+ normalize_inputs=True,
486
+ use_continuous_quantile_head=True,
487
+ force_flip_invariance=True,
488
+ infer_is_positive=True,
489
+ fix_quantile_crossing=True,
490
+ )
491
+ )
492
+ _timesfm_model = model
493
+ logger.info("TimesFM 2.5 loaded + compiled on %s", DEVICE)
494
+ return _timesfm_model
495
+
496
+
497
+ def _timesfm_predict(
498
+ brand: str, price_history: list[dict[str, float | str]] | None = None
499
+ ) -> TrendPrediction:
500
+ """
501
+ 用 TimesFM 2.5 做 zero-shot 時序預測,3-gate 信心邏輯。
502
+
503
+ 【3-gate】
504
+ n<32:純規則 fallback(mock baseline + low_confidence + disclaimer)
505
+ 32 ≤ n < 100:TimesFM + low_confidence=True
506
+ n ≥ 100:TimesFM 全信心(low_confidence=False)
507
+
508
+ 【相比 Chronos-Bolt-Tiny 的改進】
509
+ - 200M params vs 9M:對長序列關係建模能力大幅提升
510
+ - 連續 quantile head:信賴區間連續而非離散
511
+ - flip-invariance + is_positive 約束:價格序列特殊化處理
512
+ - max_context=1024:可吃更長歷史不需 truncate
513
+ """
514
+ try:
515
+ # Step 1: 即時抓 Google Trends 分數(與 Chronos path 行為一致)
516
+ google_score = _fetch_google_trend_score(brand)
517
+ n = len(price_history) if price_history else 0
518
+
519
+ # Step 2: n<32 純規則 fallback
520
+ if n < TIMESFM_LOW_CONFIDENCE_MIN:
521
+ mock = _mock_predict(
522
+ brand,
523
+ low_confidence=True,
524
+ disclaimer=_DISCLAIMER_INSUFFICIENT,
525
+ )
526
+ mock.google_trend_score = google_score
527
+ return mock
528
+
529
+ # Step 3: 準備時間序列(按日期排序,取價格 1-D array)
530
+ # n<32 gate 保證 price_history 非 None;此 raise 是 unreachable 防禦,
531
+ # 但比 `assert price_history is not None` 安全:python -O 會 strip
532
+ # asserts,只剩此 explicit raise 才能 narrow 型別 + 保護 None.sorted()
533
+ # 跑進 except 變成 silent fallback。
534
+ if price_history is None:
535
+ raise ValueError("price_history must be non-None past n<32 gate")
536
+ sorted_history = sorted(price_history, key=lambda x: str(x["date"]))
537
+ prices = [float(p["price"]) for p in sorted_history]
538
+
539
+ # Step 4: TimesFM forecast
540
+ # inputs 接受 list of 1-D arrays,回傳 (point_forecast=median, quantile_forecast)
541
+ # point_forecast shape: (batch=1, horizon=90)
542
+ # 用 numpy float32 直接傳入(避免 torch tensor 型別不符)
543
+ import numpy as np
544
+
545
+ prices_array = np.array(prices, dtype=np.float32)
546
+ model = _load_timesfm()
547
+ point_forecast, _quantile_forecast = model.forecast(
548
+ horizon=90,
549
+ inputs=[prices_array],
550
+ )
551
+ pred_30d = float(point_forecast[0, 29]) # 第 30 天
552
+ pred_90d = float(point_forecast[0, 89]) # 第 90 天
553
+
554
+ # Step 5: 趨勢方向(與 Chronos path 邏輯一致)
555
+ current_price = prices[-1]
556
+ if pred_90d > current_price * 1.05:
557
+ direction = TrendDirection.UP
558
+ elif pred_90d < current_price * 0.95:
559
+ direction = TrendDirection.DOWN
560
+ else:
561
+ direction = TrendDirection.STABLE
562
+
563
+ # Step 6: 信心 flag
564
+ low_confidence = n < TIMESFM_FULL_CONFIDENCE_MIN
565
+ disclaimer = _DISCLAIMER_LIMITED if low_confidence else ""
566
+
567
+ return TrendPrediction(
568
+ brand=brand,
569
+ trend_direction=direction,
570
+ predicted_price_30d=round(pred_30d, 2),
571
+ predicted_price_90d=round(pred_90d, 2),
572
+ google_trend_score=google_score,
573
+ forecast_generated_at=datetime.now(tz=timezone.utc).isoformat(),
574
+ low_confidence=low_confidence,
575
+ disclaimer=disclaimer,
576
+ )
577
+ except Exception:
578
+ # 任何錯誤(模型載入失敗、numpy 格式錯誤等)fallback 到 mock + low_confidence
579
+ # 確保 API 不會因為預測失敗而整個掛掉
580
+ logger.exception("TimesFM prediction failed for brand=%s", brand)
581
+ return _mock_predict(
582
+ brand,
583
+ low_confidence=True,
584
+ disclaimer=_DISCLAIMER_INSUFFICIENT,
585
+ )
uv.lock CHANGED
@@ -19,6 +19,15 @@ resolution-markers = [
19
  [manifest]
20
  overrides = [{ name = "transformers", specifier = ">=5.5.0" }]
21
 
 
 
 
 
 
 
 
 
 
22
  [[package]]
23
  name = "accelerate"
24
  version = "1.13.0"
@@ -549,6 +558,19 @@ wheels = [
549
  { url = "https://files.pythonhosted.org/packages/2a/09/f8d8f8f31e4483c10a906437b4ce31bdf3d6d417b73fe33f1a8b59e34228/einops-0.8.2-py3-none-any.whl", hash = "sha256:54058201ac7087911181bfec4af6091bb59380360f069276601256a76af08193", size = 65638, upload-time = "2026-01-26T04:13:18.546Z" },
550
  ]
551
 
 
 
 
 
 
 
 
 
 
 
 
 
 
552
  [[package]]
553
  name = "fastapi"
554
  version = "0.135.1"
@@ -705,6 +727,30 @@ wheels = [
705
  { url = "https://files.pythonhosted.org/packages/ab/6e/81d47999aebc1b155f81eca4477a616a70f238a2549848c38983f3c22a82/ftfy-6.3.1-py3-none-any.whl", hash = "sha256:7c70eb532015cd2f9adb53f101fb6c7945988d023a085d127d1573dc49dd0083", size = 44821, upload-time = "2024-10-26T00:50:33.425Z" },
706
  ]
707
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
708
  [[package]]
709
  name = "google-api-core"
710
  version = "2.30.0"
@@ -1327,6 +1373,15 @@ wheels = [
1327
  { url = "https://files.pythonhosted.org/packages/7e/82/69e539c4c2027f1e1697e09aaa2449243085a0edf81ae2c6341e84d769b6/multiprocess-0.70.19-py39-none-any.whl", hash = "sha256:0d4b4397ed669d371c81dcd1ef33fd384a44d6c3de1bd0ca7ac06d837720d3c5", size = 133477, upload-time = "2026-01-19T06:47:38.619Z" },
1328
  ]
1329
 
 
 
 
 
 
 
 
 
 
1330
  [[package]]
1331
  name = "networkx"
1332
  version = "3.6.1"
@@ -1746,6 +1801,15 @@ wheels = [
1746
  { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" },
1747
  ]
1748
 
 
 
 
 
 
 
 
 
 
1749
  [[package]]
1750
  name = "pluggy"
1751
  version = "1.6.0"
@@ -2646,6 +2710,19 @@ wheels = [
2646
  { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" },
2647
  ]
2648
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2649
  [[package]]
2650
  name = "setuptools"
2651
  version = "82.0.1"
@@ -2673,6 +2750,15 @@ wheels = [
2673
  { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
2674
  ]
2675
 
 
 
 
 
 
 
 
 
 
2676
  [[package]]
2677
  name = "starlette"
2678
  version = "0.52.1"
@@ -2707,6 +2793,26 @@ wheels = [
2707
  { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" },
2708
  ]
2709
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2710
  [[package]]
2711
  name = "timm"
2712
  version = "1.0.25"
@@ -2973,6 +3079,21 @@ wheels = [
2973
  { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
2974
  ]
2975
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2976
  [[package]]
2977
  name = "uvicorn"
2978
  version = "0.41.0"
@@ -3035,6 +3156,35 @@ wheels = [
3035
  { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
3036
  ]
3037
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3038
  [[package]]
3039
  name = "wardrobe-os-ml-service"
3040
  version = "0.0.1"
@@ -3062,6 +3212,7 @@ dependencies = [
3062
  { name = "pyyaml" },
3063
  { name = "replicate" },
3064
  { name = "scikit-learn" },
 
3065
  { name = "timm" },
3066
  { name = "torch" },
3067
  { name = "torchvision" },
@@ -3103,6 +3254,7 @@ requires-dist = [
3103
  { name = "pyyaml", specifier = ">=6.0.3" },
3104
  { name = "replicate", specifier = ">=1.0.0" },
3105
  { name = "scikit-learn", specifier = ">=1.4.0" },
 
3106
  { name = "timm", specifier = ">=1.0.25" },
3107
  { name = "torch", specifier = ">=2.5.0" },
3108
  { name = "torchvision", specifier = ">=0.20.0" },
 
19
  [manifest]
20
  overrides = [{ name = "transformers", specifier = ">=5.5.0" }]
21
 
22
+ [[package]]
23
+ name = "absl-py"
24
+ version = "2.4.0"
25
+ source = { registry = "https://pypi.org/simple" }
26
+ sdist = { url = "https://files.pythonhosted.org/packages/64/c7/8de93764ad66968d19329a7e0c147a2bb3c7054c554d4a119111b8f9440f/absl_py-2.4.0.tar.gz", hash = "sha256:8c6af82722b35cf71e0f4d1d47dcaebfff286e27110a99fc359349b247dfb5d4", size = 116543, upload-time = "2026-01-28T10:17:05.322Z" }
27
+ wheels = [
28
+ { url = "https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl", hash = "sha256:88476fd881ca8aab94ffa78b7b6c632a782ab3ba1cd19c9bd423abc4fb4cd28d", size = 135750, upload-time = "2026-01-28T10:17:04.19Z" },
29
+ ]
30
+
31
  [[package]]
32
  name = "accelerate"
33
  version = "1.13.0"
 
558
  { url = "https://files.pythonhosted.org/packages/2a/09/f8d8f8f31e4483c10a906437b4ce31bdf3d6d417b73fe33f1a8b59e34228/einops-0.8.2-py3-none-any.whl", hash = "sha256:54058201ac7087911181bfec4af6091bb59380360f069276601256a76af08193", size = 65638, upload-time = "2026-01-26T04:13:18.546Z" },
559
  ]
560
 
561
+ [[package]]
562
+ name = "einshape"
563
+ version = "1.0"
564
+ source = { registry = "https://pypi.org/simple" }
565
+ dependencies = [
566
+ { name = "absl-py" },
567
+ { name = "numpy" },
568
+ ]
569
+ sdist = { url = "https://files.pythonhosted.org/packages/cb/c6/95ad0a036656aec1cb32177a5d5abfcfbf53a01c1416484cacb8c7332a84/einshape-1.0.tar.gz", hash = "sha256:53538d75dd099f4ead4a4f786fafdcb0b729bb587e0b3afeca25ceef18c9ac14", size = 14571, upload-time = "2022-12-19T17:09:34.618Z" }
570
+ wheels = [
571
+ { url = "https://files.pythonhosted.org/packages/71/bb/34cc02f13b438d550e4709216ee1df9da8e55e15b0cc87a2cb5dee19a729/einshape-1.0-py3-none-any.whl", hash = "sha256:42da4c2dea3a27f87ee45a7cee5072a636b97cb184bb07bf5d6412ba0ff7b965", size = 21392, upload-time = "2022-12-19T17:09:32.904Z" },
572
+ ]
573
+
574
  [[package]]
575
  name = "fastapi"
576
  version = "0.135.1"
 
727
  { url = "https://files.pythonhosted.org/packages/ab/6e/81d47999aebc1b155f81eca4477a616a70f238a2549848c38983f3c22a82/ftfy-6.3.1-py3-none-any.whl", hash = "sha256:7c70eb532015cd2f9adb53f101fb6c7945988d023a085d127d1573dc49dd0083", size = 44821, upload-time = "2024-10-26T00:50:33.425Z" },
728
  ]
729
 
730
+ [[package]]
731
+ name = "gitdb"
732
+ version = "4.0.12"
733
+ source = { registry = "https://pypi.org/simple" }
734
+ dependencies = [
735
+ { name = "smmap" },
736
+ ]
737
+ sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" }
738
+ wheels = [
739
+ { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" },
740
+ ]
741
+
742
+ [[package]]
743
+ name = "gitpython"
744
+ version = "3.1.47"
745
+ source = { registry = "https://pypi.org/simple" }
746
+ dependencies = [
747
+ { name = "gitdb" },
748
+ ]
749
+ sdist = { url = "https://files.pythonhosted.org/packages/c1/bd/50db468e9b1310529a19fce651b3b0e753b5c07954d486cba31bbee9a5d5/gitpython-3.1.47.tar.gz", hash = "sha256:dba27f922bd2b42cb54c87a8ab3cb6beb6bf07f3d564e21ac848913a05a8a3cd", size = 216978, upload-time = "2026-04-22T02:44:44.059Z" }
750
+ wheels = [
751
+ { url = "https://files.pythonhosted.org/packages/f2/c5/a1bc0996af85757903cf2bf444a7824e68e0035ce63fb41d6f76f9def68b/gitpython-3.1.47-py3-none-any.whl", hash = "sha256:489f590edfd6d20571b2c0e72c6a6ac6915ee8b8cd04572330e3842207a78905", size = 209547, upload-time = "2026-04-22T02:44:41.271Z" },
752
+ ]
753
+
754
  [[package]]
755
  name = "google-api-core"
756
  version = "2.30.0"
 
1373
  { url = "https://files.pythonhosted.org/packages/7e/82/69e539c4c2027f1e1697e09aaa2449243085a0edf81ae2c6341e84d769b6/multiprocess-0.70.19-py39-none-any.whl", hash = "sha256:0d4b4397ed669d371c81dcd1ef33fd384a44d6c3de1bd0ca7ac06d837720d3c5", size = 133477, upload-time = "2026-01-19T06:47:38.619Z" },
1374
  ]
1375
 
1376
+ [[package]]
1377
+ name = "narwhals"
1378
+ version = "2.20.0"
1379
+ source = { registry = "https://pypi.org/simple" }
1380
+ sdist = { url = "https://files.pythonhosted.org/packages/e9/f3/257adc69a71011b4c8cda321b00f02c5bf1980ae38ffd05a58d9632d4de8/narwhals-2.20.0.tar.gz", hash = "sha256:c10994975fa7dc5a68c2cffcddbd5908fc8ebb2d463c5bab085309c0ee1f551e", size = 627848, upload-time = "2026-04-20T12:11:45.427Z" }
1381
+ wheels = [
1382
+ { url = "https://files.pythonhosted.org/packages/d0/69/f24d3d1c38ad69e256138b4ec2452a8c7cf66be49dc214771ae99dd4f0a0/narwhals-2.20.0-py3-none-any.whl", hash = "sha256:16e750ea5507d4ba6e8d03455b5f93a535e0405976561baea235bca5dc9f475d", size = 449373, upload-time = "2026-04-20T12:11:43.596Z" },
1383
+ ]
1384
+
1385
  [[package]]
1386
  name = "networkx"
1387
  version = "3.6.1"
 
1801
  { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" },
1802
  ]
1803
 
1804
+ [[package]]
1805
+ name = "platformdirs"
1806
+ version = "4.9.6"
1807
+ source = { registry = "https://pypi.org/simple" }
1808
+ sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
1809
+ wheels = [
1810
+ { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
1811
+ ]
1812
+
1813
  [[package]]
1814
  name = "pluggy"
1815
  version = "1.6.0"
 
2710
  { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" },
2711
  ]
2712
 
2713
+ [[package]]
2714
+ name = "sentry-sdk"
2715
+ version = "2.58.0"
2716
+ source = { registry = "https://pypi.org/simple" }
2717
+ dependencies = [
2718
+ { name = "certifi" },
2719
+ { name = "urllib3" },
2720
+ ]
2721
+ sdist = { url = "https://files.pythonhosted.org/packages/26/b3/fb8291170d0e844173164709fc0fa0c221ed75a5da740c8746f2a83b4eb1/sentry_sdk-2.58.0.tar.gz", hash = "sha256:c1144d947352d54e5b7daa63596d9f848adf684989c06c4f5a659f0c85a18f6f", size = 438764, upload-time = "2026-04-13T17:23:26.265Z" }
2722
+ wheels = [
2723
+ { url = "https://files.pythonhosted.org/packages/fa/eb/d875669993b762556ae8b2efd86219943b4c0864d22204d622a9aee3052b/sentry_sdk-2.58.0-py2.py3-none-any.whl", hash = "sha256:688d1c704ddecf382ea3326f21a67453d4caa95592d722b7c780a36a9d23109e", size = 460919, upload-time = "2026-04-13T17:23:24.675Z" },
2724
+ ]
2725
+
2726
  [[package]]
2727
  name = "setuptools"
2728
  version = "82.0.1"
 
2750
  { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
2751
  ]
2752
 
2753
+ [[package]]
2754
+ name = "smmap"
2755
+ version = "5.0.3"
2756
+ source = { registry = "https://pypi.org/simple" }
2757
+ sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" }
2758
+ wheels = [
2759
+ { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" },
2760
+ ]
2761
+
2762
  [[package]]
2763
  name = "starlette"
2764
  version = "0.52.1"
 
2793
  { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" },
2794
  ]
2795
 
2796
+ [[package]]
2797
+ name = "timesfm"
2798
+ version = "1.3.0"
2799
+ source = { registry = "https://pypi.org/simple" }
2800
+ dependencies = [
2801
+ { name = "absl-py" },
2802
+ { name = "einshape" },
2803
+ { name = "huggingface-hub" },
2804
+ { name = "numpy" },
2805
+ { name = "pandas" },
2806
+ { name = "scikit-learn" },
2807
+ { name = "typer" },
2808
+ { name = "utilsforecast" },
2809
+ { name = "wandb" },
2810
+ ]
2811
+ sdist = { url = "https://files.pythonhosted.org/packages/1d/38/b98e0e45408348ef91ba8b3035db247200d3ad8a8c8eaf62a82f2add7cf8/timesfm-1.3.0.tar.gz", hash = "sha256:c494775c17d373d8d7f2e5562468fdff371c123d1d6e43dcfe92fbf9a86fec4e", size = 48227, upload-time = "2025-07-06T22:07:07.839Z" }
2812
+ wheels = [
2813
+ { url = "https://files.pythonhosted.org/packages/d9/04/afb1888ab8db240576811b240cc7b7687c348516a72bfd618c5bd98ea9d2/timesfm-1.3.0-py3-none-any.whl", hash = "sha256:a99e22b13654bdbfaecb14a48e4a8dc6c990b191a0322a5cacd09eb0df3bfebe", size = 55019, upload-time = "2025-07-06T22:07:06.366Z" },
2814
+ ]
2815
+
2816
  [[package]]
2817
  name = "timm"
2818
  version = "1.0.25"
 
3079
  { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
3080
  ]
3081
 
3082
+ [[package]]
3083
+ name = "utilsforecast"
3084
+ version = "0.2.15"
3085
+ source = { registry = "https://pypi.org/simple" }
3086
+ dependencies = [
3087
+ { name = "narwhals" },
3088
+ { name = "numpy" },
3089
+ { name = "packaging" },
3090
+ { name = "pandas" },
3091
+ ]
3092
+ sdist = { url = "https://files.pythonhosted.org/packages/d9/55/8a37bb9ce36541fd353466259a07ccfdfaf25c996f3a71d989af4d4c7ba4/utilsforecast-0.2.15.tar.gz", hash = "sha256:c36d65d698a88d0fadc93d2d6737c304c3776397c60ae551ee17aa678caf3659", size = 60609, upload-time = "2025-12-03T16:29:08.652Z" }
3093
+ wheels = [
3094
+ { url = "https://files.pythonhosted.org/packages/6f/11/6c6ee61958b8e60f634b39e2f9a004f5d1c479cb962a2001fc3c72ceed78/utilsforecast-0.2.15-py3-none-any.whl", hash = "sha256:4b43bf5107e3cba13604cd86e93b5cf4906b57105b1900ccf98b8978aabd4150", size = 40344, upload-time = "2025-12-03T16:29:07.144Z" },
3095
+ ]
3096
+
3097
  [[package]]
3098
  name = "uvicorn"
3099
  version = "0.41.0"
 
3156
  { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
3157
  ]
3158
 
3159
+ [[package]]
3160
+ name = "wandb"
3161
+ version = "0.26.1"
3162
+ source = { registry = "https://pypi.org/simple" }
3163
+ dependencies = [
3164
+ { name = "click" },
3165
+ { name = "gitpython" },
3166
+ { name = "packaging" },
3167
+ { name = "platformdirs" },
3168
+ { name = "protobuf" },
3169
+ { name = "pydantic" },
3170
+ { name = "pyyaml" },
3171
+ { name = "requests" },
3172
+ { name = "sentry-sdk" },
3173
+ { name = "typing-extensions" },
3174
+ ]
3175
+ sdist = { url = "https://files.pythonhosted.org/packages/6a/a4/72a6640e1f566e81f184a426e3e45298d4c6672664de41adb7eb6f64370a/wandb-0.26.1.tar.gz", hash = "sha256:eef2dbaea06f0b1c0cdc5d76f544ae4c2b8848fc512442a00bd59f0502fc8aa1", size = 42159814, upload-time = "2026-04-23T16:27:34.033Z" }
3176
+ wheels = [
3177
+ { url = "https://files.pythonhosted.org/packages/8c/09/3296235f3906e904f06f2df29eed4d672fb23c0932c9486e2af64f2f2a66/wandb-0.26.1-py3-none-macosx_12_0_arm64.whl", hash = "sha256:2955fe190c005fb83ee6d73f066c8a33f09f3212a1f2eb53faa6581440e456be", size = 24857204, upload-time = "2026-04-23T16:26:58.576Z" },
3178
+ { url = "https://files.pythonhosted.org/packages/a1/ad/e39ca3086534129e42208ba00ed2c6247ce425f890219eeec33b4f162864/wandb-0.26.1-py3-none-macosx_12_0_x86_64.whl", hash = "sha256:55d91cabde98162d7116a5e19ddd052bd9848556243f1da4cbb9ffb7ad435bfc", size = 26014649, upload-time = "2026-04-23T16:27:02.559Z" },
3179
+ { url = "https://files.pythonhosted.org/packages/56/af/400d84a3bdce0b062b4baa70acb6becd2c8018697f4fbf5af9a9e1e406e5/wandb-0.26.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:7c78bc2454cfe1ffa1c3a256060a387356eed8a4488e024d9d2eba8f2b5bd51d", size = 25421317, upload-time = "2026-04-23T16:27:06.411Z" },
3180
+ { url = "https://files.pythonhosted.org/packages/7b/e9/b4bf8f3509dcea1cec52233a38991459654635b5a8e6a494eb912e1b9cfb/wandb-0.26.1-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:a2c8eeec8706dcd2872e69c3b4d20ec523082fdb4440295491556e219ad2aa67", size = 27192831, upload-time = "2026-04-23T16:27:10.308Z" },
3181
+ { url = "https://files.pythonhosted.org/packages/62/cf/4a6dce0c782223ef0eeea7139daee73418a7322befcf083512c31cebaa18/wandb-0.26.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2fa768ee0636a569afb7541cf996e56309c47070566a38916823f94e02afe586", size = 25593326, upload-time = "2026-04-23T16:27:14.259Z" },
3182
+ { url = "https://files.pythonhosted.org/packages/df/99/58c3d8c36ae8e2b7d70bf6493eb5daa1cca0231a04b025717b4cd1a78f1e/wandb-0.26.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5854928725cfeff1f284d5c043cd353f810e5da02eead2c120ef5056ad026fea", size = 27535542, upload-time = "2026-04-23T16:27:18.473Z" },
3183
+ { url = "https://files.pythonhosted.org/packages/7c/d0/4e846ffc1d0cc435518dfa581ce73ac82cfd0ebbf35f3853c9277f632e5f/wandb-0.26.1-py3-none-win32.whl", hash = "sha256:5c2bd44e575ae9944e2764d1aaa031461178276bf2636d5558399c2816ef5cfe", size = 24968151, upload-time = "2026-04-23T16:27:22.086Z" },
3184
+ { url = "https://files.pythonhosted.org/packages/e3/9b/487413eaccefdb58799a226726e24b486e9192d2671c75a4550c160aba23/wandb-0.26.1-py3-none-win_amd64.whl", hash = "sha256:5817785467d3f1676f1812ec19a89f77f6e56dfe67d9f47080075af95f705d3e", size = 24968155, upload-time = "2026-04-23T16:27:25.731Z" },
3185
+ { url = "https://files.pythonhosted.org/packages/04/dc/5baf3e99b3eeb709d6f75124b5bec8cb73d4b38d2b10df7fdcfde4966200/wandb-0.26.1-py3-none-win_arm64.whl", hash = "sha256:f848b7744f896bc04cabbb28360a2814d1551a91fa2c456243e06435729c8a2e", size = 22912416, upload-time = "2026-04-23T16:27:29.456Z" },
3186
+ ]
3187
+
3188
  [[package]]
3189
  name = "wardrobe-os-ml-service"
3190
  version = "0.0.1"
 
3212
  { name = "pyyaml" },
3213
  { name = "replicate" },
3214
  { name = "scikit-learn" },
3215
+ { name = "timesfm" },
3216
  { name = "timm" },
3217
  { name = "torch" },
3218
  { name = "torchvision" },
 
3254
  { name = "pyyaml", specifier = ">=6.0.3" },
3255
  { name = "replicate", specifier = ">=1.0.0" },
3256
  { name = "scikit-learn", specifier = ">=1.4.0" },
3257
+ { name = "timesfm", specifier = ">=1.3.0" },
3258
  { name = "timm", specifier = ">=1.0.25" },
3259
  { name = "torch", specifier = ">=2.5.0" },
3260
  { name = "torchvision", specifier = ">=0.20.0" },