feat(PORTFOLIO-002): add position storage costs
This commit is contained in:
105
app/services/storage_costs.py
Normal file
105
app/services/storage_costs.py
Normal 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
|
||||
Reference in New Issue
Block a user