Implementation design · 005-tax-lots

FIFO/LIFO tax lot tracking

How we will build it.

Status Draft Spec 005-tax-lots Owner Brian Corbin · @briancorbinxyz Branch 005-tax-lots Created Read time

Fold the ledger into an acquisition-ordered ArrayDeque<Lot> per (account, instrument): a buy pushes a lot, a sell relieves lots from the head (FIFO) or the tail (LIFO), splitting the boundary lot in place on partial consumption and recording realized gain as qty·(sellPrice − lotBasis) in exact long arithmetic. Because a lot's basis-per-unit is its integer fill price, that gain needs no division and is exact to the cent by construction. The single biggest risk is a relief-order or split-boundary error silently diverging realized gain from a full replay — mitigated by making the spec's SC-001 worked example the headline test and gating on reconcile-to-replay.

1 · Principles check

Verify this design respects every principle in v0.1.0. Any violation requires either a principles amendment or a deliberate exception logged below.

PrincipleComplianceNotes
P-2 · Small, reversible changesOKOne new tax module and its test; partial consumption is an in-place qtyOpen split, not a structural rewrite.
P-3 · Explicit over implicitOKRelief policy is selected per account and fixed at relief time; the applied policy is stamped on every RealizedGainRecord.
P-5 · Record the whyOKD-001…D-003 in §6 carry the deque choice, exact-integer gain, and short-lot symmetry, each grounded in §3.
P-6 · Tests accompany behaviorOKTDD per story: tests written failing first (T-100/T-200/T-300) before their implementation tasks.
P-7 · Done means verifiedOKRealized and unrealized gain reconcile to a full replay (NFR-002); snapshot-restore-then-replay-tail equals full replay.
P-8 · Ship dark by defaultOKBehind portfolio.analytics.enabled, default false; no relief runs until the toggle is on.
P-9 · Decisions are groundedOKEvery §6 decision rests on a verified §3 source (spec FR-001/FR-002, the exact-arithmetic contract, JDK ArrayDeque).
P-15 · Structured observabilityOKMicrometer relief timer exported at /q/metrics; OpenAPI at /q/openapi.
P-16 · Service level objectivesOKSLO-relief (p99 < 40 µs ≤ 1000 lots), SLO-pnl-reconcile (99.999%), SLO-lotset (0 when flat) are the spec's, enforced here.

Load-bearing principles only. Platform-wide conformance across all twenty principles is tracked in the umbrella design (001); this table records where this slice carries weight of its own.

2 · Technical context

Language(s)
Java 21 (Temurin 21.0.9).
Frameworks
Quarkus 3.37.3, Maven; extensions quarkus-rest, quarkus-rest-jackson, quarkus-arc, quarkus-micrometer-registry-prometheus, quarkus-smallrye-openapi.
Architecture
Vertical slice inside a modular monolith — a tax module under io.spectastic.portfolio that folds the umbrella's event stream; no new service boundary.
Storage
In-memory event-sourced projection over the TradeLedger log; snapshot = deep copy, restore-then-replay-tail equals full replay. No database.
Testing
JUnit5 unit tests plus one @QuarkusTest with rest-assured; the SC-001 worked example is the headline relief test.
Coverage
Jacoco, diff-aware: a patch target on changed lines and the rule that a change never lowers coverage of the code it touches — no single universal %.
Perf targets
Relief p99 < 40 µs at ≤ 1000 open lots (NFR-001); daily P&L reconcile bit-matches replay 99.999% (NFR-002); open lots ≤ open buys, 0 when flat (NFR-003).
Constraints
No floating point on the money path — exact integer fixed-point at scale 1e-4 (PRICE_SCALE = 10_000), whole-long quantities.

3 · Grounding & evidence

Every design-bearing fact this design rests on, and how it was confirmed — read this before the approach: it separates what the design knows from what it assumes (per REQ-LIFECYCLE-006). Status is verified (opened the source this turn), spike (decidable only by a time-boxed investigation — run it, record the finding), or assumed (taken as true without verification). A must-tier decision may not rest on an assumed/unresolved-spike fact unless accepted as a <spec-risk> (see §8).

Claim the design rests onSourceStatusFinding / note
FIFO relieves oldest-first, LIFO newest-first, with a boundary-lot split on partial consumption. specs/005-tax-lots/spec.html#FR-002 verified FR-002 mandates policy-ordered relief, split on partial consumption, gain = proceeds − relieved basis.
A lot's basis-per-unit is the exact integer fill price, so realized gain needs no division. specs/001-portfolio-analytics/spec.html · money-exactness contract verified Prices are fixed-point at PRICE_SCALE = 10_000; qty·(sellPrice − lotBasis) is pure long arithmetic — exact to the cent.
ArrayDeque gives O(1) push, O(1) head poll (FIFO), O(1) tail poll (LIFO). java.util.ArrayDeque (JDK 21) verified Amortised O(1) at both ends; a single array backing means no per-lot node allocation.
TradeEvent, PriceTick, and PRICE_SCALE are the umbrella's, owned by 001. io.spectastic.portfolio.model (owned by 001) verified This slice consumes the shared model; it defines only tax-local types.
Relief of one sell over ≤ 1000 lots meets p99 < 40 µs. JMH microbenchmark of match-split-record at 1000 lots (T-901) spike Pending — deque head/tail poll plus one in-place split is O(k) in lots relieved, not lot count; benchmark confirms the envelope in Polish.
Holding period derives purely from the recorded trade-event timestamps. assumed Wash-sale and corporate-action adjustments are out of scope (spec §6), so no basis/holding-period overlay applies here.

4 · Approach

A TaxLotBook (one per (account, instrument)) folds the ledger's event stream. A buy pushes a Lot — quantity, integer basis-per-unit, acquisition timestamp, open sequence — onto the tail of an acquisition-ordered ArrayDeque<Lot>. A sell reads the account's elected policy, relieves lots from the head under FIFO or the tail under LIFO, and when the sell is smaller than the boundary lot it reduces that lot's qtyOpen in place — a split that leaves an open remainder carrying the original basis and acquisition time. Each relief computes realized gain as qtyRelieved·(sellPrice − lotBasis) in long arithmetic and emits an immutable RealizedGainRecord; cumulative realized gain is the sum of the records. Price ticks fold into per-lot unrealized gain. A sell exceeding all open long lots closes them and opens a SHORT lot for the residual, with symmetric relief on a later buy-to-cover.

Worked example (spec SC-001). Buy 100 @ 10, then 100 @ 12, then sell 150. Under FIFO the relieved basis is 100·10 + 50·12 = 1600, leaving a 50-share lot @ 12; under LIFO it is 100·12 + 50·10 = 1700, leaving 50 @ 10. Prices are scaled ×10 000 internally, so both figures are exact longs and both reconcile to a full replay to the cent.

TradeLedger events + ticks TaxLotBook ordered lots · relieve RealizedGain audit + P&L
The book folds ledger events into an acquisition-ordered lot set and emits an immutable record per relieved lot.

5 · Alternatives considered

OptionRelief-order correctnessPartial-split simplicityLatency fitTotal
Two priority queues (one per policy)4239
Re-derive lots from a replay on each sell54110
Single acquisition-ordered ArrayDeque — chosen55515

Acquisition order is the one order both policies need — FIFO reads it from the head, LIFO from the tail — so a single deque serves both with O(1) ends and an in-place split, where priority queues duplicate state and re-deriving from replay cannot meet the relief latency envelope.

6 · Decisions

D-001 · Acquisition-ordered ArrayDeque of open lots

Status
Accepted
Context
FR-001/FR-002 require open lots held in acquisition order and relieved oldest- or newest-first (§3, spec.html#FR-002, verified).
Decision
Hold open lots per (account, instrument) in an ArrayDeque<Lot> in acquisition order. FIFO relieves the HEAD (oldest), LIFO the TAIL (newest); partial consumption reduces the boundary lot's qtyOpen in place — a split.
Consequences
+ One structure serves both policies with O(1) ends and no duplicated state. + The split is a single field mutation. − Neither end supports HIFO/specific-lot relief, which are deferred anyway.

D-002 · Exact realized gain by integer long arithmetic

Status
Accepted
Context
The money-exactness contract fixes prices at PRICE_SCALE = 10_000 with no floating point on the money path (§3, umbrella spec, verified).
Decision
Store each lot's basis-per-unit as the exact integer fill price. Realized gain per relief is qtyRelieved·(sellPrice − lotBasis) — pure long arithmetic, no division — and cumulative realized gain is the sum of the RealizedGainRecords. Worked example: sell 150 over 100@10 then 100@12 gives relieved basis 1600 (FIFO) or 1700 (LIFO), exact.
Consequences
+ Gain is exact to the cent by construction and reconciles bit-for-bit to a full replay. + No rounding policy to defend. − Callers must present scaled integers; a display layer divides by PRICE_SCALE only at the edge.

D-003 · Per-account policy fixed at relief time; oversell opens a short lot

Status
Accepted
Context
FR-003 requires the relief policy selectable per account, fixed at relief time, and recorded for audit; the spec's oversell edge case opens a short position (§3, spec.html#FR-003, verified).
Decision
Select the relief policy (FIFO or LIFO) per account, read and fix it at relief time, and stamp it on each immutable RealizedGainRecord. A sell exceeding all open long lots closes them and opens a SHORT lot for the residual; a later buy-to-cover relieves symmetrically.
Consequences
+ Every realized figure carries the method it was computed under. + Shorts reuse the same relief path. − A policy change applies only to future reliefs, never retroactively.

D-004 · Relief selection generalises beyond the deque ends for HIFO

Status
Accepted
Context
The applied change 2026-07-22-hifo-relief adds HIFO (FR-002/FR-003). FIFO/LIFO are O(1) deque-end pops, but the highest-cost-basis lot can sit anywhere in the deque (spec.html#FR-002, verified).
Decision
Relief selects the policy-preferred lot each step — FIFO the head, LIFO the tail, HIFO a scan for the greatest basisPerUnit (ties to the earliest acquired) — then relieves it and, if emptied, removes that specific lot. The ArrayDeque still holds acquisition order for the holding-period audit.
Consequences
+ One relief path serves all three policies. + FIFO/LIFO stay O(1). − HIFO is an O(open-lots) scan, bounded by the same ≤ 1000-open-lots envelope as NFR-001, so the relief-latency SLO holds unchanged.

7 · Project structure

Only the new or changed paths, grouped by the tax module boundary the §6 decisions draw.

src/main/java/io/spectastic/portfolio/
  tax/
    TaxLotBook.java          fold ledger → ordered lots, relieve by policy
    Lot.java                 { account, instrument, openSeq, acquiredTs, qtyOpen, basisPerUnit, side }
    ReliefPolicy.java        FIFO | LIFO | HIFO
    RealizedGainRecord.java  immutable audit record per relieved lot
src/test/java/io/spectastic/portfolio/
    TaxLotBookTest.java       acquisition order, FIFO/LIFO relief, split, reconcile-to-replay

8 · Risks & mitigations

RiskLikelihoodImpactMitigation
Boundary-lot split off by the relieved quantity, silently corrupting basis and realized gain.MHSC-001 worked example is the headline test (T-200); reconcile realized + unrealized to a full replay (NFR-002).
Aggregate lot quantities drift from the position engine's (003) net quantity.LMAssert Σ open-lot qty equals the position engine's net long quantity at each sequence (spec §4 invariant).
Relief latency exceeds the p99 < 40 µs envelope at 1000 lots.LMDeque O(1) ends and a single in-place split keep relief O(lots relieved); JMH benchmark in Polish (T-901) confirms.

9 · Complexity tracking

Anywhere this design introduces complexity not strictly required by the spec, justify it here. A reviewer should be able to point at any non-trivial choice and find its rationale in this section.

The short-lot path (oversell into a short position) adds a second lot side beyond the plain long-only relief the worked example exercises. It is carried now, not deferred, because the spec names oversell-into-short as an edge case (§2) and the symmetric relief reuses the same head/tail split path — omitting it would leave a known-reachable state unhandled rather than genuinely simplifying the book.

10 · Open questions

None outstanding — relief semantics, the partial-lot split, per-account policy fixed at relief time, and the immutable audit record were all settled in the interview and recorded as D-001…D-003; HIFO and specific-lot relief are deferred (spec §3 out-of-scope), not open.

11 · Change log

  1. Initial design. Acquisition-ordered ArrayDeque lot book (D-001), exact integer-long realized gain (D-002), per-account policy fixed at relief time with short-lot symmetry (D-003). Alternatives weighed the deque against twin priority queues and re-derive-from-replay; the SC-001 worked example anchors the relief test and reconcile-to-replay gates correctness.
  2. Applied 2026-07-22-hifo-relief: added HIFO relief. New decision D-004 generalises lot selection beyond the deque ends — FIFO head, LIFO tail, HIFO a highest-basis scan bounded by the NFR-001 lot envelope.