Tax Lot Ledger: Cost Basis, Lot Methods, Splits and Wash Sales
HardAI-assisted coding · AI-Assisted Feature Work
Tax Lot Ledger: Cost Basis, Lot Methods, Splits and Wash Sales
You've joined the tax-reporting team at a retail brokerage. The team owns the cost-basis service: for every account and symbol it keeps the open tax lots (shares bought together, with their cost) and works out the gains realized when shares are sold. The 1099-B export is built from its numbers, so a wrong cent shows up in a customer's tax return.
Support and compliance have a queue of tickets and the service only half exists. It can record purchases and mark open lots to market, but selling is still "not supported". You will build it out in five steps; each step is a ticket, and later tickets change rules that earlier ones introduced.
All money is integer cents. All quantities are whole shares.
Codebase
| File | What it does |
|---|---|
taxlots/api.py | run(operations) entry point and the Dispatcher that routes each operation |
taxlots/service.py | CostBasisService: purchases, sales, reads. Every lot is created by _open_lot |
taxlots/lotbook.py | LotBook (allocates lot numbers, owns the positions) and Position (the open lots of one symbol in one account) |
taxlots/selection.py | Lot selection: which lots a sale consumes. Written when every account was FIFO |
taxlots/holding.py | The one place that decides short-term vs long-term |
taxlots/money.py | Argument validation and share_of, the only rounding rule in the service |
taxlots/models.py | Lot (including the canonical lot order) and Realized records |
taxlots/realized.py | Append-only list of realized-gain records and the per-account totals |
taxlots/wash.py | Placeholder for the wash-sale work |
taxlots/corporate.py | Placeholder for splits and account-to-account transfers |
taxlots/errors.py, taxlots/config.py | Error codes and constants (365-day line, 30-day wash window, ...) |
tests/ | Existing unit and end-to-end tests (one of them currently fails) |
money.py, realized.py and errors.py are known-good. Read models.py, lotbook.py and holding.py before you change how anything is stored or classified.
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh service and returns one result per operation. Every operation is [name, day, *args].
dayis a non-negative integer day number and never decreases from one operation to the next. (drip_buysoccupies a stretch of days; the next operation'sdayis at least the day of its last purchase.)- Errors are returned as
"error: <code>"strings, never raised. A wrong number of arguments, a negative or non-integerday, or an account or symbol that is not a non-empty string returns"error: invalid arguments". An unknown operation name returns"error: unknown operation". Operations that are not built yet return"error: not supported". - Accounts and symbols are case-sensitive strings and exist implicitly: an unknown account simply has no lots.
- When an operation lists several errors, they are checked in the order listed, and a rejected operation changes nothing.
Lots, ids and order
- A lot has an id
"L<n>". Numbers come from one counter shared by every account and symbol, start at 1, and are never reused. Every new lot takes the next number, whether it came from a purchase or from splitting a lot into two (Parts 3 and 4 say when that happens). - A lot records
buy_day(the day of the purchase) andacq_day(the day its holding period starts), its remainingqtyand its remainingcostin cents (the basis of the shares still in it, not a price). - Canonical lot order is
(acq_day, lot number)ascending.lotslists in this order and FIFO uses it.
The rounding rule
Whenever cents or shares are divided, the amount being divided is what is left at that moment, never the original total, and it is rounded down: share_of(total, part, whole) = total * part // whole, and taking the whole remainder (part == whole) returns total exactly. The pieces therefore always add back up to the whole, and the leftover cents stay with whatever is left behind. money.share_of is the canonical implementation; every rule below that says "apportioned" means this.
Holding period
A slice held held = day - acq_day days is long-term if held > 365 (366 days or more) and short-term otherwise. Exactly 365 days is short-term. This applies the same way to sales, to cash-in-lieu and to the unrealized-gain queries, and holding.py is the one place that implements it.
Part 1 — Sales, realized gains and the 365-day line (TAX-3142)
Goal: implement sell (FIFO is the only method for now) and make the holding-period helper correct everywhere it is used.
| Operation | Arguments after day | Returns | Errors (in order) |
|---|---|---|---|
buy | account, symbol, qty, cost | the new lot id | invalid quantity, invalid amount |
drip_buys | account, symbol, count, every, qty, cost | [first_lot_id, last_lot_id] | invalid quantity, invalid amount, invalid schedule |
sell | account, symbol, qty, proceeds | a result object (below) | invalid quantity, invalid amount, insufficient shares |
lots | account, symbol | open lots as [lot_id, acq_day, qty, cost], canonical order | |
realized | account | {"short", "long", "disallowed"} | |
unrealized | account, symbol, price | {"short", "long"} | invalid amount |
qtymust be an integer>= 1;cost,proceedsandpriceare integers>= 0(cents;priceis cents per share).trueandfalseare not integers.drip_buysis a bulk import of a dividend-reinvestment history: it is exactlycountseparatebuyoperations ofqtyshares forcostcents each, on daysday,day + every,day + 2*every, ...countandeverymust be integers>= 1(elseinvalid schedule).- Sell rules.
sellconsumes lots oldest first in canonical order, takingmin(lot qty, shares still to sell)from each. For each slice, in that order:
- its proceeds are
share_of(proceeds still unassigned, slice shares, shares still to sell); - its basis is
share_of(lot's current cost, slice shares, lot's current qty), and the lot keeps the rest; a lot with no shares left disappears; - its gain is
proceeds - basis, and its term (short or long) is set by the lot'sacq_dayand the saleday.
A sell for more shares than the position holds (or in an unknown account or symbol) is insufficient shares and consumes nothing.
- Sell result:
{"proceeds": <proceeds>, "basis": <total basis>, "short": <gain of short-term slices>, "long": <gain of long-term slices>, "disallowed": 0, "lots": [[lot_id, shares], ...]}, wherelotslists the consumed lots in consumption order (disallowedstays 0 until Part 4). realizedtotals theshortandlonggains of every slice ever realized by that account, across all symbols (disallowedis 0 until Part 4).unrealizedsumsqty * price - costover the open lots, splitting them intoshortandlongby their term as of the queryday. It never changes anything. A symbol with no open lots gives zeros.
Ticket TAX-3142: the draft 1099-B shows lots held exactly one year as long-term. tests/test_service.py::test_a_lot_held_exactly_one_year_is_still_short_term fails, and unrealized is wrong for the same reason.
Worked example
[["buy", 0, "acct-1", "ACME", 100, 100001],
["buy", 200, "acct-1", "ACME", 50, 30000],
["sell", 365, "acct-1", "ACME", 60, 90000],
["sell", 366, "acct-1", "ACME", 60, 66000],
["lots", 366, "acct-1", "ACME"],
["realized", 367, "acct-1"],
["unrealized", 400, "acct-1", "ACME", 1000]]returns
["L1", "L2",
{"proceeds": 90000, "basis": 60000, "short": 30000, "long": 0, "disallowed": 0, "lots": [["L1", 60]]},
{"proceeds": 66000, "basis": 52001, "short": 10000, "long": 3999, "disallowed": 0, "lots": [["L1", 40], ["L2", 20]]},
[["L2", 200, 30, 18000]],
{"short": 40000, "long": 3999, "disallowed": 0},
{"short": 12000, "long": 0}]The first sale takes 60 of lot L1 (100001 cents): its basis is 100001 * 60 // 100 = 60000, held exactly 365 days so short-term. L1 keeps 40 shares and 40001 cents. On day 366 the 40 remaining L1 shares are long-term (basis 40001, the whole remainder) and 20 shares come from L2 (basis 30000 * 20 // 50 = 12000, short-term); proceeds split as 66000 * 40 // 60 = 44000 and the remaining 22000.
Part 2 — Lot-selection methods (TAX-3188)
Requirement change from compliance: clients may choose how their sales pick lots. An account has one method at a time, FIFO by default, applied to every symbol in that account.
| Operation | Arguments after day | Returns | Errors (in order) |
|---|---|---|---|
set_method | account, method | the previous method | unknown method |
sell | account, symbol, qty, proceeds[, picks] | as in Part 1 | invalid quantity, invalid amount, invalid picks, lot not found, insufficient shares |
- Methods are exactly
"FIFO","HIFO"and"SPECIFIC"(case-sensitive). The new method applies to later sales (and, in Part 3, transfers); lots and past records are never touched by a switch. - HIFO takes the lot with the highest remaining cost per share first (the lot's *current*
cost / qty, compared exactly with no floating point). Ties go to canonical lot order. After a partial sale a lot's rank can change, so rank from the current numbers each time. Within one sell, slices are consumed in that rank order and Part 1's rules for proceeds and basis apply to that order. - SPECIFIC means the caller names the lots:
picksis a list of[lot_id, shares]pairs and the slices are consumed in the order given. picksis an optional last argument. For FIFO and HIFO accounts apicksvalue isinvalid picks; for a SPECIFIC account it is required.nullcounts as not given.- A picks list is
invalid picksunless it is a non-empty list of[string, integer >= 1]pairs with no lot id repeated and shares adding up to exactlyqty. Then every named lot must be an open lot of this account and symbol, spelled exactly as its id ("L1", not"l1"or"L01"), checked for all pairs before any share count is checked (lot not found); then no pick may exceed its lot's remaining shares (insufficient shares). For FIFO and HIFOinsufficient sharesmeansqtyis more than the position holds.
Worked example
[["buy", 0, "acct-1", "ACME", 10, 10000],
["buy", 100, "acct-1", "ACME", 10, 15000],
["buy", 200, "acct-1", "ACME", 10, 12000],
["set_method", 300, "acct-1", "HIFO"],
["sell", 301, "acct-1", "ACME", 15, 30000],
["lots", 302, "acct-1", "ACME"],
["set_method", 303, "acct-1", "SPECIFIC"],
["sell", 304, "acct-1", "ACME", 6, 9000, [["L1", 2], ["L3", 4]]],
["sell", 305, "acct-1", "ACME", 1, 100],
["lots", 306, "acct-1", "ACME"]]returns
["L1", "L2", "L3", "FIFO",
{"proceeds": 30000, "basis": 21000, "short": 9000, "long": 0, "disallowed": 0, "lots": [["L2", 10], ["L3", 5]]},
[["L1", 0, 10, 10000], ["L3", 200, 5, 6000]],
"HIFO",
{"proceeds": 9000, "basis": 6800, "short": 2200, "long": 0, "disallowed": 0, "lots": [["L1", 2], ["L3", 4]]},
"error: invalid picks",
[["L1", 0, 8, 8000], ["L3", 200, 1, 1200]]]Part 3 — Stock splits and transfers between accounts (TAX-3201)
| Operation | Arguments after day | Returns | Errors (in order) |
|---|---|---|---|
split | symbol, num, den, cash_price | total cash-in-lieu paid, in cents | invalid split, invalid amount |
transfer | source, target, symbol, qty[, picks] | ids of the lots moved, in the order moved | invalid quantity, same account, invalid picks, lot not found, insufficient shares |
split is a num-for-den split of symbol (3-for-2 is 3, 2; a 1-for-10 reverse split is 1, 10). It applies to every open lot of that symbol in every account. num and den must be integers >= 1 and different from each other (else invalid split); cash_price is an integer >= 0 in cents per whole post-split share. For each lot, one lot at a time:
scaled = qty * num; the lot keepswhole = scaled // denshares and hasfrac = scaled % denleft over, which is a fraction of a share counted in units of1/den.- If
frac > 0that fractional share is sold for cash: proceedsfrac * cash_price // den, basisshare_of(lot's cost, frac, scaled). The lot's cost drops by that basis, and a realized record is added for the lot's account with that proceeds and basis, short- or long-term by the lot'sacq_dayand the splitday. These "cash-in-lieu" records count inrealizedlike any other. - The lot becomes
wholeshares. It keeps its id and both dates; a lot left with 0 shares disappears.
Fractions are decided lot by lot, never on the whole position. Total basis is preserved: a lot's remaining cost plus its cash-in-lieu basis equals its cost before the split. A symbol nobody holds returns 0.
transfer moves qty shares of symbol from source to target with their tax history. The lots are chosen by the source account's method exactly as sell chooses them (SPECIFIC needs picks; FIFO and HIFO reject them; the same picks rules and error order apply). A lot moved whole keeps its id; a lot moved in part sends a new lot (next lot number) holding take shares and share_of(lot's cost, take, lot's qty) of the cost, and the original keeps the rest and its id. Moved lots keep buy_day, acq_day, cost and (Part 4) their washed flag, and join the target's position in canonical order. A transfer is not a sale: nothing is realized and no other record changes. source == target is same account (checked after invalid quantity).
Worked examples
[["buy", 0, "acct-1", "ACME", 5, 10000],
["buy", 100, "acct-1", "ACME", 7, 16000],
["split", 500, "ACME", 3, 2, 2000],
["lots", 501, "acct-1", "ACME"],
["realized", 502, "acct-1"],
["split", 503, "ACME", 1, 10, 5000],
["lots", 504, "acct-1", "ACME"],
["realized", 505, "acct-1"]]returns
["L1", "L2", 2000,
[["L1", 0, 7, 9334], ["L2", 100, 10, 15239]],
{"short": 0, "long": 573, "disallowed": 0},
3500,
[["L2", 100, 1, 15239]],
{"short": 0, "long": -5261, "disallowed": 0}]In the 3-for-2 split L1 has scaled = 15, so 7 whole shares and frac = 1 (half a share): proceeds 1 * 2000 // 2 = 1000, basis 10000 * 1 // 15 = 666, cost left 9334. L2 has scaled = 21: 10 shares, frac = 1, proceeds 1000, basis 761. The 1-for-10 reverse split sells all 7 of L1's tenths (7 * 5000 // 10 = 3500 against its whole remaining cost 9334) and leaves L2 exactly 1 share.
[["buy", 0, "acct-1", "ACME", 10, 10000],
["buy", 100, "acct-1", "ACME", 10, 15000],
["buy", 250, "acct-2", "ACME", 4, 8000],
["transfer", 300, "acct-1", "acct-2", "ACME", 13],
["lots", 301, "acct-1", "ACME"],
["lots", 301, "acct-2", "ACME"],
["sell", 600, "acct-2", "ACME", 5, 9000],
["lots", 601, "acct-2", "ACME"],
["realized", 602, "acct-2"]]returns
["L1", "L2", "L3", ["L1", "L4"],
[["L2", 100, 7, 10500]],
[["L1", 0, 10, 10000], ["L4", 100, 3, 4500], ["L3", 250, 4, 8000]],
{"proceeds": 9000, "basis": 5000, "short": 0, "long": 4000, "disallowed": 0, "lots": [["L1", 5]]},
[["L1", 0, 5, 5000], ["L4", 100, 3, 4500], ["L3", 250, 4, 8000]],
{"short": 0, "long": 4000, "disallowed": 0}]Part 4 — Wash sales (TAX-3257)
Compliance needs a simplified wash-sale rule: a loss is not deductible if the customer buys the same symbol again in the same account shortly before or after the sale. The disallowed loss is not forgotten; it moves into the cost of the replacement shares. This is a retroactive rule: a purchase made today can change a sale reported last month. Scenarios in Parts 1-3 that have no purchase near a loss behave exactly as before.
Definitions
- A loss slice is one slice of a sell (Part 1) whose basis is greater than its proceeds. Its loss is
basis - proceedsand its shares are the slice's shares. Gains, break-even slices and cash-in-lieu records are never loss slices. - Its held days are
sale day - acq_dayof the lot it came from, as that lot was at the moment of the sale. - The window is 30 days (
config.WASH_WINDOW_DAYS) before and after the sale, both ends inclusive, measured on the replacement lot'sbuy_day(notacq_day). - A lot can be a replacement only if it is in the same account and symbol as the sale, is not
washed(see below), and did not supply shares to this same sell (not even partly: the unsold shares of a lot that was partly sold are not replacements for that sale). Lots that arrived bytransfercount by their originalbuy_day.
Matching. Take one loss slice with n open shares and L open loss (initially its shares and loss) and a candidate lot with q shares. Let m = min(n, q) and d = share_of(L, m, n).
- If
m < q, carve the lot: themmatched shares become a new lot (next lot number) withshare_of(cost, m, q)of the cost and the same dates; the original keeps the other shares and its id. Ifm == qthe whole lot is matched and keeps its id. - The matched lot's cost increases by
d, itsacq_daymoves earlier by the slice's held days (its holding period is "tacked" onto the sold shares'), and it is markedwashed: it can never again be a replacement, even after transfers or further splits. - The slice's open shares fall by
m, its open loss byd, and the slice's record remembersdas disallowed. Its reported gain becomesgain + disallowed.
When matching happens
- At the sale, after the whole sell has been consumed: each loss slice in consumption order, against the lots that already hold shares with
0 <= sale day - buy_day <= 30, candidates ordered by(buy_day, lot number). A candidate lot left partly unmatched is still available to the next slice of the same sell. - At later purchases. If a loss slice still has open shares after the sale, it stays open for 30 days. Every later purchase in the same account and symbol (each
buy, and each purchase insidedrip_buysas if it were a separatebuy) with0 <= buy_day - sale day <= 30is matched against the open slices, oldest first (sale order, then consumption order), using the same procedure. The lot opened by the buy is the candidate; if it is carved up, the original id still names the unmatched rest, sobuystill returns that id. - A split of the symbol closes every open slice of that symbol in every account: their unmatched shares are never matched. A transfer never matches anything (neither the lots it moves nor the account that receives them), and open slices stay with the account where the sale happened. Purchases in a different account never match.
Reporting. The disallowed field of a sell result is what was disallowed by the matching at that sale; it is not updated when a later purchase disallows more. realized always reflects the current state: short and long use each record's reported gain (gain + disallowed), and disallowed is the sum of every record's disallowed loss. A record's term is fixed when it is created, from the sold lot's acq_day at that moment. Tacked acq_days are real: they change canonical order (so FIFO, lots and HIFO ties), future terms and unrealized.
Worked example
[["buy", 1000, "acct-1", "ACME", 100, 100000],
["buy", 1100, "acct-1", "ACME", 50, 40000],
["sell", 1110, "acct-1", "ACME", 100, 70000],
["realized", 1111, "acct-1"],
["buy", 1120, "acct-1", "ACME", 80, 100000],
["lots", 1121, "acct-1", "ACME"],
["realized", 1122, "acct-1"],
["sell", 1400, "acct-1", "ACME", 50, 60000],
["unrealized", 1400, "acct-1", "ACME", 1200]]returns
["L1", "L2",
{"proceeds": 70000, "basis": 100000, "short": -15000, "long": 0, "disallowed": 15000, "lots": [["L1", 100]]},
{"short": -15000, "long": 0, "disallowed": 15000},
"L3",
[["L2", 990, 50, 55000], ["L4", 1010, 50, 77500], ["L3", 1120, 30, 37500]],
{"short": 0, "long": 0, "disallowed": 30000},
{"proceeds": 60000, "basis": 55000, "short": 0, "long": 5000, "disallowed": 0, "lots": [["L2", 50]]},
{"short": -1500, "long": -17500}]The sale loses 30000 on 100 shares held 110 days. L2 (bought 10 days before, 50 shares) is matched at once: d = 30000 * 50 // 100 = 15000 goes into its cost (40000 to 55000) and its acq_day moves from 1100 to 1100 - 110 = 990. The other 50 shares are still open when L3 is bought 10 days after the sale; its first 50 shares are carved into new lot L4 (cost 100000 * 50 // 80 = 62500 plus the last 15000 of loss, acq_day 1120 - 110 = 1010) and L3 keeps the other 30. The day-1400 sale takes L2 first (tacked acq_day 990, held 410 days, so long-term although it was bought 300 days earlier).
Part 5 — Planning dashboard at scale (TAX-3290, stretch)
The advisor dashboard asks: *when will this position first show a long-term gain worth harvesting?*
| Operation | Arguments after day | Returns | Errors (in order) |
|---|---|---|---|
long_gain_day | account, symbol, price, target, horizon | a day number, or null | invalid amount, invalid schedule |
long_gain_day returns the first day d in day, day + 1, ..., day + horizon on which the long-term unrealized gain of the open lots at price (cents per share), with terms judged as of d, is >= target, or null if no such day exists. price must be an integer >= 0 and target any integer (negative allowed), both else invalid amount (price is checked first); horizon must be an integer >= 0 (else invalid schedule). It never changes anything. A position with no lots has a long-term gain of 0.
The starter answers not supported. A day-by-day rescan of every lot is correct but far too slow: production positions hold years of daily dividend reinvestments (thousands of small lots), the dashboard asks about horizons of thousands of days, and the hidden tests do the same inside one run. Whatever you build must stay correct as lots are sold, split, transferred, carved up and tacked by Parts 1-4; every one of those operations changes a lot's qty, cost or acq_day, in a different module.
Worked example
[["buy", 0, "acct-1", "ACME", 10, 10000],
["buy", 100, "acct-1", "ACME", 10, 12000],
["buy", 200, "acct-1", "ACME", 10, 8000],
["long_gain_day", 300, "acct-1", "ACME", 1100, 1, 400],
["long_gain_day", 300, "acct-1", "ACME", 1100, 3000, 400],
["long_gain_day", 300, "acct-1", "ACME", 1100, 3001, 400],
["long_gain_day", 300, "acct-1", "ACME", 1100, 0, 0]]returns ["L1", "L2", "L3", 366, 566, null, 300]. At price 1100 the lots show gains of +1000, -1000 and +3000. L1 turns long-term on day 366 (gain 1000), L2 on day 466 (gain 0 in total), L3 on day 566 (gain 3000). With horizon 0 only day 300 is looked at, where nothing is long-term yet and the gain 0 meets a target of 0.
Notes
- You may use the AI assistant freely; you are expected to verify what it produces.
- Later Parts must not break earlier ones, except where a Part says it changes a rule. Hidden tests re-check earlier behaviour after later Parts.
- Parts 1-4 are the core of the work; Part 5 is a stretch.
Example 1
- Input:
- [["buy",0,"acct-1","ACME",100,100001],["buy",200,"acct-1","ACME",50,30000],["sell",365,"acct-1","ACME",60,90000],["sell",366,"acct-1","ACME",60,66000],["lots",366,"acct-1","ACME"]]
- Output:
- ["L1","L2",{"proceeds":90000,"basis":60000,"short":30000,"long":0,"disallowed":0,"lots":[["L1",60]]},{"proceeds":66000,"basis":52001,"short":10000,"long":3999,"disallowed":0,"lots":[["L1",40],["L2",20]]},[["L2",200,30,18000]]]
- Explanation
- The first sale takes 60 of L1: basis 100001 * 60 // 100 = 60000, held exactly 365 days so short-term. On day 366 the 40 leftover L1 shares take the whole remaining cost 40001 and are long-term; 20 shares come from L2 at 30000 * 20 // 50 = 12000.
Example 2
- Input:
- [["buy",0,"acct-1","ACME",10,10000],["buy",100,"acct-1","ACME",10,15000],["buy",200,"acct-1","ACME",10,12000],["set_method",300,"acct-1","HIFO"],["sell",301,"acct-1","ACME",15,30000],["lots",302,"acct-1","ACME"]]
- Output:
- ["L1","L2","L3","FIFO",{"proceeds":30000,"basis":21000,"short":9000,"long":0,"disallowed":0,"lots":[["L2",10],["L3",5]]},[["L1",0,10,10000],["L3",200,5,6000]]]
- Explanation
- HIFO takes the most expensive lot per share first: L2 (1500 a share) entirely, then half of L3 (1200 a share). The oldest lot L1 is untouched.
Example 3
- Input:
- [["buy",1000,"acct-1","ACME",100,100000],["buy",1100,"acct-1","ACME",50,40000],["sell",1110,"acct-1","ACME",100,70000],["buy",1120,"acct-1","ACME",80,100000],["lots",1121,"acct-1","ACME"],["realized",1122,"acct-1"]]
- Output:
- ["L1","L2",{"proceeds":70000,"basis":100000,"short":-15000,"long":0,"disallowed":15000,"lots":[["L1",100]]},"L3",[["L2",990,50,55000],["L4",1010,50,77500],["L3",1120,30,37500]],{"short":0,"long":0,"disallowed":30000}]
- Explanation
- The 30,000 loss is matched half against L2 (bought 10 days before the sale) at the sale, and half against a carved-out piece of L3 (bought 10 days after). Each replacement gets 15,000 added to its cost and its acquisition day moved 110 days earlier; the loss is fully disallowed in realized.
Guidelines
- Python 3 standard library only
- All money is integer cents and all quantities are whole shares; there is no floating-point arithmetic anywhere
- Every division of cents or shares uses money.share_of on what is left at that moment, rounding down, so pieces always sum to the whole
- Days are non-negative integers that never decrease from one operation to the next
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised, and a rejected operation changes nothing
- Part 5 inputs hold thousands of lots and ask about horizons of thousands of days