feat(PORTFOLIO-001): add position-level portfolio entries
This commit is contained in:
118
app/models/position.py
Normal file
118
app/models/position.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in New Issue
Block a user