fix(CORE-001D3A): accept decimal boundary inputs

This commit is contained in:
Bu5hm4nn
2026-03-26 13:19:18 +01:00
parent bb557009c7
commit 753e9d3146
6 changed files with 105 additions and 69 deletions

View File

@@ -247,11 +247,15 @@ def settings_page(workspace_id: str) -> None:
if mode == "weight":
gold_value.props("readonly")
gold_ounces.props(remove="readonly")
derived_hint.set_text("Gold weight is the editable basis; start value is derived from weight × entry price.")
derived_hint.set_text(
"Gold weight is the editable basis; start value is derived from weight × entry price."
)
else:
gold_ounces.props("readonly")
gold_value.props(remove="readonly")
derived_hint.set_text("Start value is the editable basis; gold weight is derived from start value ÷ entry price.")
derived_hint.set_text(
"Start value is the editable basis; gold weight is derived from start value ÷ entry price."
)
def render_alert_state() -> None:
try:
@@ -284,14 +288,14 @@ def settings_page(workspace_id: str) -> None:
ui.label(event.message).classes(
"text-sm font-medium text-slate-900 dark:text-slate-100"
)
ui.label(
f"Spot ${event.spot_price:,.2f} · LTV {event.ltv_ratio:.1%}"
).classes("text-xs text-slate-500 dark:text-slate-400")
ui.label(f"Spot ${event.spot_price:,.2f} · LTV {event.ltv_ratio:.1%}").classes(
"text-xs text-slate-500 dark:text-slate-400"
)
ui.label(event.severity.upper()).classes(_alert_badge_classes(event.severity))
else:
with alert_history_column:
ui.label("No alert history yet.").classes("text-sm text-slate-500 dark:text-slate-400")
except ValueError as exc:
except (ValueError, TypeError) as exc:
alert_state_container.clear()
with alert_state_container:
ui.label("INVALID").classes(_alert_badge_classes("critical"))

View File

@@ -7,9 +7,9 @@ from decimal import Decimal
from typing import Mapping
from app.domain.portfolio_math import build_alert_context
from app.domain.units import decimal_from_float
from app.models.alerts import AlertEvent, AlertHistoryRepository, AlertStatus
from app.models.portfolio import PortfolioConfig
from app.services.boundary_values import boundary_decimal
@dataclass(frozen=True, slots=True)
@@ -22,48 +22,25 @@ class AlertEvaluationInput:
email_alerts_enabled: bool
def _decimal_from_boundary_value(value: object, *, field_name: str, default: float = 0.0) -> Decimal:
if value is None:
return decimal_from_float(float(default))
if isinstance(value, bool):
raise TypeError(f"{field_name} must be numeric, got bool")
if isinstance(value, int):
parsed = float(value)
elif isinstance(value, float):
parsed = value
elif isinstance(value, str):
stripped = value.strip()
if not stripped:
parsed = float(default)
else:
try:
parsed = float(stripped)
except ValueError as exc:
raise ValueError(f"{field_name} must be numeric, got {value!r}") from exc
else:
raise TypeError(f"{field_name} must be numeric, got {type(value)!r}")
return decimal_from_float(float(parsed))
def _normalize_alert_evaluation_input(
config: PortfolioConfig,
portfolio: Mapping[str, object],
) -> AlertEvaluationInput:
return AlertEvaluationInput(
ltv_ratio=_decimal_from_boundary_value(
portfolio.get("ltv_ratio", 0.0),
ltv_ratio=boundary_decimal(
portfolio.get("ltv_ratio"),
field_name="portfolio.ltv_ratio",
),
spot_price=_decimal_from_boundary_value(
portfolio.get("spot_price", 0.0),
spot_price=boundary_decimal(
portfolio.get("spot_price"),
field_name="portfolio.spot_price",
),
updated_at=str(portfolio.get("quote_updated_at", "")),
warning_threshold=_decimal_from_boundary_value(
warning_threshold=boundary_decimal(
config.ltv_warning,
field_name="config.ltv_warning",
),
critical_threshold=_decimal_from_boundary_value(
critical_threshold=boundary_decimal(
config.margin_threshold,
field_name="config.margin_threshold",
),

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from decimal import Decimal, InvalidOperation
from app.domain.units import decimal_from_float, to_decimal
def boundary_decimal(value: object, *, field_name: str) -> Decimal:
if value is None:
raise ValueError(f"{field_name} must be present")
if isinstance(value, bool):
raise TypeError(f"{field_name} must be numeric, got bool")
if isinstance(value, float):
return decimal_from_float(value)
if isinstance(value, (Decimal, int)):
return to_decimal(value)
if isinstance(value, str):
stripped = value.strip()
if not stripped:
raise ValueError(f"{field_name} must be present")
try:
return to_decimal(stripped)
except InvalidOperation as exc:
raise ValueError(f"{field_name} must be numeric, got {value!r}") from exc
raise TypeError(f"{field_name} must be numeric, got {type(value)!r}")

View File

@@ -4,8 +4,8 @@ from dataclasses import dataclass
from decimal import Decimal
from typing import Protocol
from app.domain.units import decimal_from_float
from app.models.portfolio import PortfolioConfig
from app.services.boundary_values import boundary_decimal
class _SaveStatusConfig(Protocol):
@@ -27,38 +27,16 @@ class SaveStatusSnapshot:
margin_call_price: Decimal
def _decimal_from_boundary_value(value: object, *, field_name: str) -> Decimal:
if value is None:
raise ValueError(f"{field_name} must be present")
if isinstance(value, bool):
raise TypeError(f"{field_name} must be numeric, got bool")
if isinstance(value, int):
parsed = float(value)
elif isinstance(value, float):
parsed = value
elif isinstance(value, str):
stripped = value.strip()
if not stripped:
raise ValueError(f"{field_name} must be present")
try:
parsed = float(stripped)
except ValueError as exc:
raise ValueError(f"{field_name} must be numeric, got {value!r}") from exc
else:
raise TypeError(f"{field_name} must be numeric, got {type(value)!r}")
return decimal_from_float(float(parsed))
def _normalize_save_status_snapshot(config: PortfolioConfig | _SaveStatusConfig) -> SaveStatusSnapshot:
margin_call_price = config.margin_call_price
resolved_margin_call_price = margin_call_price() if callable(margin_call_price) else margin_call_price
return SaveStatusSnapshot(
entry_basis_mode=config.entry_basis_mode,
gold_value=_decimal_from_boundary_value(config.gold_value, field_name="config.gold_value"),
entry_price=_decimal_from_boundary_value(config.entry_price, field_name="config.entry_price"),
gold_ounces=_decimal_from_boundary_value(config.gold_ounces, field_name="config.gold_ounces"),
current_ltv=_decimal_from_boundary_value(config.current_ltv, field_name="config.current_ltv"),
margin_call_price=_decimal_from_boundary_value(
gold_value=boundary_decimal(config.gold_value, field_name="config.gold_value"),
entry_price=boundary_decimal(config.entry_price, field_name="config.entry_price"),
gold_ounces=boundary_decimal(config.gold_ounces, field_name="config.gold_ounces"),
current_ltv=boundary_decimal(config.current_ltv, field_name="config.current_ltv"),
margin_call_price=boundary_decimal(
resolved_margin_call_price,
field_name="config.margin_call_price",
),