diff --git a/src/converters/degiroConverterV3.ts b/src/converters/degiroConverterV3.ts index 40b9e383..5932934e 100644 --- a/src/converters/degiroConverterV3.ts +++ b/src/converters/degiroConverterV3.ts @@ -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(); } @@ -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 ?? "", @@ -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");