feat(PORTFOLIO-003): add premium and spread for physical gold positions
This commit is contained in:
118
app/services/position_costs.py
Normal file
118
app/services/position_costs.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Position cost calculations for premium, spread, and storage costs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from app.models.position import Position
|
||||
|
||||
|
||||
def calculate_effective_entry(
|
||||
entry_price: Decimal,
|
||||
purchase_premium: Decimal | None = None,
|
||||
) -> Decimal:
|
||||
"""Calculate effective entry cost including dealer premium.
|
||||
|
||||
Args:
|
||||
entry_price: Spot price at entry (per unit)
|
||||
purchase_premium: Dealer markup over spot as percentage (e.g., 0.04 for 4%)
|
||||
|
||||
Returns:
|
||||
Effective entry cost per unit
|
||||
"""
|
||||
if purchase_premium is None or purchase_premium == 0:
|
||||
return entry_price
|
||||
return entry_price * (Decimal("1") + purchase_premium)
|
||||
|
||||
|
||||
def calculate_effective_exit(
|
||||
current_spot: Decimal,
|
||||
bid_ask_spread: Decimal | None = None,
|
||||
) -> Decimal:
|
||||
"""Calculate effective exit value after bid/ask spread.
|
||||
|
||||
Args:
|
||||
current_spot: Current spot price (per unit)
|
||||
bid_ask_spread: Expected sale discount below spot as percentage (e.g., 0.03 for 3%)
|
||||
|
||||
Returns:
|
||||
Effective exit value per unit
|
||||
"""
|
||||
if bid_ask_spread is None or bid_ask_spread == 0:
|
||||
return current_spot
|
||||
return current_spot * (Decimal("1") - bid_ask_spread)
|
||||
|
||||
|
||||
def calculate_true_pnl(
|
||||
position: Position,
|
||||
current_spot: Decimal,
|
||||
) -> dict[str, Any]:
|
||||
"""Calculate true P&L accounting for premium and spread.
|
||||
|
||||
Args:
|
||||
position: Position to calculate P&L for
|
||||
current_spot: Current spot price per unit
|
||||
|
||||
Returns:
|
||||
Dict with paper_pnl, realized_pnl, effective_entry, effective_exit, entry_value, exit_value
|
||||
"""
|
||||
# Effective entry cost (includes premium)
|
||||
effective_entry = calculate_effective_entry(position.entry_price, position.purchase_premium)
|
||||
|
||||
# Effective exit value (after spread)
|
||||
effective_exit = calculate_effective_exit(current_spot, position.bid_ask_spread)
|
||||
|
||||
# Paper P&L (without premium/spread)
|
||||
paper_pnl = (current_spot - position.entry_price) * position.quantity
|
||||
|
||||
# True P&L (with premium/spread)
|
||||
true_pnl = (effective_exit - effective_entry) * position.quantity
|
||||
|
||||
# Entry and exit values
|
||||
entry_value = position.entry_price * position.quantity
|
||||
exit_value = current_spot * position.quantity
|
||||
|
||||
return {
|
||||
"paper_pnl": float(paper_pnl),
|
||||
"true_pnl": float(true_pnl),
|
||||
"effective_entry": float(effective_entry),
|
||||
"effective_exit": float(effective_exit),
|
||||
"entry_value": float(entry_value),
|
||||
"exit_value": float(exit_value),
|
||||
"premium_impact": float((position.purchase_premium or 0) * entry_value),
|
||||
"spread_impact": float((position.bid_ask_spread or 0) * exit_value),
|
||||
}
|
||||
|
||||
|
||||
def get_default_premium_for_product(underlying: str, product_type: str = "default") -> Decimal | None:
|
||||
"""Get default premium/spread for common gold products.
|
||||
|
||||
Args:
|
||||
underlying: Underlying instrument ("GLD", "GC=F", "XAU")
|
||||
product_type: Product type ("default", "coin_1oz", "bar_1kg", "allocated")
|
||||
|
||||
Returns:
|
||||
Tuple of (purchase_premium, bid_ask_spread) or None if not applicable
|
||||
"""
|
||||
# GLD/GLDM: ETF is liquid, minimal spread
|
||||
if underlying in ("GLD", "GLDM"):
|
||||
# ETF spread is minimal, premium is 0
|
||||
return Decimal("0"), Decimal("0.001") # 0% premium, 0.1% spread
|
||||
|
||||
# GC=F: Futures roll costs are handled separately (GCF-001)
|
||||
if underlying == "GC=F":
|
||||
return None, None
|
||||
|
||||
# XAU: Physical gold
|
||||
if underlying == "XAU":
|
||||
defaults = {
|
||||
"default": (Decimal("0.04"), Decimal("0.03")), # 4% premium, 3% spread
|
||||
"coin_1oz": (Decimal("0.04"), Decimal("0.03")), # 1oz coins: 4% premium, 3% spread
|
||||
"bar_1kg": (Decimal("0.015"), Decimal("0.015")), # 1kg bars: 1.5% premium, 1.5% spread
|
||||
"allocated": (Decimal("0.001"), Decimal("0.003")), # Allocated: 0.1% premium, 0.3% spread
|
||||
}
|
||||
return defaults.get(product_type, defaults["default"])
|
||||
|
||||
# Unknown underlying
|
||||
return None, None
|
||||
Reference in New Issue
Block a user