Skip to content

Per-wallet lot tracking: end-to-end wiring, IRS FAQ tests, and LTCG fix - #155

Open
macanudo527 wants to merge 66 commits into
eprbell:per_wallet_applicationfrom
macanudo527:add_tests_fix_wallet
Open

macanudo527 wants to merge 66 commits into
eprbell:per_wallet_applicationfrom
macanudo527:add_tests_fix_wallet

Conversation

@macanudo527

@macanudo527 macanudo527 commented May 25, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Per-wallet application for the US (-w), following Treas. Reg. §1.1012-1(j) and Rev. Proc. 2024-28. Tax years before 2025 are unchanged.

How it works

  • Before the switch (tax years before 2025): the same computation as universal application, compute_tax(). Output is identical to runs without -w.
  • At the switch (Jan 1, 2025, in the user's timezone, Rev. Proc. 2024-28 §3.10): the lots still unused under universal application are allocated to the wallets that hold funds. This is the "global allocation" of §5.02(2)(b), done by UnusedBasisAllocator. Basis and acquisition date stay together (§3.04). The rule is a lot-sorting method plus a wallet order, with optional per-asset overrides (<field>.<asset>).
  • After the switch: TransferAnalyzer.analyze_and_pair() pairs each sale and transfer with lots of its own wallet only, in chronological order.
    • Transfers keep cost basis (purchase fees included) and the original acquisition date. FIFO orders lots by acquisition date (AcquisitionDateFifo, per §1.1012-1(j)(3)(i)).
    • Same-timestamp transactions are processed In, Intra, Out, then by row.
  • Countries: they opt in with get_per_wallet_application_start_year(), which is 2025 for the US and None for everyone else, so rp2_jp -w is rejected. US runs with 2025+ data and no -w print a warning.

Unsettled law is explicit, never defaulted. New [per_wallet] config section:

  • timezone (mandatory);
  • transfer_fee_treatment (disposal or basis_carryover): required if a post-switch transfer has a crypto fee;
  • unused_basis_allocation_method and unused_basis_allocation_wallet_order: required if more than one wallet holds funds at the switch.

A transaction whose own timestamp falls in a different tax year than the configured timezone gives is an error, not a guess.

Bugs fixed (in the earlier version of this PR, and in shared code)

  • A sale that exhausted a lot failed. Pairing moved from a second universal pass into the per-wallet engine.
  • A transfer fee spanning several lots is now handled (Per Wallet Implementation: transfer with crypto fee spanning multiple lots with FIFO fails #149).
  • Artificial (transferred) lots lost the purchase fee from their basis.
  • Artificial lots of earn-type transactions were counted as income again.
  • FIFO ordered lots by arrival in the wallet instead of by acquisition date.
  • Self-transfers didn't consume their fee.
  • Under basis_carryover, basis was lost on A→B→A cycles.
  • The US holding period now uses the calendar rule of IRC §1222 / Pub. 544 instead of counting 365 days, which was wrong across Feb 29th. Other countries keep the day count.

Docs: user FAQ (per-wallet application, what counts as a wallet, locked staking rewards), input_files.md ([per_wallet]), supported_countries.md, README, CLAUDE.md.

Test plan

  • All tests pass: 182 tests, 199 subtests (pytest --tb=native --verbose); mypy, pylint 10.00, black, flake8, bandit clean.
  • test_per_wallet_tax_engine.py: hand-computed cases, including:
  • test_per_wallet_properties.py (hypothesis):
    • wallet lots equal the wallet balance after each step;
    • basis and lot amounts are conserved under both fee treatments;
    • a single wallet gives the same result per-wallet as universal;
    • JP results are unaffected.
  • test_ods_output_diff_per_wallet.py: -w output for pre-2025 datasets is identical to universal output, for every method and report; rp2_jp -w is rejected.
  • test_gain_loss.py::test_ltcg_boundary: calendar-rule boundaries, including leap years.

Not in this PR (follow-up issues)

Reports showing the wallet and original lot (1099-DA reconciliation), Form 8949 covered/noncovered boxes, specific unit allocation, rule-based wallet orders, wrapped/bridged tokens, Japanese averaging methods, gifts and inheritance, more test coverage, small cleanups, multiple switches, and specific identification of transferred lots.

🤖 Generated with Claude Code

spoderman-xbt and others added 30 commits November 17, 2025 11:11
… and remove check for selected_acquired_lot_amount > taxable_event_amount.
…rical-values-in-ODS-file

Updated README.dev.md: update local pre-commit repos
…alues-in-ODS-file

Bugfix/142 non numerical values in ods file
gain_loss.py: change >= to > in is_long_term_capital_gains() so that
exactly 365 days is correctly classified as short-term. IRS defines
long-term as "more than one year" (IRC §1222), not "one year or more".

test_gain_loss.py: four new tests covering IRS Digital Assets FAQ
scenarios that had no unit-level coverage:
- test_ltcg_boundary: 365 days = STCG, 366 = LTCG, leap-year edge case
- test_earn_type_income_recognition: HARDFORK, AIRDROP, MINING, STAKING,
  WAGES, INCOME all produce ordinary income at FMV with zero cost basis
  (Rev. Rul. 2019-24, Notice 2014-21, Rev. Rul. 2023-14, FAQ Q57-61)
- test_donate_gift_disposal_gain_loss: DONATE/GIFT disposals use the
  same proceeds-minus-basis formula as SELL (FAQ Q75-78)
- test_holding_period_resets_after_exchange: received asset holding
  period starts fresh on exchange date regardless of prior lot age (Q74)
- test_fee_out_transaction_gain_loss: FEE-typed disposal recognises
  gain/loss on crypto used purely to pay a network fee (FAQ Q97)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CLAUDE.md: new codebase guide for Claude Code covering architecture,
design conventions, common commands, and a per-test IRS authority map
for test_gain_loss.py.

user_faq.md: added IRS rule citations and guidance notes to seven
sections — airdrops (Rev. Rul. 2019-24), hard forks (Q103-107),
mining (Notice 2014-21 Q8), staking (Rev. Rul. 2023-14), wages
(FAQ Q57-61), crypto-to-crypto swaps (Q74 holding period reset), and
intra-transaction fees (Q81/Q97).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three new tests in test_gain_loss.py covering IRS rules not previously
exercised: purchase fee increases cost basis (Q8/Q56), earned crypto
carries FMV-at-receipt as basis for subsequent sale (Q13/Q59), and
crypto-to-crypto exchange triggers gain/loss on the disposed asset
(Q16-Q17/Q64-Q66). Each test links directly to the relevant IRS FAQ
anchor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Every citation in the user FAQ, input_files.md and CLAUDE.md now links
to its text, not just the first one per section, including the older
FAQ citations (IRC 61, 165, 1091, 1223(11), 1256, Rev. Rul. 2019-24,
Notice 2014-21). Rev. Proc. 2024-28 sections link to their page of the
IRS PDF (#page=N); Treasury Regulation paragraphs link to their eCFR
anchor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZgEJzUPyGHYd2xoXqcp5R
@macanudo527

Copy link
Copy Markdown
Collaborator Author

@eprbell here's the from-scratch review you suggested. The prompt is below (your outline, expanded with the regulatory ground truth, edge cases and test requirements I wanted covered). This is just the prompt and summary. There were a lot of changes it took most of a day for Opus 5.5 to grind through it all. If you need more details please let me know.

@jayr0d If you are free, can you look over the code as well? It's a lot. I'm going to give it a manual look over this week.

Prompt
CONTEXT
Read carefully:
* https://github.com/eprbell/rp2/issues/135. Read especially carefully the links in https://github.com/eprbell/rp2/issues/135#issuecomment-2454959334 and familiarize yourself with the new per-wallet regulation.
* https://github.com/eprbell/rp2/issues/134
* https://github.com/eprbell/rp2/pull/155

OBJECTIVE
Implement per-wallet tracking in RP2 correctly and safely. Deep-scan the code until you fully understand how RP2 worked before the per-wallet changes and how it works after them. Be careful and systematic.

STEP 1: GAP CHECK
Are there any gaps in your understanding of the code or the regulations? If so, do not touch the code. Stop and ask me now so we can bridge the gap. Proceed to Step 2 only if you have no gaps.

STEP 2: REVIEW
Review the code along these axes:
1. Correctness against the regulations. Does the code implement the per-wallet rules correctly? Is anything incorrect, imprecise or missing? Look for bugs, duplicated code, possible logic improvements, security issues, and obsolete, dead or out-of-date code or docs. Is there anything you would do differently?
2. Test coverage. Do the tests cover the code fully? Are any tests missing, imprecise or incorrect?
3. Code quality. The code in the CL should be modern, elegant, modular, maintainable, reusable and extensible, and should follow https://github.com/eprbell/rp2/blob/main/README.dev.md#design-guidelines.

REGULATORY GROUND TRUTH (as of Sept 2026)
Treat the following as authoritative:
- If the code or docs contradict it, flag it.
- If you believe any of it is outdated, say so and cite a primary source. Do not guess.
- For every regulatory claim in your findings, cite a source: Treas. Reg. §1.1012-1(j), Rev. Proc. 2024-28, Notice 2025-7, the Form 1099-DA instructions, or the NTA 暗号資産FAQ.
- Separate your findings into:
  (a) bugs against settled rules, and
  (b) areas where the law is unsettled. For (b), the code must make the behavior explicit, user-configurable and documented. It must never silently pick an interpretation.

US
- From 2025-01-01, basis identification is per wallet/account: specific identification within a wallet, otherwise FIFO within that wallet.
- Rev. Proc. 2024-28 safe harbor: unused basis as of 2025-01-01 is allocated to wallets, either by specific-unit allocation or by global (rule-based) allocation.
- Notice 2025-7 transition relief for standing orders and specific identification at brokers.
- Form 1099-DA: gross proceeds are reported for 2025 sales. Brokers report basis for covered assets acquired in 2026 or later and held continuously at the broker.
- The §1091 wash-sale rule does not currently apply to non-security crypto. Bill H.R. 9172 is pending and would be effective from its introduction date if enacted, so wash-sale handling must be easy to add later.
- Tax years before 2025 must still use universal application, unchanged.

Japan
- Crypto gains are currently miscellaneous income (雑所得) under aggregate taxation.
- Cost basis uses 総平均法 (the default) or 移動平均法, pooled per asset across ALL wallets, i.e. universal. Per-wallet logic must never affect JP runs.
- The enacted 20% separate taxation has no start date yet; it depends on the FIEA cabinet order and is expected to begin Jan 1, 2027 or 2028. It covers specified crypto assets sold via registered Japanese exchanges and adds a 3-year loss carryforward. Do not implement it, but note whether the current design would block per-venue classification later.

EDGE CASES
For each case below, do one of three things: confirm the code handles it and point to the test that proves it, identify the gap, or write the missing test.

Transition and boundaries
1. The universal-era lot state at 2024-12-31 does not match actual wallet balances.
   - How is the gap bridged?
   - Are both safe-harbor methods supported?
   - Is the allocation explicit user input or silently derived? Silent derivation risks hindsight selection.
2. Transfers in flight across 2024/2025 and across any other year boundary.
3. Timezones. Which timezone decides the tax year and the 2025-01-01 switch? Test a trade at 2025-01-01 03:00 JST, which is still 2024 in UTC.
4. Regression. Outputs for years before 2025 must be identical to pre-PR RP2. Use golden-file tests.
5. Accounting-method changes across years under per-wallet, and consistency between transfer semantics and accounting method within a year.

Transfers
6. Partial-lot transfers, lot splitting, decimal precision and dust remainders.
7. Fees. Cover all three situations:
   - the fee is paid in the transferred asset (sent ≠ received);
   - the fee is paid in another asset;
   - taxability of crypto-paid fees on self-transfers. This is unsettled in the US and is a disposal in Japan, so it must be configurable.
8. Sends with no matching receive, and receives with no matching send.
9. Multi-hop transfers (A→B→C on the same day), round trips (A→B→A), and a transfer and a sale with the same timestamp in one wallet. Tie-breaking must be deterministic and documented.
10. The original acquisition date and holding period are preserved through transfers. Artificial in-transactions must not create income or reset holding periods.
11. A sale or transfer that exceeds a wallet's balance must raise an error. It must not silently draw from another wallet.
12. Account migrations and forced moves when an exchange shuts down or stops serving a country. Are these treated as transfers or disposals?

Acquisitions and income
13. Staking, lending interest, mining and airdrops create a lot in the receiving wallet at FMV on receipt. For locked or delayed-dominion staking (e.g. ICP neuron maturity) the income timing is ambiguous, so it must be configurable.
14. Hard forks. Gifts received (carryover basis, and the US dual-basis rule for losses). Gifts given, donations, and inheritance (step-up in basis).
15. Wrapped, bridged and liquid-staking tokens (WETH, stETH). Whether these are disposals or transfers is unsettled, so the user must control it.

Disposals and scale
16. Crypto-to-crypto trades, spending crypto, forced liquidation when an exchange closes, and exchange insolvency or worthlessness.
17. Performance at grid-bot volume (1e5+ small trades per wallet). Check the complexity of lot selection.

Japan
18. Total-average correctness per asset per calendar year, carryover of the average into the next year, moving average, and integer-yen rounding (when and where rounding happens).
19. The same input dataset yields consistent results for US (USD, per-wallet) and JP (JPY, universal) runs. Check the FX rate source and timing.

Reporting and docs
20. Reports must expose the wallet for every lot and disposal so users can reconcile with Form 1099-DA.
21. Reports must distinguish covered from noncovered assets for Form 8949.
22. User docs must explain how to configure the allocation and per-wallet mode.

TESTS
Beyond example-based tests, add property-based tests (e.g. with hypothesis) for these invariants:
- Per-wallet lot quantities sum to the wallet balance at every step.
- Basis is conserved across transfers, net of the configured fee policy.
- No lot fraction is consumed twice.
- With a single wallet, per-wallet and universal application produce identical results.
- JP output is unchanged by the per-wallet code path.

OUTPUT FORMAT
Report findings as a list. Give each finding:
- a severity (critical / major / minor / nit)
- file:line
- category: bug, regulatory-settled, regulatory-unsettled, test gap, design, or docs
- a regulatory citation, where applicable
- a concrete proposed fix

End with a summary of anything you could not verify and why.

Decisions I made when it stopped at the gap check:

  • Notice 2026-20 (which extends the Notice 2025-7 relief through 2026) is authoritative;
  • Japan: report the gaps and guard against per-wallet runs, but don't implement averaging here;
  • scope: review, fix the critical issues and add tests; bigger redesigns become follow-up issues;
  • unsettled law (the transfer fee treatment, the allocation rule) requires explicit config, with no defaults.

Later I asked for per-coin allocation overrides, the leap-year holding-period fix, a readability pass, and tests built from the worked examples in the #135 Reddit threads. I asked it to leave everything else as issues.

Result: the PR description is updated with the details. In short:

  • before 2025, output is identical to universal (golden-file tests);
  • at the switch, the Rev. Proc. 2024-28 global allocation runs, with basis and acquisition date kept together;
  • after the switch, each wallet uses only its own lots;
  • all unsettled choices live in a new [per_wallet] config section;
  • JP rejects -w.

Several bugs in the earlier version are fixed, including sales that exhaust a lot, #149, income counted twice on transferred lots, and FIFO by arrival instead of acquisition date. There are property tests (hypothesis) for balance and basis conservation, and all CI checks are green.

I'm filing the remaining items as separate issues: reports showing the wallet (1099-DA), Form 8949 covered/noncovered boxes, specific unit allocation, Japanese averaging methods, gifts and inheritance, wrapped tokens, and a few smaller ones.

Ready for your review whenever you have time.

Here are the issues -> #159 #160 #161 #162 #163 #164 #165 #166 #167

Drafted with Claude Code

@eprbell

eprbell commented Sep 27, 2026

Copy link
Copy Markdown
Owner

OK, I've started reviewing: it's a lot of code, so I expect this will take a while and probably require a few iterations.

@macanudo527

Copy link
Copy Markdown
Collaborator Author

@eprbell I understand. I tried to keep the scope tight, but Opus found a lot it had issues with. I asked it to document everything and make the code easier to read. I'm not sure how much refactoring to do. I didn't want to completely rewrite everything and make it more difficult to review.

Anyway, I thought if we could start a little early, we could get it done by the end of the year.

@eprbell

eprbell commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

No, it's good. We may as well use this opportunity to do a full review of the RP2 code base: AI makes it manageable to find issues and to review large diffs. I'm reviewing both manually and with Astra + Opus 5.5.

@eprbell

eprbell commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

Here's the first round of review: it incorporates my own comments with Astra's and Fable 5.1's. The content is LLM-friendly (it has conventions on how to address findings, detailed operational instructions, etc.), but it should be relatively easy to read for humans as well (if a bit dry: it partially follows ASD-STE100). I will keep comments in a structured, append-only format, so it's easy to verify them against future versions of the PR (to avoid regressions).


I recommend changes before merge. The current implementation has confirmed inventory and report failures. It also needs country policy, configuration, documentation, and test changes.

This review applies to commit 5e6ab10b2156c984435f382065778cfe2ec9b15c. The base is PR 138 at 046dfe9aaecb0857c72f5fdb454d5ba09bd7c740. The main comparison is eb2a36853db5094011d664cbe498a59b50dd44cd. All examples below use synthetic data. The full test run passed 182 tests and 199 subtests. Additional checks reproduced failures outside that coverage.

This review was prepared with LLM assistance. The reported calculation failures were reproduced with synthetic data. Other findings were checked against code, tests, documentation, or primary sources.

Architecture and scope

PR 155 builds directly on PR 138 and corrects FIFO ordering to use original acquisition dates, consistent with §1.1012-1(j)(1). Its single chronological pass handles transfers and disposals together, and artificial destination lots do not create new income. It preserves purchase fees in lot basis and corrects the US calendar holding-period rule, subject to the date-policy and compatibility changes below.

Keep the three computation phases: universal calculation before the switch, opening allocation, and per-wallet calculation after the switch. Keep UnusedBasisAllocator, subject to the fixes below. Unused universal basis can differ from the lots physically associated with each purchase wallet. The replacement allocator therefore has a valid purpose. The switch does not reset acquisition dates or basis.

Implement per-wallet support for the US first. Keep the framework suitable for other countries. Keep allocation input simple and add the allocation output report. Include consistent time zone handling. Both computation paths must use shared implementations of equivalent lot-consumption and pairing rules, as specified in R13.

Keep related repairs in this PR. Use separate commits and tests to make independent behavior changes easy to review. I will confirm the input and behavior decisions for R07, R08, and R18 in this PR thread before you implement those repairs. Until then, do not infer a new input format, change historical lot ordering, or implement negative-STAKING tax treatment.

P1 means high priority. P2 means normal priority. All rows marked “In this PR” are requested before merge; R16 records supported scope.

ID Priority Scope Required result
R01 P1 In this PR Country policy replaces the -w flag.
R02 P1 In this PR Remove fee carryover; preserve MOVE disposal details.
R03 P1 In this PR Allocated lots survive transfer cycles.
R04 P1 In this PR Reports show actual remaining basis per wallet.
R05 P1 In this PR, under R02 No phantom basis or report failure after fee handling.
R06 P2 In this PR Consistent tax dates and unambiguous time zones.
R07 P1 In this PR; input design required Identify broker-withheld exchange units correctly.
R08 P2 In this PR; method contract required State and test price-versus-basis ordering.
R09 P2 In this PR Zero-receipt transfers do not create empty destinations.
R10 P2 In this PR Simple allocation input and a complete allocation report.
R11 P1 In this PR Preserve useful old tests and cover the complete feature.
R12 P2 In this PR Country-neutral, accurate documentation.
R13 P2 In this PR Shared matching rules and maintainable interfaces.
R14 P2 In this PR Remove unused code after preserving useful tests.
R15 P2 In this PR Tested US holding-period fix and preserved non-US threshold.
R16 — Scope decision One transfer/disposal selection method within each year.
R17 P2 In this PR Reports identify and link original source lots.
R18 P2 In this PR; behavior decision required Negative STAKING has a defined validation/computation contract.

R01 — In this PR: replace -w with country-owned application policy.

The CLI permits US 2025 calculations to use universal application when -w is omitted. It emits a warning and ignores the per-wallet configuration. A required mode must not depend on an optional flag.

Remove -w. Replace the switch-year-only country hook with a policy that defines allowed and default modes by effective year. Put country-specific choices in the INI file. Use [country.us] for the US section. Validate choices before computation. If only one mode is permitted, select it automatically and reject an explicit conflicting choice.

Document that this first implementation supports a universal history through 2024 and per-wallet application from 2025. A per-wallet history before 2025 is outside this initial path. Other countries retain their supported behavior. They must not acquire US settings or transition rules by accident.

Acceptance: test the normal CLI without -w, conflicting modes, missing required data, country isolation, effective-year boundaries, and invalid INI choices. US data from 2025 must not silently use universal calculation. The legal basis for wallet/account identification is 26 CFR §1.1012-1(j).

R02 — In this PR: remove US basis_carryover and preserve MOVE details.

The new choice suppresses disposal of fee units and changes later-sale basis. It is not just a report classification. IRS answers A81 and A97 treat cryptocurrency paid for transfer services as disposed of. The expense's deduction or capitalization is a separate question. IRS digital-asset FAQs

Example: buy one unit for $100. Transfer it when its value is $200 per unit. Pay 0.1 unit as the fee. Sell the received 0.9 unit for $270. Existing disposal behavior reports MOVE value $20, basis $10, and gain $10. The later sale has basis $90 and gain $180. Carryover removes the MOVE row and changes sale basis to $100. If there are no other expenses, the Investment Expenses sheet disappears.

Remove TransferFeeTreatment, _get_transfer_fee_treatment(), the carryover branches in _process_fee_pieces() and _deliver_received_piece(), the carryover self-transfer error, and the fee exclusion from taxable events. Remove the INI field and obsolete parameter, parser, import, comment, and documentation support. Simplify the remaining code. Preserve ordinary fee disposal and cycle handling. Evaluate affected tests under R11/R14 instead of deleting useful fee scenarios.

Acceptance: preserve fee quantity, source-lot details, market value, basis, and gain/loss in MOVE output. Verify the example in actual report cells. Keep the report usable by tax professionals. Do not infer deductibility from the sheet name or try to resolve every expense-law question in this PR.

R05 — Related report failures and regression checks

Under carryover, buy 10 units for $1,000; send 10 and receive nine after a one-unit fee. Sell one. The engine assigns $111.111… basis, so the remaining eight units have $888.888… under that mode. The report shows $900. Selling all nine instead leaves $100 of phantom reported basis and raises KeyError: 'B1'.

Remove the carryover path under R02 and verify the surviving disposal behavior.

Acceptance under disposal: the fee consumes $100 basis. Selling one received unit leaves eight units with $800 basis. Selling all nine received units leaves zero quantity and zero basis, with no report error. Check original-lot sold percentages across artificial descendants. The existing report test checks a percentage of 1; add a partly consumed original lot with remaining basis.

R03 — In this PR: fix opening allocation followed by a transfer cycle.

Reproduction: buy 10 units in wallet A in 2024. Allocate at the switch. Transfer four units A → B in 2025, then return four B → A. There are no fees. The calculation fails with returned amount exceeds its crypto_in: 14 > 10.

The ancestor walk stores ancestors by Account. It records the active allocated lot, then overwrites it with the historical root for the same account. Cycle restoration treats that root as live inventory. Its missing remaining-quantity entry defaults to 10, then the return adds four.

Separate live inventory identity from historical source links. Restore quantities only to active lots. Stopping live-cycle traversal at the allocation boundary is one possible design. Keep from_lot on allocated lots: original_lot, acquisition dates, and reports depend on it.

Acceptance: the example ends with 10 units in A and zero in B. Preserve total basis and original dates. Add partial returns, intervening sales, fees, multiple hops, split allocations, and all supported selection methods. Add the example as a deterministic regression test and include cycles in generated histories that cross the switch.

R04 — In this PR: report each wallet's actual remaining basis.

Reproduction: in 2025, buy one unit for $100 in A and one for $1,000 in B. With no other activity, Asset - Exchange reports $550 basis for each wallet. Expected: $100 for A and $1,000 for B.

The engine returns original input data. The existing open-positions report distributes global average basis. That inherited algorithm does not provide per-wallet basis.

Carry the required wallet-lot state through the computation/report interface. Keep original input transactions available for source references. Reports need the remaining quantities, basis, and original acquisition dates at their reporting date. Do not substitute a global average or a final inventory snapshot from after that date.

Acceptance: inspect actual ODS cells for the example, partial sales, complete sales, transfers, and date filters. Reconcile wallet totals with asset totals. Preserve universal reporting behavior outside the new policy.

R06 — In this PR: use consistent tax dates and strict time zone resolution.

With America/New_York configured, 2025-12-31T23:30:00-08:00 falls in 2026. The engine accepts 2025 as the method year. _check_tax_year_boundary() checks only the initial switch. The documentation promises broader checking.

gettz() also accepts host-dependent names. A PST value can resolve to the host's local zone. CST can produce different switch instants under different host settings.

Derive tax dates and years from one configured policy. Apply it to the switch, annual method selection, report years, date filters, and calendar-date holding-period inputs. Preserve original timestamps. Keep transaction ordering based on absolute instants. Apply this policy to both computation phases where the configuration applies. Preserve other countries' behavior where no new zone policy applies.

Prefer zoneinfo.ZoneInfo. Accept only IANA zone identifiers, for example America/New_York or UTC. Reject host-local aliases and abbreviation-only inputs such as PST and CST. Handle unknown zones and missing data clearly. The module needs system IANA data or tzdata; this matters on Windows. Python documentation

Acceptance: test the example, both sides of year boundaries, differing source offsets, daylight-saving changes, report filters, and different host zones. Check supported platforms. Document intended historical-result changes. Include the resolved zone and transition instant in R10's report metadata. Use the same date policy for R15's holding-period calculation.

R07 — In this PR: identify broker-withheld exchange units correctly.

For the specified broker withholding from digital assets received in a crypto-to-crypto exchange, the received-unit identification rule overrides normal lot selection. A cash purchase with a crypto fee is not automatically this case. 26 CFR §1.1012-1(h)(1)(iv) and (j)(3)(iii)

The parser creates an ordinary FEE disposal from an acquisition's crypto fee. A synthetic input with an older lot at $100, received units at $200, and 0.1 unit withheld selects the older lot under FIFO. It reports $10 basis and $10 gain. The special rule identifies the received units. IRS A98–A99 also describe their gain and transaction-cost treatment. IRS FAQs

Define explicit input information that links the exchange legs and identifies broker withholding from received units. Ordinary SELL plus BUY entries do not preserve that relationship. Implement selection of the received units and the corresponding expense allocation. Keep ordinary BUY fees on their existing path. Explain the input extension before implementation; do not infer this special case from every acquisition fee. A limitation notice alone does not repair the calculation.

Acceptance: distinguish cash purchases, ordinary fee payments, and the specified withholding case. Test parser-to-report results and the linked exchange's cost allocation. The example above shows wrong lot selection. Tests must also verify the expense adjustments for the linked exchange.

R08 — In this PR: define and test HIFO/LOFO ordering.

HIFO ranks by spot_price. LOFO uses the corresponding reverse choice. Example: lot A costs $100 plus a $100 purchase fee, for $200 basis. Lot B costs $150 with no fee. HIFO selects B. Opening allocation inherits this ordering.

Define the intended ordering rule before changing the sort key. Purchase-price ordering can be valid, but must not be described as adjusted-basis ordering. If the contract changes to adjusted unit basis, include the correction with a defined policy for historical selections. Acceptance: fee-bearing acquisitions distinguish price from basis; allocation and disposal each follow their documented method. Do not silently change previously established allocations.

R09 — In this PR: preserve zero-receipt transfers without creating empty destinations.

An INTRA with one unit sent and zero received passes input validation. Universal calculation accepts it. Per-wallet calculation processes the fee, creates a destination with no acquisitions, then raises IN transaction set is empty.

Create destination inventory only when units are received, or use an equivalent local repair. Do not reject this existing input through shared IntraTransaction validation.

Acceptance: consume the fee from the source wallet, preserve MOVE details, create no spurious destination lot, and generate reports successfully. Cover a previously unused destination and an existing destination. Preserve universal behavior.

R10 — In this PR: keep allocation input simple and add an opening-allocation report.

The current PR reads unused_basis_allocation_method and unused_basis_allocation_wallet_order from [per_wallet], with asset-specific overrides. Move these settings under the country configuration in R01. They are distinct from annual disposal/transfer selection.

Keep the ordered-wallet rule. For each asset, derive unused universal lots and physical wallet balances independently from historical transactions. Sort the unused lots by the selected rule. Fill each wallet to its opening balance, splitting lots as required. Preserve each piece's source ID, quantity, basis, and original acquisition date. Do not add fake purchases or real transfers to represent opening allocation.

One funded wallet can be allocated automatically. Multiple funded wallets need a complete order and method. Preserve validation for unknown or missing funded wallets, duplicate entries, negative balances, and quantity mismatch. Known wallets with zero opening balance can remain in the order and receive no units. Preserve per-asset overrides and deterministic tie handling.

Do not add a manual fragment importer, separate allocation input file, or configuration-locking mechanism. Document the limit: an established allocation is supported only if this rule can reproduce it. Specific-unit imports and prior per-wallet history are outside this initial path.

Add an output report, preferably an ODS sheet. Capture the actual opening allocation before later activity changes inventory.

Required allocation-report fields
  • Pre-allocation wallet quantities and unused source lots, including their basis and original dates.
  • One row per allocated source-lot portion: asset, original source ID/date, destination wallet, quantity, and basis.
  • Effective allocation method, wallet order, per-asset rule, and transition instant under R06's resolved date policy. Identify automatic single-wallet allocation.
  • Totals that reconcile allocated quantities and basis with the unused pool, and wallet quantities with opening balances.

Allocation currently runs only if post-switch transactions exist. A 2025 report with only a 2024 purchase skips it. Run allocation when the requested period reaches the transition and opening holdings need it. Later trading must not be required. A report ending before the switch must not require future allocation settings. Do not base this choice on today's machine date.

Acceptance: test all four allocation methods, split lots, multiple wallets/assets, overrides, zero balances, absent later activity, and a report ending before the switch. Check actual report cells. Later sales must not change the recorded opening allocation.

Revenue Procedure 2024-28 requires records of remaining wallet units and unused basis. Original acquisition dates remain paired with basis. Its global rule must reflect a timely established choice. A new configuration or report cannot prove that historical requirement. The report format is a product choice. Revenue Procedure 2024-28, §§3.03, 4.02 and 5.02

The global rule's pre-2025 record deadline differs from allocation completion under §5.02(4)–(5). State the applicable rule accurately. The safe harbor does not validate the amount of unused basis (§5.04). Preserve reconciliation errors. Include transactions through the actual end of December 31.

R11 — In this PR: preserve old coverage and add tests of the complete feature.

test_global_allocation.py has not been ported to the active allocator. Its helper still creates GlobalAllocator. There are eight success families across FIFO/LIFO/HIFO/LOFO, plus three failure families across those methods. New tests provide partial overlap. They do not replace the full opening-allocation matrix.

Use this migration map before deleting those tests.

Allocator-test migration map
Old scenario Required treatment on the active path
Simple allocation Cover all four methods; inspect unsold opening pieces as well as later sales.
Unsorted inputs and changed wallet order Preserve lot ordering, wallet ordering, deterministic ties, and equivalent-input checks.
Mixed IN/INTRA/OUT history Rebuild unused universal lots and physical balances independently; include fees.
Larger mixed history Preserve multiple wallets, partial depletion, split lots, fees, and returned funds.
Interlaced acquisitions and transfers Preserve account identity, exhausted wallets, source dates, and basis.
Two lots and three transfers Preserve split fragments, fee depletion, holder identity, and totals.
Reciprocal transfers Feed the history into allocation, then add an allocation-followed-by-cycle case.
Reciprocal transfers plus sales Check pre-switch depletion, opening pieces, later disposals, and reports.
Empty input Replace prototype-specific exceptions with the active empty/no-funded-wallet contract.
Missing wallet in supplied order Reject omission of a funded wallet; a missing whole rule is a different case.
Extra wallet in order Reject unknown wallets; accept known zero-balance wallets without allocating units.

Recompute expectations from unused universal basis. Do not copy the prototype's per-wallet remaining quantities, dummy prices, synthetic-INTRA strings, or artificial-ID sequence. Preserve economic behavior and useful invariants.

Add end-to-end generated histories through compute_tax_per_wallet that cross the switch and later year boundaries. Include timestamp ties, acquisition fees, partial/complete exhaustion, transfers and returns. Verify quantities, basis, source dates, and report output. Analyzer-only quantity checks are insufficient.

Add a per-wallet golden dataset with opening allocation, transfer fees, transfers, a cycle after allocation, and an annual method change. Check cells and formulas, including R04/R10/R17 output. Add the country/CLI, zone, and failure cases in the other findings. File existence alone is insufficient.

The ten ODS files changed against PR 138 are byte-identical to those at the main comparison commit. They belong to the earlier LOFO correction. Their changed assignments and totals agree with an independent replay. They add no per-wallet golden coverage. A comparison between two modes on this new head also does not prove compatibility with an older release.

Acceptance: replacements exercise the production path and pass before the old implementation is removed. Record the disposition of each affected scenario/assertion under R14. Run the relevant full tests and required static checks after implementation. Report the exact commit and checks; do not infer complete coverage from a passing count.

R12 — In this PR: keep general documentation country-neutral and correct stale text.

General documentation should describe RP2 behavior, configuration, inputs, and limits. Remove unrelated US tax discussions about wages, gifts, inheritance, donations, losses, and derivatives. Put necessary US support information in concise country documentation. Avoid maintaining a broad tax manual or scattered US regulatory links in general pages.

Update README.dev's universal-only description. Remove duplicate CLAUDE.md guidance. If an agent entry file is needed, use a neutral pointer to existing distribution files. Its current text also has stale Python-version, holding-period, precision, and source-link claims. The project requires Python 3.10+. Comparison precision is not blanket truncation of stored values.

Specific corrections in the proposed FAQ text:

  • Q64 on the cited digital-asset FAQ concerns exchanges, not theft. That page has no Q25. Q25 on the older virtual-currency FAQ concerns basis after a hard fork. Digital-asset FAQ, older FAQ.
  • The inheritance holding-period reference is §1223(9), not §1223(11). IRS discussion.
  • The stated 2025 end date for the personal casualty/theft limitation is obsolete. This is not a conclusion that every cryptocurrency loss is nondeductible. IRS Publication 225.
  • The claim that all IN amounts must be positive is false for STAKING construction. R18 also shows that construction does not establish downstream support.

Remove the unrelated material rather than expanding it to cover every exception. Keep necessary remaining claims precise.

Acceptance: docs, help text, examples, and validation messages agree with the implemented country policy, fee behavior, time zones, and supported inputs. Remove obsolete flags and settings from examples.

Describe RP2's (exchange, holder) wallet identity and its limits. For necessary legal citations in country documentation, include the source date and verification date. Check that each rule applies to the stated period.

R13 — In this PR: apply the design guidelines and share equivalent operations.

Follow the design guidelines. The changes below address specific interface and maintenance problems:

  • Copy caller-owned configuration dictionaries and expose immutable views. A frozen dataclass does not make its dictionaries immutable. Apply the required runtime checks to public inputs.
  • Move substantive PerWalletTransactions into its own module. The guideline's exception for trivial supporting classes still applies.
  • Replace method-object identity keys with stable identifiers or explicit value semantics. Avoid exposing mutable internal collections without a defined contract.
  • Move shared method-conversion policy out of TransferAnalyzer. Allocation must not depend on an unrelated transfer helper.
  • Resolve each effective allocation method once. Remove the possibility of disagreement between configuration text and a separately passed method object.
  • Share equivalent balance arithmetic between _get_account_balances() and BalanceSet. Preserve date limits, negative-balance validation, and acquired/sent/received totals.
  • Use meaningful fields for acquisition-date sort keys. Do not store an acquisition timestamp in a field named spot_price. No numerical tax error from that conversion has been proved.
  • Use named compound results where required. Keep lint exceptions local and justified, including the new broad signature/TODO suppressions.

The universal loop and PerWalletTransactions.take() both consume portions of lots. They already share accounting-method infrastructure and GainLoss. Extract the equivalent lot-consumption and pairing rules into shared code used by both paths. This includes partial consumption, exhausted-lot handling, remaining-quantity updates, and consumed-portion results. Do not maintain two copies of equivalent rules that must be updated by hand.

Pass each path's permitted lot pool and accounting method to the shared operations. Universal application can select across accounts; per-wallet application must constrain selection to the affected wallet. Keep transfer delivery, source links, and cycle restoration in the per-wallet orchestration. Preserve accounting-method plugin contracts and transaction ordering.

Keep original input, historical source links, live wallet inventory, opening-allocation records, country policy, and report views distinct. R03/R04/R17 show the consequences of mixing those roles. Exact private APIs are implementation choices.

Acceptance: exercise the shared operations through both computation paths. For inputs where the same lots are eligible, verify equal consumed quantities, basis, and remaining balances. Separately test cases where wallet restrictions must change the result. Cover partial lots, exhausted lots, multiple lots, fees, and method changes. Mutating an input dictionary after configuration construction must not change validated behavior. Shared arithmetic must preserve existing balance validation. Run the required type and static checks.

Isolate the shared-code refactor from tax-rule changes in its own commit. Compare universal calculation and report results before and after that refactor, including pre-2025 histories and existing golden files. The refactor must preserve those results. Check intentional changes under R06/R15 in separate commits and tests. A comparison between the two new paths alone cannot detect a regression shared by both.

R14 — In this PR: remove verified unused code after preserving coverage.

Remove GlobalAllocator after migrating useful scenarios. Remove dedicated obsolete flags, wrappers, imports, configuration, and documentation. Review skip_transfer_pointers, use_local_artificial_ids, reset_partial_amounts(), the unused public partial-amount getter, BalanceSet.account_to_balance, and the old analyze() test wrapper. Check dynamic/plugin use before deletion.

The documentation workflow uses lychee and lychee.toml. Remove obsolete .markdown-link-check.json after the repository reference check.

For every affected test or assertion, record: protected behavior; preserve/adapt, extend, or remove; reason; and replacement or surviving test. Delete an assertion only when its behavior is deliberately removed, its representation is obsolete, or named coverage makes it redundant. Keep useful invariants. Passing tests of dead code do not preserve production coverage.

Preserve the behavior of get_acquired_lot_for_timestamp(): get_acquired_lot_for_taxable_event() still calls it. Folding its implementation into that caller is an optional interface change. Its partial-amount backing data also remains active. The constructor's fallback method and abstract newer-to-older plugin path are not proved unreachable.

Acceptance: replacement tests pass against the active implementation before removal. Explain each removed behavior and confirm that no production/plugin caller was lost. Do not retain unused implementations for hypothetical future use.

R15 — In this PR: test the US holding-period correction and preserve the non-US threshold.

Keep the valid US calendar-anniversary correction. It changes some pre-2025 results, so isolate it in a commit with boundary tests and release notes. Preserve original acquisition dates through allocation and transfers. IRS holding-period guidance

The default country comparison also changes >= to >. At exactly 365 days, the checked ES and generic cases change from long-term to short-term. Restore the prior threshold unless a country-specific change is justified and tested. This is a demonstrated behavior change, not a legal conclusion about Spain.

Correct the PR's compatibility claim. Other countries still use day counts, but their threshold results changed. “Pre-2025 unchanged” cannot mean equivalence with old releases when the US correction changes them.

Acceptance: test the acquisition anniversary, the following day, leap-year boundaries, historical US results, and the exact non-US threshold. Compare against the prior implementation where compatibility is claimed. Keep calendar-date conversion under R06 distinct from the holding-period rule.

R16 — Accepted scope: keep one transfer/disposal method within each year.

The maintainer already accepted this scope in issue 135. Independent transfer selection is not requested in this PR. Keep the shared annual method, document it, and test annual changes.

Keep four concepts distinct: application mode, opening allocation, transfer selection, and disposal selection. The last two share the supported annual setting. That product restriction does not remove applicable identification or recordkeeping duties.

R17 — In this PR: restore original-lot traceability in the full report.

Reproduction: buy two units on Coinbase, transfer both to Kraken, then sell one. The acquired-lot cells show t1/-1 and no HYPERLINK formulas. Taxable-event cells have links. The original acquisition date is correct.

The report builds its row map from original input transactions, then looks up an artificial acquisition. No input row matches that artificial ID. Use original_lot for the source ID and source link. Also show the affected wallet and consumed portion. Preserve that portion's actual quantity and basis; do not replace them with whole-source-lot values.

If a report filter excludes the source row, show a stable original ID/reference without a broken hyperlink. Keep artificial IDs only as additional internal trace information if useful. Minimum source traceability is required for the supported per-wallet report. It does not require a complete 1099-DA reconciliation feature.

Acceptance: inspect cells and formulas for direct acquisitions, allocated lots, split transfers, multiple hops, cycles, and filtered reports. R04 separately checks remaining wallet positions. These report fixes can share infrastructure, but their immediate causes differ.

R18 — In this PR: define negative-STAKING behavior and remove the late failure.

A ten-unit purchase followed by a STAKING amount of minus one constructs successfully. compute_tax then raises Parameter 'taxable_event_amount' has non-positive value -1. This occurs on both the reviewed PR and the main comparison commit. The PR's per-wallet entry point also fails for that history.

Define whether negative STAKING is a supported input. If it is unsupported, reject it during input validation with a clear explanation and document that limit. If it is supported, specify its inventory, basis, income, and report effects before implementing the computation. Constructor acceptance alone must not imply complete support. An earlier error is a validation repair, not support for slashing.

Acceptance: cover the selected contract through parsing, validation, computation, and reports as applicable. Preserve positive-STAKING behavior. Correct the misleading validation/support text under R12 and retain the example as a regression test.

Privacy, sources, and verification limits

No added runtime transmission of private data or credential leak was demonstrated in the examined code and diff. This is not a complete dependency security audit. Logs and errors can contain transaction details and account/holder identifiers. Use synthetic public examples. Shared diagnostics should support removal of private values; keep useful local diagnostics available to the owner.

The legal review uses Treasury regulations, Revenue Procedure 2024-28, Notices 2025-7 and 2026-20, and current IRS FAQs. Those sources have different authority levels. The notices provide temporary broker-identification relief through 2026 under stated conditions. They do not postpone wallet tracking or replace safe-harbor requirements. A later RP2 run cannot establish that a historical identification was made on time. Notice 2025-7, Notice 2026-20.

In your reply, please address each R-number and name the commit that fixes it, so I can re-check every item on the new head.

claude added 2 commits October 5, 2026 10:55
Review item R01 (eprbell#155). A mode the law requires can't depend on
an optional flag: without -w, US data from 2025 silently used universal
application (with only a warning). Treas. Reg. 1.1012-1(j)(6) applies
per-wallet identification "to all acquisitions and dispositions of digital
assets on or after January 1, 2025".

- New ApplicationPolicy (application_policy.py): per policy period, the
  allowed application modes and the default. AbstractCountry exposes it via
  get_application_policy(), replacing get_per_wallet_application_start_year().
  Default: universal in every year. US: universal until 2024, per-wallet from
  2025, one allowed mode per period.
- -w is removed. rp2_main resolves each asset's mode for every year with
  transactions before computing anything. US data from 2025 without the
  per-wallet settings is an error, never a fallback to universal.
- Country choices move to [country.<code>] (e.g. [country.us]), replacing
  [per_wallet] (now an error that points to the new section). The optional
  application_mode is checked against every year with transactions: a value a
  year doesn't allow is rejected. A section for another country, and
  per-wallet fields for a universal-only country (JP, ES, IE, generic), are
  rejected so that no country gets US settings by accident.
- compute_tax_per_wallet() takes the switch year and checks it against the
  country's policy.
- Docs: no flag; per-wallet application is selected automatically; this
  first implementation supports a universal history through 2024 followed
  by per-wallet application from 2025.

Tests: policy unit tests (year boundaries, explicit choices, switch year,
invalid policies, universal-only countries); configuration tests (country
section, other country's section, old section, invalid mode, JP fields);
CLI tests (no -w, -w rejected, pre-2025 output unchanged with the section,
2025 data with and without the section, conflicting modes, JP isolation);
engine rejects switch years the policy doesn't allow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZgEJzUPyGHYd2xoXqcp5R
pylint 4.1.2 (astroid 4.3) reports arguments-differ on every RP2Decimal
operator, because Decimal's C implementation also accepts an optional
context argument. The overrides are only called as operators, so this is
the same false positive the file already disables as signature-differs.
CI installs the latest pylint, so this broke Static Analysis independently
of the code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZgEJzUPyGHYd2xoXqcp5R
@macanudo527

macanudo527 commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator Author

@eprbell thanks for the detailed review. I'm working through it one R-number at a time, each in its own commit with its own tests, and I'll post another comment as items land so you can re-check each one on the new head. This will hopefully keep things logically separated and make it easier to focus on reviewing one particular thing at a time.

Status on b1b8059

ID Status Commit
R01 Done 8b9e4d4
R02, R05 Next —
R03, R04, R06, R09–R15, R17 Not started —
R07, R08, R18 Waiting for your decisions on input design, ordering contract and negative STAKING (no code until then) —
R16 Accepted scope: one annual method for transfers and disposals —

R01: replace -w with a country-owned application policy (8b9e4d4)

  • -w is removed. Each country declares an ApplicationPolicy: the application modes allowed in each period, plus a default (AbstractCountry.get_application_policy(), which replaces the year-only get_per_wallet_application_start_year()).
    • Default policy: universal application in every year.
    • US: universal through 2024, per-wallet from 2025, with exactly one allowed mode in each period. Legal basis: Treas. Reg. §1.1012-1(j)(6) applies paragraph (j) "to all acquisitions and dispositions of digital assets on or after January 1, 2025".
  • Country choices are in [country.<code>] ([country.us] for the US). The old [per_wallet] section is now an error that names the new one.
    • The optional application_mode is checked against every year that has transactions: a value that any such year doesn't allow is rejected.
    • When a period allows only one mode, that mode is selected automatically.
  • Validation runs before computation. rp2_main resolves the mode of every asset first, then computes. US data from 2025 without the per-wallet settings (at least timezone) is an error, never a fallback to universal.
  • Country isolation: a section for another country is an error, and so are per-wallet fields for a universal-only country (JP, ES, IE, generic).
  • compute_tax_per_wallet() takes the switch year and checks it against the country's policy.
  • Docs state the supported path: a universal history through 2024, then per-wallet application from 2025. A per-wallet history before 2025 isn't supported yet.

Tests, which cover the acceptance list:

  • test_application_policy.py: effective-year boundaries, explicit choices, switch year, invalid policies, and universal-only countries.
  • test_configuration.py::test_country_section: invalid INI choices, another country's section, the old section, and JP per-wallet fields.
  • test_ods_output_diff_per_wallet.py (CLI):
    • the normal CLI without -w, and -w rejected;
    • pre-2025 output unchanged with the country section;
    • 2025 data with and without the required settings;
    • conflicting modes;
    • JP isolation.
  • test_per_wallet_tax_engine.py: the engine rejects switch years that the policy doesn't allow.

Checks on 8b9e4d4:

  • full suite: 193 tests, 205 subtests;
  • mypy, pylint 10.00, flake8 and bandit clean.

CI fix (6bc0a77): the Static Analysis job installs the latest pylint, and 4.1.2 (astroid 4.3) now reports arguments-differ on every RP2Decimal operator override. That failure is independent of this PR: Decimal's C implementation also accepts an optional context argument. It's the same false positive the file already disables as signature-differs, so I added arguments-differ to that disable. Checked with both pylint 4.0.9 and 4.1.2.

Link check (524fb38, b1b8059): Documentation Check reported 5 errors, all in links that predate this PR:

Following the existing pattern for bot-blocking domains, lychee.toml now excludes deepwiki.com. It also excludes www.ecfr.gov. eCFR redirects automated clients to unblock.federalregister.gov, so the checker reported the new Treasury Regulation links as redirects that it can't actually verify.

Unrelated finding: rp2_jp with its default generation language (ja) fails because template_open_positions_ja.ods doesn't exist. This happens outside this PR, so I'll open a separate issue for it rather than change it here.

claude added 3 commits October 5, 2026 12:38
eCFR redirects non-browser clients to unblock.federalregister.gov
("programmatic access to these sites is limited to access to our extensive
developer APIs"), so the Treasury Regulation links added to the docs fail the
Documentation Check although they work in a browser. Same treatment as the
other bot-blocking domains already excluded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZgEJzUPyGHYd2xoXqcp5R
The Documentation Check reported 5 errors, all in links that predate this
PR:
- CHANGELOG.md: taxbit.com/cryptocurrency-tax-guide returns 404; the guide
  is now at www.taxbit.com/blogs/cryptocurrency-tax-guide;
- README.md, README.dev.md: deepwiki.com returns 429 Too Many Requests to
  the link checker, so it's excluded like the other domains that block
  bots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZgEJzUPyGHYd2xoXqcp5R
Review item R02 (eprbell#155). basis_carryover suppressed the disposal of
the fee units and changed the basis of later sales. The IRS digital-asset
FAQs (added Dec. 15, 2025) settle the question: A81, a transfer between
one's own wallets is non-taxable "except to the extent of any digital
assets you use, or are withheld, to pay for transaction services to effect
the transfer"; A97, those units are disposed of and gain or loss is
recognized; A53, amounts paid to transfer between one's own wallets "are
not treated as digital asset transaction costs" (so they can't be added to
the basis of the received units).

- Removed TransferFeeTreatment, _get_transfer_fee_treatment(), the
  carryover branches of TransferAnalyzer (fee pieces, received pieces,
  extra basis on artificial lots), the carryover self-transfer error, the
  fee exclusion from taxable events, the transfer_fee_treatment config
  field (now an invalid field) and its docs. A transfer fee is always a
  disposal; cycle handling (A->B->A) no longer depends on the treatment.

Acceptance (report cells, tax_report_us): buy 1 unit for $100, transfer it
at $200/unit paying 0.1 unit, sell the 0.9 received for $270: Investment
Expenses has the MOVE row (0.1, proceeds $20, basis $10, gain $10, source
lot i1, transfer t1); Capital Gains has the sale (0.9, $270, basis $90,
gain $180). Before the change, basis_carryover dropped the MOVE row and set
the sale basis to $100, and a fee without the setting was an error.

Test dispositions:
- test_per_wallet_tax_engine "fee ... treated as a disposal": kept, no
  setting needed; asserts the received units keep their $900 basis.
- "fee ... basis carried over": removed (behavior deliberately removed).
- "fee without an explicit fee treatment: error": removed, now identical
  to the disposal case.
- "eprbell#149 fee spanning two lots", report test with artificial lots: kept,
  setting dropped.
- test_per_wallet_properties conservation: kept for the disposal path;
  every unit of every lot must now be held or disposed of with a gain/loss
  (the weaker carryover assertions are gone).
- test_configuration: transfer_fee_treatment is now rejected.
- New: test_transfer_fee_is_a_disposal_in_reports (report cells), with a
  read_sheet_rows() helper in tests/ods_diff.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZgEJzUPyGHYd2xoXqcp5R
@macanudo527

Copy link
Copy Markdown
Collaborator Author

@eprbell R02 is done in 53664aa. CI is green on that commit (Static Analysis, Unix and Windows Unit Tests). R05 is next.

  • Removed TransferFeeTreatment, _get_transfer_fee_treatment(), the carryover branches in TransferAnalyzer, the carryover self-transfer error, the fee exclusion from taxable events, and the transfer_fee_treatment INI field, which is now rejected as an invalid field. Its docs are gone too.
  • A transfer fee is always a disposal. Cycle handling no longer depends on a treatment setting.
  • Legal basis, from the current IRS digital-asset FAQs (added Dec. 15, 2025):
    • A81: a transfer between your own wallets is non-taxable "except to the extent of any digital assets you use, or are withheld, to pay for transaction services to effect the transfer";
    • A97: those units are disposed of, and gain or loss is recognized on them;
    • A53: amounts paid to transfer between your own wallets "are not treated as digital asset transaction costs", so they can't be added to the basis of the received units.
  • Acceptance: your example is checked in the actual tax_report_us cells by test_transfer_fee_is_a_disposal_in_reports.
    • Investment Expenses: the MOVE row shows 0.1 unit, proceeds $20, basis $10, gain $10, source lot i1 and transfer t1.
    • Capital Gains: the sale shows 0.9 unit, proceeds $270, basis $90, gain $180.
  • Test dispositions (also listed in the commit message):
  • Checks on 53664aa:
    • full suite: 194 tests, 203 subtests;
    • mypy, flake8 and bandit clean;
    • pylint 10.00 with both 4.0.9 and 4.1.2.

Adds test_transfer_fee_leaves_no_phantom_basis, which checks the
remaining basis in the engine and in the actual report cells (full
report Sent/Sold column, Open Positions by asset and by exchange)
after a transfer fee:

- buy 10 for $1,000, move them paying 1 as the fee, sell 1 of the 9
  received: 8 left with $800 of basis (the fee used up $100);
- sell all 9 received: no units and no basis left, all three reports
  are generated (the removed carryover option crashed Open Positions
  with KeyError: 'B1' here);
- a lot partly used up across wallets: 40% sold, 6 units left with
  $600 of basis (4 on Coinbase, 2 on Kraken);
- uneven amounts (3 units at $7, 0.1 fee): no rounding remainder.

On the commit before R02, with basis_carryover enabled, all four cases
fail, reproducing both bugs from the review. No engine change was
needed: R02 removed the code that caused them.

Also moves the expected gain/loss conversion into _want_tuples() so
_run_test() and the new test share it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZgEJzUPyGHYd2xoXqcp5R
@macanudo527

Copy link
Copy Markdown
Collaborator Author

@eprbell R05 is done in 170eec3. R03 is next.

What R05 checks, in plain English

When you move coins between your own wallets and pay the network fee in coins, those fee coins count as sold. They use up their own share of what you paid for them. The coins that arrive keep the rest of the original cost and the original purchase date. So once fees and sales are taken out, the cost the reports show must exactly match the coins you still hold.

Example: buy 10 coins for $1,000 ($100 each). Move them to another wallet, paying 1 coin as the fee, so 9 coins arrive.

  • The fee coin uses up $100 of cost, and the 9 coins that arrive carry the other $900.
  • Sell 1 of the 9: 8 coins are left, with $800 of cost.
  • Sell all 9: no coins and no cost are left, and every report is still generated.

The removed basis_carryover option got both wrong, as your review showed. It spread the fee coin's $100 over the 9 coins that arrived ($111.11 each), but Open Positions only subtracted the coins sold. So 8 coins showed $900 instead of $888.89, and selling all 9 left $100 of cost with no coins and crashed Open Positions with KeyError: 'B1'.

Legal basis (links for anyone checking)

  • IRS FAQs on digital asset transactions (all three answers added Dec. 15, 2025):
    • A81: a transfer between your own wallets is non-taxable "except to the extent of any digital assets you use, or are withheld, to pay for transaction services to effect the transfer".
    • A97: those fee units are "disposed of" and you "recognize gain or loss on the disposition".
    • A53: amounts paid to transfer between your own wallets "are not treated as digital asset transaction costs", so they aren't added to the cost of the coins that arrive.
  • Treas. Reg. §1.1012-1(j)(1): the cost and holding period of units sold, disposed of or transferred come from identifying those units in the wallet, and "the date any units were transferred into the taxpayer's wallet is disregarded". In other words, coins that arrive keep their original cost and purchase date.
  • IRC §1001(a): gain or loss is the amount realized minus the adjusted basis. For the fee coin: worth $150, cost $100, so a $50 gain.
  • IRC §1012(a): basis is cost. Each coin costs $100, whether it was used for the fee, sold, or is still held.

The test: test_transfer_fee_leaves_no_phantom_basis

Each case checks the gains and losses from the engine and the actual cells of all three reports. In every case, coins are bought at $100, the fee is paid when they're worth $150, and sales happen at $200.

Case Gains and losses "Sent/Sold" of the lot (full report) Open Positions
Sell 1 of the 9 received Fee: 1 coin, cost $100, gain $50. Sale: 1 coin, cost $100, gain $100. 0.2 8 coins, $100 each, $800 (all on Kraken)
Sell all 9 received Fee as above. Sale: 9 coins, cost $900, gain $900. 1.0 No B1 row, and every report is generated
Lot partly used up across wallets: move 6 of 10 (5 arrive), sell 3 Fee as above. Sale: 3 coins, cost $300, gain $300. 0.4 6 coins, $600: 4 on Coinbase ($400), 2 on Kraken ($200)
Uneven amounts: buy 3 at $7, move them paying a 0.1 fee, sell the 2.9 that arrive Fee: cost $0.70, gain $0.20. Sale: cost $20.30, gain $11.60. 1.0 No B1 row and no rounding leftover

The third case covers your request for a lot that is only partly used up and still has basis left. The original lot's sold fraction counts both the fee and the sale made from the wallet the coins were moved to. The existing report test only checked a fraction of 1.

Proof that the test catches the old bugs: I ran the same test on the commit before R02 with basis_carryover turned on. All 4 cases fail: the sale basis was $111.11 instead of $100, the sold fraction was 0.1 instead of 0.2, and Open Positions crashed with KeyError: 'B1'. On the current code all 4 pass. No engine change was needed, because R02 removed the code that caused the bugs.

Checks on 170eec3:

  • full suite: 195 tests, 207 subtests;
  • mypy, flake8 and bandit clean;
  • pylint 10.00 with both 4.0.9 and 4.1.2.

…witch purchase

Bug: buy 10 units in wallet A in 2024, allocate them at the switch, then
move 4 units A -> B and back in 2025. The calculation stopped with
"start-of-cycle transaction's returned amount exceeds its crypto_in:
14 > 10".

Cause: originates_from (the lot each earlier wallet held, used to
return units at the end of a round trip) was built by following
from_lot all the way back. Past a lot allocated at the switch, from_lot
leads to the input lot bought before the switch, which is in the same
wallet but no wallet holds after the switch. The walk overwrote the
allocated lot with that purchase, and the returned units were added to
it: its 10 units (it had no remaining-amount entry) plus 4.

Fix: originates_from of a new transfer lot is the lot the units leave
from plus that lot's own originates_from (nearest lot wins). Lots
created by the allocation have an empty originates_from, so the chain
stops there. from_lot is unchanged: original_lot, acquisition dates
and reports still follow it, and so does to_lots.

Tests:
- test_allocated_lots_survive_transfer_cycles: 9 cases x FIFO, LIFO,
  HIFO, LOFO, checking gain/losses and each wallet's final holdings
  (amount, basis, acquisition date per original lot): the review's
  example, partial return, a sale in between, fees on both legs,
  multiple hops, two round trips, a split allocation, two lots at
  different prices, and a long-term sale after the round trip. All 36
  subtests fail without the fix.
- test_invariants_hold_across_the_switch: property test on random
  histories that start in 2024, cross the switch with an allocation
  and often send funds back where they came from: wallet holdings equal
  balances, every unit and all basis is held or disposed of. It finds
  the bug without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZgEJzUPyGHYd2xoXqcp5R
@macanudo527

Copy link
Copy Markdown
Collaborator Author

@eprbell R03 is fixed in ec8d102. R04 is next.

What went wrong, in plain English

When coins come back to a wallet they were in before, RP2 puts them back into the lot they left from. This keeps the wallet's lot count right and keeps the coins' cost and date.

To find that lot, the code walked back through the coins' history (from_lot) and noted which lot each wallet had held. After the switch, that walk went one step too far.

Example (your reproduction): buy 10 coins in wallet A in 2024.

  • At the switch they are allocated to A as a new lot, X.
  • In 2025, 4 coins move A → B, then back B → A.
  • The walk from B's lot passed X (in A) and then reached the 2024 purchase, also in A, which replaced X as "the lot A held".
  • But after the switch no wallet holds the 2024 purchase. It's only history.
  • So the returning 4 coins were added to it: it counted as full (10), plus 4 made 14 out of 10, and the run stopped.

The fix

Each transfer lot now records the lots its coins actually sat in, wallet by wallet: the lot they just left, plus that lot's own record. Lots created by the allocation start with an empty record, so the chain stops at the allocation. It never reaches the pre-switch purchase. If a wallet shows up twice, the most recent lot wins.

from_lot is unchanged. original_lot, acquisition dates, reports and to_lots still follow it all the way back, as you asked. Two links with two jobs:

  • from_lot: where the cost basis came from (history);
  • originates_from: which lot each wallet actually holds (live inventory).

I added comments with this example in transfer_analyzer.py and in_transaction.py, and updated CLAUDE.md.

Why this is the right result (links for anyone checking)

  • IRS digital-asset FAQ A81: a transfer between your own wallets is non-taxable, except for coins paid as the fee. A97: those fee coins are disposed of, with gain or loss. So a round trip with no fee must leave the wallet exactly as it was.
  • Treas. Reg. §1.1012-1(j)(1): "the date any units were transferred into the taxpayer's wallet is disregarded". Coins keep their purchase date, and so their holding period, through any number of transfers.
  • Rev. Proc. 2024-28 §3.04: "the acquisition date of a unit of unused basis is the acquisition date of the digital asset unit to which the unit of unused basis was originally attached". §3.09: a unit of unused basis is "the original, per unit basis". So an allocated lot keeps the 2024 cost and date, and the coins returning to it keep them too.

Tests

test_allocated_lots_survive_transfer_cycles runs each case with FIFO, LIFO, HIFO and LOFO (36 subtests). It checks the gains and losses and what each wallet holds at the end: amount, cost and purchase date for each original purchase.

Case What each wallet holds at the end
Your example: 4 coins A → B and back A: 10 coins, $1,000, bought 2024-06-01
Partial return: 3 of the 4 come back A: 9 coins, $900; B: 1 coin, $100
A sale in between: 1 sold in B ($100 cost, $100 gain), 3 come back A: 9 coins, $900
Fees on both legs (0.1 coin each, $10 cost, $5 gain each) A: 9.8 coins, $980
Multiple hops: A → B → C → A A: 10 coins, $1,000
Two round trips in a row A: 10 coins, $1,000
Split allocation: one purchase allocated 6 to A and 4 to B, then B → A, all 10 to C and back, then 2 to B A: 8 coins, $800; B: 2 coins, $200
Two purchases at $100 and $300: all 10 to B and back A: 5 coins, $500 (June) and 5 coins, $1,500 (July), whatever order the method picks them in
Sale of all 10 after the round trip, on 2025-07-01 Long-term (more than a year after 2024-06-01), $1,000 cost, $1,000 gain

test_invariants_hold_across_the_switch is a property test on random histories over three wallets. Each history starts in 2024, crosses the switch with an allocation, and continues in 2025. Transfers often send coins back to the wallet they came from. It checks that:

  • each wallet holds exactly its balance;
  • every coin of every purchase is either still held or was disposed of, before or after the switch;
  • total cost is conserved.

Proof that the tests catch the bug: without the fix, all 36 subtests fail. The property test finds it too, with a 0.007-coin history ("returned amount exceeds its crypto_in: 0.007 > 0.006"). With the fix, everything passes.

Checks on ec8d102:

  • full suite: 197 tests, 243 subtests;
  • mypy, flake8 and bandit clean;
  • pylint 10.00 with both 4.0.9 and 4.1.2.

claude added 4 commits October 8, 2026 23:42
…ules

test_per_wallet_tax_engine.py was close to pylint's 1,000-line limit,
and the remaining review items add more tests. This commit only moves
code, with no change to any test or assertion:

- tests/per_wallet_common.py: short-form transactions (_In, _Out,
  _Intra), expected gain/losses, wallets, _allocation_config() and
  AbstractPerWalletTest (input data, accounting engine and gain/loss
  tuple helpers), shared by the two files below;
- tests/test_per_wallet_reports.py: the tests that check report cells
  (artificial lots, R02 transfer fee, R05 phantom basis);
- tests/test_per_wallet_tax_engine.py: the engine tests.

Same results before and after: 197 tests, 243 subtests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZgEJzUPyGHYd2xoXqcp5R
Bug: in 2025 buy 1 unit for $100 in Coinbase and 1 for $1,000 in
Kraken. The Asset - Exchange sheet showed $550 for each wallet: the
report spread the asset's total cost basis over all wallets in
proportion to their balances, which is how universal application
works, but in per-wallet application each wallet has its own lots
(IRC 1012(c)(1), Treas. Reg. 1.1012-1(j)).

Fix:
- TransferAnalyzer.analyze_and_pair(holdings_date) records what each
  wallet holds when its single chronological pass reaches the first
  transaction dated after holdings_date (same date rule as BalanceSet),
  as a list of WalletLot (new: wallet, lot, units held; cost basis,
  original acquisition date and original lot are derived from the lot).
- compute_tax_per_wallet() passes the configuration's to_date and
  stores the holdings in ComputedData.wallet_lots. If to_date is before
  the switch, wallet_lots is None and reports work as in universal
  application. compute_tax() is unchanged (None).
- ComputedData checks that the lots of every wallet add up to its
  balance at to_date (internal error otherwise).
- The open positions report uses wallet_lots when present: each
  wallet's balance and cost basis come from its lots, each holder's row
  is the sum of its wallets. Universal assets keep the exact same code
  and output.
- Account is now orderable (by exchange, then holder).

Tests (tests/test_per_wallet_reports.py, actual ODS cells):
test_open_positions_show_each_wallet_actual_cost_basis covers the
review's example, a partial sale, a complete sale, a transfer with a
fee, to date and from date filters, coins bought before 2025 (the
allocation at the switch decides each wallet's basis), a to date before
2025 and universal application. 5 of the 9 cases fail with the old
report; the others match the old output by design. The R03 tests and
the switch-crossing property test now read the holdings from
ComputedData.wallet_lots instead of patching the analyzer, and the
property test checks them at random report dates (it fails if the
final holdings are used instead).

Docs: docs/output_files.md, docs/user_faq.md, CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZgEJzUPyGHYd2xoXqcp5R
CI failed on a3abfe6 on Python 3.13 (macOS and Windows; the other jobs
were cancelled): 79 tests raised "TypeError: 'BalanceSetIterator' object
is not iterable" in the new ComputedData._check_wallet_lots().

BalanceSetIterator defined __next__() but not __iter__(). Plain for
loops don't need it, but from Python 3.13 list and dict comprehensions
call iter() on the iterator too, so the dict comprehension over the
BalanceSet failed. Python 3.10-3.12 accept it, which is why the local
3.11 run passed. BalanceSetIterator now derives from Iterator[Balance],
like EntrySetIterator, which provides __iter__().

Checked: full suite on Python 3.11, 3.13 and 3.14 (198 tests, 252
subtests each).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZgEJzUPyGHYd2xoXqcp5R
1. Don't stop the run when a wallet's lots and its balance differ.
   Sales have an optional crypto_out_with_fee input field: the tax
   computation (and so the wallet lots) uses it, while BalanceSet uses
   crypto_out_no_fee + crypto_fee. If both are in the input and differ
   (e.g. exchange rounding: 1.00100001 vs 1 + 0.001), the new check in
   ComputedData raised "Internal error" and no report was generated,
   while the commit before R04 completed. The difference is now a
   warning that points to the input; reports use the lots. The tests
   still check exact equality, so a code bug would fail CI.
   Test: test_inconsistent_sale_amounts_are_a_warning (fails on
   a3abfe6 with the internal error).
2. Docs: coins with no transactions in 2025 or later are still computed
   with universal application (the allocation at the switch isn't
   computed for them), so their wallets get the average cost basis and
   -f leaves out their lots bought before it. user_faq.md said only
   report periods ending before 2025 do; it's now a listed limitation.
3. WalletLot: the hash generated by dataclass included the amount, an
   unhashable RP2Decimal, so hashing a WalletLot raised TypeError. It's
   now hashed on (wallet, lot). Test: test_wallet_lot.
4. TransferAnalyzer.analyze_and_pair() type-checks holdings_date at
   entry (a string or a datetime raised a bare TypeError mid-pass).
   Test: test_holdings_date_must_be_a_date.
5. Property test: the report date is now drawn from the last step
   before the switch onwards, so most examples check the per-wallet
   holdings again (about a quarter used to stop at the "before the
   switch" check).

Checked: full suite on Python 3.10, 3.11, 3.13 and 3.14 (201 tests,
254 subtests each); mypy, flake8, bandit clean; pylint 10.00 with both
4.0.9 and 4.1.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ZgEJzUPyGHYd2xoXqcp5R
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants