degiro-report 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +5 -0
- package/dist/index.js +15 -0
- package/dist/parser.d.ts +21 -0
- package/dist/parser.js +80 -0
- package/dist/report.d.ts +25 -0
- package/dist/report.js +133 -0
- package/package.json +32 -0
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Module dependencies.
|
|
4
|
+
*/
|
|
5
|
+
import { generateReport, formatReport } from "./report.js";
|
|
6
|
+
import { parseCsv } from "./parser.js";
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
const filePath = process.argv[2];
|
|
9
|
+
if (!filePath) {
|
|
10
|
+
console.error("Usage: degiro-report <path-to-degiro-csv>");
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
const transactions = parseCsv(resolve(filePath));
|
|
14
|
+
const reports = generateReport(transactions);
|
|
15
|
+
console.log(formatReport(reports));
|
package/dist/parser.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module dependencies.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Export `Transaction` type.
|
|
6
|
+
*/
|
|
7
|
+
export interface Transaction {
|
|
8
|
+
date: string;
|
|
9
|
+
time: string;
|
|
10
|
+
product: string;
|
|
11
|
+
isin: string;
|
|
12
|
+
quantity: number;
|
|
13
|
+
price: number;
|
|
14
|
+
valueEur: number;
|
|
15
|
+
fees: number;
|
|
16
|
+
totalEur: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Parse CSV.
|
|
20
|
+
*/
|
|
21
|
+
export declare function parseCsv(filePath: string): Transaction[];
|
package/dist/parser.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module dependencies.
|
|
3
|
+
*/
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
/**
|
|
6
|
+
* Parse a European-format number string like "1.234,56" into a JS number.
|
|
7
|
+
*/
|
|
8
|
+
function parseEuNumber(raw) {
|
|
9
|
+
if (!raw || raw.trim() === "")
|
|
10
|
+
return 0;
|
|
11
|
+
const replacedSeparators = raw.replace(/\./g, '').replace(",", ".");
|
|
12
|
+
return parseFloat(replacedSeparators);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Parse a DEGIRO Transactions CSV that uses commas as field delimiters
|
|
16
|
+
* and European number format (commas as decimal separators inside quoted fields).
|
|
17
|
+
*
|
|
18
|
+
* Fields containing commas are wrapped in double-quotes by DEGIRO.
|
|
19
|
+
*/
|
|
20
|
+
function splitCsvLine(line) {
|
|
21
|
+
const fields = [];
|
|
22
|
+
let parsedField = "";
|
|
23
|
+
let inQuotes = false;
|
|
24
|
+
for (let i = 0; i < line.length; i++) {
|
|
25
|
+
const character = line[i];
|
|
26
|
+
if (character === '"') {
|
|
27
|
+
inQuotes = !inQuotes;
|
|
28
|
+
}
|
|
29
|
+
else if (character === "," && !inQuotes) {
|
|
30
|
+
fields.push(parsedField);
|
|
31
|
+
parsedField = "";
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
parsedField += character;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
fields.push(parsedField);
|
|
38
|
+
return fields;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Parse CSV.
|
|
42
|
+
*/
|
|
43
|
+
export function parseCsv(filePath) {
|
|
44
|
+
const content = readFileSync(filePath, "utf-8");
|
|
45
|
+
const lines = content.split("\n").filter((l) => l.trim().length > 0);
|
|
46
|
+
// Skip header
|
|
47
|
+
const dataLines = lines.slice(1);
|
|
48
|
+
return dataLines.map((line) => {
|
|
49
|
+
const cols = splitCsvLine(line);
|
|
50
|
+
// Columns:
|
|
51
|
+
// 0 Date
|
|
52
|
+
// 1 Time
|
|
53
|
+
// 2 Product
|
|
54
|
+
// 3 ISIN
|
|
55
|
+
// 4 Reference exchange
|
|
56
|
+
// 5 Venue
|
|
57
|
+
// 6 Quantity
|
|
58
|
+
// 7 Price
|
|
59
|
+
// 8 (price currency)
|
|
60
|
+
// 9 Local value
|
|
61
|
+
// 10 (local value currency)
|
|
62
|
+
// 11 Value EUR
|
|
63
|
+
// 12 Exchange rate
|
|
64
|
+
// 13 AutoFX Fee
|
|
65
|
+
// 14 Transaction and/or third party fees EUR
|
|
66
|
+
// 15 Total EUR
|
|
67
|
+
// 16 Order ID (sometimes empty before the UUID)
|
|
68
|
+
return {
|
|
69
|
+
date: cols[0],
|
|
70
|
+
time: cols[1],
|
|
71
|
+
product: cols[2],
|
|
72
|
+
isin: cols[3],
|
|
73
|
+
quantity: parseEuNumber(cols[6]),
|
|
74
|
+
price: parseEuNumber(cols[7]),
|
|
75
|
+
valueEur: parseEuNumber(cols[11]),
|
|
76
|
+
fees: parseEuNumber(cols[14]),
|
|
77
|
+
totalEur: parseEuNumber(cols[15]),
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
}
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module dependencies.
|
|
3
|
+
*/
|
|
4
|
+
import type { Transaction } from "./parser.js";
|
|
5
|
+
/**
|
|
6
|
+
* Export `AssetReport` type.
|
|
7
|
+
*/
|
|
8
|
+
export interface AssetReport {
|
|
9
|
+
product: string;
|
|
10
|
+
isin: string;
|
|
11
|
+
buyQuantity: number;
|
|
12
|
+
buyTotalEur: number;
|
|
13
|
+
sellQuantity: number;
|
|
14
|
+
sellTotalEur: number;
|
|
15
|
+
totalFees: number;
|
|
16
|
+
breakEvenPrice: number;
|
|
17
|
+
avgSellPrice: number;
|
|
18
|
+
remainingQuantity: number;
|
|
19
|
+
realizedPnl: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Generate report from transactions list.
|
|
23
|
+
*/
|
|
24
|
+
export declare function generateReport(transactions: Transaction[]): AssetReport[];
|
|
25
|
+
export declare function formatReport(reports: AssetReport[]): string;
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module dependencies.
|
|
3
|
+
*/
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
/**
|
|
6
|
+
* Generate report from transactions list.
|
|
7
|
+
*/
|
|
8
|
+
export function generateReport(transactions) {
|
|
9
|
+
const transactionsByAsset = new Map();
|
|
10
|
+
for (const tx of transactions) {
|
|
11
|
+
const key = tx.isin;
|
|
12
|
+
if (!transactionsByAsset.has(key)) {
|
|
13
|
+
transactionsByAsset.set(key, []);
|
|
14
|
+
}
|
|
15
|
+
transactionsByAsset.get(key).push(tx);
|
|
16
|
+
}
|
|
17
|
+
const reports = [];
|
|
18
|
+
for (const [isin, txs] of transactionsByAsset) {
|
|
19
|
+
// Sort chronologically (oldest first) for FIFO cost basis
|
|
20
|
+
txs.sort((a, b) => {
|
|
21
|
+
const dateA = parseDate(a.date, a.time);
|
|
22
|
+
const dateB = parseDate(b.date, b.time);
|
|
23
|
+
return dateA.getTime() - dateB.getTime();
|
|
24
|
+
});
|
|
25
|
+
const product = txs[0].product;
|
|
26
|
+
let buyQuantity = 0;
|
|
27
|
+
let buyTotalEur = 0; // total cost (positive number)
|
|
28
|
+
let sellQuantity = 0;
|
|
29
|
+
let sellTotalEur = 0; // total proceeds (positive number)
|
|
30
|
+
let totalFees = 0;
|
|
31
|
+
// FIFO lot queue: each entry is { qty, pricePerUnit }
|
|
32
|
+
const lots = [];
|
|
33
|
+
let realizedPnl = 0;
|
|
34
|
+
for (const tx of txs) {
|
|
35
|
+
totalFees += Math.abs(tx.fees);
|
|
36
|
+
if (tx.quantity > 0) {
|
|
37
|
+
// Buy
|
|
38
|
+
const cost = Math.abs(tx.valueEur);
|
|
39
|
+
buyQuantity += tx.quantity;
|
|
40
|
+
buyTotalEur += cost;
|
|
41
|
+
lots.push({ qty: tx.quantity, pricePerUnit: tx.price });
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
// Sell
|
|
45
|
+
const qty = Math.abs(tx.quantity);
|
|
46
|
+
const proceeds = Math.abs(tx.valueEur);
|
|
47
|
+
sellQuantity += qty;
|
|
48
|
+
sellTotalEur += proceeds;
|
|
49
|
+
const sellPrice = tx.price;
|
|
50
|
+
// Match against FIFO lots
|
|
51
|
+
let remaining = qty;
|
|
52
|
+
while (remaining > 0 && lots.length > 0) {
|
|
53
|
+
const lot = lots[0];
|
|
54
|
+
const matched = Math.min(remaining, lot.qty);
|
|
55
|
+
realizedPnl += matched * (sellPrice - lot.pricePerUnit);
|
|
56
|
+
lot.qty -= matched;
|
|
57
|
+
remaining -= matched;
|
|
58
|
+
if (lot.qty <= 0)
|
|
59
|
+
lots.shift();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const remainingQuantity = buyQuantity - sellQuantity;
|
|
64
|
+
const breakEvenPrice = buyQuantity > 0 ? buyTotalEur / buyQuantity : 0;
|
|
65
|
+
const avgSellPrice = sellQuantity > 0 ? sellTotalEur / sellQuantity : 0;
|
|
66
|
+
reports.push({
|
|
67
|
+
product,
|
|
68
|
+
isin,
|
|
69
|
+
buyQuantity,
|
|
70
|
+
buyTotalEur,
|
|
71
|
+
sellQuantity,
|
|
72
|
+
sellTotalEur,
|
|
73
|
+
totalFees,
|
|
74
|
+
breakEvenPrice,
|
|
75
|
+
avgSellPrice,
|
|
76
|
+
remainingQuantity,
|
|
77
|
+
realizedPnl,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
// Sort by product name
|
|
81
|
+
reports.sort((a, b) => a.product.localeCompare(b.product));
|
|
82
|
+
return reports;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Parse date.
|
|
86
|
+
*/
|
|
87
|
+
function parseDate(date, time) {
|
|
88
|
+
// date = "DD-MM-YYYY", time = "HH:MM"
|
|
89
|
+
const [d, m, y] = date.split("-");
|
|
90
|
+
return new Date(`${y}-${m}-${d}T${time}:00`);
|
|
91
|
+
}
|
|
92
|
+
export function formatReport(reports) {
|
|
93
|
+
const lines = [];
|
|
94
|
+
const eur = (n) => n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
95
|
+
const printPnL = (n, text) => (n >= 0 ? chalk.green(text) : chalk.red(text));
|
|
96
|
+
lines.push("=".repeat(80));
|
|
97
|
+
lines.push(" DEGIRO PORTFOLIO REPORT");
|
|
98
|
+
lines.push("=".repeat(80));
|
|
99
|
+
lines.push("");
|
|
100
|
+
let totalRealizedPnl = 0;
|
|
101
|
+
let totalFees = 0;
|
|
102
|
+
let totalBought = 0;
|
|
103
|
+
let totalSold = 0;
|
|
104
|
+
for (const r of reports) {
|
|
105
|
+
totalRealizedPnl += r.realizedPnl;
|
|
106
|
+
totalFees += r.totalFees;
|
|
107
|
+
totalBought += r.buyTotalEur;
|
|
108
|
+
totalSold += r.sellTotalEur;
|
|
109
|
+
const netPnl = r.realizedPnl - r.totalFees;
|
|
110
|
+
lines.push(` ${r.product}`);
|
|
111
|
+
lines.push(` ISIN: ${r.isin}`);
|
|
112
|
+
lines.push("-".repeat(80));
|
|
113
|
+
lines.push(` Bought: ${r.buyQuantity} units @ avg ${eur(r.breakEvenPrice)} EUR = ${eur(r.buyTotalEur)} EUR`);
|
|
114
|
+
lines.push(` Sold: ${r.sellQuantity} units @ avg ${eur(r.avgSellPrice)} EUR = ${eur(r.sellTotalEur)} EUR`);
|
|
115
|
+
lines.push(` Remaining: ${r.remainingQuantity} units`);
|
|
116
|
+
lines.push(` Break-even price: ${eur(r.breakEvenPrice)} EUR`);
|
|
117
|
+
lines.push(` Total fees: ${chalk.yellow(`${eur(r.totalFees)} EUR`)}`);
|
|
118
|
+
lines.push(` Realized P&L: ${printPnL(r.realizedPnl, `${r.realizedPnl >= 0 ? "+" : ""}${eur(r.realizedPnl)} EUR`)}`);
|
|
119
|
+
lines.push(` Net P&L (w/fees): ${printPnL(netPnl, `${netPnl >= 0 ? "+" : ""}${eur(netPnl)} EUR`)}`);
|
|
120
|
+
lines.push("");
|
|
121
|
+
}
|
|
122
|
+
const totalNetPnl = totalRealizedPnl - totalFees;
|
|
123
|
+
lines.push("=".repeat(80));
|
|
124
|
+
lines.push(" SUMMARY");
|
|
125
|
+
lines.push("=".repeat(80));
|
|
126
|
+
lines.push(` Total bought: ${eur(totalBought)} EUR`);
|
|
127
|
+
lines.push(` Total sold: ${eur(totalSold)} EUR`);
|
|
128
|
+
lines.push(` Total fees: ${chalk.yellow(`${eur(totalFees)} EUR`)}`);
|
|
129
|
+
lines.push(` Total realized P&L: ${printPnL(totalRealizedPnl, `${totalRealizedPnl >= 0 ? "+" : ""}${eur(totalRealizedPnl)} EUR`)}`);
|
|
130
|
+
lines.push(` Net P&L (w/fees): ${printPnL(totalNetPnl, `${totalNetPnl >= 0 ? "+" : ""}${eur(totalNetPnl)} EUR`)}`);
|
|
131
|
+
lines.push("");
|
|
132
|
+
return lines.join("\n");
|
|
133
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "degiro-report",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Parse Degiro transaction CSVs and generate portfolio reports",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"degiro-report": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"prepublishOnly": "npm run build"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"degiro",
|
|
19
|
+
"portfolio",
|
|
20
|
+
"transactions",
|
|
21
|
+
"report",
|
|
22
|
+
"csv"
|
|
23
|
+
],
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^22.0.0",
|
|
27
|
+
"typescript": "^5.7.0"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"chalk": "^5.6.2"
|
|
31
|
+
}
|
|
32
|
+
}
|