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

@@ -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