Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Coinbase converter #172

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion GitVersion.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
next-version: 0.27.0
next-version: 0.28.0
assembly-informational-format: "{NuGetVersion}"
mode: ContinuousDeployment
branches:
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ This tool allows you to convert CSV transaction exports to an import file that c
- [Avanza](https://avanza.se)
- [Bitvavo](https://bitvavo.com)
- [BUX](https://bux.com)
- [Coinbase](https://coinbase.com)
- [CoinTracking.info](https://cointracking.info)
- [DEGIRO](https://degiro.com)
- [Delta](https://delta.app)
Expand Down Expand Up @@ -53,6 +54,10 @@ Open the app and go to "Account Value", and then "View History". Click the downl

_Due to limitations by BUX, you can request up to 3 CSV exports per day!_.

### Coinbase

Go to Coinbase.com. Click on your account in the top-right, then click your name to go to your profile. Choose "Account statements" in the left menu (second to last from the bottom). Select a pre-generated transaction export (choose "CSV"), or create a custom CSV export.

### CoinTracking.info

Login to your CoinTracking.info account. Go to the "Transactions" section in the menu. Click the "Export"-button, then choose "CSV (Full Export)" **(this is important!)** to download the transactions.
Expand Down Expand Up @@ -247,6 +252,7 @@ You can now run `npm run start [exporttype]`. See the table with run commands be
| Avanza | `run start avanza` |
| Bitvavo | `run start bitvavo` (or `bv`) |
| BUX | `run start bux` |
| Coinbase | `run start coinbase` (or `cb`) |
| CoinTracking | `run start cointracking` (or `ct`) |
| DEGIRO | `run start degiro` |
| Delta | `run start delta` |
Expand Down
Binary file modified assets/social.pxz
Binary file not shown.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "export-to-ghostfolio",
"version": "0.27.0",
"version": "0.28.0",
"type": "module",
"description": "Convert multiple broker exports to Ghostfolio import",
"scripts": {
Expand Down
8 changes: 8 additions & 0 deletions samples/coinbase-export.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
ID,Timestamp,Transaction Type,Asset,Quantity Transacted,Price Currency,Price at Transaction,Subtotal,Total (inclusive of fees and/or spread),Fees and/or Spread,Notes
678a8bdefcb176007bfdbXXX,2025-01-17 16:57:02 UTC,Staking Income,ETH,0.000037835729,EUR,€3343.11229989,€0.12649,€0.12649,€0.00,
67869271463b8adc50c0dXXX,2025-01-14 16:36:01 UTC,Staking Income,ETH,0.000022652183,EUR,€3102.3217898325970226955,€0.07027,€0.07027,€0.00,
677d8000e36ea79e78611XXX,2025-01-07 19:26:56 UTC,Send,ETH2,-0.180914809326,EUR,€3302.6508979077133026,-€597.49846,-€597.49846,€0.00,Sent 0.180914809326 ETH2s
677d8000bda4f9ffd19c1XXX,2025-01-07 19:26:56 UTC,Receive,ETH,0.180914809326,EUR,€3302.6508979077133026,€597.49846,€597.49846,€0.00,Received 0.180914809326 ETHs
67514944a3f9bf34890d8XXX,2024-12-05 06:33:40 UTC,Convert,BTC,0.00363125,EUR,€96847.405873901104437015,€338.92698,€351.66322,,Converted 0.00363125 BTC to 156.145683 XRP
6472f36a1328dd00010f9XXX,2023-05-28 06:23:38 UTC,Convert,ETH,0.17305191,EUR,€1723.885226,€298.32163,€298.21913,,Converted 0.17305191 ETH to 0.17305191 ETH2
623d6507925c200001fcbXXX,2022-03-25 06:45:27 UTC,Buy,BTC,0.00166779,EUR,€39999.331136545,€67.01,€70.00,€2.99,Bought 0.00166779 BTC for 70 EUR
3 changes: 2 additions & 1 deletion samples/trading212-export.csv
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ Market sell,2023-12-26 14:30:05.104,US04634X2027,ASTR,"Astra Space",0.6125400000
Dividend (Dividend),2023-12-27 12:05:25,US56035L1044,MAIN,"Main Street Capital",0.1543340000,0.23,USD,Not available,,,0.03,"EUR",0.01,USD,,,,
Dividend (Dividend),2023-12-28 09:32:51,US9078181081,UNP,"Union Pacific",0.0280492000,1.11,USD,Not available,,,0.03,"EUR",0.01,USD,,,,
Dividend (Dividend),2024-01-12 14:14:14,IE00B1XNHC34,INRG,"iShares Global Clean Energy UCITS ETF",0.0280492000,630.11,GBX,Not available,,,17.67,"EUR",15.02,USD,,,,
Interest on cash,2023-11-06 22:06:41.36,,,,,,,,,,0.01,"EUR",,,"Interest on cash",8ffba791-cfc3-4002-b65d-bd63cf483d9d,,
Interest on cash,2023-11-06 22:06:41.36,,,,,,,,,,0.01,"EUR",,,"Interest on cash",8ffba791-cfc3-4002-b65d-bd63cf483d9d,,
Stock distribution,2025-01-23 14:46:06.327,US9078181081,PALAF,"Union Pacific",,EOF27002856700,11.0563304000,0.00,USD,,,"EUR",0.00,"EUR",,,,
6 changes: 6 additions & 0 deletions src/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { AbstractConverter } from "./converters/abstractconverter";
import { AvanzaConverter } from "./converters/avanzaConverter";
import { BitvavoConverter } from "./converters/bitvavoConverter";
import { BuxConverter } from "./converters/buxConverter";
import { CoinbaseConverter } from "./converters/coinbaseConverter";
import { CointrackingConverter } from "./converters/cointrackingConverter";
import { DeGiroConverter } from "./converters/degiroConverter";
import { DeGiroConverterV2 } from "./converters/degiroConverterV2";
Expand Down Expand Up @@ -115,6 +116,11 @@ async function createConverter(converterType: string, securityService?: Security
console.log("[i] Processing file using Bux converter");
converter = new BuxConverter(securityService);
break;
case "cb":
case "coinbase":
console.log("[i] Processing file using Coinbase converter");
converter = new CoinbaseConverter(securityService);
break;
case "ct":
case "cointracking":
console.log("[i] Processing file using CoinTracking converter");
Expand Down
100 changes: 100 additions & 0 deletions src/converters/coinbaseConverter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { SecurityService } from "../securityService";
import { CoinbaseConverter } from "./coinbaseConverter";
import { GhostfolioExport } from "../models/ghostfolioExport";
import YahooFinanceServiceMock from "../testing/yahooFinanceServiceMock";

describe("coinbaseConverter", () => {

beforeEach(() => {
jest.spyOn(console, "log").mockImplementation(jest.fn());
});

afterEach(() => {
jest.clearAllMocks();
});

it("should construct", () => {

// Act
const sut = new CoinbaseConverter(new SecurityService(new YahooFinanceServiceMock()));

// Assert
expect(sut).toBeTruthy();
});

it("should process sample CSV file", (done) => {

// Arange
const sut = new CoinbaseConverter(new SecurityService(new YahooFinanceServiceMock()));
const inputFile = "samples/coinbase-export.csv";

// Act
sut.readAndProcessFile(inputFile, (actualExport: GhostfolioExport) => {

// Assert
expect(actualExport).toBeTruthy();
expect(actualExport.activities.length).toBeGreaterThan(0);
expect(actualExport.activities.length).toBe(3);

done();
}, () => { done.fail("Should not have an error!"); });
});

describe("should throw an error if", () => {
it("the input file does not exist", (done) => {

// Arrange
const sut = new CoinbaseConverter(new SecurityService(new YahooFinanceServiceMock()));

let tempFileName = "tmp/testinput/coinbase-filedoesnotexist.csv";

// Act
sut.readAndProcessFile(tempFileName, () => { done.fail("Should not succeed!"); }, (err: Error) => {

// Assert
expect(err).toBeTruthy();

done();
});
});

it("the input file is empty", (done) => {

// Arrange
const sut = new CoinbaseConverter(new SecurityService());

let tempFileContent = "";
tempFileContent += "ID,Timestamp,Transaction Type,Asset,Quantity Transacted,Price Currency,Price at Transaction,Subtotal,Total (inclusive of fees and/or spread),Fees and/or Spread,Notes\n";

// Act
sut.processFileContents(tempFileContent, () => { done.fail("Should not succeed!"); }, (err: Error) => {

// Assert
expect(err).toBeTruthy();
expect(err.message).toContain("An error ocurred while parsing");

done();
});
});

it("the header and row column count doesn't match", (done) => {

// Arrange
const sut = new CoinbaseConverter(new SecurityService());

let tempFileContent = "";
tempFileContent += "ID,Timestamp,Transaction Type,Asset,Quantity Transacted,Price Currency,Price at Transaction,Subtotal,Total (inclusive of fees and/or spread),Fees and/or Spread,Notes\n";
tempFileContent += "678a8bdefcb176007bfdbXXX,2025-01-17 16:57:02 UTC,Staking Income,ETH,0.000037835729,EUR,€3343.11229989,€0.12649,€0.12649,€0.00,,,"

// Act
sut.processFileContents(tempFileContent, () => { done.fail("Should not succeed!"); }, (err: Error) => {

// Assert
expect(err).toBeTruthy();
expect(err.message).toBe("An error ocurred while parsing! Details: Invalid Record Length: columns length is 11, got 13 on line 2");

done();
});
});
});
});
154 changes: 154 additions & 0 deletions src/converters/coinbaseConverter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import dayjs from "dayjs";
import { parse } from "csv-parse";
import { SecurityService } from "../securityService";
import { AbstractConverter } from "./abstractconverter";
import { CoinbaseRecord } from "../models/coinbaseRecord";
import { GhostfolioExport } from "../models/ghostfolioExport";
import { GhostfolioOrderType } from "../models/ghostfolioOrderType";

export class CoinbaseConverter extends AbstractConverter {

constructor(securityService: SecurityService) {
super(securityService);
}

/**
* @inheritdoc
*/
public processFileContents(input: string, successCallback: any, errorCallback: any): void {

// Parse the CSV and convert to Ghostfolio import format.
parse(input, {
delimiter: ",",
fromLine: 2,
columns: this.processHeaders(input),
cast: (columnValue, context) => {

// Custom mapping below.

// Convert actions to Ghostfolio type.
if (context.column === "type") {
const action = columnValue.toLocaleLowerCase();

if (action.indexOf("buy") > -1) {
return "buy";
}
else if (action.indexOf("sell") > -1) {
return "sell";
}
else if (action.indexOf("staking") > -1) {
return "interest";
}
}

// Parse numbers to floats (from string).
if (context.column === "quantity" ||
context.column === "price" ||
context.column === "subtotal" ||
context.column === "total" ||
context.column === "fees") {

if (columnValue === "") {
return 0;
}

// extract the dnumber from the string
return Math.abs(parseFloat(columnValue.match(/(\d+.\d+)/)[0]));
}

return columnValue;
}
}, async (err, records: CoinbaseRecord[]) => {

// Check if parsing failed..
if (err || records === undefined || records.length === 0) {
let errorMsg = "An error ocurred while parsing!";

if (err) {
errorMsg += ` Details: ${err.message}`
}

return errorCallback(new Error(errorMsg))
}

console.log("[i] Read CSV file. Start processing..");
const result: GhostfolioExport = {
meta: {
date: new Date(),
version: "v0"
},
activities: []
}

// Populate the progress bar.
const bar1 = this.progress.create(records.length, 0);

for (let idx = 0; idx < records.length; idx++) {
const record = records[idx];

// Check if the record should be ignored.
if (this.isIgnoredRecord(record)) {

bar1.increment();
continue;
}

// There is no need to query Yahoo Finance for Coinbase exports as the information can be extracted wholly from the export.

let symbol = `${record.asset}-${record.priceCurrency}`

const date = dayjs(record.timestamp, "YYYY-MM-DD HH:mm:ss");

// Add record to export.
result.activities.push({
accountId: process.env.GHOSTFOLIO_ACCOUNT_ID,
comment: "",
fee: record.fees,
quantity: record.quantity,
type: GhostfolioOrderType[record.type],
unitPrice: record.price,
currency: "EUR",
dataSource: "YAHOO",
date: date.format("YYYY-MM-DDTHH:mm:ssZ"),
symbol: symbol
});

bar1.increment();
}

this.progress.stop()

successCallback(result);
});
}

/**
* @inheritdoc
*/
protected processHeaders(_: string): string[] {

// Generic header mapping from the Coinbase CSV export.
const csvHeaders = [
"id",
"timestamp",
"type",
"asset",
"quantity",
"priceCurrency",
"price",
"subtotal",
"total",
"fees",
"notes"];

return csvHeaders;
}

/**
* @inheritdoc
*/
public isIgnoredRecord(record: CoinbaseRecord): boolean {

return ["send", "receive", "convert"].some((t) => record.type.toLocaleLowerCase().indexOf(t) > -1);
}
}
2 changes: 1 addition & 1 deletion src/converters/trading212Converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe("trading212Converter", () => {
// Assert
expect(actualExport).toBeTruthy();
expect(actualExport.activities.length).toBeGreaterThan(0);
expect(actualExport.activities.length).toBe(8);
expect(actualExport.activities.length).toBe(9);

done();
}, () => { done.fail("Should not have an error!"); });
Expand Down
18 changes: 16 additions & 2 deletions src/converters/trading212Converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ export class Trading212Converter extends AbstractConverter {
// Parse numbers to floats (from string).
if (context.column === "noOfShares" ||
context.column === "priceShare" ||
context.column === "result" ||
context.column === "total") {
return parseFloat(columnValue);
}
Expand All @@ -61,6 +60,21 @@ export class Trading212Converter extends AbstractConverter {
}

return columnValue;
},
on_record: (record) => {

// On stock distributions, some fields need rearranging.
if (record.action.toLocaleLowerCase() === "stock distribution") {

record.action = "buy";
record.noOfShares = parseFloat(record.currencyPriceShare);
record.priceShare = 0;
record.currencyPriceShare = record.result;
}
console.log(record);
console.log("-----------------");

return record;
}
}, async (err, records: Trading212Record[]) => {

Expand Down Expand Up @@ -139,7 +153,7 @@ export class Trading212Converter extends AbstractConverter {
bar1.increment();
continue;
}

console.log(record);
// Add record to export.
result.activities.push({
accountId: process.env.GHOSTFOLIO_ACCOUNT_ID,
Expand Down
Loading