feat(PORTFOLIO-001): add position-level portfolio entries

This commit is contained in:
Bu5hm4nn
2026-03-28 21:29:30 +01:00
parent 447f4bbd0d
commit 1a39956757
6 changed files with 1041 additions and 7 deletions

118
app/models/position.py Normal file
View 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,
)