Forecasting app: hybrid port to HNF stack

Python FastAPI ML backend kept intact; auth replaced with central hnf_session cookie verification. Frontend rebuilt on React 18 + TS + Vite with stack design system, Plotly charts retained. Shared Postgres via DATABASE_URL; schema applied on startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-04 18:49:34 +00:00
commit 75d2c1fa9d
103 changed files with 70316 additions and 0 deletions

237
backend/api/export.py Normal file
View file

@ -0,0 +1,237 @@
"""
Export API endpoints for Excel/CSV downloads
"""
import io
from datetime import date, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
import pandas as pd
from database import get_db
from auth import get_current_user
router = APIRouter()
@router.get("/excel")
async def export_excel(
from_date: Optional[date] = Query(None),
to_date: Optional[date] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Download Excel workbook with multiple sheets:
- Daily Forecast (all models)
- Weekly Summary
- Budget Comparison
- Model Accuracy
"""
if from_date is None:
from_date = date.today()
if to_date is None:
to_date = from_date + timedelta(days=28)
# Create Excel writer
output = io.BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
# Daily forecasts sheet
daily_query = """
SELECT
f.forecast_date as "Date",
f.forecast_type as "Metric",
MAX(CASE WHEN f.model_type = 'prophet' THEN f.predicted_value END) as "Prophet",
MAX(CASE WHEN f.model_type = 'prophet' THEN f.lower_bound END) as "Prophet Lower",
MAX(CASE WHEN f.model_type = 'prophet' THEN f.upper_bound END) as "Prophet Upper",
MAX(CASE WHEN f.model_type = 'xgboost' THEN f.predicted_value END) as "XGBoost",
MAX(CASE WHEN f.model_type = 'pickup' THEN f.predicted_value END) as "Pickup",
db.budget_value as "Budget"
FROM forecasts f
LEFT JOIN daily_budgets db ON f.forecast_date = db.date AND f.forecast_type = db.budget_type
WHERE f.forecast_date BETWEEN :from_date AND :to_date
GROUP BY f.forecast_date, f.forecast_type, db.budget_value
ORDER BY f.forecast_date, f.forecast_type
"""
result = await db.execute(text(daily_query), {"from_date": from_date, "to_date": to_date})
daily_df = pd.DataFrame(result.fetchall())
if not daily_df.empty:
daily_df.to_excel(writer, sheet_name='Daily Forecast', index=False)
# Weekly summary sheet
weekly_query = """
SELECT
DATE_TRUNC('week', f.forecast_date) as "Week Start",
f.forecast_type as "Metric",
AVG(f.predicted_value) as "Avg Forecast",
SUM(f.predicted_value) as "Total Forecast",
AVG(db.budget_value) as "Avg Budget",
SUM(db.budget_value) as "Total Budget"
FROM forecasts f
LEFT JOIN daily_budgets db ON f.forecast_date = db.date AND f.forecast_type = db.budget_type
WHERE f.forecast_date BETWEEN :from_date AND :to_date
AND f.model_type = 'prophet'
GROUP BY DATE_TRUNC('week', f.forecast_date), f.forecast_type
ORDER BY "Week Start", f.forecast_type
"""
result = await db.execute(text(weekly_query), {"from_date": from_date, "to_date": to_date})
weekly_df = pd.DataFrame(result.fetchall())
if not weekly_df.empty:
weekly_df.to_excel(writer, sheet_name='Weekly Summary', index=False)
# Budget variance sheet
variance_query = """
SELECT
f.forecast_date as "Date",
f.forecast_type as "Metric",
f.predicted_value as "Forecast",
db.budget_value as "Budget",
(f.predicted_value - db.budget_value) as "Variance",
CASE
WHEN db.budget_value != 0 THEN
ROUND(((f.predicted_value - db.budget_value) / db.budget_value * 100)::numeric, 1)
ELSE NULL
END as "Variance %"
FROM forecasts f
LEFT JOIN daily_budgets db ON f.forecast_date = db.date AND f.forecast_type = db.budget_type
WHERE f.forecast_date BETWEEN :from_date AND :to_date
AND f.model_type = 'prophet'
ORDER BY f.forecast_date, f.forecast_type
"""
result = await db.execute(text(variance_query), {"from_date": from_date, "to_date": to_date})
variance_df = pd.DataFrame(result.fetchall())
if not variance_df.empty:
variance_df.to_excel(writer, sheet_name='Budget Variance', index=False)
# Accuracy sheet (historical)
accuracy_query = """
SELECT
date as "Date",
metric_type as "Metric",
actual_value as "Actual",
prophet_forecast as "Prophet",
xgboost_forecast as "XGBoost",
pickup_forecast as "Pickup",
best_model as "Best Model"
FROM actual_vs_forecast
WHERE date BETWEEN :from_date - INTERVAL '30 days' AND :from_date
ORDER BY date, metric_type
"""
result = await db.execute(text(accuracy_query), {"from_date": from_date, "to_date": to_date})
accuracy_df = pd.DataFrame(result.fetchall())
if not accuracy_df.empty:
accuracy_df.to_excel(writer, sheet_name='Historical Accuracy', index=False)
output.seek(0)
filename = f"forecast_{from_date}_{to_date}.xlsx"
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
@router.get("/csv/{metric}")
async def export_csv(
metric: str,
from_date: Optional[date] = Query(None),
to_date: Optional[date] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Download CSV for a specific metric.
"""
if from_date is None:
from_date = date.today()
if to_date is None:
to_date = from_date + timedelta(days=28)
query = """
SELECT
f.forecast_date,
f.model_type,
f.predicted_value,
f.lower_bound,
f.upper_bound,
dm.actual_value,
db.budget_value
FROM forecasts f
LEFT JOIN daily_metrics dm ON f.forecast_date = dm.date AND f.forecast_type = dm.metric_code
LEFT JOIN daily_budgets db ON f.forecast_date = db.date AND f.forecast_type = db.budget_type
WHERE f.forecast_date BETWEEN :from_date AND :to_date
AND f.forecast_type = :metric
ORDER BY f.forecast_date, f.model_type
"""
result = await db.execute(text(query), {
"from_date": from_date,
"to_date": to_date,
"metric": metric
})
df = pd.DataFrame(result.fetchall())
output = io.StringIO()
df.to_csv(output, index=False)
output.seek(0)
filename = f"{metric}_{from_date}_{to_date}.csv"
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
@router.get("/model-comparison")
async def export_model_comparison(
from_date: Optional[date] = Query(None),
to_date: Optional[date] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Export model comparison data for all metrics.
"""
if from_date is None:
from_date = date.today()
if to_date is None:
to_date = from_date + timedelta(days=28)
query = """
SELECT
f.forecast_date,
f.forecast_type,
fm.metric_name,
MAX(CASE WHEN f.model_type = 'prophet' THEN f.predicted_value END) as prophet,
MAX(CASE WHEN f.model_type = 'xgboost' THEN f.predicted_value END) as xgboost,
MAX(CASE WHEN f.model_type = 'pickup' THEN f.predicted_value END) as pickup,
dm.actual_value
FROM forecasts f
LEFT JOIN forecast_metrics fm ON f.forecast_type = fm.metric_code
LEFT JOIN daily_metrics dm ON f.forecast_date = dm.date AND f.forecast_type = dm.metric_code
WHERE f.forecast_date BETWEEN :from_date AND :to_date
GROUP BY f.forecast_date, f.forecast_type, fm.metric_name, dm.actual_value
ORDER BY f.forecast_date, f.forecast_type
"""
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
df = pd.DataFrame(result.fetchall())
output = io.BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
df.to_excel(writer, sheet_name='Model Comparison', index=False)
output.seek(0)
filename = f"model_comparison_{from_date}_{to_date}.xlsx"
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)