# 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.