From 753e9d314621f5bef92dc4ff2ddac8de77455598 Mon Sep 17 00:00:00 2001 From: Bu5hm4nn Date: Thu, 26 Mar 2026 13:19:18 +0100 Subject: [PATCH] fix(CORE-001D3A): accept decimal boundary inputs --- app/pages/settings.py | 16 ++++++++------ app/services/alerts.py | 37 ++++++-------------------------- app/services/boundary_values.py | 25 ++++++++++++++++++++++ app/services/settings_status.py | 34 ++++++----------------------- tests/test_alerts.py | 38 ++++++++++++++++++++++++++++++--- tests/test_settings.py | 24 +++++++++++++++++++-- 6 files changed, 105 insertions(+), 69 deletions(-) create mode 100644 app/services/boundary_values.py diff --git a/app/pages/settings.py b/app/pages/settings.py index 85efcdd..48c7f95 100644 --- a/app/pages/settings.py +++ b/app/pages/settings.py @@ -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")) diff --git a/app/services/alerts.py b/app/services/alerts.py index 0183e2c..ad7701c 100644 --- a/app/services/alerts.py +++ b/app/services/alerts.py @@ -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", ), diff --git a/app/services/boundary_values.py b/app/services/boundary_values.py new file mode 100644 index 0000000..a719930 --- /dev/null +++ b/app/services/boundary_values.py @@ -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}") diff --git a/app/services/settings_status.py b/app/services/settings_status.py index 22bb7a7..b816d0a 100644 --- a/app/services/settings_status.py +++ b/app/services/settings_status.py @@ -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", ), diff --git a/tests/test_alerts.py b/tests/test_alerts.py index 096daf5..58851c3 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -14,14 +14,14 @@ def alert_service(tmp_path: Path) -> AlertService: return AlertService(history_path=tmp_path / "alert_history.json") -def test_normalize_alert_evaluation_input_coerces_numeric_boundary_values() -> None: +def test_normalize_alert_evaluation_input_accepts_decimal_boundary_values() -> None: config = PortfolioConfig(gold_value=215_000.0, entry_price=215.0, loan_amount=151_000.0) normalized = _normalize_alert_evaluation_input( config, { - "ltv_ratio": "0.7023", - "spot_price": "215.0", + "ltv_ratio": Decimal("0.7023"), + "spot_price": Decimal("215.0"), "quote_updated_at": 123, }, ) @@ -33,6 +33,38 @@ def test_normalize_alert_evaluation_input_coerces_numeric_boundary_values() -> N assert normalized.critical_threshold == Decimal("0.75") +def test_alert_service_evaluate_accepts_string_boundary_values(alert_service: AlertService) -> None: + config = PortfolioConfig(gold_value=215_000.0, entry_price=215.0, loan_amount=151_000.0) + + status = alert_service.evaluate( + config, + { + "ltv_ratio": "0.7023", + "spot_price": "215.0", + "quote_updated_at": "2026-03-24T12:00:00Z", + }, + persist=False, + ) + + assert status.severity == "warning" + assert status.ltv_ratio == pytest.approx(0.7023, rel=1e-9) + assert [event.severity for event in status.history] == ["warning"] + + +def test_alert_service_rejects_missing_ltv_ratio(alert_service: AlertService) -> None: + config = PortfolioConfig(gold_value=215_000.0, entry_price=215.0, loan_amount=151_000.0) + + with pytest.raises(ValueError, match="portfolio.ltv_ratio must be present"): + alert_service.evaluate( + config, + { + "spot_price": 215.0, + "quote_updated_at": "2026-03-24T12:00:00Z", + }, + persist=False, + ) + + def test_normalize_alert_evaluation_input_rejects_bool_values() -> None: config = PortfolioConfig(gold_value=215_000.0, entry_price=215.0, loan_amount=151_000.0) diff --git a/tests/test_settings.py b/tests/test_settings.py index 74aa6ec..c2a245f 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -30,8 +30,17 @@ class StringBoundaryConfig: margin_call_price = "193.33333333333334" -def test_normalize_save_status_snapshot_coerces_numeric_boundary_values() -> None: - snapshot = _normalize_save_status_snapshot(StringBoundaryConfig()) +class DecimalBoundaryConfig: + entry_basis_mode = "weight" + gold_value = Decimal("215000") + entry_price = Decimal("215.0") + gold_ounces = Decimal("1000") + current_ltv = Decimal("0.6744186046511628") + margin_call_price = Decimal("193.33333333333334") + + +def test_normalize_save_status_snapshot_accepts_decimal_boundary_values() -> None: + snapshot = _normalize_save_status_snapshot(DecimalBoundaryConfig()) assert snapshot.entry_basis_mode == "weight" assert snapshot.gold_value == Decimal("215000") @@ -41,6 +50,17 @@ def test_normalize_save_status_snapshot_coerces_numeric_boundary_values() -> Non assert snapshot.margin_call_price == Decimal("193.33333333333334") +def test_save_status_text_accepts_string_boundary_values() -> None: + status = save_status_text(StringBoundaryConfig()) + + assert "Saved: basis=weight" in status + assert "start=$215,000" in status + assert "entry=$215.00/oz" in status + assert "weight=1,000.00 oz" in status + assert "LTV=67.4%" in status + assert "trigger=$193.33/oz" in status + + def test_normalize_save_status_snapshot_rejects_bool_values() -> None: class InvalidBoundaryConfig(StringBoundaryConfig): current_ltv = True