113 lines
3.7 KiB
Python
113 lines
3.7 KiB
Python
"""Scores router — scoring engine endpoints."""
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.dependencies import get_db, require_access
|
|
from app.schemas.common import APIEnvelope
|
|
from app.schemas.score import (
|
|
CompositeBreakdownResponse,
|
|
DimensionScoreResponse,
|
|
RankingEntry,
|
|
RankingResponse,
|
|
ScoreBreakdownResponse,
|
|
ScoreResponse,
|
|
SubScoreResponse,
|
|
WeightUpdateRequest,
|
|
)
|
|
from app.services.scoring_service import get_rankings, get_score, update_weights
|
|
|
|
|
|
def _map_breakdown(raw: dict | None) -> ScoreBreakdownResponse | None:
|
|
"""Convert a raw breakdown dict from the scoring service into a Pydantic model."""
|
|
if raw is None:
|
|
return None
|
|
return ScoreBreakdownResponse(
|
|
sub_scores=[SubScoreResponse(**s) for s in raw.get("sub_scores", [])],
|
|
formula=raw.get("formula", ""),
|
|
unavailable=raw.get("unavailable", []),
|
|
)
|
|
|
|
|
|
def _map_composite_breakdown(raw: dict | None) -> CompositeBreakdownResponse | None:
|
|
"""Convert a raw composite breakdown dict into a Pydantic model."""
|
|
if raw is None:
|
|
return None
|
|
return CompositeBreakdownResponse(
|
|
weights=raw["weights"],
|
|
available_dimensions=raw["available_dimensions"],
|
|
missing_dimensions=raw["missing_dimensions"],
|
|
renormalized_weights=raw["renormalized_weights"],
|
|
formula=raw["formula"],
|
|
)
|
|
|
|
router = APIRouter(tags=["scores"])
|
|
|
|
|
|
@router.get("/scores/{symbol}", response_model=APIEnvelope)
|
|
async def read_score(
|
|
symbol: str,
|
|
_user=Depends(require_access),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> APIEnvelope:
|
|
"""Get composite + dimension scores for a symbol. Recomputes stale scores."""
|
|
result = await get_score(db, symbol)
|
|
|
|
data = ScoreResponse(
|
|
symbol=result["symbol"],
|
|
composite_score=result["composite_score"],
|
|
composite_stale=result["composite_stale"],
|
|
weights=result["weights"],
|
|
dimensions=[
|
|
DimensionScoreResponse(
|
|
dimension=d["dimension"],
|
|
score=d["score"],
|
|
is_stale=d["is_stale"],
|
|
computed_at=d.get("computed_at"),
|
|
breakdown=_map_breakdown(d.get("breakdown")),
|
|
)
|
|
for d in result["dimensions"]
|
|
],
|
|
missing_dimensions=result["missing_dimensions"],
|
|
computed_at=result["computed_at"],
|
|
composite_breakdown=_map_composite_breakdown(
|
|
result.get("composite_breakdown")
|
|
),
|
|
)
|
|
return APIEnvelope(status="success", data=data.model_dump(mode="json"))
|
|
|
|
|
|
@router.get("/rankings", response_model=APIEnvelope)
|
|
async def read_rankings(
|
|
_user=Depends(require_access),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> APIEnvelope:
|
|
"""Get all tickers ranked by composite score descending."""
|
|
result = await get_rankings(db)
|
|
|
|
data = RankingResponse(
|
|
rankings=[
|
|
RankingEntry(
|
|
symbol=r["symbol"],
|
|
composite_score=r["composite_score"],
|
|
dimensions=[
|
|
DimensionScoreResponse(**d) for d in r["dimensions"]
|
|
],
|
|
)
|
|
for r in result["rankings"]
|
|
],
|
|
weights=result["weights"],
|
|
)
|
|
return APIEnvelope(status="success", data=data.model_dump(mode="json"))
|
|
|
|
|
|
@router.put("/scores/weights", response_model=APIEnvelope)
|
|
async def update_score_weights(
|
|
body: WeightUpdateRequest,
|
|
_user=Depends(require_access),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> APIEnvelope:
|
|
"""Update dimension weights and recompute all composite scores."""
|
|
new_weights = await update_weights(db, body.weights)
|
|
return APIEnvelope(status="success", data={"weights": new_weights})
|