Skip to content

Commit

Permalink
degiroConverterV3: don't combine records with different currencies
Browse files Browse the repository at this point in the history
  • Loading branch information
ikruglov committed Jan 9, 2025
1 parent c9c925e commit 821fb3e
Showing 1 changed file with 116 additions and 82 deletions.
198 changes: 116 additions & 82 deletions src/converters/degiroConverterV3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,52 +148,61 @@ export class DeGiroConverterV3 extends AbstractConverter {
matchingRecords = this.findMatchByIsinSameDateTime(record, records.slice(idx + 1, idx + 2));
}

if (matchingRecords.length > 0) {
// Filter out ignored records
matchingRecords = matchingRecords.filter(r => !this.isIgnoredRecord(r));
// Filter out ignored records
matchingRecords = matchingRecords.filter(r => !this.isIgnoredRecord(r));

// Register records as processed so they are skipped on next iteration(s)
matchingRecords.forEach(r => processedRecords.add(this.hashRecord(r)));
// Register records as processed so they are skipped on next iteration(s)
matchingRecords.forEach(r => processedRecords.add(this.hashRecord(r)));

if (matchingRecords.length == 0) {
// If it's a standalone record, add it immediately.
const mappedRecord = this.mapStandaloneRecord(record, security);
if (mappedRecord) result.activities.push(mappedRecord);

bar1.increment(1);
continue;
}

// Now, need to check that all records uses the same currency.
// If not, we have to report them separately even though they are part of one operation in DeGiro
const sameCurrency = matchingRecords.every(r => r.currency == record.currency);
if (!sameCurrency) {
matchingRecords.unshift(record); // combine records to work with them as one whole
matchingRecords.forEach(r => {
const mr = this.mapStandaloneRecord(r, security);
if (mr) result.activities.push(mr);
})

bar1.increment(1);
continue;
}

if (matchingRecords.length > 0) {
// This is a set of records. Check which type of record it is and then combine the records into a Ghostfolio activity.
// Okay, we're dealing with a set of records of the same currency.
// We can combined them and report as one (or multiple records).
matchingRecords.unshift(record); // combine records to work with them as one whole

// Get main and fees records
matchingRecords.unshift(record);
const mainRecords = matchingRecords.filter(r => this.isBuyOrSellRecord(r) || this.isDividendRecord(r));
let transactionFeeRecords = matchingRecords.filter(r => this.isTransactionFeeRecord(r));
// Get main and fees records
const mainRecords = matchingRecords.filter(r => this.isBuyOrSellRecord(r) || this.isDividendRecord(r));
let transactionFeeRecords = matchingRecords.filter(r => this.isTransactionFeeRecord(r));

if (mainRecords.length == 0 || matchingRecords.length != mainRecords.length + transactionFeeRecords.length) {
this.progress.log(`[i] Unexpected set of ${matchingRecords.length} records (see below)! Please add them manually..\n`);
matchingRecords.forEach(r => this.progress.log(`Record ${r.isin || r.product} from ${r.date} with ${r.amount}${r.currency}`));
bar1.increment();
continue;
}
if (mainRecords.length == 0 || matchingRecords.length != mainRecords.length + transactionFeeRecords.length) {
this.progress.log(`[i] Unexpected set of ${matchingRecords.length} records (see below)! Please add them manually..\n`);
matchingRecords.forEach(r => this.progress.log(`Record ${r.isin || r.product} from ${r.date} with ${r.amount}${r.currency}`));

// if there is one main record, report it combined with transaction fee(s). All other goes without TX fee.
mainRecords.forEach(r => {
if (this.isBuyOrSellRecord(r)) {
result.activities.push(this.combineRecords(r, transactionFeeRecords, security));
} else {
result.activities.push(this.mapDividendRecord(r, transactionFeeRecords, security));
}

transactionFeeRecords = []; // nullify tx after first iteration
});
} else {
// If it's a standalone record, add it immediately.
if (this.isBuyOrSellRecord(record)) {
result.activities.push(this.mapRecordToActivity(record, security));
} else if (this.isDividendRecord(record)) {
result.activities.push(this.mapDividendRecord(record, [], security));
} else if (this.isTransactionFeeRecord(record)) {
// sometimes there are stantalone transaction records, report them as platform fees
result.activities.push(this.mapPlatformFeeRecord(record));
bar1.increment();
continue;
}

// if there is one main record, report it combined with transaction fee(s). All other goes without TX fee.
mainRecords.forEach(r => {
if (this.isBuyOrSellRecord(r)) {
result.activities.push(this.mapBuySellRecord(r, transactionFeeRecords, security));
} else {
this.progress.log(`[i] Unknown standalone record ${record.isin || record.product} from ${record.date} with ${record.amount}${record.currency}! Please add this manually..\n`);
result.activities.push(this.mapDividendRecord(r, transactionFeeRecords, security));
}
}

transactionFeeRecords = []; // nullify tx after first iteration
});

bar1.increment();
}
Expand Down Expand Up @@ -283,45 +292,64 @@ export class DeGiroConverterV3 extends AbstractConverter {
return records.filter(r => r.isin === currentRecord.isin && r.product === currentRecord.product && r.date == currentRecord.date);
}

private mapRecordToActivity(record: DeGiroRecord, security?: YahooFinanceRecord, isTransactionFeeRecord: boolean = false): GhostfolioActivity {

let numberShares, unitPrice, feeAmount = 0;
let orderType;

// If it is not a transaction fee record, get data from the record.
if (!isTransactionFeeRecord) {
private mapTransactionFeeRecord(record: DeGiroRecord, security?: YahooFinanceRecord): GhostfolioActivity {
const date = dayjs(`${record.date} ${record.time}:00`, "DD-MM-YYYY HH:mm");
const feeAmount = record.getAbsoluteAmount();

// Get the amount of shares from the description.
const numberSharesFromDescription = record.description.match(/([\d*\.?\,?\d*]+)/)[0];
numberShares = parseFloat(numberSharesFromDescription);
return {
accountId: process.env.GHOSTFOLIO_ACCOUNT_ID,
comment: record.orderId ?? `Transaction fee ${record.isin} @ ${record.date}T${record.time}`,
fee: this.formatFloat(feeAmount),
quantity: 1,
type: GhostfolioOrderType.fee,
unitPrice: 0,
currency: record.currency ?? "",
dataSource: "YAHOO",
date: date.format("YYYY-MM-DDTHH:mm:ssZ"),
symbol: security.symbol,
};
}

// For buy/sale records, only the total amount is recorded. So the unit price needs to be calculated.
const totalAmount = record.getAmount();
unitPrice = Math.abs(totalAmount) / numberShares;
private mapBuySellRecord(record: DeGiroRecord, transactionFeeRecords: DeGiroRecord[], security?: YahooFinanceRecord): GhostfolioActivity {
// !IMPORTANT It's assumed that all records (record + transactionFeeRecords) have same currency

// If amount is negative (so money has been removed) or it's stock dividend (so free shares), thus it's a buy record.
if (totalAmount < 0 || record.description.toLocaleLowerCase().indexOf("stock dividend") > -1) {
orderType = GhostfolioOrderType.buy;
} else {
orderType = GhostfolioOrderType.sell;
}
} else {
const date = dayjs(`${record.date} ${record.time}:00`, "DD-MM-YYYY HH:mm");

// Otherwise, get the transaction fee info.
feeAmount = record.getAbsoluteAmount();
orderType = record.getAmount() < 0 ? GhostfolioOrderType.sell : GhostfolioOrderType.buy;
numberShares = 1;
unitPrice = 0;
}
/* Get the amount and unit price of shares from the description.
* For buy/sale records, only the total amount is recorded. So the unit price needs to be calculated.
* However, in the modern days degiro csv both quantity and price-per-unit are reported.
* Ex: Verkoop 1 @ 16,78 USD
* Ex: Koop 475 @ 583,5 GBX
*
* At the same time, we can't use the unit price because it may come with a different "currency".
* "Currency" is in quotes because it's the case between GBP and GBp/GBX. 1 GBP = 100 * GBp/GBX.
* So, it's not really a difference currency, but rather a different denomination.
* Yet, it makes it necessary to do the dance with dividing total amount by num of shares.
* Ex:
* 03-03-2020,11:14,03-03-2020,ISHARES GLOBAL CLEAN ENERGY UCITS ETF,IE00B1XNHC34,"Koop 475 @ 583,5 GBX",,GBP,-2771.63,GBP,,3b000105-xxxx-xxxx-xxxx-xxxxxxxxxxxx
*
* Another note: the old code contained regexp:
* record.description.match(/([\d*\.?\,?\d*]+)/)[0]
*
* This regexp seems broken to me. Confirmed by ChatGPT.
* I have no idea how it managed to work.
*/

const num = record.description.match(/([0-9]+[.,]?[0-9]*)/)[0];
const quantity = parseFloat(num);
const unitPrice = record.getAbsoluteAmount() / quantity;

// If amount is negative (so money has been removed)
const orderType = record.getAmount() < 0 ? GhostfolioOrderType.buy : GhostfolioOrderType.sell;

const date = dayjs(`${record.date} ${record.time}:00`, "DD-MM-YYYY HH:mm");
// Extract the fee from the transaction fee record and put it in the action record.
const feeAmount = transactionFeeRecords.reduce((sum, r) => sum + r.getAbsoluteAmount(), 0);

// Create the record.
return {
accountId: process.env.GHOSTFOLIO_ACCOUNT_ID,
comment: record.orderId ?? `${orderType === GhostfolioOrderType.buy ? "Buy" : "Sell"} ${record.isin} @ ${record.date}T${record.time}`,
fee: this.formatFloat(feeAmount),
quantity: numberShares,
quantity: this.formatFloat(quantity),
type: orderType,
unitPrice: this.formatFloat(unitPrice),
currency: record.currency ?? "",
Expand All @@ -331,37 +359,43 @@ export class DeGiroConverterV3 extends AbstractConverter {
};
}

private combineRecords(mainRecord: DeGiroRecord, transactionFeeRecords: DeGiroRecord[], security: YahooFinanceRecord): GhostfolioActivity {
// Map both records.
const mappedActionRecord = this.mapRecordToActivity(mainRecord, security);
const mappedTxFeeRecords = transactionFeeRecords.map(r => this.mapRecordToActivity(r, security, true));

// Extract the fee from the transaction fee record and put it in the action record.
mappedActionRecord.fee = mappedTxFeeRecords.reduce((sum, r) => sum + r.fee, 0);
private mapDividendRecord(record: DeGiroRecord, transactionFeeRecords: DeGiroRecord[], security: YahooFinanceRecord): GhostfolioActivity {
// !IMPORTANT It's assumed that all records (record + transactionFeeRecords) have same currency

return mappedActionRecord;
}

private mapDividendRecord(dividendRecord: DeGiroRecord, transactionFeeRecords: DeGiroRecord[], security: YahooFinanceRecord): GhostfolioActivity {
const unitPrice = dividendRecord.getAbsoluteAmount();
const date = dayjs(`${record.date} ${record.time}:00`, "DD-MM-YYYY HH:mm");
const unitPrice = record.getAbsoluteAmount();
const feeAmount = transactionFeeRecords.reduce((sum, r) => sum + r.getAbsoluteAmount(), 0);
const date = dayjs(`${dividendRecord.date} ${dividendRecord.time}:00`, "DD-MM-YYYY HH:mm");

// Create the record.
return {
accountId: process.env.GHOSTFOLIO_ACCOUNT_ID,
comment: `Dividend ${dividendRecord.isin} @ ${dividendRecord.date}T${dividendRecord.time}`,
comment: `Dividend ${record.isin} @ ${record.date}T${record.time}`,
fee: this.formatFloat(feeAmount),
quantity: 1,
type: GhostfolioOrderType.dividend,
unitPrice: this.formatFloat(unitPrice),
currency: dividendRecord.currency,
currency: record.currency ?? "",
dataSource: "YAHOO",
date: date.format("YYYY-MM-DDTHH:mm:ssZ"),
symbol: security.symbol,
};
}

private mapStandaloneRecord(record: DeGiroRecord, security?: YahooFinanceRecord): GhostfolioActivity {
if (this.isBuyOrSellRecord(record))
return this.mapBuySellRecord(record, [], security);

if (this.isDividendRecord(record))
return this.mapDividendRecord(record, [], security);

if (this.isTransactionFeeRecord(record))
// sometimes there are standalone transaction fee records
return this.mapTransactionFeeRecord(record, security);

this.progress.log(`[i] Unknown standalone record ${record.isin || record.product} from ${record.date} with ${record.amount}${record.currency}! Please add this manually..\n`);
return undefined;
}

private mapPlatformFeeRecord(record: DeGiroRecord): GhostfolioActivity {
const feeAmount = record.getAbsoluteAmount();
const date = dayjs(`${record.date} ${record.time}:00`, "DD-MM-YYYY HH:mm");
Expand Down

0 comments on commit 821fb3e

Please sign in to comment.