Files
vault-dash/docs/CORE-001A_DECIMAL_UNITS_ARCHITECTURE.md

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

  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

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

@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

@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

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

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

{
  "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.