Tax packets (1099-B)

Per-account per-year tax packet built from the LotBook: proceeds, basis, LT/ST split, wash-sale disallowed-loss reporting.

horizon.reporting.tax turns a LotBook into a per-account tax packet shaped like a 1099-B. Last step of the tax workflow: the pipeline opens and closes lots during the year, the wash-sale detector flags §1091 adjustments, the builder produces a document that ties out to Schedule D.

Read-layer only. No hot-path impact.

Quickest start

python
from horizon.reporting import TaxPacketBuilder
from horizon.state.wash_sale import WashSaleDetector, apply_adjustments

# Apply wash-sale adjustments at year end
detector = WashSaleDetector(lot_book)
apply_adjustments(lot_book, detector.detect())

packet = TaxPacketBuilder().build(
    account=account,
    lot_book=lot_book,
    tax_year=2026,
    client=client,                        # optional
    is_covered_default=True,              # post-2011 equities (typical)
)

print(packet.to_text())

import json
with open(f"1099B_{account.account_id}_2026.json", "w") as f:
    json.dump(packet.to_dict(), f, indent=2, default=str)

1099-B boxes

Each sale lands in one of six boxes. The builder defaults to covered + basis-reported (boxes A and D).

BoxTermBasis reported to IRS
AShort-termYes (covered)
BShort-termNo (non-covered)
CShort-termNot reported on 1099-B
DLong-termYes (covered)
ELong-termNo (non-covered)
FLong-termNot reported on 1099-B

“Covered” means the security was acquired after the IRS covered-date (2011 for equities, 2012 for mutual funds, 2014 for options and debt). The custodian reports basis for covered securities; for non-covered, the taxpayer self-reports. Pass is_covered_default=False for holdings acquired before the covered-date.

Wash-sale integration

The detector flags lots and applies basis adjustments. The tax packet surfaces both:

  • Sold loss lot: was_wash_sale=True, wash_sale_loss_disallowed shows the disallowed amount, gain_loss is zero.
  • Replacement lot: cost_basis reflects the carried-over disallowed loss. When it eventually sells, the adjusted basis flows through.

Example:

text
Buy 100 MSFT @ $300 on Jan 5   open lot_1, basis $30,000
Sell 100 MSFT @ $250 on Feb 1  close lot_1, realized -$5,000 (loss)
Buy 100 MSFT @ $260 on Feb 10  open lot_2, basis initially $26,000

After WashSaleDetector + apply_adjustments:
  lot_1: was_wash_sale = True, realized = 0 (disallowed)
  lot_2: disallowed_loss_carryover = $5,000, adjusted basis = $31,000

Tax packet rows:
  Feb 1: MSFT x 100   proceeds=$25,000   basis=$30,000
         gain_loss = 0 (adjusted from -$5,000)
         wash_sale_loss_disallowed = $5,000   was_wash_sale=True   Box A

When lot_2 later sells, the higher basis applies. The disallowed loss is deferred into that future sale.

Summary (Schedule D totals)

python
s = packet.summary
s.n_sales
s.short_term_proceeds                 # boxes A + B + C
s.short_term_basis
s.short_term_gain
s.short_term_wash_sale_adj
s.long_term_proceeds                  # boxes D + E + F
s.long_term_basis
s.long_term_gain
s.long_term_wash_sale_adj
s.total_gain
s.n_wash_sales
s.by_box["A"]                         # per-box: n_sales, proceeds, basis, gain, wash_sale_adj

Multi-account batch

python
for account in registry.accounts():
    book = lot_books[account.account_id]
    apply_adjustments(book, WashSaleDetector(book).detect())

    packet = TaxPacketBuilder().build(
        account=account,
        lot_book=book,
        tax_year=2026,
        client=registry.get_client(account.client_id),
    )
    out = f"tax/2026/{account.account_id}_1099B.json"
    with open(out, "w") as f:
        json.dump(packet.to_dict(), f, indent=2, default=str)

PDF rendering

python
from horizon.reporting.pdf import render_tax_packet_pdf
render_tax_packet_pdf(packet, out_path="tax/2026/acc_1_1099B.pdf")

Wraps WeasyPrint; install with pip install weasyprint. Pass template_path= to swap the default Jinja template.

Out of scope

  • Form 8949 layout. The packet has every field 8949 needs; the bundled PDF template groups entries by short-term / long-term but does not mimic the IRS form layout exactly. Use a tax-prep tool for filing.
  • K-1 and Section 1256. 60/40 LT/ST for certain futures and broad-based index options, K-1 pass-through for LPs, straddle rules: custom tax logic wraps the raw LT/ST split.
  • PFIC and FX. Section 988 FX gains, PFIC Section 1291, QEF, mark-to-market elections. Multi-currency packet generation is L3.
  • Crypto-specific reporting. IRS treats crypto as property; gain math is the same. 1099-DA (2026) has distinct fields. Follow-up.
  • Fees. Advisory fees appear in Fees, not on 1099-B.