feat(PORT-003): add historical ltv charts
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import Request
|
||||
@@ -8,6 +9,7 @@ from nicegui import ui
|
||||
|
||||
from app.components import PortfolioOverview
|
||||
from app.domain.portfolio_math import resolve_portfolio_spot_from_quote
|
||||
from app.models.ltv_history import LtvHistoryRepository
|
||||
from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository
|
||||
from app.pages.common import (
|
||||
dashboard_page,
|
||||
@@ -17,9 +19,12 @@ from app.pages.common import (
|
||||
strategy_catalog,
|
||||
)
|
||||
from app.services.alerts import AlertService, build_portfolio_alert_context
|
||||
from app.services.ltv_history import LtvHistoryChartModel, LtvHistoryService
|
||||
from app.services.runtime import get_data_service
|
||||
from app.services.turnstile import load_turnstile_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_CASH_BUFFER = 18_500.0
|
||||
|
||||
|
||||
@@ -47,6 +52,31 @@ def _alert_badge_classes(severity: str) -> str:
|
||||
}.get(severity, "rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700")
|
||||
|
||||
|
||||
def _ltv_chart_options(model: LtvHistoryChartModel) -> dict:
|
||||
return {
|
||||
"tooltip": {"trigger": "axis", "valueFormatter": "function (value) { return value + '%'; }"},
|
||||
"legend": {"data": ["LTV", "Margin threshold"]},
|
||||
"xAxis": {"type": "category", "data": list(model.labels)},
|
||||
"yAxis": {"type": "value", "name": "LTV %", "axisLabel": {"formatter": "{value}%"}},
|
||||
"series": [
|
||||
{
|
||||
"name": "LTV",
|
||||
"type": "line",
|
||||
"smooth": True,
|
||||
"data": list(model.ltv_values),
|
||||
"lineStyle": {"width": 3},
|
||||
},
|
||||
{
|
||||
"name": "Margin threshold",
|
||||
"type": "line",
|
||||
"data": list(model.threshold_values),
|
||||
"lineStyle": {"type": "dashed", "width": 2},
|
||||
"symbol": "none",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _render_workspace_recovery(title: str, message: str) -> None:
|
||||
with ui.column().classes("mx-auto mt-24 w-full max-w-2xl gap-6 px-6 text-center"):
|
||||
ui.icon("folder_off").classes("mx-auto text-6xl text-slate-400")
|
||||
@@ -126,6 +156,25 @@ async def overview_page(workspace_id: str) -> None:
|
||||
portfolio["cash_buffer"] = max(float(portfolio["gold_value"]) - configured_gold_value, 0.0) + _DEFAULT_CASH_BUFFER
|
||||
portfolio["hedge_budget"] = float(config.monthly_budget)
|
||||
alert_status = AlertService().evaluate(config, portfolio)
|
||||
ltv_history_service = LtvHistoryService(repository=LtvHistoryRepository(base_path=repo.base_path))
|
||||
ltv_history_notice: str | None = None
|
||||
try:
|
||||
ltv_history = ltv_history_service.record_workspace_snapshot(workspace_id, portfolio)
|
||||
ltv_chart_models = tuple(
|
||||
ltv_history_service.chart_model(
|
||||
ltv_history,
|
||||
days=days,
|
||||
current_margin_threshold=config.margin_threshold,
|
||||
)
|
||||
for days in (7, 30, 90)
|
||||
)
|
||||
ltv_history_csv = ltv_history_service.export_csv(ltv_history) if ltv_history else ""
|
||||
except Exception:
|
||||
logger.exception("Failed to prepare LTV history for workspace %s", workspace_id)
|
||||
ltv_history = []
|
||||
ltv_chart_models = ()
|
||||
ltv_history_csv = ""
|
||||
ltv_history_notice = "Historical LTV is temporarily unavailable due to a storage error."
|
||||
if portfolio["quote_source"] == "configured_entry_price":
|
||||
quote_status = "Live quote source: configured entry price fallback · Last updated Unavailable"
|
||||
else:
|
||||
@@ -248,6 +297,50 @@ async def overview_page(workspace_id: str) -> None:
|
||||
"Warning: if GLD approaches the margin-call price, collateral remediation or hedge monetization will be required."
|
||||
).classes("text-sm font-medium text-amber-700 dark:text-amber-300")
|
||||
|
||||
with ui.card().classes(
|
||||
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
|
||||
):
|
||||
with ui.row().classes(
|
||||
"w-full items-center justify-between gap-3 max-sm:flex-col max-sm:items-start"
|
||||
):
|
||||
with ui.column().classes("gap-1"):
|
||||
ui.label("Historical LTV").classes(
|
||||
"text-lg font-semibold text-slate-900 dark:text-slate-100"
|
||||
)
|
||||
ui.label(
|
||||
"Stored workspace snapshots show how LTV trended against the current margin threshold over 7, 30, and 90 day windows."
|
||||
).classes("text-sm text-slate-500 dark:text-slate-400")
|
||||
if ltv_history:
|
||||
ui.button(
|
||||
"Export CSV",
|
||||
icon="download",
|
||||
on_click=lambda: ui.download.content(
|
||||
ltv_history_csv,
|
||||
filename=f"{workspace_id}-ltv-history.csv",
|
||||
media_type="text/csv",
|
||||
),
|
||||
).props("outline color=primary")
|
||||
if ltv_history_notice:
|
||||
ui.label(ltv_history_notice).classes("text-sm text-amber-700 dark:text-amber-300")
|
||||
elif ltv_history:
|
||||
with ui.grid(columns=1).classes("w-full gap-4 xl:grid-cols-3"):
|
||||
for chart_model, chart_testid in zip(
|
||||
ltv_chart_models,
|
||||
("ltv-history-chart-7d", "ltv-history-chart-30d", "ltv-history-chart-90d"),
|
||||
strict=True,
|
||||
):
|
||||
with ui.card().classes(
|
||||
"rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950"
|
||||
):
|
||||
ui.label(chart_model.title).classes(
|
||||
"text-base font-semibold text-slate-900 dark:text-slate-100"
|
||||
)
|
||||
ui.echart(_ltv_chart_options(chart_model)).props(
|
||||
f"data-testid={chart_testid}"
|
||||
).classes("h-56 w-full")
|
||||
else:
|
||||
ui.label("No LTV snapshots recorded yet.").classes("text-sm text-slate-500 dark:text-slate-400")
|
||||
|
||||
with ui.card().classes(
|
||||
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user