Crypto Cost Basis Across Multiple Exchanges: Why Transfers Break It and How to Fix It
Almost every wrong gains number I have seen traces back to the same row. Someone bought BTC on one exchange, moved it to another, sold it there, and the tool computing their crypto cost basis across multiple exchanges never understood that the withdrawal and the deposit were the same coins. One side looks like a sale. The other looks like coins from nowhere. The gain is wrong on both ends, quietly.
This article covers why cost basis across several exchanges and wallets is hard, how software gets it wrong, a spreadsheet check you can run this afternoon, and the exact rules I use to match transfers. Full disclosure: I build Holdbound, so read the product parts with that in mind.
Why a transfer is the hard part
A buy on one exchange is one row in one file. A transfer between two of your own accounts is two rows in two files, exported from two systems that do not know about each other.
- Exchange A writes a withdrawal: 1.0 BTC out, June 1 at 14:02.
- Exchange B writes a deposit: 0.9998 BTC in, June 1 at 14:41.
Nothing in either file says "these belong together". If you do not pair them, cost basis arithmetic sees a disposal with no proceeds on A (a phantom sale of 1.0 BTC) and an acquisition with no cost on B (a phantom buy of 0.9998 BTC). Every later sale on B then carries a basis of zero.
Pairing sounds easy until you look at real exports:
- Network fees shrink the amount in flight. 1.0 BTC leaves, 0.9998 BTC arrives. Some exporters put the fee on its own row, some fold it into the sent amount, some omit it.
- Timestamps live in different timezones. Binance and Kraken export in UTC, OKX lets you choose, Coinbase does not document it. One transfer can land on two calendar dates.
- Partial withdrawals. You move 0.3 BTC of a 1.0 BTC lot; 0.7 BTC stays behind with its original cost.
- Same-day duplicates. Two withdrawals of 0.5 ETH to two wallets the same afternoon. Which deposit is which?
How tools get this wrong
I have not audited anyone else's code, so these are properties of an approach, not of a vendor.
Matching by amount alone. Fails the moment a fee shrinks the amount, and over-matches when you routinely move round numbers.
Requiring exact equality, no fee allowance. The opposite error: 1.0 out and 0.9998 in are treated as unrelated, so the phantom sale and phantom buy both survive.
Guessing on ties. Two equally plausible deposits inside the window; picking the nearer one by timestamp is right half the time, with no way to tell which half.
Treating unmatched legs as income or sales. An orphan deposit becomes a reward at market value (invented income, invented basis); an orphan withdrawal becomes a realized loss equal to its full basis.
Windows too wide, or matching within one account. A seven-day window with no other evidence pairs last Tuesday's withdrawal with this Tuesday's deposit. A withdrawal and a deposit inside the same account is a round trip, never a transfer.
No way to see or change the decision. The worst match is a silent one; if you cannot see why two legs were paired, you cannot fix the ones the rules got wrong.
Checking crypto cost basis across multiple exchanges in a spreadsheet
You do not need software to find missed transfers. You need one check: a running balance per account per coin that never goes negative and never holds coins it never acquired.
- Put every row from every exchange into one sheet:
date,account,asset,amount_in,amount_out,type. - Sort by
account, thenasset, thendate. - Add
running_balance: previous balance plusamount_inminusamount_out, restarting at zero for each account and asset. - Scan for any row where
running_balancedrops below zero (that account spent coins it never received), and any account whose first row for a coin is a sale or withdrawal (those coins came from somewhere). Both point at an unmatched transfer or a missing export row. - For each finding, look in the other accounts for a withdrawal of the same coin, a similar amount, shortly before. Write the pair into a
transfer_idcolumn on both rows. - Compare each account's end balance per coin against what the exchange shows today. Any gap is a missing row.
An hour for a few hundred rows, and it catches most cost-basis errors. What it does not do is carry cost and purchase date across; that you still copy from sender to receiver by hand.
This check is worth doing once by hand, because it teaches you what a broken ledger looks like. It is also the check a tool should be doing for you continuously — it is mechanical, and it is the single failure that distorts numbers most. Holdbound runs it on every recalculation: any withdrawal or deposit it could not pair sits in a banner across the top of the dashboard saying how many are outstanding, with a button that takes you to them, and it warns you again if you try to export a yearly summary while any remain. That is deliberate: an unpaired transfer does not look like an error, it looks like a number, which is exactly why it needs to be shouted about rather than logged.
A worked example, with the arithmetic
Two exchanges, two buys, one transfer with a fee, one sale, FIFO throughout. Prices are invented; the mechanics are not.
| Date | Account | Event | Amount | Price | Fee |
|---|---|---|---|---|---|
| Jan 10 | A | Buy BTC | 1.0 BTC | $40,000 | $40 |
| Mar 5 | A | Buy BTC | 0.5 BTC | $50,000 | $25 |
| Jun 1 | A | Withdraw BTC | 1.0 BTC | — | 0.0002 BTC network fee |
| Jun 1 | B | Deposit BTC | 0.9998 BTC | — | — |
| Aug 20 | B | Sell BTC | 0.6 BTC | $60,000 | $36 |
Fees on a buy are part of the cost; fees on a sale reduce the proceeds.
- Jan 10 lot: 1.0 × $40,000 + $40 = $40,040, or $40,040 per BTC.
- Mar 5 lot: 0.5 × $50,000 + $25 = $25,025, or $50,050 per BTC.
With the transfer matched. The 1.0 BTC that left A on June 1 is the Jan 10 lot (FIFO: oldest first). 0.9998 BTC of it arrives on B carrying the Jan 10 date and the $40,040-per-BTC cost. The 0.0002 BTC that went to the network is a separate, tiny event; I come back to it below.
The Aug 20 sale of 0.6 BTC on B:
- Proceeds: 0.6 × $60,000 − $36 = $35,964
- Basis: 0.6 × $40,040 = $24,024
- Realized gain: $35,964 − $24,024 = $11,940
Left afterwards: 0.3998 BTC on B at $40,040 per BTC (about $16,008), and the untouched 0.5 BTC Mar 5 lot on A ($25,025).
With the transfer unmatched. A's file shows 1.0 BTC leaving with no proceeds; a tool that treats that as a sale books a loss of −$40,040 on June 1. B's file shows 0.9998 BTC arriving with no cost, so the Aug 20 sale has a basis of $0 and a gain of $35,964. The year nets to −$4,076 instead of +$11,940, the purchase date on B becomes June 1 instead of January 10, and the 0.3998 BTC still on B has a basis of zero for every future sale. Same five rows. Only the link between two of them changed.
The network fee. For US readers, moving crypto between wallets you own is not a taxable event in the US, and the coins keep their original purchase date and cost. A network fee paid in crypto on a self-transfer is a small disposal of the coins used for the fee, here 0.0002 BTC with a basis of 0.0002 × $40,040 = $8.01. The IRS has not said whether a self-transfer fee can be added to your cost basis; most tax tools treat it conservatively as neither deductible nor basis-increasing. I record the fee leg and leave that decision to you and your accountant.
UK readers: moving crypto between wallets you control is not a disposal for UK CGT, and if you pay a fee in crypto, HMRC treats the fee coins as sold at market value. HMRC has not said whether the fee on a wallet-to-wallet transfer can be added to your cost. Canadian readers: the CRA says moving crypto between wallets you own is not a taxable disposition, and your adjusted cost base carries over unchanged; the CRA has not addressed how to treat the network fee on such a transfer, so track it and discuss with a professional.
One more US point that makes per-account tracking non-optional: since January 1, 2025 the IRS requires cost basis to be tracked wallet-by-wallet (or exchange-account-by-account) under its 2024 final regulations; before that many people pooled all wallets together. Rev. Proc. 2024-28 gave a one-time safe harbor to assign pre-2025 lots to the wallets that held them. A matched transfer log is how your records say which lot sits in which account.
How I match transfers in Holdbound
Every rule below is what the shipped code does, not a roadmap.
A withdrawal and a deposit are paired automatically only if all of these hold:
- Same asset. The importers normalize tickers (
XBTtoBTC); the matcher itself does not guess. - Different account. A withdrawal and a deposit in the same account is never a transfer.
- Deposit time within 48 hours after the withdrawal (the default window). The deposit can never come before the withdrawal.
- Received ≤ sent. A transfer never grows in flight.
- The shrink is within an allowance. Sent minus received must be at most the largest of: a percentage tolerance, a per-asset absolute fee allowance, or the fees declared on the two legs. The third term keeps "1 BTC sent, fee 0.0005, 0.9995 received" matchable.
If both legs carry the same transaction id, that is conclusive: it satisfies the time, amount and fee rules on its own (the asset and account rules still apply).
Each match gets a confidence label: exact for a shared txid, tolerance for equal amounts inside the window or an amount that shrank by a plausible fee. One thing I deliberately do not use as evidence is how close the two timestamps are inside the window. Confirmation times vary too much for "nearer in time" to mean "more likely the same transfer", and using it would turn ties into guesses.
Assignment is one-to-one and deterministic. If two candidates tie and neither is strictly better, nothing is matched and the pair goes on an ambiguous list for you. The matcher refuses to guess, and says so on screen.
Your decisions always win. You can link any two legs by hand, unlink a pair, or exclude a transaction from matching entirely. A forced link that breaks the rules is still honored, labeled manual, with the violated rules listed next to it.
In the generic CSV template, filling the optional txid column turns a tolerance match into an exact one.
Where Holdbound fits, and who should not use it
Your trades stay on your computer. Pay once.
Holdbound is one HTML file. Download it from https://holdbound.com/download, open it in a browser, and that is the install. Your rows live in the browser's local database (IndexedDB), with one-click JSON backup and restore. It works offline except when you press refresh, which fetches prices from CoinGecko and, along with them, a small version file from my site (that is the update notice). Nothing about your rows is sent anywhere.
It reads CSV exports from Binance, OKX, Coinbase and Kraken, or anything else through the generic template. Transfers between your own accounts are paired with the rules above, and any decision can be overridden. Cost basis runs under FIFO or average cost, with trading fees included in cost on a buy and against proceeds on a sell. For US readers: average cost is a mutual-fund rule and is not a recognized method for crypto in the US; the average-cost mode is for readers in jurisdictions that pool (Canada's adjusted cost base) or for comparison, and which method you file under is for you and your accountant to decide. The dashboard shows holdings, realized and unrealized P&L, and a trade log with notes. A holding-period card compares positions held under 7 days against those held over 90 days: performance windows, not tax categories. Export is a full CSV plus a yearly realized-gains summary.
Pricing: The first 30 days are a free, full-featured trial. After that it is $29, once — and that buys the software, not a year of it: every version released in the following 12 months is yours to keep and keeps working forever. Updates after that are $15 a year and optional; skipping them takes nothing away. Read-only applies to one case only — a trial that ended without a purchase — and even then everything stays viewable and exportable.
You should not use Holdbound if:
- You need tax forms. It produces a CSV and an annual summary, not Form 8949, Schedule D, or any country's equivalent.
- You use DeFi or NFTs. Pools, bridges, mints and airdrops are not parsed.
- You trade margin, perpetuals or options. Spot only, by design.
- You want many accounts synced by API. There is no sync; you export CSVs yourself.
- More than one person needs to work in it. It is built for one person on one computer.
- You want a vendor holding your backups. The data is on your computer, and so is the job of keeping the JSON backup safe.
FAQ
Why not just use a wider matching window?
A wider window creates more ties, and ties are where wrong matches come from. I would rather have a short default window, a fee allowance, and an explicit ambiguous list than a long window that pairs things confidently and wrongly.
This article is for general information only — not financial or tax advice.