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 Delta converter #128

Merged
merged 6 commits into from
Jan 1, 2025
Merged
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.19.1
next-version: 0.20.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 a multiple transaction exports (CSV) to an impor
- [Bitvavo](https://bitvavo.com)
- [BUX](https://bux.com)
- [DEGIRO](https://degiro.com)
- [Delta](https://delta.app)
- [eToro](https://www.etoro.com/)
- [Finpension](https://finpension.ch)
- [Freetrade](https://freetrade.io)
Expand Down Expand Up @@ -49,6 +50,10 @@ Login to your DEGIRO account and create an export file (via Inbox > Account Over

![Export instructions for DEGIRO](./assets/export-degiro.jpg)

### Delta

Open the Delta app. Open the menu, then click "Settings". Go to "Devices & Data", then "Export data". Select the portfolio to export, then click the "Download" button to get the CSV file.

### eToro

Login to your eToro account and navigate to "Portfolio". Then select "History" in the top menu. Next, click on the icon on the far right and select "Account statement". Choose the dates of interest and click "Create". On the next page, click on the Excel icon on the top right to download the file. After downloading, open the file in Excel and delete all the tabs except the "Account Activity" tab. Then use Excel to convert the file to CSV.
Expand Down Expand Up @@ -211,6 +216,7 @@ You can now run `npm run start [exporttype]`. See the table with run commands be
| Bitvavo | `run start bitvavo` (or `bv`) |
| BUX | `run start bux` |
| DEGIRO | `run start degiro` |
| Delta | `run start delta` |
| eToro | `run start etoro` |
| Finpension | `run start finpension` (or `fp`) |
| Freetrade | `run start freetrade` (or `ft`) |
Expand Down
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.19.1",
"version": "0.20.0",
"type": "module",
"description": "Convert multiple broker exports to Ghostfolio import",
"scripts": {
Expand Down
8 changes: 8 additions & 0 deletions samples/delta-export.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Date,Way,Base amount,Base currency (name),Base type,Quote amount,Quote currency,Exchange,Sent/Received from,Sent to,Fee amount,Fee currency (name),Broker,Notes
2023-06-02 16:06:47-04:00,BUY,10,ETH,CRYPTO,0.6,BTC,Binance,,,0.1,BNB,,Example of a crypto buy of 10 ETH for a total of 0.6 BTC and a fee of 0.1 BNB
2023-06-01 19:31:54-04:00,SELL,100,SOL (Solana),CRYPTO,0.88,ETH,Binance,,,,,,Example of a crypto sell. Name specified in brackets so we know SOL is Solana and not Sola
2023-05-26 02:08:15-04:00,WITHDRAW,0.1,DOT (Polkadot),CRYPTO,,,,MY_WALLET,OTHER,0.001,DOT (Polkadot),,"Example of a withdrawal. Asset name can be specified in brackets for base, quote or fee currency"
2023-05-09 14:51:31-04:00,DEPOSIT,0.001,BTC,CRYPTO,,,,DIVIDENDS,MY_WALLET,,,,Example of a deposit from staking / dividends for crypto
2023-05-08 15:00:00-04:00,BUY,5,AAPL,STOCK,1250,USD,Nasdaq,,,,,eToro,Example of a stock purchase
2023-05-08 15:00:00-04:00,DEPOSIT,5000,USD,FIAT,,,,,,,,eToro,Example of a fiat deposit
2023-05-08 15:00:00-04:00,DIVIDEND,,AAPL,STOCK,2.5,USD,Nasdaq,,,0.5,USD,eToro,Example of a dividend with a fee for AAPL
5 changes: 5 additions & 0 deletions src/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { BuxConverter } from "./converters/buxConverter";
import { DeGiroConverter } from "./converters/degiroConverter";
import { DeGiroConverterV2 } from "./converters/degiroConverterV2";
import { DeGiroConverterV3 } from "./converters/degiroConverterV3";
import { DeltaConverter } from "./converters/deltaConverter";
import { EtoroConverter } from "./converters/etoroConverter";
import { FinpensionConverter } from "./converters/finpensionConverter";
import { FreetradeConverter } from "./converters/freetradeConverter";
Expand Down Expand Up @@ -101,6 +102,10 @@ async function createConverter(converterType: string): Promise<AbstractConverter
console.log("[i] Should you have issues with the result of the converter, please report a bug at https://git.new/degiro-v3-bug");
converter = new DeGiroConverterV3(securityService);
break;
case "delta":
console.log("[i] Processing file using Delta converter");
converter = new DeltaConverter(securityService);
break;
case "etoro":
console.log("[i] Processing file using Etoro converter");
converter = new EtoroConverter(securityService);
Expand Down
147 changes: 147 additions & 0 deletions src/converters/deltaConverter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { DeltaConverter } from "./deltaConverter";
import { SecurityService } from "../securityService";
import { GhostfolioExport } from "../models/ghostfolioExport";
import YahooFinanceServiceMock from "../testing/yahooFinanceServiceMock";

describe("deltaConverter", () => {

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

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

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

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

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

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

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

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

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

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 DeltaConverter(new SecurityService(new YahooFinanceServiceMock()));

let tempFileName = "tmp/testinput/delta-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 DeltaConverter(new SecurityService(new YahooFinanceServiceMock()));

let tempFileContent = "";
tempFileContent += `Date,Way,Base amount,Base currency (name),Base type,Quote amount,Quote currency,Exchange,Sent/Received from,Sent to,Fee amount,Fee currency (name),Broker,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 DeltaConverter(new SecurityService(new YahooFinanceServiceMock()));

let tempFileContent = "";
tempFileContent += `Date,Way,Base amount,Base currency (name),Base type,Quote amount,Quote currency,Exchange,Sent/Received from,Sent to,Fee amount,Fee currency (name),Broker,Notes\n`;
tempFileContent += `2023-05-08 15:00:00-04:00,BUY,5,TSLA,STOCK,1250,USD,Nasdaq,,,,,eToro,Example of a stock purchase,,`;

// 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 14, got 16 on line 2");

done();
});
});

it("Yahoo Finance throws an error", (done) => {

// Arrange
let tempFileContent = "";
tempFileContent += `Date,Way,Base amount,Base currency (name),Base type,Quote amount,Quote currency,Exchange,Sent/Received from,Sent to,Fee amount,Fee currency (name),Broker,Notes\n`;
tempFileContent += `2023-05-08 15:00:00-04:00,BUY,5,TSLA,STOCK,1250,USD,Nasdaq,,,,,eToro,Example of a stock purchase`;

// Mock Yahoo Finance service to throw error.
const yahooFinanceServiceMock = new YahooFinanceServiceMock();
jest.spyOn(yahooFinanceServiceMock, "search").mockImplementation(() => { throw new Error("Unit test error"); });
const sut = new DeltaConverter(new SecurityService(yahooFinanceServiceMock));

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

// Assert
expect(err).toBeTruthy();
expect(err.message).toContain("Unit test error");

done();
});
});
});

it("should log when Yahoo Finance returns no symbol", (done) => {

// Arrange
let tempFileContent = "";
tempFileContent += `Date,Way,Base amount,Base currency (name),Base type,Quote amount,Quote currency,Exchange,Sent/Received from,Sent to,Fee amount,Fee currency (name),Broker,Notes\n`;
tempFileContent += `2023-05-08 15:00:00-04:00,BUY,5,TSLA,STOCK,1250,USD,Nasdaq,,,,,eToro,Example of a stock purchase`;

// Mock Yahoo Finance service to return no quotes.
const yahooFinanceServiceMock = new YahooFinanceServiceMock();
jest.spyOn(yahooFinanceServiceMock, "search").mockImplementation(() => { return Promise.resolve({ quotes: [] }) });
const sut = new DeltaConverter(new SecurityService(yahooFinanceServiceMock));

// Bit hacky, but it works.
const consoleSpy = jest.spyOn((sut as any).progress, "log");

// Act
sut.processFileContents(tempFileContent, () => {

expect(consoleSpy).toHaveBeenCalledWith("[i] No result found for buy action for TSLA with currency USD! Please add this manually..\n");

done();
}, () => done.fail("Should not have an error!"));
});
});
162 changes: 162 additions & 0 deletions src/converters/deltaConverter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import dayjs from "dayjs";
import { parse } from "csv-parse";
import { DeltaRecord } from "../models/deltaRecord";
import { AbstractConverter } from "./abstractconverter";
import { SecurityService } from "../securityService";
import { GhostfolioExport } from "../models/ghostfolioExport";
import YahooFinanceRecord from "../models/yahooFinanceRecord";
import { GhostfolioOrderType } from "../models/ghostfolioOrderType";

export class DeltaConverter 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 === "way") {
const action = columnValue.toLocaleLowerCase();

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

// Parse numbers to floats (from string).
if (context.column === "baseAmount" ||
context.column === "quoteAmount" ||
context.column === "feeAmount") {

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

return parseFloat(columnValue);
}

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

// 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;
}

// Temporary skip crypto.
if (record.baseType === "CRYPTO") {
console.log(`[i] Unsupported base type ${record.baseType} for ${record.way} action for ${record.baseCurrencyName} with currency ${record.quoteCurrency}! Please add this manually..`);
bar1.increment();
continue;
}

let security: YahooFinanceRecord;
try {
security = await this.securityService.getSecurity(
null,
record.baseCurrencyName,
null,
record.quoteCurrency,
this.progress);
}
catch (err) {
this.logQueryError(record.baseCurrencyName, idx + 2);
return errorCallback(err);
}

// Log whenever there was no match found.
if (!security) {
this.progress.log(`[i] No result found for ${record.way} action for ${record.baseCurrencyName} with currency ${record.quoteCurrency}! Please add this manually..\n`);
bar1.increment();
continue;
}

let quantity, unitPrice;

if (record.way === "dividend") {
quantity = 1;
unitPrice = Math.abs(record.quoteAmount);
} else {
quantity = record.baseAmount;
unitPrice = parseFloat((record.quoteAmount / quantity).toFixed(2));
}

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

bar1.increment();
}

this.progress.stop()

successCallback(result);
});
}

/**
* @inheritdoc
*/
public isIgnoredRecord(record: DeltaRecord): boolean {
let ignoredRecordTypes = ["deposit", "withdraw", "transfer"];

return ignoredRecordTypes.some(t => record.way.toLocaleLowerCase().indexOf(t) > -1)
}
}
16 changes: 16 additions & 0 deletions src/models/deltaRecord.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export class DeltaRecord {
date: Date;
way: string;
baseAmount: number;
baseCurrencyName: string;
baseType: string;
quoteAmount: number;
quoteCurrency: string;
exchange: string;
sentReceivedFrom: string;
sentTo: string;
feeAmount: number;
feeCurrencyName: string;
broker: string;
notes: string;
}
Loading
Loading