BrianIsaac's picture
feat: implement P1 features and production infrastructure
76897aa
"""Tax optimisation strategies for portfolio operations.
This module implements:
- Tax-loss harvesting opportunity identification
- Tax-optimised rebalancing algorithms
- Qualified dividend holding period tracking
- Lot selection optimisation
"""
from datetime import date, timedelta
from decimal import Decimal
from typing import List, Dict, Optional, Tuple
from collections import defaultdict
import logging
from backend.tax.models import (
TaxLot,
Transaction,
TaxLossHarvestingOpportunity,
TaxOptimizedSale,
CostBasisMethod,
TransactionType,
)
from backend.tax.calculator import TaxCalculator
logger = logging.getLogger(__name__)
class TaxOptimizer:
"""Optimise portfolio operations for tax efficiency."""
def __init__(self, tax_calculator: TaxCalculator):
"""Initialise tax optimiser.
Args:
tax_calculator: TaxCalculator instance for tax calculations
"""
self.tax_calculator = tax_calculator
def identify_tax_loss_harvesting_opportunities(
self,
current_prices: Dict[str, Decimal],
min_loss_threshold: Decimal = Decimal("100"),
min_loss_percentage: Decimal = Decimal("0.05"),
) -> List[TaxLossHarvestingOpportunity]:
"""Identify tax-loss harvesting opportunities.
Tax-loss harvesting involves selling securities at a loss to offset capital gains
and reduce tax liability.
Args:
current_prices: Dictionary of ticker to current price
min_loss_threshold: Minimum dollar loss to consider (default $100)
min_loss_percentage: Minimum loss percentage to consider (default 5%)
Returns:
List of harvesting opportunities
"""
opportunities = []
for ticker, lots in self.tax_calculator.tax_lots.items():
if ticker not in current_prices:
continue
current_price = current_prices[ticker]
# Calculate unrealised loss for each lot
for lot in lots:
unrealised_gain_loss = (current_price - lot.cost_basis_per_share) * lot.quantity
# Only interested in losses
if unrealised_gain_loss >= 0:
continue
loss_percentage = (
(current_price - lot.cost_basis_per_share) / lot.cost_basis_per_share
)
# Check if loss meets thresholds
if (
abs(unrealised_gain_loss) < min_loss_threshold
or abs(loss_percentage) < min_loss_percentage
):
continue
# Estimate tax savings
tax_rate = self.tax_calculator.get_capital_gains_rate(
is_long_term=lot.is_long_term
)
estimated_savings = abs(unrealised_gain_loss) * tax_rate
# Determine wash sale risk (simplified - would need transaction history)
wash_sale_risk = False # Would check recent purchases
# Generate recommendation
if lot.is_long_term:
recommendation = (
f"Sell {lot.quantity} shares at ${current_price:.2f} "
f"to harvest ${abs(unrealised_gain_loss):,.2f} long-term loss. "
f"Estimated tax savings: ${estimated_savings:,.2f}. "
f"Consider reinvesting in similar but not identical security to maintain exposure."
)
else:
recommendation = (
f"Sell {lot.quantity} shares at ${current_price:.2f} "
f"to harvest ${abs(unrealised_gain_loss):,.2f} short-term loss. "
f"Estimated tax savings: ${estimated_savings:,.2f}. "
f"Wait 31 days before repurchasing to avoid wash sale."
)
opportunity = TaxLossHarvestingOpportunity(
ticker=ticker,
current_price=current_price,
unrealised_loss=unrealised_gain_loss,
loss_percentage=loss_percentage,
quantity=lot.quantity,
holding_period_days=lot.holding_period_days,
is_long_term=lot.is_long_term,
estimated_tax_savings=estimated_savings,
wash_sale_risk=wash_sale_risk,
recommended_action=recommendation,
lot_ids=[lot.lot_id],
)
opportunities.append(opportunity)
# Sort by tax savings (highest first)
opportunities.sort(key=lambda x: x.estimated_tax_savings, reverse=True)
return opportunities
def optimize_lot_selection(
self,
ticker: str,
quantity_to_sell: Decimal,
current_price: Decimal,
goal: str = "minimize_tax",
) -> TaxOptimizedSale:
"""Optimise which lots to sell for tax efficiency.
Args:
ticker: Stock ticker symbol
quantity_to_sell: Number of shares to sell
current_price: Current market price
goal: Optimisation goal - 'minimize_tax', 'harvest_losses', or 'maximize_long_term'
Returns:
TaxOptimizedSale with recommended lots
"""
lots = self.tax_calculator.tax_lots.get(ticker, [])
if not lots:
raise ValueError(f"No lots available for {ticker}")
available_lots = [lot for lot in lots]
selected_lots = []
remaining_qty = quantity_to_sell
if goal == "minimize_tax":
# Sell lots with highest cost basis first (HIFO) to minimize gains
available_lots.sort(key=lambda x: x.cost_basis_per_share, reverse=True)
method = CostBasisMethod.HIFO
rationale = (
"Selected lots with highest cost basis (HIFO) to minimise capital gains tax. "
"This strategy reduces immediate tax liability by selling shares with the least appreciation first."
)
elif goal == "harvest_losses":
# Sell lots at a loss, prioritising largest losses
loss_lots = [
lot
for lot in available_lots
if (current_price - lot.cost_basis_per_share) < 0
]
loss_lots.sort(
key=lambda x: (current_price - x.cost_basis_per_share) * x.quantity
)
available_lots = loss_lots
method = CostBasisMethod.SPECIFIC_ID
rationale = (
"Selected lots showing losses to harvest tax losses. "
"Losses can offset capital gains and reduce overall tax liability."
)
elif goal == "maximize_long_term":
# Prioritise long-term lots to get preferential tax rate
long_term_lots = [lot for lot in available_lots if lot.is_long_term]
long_term_lots.sort(key=lambda x: x.acquisition_date)
available_lots = long_term_lots + [
lot for lot in available_lots if not lot.is_long_term
]
method = CostBasisMethod.SPECIFIC_ID
rationale = (
"Prioritised long-term holdings (>365 days) to benefit from lower long-term "
"capital gains tax rates (0%, 15%, or 20% vs ordinary income rates up to 37%)."
)
else:
raise ValueError(f"Unknown optimisation goal: {goal}")
# Select lots
for lot in available_lots:
if remaining_qty <= 0:
break
qty_from_lot = min(remaining_qty, lot.quantity)
# Create a copy of the lot with adjusted quantity
selected_lot = TaxLot(
lot_id=lot.lot_id,
ticker=lot.ticker,
acquisition_date=lot.acquisition_date,
quantity=qty_from_lot,
original_quantity=lot.original_quantity,
cost_basis_per_share=lot.cost_basis_per_share,
total_cost_basis=qty_from_lot * lot.cost_basis_per_share,
)
selected_lots.append(selected_lot)
remaining_qty -= qty_from_lot
if not selected_lots:
raise ValueError(f"No suitable lots found for goal: {goal}")
# Calculate estimated gain/loss and tax
total_proceeds = quantity_to_sell * current_price
total_cost_basis = sum(lot.total_cost_basis for lot in selected_lots)
estimated_gain_loss = total_proceeds - total_cost_basis
# Determine if majority is long-term
long_term_qty = sum(
lot.quantity for lot in selected_lots if lot.is_long_term
)
is_long_term = long_term_qty > (quantity_to_sell / 2)
# Estimate tax
tax_rate = self.tax_calculator.get_capital_gains_rate(is_long_term=is_long_term)
estimated_tax = max(Decimal("0"), estimated_gain_loss * tax_rate)
return TaxOptimizedSale(
ticker=ticker,
quantity_to_sell=quantity_to_sell,
lots_to_sell=selected_lots,
cost_basis_method=method,
estimated_gain_loss=estimated_gain_loss,
estimated_tax=estimated_tax,
is_long_term=is_long_term,
rationale=rationale,
)
def suggest_rebalancing_strategy(
self,
target_allocations: Dict[str, Decimal],
current_prices: Dict[str, Decimal],
min_trade_amount: Decimal = Decimal("100"),
) -> List[Dict[str, any]]:
"""Suggest tax-optimised rebalancing trades.
Args:
target_allocations: Target allocation percentages by ticker
current_prices: Current market prices by ticker
min_trade_amount: Minimum trade amount to consider (default $100)
Returns:
List of suggested trades with tax implications
"""
suggestions = []
# Calculate current allocations
total_value = Decimal("0")
current_values = {}
for ticker, lots in self.tax_calculator.tax_lots.items():
if ticker not in current_prices:
continue
current_price = current_prices[ticker]
ticker_value = sum(lot.quantity * current_price for lot in lots)
current_values[ticker] = ticker_value
total_value += ticker_value
if total_value == 0:
return suggestions
# Calculate required trades
for ticker, target_pct in target_allocations.items():
current_value = current_values.get(ticker, Decimal("0"))
target_value = total_value * target_pct
difference = target_value - current_value
if abs(difference) < min_trade_amount:
continue
if difference < 0:
# Need to sell
quantity_to_sell = abs(difference) / current_prices[ticker]
# Optimise lot selection to minimise tax
try:
optimised_sale = self.optimize_lot_selection(
ticker=ticker,
quantity_to_sell=quantity_to_sell,
current_price=current_prices[ticker],
goal="minimize_tax",
)
suggestions.append(
{
"action": "SELL",
"ticker": ticker,
"quantity": quantity_to_sell,
"estimated_proceeds": abs(difference),
"estimated_gain_loss": optimised_sale.estimated_gain_loss,
"estimated_tax": optimised_sale.estimated_tax,
"lots": optimised_sale.lots_to_sell,
"rationale": optimised_sale.rationale,
}
)
except ValueError as e:
logger.warning(f"Could not optimise sale for {ticker}: {e}")
else:
# Need to buy
quantity_to_buy = difference / current_prices[ticker]
suggestions.append(
{
"action": "BUY",
"ticker": ticker,
"quantity": quantity_to_buy,
"estimated_cost": difference,
"estimated_gain_loss": Decimal("0"),
"estimated_tax": Decimal("0"),
"rationale": f"Purchase to reach target allocation of {target_pct * 100:.1f}%",
}
)
return suggestions
def check_qualified_dividend_holding_period(
self,
ticker: str,
dividend_ex_date: date,
min_holding_days: int = 60,
) -> Tuple[bool, int]:
"""Check if holdings qualify for qualified dividend treatment.
Qualified dividends are taxed at lower long-term capital gains rates.
To qualify, stock must be held for >60 days during the 121-day period
beginning 60 days before the ex-dividend date.
Args:
ticker: Stock ticker symbol
dividend_ex_date: Ex-dividend date
min_holding_days: Minimum holding days required (default 60)
Returns:
Tuple of (qualifies, actual holding days)
"""
lots = self.tax_calculator.tax_lots.get(ticker, [])
if not lots:
return False, 0
# Calculate 121-day period
period_start = dividend_ex_date - timedelta(days=60)
period_end = dividend_ex_date + timedelta(days=60)
# Check if any lot qualifies
max_holding_days = 0
for lot in lots:
# Calculate days held during the period
hold_start = max(lot.acquisition_date, period_start)
hold_end = min(date.today(), period_end)
if hold_start <= hold_end:
days_held = (hold_end - hold_start).days
max_holding_days = max(max_holding_days, days_held)
qualifies = max_holding_days > min_holding_days
return qualifies, max_holding_days
def compare_cost_basis_methods(
self,
ticker: str,
quantity_to_sell: Decimal,
sale_price: Decimal,
) -> Dict[CostBasisMethod, Dict[str, Decimal]]:
"""Compare tax implications across different cost basis methods.
Args:
ticker: Stock ticker symbol
quantity_to_sell: Quantity to sell
sale_price: Sale price per share
Returns:
Dictionary mapping cost basis method to tax implications
"""
results = {}
# Save current state
original_lots = {
t: [
TaxLot(
lot_id=lot.lot_id,
ticker=lot.ticker,
acquisition_date=lot.acquisition_date,
quantity=lot.quantity,
original_quantity=lot.original_quantity,
cost_basis_per_share=lot.cost_basis_per_share,
total_cost_basis=lot.total_cost_basis,
)
for lot in lots
]
for t, lots in self.tax_calculator.tax_lots.items()
}
methods = [
CostBasisMethod.FIFO,
CostBasisMethod.LIFO,
CostBasisMethod.HIFO,
CostBasisMethod.AVERAGE,
]
for method in methods:
# Restore state
self.tax_calculator.tax_lots = {
t: [
TaxLot(
lot_id=lot.lot_id,
ticker=lot.ticker,
acquisition_date=lot.acquisition_date,
quantity=lot.quantity,
original_quantity=lot.original_quantity,
cost_basis_per_share=lot.cost_basis_per_share,
total_cost_basis=lot.total_cost_basis,
)
for lot in lots
]
for t, lots in original_lots.items()
}
try:
if method == CostBasisMethod.FIFO:
gains = self.tax_calculator.calculate_sale_fifo(
ticker, date.today(), quantity_to_sell, sale_price
)
elif method == CostBasisMethod.LIFO:
gains = self.tax_calculator.calculate_sale_lifo(
ticker, date.today(), quantity_to_sell, sale_price
)
elif method == CostBasisMethod.HIFO:
gains = self.tax_calculator.calculate_sale_hifo(
ticker, date.today(), quantity_to_sell, sale_price
)
elif method == CostBasisMethod.AVERAGE:
gains = self.tax_calculator.calculate_sale_average(
ticker, date.today(), quantity_to_sell, sale_price
)
total_gain_loss = sum(g.gain_loss for g in gains)
tax_liability, _ = self.tax_calculator.calculate_tax_liability(gains)
results[method] = {
"total_gain_loss": total_gain_loss,
"tax_liability": tax_liability,
"long_term_portion": sum(
g.gain_loss for g in gains if g.is_long_term
),
"short_term_portion": sum(
g.gain_loss for g in gains if not g.is_long_term
),
}
except Exception as e:
logger.error(f"Error calculating with {method}: {e}")
results[method] = {
"error": str(e),
}
# Restore original state
self.tax_calculator.tax_lots = original_lots
return results
def generate_tax_optimization_report(
self,
current_prices: Dict[str, Decimal],
) -> Dict[str, any]:
"""Generate comprehensive tax optimisation report.
Args:
current_prices: Current market prices by ticker
Returns:
Dictionary with comprehensive tax optimisation recommendations
"""
report = {
"harvesting_opportunities": [],
"qualified_dividends": {},
"unrealised_positions": {},
"recommendations": [],
}
# Identify harvesting opportunities
opportunities = self.identify_tax_loss_harvesting_opportunities(current_prices)
report["harvesting_opportunities"] = [
{
"ticker": opp.ticker,
"unrealised_loss": float(opp.unrealised_loss),
"estimated_tax_savings": float(opp.estimated_tax_savings),
"recommendation": opp.recommended_action,
}
for opp in opportunities[:5] # Top 5
]
# Calculate unrealised gains/losses
total_unrealised_gains = Decimal("0")
total_unrealised_losses = Decimal("0")
for ticker, price in current_prices.items():
gain, qty = self.tax_calculator.get_unrealised_gains(ticker, price)
if gain > 0:
total_unrealised_gains += gain
else:
total_unrealised_losses += abs(gain)
report["unrealised_positions"][ticker] = {
"quantity": float(qty),
"unrealised_gain_loss": float(gain),
"current_value": float(qty * price),
}
# Generate recommendations
if opportunities:
total_potential_savings = sum(opp.estimated_tax_savings for opp in opportunities)
report["recommendations"].append(
f"Tax-loss harvesting opportunities identified with potential savings of "
f"${total_potential_savings:,.2f}. Consider harvesting losses before year-end."
)
if total_unrealised_gains > total_unrealised_losses:
report["recommendations"].append(
f"Portfolio has net unrealised gains of ${total_unrealised_gains - total_unrealised_losses:,.2f}. "
f"Consider tax-loss harvesting to offset future gains."
)
report["recommendations"].append(
"IMPORTANT: This analysis is for informational purposes only. "
"Consult a qualified tax professional before making any investment decisions."
)
return report