feat(PORTFOLIO-001): add position-level portfolio entries
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
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,
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user