106 lines
3.6 KiB
Python
106 lines
3.6 KiB
Python
"""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
|