Files
vault-dash/app/models/position.py

156 lines
6.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
)