From a69fdf676217bd94a662a95334d8fb12c347c739 Mon Sep 17 00:00:00 2001 From: Bu5hm4nn Date: Tue, 24 Mar 2026 21:33:17 +0100 Subject: [PATCH] feat(CORE-001A): add decimal unit value foundation --- app/domain/__init__.py | 19 + app/domain/units.py | 245 +++++++++ docs/CORE-001A_DECIMAL_UNITS_ARCHITECTURE.md | 543 +++++++++++++++++++ docs/ROADMAP.md | 128 ++++- tests/test_units.py | 227 ++++++++ 5 files changed, 1153 insertions(+), 9 deletions(-) create mode 100644 app/domain/__init__.py create mode 100644 app/domain/units.py create mode 100644 docs/CORE-001A_DECIMAL_UNITS_ARCHITECTURE.md create mode 100644 tests/test_units.py diff --git a/app/domain/__init__.py b/app/domain/__init__.py new file mode 100644 index 0000000..1e7450a --- /dev/null +++ b/app/domain/__init__.py @@ -0,0 +1,19 @@ +from app.domain.units import ( + BaseCurrency, + Money, + PricePerWeight, + Weight, + WeightUnit, + decimal_from_float, + to_decimal, +) + +__all__ = [ + "BaseCurrency", + "WeightUnit", + "Money", + "Weight", + "PricePerWeight", + "to_decimal", + "decimal_from_float", +] diff --git a/app/domain/units.py b/app/domain/units.py new file mode 100644 index 0000000..ad94957 --- /dev/null +++ b/app/domain/units.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +from enum import StrEnum + + +class BaseCurrency(StrEnum): + USD = "USD" + EUR = "EUR" + CHF = "CHF" + + +class WeightUnit(StrEnum): + GRAM = "g" + KILOGRAM = "kg" + OUNCE_TROY = "ozt" + + +GRAMS_PER_KILOGRAM = Decimal("1000") +GRAMS_PER_TROY_OUNCE = Decimal("31.1034768") + + +DecimalLike = Decimal | int | str + + +def to_decimal(value: DecimalLike) -> Decimal: + if isinstance(value, bool): + raise TypeError("Boolean values are not valid Decimal inputs") + if isinstance(value, Decimal): + amount = value + elif isinstance(value, int): + amount = Decimal(value) + elif isinstance(value, str): + amount = Decimal(value) + else: + raise TypeError(f"Unsupported decimal input type: {type(value)!r}") + if not amount.is_finite(): + raise ValueError("Decimal value must be finite") + return amount + + +def decimal_from_float(value: float) -> Decimal: + if not isinstance(value, float): + raise TypeError(f"Expected float, got {type(value)!r}") + amount = Decimal(str(value)) + if not amount.is_finite(): + raise ValueError("Decimal value must be finite") + return amount + + +def _coerce_currency(value: BaseCurrency | str) -> BaseCurrency: + if isinstance(value, BaseCurrency): + return value + try: + return BaseCurrency(value) + except ValueError as exc: + raise ValueError(f"Invalid currency: {value!r}") from exc + + +def _coerce_weight_unit(value: WeightUnit | str) -> WeightUnit: + if isinstance(value, WeightUnit): + return value + try: + return WeightUnit(value) + except ValueError as exc: + raise ValueError(f"Invalid weight unit: {value!r}") from exc + + +def weight_unit_factor(unit: WeightUnit) -> Decimal: + if unit is WeightUnit.GRAM: + return Decimal("1") + if unit is WeightUnit.KILOGRAM: + return GRAMS_PER_KILOGRAM + if unit is WeightUnit.OUNCE_TROY: + return GRAMS_PER_TROY_OUNCE + raise ValueError(f"Unsupported weight unit: {unit}") + + +def convert_weight(amount: Decimal, from_unit: WeightUnit, to_unit: WeightUnit) -> Decimal: + if from_unit is to_unit: + return amount + grams = amount * weight_unit_factor(from_unit) + return grams / weight_unit_factor(to_unit) + + +def convert_price_per_weight(amount: Decimal, from_unit: WeightUnit, to_unit: WeightUnit) -> Decimal: + if from_unit is to_unit: + return amount + return amount * weight_unit_factor(to_unit) / weight_unit_factor(from_unit) + + +@dataclass(frozen=True, slots=True) +class Money: + amount: Decimal + currency: BaseCurrency | str + + def __post_init__(self) -> None: + object.__setattr__(self, "amount", to_decimal(self.amount)) + object.__setattr__(self, "currency", _coerce_currency(self.currency)) + + @classmethod + def zero(cls, currency: BaseCurrency) -> Money: + return cls(amount=Decimal("0"), currency=currency) + + def assert_currency(self, currency: BaseCurrency) -> Money: + if self.currency is not currency: + raise ValueError(f"Currency mismatch: {self.currency} != {currency}") + return self + + def __add__(self, other: object) -> Money: + if not isinstance(other, Money): + return NotImplemented + if self.currency is not other.currency: + raise ValueError(f"Currency mismatch: {self.currency} != {other.currency}") + return Money(amount=self.amount + other.amount, currency=self.currency) + + def __sub__(self, other: object) -> Money: + if not isinstance(other, Money): + return NotImplemented + if self.currency is not other.currency: + raise ValueError(f"Currency mismatch: {self.currency} != {other.currency}") + return Money(amount=self.amount - other.amount, currency=self.currency) + + def __mul__(self, other: object) -> Money: + if isinstance(other, bool): + return NotImplemented + if isinstance(other, Decimal): + return Money(amount=self.amount * other, currency=self.currency) + if isinstance(other, int): + return Money(amount=self.amount * Decimal(other), currency=self.currency) + return NotImplemented + + def __rmul__(self, other: object) -> Money: + return self.__mul__(other) + + def __truediv__(self, other: object) -> Money: + if isinstance(other, bool): + return NotImplemented + if isinstance(other, Decimal): + return Money(amount=self.amount / other, currency=self.currency) + if isinstance(other, int): + return Money(amount=self.amount / Decimal(other), currency=self.currency) + return NotImplemented + + def __neg__(self) -> Money: + return Money(amount=-self.amount, currency=self.currency) + + +@dataclass(frozen=True, slots=True) +class Weight: + amount: Decimal + unit: WeightUnit | str + + def __post_init__(self) -> None: + object.__setattr__(self, "amount", to_decimal(self.amount)) + object.__setattr__(self, "unit", _coerce_weight_unit(self.unit)) + + def to_unit(self, unit: WeightUnit) -> Weight: + return Weight(amount=convert_weight(self.amount, self.unit, unit), unit=unit) + + def __add__(self, other: object) -> Weight: + if not isinstance(other, Weight): + return NotImplemented + other_converted = other.to_unit(self.unit) + return Weight(amount=self.amount + other_converted.amount, unit=self.unit) + + def __sub__(self, other: object) -> Weight: + if not isinstance(other, Weight): + return NotImplemented + other_converted = other.to_unit(self.unit) + return Weight(amount=self.amount - other_converted.amount, unit=self.unit) + + def __mul__(self, other: object) -> Weight | Money: + if isinstance(other, bool): + return NotImplemented + if isinstance(other, Decimal): + return Weight(amount=self.amount * other, unit=self.unit) + if isinstance(other, int): + return Weight(amount=self.amount * Decimal(other), unit=self.unit) + if isinstance(other, PricePerWeight): + adjusted_weight = self.to_unit(other.per_unit) + return Money(amount=adjusted_weight.amount * other.amount, currency=other.currency) + return NotImplemented + + def __rmul__(self, other: object) -> Weight: + if isinstance(other, bool): + return NotImplemented + if isinstance(other, Decimal): + return Weight(amount=self.amount * other, unit=self.unit) + if isinstance(other, int): + return Weight(amount=self.amount * Decimal(other), unit=self.unit) + return NotImplemented + + def __truediv__(self, other: object) -> Weight: + if isinstance(other, bool): + return NotImplemented + if isinstance(other, Decimal): + return Weight(amount=self.amount / other, unit=self.unit) + if isinstance(other, int): + return Weight(amount=self.amount / Decimal(other), unit=self.unit) + return NotImplemented + + +@dataclass(frozen=True, slots=True) +class PricePerWeight: + amount: Decimal + currency: BaseCurrency | str + per_unit: WeightUnit | str + + def __post_init__(self) -> None: + amount = to_decimal(self.amount) + if amount < 0: + raise ValueError("PricePerWeight amount must be non-negative") + object.__setattr__(self, "amount", amount) + object.__setattr__(self, "currency", _coerce_currency(self.currency)) + object.__setattr__(self, "per_unit", _coerce_weight_unit(self.per_unit)) + + def to_unit(self, unit: WeightUnit) -> PricePerWeight: + return PricePerWeight( + amount=convert_price_per_weight(self.amount, self.per_unit, unit), + currency=self.currency, + per_unit=unit, + ) + + def __mul__(self, other: object) -> Money | PricePerWeight: + if isinstance(other, bool): + return NotImplemented + if isinstance(other, Weight): + adjusted_weight = other.to_unit(self.per_unit) + return Money(amount=adjusted_weight.amount * self.amount, currency=self.currency) + if isinstance(other, Decimal): + return PricePerWeight(amount=self.amount * other, currency=self.currency, per_unit=self.per_unit) + if isinstance(other, int): + return PricePerWeight(amount=self.amount * Decimal(other), currency=self.currency, per_unit=self.per_unit) + return NotImplemented + + def __rmul__(self, other: object) -> PricePerWeight: + if isinstance(other, bool): + return NotImplemented + if isinstance(other, Decimal): + return PricePerWeight(amount=self.amount * other, currency=self.currency, per_unit=self.per_unit) + if isinstance(other, int): + return PricePerWeight(amount=self.amount * Decimal(other), currency=self.currency, per_unit=self.per_unit) + return NotImplemented diff --git a/docs/CORE-001A_DECIMAL_UNITS_ARCHITECTURE.md b/docs/CORE-001A_DECIMAL_UNITS_ARCHITECTURE.md new file mode 100644 index 0000000..cb442fd --- /dev/null +++ b/docs/CORE-001A_DECIMAL_UNITS_ARCHITECTURE.md @@ -0,0 +1,543 @@ +# CORE-001A Decimal Unit Value Object Architecture + +## Scope + +This document defines the first implementation slice for: + +- **CORE-001** — Explicit Unit/Value Classes for Domain Quantities +- **CORE-001A** — Decimal Unit Value Object Foundation + +The goal is to introduce a small, strict, reusable domain layer that prevents silent unit confusion across portfolio, hedge, and backtesting code. + +This slice is intentionally limited to: + +- enums / typed constants for currency and weight units +- immutable Decimal-based value objects +- explicit conversion methods +- explicitly allowed arithmetic operators +- fail-closed defaults for invalid or ambiguous arithmetic + +This slice should **not** yet migrate every page or calculation path. That belongs to: + +- **CORE-001B** — overview and hedge migration +- **CORE-001C** — backtests and event-comparison migration +- **CORE-001D** — persistence / API / integration cleanup + +--- + +## Design goals + +1. **Eliminate unit ambiguity by construction.** Values must carry unit metadata. +2. **Use Decimal for bookkeeping accuracy.** No binary floating point in core domain value objects. +3. **Fail closed by default.** Arithmetic only works when explicitly defined and unit-safe. +4. **Keep the first slice small.** Add primitives first, then migrate consumers incrementally. +5. **Make edge conversions explicit.** Float-heavy libraries remain at the boundaries only. + +--- + +## Core design decisions + +### 1. Canonical numeric type: `Decimal` + +All domain value objects introduced in this slice should use Python `Decimal` as the canonical numeric representation. + +Implementation guidance: + +- never construct `Decimal` directly from `float` +- construct from: + - `str` + - `int` + - existing `Decimal` +- introduce a small helper such as `to_decimal(value: Decimal | int | str) -> Decimal` +- if a float enters from an external provider, convert it at the edge using a deliberate helper, e.g. `decimal_from_float(value: float) -> Decimal` + +### 2. Immutable value objects + +Use frozen dataclasses for predictable behavior and easy testing. + +Recommended style: + +- `@dataclass(frozen=True, slots=True)` +- validation in `__post_init__` +- methods return new values rather than mutating in place + +### 3. Unit metadata is mandatory + +A raw numeric value without unit/currency metadata must not be considered a domain quantity. + +Examples: + +- `Money(amount=Decimal("1000"), currency=BaseCurrency.USD)` +- `GoldQuantity(amount=Decimal("220"), unit=WeightUnit.OUNCE_TROY)` +- `PricePerWeight(amount=Decimal("4400"), currency=BaseCurrency.USD, per_unit=WeightUnit.OUNCE_TROY)` + +### 4. Unsupported operators should fail + +Do not make these classes behave like plain numbers. + +Examples of operations that should fail unless explicitly defined: + +- `Money + GoldQuantity` +- `GoldQuantity + PricePerWeight` +- `Money * Money` +- `PricePerWeight + Money` +- adding values with different currencies without explicit conversion support + +### 5. Explicit conversions only + +Unit changes must be requested directly. + +Examples: + +- `gold.to_unit(WeightUnit.GRAM)` +- `price.to_unit(WeightUnit.KILOGRAM)` +- `money.assert_currency(BaseCurrency.USD)` + +--- + +## Proposed module layout + +Recommended first location: + +- `app/domain/units.py` + +Optional supporting files if it grows: + +- `app/domain/__init__.py` +- `app/domain/decimal_utils.py` +- `app/domain/exceptions.py` + +Reasoning: + +- this is core domain logic, not page logic +- it should be usable from models, services, calculations, and backtesting +- avoid burying it inside `app/models/` if the types are broader than persistence models + +--- + +## Proposed enums + +## `BaseCurrency` + +```python +from enum import StrEnum + +class BaseCurrency(StrEnum): + USD = "USD" + EUR = "EUR" + CHF = "CHF" +``` + +Notes: + +- omit `Invalid`; prefer validation failure over sentinel invalid values +- add currencies only when needed + +## `WeightUnit` + +```python +from enum import StrEnum + +class WeightUnit(StrEnum): + GRAM = "g" + KILOGRAM = "kg" + OUNCE_TROY = "ozt" +``` + +Notes: + +- use **troy ounce**, not generic `oz`, because gold math should be explicit +- naming should be domain-precise to avoid ounce ambiguity + +--- + +## Proposed conversion constants + +Use Decimal constants, not floats. + +```python +GRAMS_PER_KILOGRAM = Decimal("1000") +GRAMS_PER_TROY_OUNCE = Decimal("31.1034768") +``` + +Recommended helper: + +```python +def weight_unit_factor(unit: WeightUnit) -> Decimal: + ... +``` + +Interpretation: + +- factor returns grams per given unit +- conversions can normalize through grams + +Example: + +```python +def convert_weight(amount: Decimal, from_unit: WeightUnit, to_unit: WeightUnit) -> Decimal: + grams = amount * weight_unit_factor(from_unit) + return grams / weight_unit_factor(to_unit) +``` + +--- + +## Proposed value objects + +## `Money` + +```python +@dataclass(frozen=True, slots=True) +class Money: + amount: Decimal + currency: BaseCurrency +``` + +### Allowed operations + +- `Money + Money -> Money` if same currency +- `Money - Money -> Money` if same currency +- `Money * Decimal -> Money` +- `Money / Decimal -> Money` +- unary negation +- equality on same currency and amount + +### Must fail + +- addition/subtraction across different currencies +- multiplication by `Money` +- addition to non-money quantities +- division by `Money` unless a future ratio type is added explicitly + +### Recommended methods + +- `zero(currency: BaseCurrency) -> Money` +- `assert_currency(currency: BaseCurrency) -> Money` +- `quantize_cents() -> Money` (optional, for display/persistence edges only) + +## `Weight` + +Use a neutral weight-bearing quantity rather than a `Gold` type for the foundation layer. + +```python +@dataclass(frozen=True, slots=True) +class Weight: + amount: Decimal + unit: WeightUnit +``` + +### Allowed operations + +- `Weight + Weight -> Weight` after explicit normalization or same-unit conversion inside method +- `Weight - Weight -> Weight` +- `Weight * Decimal -> Weight` +- `Weight / Decimal -> Weight` +- `to_unit(unit: WeightUnit) -> Weight` + +### Must fail + +- implicit multiplication with `Weight` +- addition to `Money` +- comparison without normalization if not explicitly handled + +## `PricePerWeight` + +```python +@dataclass(frozen=True, slots=True) +class PricePerWeight: + amount: Decimal + currency: BaseCurrency + per_unit: WeightUnit +``` + +Interpretation: + +- `4400 USD / ozt` +- `141.46 USD / g` + +### Allowed operations + +- `PricePerWeight.to_unit(unit: WeightUnit) -> PricePerWeight` +- `Weight * PricePerWeight -> Money` +- `PricePerWeight * Weight -> Money` +- `PricePerWeight * Decimal -> PricePerWeight` (optional; acceptable if useful) + +### Must fail + +- adding prices of different currencies without explicit conversion +- adding `PricePerWeight` to `Money` +- multiplying `PricePerWeight * PricePerWeight` + +## `AssetQuantity` + +Backtesting currently uses neutral `underlying_units`, while live pages also use physical-gold semantics. For the foundation layer, keep this explicit. + +Two viable approaches: + +### Option A: only introduce `Weight` in CORE-001A + +Pros: + +- simpler first slice +- cleanly solves current gold/spot confusion first + +Cons: + +- historical `underlying_units` remain primitive a bit longer + +### Option B: also introduce a neutral counted-asset quantity + +```python +@dataclass(frozen=True, slots=True) +class AssetQuantity: + amount: Decimal + symbol: str +``` + +Recommendation: + +- **Option A for CORE-001A** +- defer `AssetQuantity` to CORE-001C where historical scenario boundaries are migrated + +--- + +## Explicit operator design + +The main rule is: + +> only define operators that are unquestionably unit-safe and domain-obvious. + +Recommended first operator set: + +```python +Weight * PricePerWeight -> Money +PricePerWeight * Weight -> Money +Money + Money -> Money +Money - Money -> Money +Weight + Weight -> Weight +Weight - Weight -> Weight +``` + +Example pseudocode: + +```python +def __mul__(self, other: object) -> Money: + if isinstance(other, PricePerWeight): + adjusted_price = other.to_unit(self.unit) + return Money( + amount=self.amount * adjusted_price.amount, + currency=adjusted_price.currency, + ) + return NotImplemented +``` + +Important note: + +- returning `NotImplemented` is correct for unsupported operator pairs +- if the reverse operation is also unsupported, Python will raise a `TypeError` +- that is the desired fail-closed behavior + +--- + +## Validation rules + +Recommended invariants: + +### Money + +- currency required +- amount finite Decimal + +### Weight + +- unit required +- amount finite Decimal +- negative values allowed only if the domain needs them; otherwise reject at construction + +Recommendation: + +- allow negative values in the primitive type +- enforce business positivity at higher-level models where appropriate + +### PricePerWeight + +- currency required +- per-unit required +- amount must be non-negative for current use cases + +--- + +## Serialization guidance + +For CORE-001A, serialization can remain explicit and boring. + +Recommended shape: + +```python +{ + "amount": "4400.00", + "currency": "USD", + "per_unit": "ozt" +} +``` + +Guidelines: + +- serialize `Decimal` as string, not float +- keep enum serialization stable and human-readable +- avoid hidden coercion in JSON helpers + +Persistence migration itself belongs primarily to **CORE-001D**, but the foundational classes should be serialization-friendly. + +--- + +## Interop boundaries + +Several existing libraries/services are float-heavy: + +- yfinance payloads +- chart libraries +- some existing calculations in `app/core/` +- option pricing inputs/outputs + +CORE-001A should establish a clear policy: + +### Inside the core domain + +- use Decimal-bearing unit-safe types + +### At external edges + +- accept floats only in adapters +- immediately convert to Decimal-bearing domain types +- convert back to floats only when required by third-party/chart APIs + +Recommended helper names: + +- `decimal_from_provider_float(...)` +- `money_from_float_usd(...)` +- `price_per_ounce_usd_from_float(...)` +- `to_chart_float(...)` + +The helper names should make the boundary obvious. + +--- + +## Suggested implementation order + +### Step 1: add foundational helpers + +- `to_decimal(...)` +- `decimal_from_float(...)` +- weight conversion constants and helpers + +### Step 2: add enums + +- `BaseCurrency` +- `WeightUnit` + +### Step 3: add frozen value objects + +- `Money` +- `Weight` +- `PricePerWeight` + +### Step 4: add unit tests first + +Test at least: + +- `Weight(Decimal("1"), OUNCE_TROY).to_unit(GRAM)` +- `PricePerWeight(USD/ozt).to_unit(GRAM)` +- `Weight * PricePerWeight -> Money` +- `Money + Money` same currency succeeds +- `Money + Money` different currency fails +- invalid operator combinations raise `TypeError` +- decimal construction helpers reject unsafe ambiguous input patterns if desired + +### Step 5: add one thin usage seam + +Before full migration, wire one small non-invasive helper or calculation path to prove ergonomics. + +Recommendation: + +- introduce a helper used by the overview quote fallback path or a standalone calculation test first +- keep page-wide migration for CORE-001B + +--- + +## Proposed first test file + +- `tests/test_units.py` + +Recommended test groups: + +1. Decimal normalization helpers +2. Weight conversions +3. Price-per-weight conversions +4. Unit-safe multiplication +5. Currency mismatch failures +6. Unsupported operator failures +7. Serialization shape helpers if implemented in this slice + +--- + +## Migration notes for later slices + +## CORE-001B + +Migrate: + +- overview spot resolution / fallback +- margin-call math +- hedge starting-position math +- hedge scenario contribution math where unit-bearing values are mixed + +## CORE-001C + +Migrate: + +- workspace `gold_value -> historical underlying_units` conversion +- backtest scenario portfolio construction +- event comparison scenario materialization +- explicit distinction between: + - physical gold weight + - USD notional collateral value + - historical underlying units + +## CORE-001D + +Clean up: + +- persistence schemas +- API serialization +- cache payloads +- third-party provider adapters + +--- + +## Non-goals for CORE-001A + +Do not attempt all of this in the first slice: + +- full currency FX conversion +- replacing every float in the app +- redesigning all existing Pydantic models at once +- full options contract quantity modeling +- chart formatting overhaul +- database migration complexity + +--- + +## Recommendation + +Implement `CORE-001A` as a small, strict, test-first domain package introducing: + +- `BaseCurrency` +- `WeightUnit` +- `Money` +- `Weight` +- `PricePerWeight` + +all backed by `Decimal`, immutable, with explicit conversions and only a tiny allowed operator surface. + +That creates the foundation needed to safely migrate the visible calculation paths in `CORE-001B` and the historical scenario paths in `CORE-001C` without repeating the unit-confusion bugs already discovered in overview and backtesting. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a297888..dafa90a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -496,14 +496,124 @@ DATA-001 (Price Feed) **Dependencies:** BT-001A, BT-003A +### CORE-001: Explicit Unit/Value Classes for Domain Quantities [P1, L] **[foundation]** +**As a** developer, **I want** money, weight, spot-price, and quantity values to use explicit unit-aware value classes **so that** incompatible units cannot be mixed silently in calculations. + +**Acceptance Criteria:** +- Introduce explicit domain value types for at least: + - currency amount + - currency per weight + - gold/underlying quantity + - weight unit + - base currency +- All unit-bearing numeric values use decimal-based representation suitable for bookkeeping/accounting accuracy rather than binary floating point +- Each value object carries both numeric value and declared unit/currency metadata +- Implicit arithmetic between incompatible types is rejected by default +- Only explicit conversions and explicitly defined operators are allowed +- Conversions between supported weight units are implemented and tested +- Domain operations express intent clearly, e.g. multiplying `Gold` by `CurrencyPerWeight` yields `Currency` +- Existing high-risk calculation paths (overview, hedge, backtests, event comparison) are migrated away from raw `float` mixing for unit-bearing values +- Tests cover both valid conversions/operations and invalid combinations that must fail loudly + +**Technical Notes:** +- Prefer small immutable value objects / dataclasses over loose primitives +- Use Python `Decimal` (or an equivalent decimal fixed-precision type) as the canonical numeric representation for money, quantities, and conversion-bearing domain values +- Add enums or equivalent typed constants for currency (`USD`, `EUR`, `CHF`, ...) and weight (`grams`, `kilograms`, `oz`) +- Make conversion APIs explicit, e.g. `to_weight(...)`, `to_currency(...)`, or named constructors +- Default operators should be conservative: fail closed unless a unit-safe operation is explicitly defined +- Start with gold/collateral and spot-price paths first, then extend to options/backtesting quantities where needed +- Define clear boundaries for interoperability with existing float-heavy libraries/services so conversion into/out of `Decimal` happens intentionally at the edges +- Use this story to remove the class of bugs caused by mixing physical-gold ounces, ETF-like units, USD notionals, and USD-per-weight spot quotes + +**Dependencies:** None + +### CORE-001A: Decimal Unit Value Object Foundation [P1, M] **[depends: CORE-001]** +**As a** developer, **I want** a small core set of immutable Decimal-based value objects and enums **so that** unit-aware domain modeling exists before we migrate calculation paths. + +**Acceptance Criteria:** +- Introduce foundational domain types for at least: + - `BaseCurrency` + - `WeightUnit` + - `Money` + - `PricePerWeight` + - `GoldQuantity` / equivalent weight-bearing quantity type +- Core numeric fields use `Decimal` as the canonical representation +- Constructors/validators reject invalid or missing unit metadata +- Weight conversion methods are explicit and tested +- Unsupported arithmetic fails closed unless an operator is deliberately defined +- Tests cover constructors, conversions, equality/normalization expectations, and invalid operations + +**Technical Notes:** +- Prefer a dedicated domain module such as `app/domain/units.py` or similar +- Keep the first slice focused on primitives and unit algebra, not page migration yet +- Add string/serialization helpers carefully so persistence remains deterministic +- See `docs/CORE-001A_DECIMAL_UNITS_ARCHITECTURE.md` for the implementation-ready design + +**Dependencies:** CORE-001 + +### CORE-001B: Overview and Hedge Migration to Unit-Safe Types [P1, M] **[depends: CORE-001A, PORT-001A]** +**As a** developer, **I want** overview and hedge calculations to use the new unit-safe Decimal value objects **so that** the most user-visible collateral math stops depending on raw float conventions. + +**Acceptance Criteria:** +- Overview calculations use unit-safe types for collateral quantity, spot price, notional values, and margin-call math +- Hedge scenario calculations use unit-safe types for starting position, option contribution framing, and equity outputs where applicable +- The incompatible live-quote fallback path remains correct under the new model +- Existing visible outputs remain browser-verified on `/overview` and `/hedge` +- Regression tests cover previously discovered unit-confusion bugs + +**Technical Notes:** +- Start with calculation helpers and view-model boundaries before touching every page widget +- Convert to/from legacy float-heavy libraries only at the edges, with explicit naming +- Preserve current Lombard/domain compatibility while migrating internals + +**Dependencies:** CORE-001A, PORT-001A + +### CORE-001C: Backtests and Event Comparison Migration to Unit-Safe Types [P1, M] **[depends: CORE-001A, BT-001, BT-003A]** +**As a** developer, **I want** historical scenario inputs and outputs to use the new unit-safe Decimal value objects **so that** backtests stop mixing live portfolio units, historical units, and notional values implicitly. + +**Acceptance Criteria:** +- Backtest scenario portfolio inputs are expressed via unit-safe Decimal types at the domain boundary +- Event-comparison scenario materialization uses explicit conversions from workspace value basis to historical units +- Historical entry spot, underlying units, and resulting notionals are no longer mixed as raw floats in core scenario setup paths +- Deterministic test/demo paths still work and remain browser-tested +- Tests cover both correct conversions and fail-closed invalid unit combinations + +**Technical Notes:** +- Focus first on scenario/materialization boundaries and summary calculations rather than every chart payload +- Preserve deterministic fixture behavior while tightening the domain model +- This slice should make the current `gold_value -> historical underlying_units` conversion explicit in the type system + +**Dependencies:** CORE-001A, BT-001, BT-003A + +### CORE-001D: External Boundary and Persistence Cleanup for Decimal Unit Types [P2, M] **[depends: CORE-001B, CORE-001C]** +**As a** developer, **I want** persistence and service boundaries to handle Decimal unit-safe types explicitly **so that** the new model remains reliable across JSON, APIs, caches, and third-party integrations. + +**Acceptance Criteria:** +- Persistence format for unit-safe values is explicit and stable +- JSON/API serialization for Decimal-bearing unit objects is documented and tested +- Float-heavy provider integrations have named conversion boundaries +- Remaining raw-float domain hotspots are identified or removed +- Developer docs note when to use unit-safe value objects vs primitive edge values + +**Technical Notes:** +- Keep transport/persistence schemas boring and explicit; avoid magic implicit coercion +- Consider typed DTOs at the edges if needed to keep core domain objects strict +- This is the cleanup slice that reduces backsliding after the initial migrations + +**Dependencies:** CORE-001B, CORE-001C + ## Implementation Priority Queue -1. **PORT-004** - Introduce hashed workspace persistence as the new state foundation -2. **BT-001B** - Prefill backtest UIs from canonical saved portfolio settings -3. **BT-003B** - Add event-comparison drilldown and explanation of ranking outcomes -4. **PORT-003** - Historical LTV visibility and export groundwork -5. **BT-002** - Upgrade backtests to real daily options snapshots -6. **BT-001C** - Consolidate deterministic historical fixture/demo provider logic -7. **EXEC-001** - Core user workflow -8. **EXEC-002** - Execution capability -9. Remaining features +1. **CORE-001A** - Introduce Decimal-based unit/value object foundations before more calculation drift accumulates +2. **CORE-001B** - Migrate overview and hedge to unit-safe types on the most user-visible paths +3. **PORT-004** - Continue hashed workspace persistence as the new state foundation +4. **BT-001B** - Prefill backtest UIs from canonical saved portfolio settings +5. **CORE-001C** - Migrate backtests and event comparison to unit-safe scenario/value handling +6. **BT-003B** - Add event-comparison drilldown and explanation of ranking outcomes +7. **PORT-003** - Historical LTV visibility and export groundwork +8. **BT-002** - Upgrade backtests to real daily options snapshots +9. **BT-001C** - Consolidate deterministic historical fixture/demo provider logic +10. **CORE-001D** - Clean up persistence and external boundaries for Decimal unit-safe types +11. **EXEC-001** - Core user workflow +12. **EXEC-002** - Execution capability +13. Remaining features diff --git a/tests/test_units.py b/tests/test_units.py new file mode 100644 index 0000000..c876522 --- /dev/null +++ b/tests/test_units.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from app.domain.units import ( + BaseCurrency, + Money, + PricePerWeight, + Weight, + WeightUnit, + decimal_from_float, + to_decimal, +) + + +def test_to_decimal_accepts_decimal_int_and_string() -> None: + assert to_decimal(Decimal("1.25")) == Decimal("1.25") + assert to_decimal(2) == Decimal("2") + assert to_decimal("3.50") == Decimal("3.50") + + +@pytest.mark.parametrize("value", [1.25, True, False, object()]) +def test_to_decimal_rejects_unsafe_or_unknown_types(value: object) -> None: + with pytest.raises(TypeError): + to_decimal(value) # type: ignore[arg-type] + + +def test_decimal_from_float_converts_explicitly() -> None: + assert decimal_from_float(31.1) == Decimal("31.1") + + +@pytest.mark.parametrize("value", [float("inf"), float("-inf"), float("nan")]) +def test_decimal_from_float_rejects_non_finite_values(value: float) -> None: + with pytest.raises(ValueError, match="must be finite"): + decimal_from_float(value) + + +@pytest.mark.parametrize("value", ["NaN", "Infinity", "-Infinity"]) +def test_to_decimal_rejects_non_finite_values(value: str) -> None: + with pytest.raises(ValueError, match="must be finite"): + to_decimal(value) + + +def test_money_coerces_currency_from_string() -> None: + money = Money(amount=Decimal("10"), currency="USD") + + assert money.currency is BaseCurrency.USD + + +def test_weight_coerces_unit_from_string() -> None: + weight = Weight(amount=Decimal("1"), unit="ozt") + + assert weight.unit is WeightUnit.OUNCE_TROY + + +@pytest.mark.parametrize( + ("factory", "message"), + [ + (lambda: Money(amount=Decimal("1"), currency="BAD"), "Invalid currency"), + (lambda: Weight(amount=Decimal("1"), unit="bad"), "Invalid weight unit"), + ( + lambda: PricePerWeight(amount=Decimal("1"), currency="USD", per_unit="bad"), + "Invalid weight unit", + ), + ], +) +def test_value_objects_reject_invalid_enum_values(factory: object, message: str) -> None: + with pytest.raises(ValueError, match=message): + factory() # type: ignore[operator] + + +@pytest.mark.parametrize( + ("weight", "target_unit", "expected_amount"), + [ + (Weight(amount=Decimal("1"), unit=WeightUnit.KILOGRAM), WeightUnit.GRAM, Decimal("1000")), + ( + Weight(amount=Decimal("1"), unit=WeightUnit.OUNCE_TROY), + WeightUnit.GRAM, + Decimal("31.1034768"), + ), + ( + Weight(amount=Decimal("31.1034768"), unit=WeightUnit.GRAM), + WeightUnit.OUNCE_TROY, + Decimal("1"), + ), + ], +) +def test_weight_to_unit_converts_explicitly(weight: Weight, target_unit: WeightUnit, expected_amount: Decimal) -> None: + converted = weight.to_unit(target_unit) + + assert converted.unit is target_unit + assert converted.amount == expected_amount + + +def test_weight_addition_converts_other_weight_to_left_hand_unit() -> None: + left = Weight(amount=Decimal("1"), unit=WeightUnit.KILOGRAM) + right = Weight(amount=Decimal("500"), unit=WeightUnit.GRAM) + + result = left + right + + assert result.unit is WeightUnit.KILOGRAM + assert result.amount == Decimal("1.5") + + +def test_money_addition_requires_same_currency() -> None: + total = Money(amount=Decimal("10"), currency=BaseCurrency.USD) + Money( + amount=Decimal("2.50"), currency=BaseCurrency.USD + ) + + assert total == Money(amount=Decimal("12.50"), currency=BaseCurrency.USD) + + +def test_money_addition_rejects_currency_mismatch() -> None: + with pytest.raises(ValueError, match="Currency mismatch"): + _ = Money(amount=Decimal("10"), currency=BaseCurrency.USD) + Money( + amount=Decimal("5"), currency=BaseCurrency.EUR + ) + + +def test_price_per_weight_to_unit_converts_explicitly() -> None: + price = PricePerWeight( + amount=Decimal("4400"), + currency=BaseCurrency.USD, + per_unit=WeightUnit.OUNCE_TROY, + ) + + converted = price.to_unit(WeightUnit.GRAM) + + assert converted.currency is BaseCurrency.USD + assert converted.per_unit is WeightUnit.GRAM + assert converted.amount == Decimal("141.4632849019631142972415225") + + +@pytest.mark.parametrize( + ("weight", "price", "expected"), + [ + ( + Weight(amount=Decimal("220"), unit=WeightUnit.OUNCE_TROY), + PricePerWeight( + amount=Decimal("4400"), + currency=BaseCurrency.USD, + per_unit=WeightUnit.OUNCE_TROY, + ), + Money(amount=Decimal("968000"), currency=BaseCurrency.USD), + ), + ( + Weight(amount=Decimal("31.1034768"), unit=WeightUnit.GRAM), + PricePerWeight( + amount=Decimal("4400"), + currency=BaseCurrency.USD, + per_unit=WeightUnit.OUNCE_TROY, + ), + Money(amount=Decimal("4400"), currency=BaseCurrency.USD), + ), + ], +) +def test_weight_multiplied_by_price_per_weight_returns_money( + weight: Weight, price: PricePerWeight, expected: Money +) -> None: + assert weight * price == expected + assert price * weight == expected + + +def test_unsupported_operator_pair_fails_closed() -> None: + with pytest.raises(TypeError): + _ = Money(amount=Decimal("10"), currency=BaseCurrency.USD) * Money( + amount=Decimal("2"), currency=BaseCurrency.USD + ) + + +@pytest.mark.parametrize( + "expression", + [ + lambda: Money(amount=Decimal("10"), currency=BaseCurrency.USD) * True, + lambda: Money(amount=Decimal("10"), currency=BaseCurrency.USD) / True, + lambda: Weight(amount=Decimal("10"), unit=WeightUnit.GRAM) * True, + lambda: Weight(amount=Decimal("10"), unit=WeightUnit.GRAM) / True, + lambda: PricePerWeight(amount=Decimal("10"), currency=BaseCurrency.USD, per_unit=WeightUnit.GRAM) * True, + ], +) +def test_scalar_operators_reject_boolean_values(expression: object) -> None: + with pytest.raises(TypeError): + expression() # type: ignore[operator] + + +def test_price_per_weight_rejects_negative_amount() -> None: + with pytest.raises(ValueError, match="must be non-negative"): + PricePerWeight( + amount=Decimal("-1"), + currency=BaseCurrency.USD, + per_unit=WeightUnit.GRAM, + ) + + +@pytest.mark.parametrize( + ("factory", "message"), + [ + (lambda: Money(amount="NaN", currency=BaseCurrency.USD), "must be finite"), + (lambda: Weight(amount="Infinity", unit=WeightUnit.GRAM), "must be finite"), + ( + lambda: PricePerWeight( + amount="-Infinity", + currency=BaseCurrency.USD, + per_unit=WeightUnit.GRAM, + ), + "must be finite", + ), + ], +) +def test_value_objects_reject_non_finite_amounts(factory: object, message: str) -> None: + with pytest.raises(ValueError, match=message): + factory() # type: ignore[operator] + + +@pytest.mark.parametrize( + "factory", + [ + lambda: Money(amount=True, currency=BaseCurrency.USD), + lambda: Weight(amount=True, unit=WeightUnit.GRAM), + lambda: PricePerWeight(amount=True, currency=BaseCurrency.USD, per_unit=WeightUnit.GRAM), + ], +) +def test_value_objects_reject_boolean_amounts(factory: object) -> None: + with pytest.raises(TypeError, match="Boolean values are not valid Decimal inputs"): + factory() # type: ignore[operator]