TradingAgents/tradingagents/strategies/tax_optimization.py

133 lines
5.1 KiB
Python

"""Tax-Loss Harvesting with CGT Discount Optimization (§13.1-13.2).
Enhances tax-loss harvesting by considering the Australian 12-month CGT
discount rule. Lots held >12 months get a 50% CGT discount on gains,
so selling short-term loss lots first maximizes tax benefit (full offset)
while preserving long-term lots that benefit from the discount.
Signal output:
- Tickers with harvestable short-term losses → SUPPORTS (sell for full offset)
- Tickers with only long-term losses → NEUTRAL (50% discount reduces benefit)
- Tickers with no losses → CONTRADICTS (no tax benefit from selling)
Reference: Kakushadze & Serur §13.1-13.2 — "Tax-Loss Harvesting"
"""
from __future__ import annotations
import datetime
from tradingagents.strategies.base import BaseStrategy, StrategySignal
class TaxOptimizationStrategy(BaseStrategy):
@property
def interpretation_guide(self) -> str:
return "Usage: Identifies tax-loss harvesting opportunities. Tips: Only relevant for taxable accounts. Consider wash-sale rules (30-day window). Strongest near fiscal year-end. Combine with fundamental view — don't harvest losses on stocks you want to keep."
name = "tax_optimization"
description = "CGT discount-aware lot selection for tax-loss harvesting"
target_analysts = ["portfolio"]
def compute(self, ticker: str, date: str, **kwargs) -> StrategySignal:
lots: list[dict] = kwargs.get("lots", [])
current_price: float = kwargs.get("current_price", 0)
if not lots or current_price <= 0:
return self._neutral(ticker, date, "requires portfolio lot data — pass lots=[] and current_price via kwargs")
today = datetime.datetime.strptime(date, "%Y-%m-%d")
short_term_loss = 0.0
long_term_loss = 0.0
short_term_gain = 0.0
long_term_gain = 0.0
harvest_lots: list[dict] = []
for lot in lots:
try:
lot_date = datetime.datetime.strptime(lot["date"], "%Y-%m-%d")
qty = lot.get("qty", 0)
cost = lot.get("price", 0)
except (KeyError, ValueError):
continue
if qty <= 0 or cost <= 0:
continue
pnl = (current_price - cost) * qty
held_days = (today - lot_date).days
is_long_term = held_days >= 365
if pnl < 0:
if is_long_term:
long_term_loss += pnl
else:
short_term_loss += pnl
harvest_lots.append({
"date": lot["date"],
"qty": qty,
"cost": cost,
"pnl": round(pnl, 2),
"held_days": held_days,
"cgt_type": "long-term" if is_long_term else "short-term",
"tax_benefit": round(abs(pnl) if not is_long_term else abs(pnl) * 0.5, 2),
})
else:
if is_long_term:
long_term_gain += pnl
else:
short_term_gain += pnl
total_loss = short_term_loss + long_term_loss
# Tax benefit: short-term losses offset at 100%, long-term at 50% (CGT discount)
tax_benefit = abs(short_term_loss) + abs(long_term_loss) * 0.5
if total_loss == 0:
return self._neutral(ticker, date, "no unrealised losses")
# Sort harvest candidates: short-term first (higher tax benefit), then by loss size
harvest_lots.sort(key=lambda l: (0 if l["cgt_type"] == "short-term" else 1, l["pnl"]))
if short_term_loss < 0:
signal = "STRONG"
direction = "SUPPORTS"
label = f"${short_term_loss:+,.0f} short-term loss (100% offset)"
if long_term_loss < 0:
label += f" + ${long_term_loss:+,.0f} long-term (50% offset)"
elif long_term_loss < 0:
signal = "MODERATE"
direction = "NEUTRAL"
label = f"${long_term_loss:+,.0f} long-term loss only (50% CGT discount reduces benefit)"
else:
signal = "WEAK"
direction = "NEUTRAL"
label = "minimal harvestable losses"
return StrategySignal(
name=self.name,
ticker=ticker,
date=date,
signal=signal,
value=round(tax_benefit, 2),
value_label=f"${tax_benefit:,.0f} tax benefit — {label}",
direction=direction,
detail={
"short_term_loss": round(short_term_loss, 2),
"long_term_loss": round(long_term_loss, 2),
"short_term_gain": round(short_term_gain, 2),
"long_term_gain": round(long_term_gain, 2),
"tax_benefit": round(tax_benefit, 2),
"harvest_lots": harvest_lots,
"n_harvest_lots": len(harvest_lots),
},
)
def _neutral(self, ticker: str, date: str, reason: str) -> StrategySignal:
return StrategySignal(
name=self.name, ticker=ticker, date=date,
signal="NEUTRAL", value=0.0,
value_label=f"N/A ({reason})",
direction="NEUTRAL", detail={},
)