113 lines
6.5 KiB
Python
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}}
|