119 lines
4.4 KiB
Python
119 lines
4.4 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")
|
||
notes: Optional notes about this position
|
||
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 = ""
|
||
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,
|
||
"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", ""),
|
||
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 = "",
|
||
) -> 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
|
||
"""
|
||
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,
|
||
)
|