"""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") 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" 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, "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"), 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", 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") 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, notes=notes, storage_cost_basis=storage_cost_basis, storage_cost_period=storage_cost_period, storage_cost_currency=storage_cost_currency, )