fix(CORE-001D3A): accept decimal boundary inputs
This commit is contained in:
@@ -247,11 +247,15 @@ def settings_page(workspace_id: str) -> None:
|
|||||||
if mode == "weight":
|
if mode == "weight":
|
||||||
gold_value.props("readonly")
|
gold_value.props("readonly")
|
||||||
gold_ounces.props(remove="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:
|
else:
|
||||||
gold_ounces.props("readonly")
|
gold_ounces.props("readonly")
|
||||||
gold_value.props(remove="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:
|
def render_alert_state() -> None:
|
||||||
try:
|
try:
|
||||||
@@ -284,14 +288,14 @@ def settings_page(workspace_id: str) -> None:
|
|||||||
ui.label(event.message).classes(
|
ui.label(event.message).classes(
|
||||||
"text-sm font-medium text-slate-900 dark:text-slate-100"
|
"text-sm font-medium text-slate-900 dark:text-slate-100"
|
||||||
)
|
)
|
||||||
ui.label(
|
ui.label(f"Spot ${event.spot_price:,.2f} · LTV {event.ltv_ratio:.1%}").classes(
|
||||||
f"Spot ${event.spot_price:,.2f} · LTV {event.ltv_ratio:.1%}"
|
"text-xs text-slate-500 dark:text-slate-400"
|
||||||
).classes("text-xs text-slate-500 dark:text-slate-400")
|
)
|
||||||
ui.label(event.severity.upper()).classes(_alert_badge_classes(event.severity))
|
ui.label(event.severity.upper()).classes(_alert_badge_classes(event.severity))
|
||||||
else:
|
else:
|
||||||
with alert_history_column:
|
with alert_history_column:
|
||||||
ui.label("No alert history yet.").classes("text-sm text-slate-500 dark:text-slate-400")
|
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()
|
alert_state_container.clear()
|
||||||
with alert_state_container:
|
with alert_state_container:
|
||||||
ui.label("INVALID").classes(_alert_badge_classes("critical"))
|
ui.label("INVALID").classes(_alert_badge_classes("critical"))
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ from decimal import Decimal
|
|||||||
from typing import Mapping
|
from typing import Mapping
|
||||||
|
|
||||||
from app.domain.portfolio_math import build_alert_context
|
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.alerts import AlertEvent, AlertHistoryRepository, AlertStatus
|
||||||
from app.models.portfolio import PortfolioConfig
|
from app.models.portfolio import PortfolioConfig
|
||||||
|
from app.services.boundary_values import boundary_decimal
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -22,48 +22,25 @@ class AlertEvaluationInput:
|
|||||||
email_alerts_enabled: bool
|
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(
|
def _normalize_alert_evaluation_input(
|
||||||
config: PortfolioConfig,
|
config: PortfolioConfig,
|
||||||
portfolio: Mapping[str, object],
|
portfolio: Mapping[str, object],
|
||||||
) -> AlertEvaluationInput:
|
) -> AlertEvaluationInput:
|
||||||
return AlertEvaluationInput(
|
return AlertEvaluationInput(
|
||||||
ltv_ratio=_decimal_from_boundary_value(
|
ltv_ratio=boundary_decimal(
|
||||||
portfolio.get("ltv_ratio", 0.0),
|
portfolio.get("ltv_ratio"),
|
||||||
field_name="portfolio.ltv_ratio",
|
field_name="portfolio.ltv_ratio",
|
||||||
),
|
),
|
||||||
spot_price=_decimal_from_boundary_value(
|
spot_price=boundary_decimal(
|
||||||
portfolio.get("spot_price", 0.0),
|
portfolio.get("spot_price"),
|
||||||
field_name="portfolio.spot_price",
|
field_name="portfolio.spot_price",
|
||||||
),
|
),
|
||||||
updated_at=str(portfolio.get("quote_updated_at", "")),
|
updated_at=str(portfolio.get("quote_updated_at", "")),
|
||||||
warning_threshold=_decimal_from_boundary_value(
|
warning_threshold=boundary_decimal(
|
||||||
config.ltv_warning,
|
config.ltv_warning,
|
||||||
field_name="config.ltv_warning",
|
field_name="config.ltv_warning",
|
||||||
),
|
),
|
||||||
critical_threshold=_decimal_from_boundary_value(
|
critical_threshold=boundary_decimal(
|
||||||
config.margin_threshold,
|
config.margin_threshold,
|
||||||
field_name="config.margin_threshold",
|
field_name="config.margin_threshold",
|
||||||
),
|
),
|
||||||
|
|||||||
25
app/services/boundary_values.py
Normal file
25
app/services/boundary_values.py
Normal 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}")
|
||||||
@@ -4,8 +4,8 @@ from dataclasses import dataclass
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
from app.domain.units import decimal_from_float
|
|
||||||
from app.models.portfolio import PortfolioConfig
|
from app.models.portfolio import PortfolioConfig
|
||||||
|
from app.services.boundary_values import boundary_decimal
|
||||||
|
|
||||||
|
|
||||||
class _SaveStatusConfig(Protocol):
|
class _SaveStatusConfig(Protocol):
|
||||||
@@ -27,38 +27,16 @@ class SaveStatusSnapshot:
|
|||||||
margin_call_price: Decimal
|
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:
|
def _normalize_save_status_snapshot(config: PortfolioConfig | _SaveStatusConfig) -> SaveStatusSnapshot:
|
||||||
margin_call_price = config.margin_call_price
|
margin_call_price = config.margin_call_price
|
||||||
resolved_margin_call_price = margin_call_price() if callable(margin_call_price) else margin_call_price
|
resolved_margin_call_price = margin_call_price() if callable(margin_call_price) else margin_call_price
|
||||||
return SaveStatusSnapshot(
|
return SaveStatusSnapshot(
|
||||||
entry_basis_mode=config.entry_basis_mode,
|
entry_basis_mode=config.entry_basis_mode,
|
||||||
gold_value=_decimal_from_boundary_value(config.gold_value, field_name="config.gold_value"),
|
gold_value=boundary_decimal(config.gold_value, field_name="config.gold_value"),
|
||||||
entry_price=_decimal_from_boundary_value(config.entry_price, field_name="config.entry_price"),
|
entry_price=boundary_decimal(config.entry_price, field_name="config.entry_price"),
|
||||||
gold_ounces=_decimal_from_boundary_value(config.gold_ounces, field_name="config.gold_ounces"),
|
gold_ounces=boundary_decimal(config.gold_ounces, field_name="config.gold_ounces"),
|
||||||
current_ltv=_decimal_from_boundary_value(config.current_ltv, field_name="config.current_ltv"),
|
current_ltv=boundary_decimal(config.current_ltv, field_name="config.current_ltv"),
|
||||||
margin_call_price=_decimal_from_boundary_value(
|
margin_call_price=boundary_decimal(
|
||||||
resolved_margin_call_price,
|
resolved_margin_call_price,
|
||||||
field_name="config.margin_call_price",
|
field_name="config.margin_call_price",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ def alert_service(tmp_path: Path) -> AlertService:
|
|||||||
return AlertService(history_path=tmp_path / "alert_history.json")
|
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)
|
config = PortfolioConfig(gold_value=215_000.0, entry_price=215.0, loan_amount=151_000.0)
|
||||||
|
|
||||||
normalized = _normalize_alert_evaluation_input(
|
normalized = _normalize_alert_evaluation_input(
|
||||||
config,
|
config,
|
||||||
{
|
{
|
||||||
"ltv_ratio": "0.7023",
|
"ltv_ratio": Decimal("0.7023"),
|
||||||
"spot_price": "215.0",
|
"spot_price": Decimal("215.0"),
|
||||||
"quote_updated_at": 123,
|
"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")
|
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:
|
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)
|
config = PortfolioConfig(gold_value=215_000.0, entry_price=215.0, loan_amount=151_000.0)
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,17 @@ class StringBoundaryConfig:
|
|||||||
margin_call_price = "193.33333333333334"
|
margin_call_price = "193.33333333333334"
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_save_status_snapshot_coerces_numeric_boundary_values() -> None:
|
class DecimalBoundaryConfig:
|
||||||
snapshot = _normalize_save_status_snapshot(StringBoundaryConfig())
|
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.entry_basis_mode == "weight"
|
||||||
assert snapshot.gold_value == Decimal("215000")
|
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")
|
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:
|
def test_normalize_save_status_snapshot_rejects_bool_values() -> None:
|
||||||
class InvalidBoundaryConfig(StringBoundaryConfig):
|
class InvalidBoundaryConfig(StringBoundaryConfig):
|
||||||
current_ltv = True
|
current_ltv = True
|
||||||
|
|||||||
Reference in New Issue
Block a user