Created
March 6, 2021 04:19
-
-
Save carbonphyber/9f4ea9befb06b15c1e92d7918eecb316 to your computer and use it in GitHub Desktop.
Convert Coinbase transactions export to a cost basis CSV almost compatible with TurboTax import. No Warranties.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Read a Coinbase transaction export and print out a Cost Basis CSV (useful for tax calculation | |
* of cryptocurrency profits). The output is near the format that TurboTax expects, but not perfect | |
* (likely headers/columns are slightly deformed). This script was written with an export file | |
* which had only a single Coinbase account / wallet, only two currencies, and all transactions were | |
* USD <-> BTC (no conversions/transactions of one crypto for another). | |
* | |
* This script is meant to be run as CLI from Node (probably compatible with 12+ because of use of fs.promises). | |
* Needs an npm repo around this source file, then run `npm install` / `yarn`. | |
* Example commands: | |
* - $ mkdir coinbase-export-cost-basis | |
* - $ cd coinbase-export-cost-basis && npm init -y | |
* - $ npm install papaparse | |
* - $ node index.js | |
*/ | |
const fs = require('fs').promises; | |
const papaparse = require('papaparse'); | |
const [appName, scriptName, filePath] = process.argv; | |
const allBuyRecords = {}; | |
const allSellRecords = {}; | |
const allCostBasis = {}; | |
const bchHardForkDate = new Date('2017-08-01T00:00:00Z'); | |
const btcOriginDate = new Date('2009-08-01T00:00:00Z'); | |
const milliSecondsInYear = 31536000000; | |
// note that this rounds to the nearest cent, so expect the data to be lossy at a sub-cent level | |
const formatDollars = (num) => Math.round((num) * 100) / 100; | |
const csvContents = fs.readFile(filePath) | |
// convert file contents from buffer to string | |
.then(contents => contents.toString('utf8')) | |
// truncate anything before the first CSV header line (requirest exact text match) | |
.then(contents => { | |
const csvBeginsAt = contents.indexOf('Timestamp,Transaction Type,Asset,Quantity Transacted,USD Spot Price at Transaction,USD Subtotal,USD Total (inclusive of fees),USD Fees,Notes'); | |
if (csvBeginsAt < 0) { | |
throw new Error('Could not find headers in this file'); | |
} | |
return csvBeginsAt === 0 | |
? contents | |
: contents.substr(csvBeginsAt) | |
}) | |
// trim any whitespace at the beginning or end of the CSV string | |
.then(contents => contents.replace(/^\s+|\s+$/, '')) | |
// read CSV string into papaparse object | |
.then(contents => papaparse.parse(contents, { | |
delimiter: ',', | |
newline: "\n", | |
quoteChar: '"', | |
header: true, | |
})) | |
// Gather all buy data in one iteration | |
.then(data => { | |
// only supports BCH and BTC | |
['BCH', 'BTC'].forEach(asset => { | |
allBuyRecords[asset] = data.data | |
.filter(row => row['Asset'] === asset && ['Buy', 'Receive'].includes(row['Transaction Type'])) | |
.map(row => ({ | |
'Timestamp': new Date(row['Timestamp']), | |
'Transaction Type': 'Buy', | |
'Quantity Transacted': Number(row['Quantity Transacted'] || 0.0), | |
'Amount Excluding Fees': Number(row['USD Total (inclusive of fees)'] || 0.0), | |
'Amount Including Fees': Number(row['USD Subtotal'] || 0.0), | |
})); | |
allSellRecords[asset] = data.data | |
.filter(row => row['Asset'] === asset && ['Sell'].includes(row['Transaction Type'])) | |
.map(row => ({ | |
'Timestamp': new Date(row['Timestamp']), | |
'Transaction Type': 'Sell', | |
'Quantity Transacted': Number(row['Quantity Transacted'] || 0.0), | |
'Amount Excluding Fees': Number(row['USD Total (inclusive of fees)'] || 0.0), | |
'Amount Including Fees': Number(row['USD Subtotal'] || 0.0), | |
})); | |
}); | |
return { | |
buys: allBuyRecords, | |
sells: allSellRecords, | |
}; | |
}) | |
// Iterate through all sale data | |
.then(data => { | |
['BCH', 'BTC'].forEach(asset => { | |
if (!Array.isArray(allCostBasis[asset])) { | |
allCostBasis[asset] = []; | |
} | |
let runningAssetsSold = 0.0; | |
allSellRecords[asset].forEach((saleRow, i) => { | |
// running aggregates | |
let runningAssetsBought = 0.0; | |
// default cost basis data object | |
const buyObj = { | |
'Buy Date': new Date('1970-01-01T00:00:00Z'), | |
'Quantity': 0.0, | |
'Price': 0.0, | |
}; | |
const quantitySold = Number(saleRow['Quantity Transacted'] || 0.0); | |
const saleDate = saleRow['Timestamp']; | |
const proceeds = formatDollars(Number(saleRow['Amount Excluding Fees'] || 0.0)); | |
let buyDone = false; | |
allBuyRecords[asset].forEach(buyRow => { | |
const buyQuantity = Number(buyRow['Quantity Transacted'] || 0.0); | |
const buyPrice = Number(buyRow['Amount Including Fees'] || 0.0); | |
let intersectQuantity = 0.0; | |
let fork = 'default'; | |
if (runningAssetsBought + buyQuantity < runningAssetsSold) { | |
fork = 'not yet'; | |
// If this buy record was apart of a previous sell cost basis | |
// don't include in this cost basis. | |
} else if (runningAssetsBought > runningAssetsSold + quantitySold) { | |
fork = 'completed'; | |
// If we have already calculated the full cost basis for this sell transaction | |
// don't include in this cost basis. | |
buyDone = true; | |
// } else if (runningAssetsBought + buyQuantity <= runningAssetsSold + quantitySold) { | |
} else { | |
fork = 'include'; | |
// Include the remaining part in this cost basis. | |
const intersectionUpperBound = Math.min( | |
runningAssetsSold + quantitySold, | |
runningAssetsBought + buyQuantity, | |
); | |
const intersectionLowerBound = Math.max( | |
0.0, | |
runningAssetsSold, | |
runningAssetsBought, | |
); | |
const intersectionMax = intersectionUpperBound - intersectionLowerBound; | |
runningAssetsSold + quantitySold - runningAssetsBought; | |
intersectQuantity = Math.min(buyQuantity, intersectionMax); | |
const pricePerUnit = buyQuantity > 0.0 | |
? buyPrice / buyQuantity | |
: 0.0; | |
// Only add the quantity within this buy+sell intersection to buyObj | |
buyObj['Quantity'] += intersectQuantity; | |
// add the propertion of remaining quantity at the current buyPrice | |
buyObj['Price'] += (pricePerUnit * intersectQuantity); | |
buyObj['Buy Date'] = new Date(buyRow['Timestamp']); | |
} | |
runningAssetsBought += buyQuantity; | |
}); | |
const buyDate = buyObj['Buy Date']; | |
const quantity = quantitySold; | |
// Avoid Divide by Zero condition | |
const costBasis = buyObj['Quantity'] > 0.0 | |
? formatDollars(buyObj['Price']) | |
: 0.0; | |
const yearsHeld = (saleDate - buyDate) / milliSecondsInYear; | |
const assetCostBasisObj = { | |
'Service name': 'Coinbase', | |
'Asset': asset, | |
'Buy Date': buyDate, | |
'Sale Date': saleDate, | |
'Quantity': quantity, | |
'Proceeds': proceeds, | |
'Cost Basis': costBasis, | |
'Gain/Loss': formatDollars(proceeds - costBasis), | |
'Term': yearsHeld >= 1.0 | |
? 'Long' | |
: 'Short', | |
}; | |
allCostBasis[asset].push(assetCostBasisObj); | |
runningAssetsSold += quantity; | |
}); | |
}); | |
return data; | |
}) | |
// print out | |
.then(data => { | |
const bchText = papaparse.unparse(allCostBasis['BCH'], { | |
delimiter: ',', | |
newline: "\n", | |
quoteChar: '"', | |
}); | |
const btcText = papaparse.unparse(allCostBasis['BTC'], { | |
delimiter: ',', | |
newline: "\n", | |
quoteChar: '"', | |
}); | |
console.log(`**** BCH **** | |
${bchText} | |
**** BTC **** | |
${btcText}`); | |
return data; | |
}) | |
// handle errors | |
.catch(err => console.error(err)); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment