Spaces:
Sleeping
Sleeping
anoderb commited on
Commit Β·
2860429
1
Parent(s): c995585
feat: integrate machine learning forecasting pipeline and unify settings & dashboard under Sikomo theme
Browse files- Dockerfile +7 -2
- backend/app/config.py +63 -17
- backend/app/database.py +296 -2
- backend/app/logger.py +38 -7
- backend/app/main.py +392 -72
- backend/app/ml_pipeline.py +557 -0
- backend/app/scheduler.py +179 -24
- backend/requirements.txt +8 -1
- frontend/app/page.tsx +0 -0
Dockerfile
CHANGED
|
@@ -23,7 +23,12 @@ WORKDIR /app
|
|
| 23 |
|
| 24 |
# Install minimal OS build dependencies
|
| 25 |
RUN apt-get update && apt-get install -y \
|
|
|
|
| 26 |
gcc \
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
&& rm -rf /var/lib/apt/lists/*
|
| 28 |
|
| 29 |
# Install python dependencies as user
|
|
@@ -34,8 +39,8 @@ RUN pip install --no-cache-dir --upgrade pip \
|
|
| 34 |
# Copy backend python packages
|
| 35 |
COPY --chown=user backend/ ./
|
| 36 |
|
| 37 |
-
# Establish web serving static destination directory
|
| 38 |
-
RUN mkdir -p /app/static && chown -R user:user /app/static
|
| 39 |
COPY --chown=user --from=builder /app/frontend/out /app/static
|
| 40 |
|
| 41 |
# Enforce secure runner execution ownership context
|
|
|
|
| 23 |
|
| 24 |
# Install minimal OS build dependencies
|
| 25 |
RUN apt-get update && apt-get install -y \
|
| 26 |
+
libgomp1 \
|
| 27 |
gcc \
|
| 28 |
+
g++ \
|
| 29 |
+
make \
|
| 30 |
+
python3-dev \
|
| 31 |
+
curl \
|
| 32 |
&& rm -rf /var/lib/apt/lists/*
|
| 33 |
|
| 34 |
# Install python dependencies as user
|
|
|
|
| 39 |
# Copy backend python packages
|
| 40 |
COPY --chown=user backend/ ./
|
| 41 |
|
| 42 |
+
# Establish web serving static destination directory and model directory
|
| 43 |
+
RUN mkdir -p /app/static /app/models && chown -R user:user /app/static /app/models
|
| 44 |
COPY --chown=user --from=builder /app/frontend/out /app/static
|
| 45 |
|
| 46 |
# Enforce secure runner execution ownership context
|
backend/app/config.py
CHANGED
|
@@ -1,33 +1,79 @@
|
|
|
|
|
| 1 |
from pydantic_settings import BaseSettings
|
| 2 |
from functools import lru_cache
|
|
|
|
|
|
|
| 3 |
|
| 4 |
class Settings(BaseSettings):
|
| 5 |
-
# Database
|
| 6 |
-
MYSQL_HOST: str
|
| 7 |
-
MYSQL_PORT: int =
|
| 8 |
-
MYSQL_DATABASE: str
|
| 9 |
-
MYSQL_USER: str
|
| 10 |
-
MYSQL_PASSWORD: str
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
API_PORT: int = 8000
|
| 14 |
API_HOST: str = "0.0.0.0"
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
| 17 |
DEFAULT_SCHEDULE: str = "0 8 * * *"
|
| 18 |
-
DEFAULT_SCHEDULES: str = "0 8 * * *"
|
| 19 |
-
|
| 20 |
# CORS
|
| 21 |
FRONTEND_URL: str = "http://localhost:3000"
|
| 22 |
-
|
| 23 |
@property
|
| 24 |
def DATABASE_URL(self) -> str:
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
class Config:
|
| 28 |
env_file = ".env"
|
| 29 |
extra = "ignore"
|
| 30 |
|
| 31 |
@lru_cache()
|
| 32 |
-
def get_settings():
|
| 33 |
-
return Settings()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# config.py β Centralized Configuration using pydantic-settings
|
| 2 |
from pydantic_settings import BaseSettings
|
| 3 |
from functools import lru_cache
|
| 4 |
+
from typing import Optional
|
| 5 |
+
import os
|
| 6 |
|
| 7 |
class Settings(BaseSettings):
|
| 8 |
+
# Database (support both MYSQL_ prefix and DB_ prefix)
|
| 9 |
+
MYSQL_HOST: Optional[str] = None
|
| 10 |
+
MYSQL_PORT: Optional[int] = None
|
| 11 |
+
MYSQL_DATABASE: Optional[str] = None
|
| 12 |
+
MYSQL_USER: Optional[str] = None
|
| 13 |
+
MYSQL_PASSWORD: Optional[str] = None
|
| 14 |
+
|
| 15 |
+
DB_HOST: Optional[str] = None
|
| 16 |
+
DB_PORT: Optional[int] = None
|
| 17 |
+
DB_NAME: Optional[str] = None
|
| 18 |
+
DB_USER: Optional[str] = None
|
| 19 |
+
DB_PASSWORD: Optional[str] = None
|
| 20 |
+
|
| 21 |
+
# API Auth & Configuration
|
| 22 |
API_PORT: int = 8000
|
| 23 |
API_HOST: str = "0.0.0.0"
|
| 24 |
+
API_SECRET_KEY: str = "your-random-secret"
|
| 25 |
+
DASHBOARD_PASSWORD: str = "Bandulan112"
|
| 26 |
+
|
| 27 |
+
# Default Schedules
|
| 28 |
DEFAULT_SCHEDULE: str = "0 8 * * *"
|
| 29 |
+
DEFAULT_SCHEDULES: str = "0 8 * * *"
|
| 30 |
+
|
| 31 |
# CORS
|
| 32 |
FRONTEND_URL: str = "http://localhost:3000"
|
| 33 |
+
|
| 34 |
@property
|
| 35 |
def DATABASE_URL(self) -> str:
|
| 36 |
+
# Fallback mappings
|
| 37 |
+
host = self.MYSQL_HOST or self.DB_HOST or os.getenv("MYSQL_HOST") or os.getenv("DB_HOST") or "localhost"
|
| 38 |
+
port = self.MYSQL_PORT or self.DB_PORT or os.getenv("MYSQL_PORT") or os.getenv("DB_PORT") or 3306
|
| 39 |
+
db = self.MYSQL_DATABASE or self.DB_NAME or os.getenv("MYSQL_DATABASE") or os.getenv("DB_NAME") or "sikomo_db"
|
| 40 |
+
user = self.MYSQL_USER or self.DB_USER or os.getenv("MYSQL_USER") or os.getenv("DB_USER") or "root"
|
| 41 |
+
password = self.MYSQL_PASSWORD or self.DB_PASSWORD or os.getenv("MYSQL_PASSWORD") or os.getenv("DB_PASSWORD") or ""
|
| 42 |
+
return f"mysql+pymysql://{user}:{password}@{host}:{port}/{db}"
|
| 43 |
+
|
| 44 |
class Config:
|
| 45 |
env_file = ".env"
|
| 46 |
extra = "ignore"
|
| 47 |
|
| 48 |
@lru_cache()
|
| 49 |
+
def get_settings() -> Settings:
|
| 50 |
+
return Settings()
|
| 51 |
+
|
| 52 |
+
class RuntimeConfig:
|
| 53 |
+
"""Runtime ML Configuration editable via dashboard without server restart."""
|
| 54 |
+
def __init__(self):
|
| 55 |
+
self.optuna_trials: int = int(os.getenv("OPTUNA_TRIALS", "30"))
|
| 56 |
+
self.model_dir: str = os.getenv("MODEL_DIR", "models")
|
| 57 |
+
self.forecast_days: int = int(os.getenv("FORECAST_DAYS", "7"))
|
| 58 |
+
self.default_schedules: str = os.getenv("DEFAULT_FORECAST_SCHEDULES", "0 1 * * *")
|
| 59 |
+
|
| 60 |
+
def to_dict(self) -> dict:
|
| 61 |
+
return {
|
| 62 |
+
"optuna_trials": self.optuna_trials,
|
| 63 |
+
"model_dir": self.model_dir,
|
| 64 |
+
"forecast_days": self.forecast_days,
|
| 65 |
+
"default_schedules": self.default_schedules
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
def update(self, key: str, value: str):
|
| 69 |
+
allowed = {
|
| 70 |
+
"optuna_trials": lambda v: setattr(self, "optuna_trials", int(v)),
|
| 71 |
+
"model_dir": lambda v: setattr(self, "model_dir", v),
|
| 72 |
+
"forecast_days": lambda v: setattr(self, "forecast_days", int(v)),
|
| 73 |
+
"default_schedules": lambda v: setattr(self, "default_schedules", v),
|
| 74 |
+
}
|
| 75 |
+
if key not in allowed:
|
| 76 |
+
raise ValueError(f"Key '{key}' tidak diizinkan untuk diubah.")
|
| 77 |
+
allowed[key](value)
|
| 78 |
+
|
| 79 |
+
runtime_config = RuntimeConfig()
|
backend/app/database.py
CHANGED
|
@@ -1,8 +1,12 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
| 2 |
from app.config import get_settings
|
| 3 |
|
| 4 |
settings = get_settings()
|
| 5 |
|
|
|
|
| 6 |
engine = create_engine(
|
| 7 |
settings.DATABASE_URL,
|
| 8 |
pool_pre_ping=True,
|
|
@@ -10,4 +14,294 @@ engine = create_engine(
|
|
| 10 |
)
|
| 11 |
|
| 12 |
def get_db_engine():
|
| 13 |
-
return engine
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# database.py β MySQL Connection & CRUD Operations for Scraper and Forecast
|
| 2 |
+
import pandas as pd
|
| 3 |
+
import sqlalchemy
|
| 4 |
+
from sqlalchemy import create_engine, text
|
| 5 |
from app.config import get_settings
|
| 6 |
|
| 7 |
settings = get_settings()
|
| 8 |
|
| 9 |
+
# Global Singleton DB Engine
|
| 10 |
engine = create_engine(
|
| 11 |
settings.DATABASE_URL,
|
| 12 |
pool_pre_ping=True,
|
|
|
|
| 14 |
)
|
| 15 |
|
| 16 |
def get_db_engine():
|
| 17 |
+
return engine
|
| 18 |
+
|
| 19 |
+
def get_engine():
|
| 20 |
+
"""Compatibility alias for forecast operations."""
|
| 21 |
+
return engine
|
| 22 |
+
|
| 23 |
+
# ββ READ OPERATIONS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 24 |
+
|
| 25 |
+
def get_all_komoditas():
|
| 26 |
+
"""Ambil semua komoditas aktif."""
|
| 27 |
+
return pd.read_sql(
|
| 28 |
+
"SELECT id, nama, slug, unit, volatile, volatilitas_skor FROM komoditas WHERE is_active=1",
|
| 29 |
+
engine
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
def get_harga_harian(komoditas_id: int, days: int = 730):
|
| 33 |
+
"""Ambil data harga harian untuk satu komoditas (default 2 tahun terakhir)."""
|
| 34 |
+
query = f"""
|
| 35 |
+
SELECT hh.tanggal AS date, p.nama AS pasar_nama, hh.harga
|
| 36 |
+
FROM harga_harian hh
|
| 37 |
+
JOIN pasar p ON p.id = hh.pasar_id
|
| 38 |
+
WHERE hh.komoditas_id = {komoditas_id}
|
| 39 |
+
AND hh.tanggal >= DATE_SUB(CURDATE(), INTERVAL {days} DAY)
|
| 40 |
+
ORDER BY hh.tanggal ASC
|
| 41 |
+
"""
|
| 42 |
+
df_raw = pd.read_sql(query, engine)
|
| 43 |
+
if df_raw.empty:
|
| 44 |
+
return pd.DataFrame()
|
| 45 |
+
# Pivot β wide format
|
| 46 |
+
df = df_raw.pivot_table(
|
| 47 |
+
index='date', columns='pasar_nama', values='harga'
|
| 48 |
+
).reset_index()
|
| 49 |
+
df.columns.name = None
|
| 50 |
+
df['date'] = pd.to_datetime(df['date'])
|
| 51 |
+
df = df.sort_values('date').reset_index(drop=True)
|
| 52 |
+
return df
|
| 53 |
+
|
| 54 |
+
def get_pasar_list(komoditas_id: int):
|
| 55 |
+
"""Ambil daftar pasar yang punya data untuk komoditas ini."""
|
| 56 |
+
return pd.read_sql(f"""
|
| 57 |
+
SELECT DISTINCT p.id, p.nama
|
| 58 |
+
FROM harga_harian hh
|
| 59 |
+
JOIN pasar p ON p.id = hh.pasar_id
|
| 60 |
+
WHERE hh.komoditas_id = {komoditas_id}
|
| 61 |
+
""", engine)
|
| 62 |
+
|
| 63 |
+
# ββ WRITE OPERATIONS ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 64 |
+
|
| 65 |
+
def save_model_ml(data: dict):
|
| 66 |
+
"""Upsert ke tabel model_ml."""
|
| 67 |
+
with engine.begin() as conn:
|
| 68 |
+
conn.execute(text("""
|
| 69 |
+
INSERT INTO model_ml (
|
| 70 |
+
komoditas_id, nama_model, versi, deskripsi, file_path,
|
| 71 |
+
mape, rmse, mae, r2_score, confidence_level,
|
| 72 |
+
stabilitas, status_validasi, catatan_validasi,
|
| 73 |
+
tanggal_training, tanggal_evaluasi, is_active,
|
| 74 |
+
created_at, updated_at
|
| 75 |
+
) VALUES (
|
| 76 |
+
:komoditas_id, :nama_model, :versi, :deskripsi, :file_path,
|
| 77 |
+
:mape, :rmse, :mae, :r2_score, :confidence_level,
|
| 78 |
+
:stabilitas, :status_validasi, :catatan_validasi,
|
| 79 |
+
:tanggal_training, :tanggal_evaluasi, 1, NOW(), NOW()
|
| 80 |
+
)
|
| 81 |
+
ON DUPLICATE KEY UPDATE
|
| 82 |
+
nama_model = VALUES(nama_model),
|
| 83 |
+
versi = VALUES(versi),
|
| 84 |
+
deskripsi = VALUES(deskripsi),
|
| 85 |
+
file_path = VALUES(file_path),
|
| 86 |
+
mape = VALUES(mape),
|
| 87 |
+
rmse = VALUES(rmse),
|
| 88 |
+
mae = VALUES(mae),
|
| 89 |
+
r2_score = VALUES(r2_score),
|
| 90 |
+
confidence_level = VALUES(confidence_level),
|
| 91 |
+
stabilitas = VALUES(stabilitas),
|
| 92 |
+
status_validasi = VALUES(status_validasi),
|
| 93 |
+
catatan_validasi = VALUES(catatan_validasi),
|
| 94 |
+
tanggal_evaluasi = VALUES(tanggal_evaluasi),
|
| 95 |
+
updated_at = NOW()
|
| 96 |
+
"""), data)
|
| 97 |
+
|
| 98 |
+
with engine.connect() as conn:
|
| 99 |
+
return conn.execute(
|
| 100 |
+
text("SELECT id FROM model_ml WHERE komoditas_id=:kid ORDER BY updated_at DESC LIMIT 1"),
|
| 101 |
+
{'kid': data['komoditas_id']}
|
| 102 |
+
).scalar()
|
| 103 |
+
|
| 104 |
+
def save_hasil_prediksi(predictions: list, komoditas_id: int, pasar_id, model_id: int, meta: dict):
|
| 105 |
+
"""Hapus prediksi lama & insert 7 baris baru."""
|
| 106 |
+
with engine.begin() as conn:
|
| 107 |
+
conn.execute(text("""
|
| 108 |
+
DELETE FROM hasil_prediksi
|
| 109 |
+
WHERE komoditas_id = :kid AND pasar_id = :pid
|
| 110 |
+
AND tanggal_target >= CURDATE()
|
| 111 |
+
"""), {'kid': komoditas_id, 'pid': pasar_id})
|
| 112 |
+
|
| 113 |
+
for p in predictions:
|
| 114 |
+
conn.execute(text("""
|
| 115 |
+
INSERT INTO hasil_prediksi (
|
| 116 |
+
komoditas_id, pasar_id, model_id,
|
| 117 |
+
tanggal_prediksi, tanggal_target,
|
| 118 |
+
harga_prediksi, confidence_level,
|
| 119 |
+
model_name, mape, rmse, created_at
|
| 120 |
+
) VALUES (
|
| 121 |
+
:kid, :pid, :model_id,
|
| 122 |
+
CURDATE(), :tanggal_target,
|
| 123 |
+
:harga_prediksi, :confidence_level,
|
| 124 |
+
:model_name, :mape, :rmse, NOW()
|
| 125 |
+
)
|
| 126 |
+
"""), {
|
| 127 |
+
'kid' : komoditas_id,
|
| 128 |
+
'pid' : pasar_id,
|
| 129 |
+
'model_id' : model_id,
|
| 130 |
+
'tanggal_target' : p['tanggal'],
|
| 131 |
+
'harga_prediksi' : p['harga_prediksi'],
|
| 132 |
+
'confidence_level': meta['confidence_level'],
|
| 133 |
+
'model_name' : meta['nama_model'],
|
| 134 |
+
'mape' : meta['mape'],
|
| 135 |
+
'rmse' : meta['rmse'],
|
| 136 |
+
})
|
| 137 |
+
|
| 138 |
+
def save_ringkasan_prediksi(data: dict, komoditas_id: int, model_id: int):
|
| 139 |
+
"""Upsert ringkasan prediksi."""
|
| 140 |
+
with engine.begin() as conn:
|
| 141 |
+
conn.execute(text("""
|
| 142 |
+
INSERT INTO ringkasan_prediksi (
|
| 143 |
+
komoditas_id, model_id,
|
| 144 |
+
harga_min, harga_max, tren, confidence_level,
|
| 145 |
+
status_analisis, deskripsi_status,
|
| 146 |
+
tanggal_mulai, tanggal_akhir, created_at
|
| 147 |
+
) VALUES (
|
| 148 |
+
:komoditas_id, :model_id,
|
| 149 |
+
:harga_min, :harga_max, :tren, :confidence_level,
|
| 150 |
+
:status_analisis, :deskripsi_status,
|
| 151 |
+
:tanggal_mulai, :tanggal_akhir, NOW()
|
| 152 |
+
)
|
| 153 |
+
ON DUPLICATE KEY UPDATE
|
| 154 |
+
harga_min = VALUES(harga_min),
|
| 155 |
+
harga_max = VALUES(harga_max),
|
| 156 |
+
tren = VALUES(tren),
|
| 157 |
+
confidence_level = VALUES(confidence_level),
|
| 158 |
+
status_analisis = VALUES(status_analisis),
|
| 159 |
+
deskripsi_status = VALUES(deskripsi_status),
|
| 160 |
+
tanggal_mulai = VALUES(tanggal_mulai),
|
| 161 |
+
tanggal_akhir = VALUES(tanggal_akhir)
|
| 162 |
+
"""), {**data, 'komoditas_id': komoditas_id, 'model_id': model_id})
|
| 163 |
+
|
| 164 |
+
def save_insight_prediksi(insights: list, komoditas_id: int, model_id: int):
|
| 165 |
+
"""Hapus insight lama & insert baru."""
|
| 166 |
+
with engine.begin() as conn:
|
| 167 |
+
conn.execute(text(
|
| 168 |
+
"DELETE FROM insight_prediksi WHERE komoditas_id = :kid"
|
| 169 |
+
), {'kid': komoditas_id})
|
| 170 |
+
for ins in insights:
|
| 171 |
+
conn.execute(text("""
|
| 172 |
+
INSERT INTO insight_prediksi (
|
| 173 |
+
komoditas_id, model_id, konten, tipe, ikon,
|
| 174 |
+
urutan, is_active, created_at, updated_at
|
| 175 |
+
) VALUES (
|
| 176 |
+
:kid, :model_id, :konten, :tipe, :ikon,
|
| 177 |
+
:urutan, 1, NOW(), NOW()
|
| 178 |
+
)
|
| 179 |
+
"""), {
|
| 180 |
+
'kid' : komoditas_id,
|
| 181 |
+
'model_id': model_id,
|
| 182 |
+
'konten' : ins['konten'],
|
| 183 |
+
'tipe' : ins['tipe'],
|
| 184 |
+
'ikon' : ins['ikon'],
|
| 185 |
+
'urutan' : ins['urutan'],
|
| 186 |
+
})
|
| 187 |
+
|
| 188 |
+
def update_komoditas_volatilitas(komoditas_id: int, volatile: int, skor: float):
|
| 189 |
+
with engine.begin() as conn:
|
| 190 |
+
conn.execute(text("""
|
| 191 |
+
UPDATE komoditas SET volatile=:v, volatilitas_skor=:s, updated_at=NOW()
|
| 192 |
+
WHERE id=:kid
|
| 193 |
+
"""), {'v': volatile, 's': skor, 'kid': komoditas_id})
|
| 194 |
+
|
| 195 |
+
def save_all_model_results(all_results: dict, best_meta: dict, komoditas_id: int, pasar_id: int):
|
| 196 |
+
"""Save ALL model results to model_ml (not just best). Best gets is_active=1."""
|
| 197 |
+
best_name = best_meta.get('nama_model', '')
|
| 198 |
+
with engine.begin() as conn:
|
| 199 |
+
# Deactivate old models for this komoditas
|
| 200 |
+
conn.execute(text(
|
| 201 |
+
"UPDATE model_ml SET is_active=0 WHERE komoditas_id=:kid"
|
| 202 |
+
), {'kid': komoditas_id})
|
| 203 |
+
# Insert each model result
|
| 204 |
+
for model_name, metrics in all_results.items():
|
| 205 |
+
is_best = 1 if model_name == best_name or f"{model_name}_Tuned" == best_name else 0
|
| 206 |
+
|
| 207 |
+
# Hitung stabilitas sederhana untuk kolom ENUM agar tidak error
|
| 208 |
+
mape_val = metrics.get('mape', 0)
|
| 209 |
+
stabilitas_label = (
|
| 210 |
+
'optimal' if mape_val < 2 else
|
| 211 |
+
'baik' if mape_val < 5 else
|
| 212 |
+
'cukup' if mape_val < 10 else
|
| 213 |
+
'perlu_retrain'
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
conn.execute(text("""
|
| 217 |
+
INSERT INTO model_ml (
|
| 218 |
+
komoditas_id, nama_model, versi, deskripsi, file_path,
|
| 219 |
+
mape, rmse, mae, r2_score, confidence_level,
|
| 220 |
+
stabilitas, status_validasi, catatan_validasi,
|
| 221 |
+
tanggal_training, tanggal_evaluasi, is_active,
|
| 222 |
+
created_at, updated_at
|
| 223 |
+
) VALUES (
|
| 224 |
+
:komoditas_id, :nama_model, '1.0', :deskripsi, '',
|
| 225 |
+
:mape, :rmse, :mae, :r2_score, 0,
|
| 226 |
+
:stabilitas, 'terverifikasi', '',
|
| 227 |
+
CURDATE(), CURDATE(), :is_active, NOW(), NOW()
|
| 228 |
+
)
|
| 229 |
+
"""), {
|
| 230 |
+
'komoditas_id': komoditas_id,
|
| 231 |
+
'nama_model': model_name,
|
| 232 |
+
'deskripsi': f"Model {model_name} untuk komoditas ID {komoditas_id}",
|
| 233 |
+
'mape': round(mape_val, 4),
|
| 234 |
+
'rmse': round(metrics.get('rmse', 0), 4),
|
| 235 |
+
'mae': round(metrics.get('mae', 0), 4),
|
| 236 |
+
'r2_score': round(metrics.get('r2', 0), 4),
|
| 237 |
+
'stabilitas': stabilitas_label,
|
| 238 |
+
'is_active': is_best,
|
| 239 |
+
})
|
| 240 |
+
|
| 241 |
+
# ββ READ OPERATIONS FOR FRONTEND API ββββββββββββββββββββββββββββββββββββββββββ
|
| 242 |
+
|
| 243 |
+
def get_prediksi_data(komoditas_id: int):
|
| 244 |
+
"""Ambil semua data yang dibutuhkan frontend untuk satu komoditas."""
|
| 245 |
+
komoditas = pd.read_sql(f"""
|
| 246 |
+
SELECT k.*, kk.nama as kategori_nama
|
| 247 |
+
FROM komoditas k
|
| 248 |
+
JOIN kategori_komoditas kk ON kk.id = k.kategori_id
|
| 249 |
+
WHERE k.id = {komoditas_id}
|
| 250 |
+
""", engine).to_dict('records')
|
| 251 |
+
komoditas = komoditas[0] if komoditas else None
|
| 252 |
+
|
| 253 |
+
harga_terkini = pd.read_sql(f"""
|
| 254 |
+
SELECT hh.harga, hh.tanggal, p.nama as pasar_nama, p.id as pasar_id
|
| 255 |
+
FROM harga_harian hh
|
| 256 |
+
JOIN pasar p ON p.id = hh.pasar_id
|
| 257 |
+
WHERE hh.komoditas_id = {komoditas_id}
|
| 258 |
+
ORDER BY hh.tanggal DESC LIMIT 1
|
| 259 |
+
""", engine).to_dict('records')
|
| 260 |
+
harga_terkini = harga_terkini[0] if harga_terkini else None
|
| 261 |
+
|
| 262 |
+
# Best (active) model
|
| 263 |
+
model = pd.read_sql(f"""
|
| 264 |
+
SELECT * FROM model_ml WHERE komoditas_id={komoditas_id} AND is_active=1
|
| 265 |
+
ORDER BY updated_at DESC LIMIT 1
|
| 266 |
+
""", engine).to_dict('records')
|
| 267 |
+
model = model[0] if model else None
|
| 268 |
+
|
| 269 |
+
# ALL models for comparison (latest run)
|
| 270 |
+
all_models = pd.read_sql(f"""
|
| 271 |
+
SELECT nama_model, mape, rmse, mae, r2_score, is_active,
|
| 272 |
+
tanggal_training, created_at
|
| 273 |
+
FROM model_ml WHERE komoditas_id={komoditas_id}
|
| 274 |
+
AND DATE(created_at) = (
|
| 275 |
+
SELECT DATE(MAX(created_at)) FROM model_ml WHERE komoditas_id={komoditas_id}
|
| 276 |
+
)
|
| 277 |
+
ORDER BY rmse ASC
|
| 278 |
+
""", engine).to_dict('records')
|
| 279 |
+
|
| 280 |
+
ringkasan = pd.read_sql(f"""
|
| 281 |
+
SELECT * FROM ringkasan_prediksi WHERE komoditas_id={komoditas_id}
|
| 282 |
+
ORDER BY created_at DESC LIMIT 1
|
| 283 |
+
""", engine).to_dict('records')
|
| 284 |
+
ringkasan = ringkasan[0] if ringkasan else None
|
| 285 |
+
|
| 286 |
+
hasil = pd.read_sql(f"""
|
| 287 |
+
SELECT tanggal_target, harga_prediksi, confidence_level
|
| 288 |
+
FROM hasil_prediksi
|
| 289 |
+
WHERE komoditas_id={komoditas_id} AND tanggal_target >= CURDATE()
|
| 290 |
+
ORDER BY tanggal_target ASC
|
| 291 |
+
""", engine).to_dict('records')
|
| 292 |
+
|
| 293 |
+
insights = pd.read_sql(f"""
|
| 294 |
+
SELECT * FROM insight_prediksi
|
| 295 |
+
WHERE komoditas_id={komoditas_id} AND is_active=1
|
| 296 |
+
ORDER BY urutan ASC
|
| 297 |
+
""", engine).to_dict('records')
|
| 298 |
+
|
| 299 |
+
return {
|
| 300 |
+
'komoditas' : komoditas,
|
| 301 |
+
'harga_terkini' : harga_terkini,
|
| 302 |
+
'model' : model,
|
| 303 |
+
'all_models' : all_models,
|
| 304 |
+
'ringkasan' : ringkasan,
|
| 305 |
+
'prediksi_7hari': hasil,
|
| 306 |
+
'insights' : insights,
|
| 307 |
+
}
|
backend/app/logger.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
|
|
| 1 |
import logging
|
| 2 |
from datetime import datetime
|
| 3 |
from typing import List, Dict, Optional
|
| 4 |
|
| 5 |
class MemoryLogHandler(logging.Handler):
|
|
|
|
| 6 |
def __init__(self, capacity: int = 500):
|
| 7 |
super().__init__()
|
| 8 |
self.capacity = capacity
|
|
@@ -10,7 +12,6 @@ class MemoryLogHandler(logging.Handler):
|
|
| 10 |
|
| 11 |
def emit(self, record):
|
| 12 |
try:
|
| 13 |
-
# We want just the message or formatted message
|
| 14 |
msg = record.getMessage()
|
| 15 |
log_entry = {
|
| 16 |
"timestamp": datetime.fromtimestamp(record.created).isoformat(),
|
|
@@ -33,12 +34,42 @@ class MemoryLogHandler(logging.Handler):
|
|
| 33 |
def clear_logs(self):
|
| 34 |
self.logs.clear()
|
| 35 |
|
| 36 |
-
|
|
|
|
|
|
|
| 37 |
|
| 38 |
-
def get_app_logger(name: str):
|
| 39 |
-
logger
|
|
|
|
| 40 |
logger.setLevel(logging.INFO)
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
return logger
|
|
|
|
| 1 |
+
# logger.py β In-memory log handler for scraper and forecast log separation
|
| 2 |
import logging
|
| 3 |
from datetime import datetime
|
| 4 |
from typing import List, Dict, Optional
|
| 5 |
|
| 6 |
class MemoryLogHandler(logging.Handler):
|
| 7 |
+
"""Stores log entries in memory for API log streaming."""
|
| 8 |
def __init__(self, capacity: int = 500):
|
| 9 |
super().__init__()
|
| 10 |
self.capacity = capacity
|
|
|
|
| 12 |
|
| 13 |
def emit(self, record):
|
| 14 |
try:
|
|
|
|
| 15 |
msg = record.getMessage()
|
| 16 |
log_entry = {
|
| 17 |
"timestamp": datetime.fromtimestamp(record.created).isoformat(),
|
|
|
|
| 34 |
def clear_logs(self):
|
| 35 |
self.logs.clear()
|
| 36 |
|
| 37 |
+
# Separated handlers
|
| 38 |
+
scraper_memory_handler = MemoryLogHandler(capacity=500)
|
| 39 |
+
forecast_memory_handler = MemoryLogHandler(capacity=500)
|
| 40 |
|
| 41 |
+
def get_app_logger(name: str) -> logging.Logger:
|
| 42 |
+
"""Default app logger for scraping operations."""
|
| 43 |
+
logger = logging.getLogger(f"app.scraper.{name}")
|
| 44 |
logger.setLevel(logging.INFO)
|
| 45 |
+
|
| 46 |
+
# Add memory handler
|
| 47 |
+
if not any(h is scraper_memory_handler for h in logger.handlers):
|
| 48 |
+
logger.addHandler(scraper_memory_handler)
|
| 49 |
+
|
| 50 |
+
# Add console stream handler
|
| 51 |
+
if not any(isinstance(h, logging.StreamHandler) and not isinstance(h, MemoryLogHandler) for h in logger.handlers):
|
| 52 |
+
console = logging.StreamHandler()
|
| 53 |
+
console.setLevel(logging.INFO)
|
| 54 |
+
console.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] scraper.%(name)s: %(message)s"))
|
| 55 |
+
logger.addHandler(console)
|
| 56 |
+
|
| 57 |
+
return logger
|
| 58 |
+
|
| 59 |
+
def get_forecast_logger(name: str) -> logging.Logger:
|
| 60 |
+
"""Dedicated app logger for forecasting operations."""
|
| 61 |
+
logger = logging.getLogger(f"app.forecast.{name}")
|
| 62 |
+
logger.setLevel(logging.INFO)
|
| 63 |
+
|
| 64 |
+
# Add memory handler
|
| 65 |
+
if not any(h is forecast_memory_handler for h in logger.handlers):
|
| 66 |
+
logger.addHandler(forecast_memory_handler)
|
| 67 |
+
|
| 68 |
+
# Add console stream handler
|
| 69 |
+
if not any(isinstance(h, logging.StreamHandler) and not isinstance(h, MemoryLogHandler) for h in logger.handlers):
|
| 70 |
+
console = logging.StreamHandler()
|
| 71 |
+
console.setLevel(logging.INFO)
|
| 72 |
+
console.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] forecast.%(name)s: %(message)s"))
|
| 73 |
+
logger.addHandler(console)
|
| 74 |
+
|
| 75 |
return logger
|
backend/app/main.py
CHANGED
|
@@ -1,20 +1,41 @@
|
|
| 1 |
-
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
from fastapi.staticfiles import StaticFiles
|
|
|
|
| 4 |
from fastapi.responses import FileResponse
|
|
|
|
| 5 |
from pydantic import BaseModel
|
| 6 |
-
from typing import Optional
|
| 7 |
-
from app.scheduler import scraper_scheduler
|
| 8 |
-
from app.scraper import scraping_hari_ini, upload_to_mysql
|
| 9 |
-
from app.database import get_db_engine
|
| 10 |
-
from app.config import get_settings
|
| 11 |
-
from app.logger import get_app_logger, memory_handler
|
| 12 |
-
import os
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
settings = get_settings()
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
app = FastAPI(title="Sikomo Scraper API", version="
|
| 18 |
|
| 19 |
app.add_middleware(
|
| 20 |
CORSMiddleware,
|
|
@@ -24,85 +45,200 @@ app.add_middleware(
|
|
| 24 |
allow_headers=["*"],
|
| 25 |
)
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
label: Optional[str] = ""
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
| 33 |
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
@app.on_event("startup")
|
| 38 |
async def startup_event():
|
| 39 |
-
logger.info("π Memulai Sikomo Scraper API Server...")
|
|
|
|
| 40 |
scraper_scheduler.start_all()
|
|
|
|
| 41 |
logger.info("π API Server siap menerima permintaan.")
|
| 42 |
|
| 43 |
@app.on_event("shutdown")
|
| 44 |
async def shutdown_event():
|
| 45 |
scraper_scheduler.stop_all()
|
|
|
|
| 46 |
logger.info("π API Server dimatikan.")
|
| 47 |
|
| 48 |
-
#
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
return scraper_scheduler.get_status()
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
return scraper_scheduler.list_schedules()
|
| 70 |
|
| 71 |
-
@app.post("/schedules/add")
|
| 72 |
-
async def
|
| 73 |
try:
|
| 74 |
job_id = scraper_scheduler.add_schedule(schedule.cron_expression, label=schedule.label)
|
| 75 |
return {
|
| 76 |
"success": True,
|
| 77 |
"job_id": job_id,
|
| 78 |
-
"message": f"Jadwal berhasil ditambahkan: {schedule.cron_expression}"
|
| 79 |
}
|
| 80 |
except Exception as e:
|
| 81 |
raise HTTPException(status_code=400, detail=str(e))
|
| 82 |
|
| 83 |
-
@app.delete("/schedules/{job_id}/remove")
|
| 84 |
-
async def
|
| 85 |
try:
|
| 86 |
scraper_scheduler.remove_schedule(job_id)
|
| 87 |
-
return {"success": True, "message": "Jadwal berhasil dihapus"}
|
| 88 |
except KeyError:
|
| 89 |
raise HTTPException(status_code=404, detail="Jadwal tidak ditemukan")
|
| 90 |
except Exception as e:
|
| 91 |
raise HTTPException(status_code=400, detail=str(e))
|
| 92 |
|
| 93 |
-
|
| 94 |
-
@app.post("/schedule/update")
|
| 95 |
-
async def update_schedule(schedule: ScheduleUpdate):
|
| 96 |
-
try:
|
| 97 |
-
# Clear existing and add new
|
| 98 |
-
scraper_scheduler.stop_all()
|
| 99 |
-
scraper_scheduler.start_all()
|
| 100 |
-
job_id = scraper_scheduler.add_schedule(schedule.cron_expression, label="Jadwal Utama")
|
| 101 |
-
return {"success": True, "message": f"Jadwal diupdate ke: {schedule.cron_expression}"}
|
| 102 |
-
except Exception as e:
|
| 103 |
-
raise HTTPException(status_code=400, detail=str(e))
|
| 104 |
-
|
| 105 |
-
@app.post("/scrape/manual")
|
| 106 |
def manual_scrape():
|
| 107 |
logger.info("β‘ Permintaan manual scraping dipicu via API...")
|
| 108 |
try:
|
|
@@ -111,12 +247,12 @@ def manual_scrape():
|
|
| 111 |
stats = upload_to_mysql(df, engine)
|
| 112 |
return {"success": True, "stats": stats}
|
| 113 |
except Exception as e:
|
| 114 |
-
logger.error(f"β Manual
|
| 115 |
raise HTTPException(status_code=500, detail=str(e))
|
| 116 |
|
| 117 |
-
@app.post("/
|
| 118 |
def custom_scrape(req: CustomScrapeRequest):
|
| 119 |
-
logger.info(f"β‘ Permintaan
|
| 120 |
try:
|
| 121 |
from datetime import datetime
|
| 122 |
target_date = datetime.strptime(req.date, "%Y-%m-%d")
|
|
@@ -127,28 +263,212 @@ def custom_scrape(req: CustomScrapeRequest):
|
|
| 127 |
except ValueError:
|
| 128 |
raise HTTPException(status_code=400, detail="Format tanggal salah. Gunakan YYYY-MM-DD.")
|
| 129 |
except Exception as e:
|
| 130 |
-
logger.error(f"β
|
| 131 |
raise HTTPException(status_code=500, detail=str(e))
|
| 132 |
|
| 133 |
-
@app.post("/
|
| 134 |
def stop_manual_scrape():
|
| 135 |
-
from app.scraper import stop_event
|
| 136 |
logger.warning("π Menerima permintaan penghentian scraping manual...")
|
| 137 |
-
|
| 138 |
-
return {"success": True, "message": "Permintaan penghentian dikirim"}
|
| 139 |
|
| 140 |
-
@app.post("/schedule/start")
|
| 141 |
-
async def
|
| 142 |
scraper_scheduler.start_all()
|
| 143 |
-
return {"success": True, "message": "Scheduler
|
| 144 |
|
| 145 |
-
@app.post("/schedule/stop")
|
| 146 |
-
async def
|
| 147 |
scraper_scheduler.stop_all()
|
| 148 |
-
return {"success": True, "message": "Scheduler
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
|
| 150 |
-
#
|
| 151 |
-
# Mount static folder if exists (in Docker production or local test)
|
| 152 |
static_dirs = [
|
| 153 |
"/app/static",
|
| 154 |
os.path.join(os.path.dirname(os.path.dirname(__file__)), "static"),
|
|
@@ -166,6 +486,6 @@ if not mounted:
|
|
| 166 |
@app.get("/")
|
| 167 |
async def root_fallback():
|
| 168 |
return {
|
| 169 |
-
"message": "Sikomo Scraper API Server beroperasi normal.",
|
| 170 |
"note": "Frontend static files belum di-build/mount ke direktori static."
|
| 171 |
}
|
|
|
|
| 1 |
+
# main.py β FastAPI: Refactored with modular config, logger, scheduler
|
| 2 |
+
import os
|
| 3 |
+
import threading
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from typing import Optional, List
|
| 6 |
+
from fastapi import FastAPI, HTTPException, Depends, BackgroundTasks, Query
|
| 7 |
from fastapi.staticfiles import StaticFiles
|
| 8 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 9 |
from fastapi.responses import FileResponse
|
| 10 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 11 |
from pydantic import BaseModel
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
+
# Modular App Imports
|
| 14 |
+
from app.config import get_settings, runtime_config
|
| 15 |
+
from app.logger import get_app_logger, get_forecast_logger, scraper_memory_handler, forecast_memory_handler
|
| 16 |
+
from app.scheduler import scraper_scheduler, forecast_scheduler
|
| 17 |
+
from app.scraper import scraping_hari_ini, upload_to_mysql, stop_event as scraper_stop_event
|
| 18 |
+
from app.database import (
|
| 19 |
+
get_db_engine,
|
| 20 |
+
get_all_komoditas,
|
| 21 |
+
get_harga_harian,
|
| 22 |
+
get_pasar_list,
|
| 23 |
+
get_prediksi_data,
|
| 24 |
+
save_model_ml,
|
| 25 |
+
save_hasil_prediksi,
|
| 26 |
+
save_ringkasan_prediksi,
|
| 27 |
+
save_insight_prediksi,
|
| 28 |
+
update_komoditas_volatilitas,
|
| 29 |
+
save_all_model_results,
|
| 30 |
+
)
|
| 31 |
+
from app.ml_pipeline import run_pipeline
|
| 32 |
+
from sqlalchemy import text
|
| 33 |
+
|
| 34 |
settings = get_settings()
|
| 35 |
+
logger = get_app_logger("main")
|
| 36 |
+
forecast_logger = get_forecast_logger("main")
|
| 37 |
|
| 38 |
+
app = FastAPI(title="Sikomo Scraper & ML Portal API", version="2.0.0")
|
| 39 |
|
| 40 |
app.add_middleware(
|
| 41 |
CORSMiddleware,
|
|
|
|
| 45 |
allow_headers=["*"],
|
| 46 |
)
|
| 47 |
|
| 48 |
+
# ββ Authentication Dependency ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 49 |
+
security = HTTPBearer()
|
|
|
|
| 50 |
|
| 51 |
+
def verify_token(creds: HTTPAuthorizationCredentials = Depends(security)):
|
| 52 |
+
if creds.credentials != settings.API_SECRET_KEY:
|
| 53 |
+
raise HTTPException(status_code=401, detail="Unauthorized")
|
| 54 |
+
return creds.credentials
|
| 55 |
|
| 56 |
+
# ββ ML Forecasting State ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 57 |
+
running_forecast_tasks: dict = {} # komoditas_id -> True/False
|
| 58 |
+
forecast_stop_event = threading.Event() # For aborting forecast
|
| 59 |
+
|
| 60 |
+
def run_forecast_for_komoditas(komoditas_id: int, komoditas_nama: str):
|
| 61 |
+
"""Run forecast for a single komoditas. Tracks running state."""
|
| 62 |
+
if running_forecast_tasks.get(komoditas_id):
|
| 63 |
+
forecast_logger.warning(f"β οΈ {komoditas_nama} sudah sedang berjalan, skip.")
|
| 64 |
+
return
|
| 65 |
+
|
| 66 |
+
running_forecast_tasks[komoditas_id] = True
|
| 67 |
+
try:
|
| 68 |
+
if forecast_stop_event.is_set():
|
| 69 |
+
forecast_logger.warning(f"β Forecast dihentikan sebelum {komoditas_nama}.")
|
| 70 |
+
return
|
| 71 |
+
|
| 72 |
+
df = get_harga_harian(komoditas_id)
|
| 73 |
+
if df.empty or len(df) < 60:
|
| 74 |
+
forecast_logger.warning(f"β οΈ {komoditas_nama}: data kurang ({len(df)} baris)")
|
| 75 |
+
return
|
| 76 |
+
|
| 77 |
+
price_cols = [c for c in df.columns if c != 'date']
|
| 78 |
+
target_col = price_cols[0]
|
| 79 |
+
|
| 80 |
+
pasar_df = get_pasar_list(komoditas_id)
|
| 81 |
+
pasar_id = int(pasar_df.iloc[0]['id']) if not pasar_df.empty else 1
|
| 82 |
+
|
| 83 |
+
def log_cb(msg):
|
| 84 |
+
forecast_logger.info(f"[{komoditas_nama}] {msg}")
|
| 85 |
+
|
| 86 |
+
result = run_pipeline(
|
| 87 |
+
komoditas_id=komoditas_id,
|
| 88 |
+
komoditas_nama=komoditas_nama,
|
| 89 |
+
df=df,
|
| 90 |
+
target_market=target_col,
|
| 91 |
+
pasar_id=pasar_id,
|
| 92 |
+
n_trials=runtime_config.optuna_trials,
|
| 93 |
+
forecast_days=runtime_config.forecast_days,
|
| 94 |
+
log_cb=log_cb,
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
# Save results to DB
|
| 98 |
+
meta = result['metadata']
|
| 99 |
+
labels = result['labels']
|
| 100 |
+
preds = result['predictions']
|
| 101 |
+
all_model_results = result.get('model_comparison', {})
|
| 102 |
+
|
| 103 |
+
# Save all model results
|
| 104 |
+
save_all_model_results(all_model_results, meta, komoditas_id, pasar_id)
|
| 105 |
+
|
| 106 |
+
# Save best model specifically
|
| 107 |
+
model_id = save_model_ml(meta)
|
| 108 |
+
save_hasil_prediksi(preds, komoditas_id, pasar_id, model_id, meta)
|
| 109 |
+
save_ringkasan_prediksi({
|
| 110 |
+
'harga_min': labels['harga_min'],
|
| 111 |
+
'harga_max': labels['harga_max'],
|
| 112 |
+
'tren': labels['tren'],
|
| 113 |
+
'confidence_level': labels['confidence_level'],
|
| 114 |
+
'status_analisis': labels['status_analisis']['judul'],
|
| 115 |
+
'deskripsi_status': labels['status_analisis']['deskripsi'],
|
| 116 |
+
'tanggal_mulai': preds[0]['tanggal'],
|
| 117 |
+
'tanggal_akhir': preds[-1]['tanggal'],
|
| 118 |
+
}, komoditas_id, model_id)
|
| 119 |
+
save_insight_prediksi(labels['insights'], komoditas_id, model_id)
|
| 120 |
+
cv = meta.get('data_quality', {}).get('cv', 0)
|
| 121 |
+
update_komoditas_volatilitas(komoditas_id, 1 if cv >= 5 else 0, cv)
|
| 122 |
|
| 123 |
+
forecast_logger.info(f"β
{komoditas_nama} selesai! Best: {meta['nama_model']} MAPE={meta['mape']:.2f}%")
|
| 124 |
+
|
| 125 |
+
except Exception as e:
|
| 126 |
+
forecast_logger.error(f"β {komoditas_nama} gagal: {e}")
|
| 127 |
+
finally:
|
| 128 |
+
running_forecast_tasks[komoditas_id] = False
|
| 129 |
+
|
| 130 |
+
def auto_forecast_all():
|
| 131 |
+
"""Run forecast for ALL komoditas (scheduled or manual run-all)."""
|
| 132 |
+
forecast_logger.info("π Auto forecast semua komoditas dimulai...")
|
| 133 |
+
forecast_stop_event.clear()
|
| 134 |
+
try:
|
| 135 |
+
komoditas_list = get_all_komoditas()
|
| 136 |
+
total = len(komoditas_list)
|
| 137 |
+
for idx, (_, row) in enumerate(komoditas_list.iterrows()):
|
| 138 |
+
if forecast_stop_event.is_set():
|
| 139 |
+
forecast_logger.warning("β Forecast dihentikan oleh user.")
|
| 140 |
+
break
|
| 141 |
+
forecast_logger.info(f"π¦ [{idx+1}/{total}] Memproses {row['nama']}...")
|
| 142 |
+
run_forecast_for_komoditas(int(row['id']), row['nama'])
|
| 143 |
+
forecast_logger.info("π Auto forecast selesai.")
|
| 144 |
+
except Exception as e:
|
| 145 |
+
forecast_logger.error(f"β Auto forecast error: {e}")
|
| 146 |
+
|
| 147 |
+
# Register forecast job with scheduler
|
| 148 |
+
forecast_scheduler.set_forecast_job(auto_forecast_all)
|
| 149 |
+
|
| 150 |
+
# ββ API Startup & Shutdown βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 151 |
@app.on_event("startup")
|
| 152 |
async def startup_event():
|
| 153 |
+
logger.info("π Memulai Sikomo Scraper & Forecast API Server...")
|
| 154 |
+
os.makedirs(runtime_config.model_dir, exist_ok=True)
|
| 155 |
scraper_scheduler.start_all()
|
| 156 |
+
forecast_scheduler.start_all(default_schedules=runtime_config.default_schedules)
|
| 157 |
logger.info("π API Server siap menerima permintaan.")
|
| 158 |
|
| 159 |
@app.on_event("shutdown")
|
| 160 |
async def shutdown_event():
|
| 161 |
scraper_scheduler.stop_all()
|
| 162 |
+
forecast_scheduler.stop_all()
|
| 163 |
logger.info("π API Server dimatikan.")
|
| 164 |
|
| 165 |
+
# ββ Request Validation Models ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 166 |
+
class ScheduleAdd(BaseModel):
|
| 167 |
+
cron_expression: str
|
| 168 |
+
label: Optional[str] = ""
|
| 169 |
|
| 170 |
+
class CustomScrapeRequest(BaseModel):
|
| 171 |
+
date: str # Format YYYY-MM-DD
|
|
|
|
| 172 |
|
| 173 |
+
class ForecastRequest(BaseModel):
|
| 174 |
+
komoditas_id: int
|
| 175 |
+
|
| 176 |
+
class InsightUpdate(BaseModel):
|
| 177 |
+
konten: str
|
| 178 |
+
tipe: str
|
| 179 |
+
ikon: str
|
| 180 |
+
urutan: int
|
| 181 |
+
|
| 182 |
+
class RingkasanUpdate(BaseModel):
|
| 183 |
+
status_analisis: Optional[str] = None
|
| 184 |
+
deskripsi_status: Optional[str] = None
|
| 185 |
+
tren: Optional[str] = None
|
| 186 |
+
|
| 187 |
+
class ModelUpdate(BaseModel):
|
| 188 |
+
deskripsi: Optional[str] = None
|
| 189 |
+
catatan_validasi: Optional[str] = None
|
| 190 |
+
|
| 191 |
+
class RuntimeConfigUpdate(BaseModel):
|
| 192 |
+
key: str
|
| 193 |
+
value: str
|
| 194 |
+
|
| 195 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 196 |
+
# ENDPOINTS
|
| 197 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 198 |
|
| 199 |
+
# ββ Auth ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 200 |
+
@app.post("/auth/login")
|
| 201 |
+
def login(body: dict):
|
| 202 |
+
if body.get('password') != settings.DASHBOARD_PASSWORD:
|
| 203 |
+
raise HTTPException(status_code=401, detail="Password salah")
|
| 204 |
+
return {"token": settings.API_SECRET_KEY}
|
| 205 |
|
| 206 |
+
# ββ Portal Scraper API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 207 |
+
@app.get("/scraper/status", dependencies=[Depends(verify_token)])
|
| 208 |
+
async def get_scraper_status():
|
| 209 |
+
return scraper_scheduler.get_status()
|
| 210 |
+
|
| 211 |
+
@app.get("/scraper/logs", dependencies=[Depends(verify_token)])
|
| 212 |
+
async def get_scraper_logs(limit: int = Query(100, le=500), level: Optional[str] = None):
|
| 213 |
+
return scraper_memory_handler.get_logs(limit=limit, level=level)
|
| 214 |
+
|
| 215 |
+
@app.get("/scraper/schedules", dependencies=[Depends(verify_token)])
|
| 216 |
+
async def get_scraper_schedules():
|
| 217 |
return scraper_scheduler.list_schedules()
|
| 218 |
|
| 219 |
+
@app.post("/scraper/schedules/add", dependencies=[Depends(verify_token)])
|
| 220 |
+
async def add_scraper_schedule(schedule: ScheduleAdd):
|
| 221 |
try:
|
| 222 |
job_id = scraper_scheduler.add_schedule(schedule.cron_expression, label=schedule.label)
|
| 223 |
return {
|
| 224 |
"success": True,
|
| 225 |
"job_id": job_id,
|
| 226 |
+
"message": f"Jadwal scraper berhasil ditambahkan: {schedule.cron_expression}"
|
| 227 |
}
|
| 228 |
except Exception as e:
|
| 229 |
raise HTTPException(status_code=400, detail=str(e))
|
| 230 |
|
| 231 |
+
@app.delete("/scraper/schedules/{job_id}/remove", dependencies=[Depends(verify_token)])
|
| 232 |
+
async def remove_scraper_schedule(job_id: str):
|
| 233 |
try:
|
| 234 |
scraper_scheduler.remove_schedule(job_id)
|
| 235 |
+
return {"success": True, "message": "Jadwal scraper berhasil dihapus"}
|
| 236 |
except KeyError:
|
| 237 |
raise HTTPException(status_code=404, detail="Jadwal tidak ditemukan")
|
| 238 |
except Exception as e:
|
| 239 |
raise HTTPException(status_code=400, detail=str(e))
|
| 240 |
|
| 241 |
+
@app.post("/scraper/run/manual", dependencies=[Depends(verify_token)])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
def manual_scrape():
|
| 243 |
logger.info("β‘ Permintaan manual scraping dipicu via API...")
|
| 244 |
try:
|
|
|
|
| 247 |
stats = upload_to_mysql(df, engine)
|
| 248 |
return {"success": True, "stats": stats}
|
| 249 |
except Exception as e:
|
| 250 |
+
logger.error(f"β Manual scraping gagal: {e}")
|
| 251 |
raise HTTPException(status_code=500, detail=str(e))
|
| 252 |
|
| 253 |
+
@app.post("/scraper/run/custom", dependencies=[Depends(verify_token)])
|
| 254 |
def custom_scrape(req: CustomScrapeRequest):
|
| 255 |
+
logger.info(f"β‘ Permintaan scraping custom untuk tanggal {req.date} dipicu via API...")
|
| 256 |
try:
|
| 257 |
from datetime import datetime
|
| 258 |
target_date = datetime.strptime(req.date, "%Y-%m-%d")
|
|
|
|
| 263 |
except ValueError:
|
| 264 |
raise HTTPException(status_code=400, detail="Format tanggal salah. Gunakan YYYY-MM-DD.")
|
| 265 |
except Exception as e:
|
| 266 |
+
logger.error(f"β Scraping custom gagal: {e}")
|
| 267 |
raise HTTPException(status_code=500, detail=str(e))
|
| 268 |
|
| 269 |
+
@app.post("/scraper/run/stop", dependencies=[Depends(verify_token)])
|
| 270 |
def stop_manual_scrape():
|
|
|
|
| 271 |
logger.warning("π Menerima permintaan penghentian scraping manual...")
|
| 272 |
+
scraper_stop_event.set()
|
| 273 |
+
return {"success": True, "message": "Permintaan penghentian scraper dikirim"}
|
| 274 |
|
| 275 |
+
@app.post("/scraper/schedule/start", dependencies=[Depends(verify_token)])
|
| 276 |
+
async def start_scraper_scheduler():
|
| 277 |
scraper_scheduler.start_all()
|
| 278 |
+
return {"success": True, "message": "Scheduler scraper diaktifkan"}
|
| 279 |
|
| 280 |
+
@app.post("/scraper/schedule/stop", dependencies=[Depends(verify_token)])
|
| 281 |
+
async def stop_scraper_scheduler():
|
| 282 |
scraper_scheduler.stop_all()
|
| 283 |
+
return {"success": True, "message": "Scheduler scraper dimatikan"}
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
# ββ Portal Forecast API βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 287 |
+
@app.get("/forecast/status", dependencies=[Depends(verify_token)])
|
| 288 |
+
def get_forecast_status():
|
| 289 |
+
sched = forecast_scheduler.get_status()
|
| 290 |
+
return {
|
| 291 |
+
**sched,
|
| 292 |
+
"running_tasks": {k: v for k, v in running_forecast_tasks.items() if v},
|
| 293 |
+
"any_forecast_running": any(running_forecast_tasks.values()),
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
@app.get("/forecast/logs", dependencies=[Depends(verify_token)])
|
| 297 |
+
def get_forecast_logs(limit: int = Query(200, le=500), level: Optional[str] = None):
|
| 298 |
+
return forecast_memory_handler.get_logs(limit=limit, level=level)
|
| 299 |
+
|
| 300 |
+
@app.post("/forecast/clear-logs", dependencies=[Depends(verify_token)])
|
| 301 |
+
def clear_forecast_logs():
|
| 302 |
+
forecast_memory_handler.clear_logs()
|
| 303 |
+
return {"success": True, "message": "Log forecast berhasil dihapus."}
|
| 304 |
+
|
| 305 |
+
@app.get("/forecast/schedules", dependencies=[Depends(verify_token)])
|
| 306 |
+
def list_forecast_schedules():
|
| 307 |
+
return forecast_scheduler.list_schedules()
|
| 308 |
+
|
| 309 |
+
@app.post("/forecast/schedules/add", dependencies=[Depends(verify_token)])
|
| 310 |
+
def add_forecast_schedule(schedule: ScheduleAdd):
|
| 311 |
+
try:
|
| 312 |
+
job_id = forecast_scheduler.add_schedule(
|
| 313 |
+
schedule.cron_expression, label=schedule.label
|
| 314 |
+
)
|
| 315 |
+
return {
|
| 316 |
+
"success": True,
|
| 317 |
+
"job_id": job_id,
|
| 318 |
+
"message": f"Jadwal forecast berhasil ditambahkan: {schedule.cron_expression}",
|
| 319 |
+
}
|
| 320 |
+
except Exception as e:
|
| 321 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 322 |
+
|
| 323 |
+
@app.delete("/forecast/schedules/{job_id}/remove", dependencies=[Depends(verify_token)])
|
| 324 |
+
def remove_forecast_schedule(job_id: str):
|
| 325 |
+
try:
|
| 326 |
+
forecast_scheduler.remove_schedule(job_id)
|
| 327 |
+
return {"success": True, "message": "Jadwal forecast berhasil dihapus."}
|
| 328 |
+
except KeyError:
|
| 329 |
+
raise HTTPException(status_code=404, detail="Jadwal tidak ditemukan")
|
| 330 |
+
except Exception as e:
|
| 331 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 332 |
+
|
| 333 |
+
@app.post("/forecast/schedule/start", dependencies=[Depends(verify_token)])
|
| 334 |
+
def start_forecast_scheduler():
|
| 335 |
+
forecast_scheduler.start_all(default_schedules=runtime_config.default_schedules)
|
| 336 |
+
return {"success": True, "message": "Scheduler forecast diaktifkan."}
|
| 337 |
+
|
| 338 |
+
@app.post("/forecast/schedule/stop", dependencies=[Depends(verify_token)])
|
| 339 |
+
def stop_forecast_scheduler():
|
| 340 |
+
forecast_scheduler.stop_all()
|
| 341 |
+
return {"success": True, "message": "Scheduler forecast dimatikan."}
|
| 342 |
+
|
| 343 |
+
@app.get("/forecast/komoditas", dependencies=[Depends(verify_token)])
|
| 344 |
+
def list_forecast_komoditas():
|
| 345 |
+
df = get_all_komoditas()
|
| 346 |
+
return df.to_dict('records')
|
| 347 |
+
|
| 348 |
+
@app.get("/forecast/prediksi/{komoditas_id}", dependencies=[Depends(verify_token)])
|
| 349 |
+
def get_forecast_prediksi(komoditas_id: int):
|
| 350 |
+
return get_prediksi_data(komoditas_id)
|
| 351 |
+
|
| 352 |
+
@app.post("/forecast/run", dependencies=[Depends(verify_token)])
|
| 353 |
+
def run_forecast_manual(req: ForecastRequest, bg: BackgroundTasks):
|
| 354 |
+
"""Run forecast for a SINGLE komoditas."""
|
| 355 |
+
if running_forecast_tasks.get(req.komoditas_id):
|
| 356 |
+
raise HTTPException(409, f"Komoditas ID {req.komoditas_id} sedang diproses.")
|
| 357 |
+
|
| 358 |
+
komoditas_list = get_all_komoditas()
|
| 359 |
+
row = komoditas_list[komoditas_list['id'] == req.komoditas_id]
|
| 360 |
+
if row.empty:
|
| 361 |
+
raise HTTPException(404, "Komoditas tidak ditemukan")
|
| 362 |
+
nama = row.iloc[0]['nama']
|
| 363 |
+
forecast_stop_event.clear()
|
| 364 |
+
bg.add_task(run_forecast_for_komoditas, req.komoditas_id, nama)
|
| 365 |
+
return {"message": f"Forecast untuk {nama} dimulai di background."}
|
| 366 |
+
|
| 367 |
+
@app.post("/forecast/run-all", dependencies=[Depends(verify_token)])
|
| 368 |
+
def run_forecast_all(bg: BackgroundTasks):
|
| 369 |
+
"""Run forecast for ALL komoditas."""
|
| 370 |
+
if any(running_forecast_tasks.values()):
|
| 371 |
+
raise HTTPException(409, "Ada forecast yang sedang berjalan.")
|
| 372 |
+
forecast_stop_event.clear()
|
| 373 |
+
bg.add_task(auto_forecast_all)
|
| 374 |
+
return {"message": "Forecast semua komoditas dimulai."}
|
| 375 |
+
|
| 376 |
+
@app.post("/forecast/stop", dependencies=[Depends(verify_token)])
|
| 377 |
+
def stop_forecast():
|
| 378 |
+
"""Abort running forecast."""
|
| 379 |
+
forecast_logger.warning("π Menerima permintaan penghentian forecast...")
|
| 380 |
+
forecast_stop_event.set()
|
| 381 |
+
return {"success": True, "message": "Permintaan penghentian dikirim."}
|
| 382 |
+
|
| 383 |
+
@app.get("/forecast/settings", dependencies=[Depends(verify_token)])
|
| 384 |
+
def get_runtime_settings():
|
| 385 |
+
return runtime_config.to_dict()
|
| 386 |
+
|
| 387 |
+
@app.post("/forecast/settings", dependencies=[Depends(verify_token)])
|
| 388 |
+
def update_runtime_settings(body: RuntimeConfigUpdate):
|
| 389 |
+
try:
|
| 390 |
+
runtime_config.update(body.key, body.value)
|
| 391 |
+
return {"message": f"{body.key} berhasil diperbarui ke '{body.value}'."}
|
| 392 |
+
except ValueError as e:
|
| 393 |
+
raise HTTPException(400, str(e))
|
| 394 |
+
|
| 395 |
+
# ββ CRUD Forecast Insights, Ringkasan, Model ββββββββββββββββββββββββββββββββββ
|
| 396 |
+
@app.get("/forecast/insight/{komoditas_id}", dependencies=[Depends(verify_token)])
|
| 397 |
+
def get_insights(komoditas_id: int):
|
| 398 |
+
engine = get_db_engine()
|
| 399 |
+
with engine.connect() as conn:
|
| 400 |
+
rows = conn.execute(
|
| 401 |
+
text("SELECT * FROM insight_prediksi WHERE komoditas_id=:kid ORDER BY urutan"),
|
| 402 |
+
{'kid': komoditas_id}
|
| 403 |
+
).fetchall()
|
| 404 |
+
return [dict(r._mapping) for r in rows]
|
| 405 |
+
|
| 406 |
+
@app.put("/forecast/insight/{insight_id}", dependencies=[Depends(verify_token)])
|
| 407 |
+
def update_insight(insight_id: int, body: InsightUpdate):
|
| 408 |
+
engine = get_db_engine()
|
| 409 |
+
with engine.begin() as conn:
|
| 410 |
+
conn.execute(text("""
|
| 411 |
+
UPDATE insight_prediksi
|
| 412 |
+
SET konten=:konten, tipe=:tipe, ikon=:ikon, urutan=:urutan, updated_at=NOW()
|
| 413 |
+
WHERE id=:id
|
| 414 |
+
"""), {**body.dict(), 'id': insight_id})
|
| 415 |
+
return {"message": "Insight diperbarui."}
|
| 416 |
+
|
| 417 |
+
@app.post("/forecast/insight/{komoditas_id}", dependencies=[Depends(verify_token)])
|
| 418 |
+
def add_insight(komoditas_id: int, body: InsightUpdate):
|
| 419 |
+
engine = get_db_engine()
|
| 420 |
+
with engine.begin() as conn:
|
| 421 |
+
conn.execute(text("""
|
| 422 |
+
INSERT INTO insight_prediksi (komoditas_id, konten, tipe, ikon, urutan, is_active, created_at, updated_at)
|
| 423 |
+
VALUES (:kid, :konten, :tipe, :ikon, :urutan, 1, NOW(), NOW())
|
| 424 |
+
"""), {**body.dict(), 'kid': komoditas_id})
|
| 425 |
+
return {"message": "Insight ditambahkan."}
|
| 426 |
+
|
| 427 |
+
@app.delete("/forecast/insight/{insight_id}", dependencies=[Depends(verify_token)])
|
| 428 |
+
def delete_insight(insight_id: int):
|
| 429 |
+
engine = get_db_engine()
|
| 430 |
+
with engine.begin() as conn:
|
| 431 |
+
conn.execute(text("DELETE FROM insight_prediksi WHERE id=:id"), {'id': insight_id})
|
| 432 |
+
return {"message": "Insight dihapus."}
|
| 433 |
+
|
| 434 |
+
@app.put("/forecast/ringkasan/{komoditas_id}", dependencies=[Depends(verify_token)])
|
| 435 |
+
def update_ringkasan(komoditas_id: int, body: RingkasanUpdate):
|
| 436 |
+
engine = get_db_engine()
|
| 437 |
+
updates = {k: v for k, v in body.dict().items() if v is not None}
|
| 438 |
+
if not updates:
|
| 439 |
+
raise HTTPException(400, "Tidak ada field yang diupdate.")
|
| 440 |
+
set_clause = ', '.join([f"{k}=:{k}" for k in updates])
|
| 441 |
+
with engine.begin() as conn:
|
| 442 |
+
conn.execute(
|
| 443 |
+
text(f"UPDATE ringkasan_prediksi SET {set_clause} WHERE komoditas_id=:kid ORDER BY created_at DESC LIMIT 1"),
|
| 444 |
+
{**updates, 'kid': komoditas_id}
|
| 445 |
+
)
|
| 446 |
+
return {"message": "Ringkasan diperbarui."}
|
| 447 |
+
|
| 448 |
+
@app.put("/forecast/model-ml/{komoditas_id}", dependencies=[Depends(verify_token)])
|
| 449 |
+
def update_model_ml(komoditas_id: int, body: ModelUpdate):
|
| 450 |
+
engine = get_db_engine()
|
| 451 |
+
updates = {k: v for k, v in body.dict().items() if v is not None}
|
| 452 |
+
if not updates:
|
| 453 |
+
raise HTTPException(400, "Tidak ada field yang diupdate.")
|
| 454 |
+
set_clause = ', '.join([f"{k}=:{k}" for k in updates])
|
| 455 |
+
with engine.begin() as conn:
|
| 456 |
+
conn.execute(
|
| 457 |
+
text(f"UPDATE model_ml SET {set_clause}, updated_at=NOW() WHERE komoditas_id=:kid AND is_active=1"),
|
| 458 |
+
{**updates, 'kid': komoditas_id}
|
| 459 |
+
)
|
| 460 |
+
return {"message": "Model ML diperbarui."}
|
| 461 |
+
|
| 462 |
+
# ββ Debug Endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 463 |
+
@app.get("/debug/static", dependencies=[Depends(verify_token)])
|
| 464 |
+
def debug_static():
|
| 465 |
+
files = []
|
| 466 |
+
for root, dirs, filenames in os.walk("/app/static"):
|
| 467 |
+
for f in filenames:
|
| 468 |
+
files.append(os.path.relpath(os.path.join(root, f), "/app/static"))
|
| 469 |
+
return {"files": sorted(files)}
|
| 470 |
|
| 471 |
+
# ββ SPA Serving fallback ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 472 |
static_dirs = [
|
| 473 |
"/app/static",
|
| 474 |
os.path.join(os.path.dirname(os.path.dirname(__file__)), "static"),
|
|
|
|
| 486 |
@app.get("/")
|
| 487 |
async def root_fallback():
|
| 488 |
return {
|
| 489 |
+
"message": "Sikomo Scraper & Forecast API Server beroperasi normal.",
|
| 490 |
"note": "Frontend static files belum di-build/mount ke direktori static."
|
| 491 |
}
|
backend/app/ml_pipeline.py
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ml_pipeline.py β Full ML pipeline: cleaning β training β forecasting β labeling
|
| 2 |
+
import os, json, pickle, warnings, logging
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import numpy as np
|
| 5 |
+
from datetime import date
|
| 6 |
+
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score
|
| 7 |
+
from sklearn.model_selection import TimeSeriesSplit
|
| 8 |
+
import lightgbm as lgb
|
| 9 |
+
import xgboost as xgb
|
| 10 |
+
from statsmodels.tsa.arima.model import ARIMA
|
| 11 |
+
from statsmodels.tsa.statespace.sarimax import SARIMAX
|
| 12 |
+
import optuna
|
| 13 |
+
optuna.logging.set_verbosity(optuna.logging.WARNING)
|
| 14 |
+
warnings.filterwarnings('ignore')
|
| 15 |
+
logging.getLogger('cmdstanpy').setLevel(logging.WARNING)
|
| 16 |
+
logging.getLogger('prophet').setLevel(logging.WARNING)
|
| 17 |
+
|
| 18 |
+
MODEL_DIR = os.getenv('MODEL_DIR', 'models')
|
| 19 |
+
os.makedirs(MODEL_DIR, exist_ok=True)
|
| 20 |
+
|
| 21 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 22 |
+
# 1. DATA QUALITY & CLEANING
|
| 23 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 24 |
+
|
| 25 |
+
def analyze_data_quality(df, col):
|
| 26 |
+
total, missing = len(df), df[col].isna().sum()
|
| 27 |
+
gap_lengths, cur = [], 0
|
| 28 |
+
for v in df[col].isna():
|
| 29 |
+
if v: cur += 1
|
| 30 |
+
else:
|
| 31 |
+
if cur > 0: gap_lengths.append(cur)
|
| 32 |
+
cur = 0
|
| 33 |
+
max_gap = max(gap_lengths) if gap_lengths else 0
|
| 34 |
+
outliers = (df[col].pct_change().abs() > 0.5).sum()
|
| 35 |
+
mean, std = df[col].mean(), df[col].std()
|
| 36 |
+
cv = (std / mean * 100) if mean > 0 else 0
|
| 37 |
+
return {
|
| 38 |
+
'total_records': total,
|
| 39 |
+
'missing' : int(missing),
|
| 40 |
+
'missing_pct' : round(missing / total * 100, 2),
|
| 41 |
+
'max_gap_days' : max_gap,
|
| 42 |
+
'outliers' : int(outliers),
|
| 43 |
+
'cv' : round(cv, 2),
|
| 44 |
+
'volatility' : 'rendah' if cv < 2 else ('sedang' if cv < 5 else 'tinggi'),
|
| 45 |
+
'mean_price' : round(mean, 2),
|
| 46 |
+
'std_price' : round(std, 2),
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
def clean_price_data(df, col, quality_info):
|
| 50 |
+
df = df.copy()
|
| 51 |
+
df[f'{col}_was_missing'] = df[col].isna().astype(int)
|
| 52 |
+
temp = df[col].copy()
|
| 53 |
+
mask = temp.isna()
|
| 54 |
+
gap_id = (mask != mask.shift()).cumsum()
|
| 55 |
+
gap_sizes = mask.groupby(gap_id).transform('sum')
|
| 56 |
+
|
| 57 |
+
temp = temp.fillna(method='ffill', limit=3)
|
| 58 |
+
med_mask = mask & (gap_sizes > 3) & (gap_sizes <= 7)
|
| 59 |
+
ti = temp.interpolate(method='linear', limit=7)
|
| 60 |
+
temp[med_mask] = ti[med_mask]
|
| 61 |
+
|
| 62 |
+
long_mask = mask & (gap_sizes > 7)
|
| 63 |
+
if long_mask.any():
|
| 64 |
+
rolling_med = temp.rolling(30, min_periods=3, center=True).median()
|
| 65 |
+
ti2 = temp.interpolate(method='linear')
|
| 66 |
+
temp[long_mask] = ti2.clip(lower=rolling_med*0.85, upper=rolling_med*1.15)[long_mask]
|
| 67 |
+
|
| 68 |
+
df[col] = temp
|
| 69 |
+
outlier_mask = df[col].pct_change().abs() > 0.4
|
| 70 |
+
if outlier_mask.any():
|
| 71 |
+
df.loc[outlier_mask, col] = np.nan
|
| 72 |
+
df[col] = df[col].interpolate(method='linear', limit=5)
|
| 73 |
+
return df
|
| 74 |
+
|
| 75 |
+
def recommend_models(quality_info):
|
| 76 |
+
models = ['lightgbm', 'xgboost']
|
| 77 |
+
if quality_info['missing_pct'] < 30 and quality_info['max_gap_days'] < 14:
|
| 78 |
+
models.append('prophet')
|
| 79 |
+
if quality_info['cv'] < 5 and quality_info['missing_pct'] < 20:
|
| 80 |
+
models.append('arima')
|
| 81 |
+
if quality_info['total_records'] >= 90 and quality_info['missing_pct'] < 25:
|
| 82 |
+
models.append('sarima')
|
| 83 |
+
return models
|
| 84 |
+
|
| 85 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 86 |
+
# 2. FEATURE ENGINEERING
|
| 87 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 88 |
+
|
| 89 |
+
def create_features(df, target_col, other_cols=None):
|
| 90 |
+
df = df.copy()
|
| 91 |
+
df['year'] = df['date'].dt.year
|
| 92 |
+
df['month'] = df['date'].dt.month
|
| 93 |
+
df['day'] = df['date'].dt.day
|
| 94 |
+
df['day_of_week'] = df['date'].dt.dayofweek
|
| 95 |
+
df['day_of_year'] = df['date'].dt.dayofyear
|
| 96 |
+
df['week_of_year'] = df['date'].dt.isocalendar().week.astype(int)
|
| 97 |
+
df['quarter'] = df['date'].dt.quarter
|
| 98 |
+
df['is_weekend'] = (df['day_of_week'] >= 5).astype(int)
|
| 99 |
+
df['is_month_start']= df['date'].dt.is_month_start.astype(int)
|
| 100 |
+
df['is_month_end'] = df['date'].dt.is_month_end.astype(int)
|
| 101 |
+
for lag in [1, 2, 3, 7, 14, 30]:
|
| 102 |
+
df[f'price_lag_{lag}'] = df[target_col].shift(lag)
|
| 103 |
+
for w in [7, 14, 30]:
|
| 104 |
+
df[f'price_rolling_mean_{w}'] = df[target_col].rolling(w).mean()
|
| 105 |
+
df[f'price_rolling_std_{w}'] = df[target_col].rolling(w).std()
|
| 106 |
+
df[f'price_rolling_min_{w}'] = df[target_col].rolling(w).min()
|
| 107 |
+
df[f'price_rolling_max_{w}'] = df[target_col].rolling(w).max()
|
| 108 |
+
df['price_diff_1'] = df[target_col].diff(1)
|
| 109 |
+
df['price_diff_7'] = df[target_col].diff(7)
|
| 110 |
+
df['price_pct_change_1'] = df[target_col].pct_change(1) * 100
|
| 111 |
+
df['price_pct_change_7'] = df[target_col].pct_change(7) * 100
|
| 112 |
+
df['sma_7'] = df[target_col].rolling(7).mean()
|
| 113 |
+
df['sma_30'] = df[target_col].rolling(30).mean()
|
| 114 |
+
df['sma_diff'] = df['sma_7'] - df['sma_30']
|
| 115 |
+
df['volatility_7'] = df[target_col].rolling(7).std()
|
| 116 |
+
df['volatility_30'] = df[target_col].rolling(30).std()
|
| 117 |
+
if other_cols:
|
| 118 |
+
for i, oc in enumerate(other_cols):
|
| 119 |
+
if oc in df.columns:
|
| 120 |
+
df[f'other_price_{i}'] = df[oc]
|
| 121 |
+
df[f'price_spread_{i}'] = df[target_col] - df[oc]
|
| 122 |
+
df[f'price_ratio_{i}'] = df[target_col] / (df[oc] + 1e-6)
|
| 123 |
+
return df
|
| 124 |
+
|
| 125 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 126 |
+
# 3. TRAINING
|
| 127 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 128 |
+
|
| 129 |
+
def evaluate_model(y_true, y_pred, model_name=""):
|
| 130 |
+
rmse = float(np.sqrt(mean_squared_error(y_true, y_pred)))
|
| 131 |
+
return {
|
| 132 |
+
'model' : model_name,
|
| 133 |
+
'rmse' : rmse,
|
| 134 |
+
'mae' : float(mean_absolute_error(y_true, y_pred)),
|
| 135 |
+
'r2' : float(r2_score(y_true, y_pred)),
|
| 136 |
+
'mape' : float(np.mean(np.abs((y_true - y_pred) / (np.abs(y_true)+1e-6))) * 100),
|
| 137 |
+
'median_ae': float(np.median(np.abs(y_true - y_pred))),
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
def train_lightgbm(X_train, y_train, X_test, y_test):
|
| 141 |
+
m = lgb.LGBMRegressor(
|
| 142 |
+
objective='regression', metric='rmse', num_leaves=31,
|
| 143 |
+
max_depth=7, learning_rate=0.05, n_estimators=300,
|
| 144 |
+
min_child_samples=20, subsample=0.8, colsample_bytree=0.8,
|
| 145 |
+
reg_alpha=0.1, reg_lambda=0.1, verbose=-1,
|
| 146 |
+
random_state=42, force_col_wise=True,
|
| 147 |
+
)
|
| 148 |
+
m.fit(X_train, y_train, eval_set=[(X_test, y_test)],
|
| 149 |
+
eval_metric='rmse', callbacks=[lgb.early_stopping(50, verbose=False)])
|
| 150 |
+
return m, evaluate_model(y_test, m.predict(X_test), 'LightGBM')
|
| 151 |
+
|
| 152 |
+
def train_xgboost(X_train, y_train, X_test, y_test):
|
| 153 |
+
m = xgb.XGBRegressor(
|
| 154 |
+
objective='reg:squarederror', n_estimators=300, max_depth=6,
|
| 155 |
+
learning_rate=0.05, subsample=0.8, colsample_bytree=0.8,
|
| 156 |
+
reg_alpha=0.1, reg_lambda=0.1, random_state=42, verbosity=0,
|
| 157 |
+
)
|
| 158 |
+
m.fit(X_train, y_train, eval_set=[(X_test, y_test)], verbose=False)
|
| 159 |
+
return m, evaluate_model(y_test, m.predict(X_test), 'XGBoost')
|
| 160 |
+
|
| 161 |
+
def train_prophet(dates_train, y_train, dates_test, y_test):
|
| 162 |
+
"""Train Prophet with robust error handling and data validation."""
|
| 163 |
+
try:
|
| 164 |
+
from prophet import Prophet
|
| 165 |
+
except ImportError:
|
| 166 |
+
raise RuntimeError("Prophet not installed")
|
| 167 |
+
# Ensure clean datetime + float data
|
| 168 |
+
ds_train = pd.to_datetime(dates_train).reset_index(drop=True)
|
| 169 |
+
y_tr = y_train.reset_index(drop=True).astype(float)
|
| 170 |
+
ds_test = pd.to_datetime(dates_test).reset_index(drop=True)
|
| 171 |
+
y_te = y_test.reset_index(drop=True).astype(float)
|
| 172 |
+
# Remove NaN rows
|
| 173 |
+
mask = y_tr.notna()
|
| 174 |
+
ds_train = ds_train[mask]
|
| 175 |
+
y_tr = y_tr[mask]
|
| 176 |
+
if len(y_tr) < 30:
|
| 177 |
+
raise ValueError("Prophet: data training terlalu sedikit setelah cleaning")
|
| 178 |
+
df_train = pd.DataFrame({'ds': ds_train.values, 'y': y_tr.values})
|
| 179 |
+
df_test = pd.DataFrame({'ds': ds_test.values})
|
| 180 |
+
m = Prophet(
|
| 181 |
+
yearly_seasonality=True, weekly_seasonality=True,
|
| 182 |
+
daily_seasonality=False, changepoint_prior_scale=0.05,
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
# Matikan log dengan cara yang lebih aman
|
| 186 |
+
logging.getLogger('prophet').setLevel(logging.ERROR)
|
| 187 |
+
logging.getLogger('cmdstanpy').setLevel(logging.ERROR)
|
| 188 |
+
|
| 189 |
+
m.fit(df_train)
|
| 190 |
+
fc = m.predict(df_test)
|
| 191 |
+
pred = np.clip(fc['yhat'].values, 0, None)
|
| 192 |
+
return m, evaluate_model(y_te.values, pred, 'Prophet')
|
| 193 |
+
|
| 194 |
+
def train_arima(y_train, y_test):
|
| 195 |
+
m = ARIMA(y_train.values, order=(5, 1, 0)).fit()
|
| 196 |
+
fc = np.clip(m.forecast(steps=len(y_test)), 0, None)
|
| 197 |
+
return m, evaluate_model(y_test.values, fc, 'ARIMA')
|
| 198 |
+
|
| 199 |
+
def train_sarima(y_train, y_test):
|
| 200 |
+
"""Train SARIMA with seasonal_order for weekly patterns."""
|
| 201 |
+
m = SARIMAX(
|
| 202 |
+
y_train.values, order=(1, 1, 1), seasonal_order=(1, 1, 1, 7),
|
| 203 |
+
enforce_stationarity=False, enforce_invertibility=False,
|
| 204 |
+
).fit(disp=False, maxiter=200)
|
| 205 |
+
fc = np.clip(m.forecast(steps=len(y_test)), 0, None)
|
| 206 |
+
return m, evaluate_model(y_test.values, fc, 'SARIMA')
|
| 207 |
+
|
| 208 |
+
def forecast_sarima(model, n_days=7):
|
| 209 |
+
"""Forecast n_days ahead using fitted SARIMA model."""
|
| 210 |
+
fc = np.clip(model.forecast(steps=n_days), 0, None)
|
| 211 |
+
last_date = pd.Timestamp.now().normalize()
|
| 212 |
+
return [
|
| 213 |
+
{'tanggal': str((last_date + pd.Timedelta(days=i+1)).date()),
|
| 214 |
+
'harga_prediksi': round(float(fc[i]), 2)}
|
| 215 |
+
for i in range(n_days)
|
| 216 |
+
]
|
| 217 |
+
|
| 218 |
+
def tune_with_optuna(model_name, X_train, y_train, n_trials=50):
|
| 219 |
+
def objective(trial):
|
| 220 |
+
if model_name == 'LightGBM':
|
| 221 |
+
params = {
|
| 222 |
+
'objective':'regression','metric':'rmse','verbose':-1,
|
| 223 |
+
'random_state':42,'force_col_wise':True,
|
| 224 |
+
'num_leaves' : trial.suggest_int('num_leaves', 20, 100),
|
| 225 |
+
'max_depth' : trial.suggest_int('max_depth', 4, 12),
|
| 226 |
+
'learning_rate' : trial.suggest_float('learning_rate', 0.01, 0.3),
|
| 227 |
+
'n_estimators' : trial.suggest_int('n_estimators', 100, 500),
|
| 228 |
+
'min_child_samples': trial.suggest_int('min_child_samples', 5, 50),
|
| 229 |
+
'subsample' : trial.suggest_float('subsample', 0.5, 1.0),
|
| 230 |
+
'colsample_bytree' : trial.suggest_float('colsample_bytree', 0.5, 1.0),
|
| 231 |
+
'reg_alpha' : trial.suggest_float('reg_alpha', 0.0, 1.0),
|
| 232 |
+
'reg_lambda' : trial.suggest_float('reg_lambda', 0.0, 1.0),
|
| 233 |
+
}
|
| 234 |
+
Cls = lgb.LGBMRegressor
|
| 235 |
+
else:
|
| 236 |
+
params = {
|
| 237 |
+
'objective':'reg:squarederror','random_state':42,'verbosity':0,
|
| 238 |
+
'n_estimators' : trial.suggest_int('n_estimators', 100, 500),
|
| 239 |
+
'max_depth' : trial.suggest_int('max_depth', 3, 10),
|
| 240 |
+
'learning_rate' : trial.suggest_float('learning_rate', 0.01, 0.3),
|
| 241 |
+
'subsample' : trial.suggest_float('subsample', 0.5, 1.0),
|
| 242 |
+
'colsample_bytree': trial.suggest_float('colsample_bytree', 0.5, 1.0),
|
| 243 |
+
'reg_alpha' : trial.suggest_float('reg_alpha', 0.0, 1.0),
|
| 244 |
+
'reg_lambda' : trial.suggest_float('reg_lambda', 0.0, 1.0),
|
| 245 |
+
}
|
| 246 |
+
Cls = xgb.XGBRegressor
|
| 247 |
+
scores = []
|
| 248 |
+
for tr, val in TimeSeriesSplit(n_splits=5).split(X_train):
|
| 249 |
+
m = Cls(**params)
|
| 250 |
+
m.fit(X_train.iloc[tr], y_train.iloc[tr])
|
| 251 |
+
scores.append(np.sqrt(mean_squared_error(y_train.iloc[val], m.predict(X_train.iloc[val]))))
|
| 252 |
+
return np.mean(scores)
|
| 253 |
+
|
| 254 |
+
study = optuna.create_study(direction='minimize')
|
| 255 |
+
study.optimize(objective, n_trials=n_trials)
|
| 256 |
+
return study.best_params
|
| 257 |
+
|
| 258 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 259 |
+
# 4. FORECASTING
|
| 260 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 261 |
+
|
| 262 |
+
def forecast_tree(model, df_hist, target_col, other_cols, feature_cols, n_days=7):
|
| 263 |
+
df_temp = df_hist.copy()
|
| 264 |
+
preds = []
|
| 265 |
+
last_date = df_temp['date'].max()
|
| 266 |
+
for i in range(1, n_days + 1):
|
| 267 |
+
next_date = last_date + pd.Timedelta(days=i)
|
| 268 |
+
new_row = {'date': next_date, target_col: np.nan}
|
| 269 |
+
for oc in (other_cols or []):
|
| 270 |
+
if oc in df_temp.columns:
|
| 271 |
+
lo = df_temp[oc].dropna()
|
| 272 |
+
new_row[oc] = float(lo.iloc[-1]) if len(lo) else np.nan
|
| 273 |
+
df_temp = pd.concat([df_temp, pd.DataFrame([new_row])], ignore_index=True)
|
| 274 |
+
df_feat = create_features(df_temp, target_col, other_cols)
|
| 275 |
+
last_row = df_feat.iloc[[-1]].copy()
|
| 276 |
+
for col in feature_cols:
|
| 277 |
+
if col not in last_row.columns:
|
| 278 |
+
last_row[col] = 0.0
|
| 279 |
+
pred = max(0, float(model.predict(last_row[feature_cols])[0]))
|
| 280 |
+
preds.append({'tanggal': str(next_date.date()), 'harga_prediksi': round(pred, 2)})
|
| 281 |
+
df_temp.loc[df_temp['date'] == next_date, target_col] = pred
|
| 282 |
+
return preds
|
| 283 |
+
|
| 284 |
+
def forecast_arima(model, n_days=7):
|
| 285 |
+
"""Forecast n_days ahead using fitted ARIMA model."""
|
| 286 |
+
fc = np.clip(model.forecast(steps=n_days), 0, None)
|
| 287 |
+
last_date = pd.Timestamp.now().normalize()
|
| 288 |
+
return [
|
| 289 |
+
{'tanggal': str((last_date + pd.Timedelta(days=i+1)).date()),
|
| 290 |
+
'harga_prediksi': round(float(fc[i]), 2)}
|
| 291 |
+
for i in range(n_days)
|
| 292 |
+
]
|
| 293 |
+
|
| 294 |
+
def forecast_prophet(model, last_date, n_days=7):
|
| 295 |
+
future = pd.DataFrame({'ds': pd.date_range(start=last_date + pd.Timedelta(days=1), periods=n_days)})
|
| 296 |
+
fc = model.predict(future)
|
| 297 |
+
return [{'tanggal': str(r.ds.date()), 'harga_prediksi': round(max(0, r.yhat), 2)}
|
| 298 |
+
for _, r in fc.iterrows()]
|
| 299 |
+
|
| 300 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 301 |
+
# 5. LABEL GENERATOR (untuk frontend)
|
| 302 |
+
# βββββββββββββββββββββββββββββββββββββοΏ½οΏ½ββββββββββββββββββββββββββββββββββββββββ
|
| 303 |
+
|
| 304 |
+
def get_tren(preds):
|
| 305 |
+
pct = (preds[-1]['harga_prediksi'] - preds[0]['harga_prediksi']) / preds[0]['harga_prediksi'] * 100
|
| 306 |
+
if pct > 2: return 'naik_tajam', pct
|
| 307 |
+
elif pct > 0.5: return 'naik_terkendali', pct
|
| 308 |
+
elif pct < -2: return 'turun_tajam', pct
|
| 309 |
+
elif pct < -0.5: return 'turun_terkendali', pct
|
| 310 |
+
else: return 'stabil', pct
|
| 311 |
+
|
| 312 |
+
def get_labels(mape, rmse, mean_price, cv, preds, komoditas_nama):
|
| 313 |
+
harga_vals = [p['harga_prediksi'] for p in preds]
|
| 314 |
+
tren, pct = get_tren(preds)
|
| 315 |
+
confidence = round(max(0, min(100, 100 - mape * 2)), 2)
|
| 316 |
+
stabilitas = ('optimal' if mape < 2 else 'baik' if mape < 5
|
| 317 |
+
else 'cukup' if mape < 10 else 'perlu_retrain')
|
| 318 |
+
mape_label = ('Sangat Rendah' if mape < 5 else 'Rendah' if mape < 10
|
| 319 |
+
else 'Sedang' if mape < 15 else 'Tinggi' if mape < 25 else 'Sangat Tinggi')
|
| 320 |
+
rmse_pct = rmse / mean_price * 100
|
| 321 |
+
rmse_label = ('Presisi Sangat Tinggi' if rmse_pct < 1 else 'Presisi Tinggi' if rmse_pct < 2
|
| 322 |
+
else 'Presisi Sedang' if rmse_pct < 5 else 'Presisi Rendah')
|
| 323 |
+
cl_label = ('Sangat Tinggi' if confidence >= 90 else 'Tinggi' if confidence >= 80
|
| 324 |
+
else 'Sedang' if confidence >= 65 else 'Rendah')
|
| 325 |
+
vol_label = ('Rendah' if cv < 2 else 'Sedang' if cv < 5 else 'Tinggi')
|
| 326 |
+
|
| 327 |
+
if mape < 10:
|
| 328 |
+
status = {'judul': 'Akurasi Terverifikasi',
|
| 329 |
+
'deskripsi': f'Prediksi divalidasi, deviasi rata-rata di bawah {mape:.1f}%.'}
|
| 330 |
+
elif mape < 20:
|
| 331 |
+
status = {'judul': 'Akurasi Cukup',
|
| 332 |
+
'deskripsi': f'Deviasi rata-rata {mape:.1f}%. Gunakan sebagai referensi.'}
|
| 333 |
+
else:
|
| 334 |
+
status = {'judul': 'Perlu Perhatian',
|
| 335 |
+
'deskripsi': f'Deviasi {mape:.1f}%. Disarankan retraining.'}
|
| 336 |
+
|
| 337 |
+
tren_map = {
|
| 338 |
+
'stabil' : ('positif', 'check-circle', f'Harga {komoditas_nama} diprediksi stabil 7 hari ke depan.'),
|
| 339 |
+
'naik_terkendali' : ('negatif', 'trending-up', f'Harga {komoditas_nama} diprediksi naik {abs(pct):.1f}%.'),
|
| 340 |
+
'naik_tajam' : ('negatif', 'alert-triangle', f'Harga {komoditas_nama} diprediksi naik tajam {abs(pct):.1f}%.'),
|
| 341 |
+
'turun_terkendali': ('positif', 'trending-down', f'Harga {komoditas_nama} diprediksi turun {abs(pct):.1f}%.'),
|
| 342 |
+
'turun_tajam' : ('netral', 'alert-triangle', f'Harga {komoditas_nama} diprediksi turun tajam {abs(pct):.1f}%.'),
|
| 343 |
+
}
|
| 344 |
+
t1, ikon1, k1 = tren_map.get(tren, ('netral', 'info', f'Tren: {tren}'))
|
| 345 |
+
peak = max(preds, key=lambda x: x['harga_prediksi'])
|
| 346 |
+
trough = min(preds, key=lambda x: x['harga_prediksi'])
|
| 347 |
+
|
| 348 |
+
insights = [
|
| 349 |
+
{'tipe': t1, 'ikon': ikon1, 'konten': k1, 'urutan': 1},
|
| 350 |
+
{
|
| 351 |
+
'tipe' : 'positif' if cv < 2 else ('netral' if cv < 5 else 'negatif'),
|
| 352 |
+
'ikon' : 'check-circle' if cv < 2 else 'info',
|
| 353 |
+
'konten': ('Pasokan terpantau mencukupi.' if cv < 2
|
| 354 |
+
else f'Volatilitas sedang (CV={cv:.1f}%).' if cv < 5
|
| 355 |
+
else f'Harga sangat fluktuatif (CV={cv:.1f}%).'),
|
| 356 |
+
'urutan': 2,
|
| 357 |
+
},
|
| 358 |
+
{
|
| 359 |
+
'tipe' : 'netral',
|
| 360 |
+
'ikon' : 'calendar',
|
| 361 |
+
'konten': (f"Harga tertinggi Rp {peak['harga_prediksi']:,.0f}, "
|
| 362 |
+
f"terendah Rp {trough['harga_prediksi']:,.0f} dalam 7 hari."),
|
| 363 |
+
'urutan': 3,
|
| 364 |
+
},
|
| 365 |
+
]
|
| 366 |
+
|
| 367 |
+
return {
|
| 368 |
+
'tren' : tren,
|
| 369 |
+
'pct_change' : round(pct, 2),
|
| 370 |
+
'confidence_level': confidence,
|
| 371 |
+
'confidence_label': cl_label,
|
| 372 |
+
'stabilitas' : stabilitas,
|
| 373 |
+
'mape_label' : mape_label,
|
| 374 |
+
'rmse_label' : rmse_label,
|
| 375 |
+
'volatilitas_label': vol_label,
|
| 376 |
+
'status_analisis' : status,
|
| 377 |
+
'insights' : insights,
|
| 378 |
+
'harga_min' : min(harga_vals),
|
| 379 |
+
'harga_max' : max(harga_vals),
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 383 |
+
# 6. MAIN PIPELINE β dipanggil oleh API
|
| 384 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 385 |
+
|
| 386 |
+
def run_pipeline(komoditas_id: int, komoditas_nama: str, df: pd.DataFrame,
|
| 387 |
+
target_market: str, pasar_id: int, n_trials: int = 50,
|
| 388 |
+
forecast_days: int = 7, log_cb=None):
|
| 389 |
+
"""
|
| 390 |
+
Full pipeline: cleaning β training β tuning β forecasting β labeling β save.
|
| 391 |
+
log_cb: callback function(msg: str) untuk streaming log ke frontend.
|
| 392 |
+
"""
|
| 393 |
+
def log(msg):
|
| 394 |
+
if log_cb: log_cb(msg)
|
| 395 |
+
|
| 396 |
+
other_cols = [c for c in df.columns if c not in ['date', target_market]
|
| 397 |
+
and not c.endswith('_was_missing')]
|
| 398 |
+
|
| 399 |
+
# ββ Cleaning ββ
|
| 400 |
+
log("π§Ή Cleaning data...")
|
| 401 |
+
all_cols = [target_market] + other_cols
|
| 402 |
+
quality_reports = {}
|
| 403 |
+
for col in all_cols:
|
| 404 |
+
if col in df.columns:
|
| 405 |
+
q = analyze_data_quality(df, col)
|
| 406 |
+
quality_reports[col] = q
|
| 407 |
+
df = clean_price_data(df, col, q)
|
| 408 |
+
|
| 409 |
+
models_to_run = recommend_models(quality_reports.get(target_market, {}))
|
| 410 |
+
log(f"π€ Model yang akan dijalankan: {models_to_run}")
|
| 411 |
+
|
| 412 |
+
# ββ Prepare dataset ββ
|
| 413 |
+
exclude = ['date'] + [c for c in df.columns if c.endswith('_was_missing')]
|
| 414 |
+
df_feat = create_features(df, target_market, other_cols)
|
| 415 |
+
df_clean = df_feat.dropna(subset=[target_market]).reset_index(drop=True)
|
| 416 |
+
feature_cols = [c for c in df_clean.columns if c not in exclude + all_cols]
|
| 417 |
+
|
| 418 |
+
X = df_clean[feature_cols]
|
| 419 |
+
y = df_clean[target_market]
|
| 420 |
+
dates = df_clean['date']
|
| 421 |
+
split = int(len(df_clean) * 0.7)
|
| 422 |
+
X_train, X_test = X.iloc[:split], X.iloc[split:]
|
| 423 |
+
y_train, y_test = y.iloc[:split], y.iloc[split:]
|
| 424 |
+
mean_price = float(y.mean())
|
| 425 |
+
|
| 426 |
+
# ββ Train ββ
|
| 427 |
+
all_results = {}
|
| 428 |
+
lgb_model = xgb_model = prophet_model = None
|
| 429 |
+
|
| 430 |
+
if 'lightgbm' in models_to_run:
|
| 431 |
+
log("Training LightGBM...")
|
| 432 |
+
lgb_model, res = train_lightgbm(X_train, y_train, X_test, y_test)
|
| 433 |
+
all_results['LightGBM'] = {**res, 'obj': lgb_model, 'type': 'tree'}
|
| 434 |
+
|
| 435 |
+
if 'xgboost' in models_to_run:
|
| 436 |
+
log("Training XGBoost...")
|
| 437 |
+
xgb_model, res = train_xgboost(X_train, y_train, X_test, y_test)
|
| 438 |
+
all_results['XGBoost'] = {**res, 'obj': xgb_model, 'type': 'tree'}
|
| 439 |
+
|
| 440 |
+
if 'prophet' in models_to_run:
|
| 441 |
+
log("Training Prophet...")
|
| 442 |
+
try:
|
| 443 |
+
prophet_model, res = train_prophet(dates.iloc[:split], y_train, dates.iloc[split:], y_test)
|
| 444 |
+
all_results['Prophet'] = {**res, 'obj': prophet_model, 'type': 'prophet'}
|
| 445 |
+
except Exception as e:
|
| 446 |
+
log(f"β οΈ Prophet gagal: {e}")
|
| 447 |
+
|
| 448 |
+
if 'arima' in models_to_run:
|
| 449 |
+
log("Training ARIMA...")
|
| 450 |
+
try:
|
| 451 |
+
arima_model, res = train_arima(y_train, y_test)
|
| 452 |
+
all_results['ARIMA'] = {**res, 'obj': arima_model, 'type': 'arima'}
|
| 453 |
+
except Exception as e:
|
| 454 |
+
log(f"β οΈ ARIMA gagal: {e}")
|
| 455 |
+
|
| 456 |
+
if 'sarima' in models_to_run:
|
| 457 |
+
log("Training SARIMA...")
|
| 458 |
+
try:
|
| 459 |
+
sarima_model, res = train_sarima(y_train, y_test)
|
| 460 |
+
all_results['SARIMA'] = {**res, 'obj': sarima_model, 'type': 'sarima'}
|
| 461 |
+
except Exception as e:
|
| 462 |
+
log(f"β οΈ SARIMA gagal: {e}")
|
| 463 |
+
|
| 464 |
+
best_name = min(all_results, key=lambda k: all_results[k]['rmse'])
|
| 465 |
+
best = all_results[best_name]
|
| 466 |
+
log(f"π Best model: {best_name} (RMSE={best['rmse']:.2f})")
|
| 467 |
+
|
| 468 |
+
# ββ Tuning ββ
|
| 469 |
+
final_model = best['obj']
|
| 470 |
+
final_result = best
|
| 471 |
+
final_name = best_name
|
| 472 |
+
|
| 473 |
+
if best['type'] == 'tree':
|
| 474 |
+
log(f"π§ Tuning {best_name} dengan Optuna ({n_trials} trials)...")
|
| 475 |
+
best_params = tune_with_optuna(best_name, X_train, y_train, n_trials)
|
| 476 |
+
if best_name == 'LightGBM':
|
| 477 |
+
tuned = lgb.LGBMRegressor(**best_params, objective='regression',
|
| 478 |
+
verbose=-1, random_state=42, force_col_wise=True)
|
| 479 |
+
else:
|
| 480 |
+
tuned = xgb.XGBRegressor(**best_params, objective='reg:squarederror',
|
| 481 |
+
random_state=42, verbosity=0)
|
| 482 |
+
tuned.fit(X_train, y_train)
|
| 483 |
+
tuned_res = evaluate_model(y_test, tuned.predict(X_test), f"{best_name}_Tuned")
|
| 484 |
+
if tuned_res['rmse'] < best['rmse']:
|
| 485 |
+
final_model = tuned
|
| 486 |
+
final_result = tuned_res
|
| 487 |
+
final_name = f"{best_name}_Tuned"
|
| 488 |
+
log(f"β
Tuned lebih baik, improvement={(best['rmse']-tuned_res['rmse'])/best['rmse']*100:.1f}%")
|
| 489 |
+
|
| 490 |
+
# ββ Forecast ββ
|
| 491 |
+
n_fc = forecast_days
|
| 492 |
+
log(f"π
Forecasting {n_fc} hari...")
|
| 493 |
+
df_hist = df[['date', target_market] + [c for c in other_cols if c in df.columns]].copy()
|
| 494 |
+
f_type = final_result.get('type', '')
|
| 495 |
+
if f_type == 'prophet' or final_name == 'Prophet':
|
| 496 |
+
predictions = forecast_prophet(final_model, df_hist['date'].max(), n_fc)
|
| 497 |
+
elif f_type == 'arima':
|
| 498 |
+
predictions = forecast_arima(final_model, n_fc)
|
| 499 |
+
elif f_type == 'sarima':
|
| 500 |
+
predictions = forecast_sarima(final_model, n_fc)
|
| 501 |
+
else:
|
| 502 |
+
predictions = forecast_tree(final_model, df_hist, target_market, other_cols, feature_cols, n_fc)
|
| 503 |
+
|
| 504 |
+
# ββ Labels ββ
|
| 505 |
+
cv = quality_reports.get(target_market, {}).get('cv', 0)
|
| 506 |
+
labels = get_labels(
|
| 507 |
+
final_result['mape'], final_result['rmse'],
|
| 508 |
+
mean_price, cv, predictions, komoditas_nama
|
| 509 |
+
)
|
| 510 |
+
|
| 511 |
+
# ββ Simpan model ββ
|
| 512 |
+
prefix = os.path.join(MODEL_DIR, f"model_{komoditas_id}_{pasar_id}")
|
| 513 |
+
model_path= f"{prefix}.pkl"
|
| 514 |
+
with open(model_path, 'wb') as f:
|
| 515 |
+
pickle.dump(final_model, f)
|
| 516 |
+
with open(f"{prefix}_features.json", 'w') as f:
|
| 517 |
+
json.dump(feature_cols, f)
|
| 518 |
+
|
| 519 |
+
metadata = {
|
| 520 |
+
'komoditas_id' : komoditas_id,
|
| 521 |
+
'komoditas_nama' : komoditas_nama,
|
| 522 |
+
'pasar_id' : pasar_id,
|
| 523 |
+
'target_market' : target_market,
|
| 524 |
+
'other_markets' : other_cols,
|
| 525 |
+
'nama_model' : final_name,
|
| 526 |
+
'versi' : '1.0',
|
| 527 |
+
'file_path' : model_path,
|
| 528 |
+
'mape' : round(final_result['mape'], 4),
|
| 529 |
+
'rmse' : round(final_result['rmse'], 4),
|
| 530 |
+
'mae' : round(final_result['mae'], 4),
|
| 531 |
+
'r2_score' : round(final_result['r2'], 4),
|
| 532 |
+
'confidence_level': labels['confidence_level'],
|
| 533 |
+
'stabilitas' : labels['stabilitas'],
|
| 534 |
+
'status_validasi' : 'terverifikasi',
|
| 535 |
+
'catatan_validasi': labels['status_analisis']['deskripsi'],
|
| 536 |
+
'tanggal_training': date.today().isoformat(),
|
| 537 |
+
'tanggal_evaluasi': date.today().isoformat(),
|
| 538 |
+
'deskripsi' : f"Model {final_name} untuk prediksi harga {komoditas_nama}.",
|
| 539 |
+
'models_tried' : list(all_results.keys()),
|
| 540 |
+
'data_quality' : quality_reports.get(target_market, {}),
|
| 541 |
+
}
|
| 542 |
+
with open(f"{prefix}_metadata.json", 'w') as f:
|
| 543 |
+
json.dump(metadata, f, indent=2)
|
| 544 |
+
|
| 545 |
+
log(f"β
Pipeline selesai! MAPE={final_result['mape']:.2f}%")
|
| 546 |
+
|
| 547 |
+
return {
|
| 548 |
+
'predictions' : predictions,
|
| 549 |
+
'labels' : labels,
|
| 550 |
+
'metadata' : metadata,
|
| 551 |
+
'model_comparison': {k: {
|
| 552 |
+
'rmse': round(v['rmse'], 4),
|
| 553 |
+
'mape': round(v['mape'], 4),
|
| 554 |
+
'mae': round(v['mae'], 4),
|
| 555 |
+
'r2': round(v['r2'], 4)
|
| 556 |
+
} for k, v in all_results.items()},
|
| 557 |
+
}
|
backend/app/scheduler.py
CHANGED
|
@@ -1,16 +1,29 @@
|
|
|
|
|
| 1 |
from apscheduler.schedulers.background import BackgroundScheduler
|
| 2 |
from apscheduler.triggers.cron import CronTrigger
|
| 3 |
from app.scraper import scraping_hari_ini, upload_to_mysql
|
| 4 |
from app.database import get_db_engine
|
| 5 |
from app.config import get_settings
|
| 6 |
-
from app.logger import get_app_logger
|
| 7 |
from datetime import datetime
|
| 8 |
import uuid
|
| 9 |
-
from pytz import timezone
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
settings = get_settings()
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
class ScraperScheduler:
|
| 16 |
def __init__(self):
|
|
@@ -21,7 +34,7 @@ class ScraperScheduler:
|
|
| 21 |
self.schedules = {} # id -> {"cron": str, "label": str}
|
| 22 |
|
| 23 |
def run_scraping(self):
|
| 24 |
-
|
| 25 |
try:
|
| 26 |
df = scraping_hari_ini()
|
| 27 |
engine = get_db_engine()
|
|
@@ -32,21 +45,32 @@ class ScraperScheduler:
|
|
| 32 |
"stats": stats,
|
| 33 |
"timestamp": self.last_run.isoformat()
|
| 34 |
}
|
| 35 |
-
|
| 36 |
except Exception as e:
|
| 37 |
self.last_result = {
|
| 38 |
"success": False,
|
| 39 |
"error": str(e),
|
| 40 |
"timestamp": datetime.now().isoformat()
|
| 41 |
}
|
| 42 |
-
|
| 43 |
|
| 44 |
def start_all(self):
|
| 45 |
if not self.is_running:
|
|
|
|
| 46 |
self.scheduler.start()
|
| 47 |
self.is_running = True
|
| 48 |
-
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
# Load default schedules if empty
|
| 51 |
if not self.schedules:
|
| 52 |
defaults = [s.strip() for s in settings.DEFAULT_SCHEDULES.split(",") if s.strip()]
|
|
@@ -55,15 +79,16 @@ class ScraperScheduler:
|
|
| 55 |
|
| 56 |
def stop_all(self):
|
| 57 |
if self.is_running:
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
| 60 |
self.is_running = False
|
| 61 |
self.schedules.clear()
|
| 62 |
-
|
| 63 |
|
| 64 |
def add_schedule(self, cron_expression: str, label: str = "") -> str:
|
| 65 |
-
# Check if we already have this exact cron to avoid exact duplicates if desired,
|
| 66 |
-
# but let's allow unique IDs
|
| 67 |
job_id = str(uuid.uuid4())[:8]
|
| 68 |
try:
|
| 69 |
trigger = CronTrigger.from_crontab(cron_expression, timezone=wib_tz)
|
|
@@ -76,35 +101,32 @@ class ScraperScheduler:
|
|
| 76 |
if not label:
|
| 77 |
label = f"Jadwal {cron_expression}"
|
| 78 |
self.schedules[job_id] = {"cron": cron_expression, "label": label}
|
| 79 |
-
|
| 80 |
|
| 81 |
-
# Ensure scheduler is running if we add a job
|
| 82 |
if not self.is_running:
|
| 83 |
self.scheduler.start()
|
| 84 |
self.is_running = True
|
| 85 |
-
|
| 86 |
|
| 87 |
return job_id
|
| 88 |
except Exception as e:
|
| 89 |
-
|
| 90 |
raise ValueError(f"Format Cron tidak valid: {e}")
|
| 91 |
|
| 92 |
def remove_schedule(self, job_id: str):
|
| 93 |
if job_id in self.schedules:
|
| 94 |
try:
|
| 95 |
self.scheduler.remove_job(job_id)
|
| 96 |
-
except:
|
| 97 |
pass
|
| 98 |
deleted = self.schedules.pop(job_id)
|
| 99 |
-
|
| 100 |
else:
|
| 101 |
raise KeyError("ID Jadwal tidak ditemukan")
|
| 102 |
|
| 103 |
def list_schedules(self):
|
| 104 |
result = []
|
| 105 |
-
# Get active jobs from APScheduler to find next_run_time
|
| 106 |
jobs_map = {job.id: job for job in self.scheduler.get_jobs()}
|
| 107 |
-
|
| 108 |
for jid, info in self.schedules.items():
|
| 109 |
job_obj = jobs_map.get(jid)
|
| 110 |
next_run = job_obj.next_run_time.isoformat() if (job_obj and job_obj.next_run_time) else None
|
|
@@ -117,8 +139,7 @@ class ScraperScheduler:
|
|
| 117 |
return result
|
| 118 |
|
| 119 |
def get_status(self):
|
| 120 |
-
|
| 121 |
-
jobs = self.scheduler.get_jobs()
|
| 122 |
next_runs = [j.next_run_time for j in jobs if j.next_run_time]
|
| 123 |
next_run_overall = min(next_runs).isoformat() if next_runs else None
|
| 124 |
|
|
@@ -130,4 +151,138 @@ class ScraperScheduler:
|
|
| 130 |
"active_schedules_count": len(self.schedules)
|
| 131 |
}
|
| 132 |
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# scheduler.py β Multiple-schedule Scraping and Forecasting Manager
|
| 2 |
from apscheduler.schedulers.background import BackgroundScheduler
|
| 3 |
from apscheduler.triggers.cron import CronTrigger
|
| 4 |
from app.scraper import scraping_hari_ini, upload_to_mysql
|
| 5 |
from app.database import get_db_engine
|
| 6 |
from app.config import get_settings
|
| 7 |
+
from app.logger import get_app_logger, get_forecast_logger
|
| 8 |
from datetime import datetime
|
| 9 |
import uuid
|
|
|
|
| 10 |
|
| 11 |
+
try:
|
| 12 |
+
from pytz import timezone as pytz_timezone
|
| 13 |
+
wib_tz = pytz_timezone("Asia/Jakarta")
|
| 14 |
+
except ImportError:
|
| 15 |
+
import zoneinfo
|
| 16 |
+
wib_tz = zoneinfo.ZoneInfo("Asia/Jakarta")
|
| 17 |
+
|
| 18 |
+
# Loggers
|
| 19 |
+
scraper_log = get_app_logger("scheduler")
|
| 20 |
+
forecast_log = get_forecast_logger("scheduler")
|
| 21 |
+
|
| 22 |
settings = get_settings()
|
| 23 |
+
|
| 24 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 25 |
+
# 1. SCRAPER SCHEDULER
|
| 26 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 27 |
|
| 28 |
class ScraperScheduler:
|
| 29 |
def __init__(self):
|
|
|
|
| 34 |
self.schedules = {} # id -> {"cron": str, "label": str}
|
| 35 |
|
| 36 |
def run_scraping(self):
|
| 37 |
+
scraper_log.info("β° Menjalankan tugas scraping terjadwal otomatis...")
|
| 38 |
try:
|
| 39 |
df = scraping_hari_ini()
|
| 40 |
engine = get_db_engine()
|
|
|
|
| 45 |
"stats": stats,
|
| 46 |
"timestamp": self.last_run.isoformat()
|
| 47 |
}
|
| 48 |
+
scraper_log.info("β
Tugas scraping otomatis selesai dijalankan.")
|
| 49 |
except Exception as e:
|
| 50 |
self.last_result = {
|
| 51 |
"success": False,
|
| 52 |
"error": str(e),
|
| 53 |
"timestamp": datetime.now().isoformat()
|
| 54 |
}
|
| 55 |
+
scraper_log.error(f"β Tugas scraping otomatis gagal: {e}")
|
| 56 |
|
| 57 |
def start_all(self):
|
| 58 |
if not self.is_running:
|
| 59 |
+
self.scheduler = BackgroundScheduler(timezone=wib_tz)
|
| 60 |
self.scheduler.start()
|
| 61 |
self.is_running = True
|
| 62 |
+
scraper_log.info("βΆοΈ Mesin Scheduler Scraper telah diaktifkan.")
|
| 63 |
|
| 64 |
+
# Re-register existing schedules
|
| 65 |
+
for jid, info in list(self.schedules.items()):
|
| 66 |
+
try:
|
| 67 |
+
trigger = CronTrigger.from_crontab(info["cron"], timezone=wib_tz)
|
| 68 |
+
self.scheduler.add_job(
|
| 69 |
+
self.run_scraping, trigger, id=jid, replace_existing=True
|
| 70 |
+
)
|
| 71 |
+
except Exception:
|
| 72 |
+
pass
|
| 73 |
+
|
| 74 |
# Load default schedules if empty
|
| 75 |
if not self.schedules:
|
| 76 |
defaults = [s.strip() for s in settings.DEFAULT_SCHEDULES.split(",") if s.strip()]
|
|
|
|
| 79 |
|
| 80 |
def stop_all(self):
|
| 81 |
if self.is_running:
|
| 82 |
+
try:
|
| 83 |
+
self.scheduler.shutdown(wait=False)
|
| 84 |
+
except Exception:
|
| 85 |
+
pass
|
| 86 |
+
self.scheduler = BackgroundScheduler(timezone=wib_tz)
|
| 87 |
self.is_running = False
|
| 88 |
self.schedules.clear()
|
| 89 |
+
scraper_log.info("βΉοΈ Mesin Scheduler Scraper telah dimatikan.")
|
| 90 |
|
| 91 |
def add_schedule(self, cron_expression: str, label: str = "") -> str:
|
|
|
|
|
|
|
| 92 |
job_id = str(uuid.uuid4())[:8]
|
| 93 |
try:
|
| 94 |
trigger = CronTrigger.from_crontab(cron_expression, timezone=wib_tz)
|
|
|
|
| 101 |
if not label:
|
| 102 |
label = f"Jadwal {cron_expression}"
|
| 103 |
self.schedules[job_id] = {"cron": cron_expression, "label": label}
|
| 104 |
+
scraper_log.info(f"π
Jadwal baru ditambahkan: {cron_expression} ({label})")
|
| 105 |
|
|
|
|
| 106 |
if not self.is_running:
|
| 107 |
self.scheduler.start()
|
| 108 |
self.is_running = True
|
| 109 |
+
scraper_log.info("βΆοΈ Mesin Scheduler Scraper menyala karena ada jadwal baru.")
|
| 110 |
|
| 111 |
return job_id
|
| 112 |
except Exception as e:
|
| 113 |
+
scraper_log.error(f"β Gagal menambahkan jadwal cron '{cron_expression}': {e}")
|
| 114 |
raise ValueError(f"Format Cron tidak valid: {e}")
|
| 115 |
|
| 116 |
def remove_schedule(self, job_id: str):
|
| 117 |
if job_id in self.schedules:
|
| 118 |
try:
|
| 119 |
self.scheduler.remove_job(job_id)
|
| 120 |
+
except Exception:
|
| 121 |
pass
|
| 122 |
deleted = self.schedules.pop(job_id)
|
| 123 |
+
scraper_log.info(f"ποΈ Jadwal dihapus: {deleted['cron']} ({deleted['label']})")
|
| 124 |
else:
|
| 125 |
raise KeyError("ID Jadwal tidak ditemukan")
|
| 126 |
|
| 127 |
def list_schedules(self):
|
| 128 |
result = []
|
|
|
|
| 129 |
jobs_map = {job.id: job for job in self.scheduler.get_jobs()}
|
|
|
|
| 130 |
for jid, info in self.schedules.items():
|
| 131 |
job_obj = jobs_map.get(jid)
|
| 132 |
next_run = job_obj.next_run_time.isoformat() if (job_obj and job_obj.next_run_time) else None
|
|
|
|
| 139 |
return result
|
| 140 |
|
| 141 |
def get_status(self):
|
| 142 |
+
jobs = self.scheduler.get_jobs() if self.is_running else []
|
|
|
|
| 143 |
next_runs = [j.next_run_time for j in jobs if j.next_run_time]
|
| 144 |
next_run_overall = min(next_runs).isoformat() if next_runs else None
|
| 145 |
|
|
|
|
| 151 |
"active_schedules_count": len(self.schedules)
|
| 152 |
}
|
| 153 |
|
| 154 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 155 |
+
# 2. FORECAST SCHEDULER
|
| 156 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 157 |
+
|
| 158 |
+
class ForecastScheduler:
|
| 159 |
+
def __init__(self):
|
| 160 |
+
self.scheduler = BackgroundScheduler(timezone=wib_tz)
|
| 161 |
+
self.is_running = False
|
| 162 |
+
self.last_run = None
|
| 163 |
+
self.last_result = None
|
| 164 |
+
self.schedules = {} # id -> {"cron": str, "label": str}
|
| 165 |
+
self._forecast_job_fn = None
|
| 166 |
+
|
| 167 |
+
def set_forecast_job(self, fn):
|
| 168 |
+
self._forecast_job_fn = fn
|
| 169 |
+
|
| 170 |
+
def _run_job(self):
|
| 171 |
+
forecast_log.info("β° Menjalankan forecast terjadwal otomatis...")
|
| 172 |
+
try:
|
| 173 |
+
if self._forecast_job_fn:
|
| 174 |
+
self._forecast_job_fn()
|
| 175 |
+
self.last_run = datetime.now()
|
| 176 |
+
self.last_result = {
|
| 177 |
+
"success": True,
|
| 178 |
+
"timestamp": self.last_run.isoformat(),
|
| 179 |
+
}
|
| 180 |
+
forecast_log.info("β
Forecast terjadwal selesai.")
|
| 181 |
+
except Exception as e:
|
| 182 |
+
self.last_result = {
|
| 183 |
+
"success": False,
|
| 184 |
+
"error": str(e),
|
| 185 |
+
"timestamp": datetime.now().isoformat(),
|
| 186 |
+
}
|
| 187 |
+
forecast_log.error(f"β Forecast terjadwal gagal: {e}")
|
| 188 |
+
|
| 189 |
+
def start_all(self, default_schedules: str = ""):
|
| 190 |
+
if not self.is_running:
|
| 191 |
+
self.scheduler = BackgroundScheduler(timezone=wib_tz)
|
| 192 |
+
self.scheduler.start()
|
| 193 |
+
self.is_running = True
|
| 194 |
+
forecast_log.info("βΆοΈ Mesin Scheduler Forecast telah diaktifkan.")
|
| 195 |
+
|
| 196 |
+
# Re-register existing schedules
|
| 197 |
+
for jid, info in list(self.schedules.items()):
|
| 198 |
+
try:
|
| 199 |
+
trigger = CronTrigger.from_crontab(info["cron"], timezone=wib_tz)
|
| 200 |
+
self.scheduler.add_job(
|
| 201 |
+
self._run_job, trigger, id=jid, replace_existing=True
|
| 202 |
+
)
|
| 203 |
+
except Exception:
|
| 204 |
+
pass
|
| 205 |
+
|
| 206 |
+
# Load defaults if empty
|
| 207 |
+
if not self.schedules and default_schedules:
|
| 208 |
+
for cron in [s.strip() for s in default_schedules.split(",") if s.strip()]:
|
| 209 |
+
self.add_schedule(cron, label="Jadwal Default")
|
| 210 |
+
|
| 211 |
+
def stop_all(self):
|
| 212 |
+
if self.is_running:
|
| 213 |
+
try:
|
| 214 |
+
self.scheduler.shutdown(wait=False)
|
| 215 |
+
except Exception:
|
| 216 |
+
pass
|
| 217 |
+
self.scheduler = BackgroundScheduler(timezone=wib_tz)
|
| 218 |
+
self.is_running = False
|
| 219 |
+
self.schedules.clear()
|
| 220 |
+
forecast_log.info("βΉοΈ Mesin Scheduler Forecast telah dimatikan.")
|
| 221 |
+
|
| 222 |
+
def add_schedule(self, cron_expression: str, label: str = "") -> str:
|
| 223 |
+
job_id = str(uuid.uuid4())[:8]
|
| 224 |
+
try:
|
| 225 |
+
trigger = CronTrigger.from_crontab(cron_expression, timezone=wib_tz)
|
| 226 |
+
self.scheduler.add_job(
|
| 227 |
+
self._run_job, trigger, id=job_id, replace_existing=True
|
| 228 |
+
)
|
| 229 |
+
if not label:
|
| 230 |
+
label = f"Jadwal {cron_expression}"
|
| 231 |
+
self.schedules[job_id] = {"cron": cron_expression, "label": label}
|
| 232 |
+
forecast_log.info(f"π
Jadwal baru ditambahkan: {cron_expression} ({label})")
|
| 233 |
+
|
| 234 |
+
if not self.is_running:
|
| 235 |
+
self.scheduler.start()
|
| 236 |
+
self.is_running = True
|
| 237 |
+
forecast_log.info("βΆοΈ Scheduler otomatis menyala karena ada jadwal baru.")
|
| 238 |
+
|
| 239 |
+
return job_id
|
| 240 |
+
except Exception as e:
|
| 241 |
+
forecast_log.error(f"β Gagal menambahkan jadwal cron '{cron_expression}': {e}")
|
| 242 |
+
raise ValueError(f"Format Cron tidak valid: {e}")
|
| 243 |
+
|
| 244 |
+
def remove_schedule(self, job_id: str):
|
| 245 |
+
if job_id in self.schedules:
|
| 246 |
+
try:
|
| 247 |
+
self.scheduler.remove_job(job_id)
|
| 248 |
+
except Exception:
|
| 249 |
+
pass
|
| 250 |
+
deleted = self.schedules.pop(job_id)
|
| 251 |
+
forecast_log.info(f"ποΈ Jadwal dihapus: {deleted['cron']} ({deleted['label']})")
|
| 252 |
+
else:
|
| 253 |
+
raise KeyError("ID Jadwal tidak ditemukan")
|
| 254 |
+
|
| 255 |
+
def list_schedules(self):
|
| 256 |
+
result = []
|
| 257 |
+
jobs_map = {job.id: job for job in self.scheduler.get_jobs()}
|
| 258 |
+
for jid, info in self.schedules.items():
|
| 259 |
+
job_obj = jobs_map.get(jid)
|
| 260 |
+
next_run = (
|
| 261 |
+
job_obj.next_run_time.isoformat()
|
| 262 |
+
if (job_obj and job_obj.next_run_time)
|
| 263 |
+
else None
|
| 264 |
+
)
|
| 265 |
+
result.append({
|
| 266 |
+
"id": jid,
|
| 267 |
+
"cron_expression": info["cron"],
|
| 268 |
+
"label": info["label"],
|
| 269 |
+
"next_run": next_run,
|
| 270 |
+
})
|
| 271 |
+
return result
|
| 272 |
+
|
| 273 |
+
def get_status(self):
|
| 274 |
+
jobs = self.scheduler.get_jobs() if self.is_running else []
|
| 275 |
+
next_runs = [j.next_run_time for j in jobs if j.next_run_time]
|
| 276 |
+
next_run_overall = min(next_runs).isoformat() if next_runs else None
|
| 277 |
+
|
| 278 |
+
return {
|
| 279 |
+
"is_running": self.is_running,
|
| 280 |
+
"last_run": self.last_run.isoformat() if self.last_run else None,
|
| 281 |
+
"last_result": self.last_result,
|
| 282 |
+
"next_run": next_run_overall,
|
| 283 |
+
"active_schedules_count": len(self.schedules),
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
# Singleton instances
|
| 287 |
+
scraper_scheduler = ScraperScheduler()
|
| 288 |
+
forecast_scheduler = ForecastScheduler()
|
backend/requirements.txt
CHANGED
|
@@ -10,4 +10,11 @@ apscheduler==3.10.4
|
|
| 10 |
pydantic==2.5.3
|
| 11 |
python-multipart==0.0.6
|
| 12 |
pydantic-settings==2.1.0
|
| 13 |
-
aiofiles==23.2.1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
pydantic==2.5.3
|
| 11 |
python-multipart==0.0.6
|
| 12 |
pydantic-settings==2.1.0
|
| 13 |
+
aiofiles==23.2.1
|
| 14 |
+
scikit-learn==1.4.2
|
| 15 |
+
lightgbm==4.3.0
|
| 16 |
+
xgboost==2.0.3
|
| 17 |
+
prophet==1.1.5
|
| 18 |
+
statsmodels==0.14.2
|
| 19 |
+
optuna==3.6.1
|
| 20 |
+
pytz==2024.1
|
frontend/app/page.tsx
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|