Tax lots and wash-sale detection
Per-lot cost basis, FIFO / LIFO / HIFO / SpecID / AverageCost closes, IRS §1091 wash-sale detector.
The default PositionLedger keeps one weighted-average avg_cost per market. That works for intraday P&L, not for tax reporting. 1099-B wants per-lot cost basis, and the taxable outcome of a close depends on which lot is closed.
horizon.state.LotBook tracks lots independently of the ledger. horizon.state.WashSaleDetector applies IRC §1091.
Import
from horizon.state import (
LotBook, LotSide, TaxLot,
WashSaleDetector, WashSaleAdjustment,
apply_wash_sale_adjustments,
)
from horizon.types import TaxLotElection
Opening lots
book = LotBook(default_election=TaxLotElection.FIFO)
lot1 = book.open_lot(
"AAPL", "buy", 100, 150.00,
acquired_at=datetime(2026, 1, 10, tzinfo=timezone.utc),
account_id="acc_jane_taxable",
strategy_id="momentum_v2",
fees=1.00,
)
lot2 = book.open_lot(
"AAPL", "buy", 100, 180.00,
acquired_at=datetime(2026, 2, 15, tzinfo=timezone.utc),
account_id="acc_jane_taxable",
)
Side is the order side that opened the position:
"buy"maps toLotSide.Long."sell"maps toLotSide.Short(short sale).
Closing: five elections
close_quantity matches the close against open lots using the election passed in (or the book’s default). See Order lifecycle for TaxLotElection on OrderAction.
FIFO (IRS default for equities)
Earliest-acquired lots close first.
book = LotBook(default_election=TaxLotElection.FIFO)
book.open_lot("AAPL", "buy", 100, 100, ts1) # older
book.open_lot("AAPL", "buy", 100, 200, ts2) # newer
closed = book.close_quantity("AAPL", "sell", 50, 150, ts3)
# Closes 50 from the $100 lot. Realized = 50 * (150 - 100) = +$2,500
LIFO (latest first)
book = LotBook(default_election=TaxLotElection.LIFO)
# Same positions. Closes the $200 lot first.
# Realized = 50 * (150 - 200) = -$2,500
HIFO (highest-cost first)
Tax-loss harvesting default. Closes the most expensive lots first.
book = LotBook(default_election=TaxLotElection.HIFO)
book.open_lot("AAPL", "buy", 100, 100, ts1)
book.open_lot("AAPL", "buy", 100, 300, ts2)
book.open_lot("AAPL", "buy", 100, 200, ts3)
closed = book.close_quantity("AAPL", "sell", 50, 150, ts4)
# HIFO picks the $300 lot. Realized = 50 * (150 - 300) = -$7,500
SpecID (caller names the lot)
a = book.open_lot("AAPL", "buy", 100, 100, ts1)
b = book.open_lot("AAPL", "buy", 100, 200, ts2)
closed = book.close_quantity(
"AAPL", "sell", 100, 150, ts3,
election=TaxLotElection.SpecID,
spec_lot_ids=[b.lot_id], # close the $200 lot
)
AverageCost (mutual-fund convention)
Blends all open lots into a single weighted-average basis.
book.close_quantity(
"AAPL", "sell", 100, 180, ts3,
election=TaxLotElection.AverageCost,
)
# Basis = (100x100 + 100x200) / 200 = 150. P&L = 100 * (180 - 150) = +$3,000
Partial closes
If the close is smaller than a lot, the matched portion is recorded and the remainder stays open:
book.open_lot("AAPL", "buy", 100, 150, ts1)
closed = book.close_quantity("AAPL", "sell", 30, 170, ts2)
# closed[0].quantity == 30; 70 shares remain open
Shorts
Open with "sell", cover with "buy":
book.open_lot("AAPL", "sell", 100, 200, ts1, account_id="acc_1")
closed = book.close_quantity("AAPL", "buy", 100, 150, ts2, account_id="acc_1")
# Short profit = (entry - cover) * qty = (200 - 150) * 100 = +$5,000
Holding period
closed[0].holding_period # days, as float
closed[0].is_long_term # True if > 365 days
Wash-sale detection
IRS §1091: a loss close plus a replacement buy within 30 days before or after disallows the loss and shifts it onto the replacement lot’s basis. The detector walks lot history and emits adjustments.
Basic case
book.open_lot("MSFT", "buy", 100, 300, day(0), account_id="acc_1")
book.close_quantity("MSFT", "sell", 100, 250, day(20), account_id="acc_1")
book.open_lot("MSFT", "buy", 100, 260, day(30), account_id="acc_1")
detector = WashSaleDetector(book)
adjustments = detector.detect()
# 1 adjustment: $5,000 loss disallowed.
# The loss shifts to the replacement lot's basis.
apply_wash_sale_adjustments(book, adjustments)
# Replacement lot: cost_basis_per_share = 260 + 5000/100 = 310
When the replacement lot later sells, the carryover basis reduces the gain (or expands the loss). The economic loss is deferred, not erased.
Partial replacement
book.close_quantity("MSFT", "sell", 100, 250, day(20), account_id="acc_1") # $5,000 loss on 100 sh
book.open_lot("MSFT", "buy", 40, 260, day(30), account_id="acc_1") # 40 replacement
adjustments = detector.detect()
# 40/100 of loss disallowed: $2,000 shifts to the 40-share replacement lot.
# $3,000 loss stands.
Cross-account matching
Each account is examined independently by default. IRS “same taxpayer” rules allow a wash sale to straddle a client’s IRA and taxable brokerage. Set group_accounts=True when the LotBook’s accounts share one taxpayer:
detector = WashSaleDetector(book, group_accounts=True)
Keep separate LotBooks per taxpayer in a multi-client setup.
Out of scope
- “Substantially identical.” The detector matches on
market_idonly. Whether a SPY put matches a SPY sale, or whether different share classes match, is a judgment call. Pre-groupmarket_ids that should map to one underlying. - Short-sale losses. Short-cover losses have their own rules (short-sale tax rules, not §1091). Ignored.
- Cross-security replacements. Hedges via options / futures / ETFs of the same underlying are not caught. Wrap the detector with a same-underlying resolver if needed.
Queries
book.open_quantity("AAPL", account_id="acc_1")
book.open_quantity("AAPL", account_id="acc_1", side=LotSide.Long)
book.weighted_avg_cost("AAPL", account_id="acc_1")
book.realized_pnl("AAPL", account_id="acc_1")
book.open_lots(market_id="AAPL", account_id="acc_1")
book.closed_lots(market_id="AAPL", account_id="acc_1")
book.find_lot(lot_id)
Wiring to the ledger
hz.run(accounts=..., audit_log=...) populates per-account lot books automatically from drained fills using each account’s tax_lot_election. See Running.
For explicit wiring (custom loops):
def on_fill(fill: VenueFill, account: Account):
ledger.apply_fill(market_id=fill.market_id, side=fill.side, ...)
if fill.side == "buy":
if book.open_quantity(fill.market_id, account_id=account.account_id, side=LotSide.Short) > 0:
book.close_quantity(fill.market_id, "buy", fill.quantity, fill.price,
fill.timestamp, account_id=account.account_id,
election=account.tax_lot_election, fees=fill.fee)
else:
book.open_lot(fill.market_id, "buy", fill.quantity, fill.price,
fill.timestamp, account_id=account.account_id, fees=fill.fee)
elif fill.side == "sell":
if book.open_quantity(fill.market_id, account_id=account.account_id, side=LotSide.Long) > 0:
book.close_quantity(fill.market_id, "sell", fill.quantity, fill.price,
fill.timestamp, account_id=account.account_id,
election=account.tax_lot_election, fees=fill.fee)
else:
book.open_lot(fill.market_id, "sell", fill.quantity, fill.price,
fill.timestamp, account_id=account.account_id, fees=fill.fee)
Running detection
End of day, end of period, or on every close (pick a cadence):
detector = WashSaleDetector(book)
adjustments = detector.detect()
if adjustments:
for adj in adjustments:
audit_log.record(
AuditCategory.WashSaleDetected,
account_id=adj.account_id,
market_id=adj.market_id,
message=f"disallowed loss ${adj.disallowed_loss:.2f}",
payload={
"closed_lot_id": adj.closed_lot_id,
"replacement_lot_id": adj.replacement_lot_id,
"disallowed_loss": adj.disallowed_loss,
"reason": adj.reason,
},
)
apply_wash_sale_adjustments(book, adjustments)
The audit trail records detection and adjustment. A tax auditor can reproduce it deterministically. See Audit trail and Tax packets.