forecasting/backend/api/crossref.py
jtricerolph 75d2c1fa9d 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>
2026-07-04 18:49:34 +00:00

300 lines
9.1 KiB
Python

"""
Cross-Reference Validation API endpoints
Validate that related forecasts align with each other
"""
from datetime import date
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from database import get_db
from auth import get_current_user
router = APIRouter()
@router.get("/check")
async def run_cross_reference_check(
check_date: date = Query(..., description="Date to run cross-reference checks for"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Run all cross-reference checks for a specific date.
Returns validation results showing if forecasts are internally consistent.
"""
# Get all active cross-reference configurations
config_query = """
SELECT check_name, check_category, formula, compares_to, tolerance_pct, input_metrics
FROM cross_reference_config
WHERE is_active = TRUE
ORDER BY display_order
"""
config_result = await db.execute(text(config_query))
configs = config_result.fetchall()
# Get all forecast values for the date
forecast_query = """
SELECT forecast_type, predicted_value
FROM forecasts
WHERE forecast_date = :check_date
AND model_type = 'prophet'
"""
forecast_result = await db.execute(text(forecast_query), {"check_date": check_date})
forecasts = {row.forecast_type: float(row.predicted_value) for row in forecast_result.fetchall()}
results = []
for config in configs:
# For now, return placeholder results
# In production, would evaluate formula against forecasts
compares_to_value = forecasts.get(config.compares_to)
results.append({
"check_name": config.check_name,
"check_category": config.check_category,
"formula": config.formula,
"compares_to": config.compares_to,
"forecasted_value": compares_to_value,
"calculated_value": None, # Would be calculated from formula
"difference": None,
"difference_pct": None,
"tolerance_pct": float(config.tolerance_pct),
"status": "ok" # Would be evaluated based on tolerance
})
return {
"check_date": check_date,
"results": results,
"alignment_score": 100 # Would be calculated
}
@router.get("/report")
async def get_cross_reference_report(
from_date: date = Query(...),
to_date: date = Query(...),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get cross-reference validation report for date range.
"""
query = """
SELECT
forecast_date,
check_name,
check_category,
calculated_value,
forecasted_value,
difference,
difference_pct,
tolerance_pct,
status
FROM forecast_cross_reference
WHERE forecast_date BETWEEN :from_date AND :to_date
ORDER BY forecast_date, check_category, check_name
"""
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
rows = result.fetchall()
return [
{
"date": row.forecast_date,
"check_name": row.check_name,
"category": row.check_category,
"calculated": float(row.calculated_value) if row.calculated_value else None,
"forecasted": float(row.forecasted_value) if row.forecasted_value else None,
"difference": float(row.difference) if row.difference else None,
"difference_pct": float(row.difference_pct) if row.difference_pct else None,
"tolerance_pct": float(row.tolerance_pct),
"status": row.status
}
for row in rows
]
@router.get("/discrepancies")
async def get_discrepancies(
from_date: date = Query(...),
to_date: date = Query(...),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get list of dates with cross-reference discrepancies.
"""
query = """
SELECT
forecast_date,
check_name,
check_category,
difference_pct,
tolerance_pct,
possible_causes,
recommendation
FROM forecast_cross_reference
WHERE forecast_date BETWEEN :from_date AND :to_date
AND status = 'discrepancy'
ORDER BY ABS(difference_pct) DESC
"""
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
rows = result.fetchall()
return [
{
"date": row.forecast_date,
"check_name": row.check_name,
"category": row.check_category,
"difference_pct": float(row.difference_pct) if row.difference_pct else None,
"tolerance_pct": float(row.tolerance_pct),
"possible_causes": row.possible_causes,
"recommendation": row.recommendation
}
for row in rows
]
@router.get("/alignment-score")
async def get_alignment_score(
from_date: date = Query(...),
to_date: date = Query(...),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get overall alignment score for date range.
Higher score = more internally consistent forecasts.
"""
query = """
SELECT
COUNT(*) as total_checks,
SUM(CASE WHEN status = 'ok' THEN 1 ELSE 0 END) as passed,
SUM(CASE WHEN status = 'warning' THEN 1 ELSE 0 END) as warnings,
SUM(CASE WHEN status = 'discrepancy' THEN 1 ELSE 0 END) as discrepancies
FROM forecast_cross_reference
WHERE forecast_date BETWEEN :from_date AND :to_date
"""
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
row = result.fetchone()
if not row or row.total_checks == 0:
return {
"from_date": from_date,
"to_date": to_date,
"alignment_score": 100,
"total_checks": 0,
"passed": 0,
"warnings": 0,
"discrepancies": 0
}
# Score: 100 * (passed / total), with warnings counting as 0.5
score = ((row.passed + row.warnings * 0.5) / row.total_checks) * 100
return {
"from_date": from_date,
"to_date": to_date,
"alignment_score": round(score, 1),
"total_checks": row.total_checks,
"passed": row.passed,
"warnings": row.warnings,
"discrepancies": row.discrepancies
}
@router.get("/config")
async def get_crossref_config(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get cross-reference check configuration.
"""
query = """
SELECT
check_name,
check_category,
description,
formula,
compares_to,
tolerance_pct,
input_metrics,
is_correlation_check,
expected_correlation,
is_active,
display_order
FROM cross_reference_config
ORDER BY display_order
"""
result = await db.execute(text(query))
rows = result.fetchall()
return [
{
"check_name": row.check_name,
"category": row.check_category,
"description": row.description,
"formula": row.formula,
"compares_to": row.compares_to,
"tolerance_pct": float(row.tolerance_pct),
"input_metrics": row.input_metrics,
"is_correlation_check": row.is_correlation_check,
"expected_correlation": float(row.expected_correlation) if row.expected_correlation else None,
"is_active": row.is_active
}
for row in rows
]
@router.put("/config/{check_name}")
async def update_crossref_config(
check_name: str,
tolerance_pct: Optional[float] = Query(None),
is_active: Optional[bool] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Update tolerance or active status for a cross-reference check.
"""
updates = []
params = {"check_name": check_name}
if tolerance_pct is not None:
updates.append("tolerance_pct = :tolerance_pct")
params["tolerance_pct"] = tolerance_pct
if is_active is not None:
updates.append("is_active = :is_active")
params["is_active"] = is_active
if not updates:
return {"status": "no_changes", "check_name": check_name}
query = f"""
UPDATE cross_reference_config
SET {', '.join(updates)}
WHERE check_name = :check_name
RETURNING check_name
"""
result = await db.execute(text(query), params)
await db.commit()
row = result.fetchone()
if not row:
raise ValueError(f"Check not found: {check_name}")
return {
"status": "updated",
"check_name": check_name,
"updates": {k: v for k, v in params.items() if k != "check_name"}
}