feat(PORTFOLIO-002): add position storage costs

This commit is contained in:
Bu5hm4nn
2026-03-28 23:48:41 +01:00
parent e148d55cda
commit 0e972e9dd6
5 changed files with 501 additions and 1 deletions

View File

@@ -0,0 +1,105 @@
"""Storage cost calculation service for positions with physical storage requirements."""
from __future__ import annotations
from decimal import Decimal
from app.models.position import Position
_DECIMAL_ZERO = Decimal("0")
_DECIMAL_ONE = Decimal("1")
_DECIMAL_HUNDRED = Decimal("100")
_DECIMAL_TWELVE = Decimal("12")
def calculate_annual_storage_cost(position: Position, current_value: Decimal) -> Decimal:
"""Calculate annual storage cost for a single position.
Args:
position: Position with optional storage_cost_basis and storage_cost_period
current_value: Current market value of the position (quantity × current_price)
Returns:
Annual storage cost in position's storage_cost_currency (default USD)
Notes:
- If storage_cost_basis is None, returns 0 (no storage cost)
- If storage_cost_period is "monthly", annualizes the cost (×12)
- If storage_cost_basis is a percentage, applies it to current_value
- If storage_cost_basis is a fixed amount, uses it directly
"""
if position.storage_cost_basis is None:
return _DECIMAL_ZERO
basis = position.storage_cost_basis
period = position.storage_cost_period or "annual"
# Determine if basis is a percentage (e.g., 0.12 for 0.12%) or fixed amount
# Heuristic: if basis < 1, treat as percentage; otherwise as fixed amount
if basis < _DECIMAL_ONE:
# Percentage-based cost
if period == "monthly":
# Monthly percentage, annualize it
annual_rate = basis * _DECIMAL_TWELVE
else:
# Already annual
annual_rate = basis
# Apply percentage to current value
return (current_value * annual_rate) / _DECIMAL_HUNDRED
else:
# Fixed amount
if period == "monthly":
# Monthly fixed cost, annualize it
return basis * _DECIMAL_TWELVE
else:
# Already annual fixed cost
return basis
def calculate_total_storage_cost(
positions: list[Position],
current_values: dict[str, Decimal],
) -> Decimal:
"""Calculate total annual storage cost across all positions.
Args:
positions: List of positions with optional storage costs
current_values: Mapping of position ID (str) to current market value
Returns:
Total annual storage cost in USD (assumes all positions use USD)
"""
total = _DECIMAL_ZERO
for position in positions:
current_value = current_values.get(str(position.id), _DECIMAL_ZERO)
cost = calculate_annual_storage_cost(position, current_value)
total += cost
return total
def get_default_storage_cost_for_underlying(underlying: str) -> tuple[Decimal | None, str | None]:
"""Get default storage cost settings for a given underlying instrument.
Args:
underlying: Instrument symbol (e.g., "XAU", "GLD", "GC=F")
Returns:
Tuple of (storage_cost_basis, storage_cost_period) or (None, None) if no default
Notes:
- XAU (physical gold): 0.12% annual for allocated vault storage
- GLD: None (expense ratio baked into share price)
- GC=F: None (roll costs are the storage analog, handled separately)
"""
if underlying == "XAU":
# Physical gold: 0.12% annual storage cost for allocated vault storage
return Decimal("0.12"), "annual"
elif underlying == "GLD":
# GLD: expense ratio is implicit in share price, no separate storage cost
return None, None
elif underlying == "GC=F":
# Futures: roll costs are the storage analog (deferred to GCF-001)
return None, None
else:
return None, None