Spaces:
Running
on
Zero
Running
on
Zero
| """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 | |