TIF_E41253368/api/app/services/prediction_service.py

113 lines
6.5 KiB
Python

from datetime import date, datetime, timedelta, timezone
from typing import Any, Dict, List
import numpy as np
from app.errors import ApiError
from app.services.forecaster import forecast_recursive
from app.services.model_registry import COMMODITIES, MARKETS, ModelRegistry
from app.data.repository import DataUnavailableError, PriceRepository
NOTES = [
"Model menghasilkan prediksi rekursif hingga 30 hari; error dapat menumpuk pada horizon panjang.",
]
class PredictionService:
def __init__(self, registry: ModelRegistry, repository: PriceRepository, stale_days: int, trend_threshold_pct: float) -> None:
self.registry = registry
self.repository = repository
self.stale_days = stale_days
self.trend_threshold_pct = trend_threshold_pct
self._cache: Dict[Any, Dict[str, Any]] = {}
def _validate(self, commodity: str, market: str) -> Any:
if commodity not in COMMODITIES:
raise ApiError("UNKNOWN_COMMODITY", "Komoditas tidak dikenal.", 404)
if market not in MARKETS:
raise ApiError("UNKNOWN_MARKET", "Pasar tidak dikenal.", 404)
artifact = self.registry.get(commodity, market)
if artifact is None:
raise ApiError("MODEL_NOT_AVAILABLE", "Model untuk kombinasi ini tidak tersedia.", 404)
return artifact
def _series(self, commodity: str, market: str):
try:
series = self.repository.get_series(commodity, market).dropna()
except DataUnavailableError as error:
raise ApiError("INSUFFICIENT_DATA", str(error), 422)
if series.empty:
raise ApiError("INSUFFICIENT_DATA", "Data historis valid tidak tersedia.", 422)
return series
def predict(self, commodity: str, market: str, horizon: int) -> Dict[str, Any]:
artifact = self._validate(commodity, market)
series = self._series(commodity, market)
if len(series) < artifact.time_step:
raise ApiError("INSUFFICIENT_DATA", "Jumlah data valid belum mencukupi window model.", 422, {"required": artifact.time_step, "available": len(series)})
last_date = series.index[-1].date()
window_key = tuple(np.round(series.iloc[-artifact.time_step:].to_numpy(dtype=float), 6))
cache_key = (commodity, market, last_date, window_key)
cached = self._cache.get(cache_key)
out_of_range = False
if cached is None:
window = series.iloc[-artifact.time_step:].to_numpy(dtype=float) / 1000.0
if artifact.model_time_step != artifact.time_step:
raise ApiError("MODEL_NOT_AVAILABLE", "Artifact model keriting masih berformat 7 langkah, sedangkan API memerlukan 14 langkah.", 404, {"api_time_step": artifact.time_step, "model_time_step": artifact.model_time_step})
prices_in_thousand, out_of_range = forecast_recursive(artifact.model, artifact.scaler, window, 30)
prices = [price * 1000 for price in prices_in_thousand]
cached = {"prices": prices, "out_of_range": out_of_range}
self._cache[cache_key] = cached
prices = cached["prices"][:horizon]
last_actual = float(series.iloc[-1])
warnings: List[str] = []
data_age_days = (date.today() - last_date).days
if data_age_days > self.stale_days:
warnings.append("data_stale")
if cached["out_of_range"]:
warnings.append("out_of_training_range")
predictions = []
previous = last_actual
for step, price in enumerate(prices, 1):
change = price - previous
change_pct = round(change / previous * 100, 2) if previous else None
predictions.append({"step": step, "date": (last_date + timedelta(days=step)).isoformat(), "price": price, "change_rp": change, "change_pct": change_pct})
previous = price
first_price = prices[0]
last_price = prices[-1]
total_pct = ((last_price - last_actual) / last_actual * 100) if last_actual else 0
trend = "up" if total_pct > self.trend_threshold_pct else "down" if total_pct < -self.trend_threshold_pct else "flat"
return {"data": {"commodity": {"slug": commodity, "name": COMMODITIES[commodity]}, "market": {"slug": market, "name": MARKETS[market]}, "horizon": horizon, "last_data_date": last_date.isoformat(), "last_actual_price": last_actual, "predictions": predictions, "summary": {"first_price": first_price, "last_price": last_price, "min_price": min(prices), "max_price": max(prices), "average_price": sum(prices) / len(prices), "trend": trend}}, "meta": {"method": "recursive_multi_step", "time_step": artifact.time_step, "data_age_days": data_age_days, "generated_at": datetime.now(timezone(timedelta(hours=7))).isoformat(), "warnings": warnings, "notes": NOTES}}
def history(self, commodity: str, market: str, days: int) -> Dict[str, Any]:
self._validate(commodity, market)
try:
series = self.repository.get_series(commodity, market)
except DataUnavailableError as error:
raise ApiError("INSUFFICIENT_DATA", str(error), 422)
if series.dropna().empty:
raise ApiError("INSUFFICIENT_DATA", "Data historis valid tidak tersedia.", 422)
selected = series.tail(days)
return {"data": [{"date": index.date().isoformat(), "price": None if np.isnan(value) else float(value)} for index, value in selected.items()]}
def predict_map(self, commodity: str, horizon: int, markets: List[Dict[str, Any]]) -> Dict[str, Any]:
if commodity not in COMMODITIES:
raise ApiError("UNKNOWN_COMMODITY", "Komoditas tidak dikenal.", 404)
data = []
for market_info in markets:
market = market_info["slug"]
artifact = self.registry.get(commodity, market)
item = {**market_info, "market": market, "available": artifact is not None}
if artifact is None:
data.append(item)
continue
try:
result = self.predict(commodity, market, horizon)["data"]
prediction = result["predictions"][-1]
item.update({"last_actual_price": result["last_actual_price"], "predicted_price": prediction["price"], "change_rp": prediction["change_rp"], "change_pct": prediction["change_pct"], "trend": result["summary"]["trend"]})
except ApiError as error:
item.update({"available": False, "reason": error.code})
data.append(item)
return {"data": data, "meta": {"commodity": {"slug": commodity, "name": COMMODITIES[commodity]}, "horizon": horizon}}