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

python
from horizon.state import (
    LotBook, LotSide, TaxLot,
    WashSaleDetector, WashSaleAdjustment,
    apply_wash_sale_adjustments,
)
from horizon.types import TaxLotElection

Opening lots

python
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 to LotSide.Long.
  • "sell" maps to LotSide.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.

python
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)

python
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.

python
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)

python
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.

python
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:

python
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":

python
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

python
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

python
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

python
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:

python
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_id only. Whether a SPY put matches a SPY sale, or whether different share classes match, is a judgment call. Pre-group market_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

python
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):

python
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):

python
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.