feat(CORE-001A): add decimal unit value foundation

This commit is contained in:
Bu5hm4nn
2026-03-24 21:33:17 +01:00
parent 5ac66ea97b
commit a69fdf6762
5 changed files with 1153 additions and 9 deletions

View File

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

View File

@@ -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