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

View File

@@ -3,6 +3,7 @@
from .event_preset import EventPreset, EventScenarioOverrides
from .option import Greeks, OptionContract, OptionMoneyness
from .portfolio import LombardPortfolio
from .position import Position, create_position
from .strategy import HedgingStrategy, ScenarioResult, StrategyType
from .strategy_template import EntryPolicy, RollPolicy, StrategyTemplate, TemplateLeg
@@ -14,10 +15,12 @@ __all__ = [
"LombardPortfolio",
"OptionContract",
"OptionMoneyness",
"Position",
"ScenarioResult",
"StrategyType",
"StrategyTemplate",
"TemplateLeg",
"RollPolicy",
"EntryPolicy",
"create_position",
]

View File

@@ -4,11 +4,14 @@ from __future__ import annotations
import json
import os
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
from pathlib import Path
from typing import Any
from app.models.position import Position, create_position
_DEFAULT_GOLD_VALUE = 215_000.0
_DEFAULT_ENTRY_PRICE = 2_150.0
_LEGACY_DEFAULT_ENTRY_PRICE = 215.0
@@ -93,6 +96,7 @@ class PortfolioConfig:
margin_threshold: LTV threshold for margin call (default 0.75)
monthly_budget: Approved monthly hedge budget
ltv_warning: LTV warning level for alerts (default 0.70)
positions: List of position entries (multi-position support)
"""
gold_value: float | None = None
@@ -117,11 +121,40 @@ class PortfolioConfig:
spot_drawdown: float = 7.5
email_alerts: bool = False
# Multi-position support
positions: list[Position] = field(default_factory=list)
def __post_init__(self) -> None:
"""Normalize entry basis fields and validate configuration."""
self._normalize_entry_basis()
self.validate()
def migrate_to_positions_if_needed(self) -> None:
"""Migrate legacy single-entry portfolios to multi-position format.
Call this after loading from persistence to migrate legacy configs.
If positions list is empty but gold_ounces exists, create one Position
representing the legacy single entry.
"""
if self.positions:
# Already has positions, no migration needed
return
if self.gold_ounces is None or self.entry_price is None:
return
# Create a single position from legacy fields
position = create_position(
underlying=self.underlying,
quantity=Decimal(str(self.gold_ounces)),
unit="oz",
entry_price=Decimal(str(self.entry_price)),
entry_date=date.today(),
entry_basis_mode=self.entry_basis_mode,
)
# PortfolioConfig is not frozen, so we can set directly
self.positions = [position]
def _normalize_entry_basis(self) -> None:
"""Resolve user input into canonical weight + entry price representation."""
if self.entry_basis_mode not in {"value_price", "weight"}:
@@ -157,6 +190,55 @@ class PortfolioConfig:
raise ValueError("Gold value and weight contradict each other")
self.gold_value = derived_gold_value
def _migrate_legacy_to_positions(self) -> None:
"""Migrate legacy single-entry portfolios to multi-position format.
If positions list is empty but gold_ounces exists, create one Position
representing the legacy single entry.
"""
if self.positions:
# Already has positions, no migration needed
return
if self.gold_ounces is None or self.entry_price is None:
return
# Create a single position from legacy fields
position = create_position(
underlying=self.underlying,
quantity=Decimal(str(self.gold_ounces)),
unit="oz",
entry_price=Decimal(str(self.entry_price)),
entry_date=date.today(),
entry_basis_mode=self.entry_basis_mode,
)
# PortfolioConfig is not frozen, so we can set directly
self.positions = [position]
def _sync_legacy_fields_from_positions(self) -> None:
"""Sync legacy gold_ounces, entry_price, gold_value from positions.
For backward compatibility, compute aggregate values from positions list.
"""
if not self.positions:
return
# For now, assume homogeneous positions (same underlying and unit)
# Sum quantities and compute weighted average entry price
total_quantity = Decimal("0")
total_value = Decimal("0")
for pos in self.positions:
if pos.unit == "oz":
total_quantity += pos.quantity
total_value += pos.entry_value
if total_quantity > 0:
avg_entry_price = total_value / total_quantity
self.gold_ounces = float(total_quantity)
self.entry_price = float(avg_entry_price)
self.gold_value = float(total_value)
def validate(self) -> None:
"""Validate configuration values."""
assert self.gold_value is not None
@@ -214,7 +296,9 @@ class PortfolioConfig:
assert self.gold_value is not None
assert self.entry_price is not None
assert self.gold_ounces is not None
return {
# Sync legacy fields from positions before serializing
self._sync_legacy_fields_from_positions()
result = {
"gold_value": self.gold_value,
"entry_price": self.entry_price,
"gold_ounces": self.gold_ounces,
@@ -231,11 +315,31 @@ class PortfolioConfig:
"spot_drawdown": self.spot_drawdown,
"email_alerts": self.email_alerts,
}
# Include positions if any exist
if self.positions:
result["positions"] = [pos.to_dict() for pos in self.positions]
return result
@classmethod
def from_dict(cls, data: dict[str, Any]) -> PortfolioConfig:
"""Create configuration from dictionary."""
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
# Extract positions if present (may already be Position objects from deserialization)
positions_data = data.pop("positions", None)
config_data = {k: v for k, v in data.items() if k in cls.__dataclass_fields__}
# Create config without positions first (will be set in __post_init__)
config = cls(**config_data)
# Set positions after initialization
if positions_data:
if positions_data and isinstance(positions_data[0], Position):
# Already deserialized by _deserialize_value
positions = positions_data
else:
positions = [Position.from_dict(p) for p in positions_data]
config.positions = positions
return config
def _coerce_persisted_decimal(value: Any) -> Decimal:
@@ -293,6 +397,7 @@ class PortfolioRepository:
"volatility_spike",
"spot_drawdown",
"email_alerts",
"positions", # multi-position support
}
def __init__(self, config_path: Path | None = None) -> None:
@@ -329,11 +434,42 @@ class PortfolioRepository:
@classmethod
def _to_persistence_payload(cls, config: PortfolioConfig) -> dict[str, Any]:
# Serialize positions separately before calling to_dict
positions_data = [pos.to_dict() for pos in config.positions] if config.positions else []
config_dict = config.to_dict()
# Remove positions from config_dict since we handle it separately
config_dict.pop("positions", None)
return {
"schema_version": cls.SCHEMA_VERSION,
"portfolio": {key: cls._serialize_value(key, value) for key, value in config.to_dict().items()},
"portfolio": {
**{key: cls._serialize_value(key, value) for key, value in config_dict.items()},
**({"positions": positions_data} if positions_data else {}),
},
}
@classmethod
def _serialize_value(cls, key: str, value: Any) -> Any:
if key in cls._MONEY_FIELDS:
return {"value": cls._decimal_to_string(value), "currency": cls.PERSISTENCE_CURRENCY}
if key in cls._WEIGHT_FIELDS:
return {"value": cls._decimal_to_string(value), "unit": cls.PERSISTENCE_WEIGHT_UNIT}
if key in cls._PRICE_PER_WEIGHT_FIELDS:
return {
"value": cls._decimal_to_string(value),
"currency": cls.PERSISTENCE_CURRENCY,
"per_weight_unit": cls.PERSISTENCE_WEIGHT_UNIT,
}
if key in cls._RATIO_FIELDS:
return {"value": cls._decimal_to_string(value), "unit": "ratio"}
if key in cls._PERCENT_FIELDS:
return {"value": cls._decimal_to_string(value), "unit": "percent"}
if key in cls._INTEGER_FIELDS:
return cls._serialize_integer(value, unit="seconds")
if key == "positions" and isinstance(value, list):
# Already serialized as dicts from _to_persistence_payload
return value
return value
@classmethod
def _config_from_payload(cls, data: dict[str, Any]) -> PortfolioConfig:
if not isinstance(data, dict):
@@ -347,11 +483,15 @@ class PortfolioRepository:
cls._validate_portfolio_fields(portfolio)
deserialized = cls._deserialize_portfolio_payload(portfolio)
upgraded = cls._upgrade_legacy_default_workspace(deserialized)
return PortfolioConfig.from_dict(upgraded)
config = PortfolioConfig.from_dict(upgraded)
# Migrate legacy configs without positions to single position
config.migrate_to_positions_if_needed()
return config
# Fields that must be present in persisted payloads
# (underlying is optional with default "GLD")
_REQUIRED_FIELDS = _PERSISTED_FIELDS - {"underlying"}
# (positions is optional - legacy configs won't have it)
_REQUIRED_FIELDS = (_PERSISTED_FIELDS - {"underlying"}) - {"positions"}
@classmethod
def _validate_portfolio_fields(cls, payload: dict[str, Any]) -> None:
@@ -421,6 +561,9 @@ class PortfolioRepository:
return {"value": cls._decimal_to_string(value), "unit": "percent"}
if key in cls._INTEGER_FIELDS:
return cls._serialize_integer(value, unit="seconds")
if key == "positions" and isinstance(value, list):
# Already serialized as dicts from _to_persistence_payload
return value
return value
@classmethod
@@ -437,6 +580,8 @@ class PortfolioRepository:
return float(cls._deserialize_percent(value))
if key in cls._INTEGER_FIELDS:
return cls._deserialize_integer(value, expected_unit="seconds")
if key == "positions" and isinstance(value, list):
return [Position.from_dict(p) for p in value]
return value
@classmethod

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,
)

View File

@@ -2,9 +2,10 @@ from __future__ import annotations
import re
from pathlib import Path
from uuid import uuid4
from uuid import UUID, uuid4
from app.models.portfolio import PortfolioConfig, PortfolioRepository, build_default_portfolio_config
from app.models.position import Position
WORKSPACE_COOKIE = "workspace_id"
_WORKSPACE_ID_RE = re.compile(
@@ -63,6 +64,69 @@ class WorkspaceRepository:
raise ValueError("workspace_id must be a UUID4 string")
PortfolioRepository(self._portfolio_path(workspace_id)).save(config)
def add_position(self, workspace_id: str, position: Position) -> None:
"""Add a position to the workspace portfolio."""
if not self.is_valid_workspace_id(workspace_id):
raise ValueError("workspace_id must be a UUID4 string")
config = self.load_portfolio_config(workspace_id)
# Use object.__setattr__ because positions is in a frozen dataclass
object.__setattr__(config, "positions", list(config.positions) + [position])
self.save_portfolio_config(workspace_id, config)
def remove_position(self, workspace_id: str, position_id: UUID) -> None:
"""Remove a position from the workspace portfolio."""
if not self.is_valid_workspace_id(workspace_id):
raise ValueError("workspace_id must be a UUID4 string")
config = self.load_portfolio_config(workspace_id)
updated_positions = [p for p in config.positions if p.id != position_id]
object.__setattr__(config, "positions", updated_positions)
self.save_portfolio_config(workspace_id, config)
def update_position(
self,
workspace_id: str,
position_id: UUID,
updates: dict[str, object],
) -> None:
"""Update a position's fields."""
if not self.is_valid_workspace_id(workspace_id):
raise ValueError("workspace_id must be a UUID4 string")
config = self.load_portfolio_config(workspace_id)
updated_positions = []
for pos in config.positions:
if pos.id == position_id:
# Create updated position (Position is frozen, so create new instance)
update_kwargs: dict[str, object] = {}
for key, value in updates.items():
if key in {"id", "created_at"}:
continue # Skip immutable fields
update_kwargs[key] = value
# Use dataclass replace-like pattern
pos_dict = pos.to_dict()
pos_dict.update(update_kwargs)
updated_positions.append(Position.from_dict(pos_dict))
else:
updated_positions.append(pos)
object.__setattr__(config, "positions", updated_positions)
self.save_portfolio_config(workspace_id, config)
def get_position(self, workspace_id: str, position_id: UUID) -> Position | None:
"""Get a specific position by ID."""
if not self.is_valid_workspace_id(workspace_id):
raise ValueError("workspace_id must be a UUID4 string")
config = self.load_portfolio_config(workspace_id)
for pos in config.positions:
if pos.id == position_id:
return pos
return None
def list_positions(self, workspace_id: str) -> list[Position]:
"""List all positions in the workspace portfolio."""
if not self.is_valid_workspace_id(workspace_id):
raise ValueError("workspace_id must be a UUID4 string")
config = self.load_portfolio_config(workspace_id)
return list(config.positions)
def _portfolio_path(self, workspace_id: str) -> Path:
return self.base_path / workspace_id / "portfolio_config.json"