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