Implementation design · 005-tax-lots
FIFO/LIFO tax lot tracking
How we will build it.
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.
| Principle | Compliance | Notes |
|---|---|---|
| P-2 · Small, reversible changes | One new tax module and its test; partial consumption is an in-place qtyOpen split, not a structural rewrite. | |
| P-3 · Explicit over implicit | Relief policy is selected per account and fixed at relief time; the applied policy is stamped on every RealizedGainRecord. | |
| P-5 · Record the why | D-001…D-003 in §6 carry the deque choice, exact-integer gain, and short-lot symmetry, each grounded in §3. | |
| P-6 · Tests accompany behavior | TDD per story: tests written failing first (T-100/T-200/T-300) before their implementation tasks. | |
| P-7 · Done means verified | Realized and unrealized gain reconcile to a full replay (NFR-002); snapshot-restore-then-replay-tail equals full replay. | |
| P-8 · Ship dark by default | Behind portfolio.analytics.enabled, default false; no relief runs until the toggle is on. | |
| P-9 · Decisions are grounded | Every §6 decision rests on a verified §3 source (spec FR-001/FR-002, the exact-arithmetic contract, JDK ArrayDeque). | |
| P-15 · Structured observability | Micrometer relief timer exported at /q/metrics; OpenAPI at /q/openapi. | |
| P-16 · Service level objectives | SLO-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
taxmodule underio.spectastic.portfoliothat folds the umbrella's event stream; no new service boundary. - Storage
- In-memory event-sourced projection over the
TradeLedgerlog; snapshot = deep copy, restore-then-replay-tail equals full replay. No database. - Testing
- JUnit5 unit tests plus one
@QuarkusTestwith 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 on | Source | Status | Finding / note |
|---|---|---|---|
| FIFO relieves oldest-first, LIFO newest-first, with a boundary-lot split on partial consumption. | specs/005-tax-lots/spec.html#FR-002 |
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 |
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) |
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) |
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) | 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. | — | 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.
5 · Alternatives considered
| Option | Relief-order correctness | Partial-split simplicity | Latency fit | Total |
|---|---|---|---|---|
| Two priority queues (one per policy) | 4 | 2 | 3 | 9 |
| Re-derive lots from a replay on each sell | 5 | 4 | 1 | 10 |
Single acquisition-ordered ArrayDeque — chosen | 5 | 5 | 5 | 15 |
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 anArrayDeque<Lot>in acquisition order. FIFO relieves the HEAD (oldest), LIFO the TAIL (newest); partial consumption reduces the boundary lot'sqtyOpenin 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_000with 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 theRealizedGainRecords. 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_SCALEonly 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. TheArrayDequestill 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
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Boundary-lot split off by the relieved quantity, silently corrupting basis and realized gain. | M | H | SC-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. | L | M | Assert Σ 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. | L | M | Deque 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
- Initial design. Acquisition-ordered
ArrayDequelot 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. - 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.