feat(CORE-001A): add decimal unit value foundation
This commit is contained in:
19
app/domain/__init__.py
Normal file
19
app/domain/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from app.domain.units import (
|
||||
BaseCurrency,
|
||||
Money,
|
||||
PricePerWeight,
|
||||
Weight,
|
||||
WeightUnit,
|
||||
decimal_from_float,
|
||||
to_decimal,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BaseCurrency",
|
||||
"WeightUnit",
|
||||
"Money",
|
||||
"Weight",
|
||||
"PricePerWeight",
|
||||
"to_decimal",
|
||||
"decimal_from_float",
|
||||
]
|
||||
245
app/domain/units.py
Normal file
245
app/domain/units.py
Normal file
@@ -0,0 +1,245 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class BaseCurrency(StrEnum):
|
||||
USD = "USD"
|
||||
EUR = "EUR"
|
||||
CHF = "CHF"
|
||||
|
||||
|
||||
class WeightUnit(StrEnum):
|
||||
GRAM = "g"
|
||||
KILOGRAM = "kg"
|
||||
OUNCE_TROY = "ozt"
|
||||
|
||||
|
||||
GRAMS_PER_KILOGRAM = Decimal("1000")
|
||||
GRAMS_PER_TROY_OUNCE = Decimal("31.1034768")
|
||||
|
||||
|
||||
DecimalLike = Decimal | int | str
|
||||
|
||||
|
||||
def to_decimal(value: DecimalLike) -> Decimal:
|
||||
if isinstance(value, bool):
|
||||
raise TypeError("Boolean values are not valid Decimal inputs")
|
||||
if isinstance(value, Decimal):
|
||||
amount = value
|
||||
elif isinstance(value, int):
|
||||
amount = Decimal(value)
|
||||
elif isinstance(value, str):
|
||||
amount = Decimal(value)
|
||||
else:
|
||||
raise TypeError(f"Unsupported decimal input type: {type(value)!r}")
|
||||
if not amount.is_finite():
|
||||
raise ValueError("Decimal value must be finite")
|
||||
return amount
|
||||
|
||||
|
||||
def decimal_from_float(value: float) -> Decimal:
|
||||
if not isinstance(value, float):
|
||||
raise TypeError(f"Expected float, got {type(value)!r}")
|
||||
amount = Decimal(str(value))
|
||||
if not amount.is_finite():
|
||||
raise ValueError("Decimal value must be finite")
|
||||
return amount
|
||||
|
||||
|
||||
def _coerce_currency(value: BaseCurrency | str) -> BaseCurrency:
|
||||
if isinstance(value, BaseCurrency):
|
||||
return value
|
||||
try:
|
||||
return BaseCurrency(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid currency: {value!r}") from exc
|
||||
|
||||
|
||||
def _coerce_weight_unit(value: WeightUnit | str) -> WeightUnit:
|
||||
if isinstance(value, WeightUnit):
|
||||
return value
|
||||
try:
|
||||
return WeightUnit(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid weight unit: {value!r}") from exc
|
||||
|
||||
|
||||
def weight_unit_factor(unit: WeightUnit) -> Decimal:
|
||||
if unit is WeightUnit.GRAM:
|
||||
return Decimal("1")
|
||||
if unit is WeightUnit.KILOGRAM:
|
||||
return GRAMS_PER_KILOGRAM
|
||||
if unit is WeightUnit.OUNCE_TROY:
|
||||
return GRAMS_PER_TROY_OUNCE
|
||||
raise ValueError(f"Unsupported weight unit: {unit}")
|
||||
|
||||
|
||||
def convert_weight(amount: Decimal, from_unit: WeightUnit, to_unit: WeightUnit) -> Decimal:
|
||||
if from_unit is to_unit:
|
||||
return amount
|
||||
grams = amount * weight_unit_factor(from_unit)
|
||||
return grams / weight_unit_factor(to_unit)
|
||||
|
||||
|
||||
def convert_price_per_weight(amount: Decimal, from_unit: WeightUnit, to_unit: WeightUnit) -> Decimal:
|
||||
if from_unit is to_unit:
|
||||
return amount
|
||||
return amount * weight_unit_factor(to_unit) / weight_unit_factor(from_unit)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Money:
|
||||
amount: Decimal
|
||||
currency: BaseCurrency | str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "amount", to_decimal(self.amount))
|
||||
object.__setattr__(self, "currency", _coerce_currency(self.currency))
|
||||
|
||||
@classmethod
|
||||
def zero(cls, currency: BaseCurrency) -> Money:
|
||||
return cls(amount=Decimal("0"), currency=currency)
|
||||
|
||||
def assert_currency(self, currency: BaseCurrency) -> Money:
|
||||
if self.currency is not currency:
|
||||
raise ValueError(f"Currency mismatch: {self.currency} != {currency}")
|
||||
return self
|
||||
|
||||
def __add__(self, other: object) -> Money:
|
||||
if not isinstance(other, Money):
|
||||
return NotImplemented
|
||||
if self.currency is not other.currency:
|
||||
raise ValueError(f"Currency mismatch: {self.currency} != {other.currency}")
|
||||
return Money(amount=self.amount + other.amount, currency=self.currency)
|
||||
|
||||
def __sub__(self, other: object) -> Money:
|
||||
if not isinstance(other, Money):
|
||||
return NotImplemented
|
||||
if self.currency is not other.currency:
|
||||
raise ValueError(f"Currency mismatch: {self.currency} != {other.currency}")
|
||||
return Money(amount=self.amount - other.amount, currency=self.currency)
|
||||
|
||||
def __mul__(self, other: object) -> Money:
|
||||
if isinstance(other, bool):
|
||||
return NotImplemented
|
||||
if isinstance(other, Decimal):
|
||||
return Money(amount=self.amount * other, currency=self.currency)
|
||||
if isinstance(other, int):
|
||||
return Money(amount=self.amount * Decimal(other), currency=self.currency)
|
||||
return NotImplemented
|
||||
|
||||
def __rmul__(self, other: object) -> Money:
|
||||
return self.__mul__(other)
|
||||
|
||||
def __truediv__(self, other: object) -> Money:
|
||||
if isinstance(other, bool):
|
||||
return NotImplemented
|
||||
if isinstance(other, Decimal):
|
||||
return Money(amount=self.amount / other, currency=self.currency)
|
||||
if isinstance(other, int):
|
||||
return Money(amount=self.amount / Decimal(other), currency=self.currency)
|
||||
return NotImplemented
|
||||
|
||||
def __neg__(self) -> Money:
|
||||
return Money(amount=-self.amount, currency=self.currency)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Weight:
|
||||
amount: Decimal
|
||||
unit: WeightUnit | str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "amount", to_decimal(self.amount))
|
||||
object.__setattr__(self, "unit", _coerce_weight_unit(self.unit))
|
||||
|
||||
def to_unit(self, unit: WeightUnit) -> Weight:
|
||||
return Weight(amount=convert_weight(self.amount, self.unit, unit), unit=unit)
|
||||
|
||||
def __add__(self, other: object) -> Weight:
|
||||
if not isinstance(other, Weight):
|
||||
return NotImplemented
|
||||
other_converted = other.to_unit(self.unit)
|
||||
return Weight(amount=self.amount + other_converted.amount, unit=self.unit)
|
||||
|
||||
def __sub__(self, other: object) -> Weight:
|
||||
if not isinstance(other, Weight):
|
||||
return NotImplemented
|
||||
other_converted = other.to_unit(self.unit)
|
||||
return Weight(amount=self.amount - other_converted.amount, unit=self.unit)
|
||||
|
||||
def __mul__(self, other: object) -> Weight | Money:
|
||||
if isinstance(other, bool):
|
||||
return NotImplemented
|
||||
if isinstance(other, Decimal):
|
||||
return Weight(amount=self.amount * other, unit=self.unit)
|
||||
if isinstance(other, int):
|
||||
return Weight(amount=self.amount * Decimal(other), unit=self.unit)
|
||||
if isinstance(other, PricePerWeight):
|
||||
adjusted_weight = self.to_unit(other.per_unit)
|
||||
return Money(amount=adjusted_weight.amount * other.amount, currency=other.currency)
|
||||
return NotImplemented
|
||||
|
||||
def __rmul__(self, other: object) -> Weight:
|
||||
if isinstance(other, bool):
|
||||
return NotImplemented
|
||||
if isinstance(other, Decimal):
|
||||
return Weight(amount=self.amount * other, unit=self.unit)
|
||||
if isinstance(other, int):
|
||||
return Weight(amount=self.amount * Decimal(other), unit=self.unit)
|
||||
return NotImplemented
|
||||
|
||||
def __truediv__(self, other: object) -> Weight:
|
||||
if isinstance(other, bool):
|
||||
return NotImplemented
|
||||
if isinstance(other, Decimal):
|
||||
return Weight(amount=self.amount / other, unit=self.unit)
|
||||
if isinstance(other, int):
|
||||
return Weight(amount=self.amount / Decimal(other), unit=self.unit)
|
||||
return NotImplemented
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PricePerWeight:
|
||||
amount: Decimal
|
||||
currency: BaseCurrency | str
|
||||
per_unit: WeightUnit | str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
amount = to_decimal(self.amount)
|
||||
if amount < 0:
|
||||
raise ValueError("PricePerWeight amount must be non-negative")
|
||||
object.__setattr__(self, "amount", amount)
|
||||
object.__setattr__(self, "currency", _coerce_currency(self.currency))
|
||||
object.__setattr__(self, "per_unit", _coerce_weight_unit(self.per_unit))
|
||||
|
||||
def to_unit(self, unit: WeightUnit) -> PricePerWeight:
|
||||
return PricePerWeight(
|
||||
amount=convert_price_per_weight(self.amount, self.per_unit, unit),
|
||||
currency=self.currency,
|
||||
per_unit=unit,
|
||||
)
|
||||
|
||||
def __mul__(self, other: object) -> Money | PricePerWeight:
|
||||
if isinstance(other, bool):
|
||||
return NotImplemented
|
||||
if isinstance(other, Weight):
|
||||
adjusted_weight = other.to_unit(self.per_unit)
|
||||
return Money(amount=adjusted_weight.amount * self.amount, currency=self.currency)
|
||||
if isinstance(other, Decimal):
|
||||
return PricePerWeight(amount=self.amount * other, currency=self.currency, per_unit=self.per_unit)
|
||||
if isinstance(other, int):
|
||||
return PricePerWeight(amount=self.amount * Decimal(other), currency=self.currency, per_unit=self.per_unit)
|
||||
return NotImplemented
|
||||
|
||||
def __rmul__(self, other: object) -> PricePerWeight:
|
||||
if isinstance(other, bool):
|
||||
return NotImplemented
|
||||
if isinstance(other, Decimal):
|
||||
return PricePerWeight(amount=self.amount * other, currency=self.currency, per_unit=self.per_unit)
|
||||
if isinstance(other, int):
|
||||
return PricePerWeight(amount=self.amount * Decimal(other), currency=self.currency, per_unit=self.per_unit)
|
||||
return NotImplemented
|
||||
543
docs/CORE-001A_DECIMAL_UNITS_ARCHITECTURE.md
Normal file
543
docs/CORE-001A_DECIMAL_UNITS_ARCHITECTURE.md
Normal 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.
|
||||
128
docs/ROADMAP.md
128
docs/ROADMAP.md
@@ -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
|
||||
|
||||
227
tests/test_units.py
Normal file
227
tests/test_units.py
Normal file
@@ -0,0 +1,227 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domain.units import (
|
||||
BaseCurrency,
|
||||
Money,
|
||||
PricePerWeight,
|
||||
Weight,
|
||||
WeightUnit,
|
||||
decimal_from_float,
|
||||
to_decimal,
|
||||
)
|
||||
|
||||
|
||||
def test_to_decimal_accepts_decimal_int_and_string() -> None:
|
||||
assert to_decimal(Decimal("1.25")) == Decimal("1.25")
|
||||
assert to_decimal(2) == Decimal("2")
|
||||
assert to_decimal("3.50") == Decimal("3.50")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [1.25, True, False, object()])
|
||||
def test_to_decimal_rejects_unsafe_or_unknown_types(value: object) -> None:
|
||||
with pytest.raises(TypeError):
|
||||
to_decimal(value) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_decimal_from_float_converts_explicitly() -> None:
|
||||
assert decimal_from_float(31.1) == Decimal("31.1")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [float("inf"), float("-inf"), float("nan")])
|
||||
def test_decimal_from_float_rejects_non_finite_values(value: float) -> None:
|
||||
with pytest.raises(ValueError, match="must be finite"):
|
||||
decimal_from_float(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["NaN", "Infinity", "-Infinity"])
|
||||
def test_to_decimal_rejects_non_finite_values(value: str) -> None:
|
||||
with pytest.raises(ValueError, match="must be finite"):
|
||||
to_decimal(value)
|
||||
|
||||
|
||||
def test_money_coerces_currency_from_string() -> None:
|
||||
money = Money(amount=Decimal("10"), currency="USD")
|
||||
|
||||
assert money.currency is BaseCurrency.USD
|
||||
|
||||
|
||||
def test_weight_coerces_unit_from_string() -> None:
|
||||
weight = Weight(amount=Decimal("1"), unit="ozt")
|
||||
|
||||
assert weight.unit is WeightUnit.OUNCE_TROY
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("factory", "message"),
|
||||
[
|
||||
(lambda: Money(amount=Decimal("1"), currency="BAD"), "Invalid currency"),
|
||||
(lambda: Weight(amount=Decimal("1"), unit="bad"), "Invalid weight unit"),
|
||||
(
|
||||
lambda: PricePerWeight(amount=Decimal("1"), currency="USD", per_unit="bad"),
|
||||
"Invalid weight unit",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_value_objects_reject_invalid_enum_values(factory: object, message: str) -> None:
|
||||
with pytest.raises(ValueError, match=message):
|
||||
factory() # type: ignore[operator]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("weight", "target_unit", "expected_amount"),
|
||||
[
|
||||
(Weight(amount=Decimal("1"), unit=WeightUnit.KILOGRAM), WeightUnit.GRAM, Decimal("1000")),
|
||||
(
|
||||
Weight(amount=Decimal("1"), unit=WeightUnit.OUNCE_TROY),
|
||||
WeightUnit.GRAM,
|
||||
Decimal("31.1034768"),
|
||||
),
|
||||
(
|
||||
Weight(amount=Decimal("31.1034768"), unit=WeightUnit.GRAM),
|
||||
WeightUnit.OUNCE_TROY,
|
||||
Decimal("1"),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_weight_to_unit_converts_explicitly(weight: Weight, target_unit: WeightUnit, expected_amount: Decimal) -> None:
|
||||
converted = weight.to_unit(target_unit)
|
||||
|
||||
assert converted.unit is target_unit
|
||||
assert converted.amount == expected_amount
|
||||
|
||||
|
||||
def test_weight_addition_converts_other_weight_to_left_hand_unit() -> None:
|
||||
left = Weight(amount=Decimal("1"), unit=WeightUnit.KILOGRAM)
|
||||
right = Weight(amount=Decimal("500"), unit=WeightUnit.GRAM)
|
||||
|
||||
result = left + right
|
||||
|
||||
assert result.unit is WeightUnit.KILOGRAM
|
||||
assert result.amount == Decimal("1.5")
|
||||
|
||||
|
||||
def test_money_addition_requires_same_currency() -> None:
|
||||
total = Money(amount=Decimal("10"), currency=BaseCurrency.USD) + Money(
|
||||
amount=Decimal("2.50"), currency=BaseCurrency.USD
|
||||
)
|
||||
|
||||
assert total == Money(amount=Decimal("12.50"), currency=BaseCurrency.USD)
|
||||
|
||||
|
||||
def test_money_addition_rejects_currency_mismatch() -> None:
|
||||
with pytest.raises(ValueError, match="Currency mismatch"):
|
||||
_ = Money(amount=Decimal("10"), currency=BaseCurrency.USD) + Money(
|
||||
amount=Decimal("5"), currency=BaseCurrency.EUR
|
||||
)
|
||||
|
||||
|
||||
def test_price_per_weight_to_unit_converts_explicitly() -> None:
|
||||
price = PricePerWeight(
|
||||
amount=Decimal("4400"),
|
||||
currency=BaseCurrency.USD,
|
||||
per_unit=WeightUnit.OUNCE_TROY,
|
||||
)
|
||||
|
||||
converted = price.to_unit(WeightUnit.GRAM)
|
||||
|
||||
assert converted.currency is BaseCurrency.USD
|
||||
assert converted.per_unit is WeightUnit.GRAM
|
||||
assert converted.amount == Decimal("141.4632849019631142972415225")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("weight", "price", "expected"),
|
||||
[
|
||||
(
|
||||
Weight(amount=Decimal("220"), unit=WeightUnit.OUNCE_TROY),
|
||||
PricePerWeight(
|
||||
amount=Decimal("4400"),
|
||||
currency=BaseCurrency.USD,
|
||||
per_unit=WeightUnit.OUNCE_TROY,
|
||||
),
|
||||
Money(amount=Decimal("968000"), currency=BaseCurrency.USD),
|
||||
),
|
||||
(
|
||||
Weight(amount=Decimal("31.1034768"), unit=WeightUnit.GRAM),
|
||||
PricePerWeight(
|
||||
amount=Decimal("4400"),
|
||||
currency=BaseCurrency.USD,
|
||||
per_unit=WeightUnit.OUNCE_TROY,
|
||||
),
|
||||
Money(amount=Decimal("4400"), currency=BaseCurrency.USD),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_weight_multiplied_by_price_per_weight_returns_money(
|
||||
weight: Weight, price: PricePerWeight, expected: Money
|
||||
) -> None:
|
||||
assert weight * price == expected
|
||||
assert price * weight == expected
|
||||
|
||||
|
||||
def test_unsupported_operator_pair_fails_closed() -> None:
|
||||
with pytest.raises(TypeError):
|
||||
_ = Money(amount=Decimal("10"), currency=BaseCurrency.USD) * Money(
|
||||
amount=Decimal("2"), currency=BaseCurrency.USD
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expression",
|
||||
[
|
||||
lambda: Money(amount=Decimal("10"), currency=BaseCurrency.USD) * True,
|
||||
lambda: Money(amount=Decimal("10"), currency=BaseCurrency.USD) / True,
|
||||
lambda: Weight(amount=Decimal("10"), unit=WeightUnit.GRAM) * True,
|
||||
lambda: Weight(amount=Decimal("10"), unit=WeightUnit.GRAM) / True,
|
||||
lambda: PricePerWeight(amount=Decimal("10"), currency=BaseCurrency.USD, per_unit=WeightUnit.GRAM) * True,
|
||||
],
|
||||
)
|
||||
def test_scalar_operators_reject_boolean_values(expression: object) -> None:
|
||||
with pytest.raises(TypeError):
|
||||
expression() # type: ignore[operator]
|
||||
|
||||
|
||||
def test_price_per_weight_rejects_negative_amount() -> None:
|
||||
with pytest.raises(ValueError, match="must be non-negative"):
|
||||
PricePerWeight(
|
||||
amount=Decimal("-1"),
|
||||
currency=BaseCurrency.USD,
|
||||
per_unit=WeightUnit.GRAM,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("factory", "message"),
|
||||
[
|
||||
(lambda: Money(amount="NaN", currency=BaseCurrency.USD), "must be finite"),
|
||||
(lambda: Weight(amount="Infinity", unit=WeightUnit.GRAM), "must be finite"),
|
||||
(
|
||||
lambda: PricePerWeight(
|
||||
amount="-Infinity",
|
||||
currency=BaseCurrency.USD,
|
||||
per_unit=WeightUnit.GRAM,
|
||||
),
|
||||
"must be finite",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_value_objects_reject_non_finite_amounts(factory: object, message: str) -> None:
|
||||
with pytest.raises(ValueError, match=message):
|
||||
factory() # type: ignore[operator]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"factory",
|
||||
[
|
||||
lambda: Money(amount=True, currency=BaseCurrency.USD),
|
||||
lambda: Weight(amount=True, unit=WeightUnit.GRAM),
|
||||
lambda: PricePerWeight(amount=True, currency=BaseCurrency.USD, per_unit=WeightUnit.GRAM),
|
||||
],
|
||||
)
|
||||
def test_value_objects_reject_boolean_amounts(factory: object) -> None:
|
||||
with pytest.raises(TypeError, match="Boolean values are not valid Decimal inputs"):
|
||||
factory() # type: ignore[operator]
|
||||
Reference in New Issue
Block a user