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
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).
| Box | Term | Basis reported to IRS |
|---|---|---|
| A | Short-term | Yes (covered) |
| B | Short-term | No (non-covered) |
| C | Short-term | Not reported on 1099-B |
| D | Long-term | Yes (covered) |
| E | Long-term | No (non-covered) |
| F | Long-term | Not 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_disallowedshows the disallowed amount,gain_lossis zero. - Replacement lot:
cost_basisreflects the carried-over disallowed loss. When it eventually sells, the adjusted basis flows through.
Example:
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)
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
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
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.