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}}