12 KiB
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
- Eliminate unit ambiguity by construction. Values must carry unit metadata.
- Use Decimal for bookkeeping accuracy. No binary floating point in core domain value objects.
- Fail closed by default. Arithmetic only works when explicitly defined and unit-safe.
- Keep the first slice small. Add primitives first, then migrate consumers incrementally.
- 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
Decimaldirectly fromfloat - construct from:
strint- 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 + GoldQuantityGoldQuantity + PricePerWeightMoney * MoneyPricePerWeight + 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__.pyapp/domain/decimal_utils.pyapp/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
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
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.
GRAMS_PER_KILOGRAM = Decimal("1000")
GRAMS_PER_TROY_OUNCE = Decimal("31.1034768")
Recommended helper:
def weight_unit_factor(unit: WeightUnit) -> Decimal:
...
Interpretation:
- factor returns grams per given unit
- conversions can normalize through grams
Example:
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
@dataclass(frozen=True, slots=True)
class Money:
amount: Decimal
currency: BaseCurrency
Allowed operations
Money + Money -> Moneyif same currencyMoney - Money -> Moneyif same currencyMoney * Decimal -> MoneyMoney / 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
Moneyunless a future ratio type is added explicitly
Recommended methods
zero(currency: BaseCurrency) -> Moneyassert_currency(currency: BaseCurrency) -> Moneyquantize_cents() -> Money(optional, for display/persistence edges only)
Weight
Use a neutral weight-bearing quantity rather than a Gold type for the foundation layer.
@dataclass(frozen=True, slots=True)
class Weight:
amount: Decimal
unit: WeightUnit
Allowed operations
Weight + Weight -> Weightafter explicit normalization or same-unit conversion inside methodWeight - Weight -> WeightWeight * Decimal -> WeightWeight / Decimal -> Weightto_unit(unit: WeightUnit) -> Weight
Must fail
- implicit multiplication with
Weight - addition to
Money - comparison without normalization if not explicitly handled
PricePerWeight
@dataclass(frozen=True, slots=True)
class PricePerWeight:
amount: Decimal
currency: BaseCurrency
per_unit: WeightUnit
Interpretation:
4400 USD / ozt141.46 USD / g
Allowed operations
PricePerWeight.to_unit(unit: WeightUnit) -> PricePerWeightWeight * PricePerWeight -> MoneyPricePerWeight * Weight -> MoneyPricePerWeight * Decimal -> PricePerWeight(optional; acceptable if useful)
Must fail
- adding prices of different currencies without explicit conversion
- adding
PricePerWeighttoMoney - 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_unitsremain primitive a bit longer
Option B: also introduce a neutral counted-asset quantity
@dataclass(frozen=True, slots=True)
class AssetQuantity:
amount: Decimal
symbol: str
Recommendation:
- Option A for CORE-001A
- defer
AssetQuantityto 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:
Weight * PricePerWeight -> Money
PricePerWeight * Weight -> Money
Money + Money -> Money
Money - Money -> Money
Weight + Weight -> Weight
Weight - Weight -> Weight
Example pseudocode:
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
NotImplementedis 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:
{
"amount": "4400.00",
"currency": "USD",
"per_unit": "ozt"
}
Guidelines:
- serialize
Decimalas 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
BaseCurrencyWeightUnit
Step 3: add frozen value objects
MoneyWeightPricePerWeight
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 -> MoneyMoney + Moneysame currency succeedsMoney + Moneydifferent 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:
- Decimal normalization helpers
- Weight conversions
- Price-per-weight conversions
- Unit-safe multiplication
- Currency mismatch failures
- Unsupported operator failures
- 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_unitsconversion - 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:
BaseCurrencyWeightUnitMoneyWeightPricePerWeight
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.