156 lines
6.8 KiB
Python
156 lines
6.8 KiB
Python
"""Position model for multi-position portfolio entries."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
from datetime import UTC, date, datetime
|
||
from decimal import Decimal
|
||
from typing import Any
|
||
from uuid import UUID, uuid4
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Position:
|
||
"""A single position entry in a portfolio.
|
||
|
||
Attributes:
|
||
id: Unique identifier for this position
|
||
underlying: Underlying instrument symbol (e.g., "GLD", "GC=F", "XAU")
|
||
quantity: Number of units held (shares, contracts, grams, or oz)
|
||
unit: Unit of quantity (e.g., "shares", "contracts", "g", "oz")
|
||
entry_price: Price per unit at purchase (in USD)
|
||
entry_date: Date of position entry (for historical conversion lookups)
|
||
entry_basis_mode: Entry basis mode ("weight" or "value_price")
|
||
purchase_premium: Dealer markup over spot as percentage (e.g., Decimal("0.04") for 4%)
|
||
bid_ask_spread: Expected sale discount below spot as percentage (e.g., Decimal("0.03") for 3%)
|
||
notes: Optional notes about this position
|
||
storage_cost_basis: Annual storage cost as percentage (e.g., Decimal("0.12") for 0.12%) or fixed amount
|
||
storage_cost_period: Period for storage cost ("annual" or "monthly")
|
||
storage_cost_currency: Currency for fixed amount costs (default "USD")
|
||
created_at: Timestamp when position was created
|
||
"""
|
||
|
||
id: UUID
|
||
underlying: str
|
||
quantity: Decimal
|
||
unit: str
|
||
entry_price: Decimal
|
||
entry_date: date
|
||
entry_basis_mode: str = "weight"
|
||
purchase_premium: Decimal | None = None
|
||
bid_ask_spread: Decimal | None = None
|
||
notes: str = ""
|
||
storage_cost_basis: Decimal | None = None
|
||
storage_cost_period: str | None = None
|
||
storage_cost_currency: str = "USD"
|
||
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||
|
||
def __post_init__(self) -> None:
|
||
"""Validate position fields."""
|
||
if not self.underlying:
|
||
raise ValueError("underlying must be non-empty")
|
||
# Use object.__getattribute__ because Decimal comparison with frozen dataclass
|
||
quantity = object.__getattribute__(self, "quantity")
|
||
entry_price = object.__getattribute__(self, "entry_price")
|
||
if quantity <= 0:
|
||
raise ValueError("quantity must be positive")
|
||
if not self.unit:
|
||
raise ValueError("unit must be non-empty")
|
||
if entry_price <= 0:
|
||
raise ValueError("entry_price must be positive")
|
||
if self.entry_basis_mode not in {"weight", "value_price"}:
|
||
raise ValueError("entry_basis_mode must be 'weight' or 'value_price'")
|
||
|
||
@property
|
||
def entry_value(self) -> Decimal:
|
||
"""Calculate total entry value (quantity × entry_price)."""
|
||
return self.quantity * self.entry_price
|
||
|
||
def to_dict(self) -> dict[str, Any]:
|
||
"""Convert position to dictionary for serialization."""
|
||
return {
|
||
"id": str(self.id),
|
||
"underlying": self.underlying,
|
||
"quantity": str(self.quantity),
|
||
"unit": self.unit,
|
||
"entry_price": str(self.entry_price),
|
||
"entry_date": self.entry_date.isoformat(),
|
||
"entry_basis_mode": self.entry_basis_mode,
|
||
"purchase_premium": str(self.purchase_premium) if self.purchase_premium is not None else None,
|
||
"bid_ask_spread": str(self.bid_ask_spread) if self.bid_ask_spread is not None else None,
|
||
"notes": self.notes,
|
||
"storage_cost_basis": str(self.storage_cost_basis) if self.storage_cost_basis is not None else None,
|
||
"storage_cost_period": self.storage_cost_period,
|
||
"storage_cost_currency": self.storage_cost_currency,
|
||
"created_at": self.created_at.isoformat(),
|
||
}
|
||
|
||
@classmethod
|
||
def from_dict(cls, data: dict[str, Any]) -> Position:
|
||
"""Create position from dictionary."""
|
||
return cls(
|
||
id=UUID(data["id"]) if isinstance(data["id"], str) else data["id"],
|
||
underlying=data["underlying"],
|
||
quantity=Decimal(data["quantity"]),
|
||
unit=data["unit"],
|
||
entry_price=Decimal(data["entry_price"]),
|
||
entry_date=date.fromisoformat(data["entry_date"]),
|
||
entry_basis_mode=data.get("entry_basis_mode", "weight"),
|
||
purchase_premium=(Decimal(data["purchase_premium"]) if data.get("purchase_premium") is not None else None),
|
||
bid_ask_spread=(Decimal(data["bid_ask_spread"]) if data.get("bid_ask_spread") is not None else None),
|
||
notes=data.get("notes", ""),
|
||
storage_cost_basis=(
|
||
Decimal(data["storage_cost_basis"]) if data.get("storage_cost_basis") is not None else None
|
||
),
|
||
storage_cost_period=data.get("storage_cost_period"),
|
||
storage_cost_currency=data.get("storage_cost_currency", "USD"),
|
||
created_at=datetime.fromisoformat(data["created_at"]) if "created_at" in data else datetime.now(UTC),
|
||
)
|
||
|
||
|
||
def create_position(
|
||
underlying: str = "GLD",
|
||
quantity: Decimal | None = None,
|
||
unit: str = "oz",
|
||
entry_price: Decimal | None = None,
|
||
entry_date: date | None = None,
|
||
entry_basis_mode: str = "weight",
|
||
purchase_premium: Decimal | None = None,
|
||
bid_ask_spread: Decimal | None = None,
|
||
notes: str = "",
|
||
storage_cost_basis: Decimal | None = None,
|
||
storage_cost_period: str | None = None,
|
||
storage_cost_currency: str = "USD",
|
||
) -> Position:
|
||
"""Create a new position with sensible defaults.
|
||
|
||
Args:
|
||
underlying: Underlying instrument (default: "GLD")
|
||
quantity: Position quantity (default: Decimal("100"))
|
||
unit: Unit of quantity (default: "oz")
|
||
entry_price: Entry price per unit (default: Decimal("2150"))
|
||
entry_date: Entry date (default: today)
|
||
entry_basis_mode: Entry basis mode (default: "weight")
|
||
purchase_premium: Dealer markup over spot as percentage (default: None)
|
||
bid_ask_spread: Expected sale discount below spot as percentage (default: None)
|
||
notes: Optional notes
|
||
storage_cost_basis: Annual storage cost as percentage or fixed amount (default: None)
|
||
storage_cost_period: Period for storage cost ("annual" or "monthly", default: None)
|
||
storage_cost_currency: Currency for fixed amount costs (default: "USD")
|
||
"""
|
||
return Position(
|
||
id=uuid4(),
|
||
underlying=underlying,
|
||
quantity=quantity if quantity is not None else Decimal("100"),
|
||
unit=unit,
|
||
entry_price=entry_price if entry_price is not None else Decimal("2150"),
|
||
entry_date=entry_date or date.today(),
|
||
entry_basis_mode=entry_basis_mode,
|
||
purchase_premium=purchase_premium,
|
||
bid_ask_spread=bid_ask_spread,
|
||
notes=notes,
|
||
storage_cost_basis=storage_cost_basis,
|
||
storage_cost_period=storage_cost_period,
|
||
storage_cost_currency=storage_cost_currency,
|
||
)
|