544 lines
12 KiB
Markdown
544 lines
12 KiB
Markdown
# 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.
|