
Tax Loss Harvesting
- 195 installs
- 257 repo stars
- Updated June 24, 2026
- agiprolabs/claude-trading-skills
tax-loss-harvesting is a Claude Code skill for identifying, scoring, and planning tax-loss harvesting opportunities with wash-sale compliance and carryforward tracking.
About
tax-loss-harvesting is a Claude Code skill for identifying, scoring, and planning tax-loss harvesting across a crypto portfolio. It ranks unrealized losses on magnitude, urgency to long-term threshold, wash-sale risk, and available gains to offset, computes net benefit after transaction costs, and tracks annual loss carryforward. A developer uses it to plan which losing positions to realize to reduce current-year tax liability while staying wash-sale compliant.
- Ranks and scores unrealized-loss harvesting opportunities
- Wash-sale compliance and net-benefit after transaction costs
- Annual loss carryforward and year-end use-it-or-lose-it planning
Tax Loss Harvesting by the numbers
- 195 all-time installs (skills.sh)
- Ranked #477 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
tax-loss-harvesting capabilities & compatibility
- Capabilities
- tax loss harvesting · wash sale check · carryforward tracking · opportunity scoring
- Use cases
- trading · data analysis
- Pricing
- Free
What tax-loss-harvesting says it does
Tax-loss harvesting is the practice of intentionally realizing investment losses to offset realized capital gains, thereby reducing your current-year tax liability.
Not all unrealized losses are equally valuable to harvest. This skill scores each opportunity on four dimensions:
npx skills add https://github.com/agiprolabs/claude-trading-skills --skill tax-loss-harvestingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 195 |
|---|---|
| repo stars | ★ 257 |
| Last updated | June 24, 2026 |
| Repository | agiprolabs/claude-trading-skills ↗ |
What it does
Identify, score, and plan crypto tax-loss harvesting opportunities with wash-sale compliance.
Who is it for?
Planning which unrealized-loss positions to realize to offset gains and reduce current-year crypto taxes.
Skip if: Tax advice; the skill states it is informational analysis only, not tax advice.
When should I use this skill?
You want to reduce current-year tax liability by harvesting losses without triggering wash-sale rules.
What you get
A ranked, net-benefit-scored harvesting plan that respects wash-sale windows and tracks loss carryforward.
- A ranked list of harvesting opportunities with net benefit
- A wash-sale-aware harvesting plan with carryforward tracking
By the numbers
- Scores opportunities on 4 dimensions
- $3,000 annual ordinary-income deduction cap
- 61-day wash-sale window (30 before + sale + 30 after)
Files
Tax-Loss Harvesting
Identify, score, and plan tax-loss harvesting (TLH) opportunities across a crypto portfolio. This skill covers unrealized-loss ranking, net-benefit calculation, wash sale compliance, annual loss carryforward tracking, and year-end "use it or lose it" strategies.
Disclaimer: This skill provides informational analysis only. It is NOT tax advice. Tax rules vary by jurisdiction and change frequently. Consult a qualified tax professional before making any tax-related trading decisions.
How Tax-Loss Harvesting Works
Tax-loss harvesting is the practice of intentionally realizing investment losses to offset realized capital gains, thereby reducing your current-year tax liability.
Core Mechanism
1. Identify positions with unrealized losses in your portfolio. 2. Sell those positions to realize the loss. 3. Offset realized gains with the harvested loss, reducing taxable income. 4. Optionally re-enter a similar (but not "substantially identical") position to maintain market exposure.
Short-Term vs Long-Term
| Holding Period | Classification | Typical Tax Rate |
|---|---|---|
| < 1 year | Short-term capital gain/loss | Ordinary income rate |
| >= 1 year | Long-term capital gain/loss | Preferential rate (0-20%) |
Short-term losses first offset short-term gains; long-term losses first offset long-term gains. Remaining net losses cross over to offset the other category.
Annual Loss Deduction Limit
If total net losses exceed total gains, the excess is deductible against ordinary income up to $3,000 per year ($1,500 if married filing separately). Any remaining loss carries forward indefinitely to future tax years.
Ranking Unrealized Losses
Not all unrealized losses are equally valuable to harvest. This skill scores each opportunity on four dimensions:
1. Loss Magnitude
Larger dollar losses provide more tax savings. The raw loss is the difference between current market value and cost basis.
unrealized_loss = current_value - cost_basis # negative when loss
tax_savings = abs(unrealized_loss) * marginal_tax_rate2. Days Until Long-Term Threshold
A position approaching the 1-year holding mark deserves special consideration:
- If close to crossing into long-term territory, harvesting now locks in a short-term loss (offsets higher-taxed short-term gains).
- If already long-term, the loss offsets long-term gains (lower tax rate benefit).
days_held = (today - acquisition_date).days
days_to_long_term = max(0, 365 - days_held)Positions with fewer days remaining until long-term are more urgent to evaluate because once they cross 365 days, a short-term loss becomes a less-valuable long-term loss.
3. Wash Sale Risk (Correlation Score)
The IRS wash sale rule prohibits claiming a loss if you buy a "substantially identical" security within 30 days before or after the sale. In crypto, the exact application is evolving, but prudent planning avoids re-entering the same token within the 61-day wash sale window (30 days before + sale day + 30 days after).
Correlation scoring: If you hold (or plan to re-enter) a position that is highly correlated with the harvested asset, wash sale risk increases. Score this as:
wash_sale_risk = 1.0 # if same token re-entry planned within 30 days
wash_sale_risk = correlation_coefficient # if correlated substitute held
wash_sale_risk = 0.0 # if no re-entry or uncorrelated substituteHigher wash sale risk reduces the effective score of the opportunity.
4. Available Gains to Offset
A harvested loss is only immediately useful if there are realized gains to offset. Score opportunities higher when:
- There are matching-type gains (short-term loss vs short-term gain).
- The loss amount does not greatly exceed available gains (diminishing marginal benefit beyond the $3K deduction cap).
offset_efficiency = min(1.0, available_matching_gains / abs(unrealized_loss))Composite Score
def tlh_score(
unrealized_loss: float,
days_to_long_term: int,
wash_sale_risk: float,
offset_efficiency: float,
weights: dict | None = None,
) -> float:
w = weights or {
"magnitude": 0.35,
"urgency": 0.25,
"wash_safety": 0.20,
"offset_match": 0.20,
}
magnitude_score = min(abs(unrealized_loss) / 10_000, 1.0)
urgency_score = max(0, 1.0 - days_to_long_term / 365)
wash_safety_score = 1.0 - wash_sale_risk
return (
w["magnitude"] * magnitude_score
+ w["urgency"] * urgency_score
+ w["wash_safety"] * wash_safety_score
+ w["offset_match"] * offset_efficiency
)Net Benefit Calculation
Harvesting a loss is not free. Transaction costs (swap fees, slippage, gas) reduce the benefit.
def net_benefit(
unrealized_loss: float,
marginal_tax_rate: float,
transaction_cost: float,
re_entry_cost: float = 0.0,
) -> float:
"""Compute net dollar benefit of harvesting a loss.
Args:
unrealized_loss: Negative number representing the loss.
marginal_tax_rate: Applicable tax rate (0.0 to 1.0).
transaction_cost: Cost to execute the sell (fees + slippage).
re_entry_cost: Cost to re-enter a substitute position.
Returns:
Net benefit in dollars. Positive means harvesting is worthwhile.
"""
tax_savings = abs(unrealized_loss) * marginal_tax_rate
total_costs = transaction_cost + re_entry_cost
return tax_savings - total_costsRule of thumb: Only harvest when net_benefit > 0 by a meaningful margin. Very small losses are not worth the transaction costs and operational complexity.
Year-End "Use It or Lose It"
Near December 31, evaluate whether to accelerate harvesting:
1. Tally year-to-date realized gains (both short-term and long-term). 2. Identify unrealized losses that can offset those gains. 3. Prioritize losses that offset same-type gains (short-term loss vs short-term gain yields the highest tax rate differential). 4. Check the $3K excess limit: If net losses already exceed gains by $3K, additional harvesting this year has no immediate tax benefit (though the carryforward still has value). 5. Consider settlement timing: Ensure trades settle before year-end.
Wash Sale Compliance
The 61-Day Window
The wash sale rule applies to purchases of substantially identical securities within:
- 30 days before the sale (retroactive wash sale)
- The day of the sale
- 30 days after the sale
If triggered, the disallowed loss is added to the cost basis of the replacement shares, deferring (not eliminating) the tax benefit.
Compliance Strategies
| Strategy | Description | Trade-off |
|---|---|---|
| Wait 31 days | Sell, wait 31 days, re-buy | Market exposure gap |
| Substitute asset | Sell, immediately buy a non-identical but correlated asset | Tracking error |
| No re-entry | Sell and stay out | Lost upside |
| Double-up | Buy additional shares, wait 31 days, sell original lot | Capital intensive |
Crypto-Specific Considerations
- The IRS has not explicitly ruled that the wash sale rule applies to cryptocurrency (as of 2025). However, proposed legislation may extend it.
- Prudent practitioners treat crypto as subject to wash sale rules for conservative planning.
- Different tokens (e.g., SOL vs ETH) are generally considered non-identical.
- Wrapped versions of the same token (e.g., SOL vs wSOL) may be considered substantially identical.
Annual Loss Carryforward Tracking
def compute_carryforward(
realized_gains_st: float,
realized_gains_lt: float,
realized_losses_st: float,
realized_losses_lt: float,
prior_carryforward: float = 0.0,
annual_deduction_limit: float = 3_000.0,
) -> dict:
"""Compute net tax position and carryforward.
Returns dict with keys:
net_st, net_lt, total_net,
deduction_used, carryforward
"""
net_st = realized_gains_st + realized_losses_st # losses are negative
net_lt = realized_gains_lt + realized_losses_lt
total_net = net_st + net_lt - prior_carryforward
if total_net >= 0:
return {
"net_st": net_st, "net_lt": net_lt,
"total_net": total_net, "deduction_used": 0.0,
"carryforward": 0.0,
}
excess_loss = abs(total_net)
deduction_used = min(excess_loss, annual_deduction_limit)
carryforward = max(0, excess_loss - annual_deduction_limit)
return {
"net_st": net_st, "net_lt": net_lt,
"total_net": total_net,
"deduction_used": deduction_used,
"carryforward": carryforward,
}Prerequisites
- Python 3.10+
- No external dependencies for core calculations
- Portfolio data: cost basis, acquisition date, current market value per lot
- Tax parameters: marginal tax rate, filing status, prior carryforward
Capabilities
| Capability | Description |
|---|---|
| Opportunity scanning | Identify all positions with unrealized losses |
| Multi-factor scoring | Rank by magnitude, urgency, wash safety, offset match |
| Net benefit analysis | Compare tax savings against transaction costs |
| Wash sale tracking | Flag positions within the 61-day window |
| Carryforward calculator | Track annual $3K limit and loss carryforward |
| Year-end planning | Prioritize harvesting before December 31 |
| Harvesting plan output | Generate actionable plan with sell orders and re-entry dates |
Quick Start
from datetime import date
# Define a portfolio position
position = {
"symbol": "TOKEN-A",
"cost_basis": 10_000.0,
"current_value": 6_500.0,
"acquisition_date": date(2025, 8, 15),
"quantity": 500.0,
}
unrealized_loss = position["current_value"] - position["cost_basis"] # -3500
days_held = (date.today() - position["acquisition_date"]).days
days_to_lt = max(0, 365 - days_held)
# Score the opportunity
score = tlh_score(
unrealized_loss=unrealized_loss,
days_to_long_term=days_to_lt,
wash_sale_risk=0.0,
offset_efficiency=0.8,
)
print(f"TLH score: {score:.3f}")
# Calculate net benefit
benefit = net_benefit(
unrealized_loss=unrealized_loss,
marginal_tax_rate=0.35,
transaction_cost=15.0,
re_entry_cost=15.0,
)
print(f"Net benefit: ${benefit:.2f}")Use Cases
1. Quarterly portfolio review: Scan all positions for harvesting opportunities ranked by composite score. 2. Year-end tax planning: Identify optimal set of positions to harvest before December 31 given year-to-date gain/loss totals. 3. Ongoing monitoring: Flag positions that are approaching the long-term threshold where harvesting a short-term loss becomes urgent. 4. Carryforward management: Track multi-year loss carryforward balances and project when they will be fully utilized. 5. Transaction cost analysis: Determine minimum loss threshold worth harvesting given current fee environment.
Files
| File | Description |
|---|---|
references/planned_features.md | TLH mechanics, scoring formula, wash sale interaction, carryforward rules, year-end strategies |
scripts/harvest_scanner.py | Demo scanner: score opportunities, generate harvesting plan, compute net benefit |
Important: This skill provides analytical tools for informational purposes only. All tax-related decisions should be reviewed by a qualified tax professional. Tax laws vary by jurisdiction and are subject to change.
Tax-Loss Harvesting — Planned Features Reference
TLH Mechanics
Tax-loss harvesting (TLH) converts unrealized portfolio losses into realized losses that offset taxable gains. The core loop:
1. Scan — Identify positions where current_value < cost_basis. 2. Score — Rank each opportunity by a composite metric (see Scoring Formula below). 3. Filter — Exclude opportunities where net benefit is negative or wash sale risk is too high. 4. Plan — Generate sell orders with quantities, expected proceeds, and re-entry dates. 5. Execute — Sell the position (outside this skill's scope — see jupiter-swap or dex-execution). 6. Track — Record the realized loss, update carryforward, set wash sale window alerts.
Lot-Level Tracking
When a position was acquired across multiple purchases ("lots"), each lot has its own cost basis and acquisition date. TLH should evaluate lots independently because:
- Some lots may have gains while others have losses.
- Lot-level selection (specific identification) lets you harvest only the losing lots.
- FIFO, LIFO, and specific identification methods produce different tax outcomes.
Realized vs Unrealized
| Term | Definition |
|---|---|
| Unrealized loss | Paper loss — position held, not yet sold |
| Realized loss | Loss locked in by selling the position |
| Harvested loss | Realized loss intentionally created for tax purposes |
Only realized losses can offset gains on a tax return.
Scoring Formula
The composite TLH score ranks opportunities on four equally-weighted-by-default dimensions, each normalized to [0, 1]:
Magnitude Score
magnitude = min(abs(unrealized_loss) / reference_amount, 1.0)reference_amount defaults to $10,000 but should scale with portfolio size. A $500 loss in a $10K portfolio is significant; in a $1M portfolio it is noise.
Urgency Score
urgency = max(0, 1.0 - days_to_long_term / 365)A position 10 days from crossing into long-term territory scores 0.97 urgency. One purchased yesterday scores near 0. This reflects the fact that short-term losses offset higher-taxed short-term gains.
Wash Safety Score
wash_safety = 1.0 - wash_sale_riskWhere wash_sale_risk is:
1.0— Same-token re-entry planned within 30 days.0.5–0.9— Highly correlated substitute held or planned.0.0— No re-entry or uncorrelated substitute.
Offset Match Score
offset_match = min(1.0, available_matching_gains / abs(unrealized_loss))A loss of $5,000 with $5,000 in matching gains scores 1.0. A loss of $5,000 with only $1,000 in matching gains scores 0.2. Losses beyond available gains still have value (up to $3K deduction + carryforward), but immediate benefit is lower.
Composite
score = w_mag * magnitude + w_urg * urgency + w_wash * wash_safety + w_off * offset_matchDefault weights: {magnitude: 0.35, urgency: 0.25, wash_safety: 0.20, offset_match: 0.20}.
Adjust weights based on context:
- Year-end rush: Increase urgency weight.
- High wash sale environment: Increase wash_safety weight.
- Large realized gains: Increase offset_match weight.
Wash Sale Interaction
The 61-Day Window
A wash sale is triggered when a taxpayer sells a security at a loss and, within 30 days before or after the sale, acquires a "substantially identical" security.
wash_sale_window_start = sale_date - 30 days
wash_sale_window_end = sale_date + 30 daysConsequences of Triggering a Wash Sale
The loss is disallowed for the current tax year. However:
- The disallowed loss is added to the cost basis of the replacement shares.
- The holding period of the replacement shares includes the holding period of the original shares.
- The loss is deferred, not permanently lost.
Planning Around Wash Sales
Before selling — Check if you purchased the same token within the prior 30 days. If so, that purchase triggers a retroactive wash sale on the planned harvest.
After selling — Set a 31-day calendar reminder before re-entering the position. Any purchase of the same token within 30 days disallows the loss.
Substitute positions — If you want continuous market exposure, buy a different but correlated token. For Solana tokens, consider:
- Selling SOL and buying a SOL-correlated token (e.g., JTO, BONK) — generally not "substantially identical."
- Selling one memecoin and buying another in the same sector — generally safe.
- Selling a wrapped token and buying the unwrapped version — potentially risky, may be considered identical.
Carryforward Rules
Annual Deduction Limit
Net capital losses exceeding net capital gains may offset up to $3,000 of ordinary income per year ($1,500 for married filing separately).
Carryforward Mechanics
Excess losses carry forward indefinitely to future tax years. The carryforward retains its character (short-term or long-term) in most cases.
Year-by-year tracking example:
| Year | Realized Gains | Realized Losses | Net | Deduction Used | Carryforward |
|---|---|---|---|---|---|
| 2025 | $5,000 | -$15,000 | -$10,000 | $3,000 | $7,000 |
| 2026 | $8,000 | -$2,000 | -$1,000* | $1,000 | $0 |
*Year 2026 net = $8,000 - $2,000 - $7,000 (carryforward) = -$1,000.
Order of Application
1. Short-term losses offset short-term gains. 2. Long-term losses offset long-term gains. 3. Net short-term loss offsets net long-term gain (and vice versa). 4. Remaining net loss up to $3,000 offsets ordinary income. 5. Excess carries forward.
Year-End Strategies
Strategy 1: Gain-Matching Harvest
Identify all realized gains for the year. Harvest losses equal to those gains to zero out the tax liability. Stop harvesting once gains are fully offset plus the $3K ordinary income deduction.
Strategy 2: Aggressive Harvest
Harvest every available loss regardless of current gains. Benefits:
- Builds a large carryforward for future years.
- Useful if you expect large gains next year.
Drawbacks:
- Transaction costs on many small positions.
- Opportunity cost of being out of positions for 31 days.
Strategy 3: Threshold Harvest
Set a minimum loss threshold (e.g., $500 or 10% of position value). Only harvest losses exceeding the threshold to balance tax savings against complexity.
Strategy 4: Continuous Harvest
Monitor positions throughout the year rather than waiting for year-end. Benefits:
- Captures losses that may recover by December.
- Spreads transaction costs over time.
- Avoids year-end liquidity crunches.
Year-End Checklist
1. Tally all realized gains and losses year-to-date. 2. Apply any prior-year carryforward. 3. Compute remaining taxable gain. 4. Scan portfolio for unrealized losses. 5. Score and rank harvesting opportunities. 6. Filter by net benefit > 0. 7. Check wash sale windows for recent purchases. 8. Execute harvests with enough time for settlement before Dec 31. 9. Set 31-day reminders for re-entry eligibility. 10. Update carryforward tracker for next year.
Crypto-Specific Notes
- IRS Notice 2014-21 treats cryptocurrency as property, subject to capital gains rules.
- The wash sale rule (IRC Section 1091) technically applies to "stock or securities." Whether crypto qualifies is debated, but proposed legislation (e.g., Build Back Better Act) aimed to extend it explicitly to digital assets.
- Conservative approach: Treat crypto as subject to wash sales.
- Aggressive approach: Argue crypto is not a "security" and wash sales do not apply. This carries audit risk.
- Cost basis methods: FIFO is the IRS default; specific identification requires contemporaneous records.
- Staking rewards: Received tokens have a cost basis equal to fair market value at time of receipt. These can also generate TLH opportunities if value drops.
#!/usr/bin/env python3
"""Tax-loss harvesting opportunity scanner.
Scans a portfolio for unrealized losses, scores each harvesting opportunity
on four dimensions (magnitude, urgency, wash safety, offset match), generates
a prioritized harvesting plan, and computes the net benefit of each harvest.
Usage:
python scripts/harvest_scanner.py --demo
Dependencies:
None (standard library only)
Environment Variables:
None required (demo mode uses synthetic data)
"""
import argparse
import json
import sys
from dataclasses import dataclass, field, asdict
from datetime import date, timedelta
from typing import Optional
# ── Configuration ───────────────────────────────────────────────────
DEFAULT_MARGINAL_TAX_RATE_ST = 0.37 # Federal short-term (ordinary income)
DEFAULT_MARGINAL_TAX_RATE_LT = 0.20 # Federal long-term capital gains
DEFAULT_ANNUAL_DEDUCTION_LIMIT = 3_000.0
DEFAULT_TRANSACTION_COST_BPS = 50 # 0.50% round-trip (swap + slippage)
LONG_TERM_THRESHOLD_DAYS = 365
WASH_SALE_WINDOW_DAYS = 30
REFERENCE_LOSS_AMOUNT = 10_000.0 # For magnitude normalization
SCORE_WEIGHTS = {
"magnitude": 0.35,
"urgency": 0.25,
"wash_safety": 0.20,
"offset_match": 0.20,
}
# ── Data Models ─────────────────────────────────────────────────────
@dataclass
class Lot:
"""A single tax lot within a portfolio position."""
symbol: str
quantity: float
cost_basis_per_unit: float
acquisition_date: date
current_price: float
wash_sale_risk: float = 0.0 # 0.0 = safe, 1.0 = same-token re-entry planned
@property
def cost_basis(self) -> float:
return self.quantity * self.cost_basis_per_unit
@property
def current_value(self) -> float:
return self.quantity * self.current_price
@property
def unrealized_pnl(self) -> float:
return self.current_value - self.cost_basis
@property
def unrealized_pnl_pct(self) -> float:
if self.cost_basis == 0:
return 0.0
return self.unrealized_pnl / self.cost_basis
@property
def days_held(self) -> int:
return (date.today() - self.acquisition_date).days
@property
def is_long_term(self) -> bool:
return self.days_held >= LONG_TERM_THRESHOLD_DAYS
@property
def days_to_long_term(self) -> int:
return max(0, LONG_TERM_THRESHOLD_DAYS - self.days_held)
@property
def has_loss(self) -> bool:
return self.unrealized_pnl < 0
@dataclass
class HarvestOpportunity:
"""A scored tax-loss harvesting opportunity."""
lot: Lot
magnitude_score: float = 0.0
urgency_score: float = 0.0
wash_safety_score: float = 0.0
offset_match_score: float = 0.0
composite_score: float = 0.0
tax_savings: float = 0.0
transaction_cost: float = 0.0
re_entry_cost: float = 0.0
net_benefit: float = 0.0
re_entry_eligible_date: Optional[date] = None
@dataclass
class TaxSummary:
"""Annual tax gain/loss summary with carryforward."""
realized_gains_st: float = 0.0
realized_gains_lt: float = 0.0
realized_losses_st: float = 0.0
realized_losses_lt: float = 0.0
prior_carryforward: float = 0.0
annual_deduction_limit: float = DEFAULT_ANNUAL_DEDUCTION_LIMIT
@property
def net_st(self) -> float:
return self.realized_gains_st + self.realized_losses_st
@property
def net_lt(self) -> float:
return self.realized_gains_lt + self.realized_losses_lt
@property
def total_net(self) -> float:
return self.net_st + self.net_lt - self.prior_carryforward
@property
def available_st_gains(self) -> float:
return max(0.0, self.realized_gains_st + self.realized_losses_st)
@property
def available_lt_gains(self) -> float:
return max(0.0, self.realized_gains_lt + self.realized_losses_lt)
def compute_carryforward(self) -> dict:
"""Compute deduction used and carryforward for the year."""
if self.total_net >= 0:
return {
"net_st": self.net_st,
"net_lt": self.net_lt,
"total_net": self.total_net,
"deduction_used": 0.0,
"carryforward": 0.0,
}
excess_loss = abs(self.total_net)
deduction_used = min(excess_loss, self.annual_deduction_limit)
carryforward = max(0.0, excess_loss - self.annual_deduction_limit)
return {
"net_st": self.net_st,
"net_lt": self.net_lt,
"total_net": self.total_net,
"deduction_used": deduction_used,
"carryforward": carryforward,
}
# ── Scoring Engine ──────────────────────────────────────────────────
def compute_magnitude_score(
unrealized_loss: float,
reference_amount: float = REFERENCE_LOSS_AMOUNT,
) -> float:
"""Normalize loss magnitude to [0, 1]."""
return min(abs(unrealized_loss) / reference_amount, 1.0)
def compute_urgency_score(days_to_long_term: int) -> float:
"""Score urgency of harvesting before long-term threshold.
Higher score = closer to crossing into long-term territory,
meaning a short-term loss is about to become a long-term loss.
"""
if days_to_long_term <= 0:
return 0.0 # Already long-term, no urgency for ST harvesting
return max(0.0, 1.0 - days_to_long_term / LONG_TERM_THRESHOLD_DAYS)
def compute_wash_safety_score(wash_sale_risk: float) -> float:
"""Invert wash sale risk to get a safety score."""
return 1.0 - max(0.0, min(1.0, wash_sale_risk))
def compute_offset_match_score(
unrealized_loss: float,
available_matching_gains: float,
) -> float:
"""Score how well this loss matches available gains."""
if abs(unrealized_loss) < 0.01:
return 0.0
return min(1.0, available_matching_gains / abs(unrealized_loss))
def score_opportunity(
lot: Lot,
tax_summary: TaxSummary,
marginal_rate_st: float = DEFAULT_MARGINAL_TAX_RATE_ST,
marginal_rate_lt: float = DEFAULT_MARGINAL_TAX_RATE_LT,
transaction_cost_bps: int = DEFAULT_TRANSACTION_COST_BPS,
weights: Optional[dict] = None,
) -> HarvestOpportunity:
"""Score a single lot as a TLH opportunity.
Args:
lot: The tax lot to evaluate.
tax_summary: Year-to-date tax gain/loss summary.
marginal_rate_st: Marginal tax rate for short-term gains.
marginal_rate_lt: Marginal tax rate for long-term gains.
transaction_cost_bps: Round-trip transaction cost in basis points.
weights: Custom scoring weights (defaults to SCORE_WEIGHTS).
Returns:
A fully scored HarvestOpportunity.
"""
if not lot.has_loss:
return HarvestOpportunity(lot=lot)
w = weights or SCORE_WEIGHTS
loss = lot.unrealized_pnl # negative
# Determine applicable tax rate and matching gains
if lot.is_long_term:
marginal_rate = marginal_rate_lt
available_gains = tax_summary.available_lt_gains
else:
marginal_rate = marginal_rate_st
available_gains = tax_summary.available_st_gains
# Component scores
mag = compute_magnitude_score(loss)
urg = compute_urgency_score(lot.days_to_long_term)
wash = compute_wash_safety_score(lot.wash_sale_risk)
offset = compute_offset_match_score(loss, available_gains)
composite = (
w["magnitude"] * mag
+ w["urgency"] * urg
+ w["wash_safety"] * wash
+ w["offset_match"] * offset
)
# Net benefit
tax_savings = abs(loss) * marginal_rate
txn_cost = lot.current_value * (transaction_cost_bps / 10_000)
re_cost = txn_cost # Assume similar cost to re-enter
benefit = tax_savings - txn_cost - re_cost
re_entry_date = date.today() + timedelta(days=WASH_SALE_WINDOW_DAYS + 1)
return HarvestOpportunity(
lot=lot,
magnitude_score=mag,
urgency_score=urg,
wash_safety_score=wash,
offset_match_score=offset,
composite_score=composite,
tax_savings=tax_savings,
transaction_cost=txn_cost,
re_entry_cost=re_cost,
net_benefit=benefit,
re_entry_eligible_date=re_entry_date,
)
# ── Portfolio Scanner ───────────────────────────────────────────────
def scan_portfolio(
lots: list[Lot],
tax_summary: TaxSummary,
min_loss_threshold: float = 50.0,
min_net_benefit: float = 0.0,
**kwargs,
) -> list[HarvestOpportunity]:
"""Scan all lots and return scored harvesting opportunities.
Args:
lots: All tax lots in the portfolio.
tax_summary: Year-to-date realized gain/loss summary.
min_loss_threshold: Minimum dollar loss to consider.
min_net_benefit: Minimum net benefit to include in results.
**kwargs: Passed to score_opportunity.
Returns:
List of opportunities sorted by composite score (descending).
"""
opportunities: list[HarvestOpportunity] = []
for lot in lots:
if not lot.has_loss:
continue
if abs(lot.unrealized_pnl) < min_loss_threshold:
continue
opp = score_opportunity(lot, tax_summary, **kwargs)
if opp.net_benefit >= min_net_benefit:
opportunities.append(opp)
opportunities.sort(key=lambda o: o.composite_score, reverse=True)
return opportunities
# ── Plan Generator ──────────────────────────────────────────────────
def generate_harvest_plan(
opportunities: list[HarvestOpportunity],
tax_summary: TaxSummary,
max_harvests: int = 10,
) -> dict:
"""Generate a harvesting plan from scored opportunities.
Args:
opportunities: Scored and sorted opportunities.
tax_summary: Current year tax summary.
max_harvests: Maximum number of positions to harvest.
Returns:
Plan dict with actions, summary, and carryforward projection.
"""
actions = []
total_loss_harvested = 0.0
total_tax_savings = 0.0
total_costs = 0.0
for opp in opportunities[:max_harvests]:
loss_amount = abs(opp.lot.unrealized_pnl)
action = {
"rank": len(actions) + 1,
"symbol": opp.lot.symbol,
"action": "SELL",
"quantity": opp.lot.quantity,
"current_price": opp.lot.current_price,
"cost_basis_per_unit": opp.lot.cost_basis_per_unit,
"loss_amount": round(loss_amount, 2),
"loss_pct": round(opp.lot.unrealized_pnl_pct * 100, 1),
"holding_period": "long-term" if opp.lot.is_long_term else "short-term",
"days_held": opp.lot.days_held,
"composite_score": round(opp.composite_score, 4),
"tax_savings": round(opp.tax_savings, 2),
"transaction_cost": round(opp.transaction_cost, 2),
"net_benefit": round(opp.net_benefit, 2),
"wash_sale_risk": opp.lot.wash_sale_risk,
"re_entry_eligible": str(opp.re_entry_eligible_date),
}
actions.append(action)
total_loss_harvested += loss_amount
total_tax_savings += opp.tax_savings
total_costs += opp.transaction_cost + opp.re_entry_cost
# Project carryforward after harvesting
projected_losses_st = tax_summary.realized_losses_st
projected_losses_lt = tax_summary.realized_losses_lt
for opp in opportunities[:max_harvests]:
if opp.lot.is_long_term:
projected_losses_lt += opp.lot.unrealized_pnl
else:
projected_losses_st += opp.lot.unrealized_pnl
projected_summary = TaxSummary(
realized_gains_st=tax_summary.realized_gains_st,
realized_gains_lt=tax_summary.realized_gains_lt,
realized_losses_st=projected_losses_st,
realized_losses_lt=projected_losses_lt,
prior_carryforward=tax_summary.prior_carryforward,
)
carryforward = projected_summary.compute_carryforward()
plan = {
"plan_date": str(date.today()),
"actions": actions,
"summary": {
"positions_to_harvest": len(actions),
"total_loss_harvested": round(total_loss_harvested, 2),
"total_tax_savings": round(total_tax_savings, 2),
"total_transaction_costs": round(total_costs, 2),
"total_net_benefit": round(total_tax_savings - total_costs, 2),
},
"projected_tax_position": {
"net_short_term": round(carryforward["net_st"], 2),
"net_long_term": round(carryforward["net_lt"], 2),
"total_net": round(carryforward["total_net"], 2),
"deduction_used": round(carryforward["deduction_used"], 2),
"carryforward_to_next_year": round(carryforward["carryforward"], 2),
},
}
return plan
# ── Demo Data ───────────────────────────────────────────────────────
def build_demo_portfolio() -> list[Lot]:
"""Build a synthetic portfolio with a mix of gains and losses."""
today = date.today()
return [
Lot(
symbol="SOL",
quantity=100.0,
cost_basis_per_unit=180.00,
acquisition_date=today - timedelta(days=200),
current_price=135.00,
wash_sale_risk=0.3,
),
Lot(
symbol="BONK",
quantity=50_000_000.0,
cost_basis_per_unit=0.000035,
acquisition_date=today - timedelta(days=90),
current_price=0.000018,
wash_sale_risk=0.0,
),
Lot(
symbol="JTO",
quantity=2_000.0,
cost_basis_per_unit=4.50,
acquisition_date=today - timedelta(days=350),
current_price=2.80,
wash_sale_risk=0.1,
),
Lot(
symbol="WIF",
quantity=5_000.0,
cost_basis_per_unit=2.20,
acquisition_date=today - timedelta(days=45),
current_price=1.10,
wash_sale_risk=0.8,
),
Lot(
symbol="PYTH",
quantity=10_000.0,
cost_basis_per_unit=0.50,
acquisition_date=today - timedelta(days=400),
current_price=0.35,
wash_sale_risk=0.0,
),
# Positions with GAINS (should be excluded from harvest scan)
Lot(
symbol="JUP",
quantity=3_000.0,
cost_basis_per_unit=0.60,
acquisition_date=today - timedelta(days=180),
current_price=1.25,
wash_sale_risk=0.0,
),
Lot(
symbol="RAY",
quantity=500.0,
cost_basis_per_unit=1.80,
acquisition_date=today - timedelta(days=120),
current_price=5.50,
wash_sale_risk=0.0,
),
]
def build_demo_tax_summary() -> TaxSummary:
"""Build a year-to-date tax summary with some realized gains."""
return TaxSummary(
realized_gains_st=4_500.0,
realized_gains_lt=2_200.0,
realized_losses_st=-800.0,
realized_losses_lt=-300.0,
prior_carryforward=1_500.0,
)
# ── Display ─────────────────────────────────────────────────────────
def print_portfolio_summary(lots: list[Lot]) -> None:
"""Print a table of all portfolio positions."""
print("\n" + "=" * 90)
print("PORTFOLIO POSITIONS")
print("=" * 90)
header = f"{'Symbol':<8} {'Qty':>14} {'Basis':>10} {'Price':>10} {'Value':>12} {'P&L':>10} {'P&L%':>7} {'Days':>5}"
print(header)
print("-" * 90)
total_value = 0.0
total_pnl = 0.0
for lot in lots:
pnl_str = f"${lot.unrealized_pnl:,.2f}"
pct_str = f"{lot.unrealized_pnl_pct * 100:.1f}%"
print(
f"{lot.symbol:<8} "
f"{lot.quantity:>14,.2f} "
f"${lot.cost_basis_per_unit:>9.4f} "
f"${lot.current_price:>9.4f} "
f"${lot.current_value:>11,.2f} "
f"{pnl_str:>10} "
f"{pct_str:>7} "
f"{lot.days_held:>5}"
)
total_value += lot.current_value
total_pnl += lot.unrealized_pnl
print("-" * 90)
print(f"{'TOTAL':<8} {'':>14} {'':>10} {'':>10} ${total_value:>11,.2f} ${total_pnl:>9,.2f}")
print()
def print_tax_summary(summary: TaxSummary) -> None:
"""Print the year-to-date tax summary."""
print("=" * 60)
print("YEAR-TO-DATE TAX SUMMARY")
print("=" * 60)
cf = summary.compute_carryforward()
print(f" Realized ST gains: ${summary.realized_gains_st:>10,.2f}")
print(f" Realized ST losses: ${summary.realized_losses_st:>10,.2f}")
print(f" Net short-term: ${cf['net_st']:>10,.2f}")
print()
print(f" Realized LT gains: ${summary.realized_gains_lt:>10,.2f}")
print(f" Realized LT losses: ${summary.realized_losses_lt:>10,.2f}")
print(f" Net long-term: ${cf['net_lt']:>10,.2f}")
print()
print(f" Prior-year carryforward: ${summary.prior_carryforward:>10,.2f}")
print(f" Total net: ${cf['total_net']:>10,.2f}")
print(f" Deduction used: ${cf['deduction_used']:>10,.2f}")
print(f" Carryforward: ${cf['carryforward']:>10,.2f}")
print()
def print_opportunities(opportunities: list[HarvestOpportunity]) -> None:
"""Print scored harvesting opportunities."""
print("=" * 100)
print("TAX-LOSS HARVESTING OPPORTUNITIES (ranked by composite score)")
print("=" * 100)
header = (
f"{'#':>2} {'Symbol':<8} {'Loss':>10} {'Loss%':>7} "
f"{'Mag':>5} {'Urg':>5} {'Wash':>5} {'Off':>5} {'Score':>6} "
f"{'Savings':>9} {'Costs':>8} {'Net':>9} {'Period':<6}"
)
print(header)
print("-" * 100)
for i, opp in enumerate(opportunities, 1):
loss = opp.lot.unrealized_pnl
period = "LT" if opp.lot.is_long_term else "ST"
print(
f"{i:>2} {opp.lot.symbol:<8} "
f"${loss:>9,.2f} "
f"{opp.lot.unrealized_pnl_pct * 100:>6.1f}% "
f"{opp.magnitude_score:>5.3f} "
f"{opp.urgency_score:>5.3f} "
f"{opp.wash_safety_score:>5.3f} "
f"{opp.offset_match_score:>5.3f} "
f"{opp.composite_score:>6.4f} "
f"${opp.tax_savings:>8,.2f} "
f"${opp.transaction_cost + opp.re_entry_cost:>7,.2f} "
f"${opp.net_benefit:>8,.2f} "
f"{period:<6}"
)
print()
def print_plan(plan: dict) -> None:
"""Print the harvesting plan."""
print("=" * 80)
print("HARVESTING PLAN")
print("=" * 80)
for action in plan["actions"]:
print(f"\n #{action['rank']} {action['action']} {action['symbol']}")
print(f" Quantity: {action['quantity']:,.2f}")
print(f" Loss: ${action['loss_amount']:,.2f} ({action['loss_pct']}%)")
print(f" Holding period: {action['holding_period']} ({action['days_held']} days)")
print(f" Score: {action['composite_score']:.4f}")
print(f" Tax savings: ${action['tax_savings']:,.2f}")
print(f" Transaction cost: ${action['transaction_cost']:,.2f}")
print(f" Net benefit: ${action['net_benefit']:,.2f}")
wash_label = "LOW" if action["wash_sale_risk"] < 0.3 else ("MED" if action["wash_sale_risk"] < 0.7 else "HIGH")
print(f" Wash sale risk: {wash_label} ({action['wash_sale_risk']:.1f})")
print(f" Re-entry eligible: {action['re_entry_eligible']}")
s = plan["summary"]
print(f"\n {'─' * 50}")
print(f" Positions to harvest: {s['positions_to_harvest']}")
print(f" Total loss harvested: ${s['total_loss_harvested']:,.2f}")
print(f" Total tax savings: ${s['total_tax_savings']:,.2f}")
print(f" Total transaction costs: ${s['total_transaction_costs']:,.2f}")
print(f" TOTAL NET BENEFIT: ${s['total_net_benefit']:,.2f}")
p = plan["projected_tax_position"]
print(f"\n PROJECTED TAX POSITION (after harvesting):")
print(f" Net short-term: ${p['net_short_term']:,.2f}")
print(f" Net long-term: ${p['net_long_term']:,.2f}")
print(f" Total net: ${p['total_net']:,.2f}")
print(f" Deduction used: ${p['deduction_used']:,.2f}")
print(f" Carryforward: ${p['carryforward_to_next_year']:,.2f}")
print()
# ── Main ────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(
description="Tax-loss harvesting opportunity scanner"
)
parser.add_argument(
"--demo",
action="store_true",
help="Run with synthetic demo portfolio",
)
parser.add_argument(
"--json",
action="store_true",
help="Output plan as JSON instead of formatted text",
)
parser.add_argument(
"--min-loss",
type=float,
default=50.0,
help="Minimum dollar loss to consider (default: 50)",
)
parser.add_argument(
"--tax-rate-st",
type=float,
default=DEFAULT_MARGINAL_TAX_RATE_ST,
help=f"Short-term marginal tax rate (default: {DEFAULT_MARGINAL_TAX_RATE_ST})",
)
parser.add_argument(
"--tax-rate-lt",
type=float,
default=DEFAULT_MARGINAL_TAX_RATE_LT,
help=f"Long-term marginal tax rate (default: {DEFAULT_MARGINAL_TAX_RATE_LT})",
)
args = parser.parse_args()
if not args.demo:
print("Currently only --demo mode is supported.")
print("Usage: python scripts/harvest_scanner.py --demo")
sys.exit(1)
# Build demo data
lots = build_demo_portfolio()
tax_summary = build_demo_tax_summary()
# Scan for opportunities
opportunities = scan_portfolio(
lots=lots,
tax_summary=tax_summary,
min_loss_threshold=args.min_loss,
marginal_rate_st=args.tax_rate_st,
marginal_rate_lt=args.tax_rate_lt,
)
# Generate plan
plan = generate_harvest_plan(opportunities, tax_summary)
if args.json:
print(json.dumps(plan, indent=2))
return
# Display results
print_portfolio_summary(lots)
print_tax_summary(tax_summary)
print_opportunities(opportunities)
print_plan(plan)
print("=" * 80)
print("DISCLAIMER: This analysis is for informational purposes only.")
print("It is NOT tax advice. Consult a qualified tax professional")
print("before making any tax-related trading decisions.")
print("=" * 80)
if __name__ == "__main__":
main()
Related skills
FAQ
On what dimensions are losses scored?
Loss magnitude, days until the long-term threshold (urgency), wash-sale risk from correlated or same-token re-entry, and available matching gains to offset, combined into a weighted composite score.
What is the annual loss deduction limit?
If net losses exceed gains, the excess is deductible against ordinary income up to $3,000 per year ($1,500 if married filing separately), with any remainder carried forward indefinitely.