
Wash Sale Detection
- 198 installs
- 257 repo stars
- Updated June 24, 2026
- agiprolabs/claude-trading-skills
wash-sale-detection is a Claude Code skill that flags crypto wash sales under 2025 US tax rules, computing disallowed losses, basis adjustments, and safe re-entry countdowns.
About
A Claude Code skill that detects crypto wash sales under the US tax rules effective 2025. It scans trade history for the 61-day window around realized losses, computes disallowed losses and cost-basis adjustments, and shows a safe re-entry countdown per token. It runs on the Python standard library and is explicitly informational, not tax advice.
- Detects crypto wash sales under 2025 US rules (IRC Section 1091)
- Monitors the 61-day window and computes disallowed loss and basis adjustment
- Shows a safe re-entry countdown per token; standard library only
Wash Sale Detection by the numbers
- 198 all-time installs (skills.sh)
- Ranked #473 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
wash-sale-detection capabilities & compatibility
Free; runs locally on the Python standard library with no external dependencies
- Capabilities
- wash sale detection · trade accounting · cost basis tracking · tax compliance · loss tracking
- Use cases
- data analysis · trading
- Pricing
- Free
What wash-sale-detection says it does
Detect wash sales under current US crypto tax rules (effective 2025), monitor the 61-day window around realized losses, track disallowed losses with basis adjustments, and compute safe re-entry countd
This skill provides informational analysis only. It is NOT tax advice.
No external dependencies required (standard library only)
npx skills add https://github.com/agiprolabs/claude-trading-skills --skill wash-sale-detectionAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 198 |
|---|---|
| repo stars | ★ 257 |
| Last updated | June 24, 2026 |
| Repository | agiprolabs/claude-trading-skills ↗ |
What it does
Scan crypto trade history for wash sales under 2025 US rules and compute disallowed losses and safe re-entry dates.
Who is it for?
Reviewing crypto trade history for wash-sale violations before filing US taxes.
Skip if: Providing formal tax advice or handling non-US tax regimes.
When should I use this skill?
You need to check whether crypto losses are disallowed by the 61-day wash-sale window.
What you get
A list of wash-sale violations with disallowed losses, adjusted basis, and safe re-entry dates.
- A wash-sale report with disallowed losses, adjusted basis, and per-token re-entry countdowns
By the numbers
- 61-day wash-sale window (30 days before to 30 days after)
- Effective January 1, 2025 under IRC Section 1091
Files
Wash Sale Detection
Detect wash sales under current US crypto tax rules (effective 2025), monitor the 61-day window around realized losses, track disallowed losses with basis adjustments, and compute safe re-entry countdowns.
Disclaimer: This skill provides informational analysis only. It is NOT tax advice. Consult a qualified tax professional or CPA for guidance on your specific situation. Tax law is complex, and the application of wash sale rules to cryptocurrency may vary based on individual circumstances, IRS guidance updates, and court rulings.
Background
Before 2025, cryptocurrency was not subject to the wash sale rule because digital assets were classified as property rather than securities. The Infrastructure Investment and Jobs Act and subsequent IRS rulemaking extended wash sale treatment to digital assets beginning January 1, 2025.
Under IRC Section 1091 (as amended for digital assets), if you sell or dispose of a cryptocurrency at a loss and acquire a substantially identical asset within a 61-day window (30 days before the sale through 30 days after), the loss is disallowed for tax purposes. The disallowed loss is added to the cost basis of the replacement position.
Key Concepts
The 61-Day Window
Day -30 ................. Day 0 ................. Day +30
|--- 30 days before ---|--- sale day ---|--- 30 days after ---|
^ ^ ^
Window opens Loss realized Window closes- Day 0: The day you sell a position at a realized loss
- Days -30 to -1: Purchases in this range trigger a wash sale retroactively
- Days +1 to +30: Purchases in this range trigger a wash sale prospectively
- The window is calendar days, not trading days
Substantially Identical Assets
For crypto, "substantially identical" generally means the same token. Selling SOL at a loss and buying SOL within 30 days is a wash sale. Selling SOL and buying ETH is not (they are different assets).
Edge cases that may be scrutinized:
- Wrapped vs unwrapped versions of the same token (e.g., SOL vs wSOL)
- Tokens across different chains (e.g., USDC on Solana vs USDC on Ethereum)
- Derivative tokens that track the same underlying (e.g., stSOL and SOL)
Disallowed Loss and Basis Adjustment
When a wash sale occurs: 1. The realized loss is disallowed — you cannot deduct it in the current tax year 2. The disallowed loss is added to the cost basis of the replacement position 3. The holding period of the original position may carry over to the replacement
Example:
- Buy 10 SOL at $100 each (cost basis: $1,000)
- Sell 10 SOL at $80 each (proceeds: $800, loss: $200)
- Buy 10 SOL at $85 within 15 days (wash sale triggered)
- New cost basis: $850 + $200 disallowed loss = $1,050
- The $200 loss is not gone — it is deferred into the new position
Prerequisites
- Python 3.10+
- No external dependencies required (standard library only)
- Trade history data in CSV or structured format with: date, action (buy/sell), token, quantity, price, proceeds, cost basis
Capabilities
1. Wash Sale Scanning — Analyze a trade history and flag all wash sale violations 2. 61-Day Window Monitoring — Track open windows for recent loss-generating sales 3. Disallowed Loss Calculation — Compute the exact disallowed amount per wash sale 4. Basis Adjustment Tracking — Show adjusted cost basis for replacement positions 5. Safe Re-Entry Countdown — For each token sold at a loss, show days remaining until safe to re-enter 6. Automation Hazard Detection — Flag copy-trade systems or bot strategies that may inadvertently trigger wash sales
Quick Start
from datetime import date
# Define your trade history
trades = [
{"date": date(2025, 3, 1), "action": "buy", "token": "SOL", "qty": 10, "price": 100.0},
{"date": date(2025, 3, 15), "action": "sell", "token": "SOL", "qty": 10, "price": 80.0},
{"date": date(2025, 3, 25), "action": "buy", "token": "SOL", "qty": 10, "price": 85.0},
]
# Check for wash sales
from scripts.wash_sale_scanner import WashSaleScanner
scanner = WashSaleScanner(trades)
results = scanner.scan()
for ws in results.wash_sales:
print(f"WASH SALE: {ws.token} — Loss ${ws.disallowed_loss:.2f} disallowed")
print(f" Sale: {ws.sale_date} | Re-entry: {ws.replacement_date}")
print(f" Adjusted basis: ${ws.adjusted_basis:.2f}")
# Check safe re-entry countdowns
for countdown in results.countdowns:
print(f"{countdown.token}: {countdown.days_remaining} days until safe re-entry")Use Cases
1. End-of-Year Tax Review
Scan your full year of trading activity to identify all wash sales before filing taxes. Generate a report showing total disallowed losses and adjusted cost bases.
2. Real-Time Monitoring
Before placing a buy order, check whether the token has an open wash sale window from a recent loss. Avoid inadvertent wash sales by waiting for the countdown to expire.
3. Copy-Trade and Bot Audit
Automated trading systems (copy-trading bots, DCA bots, grid bots) frequently trigger wash sales because they buy and sell the same tokens repeatedly. Run this scanner on bot trade exports to quantify the tax impact.
4. Tax-Loss Harvesting Coordination
When executing a tax-loss harvesting strategy, use the safe re-entry countdown to plan when you can re-enter positions. Swap into a non-identical asset during the 30-day window if you want to maintain market exposure.
5. Multi-Account Wash Sale Detection
The wash sale rule applies across all accounts controlled by the same taxpayer. If you trade SOL on multiple exchanges or wallets, aggregate the trade history before scanning.
Edge Cases and Automation Hazards
DCA Bots and Grid Bots
Dollar-cost averaging bots that buy a token weekly will almost certainly trigger wash sales if the token is also sold at a loss during the same period. The scanner flags overlapping buy/sell patterns within the 61-day window.
Copy-Trading
If a copy-trade system sells a token at a loss and the leader re-enters within 30 days, your copied trades inherit the wash sale. There is no "I didn't place the trade" exception.
Partial Fills and Multiple Lots
When a sale at a loss is followed by multiple smaller purchases, the wash sale applies to each purchase up to the quantity of the loss-generating sale. The scanner handles partial matching.
Cross-Wallet Transfers
Transferring tokens to another wallet you control and selling there does not avoid the wash sale rule. The rule follows the taxpayer, not the account.
Safe Re-Entry Strategy
After selling a token at a loss:
1. Wait 31 calendar days before repurchasing the same token 2. During the waiting period, consider holding a non-identical substitute (e.g., sell SOL, hold ETH for exposure to crypto broadly) 3. Use the countdown timer to know exactly when re-entry is safe 4. Set calendar reminders for window expiration dates
Token: SOL
Sale Date: 2025-03-15
Loss: $200.00
Window Closes: 2025-04-14
Days Remaining: 12
Status: DO NOT BUY — wash sale window activeBasis Adjustment Walkthrough
Detailed step-by-step basis adjustment example:
TRADE 1: Buy 100 SOL @ $150.00 → Basis: $15,000.00
TRADE 2: Sell 100 SOL @ $120.00 → Proceeds: $12,000.00, Loss: $3,000.00
TRADE 3: Buy 100 SOL @ $125.00 → Basis before adjustment: $12,500.00
(within 30 days of Trade 2)
WASH SALE TRIGGERED:
Disallowed loss: $3,000.00
Adjusted basis: $12,500.00 + $3,000.00 = $15,500.00
Effective price: $155.00 per SOL (not $125.00)
Later sale at $160.00:
Proceeds: $16,000.00
Adj. basis: $15,500.00
Gain: $500.00 (not $3,500.00)
The $3,000 loss is recovered through the higher basis.Files
References
references/planned_features.md— Wash sale rules in depth, 61-day window mechanics, basis adjustment examples, automation edge cases, IRS guidance references
Scripts
scripts/wash_sale_scanner.py— Complete wash sale scanner: loads trade history, identifies wash sales, computes disallowed losses and basis adjustments, shows safe re-entry countdowns. Run with--demofor example scenarios.
Limitations
- This tool implements a simplified interpretation of wash sale rules as applied to crypto
- "Substantially identical" determination for wrapped tokens and derivatives may require professional judgment
- The scanner does not handle options, futures, or other derivative instruments on crypto
- Multi-account detection requires you to aggregate trade data manually
- Rules may change as IRS issues further guidance on digital asset wash sales
- State tax rules may differ from federal treatment
Wash Sale Detection — Rules, Mechanics, and Planned Features
IRS Wash Sale Rule — IRC Section 1091
Statutory Foundation
The wash sale rule was originally enacted in 1921 to prevent taxpayers from claiming artificial tax losses by selling securities at a loss and immediately repurchasing them. The rule was codified as Internal Revenue Code Section 1091.
Prior to 2025, cryptocurrency was classified as "property" (IRS Notice 2014-21) and was explicitly excluded from wash sale treatment. The Infrastructure Investment and Jobs Act (signed November 2021) and subsequent IRS rulemaking extended the definition of covered assets to include "specified digital assets" effective January 1, 2025.
What Triggers a Wash Sale
A wash sale occurs when ALL of the following conditions are met:
1. A taxpayer disposes of a digital asset at a realized loss 2. Within the 61-day window (30 days before through 30 days after the sale), the taxpayer acquires a substantially identical digital asset 3. The acquisition is by purchase, receipt as compensation, or through a contract or option
What Does NOT Trigger a Wash Sale
- Selling at a gain — wash sale rules only apply to losses
- Buying a different token after selling at a loss (SOL loss, then buying ETH)
- Repurchasing the same token after 30 calendar days have elapsed
- Gifting the asset (though gift tax rules may apply separately)
61-Day Window Mechanics
Calendar Day Counting
The 61-day window uses calendar days, not trading days or business days.
Example: Sale on March 15, 2025
Pre-sale window: Feb 13, 2025 — Mar 14, 2025 (30 calendar days)
Sale day: Mar 15, 2025 (Day 0)
Post-sale window: Mar 16, 2025 — Apr 14, 2025 (30 calendar days)
Total window: Feb 13, 2025 — Apr 14, 2025 (61 calendar days)Retroactive Triggering
A wash sale can be triggered retroactively. If you buy a token on March 1 and then sell the same token at a loss on March 20, the March 1 purchase falls within the 30-day pre-sale window. The loss is disallowed.
Multiple Sales and Purchases
When multiple purchases fall within the wash sale window of a single loss-generating sale, the wash sale rule applies to purchases in chronological order up to the quantity of the loss sale.
Mar 1: Buy 50 SOL @ $100
Mar 15: Sell 100 SOL @ $80 (loss of $2,000)
Mar 20: Buy 30 SOL @ $85
Mar 25: Buy 40 SOL @ $82
Apr 1: Buy 50 SOL @ $90
Wash sales:
Mar 20 purchase: 30 SOL matched → $600 disallowed
Mar 25 purchase: 40 SOL matched → $800 disallowed
Total matched: 70 of 100 SOL → $1,400 of $2,000 disallowed
Remaining deductible loss: $600 (for the unmatched 30 SOL)
Apr 1 purchase: outside window (Day 17 is within window — recalculate)Important: count days carefully. April 1 is Day 17 after March 15, still within the 30-day window. All 100 SOL would be matched in this example.
Partial Quantity Matching
If you sell 100 tokens at a loss but only repurchase 60 within the window:
- 60 tokens' worth of loss is disallowed (proportional)
- 40 tokens' worth of loss is deductible
- The disallowed amount is added to the basis of the 60 replacement tokens
Basis Adjustment Examples
Simple Case
Jan 10: Buy 10 SOL @ $200 (basis: $2,000)
Feb 5: Sell 10 SOL @ $150 (proceeds: $1,500, loss: $500)
Feb 20: Buy 10 SOL @ $160 (basis before adj: $1,600)
Wash sale: $500 loss disallowed
Adjusted basis: $1,600 + $500 = $2,100
Per-unit adjusted basis: $210 (not $160)Partial Match Case
Jan 10: Buy 100 SOL @ $200 (basis: $20,000)
Feb 5: Sell 100 SOL @ $150 (proceeds: $15,000, loss: $5,000)
Feb 20: Buy 40 SOL @ $160 (basis before adj: $6,400)
Wash sale on 40 of 100 SOL:
Disallowed loss: $5,000 * (40/100) = $2,000
Deductible loss: $5,000 - $2,000 = $3,000
Adjusted basis of 40 new SOL: $6,400 + $2,000 = $8,400
Per-unit adjusted basis: $210 per SOLChained Wash Sales
A wash sale can chain: if the replacement position is also sold at a loss and repurchased within 30 days, the accumulated disallowed losses carry forward.
Jan 10: Buy 10 SOL @ $200 (basis: $2,000)
Feb 1: Sell 10 SOL @ $150 (loss: $500 → disallowed)
Feb 15: Buy 10 SOL @ $155 (adj basis: $1,550 + $500 = $2,050)
Mar 1: Sell 10 SOL @ $140 (loss: $2,050 - $1,400 = $650 → disallowed)
Mar 15: Buy 10 SOL @ $145 (adj basis: $1,450 + $650 = $2,100)
After two chained wash sales, the new position carries $1,150
of accumulated disallowed losses in its adjusted basis.Automation Edge Cases
DCA Bot Hazard
A DCA bot buying SOL every week creates a near-guaranteed wash sale scenario if SOL is ever sold at a loss:
Weekly DCA: Buy 1 SOL every Monday
Mar 3: Buy 1 SOL @ $180
Mar 10: Buy 1 SOL @ $170
Mar 17: Sell 5 SOL @ $160 (loss)
Mar 24: Buy 1 SOL @ $155 ← Wash sale (Day +7)
Mar 31: Buy 1 SOL @ $150 ← Wash sale (Day +14)
The DCA bot must be paused for 31 days after any loss-generating sale
to avoid wash sales on that token.Copy-Trading Systems
Copy-trade bots replicate a leader's trades. If the leader sells at a loss and re-enters within 30 days, all copiers inherit the wash sale. The copier has no defense — the rule applies regardless of intent or automation.
Mitigation: Filter copy-trade signals to suppress re-entry buys for tokens with active wash sale windows.
Grid Bot Hazard
Grid bots place buy and sell orders at fixed intervals. They frequently sell and rebuy the same token within minutes. Every loss-generating sell followed by a grid buy is a wash sale.
Mitigation: Grid bots on tokens expected to be volatile should account for wash sale costs in profitability calculations.
Multi-Exchange / Multi-Wallet
The wash sale rule applies to the taxpayer, not to individual accounts. A sale on Exchange A and a purchase on Exchange B within the window is a wash sale. This includes:
- Multiple CEX accounts
- DEX wallets
- Custodial vs self-custody
- Spouse's accounts (for joint filers)
IRS Guidance References
| Source | Key Point |
|---|---|
| IRC § 1091 | Statutory wash sale rule |
| IRS Notice 2014-21 | Crypto treated as property |
| Infrastructure Investment and Jobs Act (2021) | Extended reporting and wash sale rules to digital assets |
| IRC § 6045 (amended) | Broker reporting requirements for digital assets |
| IRS Publication 550 | Investment income and expenses, wash sale details |
| Rev. Rul. 2019-24 | Hard forks and airdrops treatment |
Effective Dates
- January 1, 2025: Wash sale rule applies to digital assets
- January 1, 2025: Broker reporting (Form 1099-DA) requirements begin phased rollout
- January 1, 2026: Full cost basis reporting by brokers mandatory
Open Questions in IRS Guidance
As of early 2025, the IRS has not issued definitive guidance on several crypto-specific questions:
1. Are wrapped tokens (wSOL) "substantially identical" to the unwrapped token (SOL)? 2. Are cross-chain versions of the same token (USDC on Solana vs Ethereum) substantially identical? 3. Are liquid staking derivatives (stSOL, mSOL, jitoSOL) substantially identical to SOL? 4. How do LP tokens interact with wash sale rules when the LP contains the sold token? 5. Does staking/unstaking constitute a disposition for wash sale purposes?
Conservative approach: Treat all of the above as substantially identical until IRS clarifies otherwise.
Planned Scanner Features
- [ ] CSV and JSON trade history import
- [ ] Multi-token parallel scanning
- [ ] Partial quantity matching with proportional loss allocation
- [ ] Chained wash sale detection across sequential trades
- [ ] Multi-account aggregation from combined exports
- [ ] Integration with exchange API exports (Coinbase, Kraken, Binance)
- [ ] Holding period carryover tracking
- [ ] Tax-loss harvesting coordination with substitute asset suggestions
- [ ] Real-time pre-trade wash sale warning
- [ ] Annual tax summary report generation
#!/usr/bin/env python3
"""Wash sale scanner for cryptocurrency trades under 2025 US tax rules.
Loads a trade history, identifies wash sales within the 61-day window,
computes disallowed losses and basis adjustments, and shows safe re-entry
countdowns for tokens sold at a loss.
Usage:
python scripts/wash_sale_scanner.py --demo
python scripts/wash_sale_scanner.py --csv trades.csv
Dependencies:
None (Python 3.10+ standard library only)
Environment Variables:
None required.
DISCLAIMER: This tool is for informational purposes only and does NOT
constitute tax advice. Consult a qualified tax professional for guidance
on your specific tax situation.
"""
from __future__ import annotations
import argparse
import csv
import sys
from dataclasses import dataclass, field
from datetime import date, timedelta
from typing import Optional
# ── Data Models ─────────────────────────────────────────────────────
WASH_SALE_WINDOW_DAYS = 30
@dataclass
class Trade:
"""A single trade record."""
date: date
action: str # "buy" or "sell"
token: str
qty: float
price: float
trade_id: int = 0
@property
def total(self) -> float:
return self.qty * self.price
def __str__(self) -> str:
return (
f"[{self.trade_id:>3}] {self.date} {self.action.upper():>4} "
f"{self.qty:>10.4f} {self.token:<6} @ ${self.price:>10.2f} "
f"= ${self.total:>12.2f}"
)
@dataclass
class WashSale:
"""A detected wash sale event."""
token: str
sale_date: date
sale_qty: float
sale_price: float
sale_basis_per_unit: float
replacement_date: date
replacement_qty: float
replacement_price: float
matched_qty: float
disallowed_loss: float
adjusted_basis: float
sale_trade_id: int = 0
replacement_trade_id: int = 0
def __str__(self) -> str:
return (
f" WASH SALE: {self.token}\n"
f" Sale: {self.sale_date} — {self.matched_qty:.4f} units "
f"@ ${self.sale_price:.2f} (basis ${self.sale_basis_per_unit:.2f})\n"
f" Replacement: {self.replacement_date} — {self.replacement_qty:.4f} units "
f"@ ${self.replacement_price:.2f}\n"
f" Disallowed loss: ${self.disallowed_loss:.2f}\n"
f" Adjusted basis: ${self.adjusted_basis:.2f} "
f"(${self.adjusted_basis / self.matched_qty:.2f}/unit)"
)
@dataclass
class Countdown:
"""Safe re-entry countdown for a token sold at a loss."""
token: str
sale_date: date
loss_amount: float
window_close: date
as_of: date
@property
def days_remaining(self) -> int:
delta = (self.window_close - self.as_of).days
return max(0, delta)
@property
def is_safe(self) -> bool:
return self.days_remaining == 0
@property
def status(self) -> str:
if self.is_safe:
return "SAFE to re-enter"
return f"DO NOT BUY — {self.days_remaining} days remaining"
def __str__(self) -> str:
return (
f" {self.token:<8} | Sale: {self.sale_date} | "
f"Loss: ${self.loss_amount:>10.2f} | "
f"Window closes: {self.window_close} | {self.status}"
)
@dataclass
class ScanResults:
"""Complete results from a wash sale scan."""
trades: list[Trade] = field(default_factory=list)
wash_sales: list[WashSale] = field(default_factory=list)
countdowns: list[Countdown] = field(default_factory=list)
total_disallowed: float = 0.0
total_deductible: float = 0.0
total_realized_losses: float = 0.0
# ── Scanner ─────────────────────────────────────────────────────────
class WashSaleScanner:
"""Scans a trade history for wash sales under the 61-day window rule.
Args:
trades: List of trade dictionaries or Trade objects.
as_of: Date for countdown calculations (defaults to today).
"""
def __init__(
self,
trades: list[dict | Trade],
as_of: Optional[date] = None,
) -> None:
self.as_of = as_of or date.today()
self.trades = self._normalize_trades(trades)
def _normalize_trades(self, raw_trades: list[dict | Trade]) -> list[Trade]:
"""Convert input trades to Trade objects and assign IDs."""
trades: list[Trade] = []
for i, t in enumerate(raw_trades):
if isinstance(t, Trade):
t.trade_id = i + 1
trades.append(t)
elif isinstance(t, dict):
trade_date = t["date"]
if isinstance(trade_date, str):
trade_date = date.fromisoformat(trade_date)
trades.append(Trade(
date=trade_date,
action=t["action"].lower(),
token=t["token"].upper(),
qty=float(t["qty"]),
price=float(t["price"]),
trade_id=i + 1,
))
else:
raise TypeError(f"Unsupported trade type: {type(t)}")
trades.sort(key=lambda x: (x.date, x.trade_id))
return trades
def scan(self) -> ScanResults:
"""Run the wash sale scan across all trades.
Returns:
ScanResults with detected wash sales, countdowns, and totals.
"""
results = ScanResults(trades=list(self.trades))
tokens = {t.token for t in self.trades}
for token in sorted(tokens):
self._scan_token(token, results)
results.total_disallowed = sum(ws.disallowed_loss for ws in results.wash_sales)
results.total_realized_losses = (
results.total_disallowed + results.total_deductible
)
return results
def _scan_token(self, token: str, results: ScanResults) -> None:
"""Scan a single token for wash sales."""
token_trades = [t for t in self.trades if t.token == token]
sells = [t for t in token_trades if t.action == "sell"]
buys = [t for t in token_trades if t.action == "buy"]
# Track cost basis per buy lot (simple: use buy price as basis)
# In production, this would use FIFO/LIFO/specific identification
buy_basis: dict[int, float] = {b.trade_id: b.price for b in buys}
for sell in sells:
# Determine cost basis for this sale using earliest unmatched buys (FIFO)
basis_per_unit = self._get_basis_for_sale(sell, buys, buy_basis)
loss_per_unit = basis_per_unit - sell.price
if loss_per_unit <= 0:
# No loss — no wash sale possible
continue
total_loss = loss_per_unit * sell.qty
remaining_qty = sell.qty
# Find replacement purchases within the 61-day window
window_start = sell.date - timedelta(days=WASH_SALE_WINDOW_DAYS)
window_end = sell.date + timedelta(days=WASH_SALE_WINDOW_DAYS)
replacements = [
b for b in buys
if window_start <= b.date <= window_end
and b.date != sell.date
and b.trade_id != sell.trade_id
]
# Also include same-day buys that occur after the sell (by trade_id)
same_day_buys = [
b for b in buys
if b.date == sell.date and b.trade_id > sell.trade_id
]
replacements = sorted(
replacements + same_day_buys,
key=lambda x: (x.date, x.trade_id),
)
matched_any = False
for replacement in replacements:
if remaining_qty <= 0:
break
matched_qty = min(remaining_qty, replacement.qty)
disallowed = loss_per_unit * matched_qty
original_cost = replacement.price * matched_qty
adjusted = original_cost + disallowed
ws = WashSale(
token=token,
sale_date=sell.date,
sale_qty=sell.qty,
sale_price=sell.price,
sale_basis_per_unit=basis_per_unit,
replacement_date=replacement.date,
replacement_qty=replacement.qty,
replacement_price=replacement.price,
matched_qty=matched_qty,
disallowed_loss=disallowed,
adjusted_basis=adjusted,
sale_trade_id=sell.trade_id,
replacement_trade_id=replacement.trade_id,
)
results.wash_sales.append(ws)
remaining_qty -= matched_qty
matched_any = True
# Deductible portion = unmatched quantity
deductible = loss_per_unit * remaining_qty
results.total_deductible += deductible
# Add countdown for this loss sale
window_close = sell.date + timedelta(days=WASH_SALE_WINDOW_DAYS + 1)
results.countdowns.append(Countdown(
token=token,
sale_date=sell.date,
loss_amount=total_loss,
window_close=window_close,
as_of=self.as_of,
))
def _get_basis_for_sale(
self,
sell: Trade,
buys: list[Trade],
buy_basis: dict[int, float],
) -> float:
"""Get the cost basis per unit for a sell trade (simplified FIFO).
In a production system this would track lot-level consumption.
Here we use the average basis of prior buys as a simplification.
"""
prior_buys = [b for b in buys if b.date <= sell.date]
if not prior_buys:
return sell.price # No basis info, assume breakeven
total_cost = sum(b.qty * buy_basis[b.trade_id] for b in prior_buys)
total_qty = sum(b.qty for b in prior_buys)
if total_qty == 0:
return sell.price
return total_cost / total_qty
# ── CSV Loader ──────────────────────────────────────────────────────
def load_trades_from_csv(filepath: str) -> list[dict]:
"""Load trades from a CSV file.
Expected columns: date, action, token, qty, price
Date format: YYYY-MM-DD
Args:
filepath: Path to the CSV file.
Returns:
List of trade dictionaries.
"""
trades: list[dict] = []
try:
with open(filepath, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
trades.append({
"date": row["date"],
"action": row["action"],
"token": row["token"],
"qty": float(row["qty"]),
"price": float(row["price"]),
})
except FileNotFoundError:
print(f"Error: File not found: {filepath}")
sys.exit(1)
except KeyError as e:
print(f"Error: Missing column in CSV: {e}")
print("Expected columns: date, action, token, qty, price")
sys.exit(1)
return trades
# ── Demo Data ───────────────────────────────────────────────────────
def get_demo_trades() -> list[dict]:
"""Return demo trade data illustrating wash sale scenarios.
Scenarios covered:
1. Simple wash sale: sell SOL at loss, rebuy within 15 days
2. No wash sale: sell ETH at loss, wait 35 days to rebuy
3. Partial match: sell BONK at loss, rebuy smaller quantity
4. Pre-sale trigger: buy JUP, then sell existing JUP at loss within 30 days
5. Chained wash sale: repeated sell-at-loss and rebuy on RAY
"""
return [
# === Scenario 1: Simple wash sale (SOL) ===
{"date": "2025-01-10", "action": "buy", "token": "SOL", "qty": 10, "price": 200.00},
{"date": "2025-02-05", "action": "sell", "token": "SOL", "qty": 10, "price": 150.00},
{"date": "2025-02-20", "action": "buy", "token": "SOL", "qty": 10, "price": 160.00},
# === Scenario 2: No wash sale — waited > 30 days (ETH) ===
{"date": "2025-01-15", "action": "buy", "token": "ETH", "qty": 2, "price": 3200.00},
{"date": "2025-02-10", "action": "sell", "token": "ETH", "qty": 2, "price": 2800.00},
{"date": "2025-03-20", "action": "buy", "token": "ETH", "qty": 2, "price": 2900.00},
# === Scenario 3: Partial match (BONK) ===
{"date": "2025-02-01", "action": "buy", "token": "BONK", "qty": 1000000, "price": 0.00003},
{"date": "2025-03-01", "action": "sell", "token": "BONK", "qty": 1000000, "price": 0.00002},
{"date": "2025-03-15", "action": "buy", "token": "BONK", "qty": 400000, "price": 0.000022},
# === Scenario 4: Pre-sale window trigger (JUP) ===
{"date": "2025-01-05", "action": "buy", "token": "JUP", "qty": 500, "price": 1.20},
{"date": "2025-02-01", "action": "buy", "token": "JUP", "qty": 200, "price": 0.90},
{"date": "2025-02-15", "action": "sell", "token": "JUP", "qty": 500, "price": 0.85},
# === Scenario 5: Chained wash sales (RAY) ===
{"date": "2025-01-20", "action": "buy", "token": "RAY", "qty": 100, "price": 5.00},
{"date": "2025-02-10", "action": "sell", "token": "RAY", "qty": 100, "price": 4.00},
{"date": "2025-02-25", "action": "buy", "token": "RAY", "qty": 100, "price": 4.20},
{"date": "2025-03-15", "action": "sell", "token": "RAY", "qty": 100, "price": 3.80},
{"date": "2025-03-28", "action": "buy", "token": "RAY", "qty": 100, "price": 3.90},
]
# ── Report Formatting ──────────────────────────────────────────────
def print_report(results: ScanResults) -> None:
"""Print a formatted wash sale report to stdout."""
sep = "=" * 78
print()
print(sep)
print(" WASH SALE DETECTION REPORT")
print(sep)
# Trade summary
print(f"\n Total trades analyzed: {len(results.trades)}")
tokens = sorted({t.token for t in results.trades})
print(f" Tokens: {', '.join(tokens)}")
# All trades
print(f"\n{'─' * 78}")
print(" TRADE HISTORY")
print(f"{'─' * 78}")
for trade in results.trades:
print(f" {trade}")
# Wash sales
print(f"\n{'─' * 78}")
print(" WASH SALES DETECTED")
print(f"{'─' * 78}")
if results.wash_sales:
for i, ws in enumerate(results.wash_sales, 1):
print(f"\n #{i}")
print(ws)
else:
print(" None detected.")
# Summary
print(f"\n{'─' * 78}")
print(" LOSS SUMMARY")
print(f"{'─' * 78}")
print(f" Total realized losses: ${results.total_realized_losses:>12.2f}")
print(f" Disallowed (wash sale): ${results.total_disallowed:>12.2f}")
print(f" Deductible: ${results.total_deductible:>12.2f}")
# Countdowns
print(f"\n{'─' * 78}")
print(" SAFE RE-ENTRY COUNTDOWNS")
print(f"{'─' * 78}")
if results.countdowns:
for cd in results.countdowns:
print(cd)
else:
print(" No active loss windows.")
# Disclaimer
print(f"\n{'─' * 78}")
print(" DISCLAIMER: This report is for informational purposes only.")
print(" It does NOT constitute tax advice. Consult a qualified tax")
print(" professional for guidance on your specific tax situation.")
print(sep)
print()
# ── CLI ─────────────────────────────────────────────────────────────
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Wash sale scanner for cryptocurrency trades (2025 US rules)",
epilog="DISCLAIMER: Not tax advice. Consult a qualified tax professional.",
)
parser.add_argument(
"--demo",
action="store_true",
help="Run with built-in demo trade data showing wash sale scenarios",
)
parser.add_argument(
"--csv",
type=str,
help="Path to CSV file with columns: date, action, token, qty, price",
)
parser.add_argument(
"--as-of",
type=str,
default=None,
help="Date for countdown calculations (YYYY-MM-DD, default: today)",
)
return parser.parse_args()
def main() -> None:
"""Entry point for the wash sale scanner CLI."""
args = parse_args()
if not args.demo and not args.csv:
print("Error: Specify --demo or --csv <file>")
print("Run with --help for usage information.")
sys.exit(1)
as_of: Optional[date] = None
if args.as_of:
try:
as_of = date.fromisoformat(args.as_of)
except ValueError:
print(f"Error: Invalid date format: {args.as_of} (expected YYYY-MM-DD)")
sys.exit(1)
if args.demo:
trades = get_demo_trades()
# Use a fixed date for demo so output is deterministic
if as_of is None:
as_of = date(2025, 4, 15)
else:
trades = load_trades_from_csv(args.csv)
scanner = WashSaleScanner(trades, as_of=as_of)
results = scanner.scan()
print_report(results)
if __name__ == "__main__":
main()
Related skills
FAQ
Does the wash-sale rule apply to crypto?
Yes, US wash-sale treatment was extended to digital assets beginning January 1, 2025 under IRC Section 1091 as amended.
What is the window?
A 61-day window: 30 days before the loss sale through 30 days after, measured in calendar days.