@gabrielerandelli/minus-tracker 0.7.0 → 0.8.1
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/README.md +168 -32
- package/dist/cli/index.js +224 -82
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +179 -69
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +11 -1
- package/dist/index.d.ts +11 -1
- package/dist/index.js +179 -69
- package/dist/index.js.map +1 -1
- package/dist/mcp/index.js +1885 -0
- package/dist/mcp/index.js.map +1 -0
- package/package.json +18 -5
|
@@ -0,0 +1,1885 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/mcp/server.ts
|
|
4
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
5
|
+
import * as path2 from "path";
|
|
6
|
+
import * as fs3 from "fs";
|
|
7
|
+
import { Ajv } from "ajv";
|
|
8
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
9
|
+
import {
|
|
10
|
+
ListToolsRequestSchema,
|
|
11
|
+
CallToolRequestSchema
|
|
12
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
13
|
+
|
|
14
|
+
// src/mcp/schemas.generated.ts
|
|
15
|
+
var parseTransactionsInputSchema = {
|
|
16
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
17
|
+
"type": "object",
|
|
18
|
+
"properties": {
|
|
19
|
+
"csv": {
|
|
20
|
+
"type": "string"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"required": [
|
|
24
|
+
"csv"
|
|
25
|
+
],
|
|
26
|
+
"additionalProperties": false,
|
|
27
|
+
"definitions": {}
|
|
28
|
+
};
|
|
29
|
+
var classifyInstrumentsInputSchema = {
|
|
30
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
31
|
+
"type": "object",
|
|
32
|
+
"properties": {
|
|
33
|
+
"transactions": {
|
|
34
|
+
"type": "array",
|
|
35
|
+
"items": {
|
|
36
|
+
"$ref": "#/definitions/Transaction"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"existingClassification": {
|
|
40
|
+
"$ref": "#/definitions/ClassificationMap"
|
|
41
|
+
},
|
|
42
|
+
"overrides": {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"additionalProperties": {
|
|
45
|
+
"$ref": "#/definitions/AssetClass"
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"offline": {
|
|
49
|
+
"type": "boolean"
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"required": [
|
|
53
|
+
"transactions"
|
|
54
|
+
],
|
|
55
|
+
"additionalProperties": false,
|
|
56
|
+
"definitions": {
|
|
57
|
+
"Transaction": {
|
|
58
|
+
"type": "object",
|
|
59
|
+
"properties": {
|
|
60
|
+
"isin": {
|
|
61
|
+
"type": "string"
|
|
62
|
+
},
|
|
63
|
+
"product": {
|
|
64
|
+
"type": "string"
|
|
65
|
+
},
|
|
66
|
+
"date": {
|
|
67
|
+
"type": "string"
|
|
68
|
+
},
|
|
69
|
+
"type": {
|
|
70
|
+
"type": "string",
|
|
71
|
+
"enum": [
|
|
72
|
+
"BUY",
|
|
73
|
+
"SELL"
|
|
74
|
+
]
|
|
75
|
+
},
|
|
76
|
+
"quantity": {
|
|
77
|
+
"type": "number"
|
|
78
|
+
},
|
|
79
|
+
"pricePerUnit": {
|
|
80
|
+
"type": "number"
|
|
81
|
+
},
|
|
82
|
+
"currency": {
|
|
83
|
+
"type": "string"
|
|
84
|
+
},
|
|
85
|
+
"totalLocal": {
|
|
86
|
+
"type": "number"
|
|
87
|
+
},
|
|
88
|
+
"totalEUR": {
|
|
89
|
+
"type": "number"
|
|
90
|
+
},
|
|
91
|
+
"feesEUR": {
|
|
92
|
+
"type": "number"
|
|
93
|
+
},
|
|
94
|
+
"fxRate": {
|
|
95
|
+
"type": "number"
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
"required": [
|
|
99
|
+
"isin",
|
|
100
|
+
"product",
|
|
101
|
+
"date",
|
|
102
|
+
"type",
|
|
103
|
+
"quantity",
|
|
104
|
+
"pricePerUnit",
|
|
105
|
+
"currency",
|
|
106
|
+
"totalLocal",
|
|
107
|
+
"totalEUR",
|
|
108
|
+
"feesEUR"
|
|
109
|
+
],
|
|
110
|
+
"additionalProperties": false
|
|
111
|
+
},
|
|
112
|
+
"ClassificationMap": {
|
|
113
|
+
"type": "object",
|
|
114
|
+
"additionalProperties": {
|
|
115
|
+
"$ref": "#/definitions/ClassificationEntry"
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
"ClassificationEntry": {
|
|
119
|
+
"type": "object",
|
|
120
|
+
"properties": {
|
|
121
|
+
"product": {
|
|
122
|
+
"type": "string"
|
|
123
|
+
},
|
|
124
|
+
"assetClass": {
|
|
125
|
+
"$ref": "#/definitions/AssetClass"
|
|
126
|
+
},
|
|
127
|
+
"bucketGain": {
|
|
128
|
+
"type": "string",
|
|
129
|
+
"enum": [
|
|
130
|
+
"A",
|
|
131
|
+
"B"
|
|
132
|
+
]
|
|
133
|
+
},
|
|
134
|
+
"bucketLoss": {
|
|
135
|
+
"type": "string",
|
|
136
|
+
"enum": [
|
|
137
|
+
"A",
|
|
138
|
+
"B"
|
|
139
|
+
]
|
|
140
|
+
},
|
|
141
|
+
"taxRate": {
|
|
142
|
+
"type": "number"
|
|
143
|
+
},
|
|
144
|
+
"whiteListed": {
|
|
145
|
+
"type": [
|
|
146
|
+
"boolean",
|
|
147
|
+
"null"
|
|
148
|
+
]
|
|
149
|
+
},
|
|
150
|
+
"confirmedByUser": {
|
|
151
|
+
"type": "boolean"
|
|
152
|
+
},
|
|
153
|
+
"source": {
|
|
154
|
+
"type": "string",
|
|
155
|
+
"enum": [
|
|
156
|
+
"openfigi",
|
|
157
|
+
"user"
|
|
158
|
+
]
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
"required": [
|
|
162
|
+
"product",
|
|
163
|
+
"assetClass",
|
|
164
|
+
"bucketGain",
|
|
165
|
+
"bucketLoss",
|
|
166
|
+
"taxRate",
|
|
167
|
+
"whiteListed",
|
|
168
|
+
"confirmedByUser",
|
|
169
|
+
"source"
|
|
170
|
+
],
|
|
171
|
+
"additionalProperties": false
|
|
172
|
+
},
|
|
173
|
+
"AssetClass": {
|
|
174
|
+
"type": "string",
|
|
175
|
+
"enum": [
|
|
176
|
+
"ETF",
|
|
177
|
+
"Stock",
|
|
178
|
+
"ETC",
|
|
179
|
+
"GovtBondWL",
|
|
180
|
+
"GovtBondOther",
|
|
181
|
+
"CorpBond",
|
|
182
|
+
"Derivative",
|
|
183
|
+
"LeverageCert",
|
|
184
|
+
"CapProtectedCert"
|
|
185
|
+
]
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
var calculateGainsInputSchema = {
|
|
190
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
191
|
+
"type": "object",
|
|
192
|
+
"properties": {
|
|
193
|
+
"transactions": {
|
|
194
|
+
"type": "array",
|
|
195
|
+
"items": {
|
|
196
|
+
"$ref": "#/definitions/Transaction"
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
"method": {
|
|
200
|
+
"$ref": "#/definitions/LotMethod"
|
|
201
|
+
},
|
|
202
|
+
"parseWarnings": {
|
|
203
|
+
"type": "array",
|
|
204
|
+
"items": {
|
|
205
|
+
"type": "string"
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
"classification": {
|
|
209
|
+
"$ref": "#/definitions/ClassificationMap"
|
|
210
|
+
},
|
|
211
|
+
"carryForward": {
|
|
212
|
+
"type": "array",
|
|
213
|
+
"items": {
|
|
214
|
+
"$ref": "#/definitions/CarryForward"
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
"incomeRows": {
|
|
218
|
+
"type": "array",
|
|
219
|
+
"items": {
|
|
220
|
+
"$ref": "#/definitions/IncomeRow"
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
"required": [
|
|
225
|
+
"transactions",
|
|
226
|
+
"method"
|
|
227
|
+
],
|
|
228
|
+
"additionalProperties": false,
|
|
229
|
+
"definitions": {
|
|
230
|
+
"Transaction": {
|
|
231
|
+
"type": "object",
|
|
232
|
+
"properties": {
|
|
233
|
+
"isin": {
|
|
234
|
+
"type": "string"
|
|
235
|
+
},
|
|
236
|
+
"product": {
|
|
237
|
+
"type": "string"
|
|
238
|
+
},
|
|
239
|
+
"date": {
|
|
240
|
+
"type": "string"
|
|
241
|
+
},
|
|
242
|
+
"type": {
|
|
243
|
+
"type": "string",
|
|
244
|
+
"enum": [
|
|
245
|
+
"BUY",
|
|
246
|
+
"SELL"
|
|
247
|
+
]
|
|
248
|
+
},
|
|
249
|
+
"quantity": {
|
|
250
|
+
"type": "number"
|
|
251
|
+
},
|
|
252
|
+
"pricePerUnit": {
|
|
253
|
+
"type": "number"
|
|
254
|
+
},
|
|
255
|
+
"currency": {
|
|
256
|
+
"type": "string"
|
|
257
|
+
},
|
|
258
|
+
"totalLocal": {
|
|
259
|
+
"type": "number"
|
|
260
|
+
},
|
|
261
|
+
"totalEUR": {
|
|
262
|
+
"type": "number"
|
|
263
|
+
},
|
|
264
|
+
"feesEUR": {
|
|
265
|
+
"type": "number"
|
|
266
|
+
},
|
|
267
|
+
"fxRate": {
|
|
268
|
+
"type": "number"
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
"required": [
|
|
272
|
+
"isin",
|
|
273
|
+
"product",
|
|
274
|
+
"date",
|
|
275
|
+
"type",
|
|
276
|
+
"quantity",
|
|
277
|
+
"pricePerUnit",
|
|
278
|
+
"currency",
|
|
279
|
+
"totalLocal",
|
|
280
|
+
"totalEUR",
|
|
281
|
+
"feesEUR"
|
|
282
|
+
],
|
|
283
|
+
"additionalProperties": false
|
|
284
|
+
},
|
|
285
|
+
"LotMethod": {
|
|
286
|
+
"type": "string",
|
|
287
|
+
"enum": [
|
|
288
|
+
"LIFO",
|
|
289
|
+
"FIFO"
|
|
290
|
+
]
|
|
291
|
+
},
|
|
292
|
+
"ClassificationMap": {
|
|
293
|
+
"type": "object",
|
|
294
|
+
"additionalProperties": {
|
|
295
|
+
"$ref": "#/definitions/ClassificationEntry"
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
"ClassificationEntry": {
|
|
299
|
+
"type": "object",
|
|
300
|
+
"properties": {
|
|
301
|
+
"product": {
|
|
302
|
+
"type": "string"
|
|
303
|
+
},
|
|
304
|
+
"assetClass": {
|
|
305
|
+
"$ref": "#/definitions/AssetClass"
|
|
306
|
+
},
|
|
307
|
+
"bucketGain": {
|
|
308
|
+
"type": "string",
|
|
309
|
+
"enum": [
|
|
310
|
+
"A",
|
|
311
|
+
"B"
|
|
312
|
+
]
|
|
313
|
+
},
|
|
314
|
+
"bucketLoss": {
|
|
315
|
+
"type": "string",
|
|
316
|
+
"enum": [
|
|
317
|
+
"A",
|
|
318
|
+
"B"
|
|
319
|
+
]
|
|
320
|
+
},
|
|
321
|
+
"taxRate": {
|
|
322
|
+
"type": "number"
|
|
323
|
+
},
|
|
324
|
+
"whiteListed": {
|
|
325
|
+
"type": [
|
|
326
|
+
"boolean",
|
|
327
|
+
"null"
|
|
328
|
+
]
|
|
329
|
+
},
|
|
330
|
+
"confirmedByUser": {
|
|
331
|
+
"type": "boolean"
|
|
332
|
+
},
|
|
333
|
+
"source": {
|
|
334
|
+
"type": "string",
|
|
335
|
+
"enum": [
|
|
336
|
+
"openfigi",
|
|
337
|
+
"user"
|
|
338
|
+
]
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
"required": [
|
|
342
|
+
"product",
|
|
343
|
+
"assetClass",
|
|
344
|
+
"bucketGain",
|
|
345
|
+
"bucketLoss",
|
|
346
|
+
"taxRate",
|
|
347
|
+
"whiteListed",
|
|
348
|
+
"confirmedByUser",
|
|
349
|
+
"source"
|
|
350
|
+
],
|
|
351
|
+
"additionalProperties": false
|
|
352
|
+
},
|
|
353
|
+
"AssetClass": {
|
|
354
|
+
"type": "string",
|
|
355
|
+
"enum": [
|
|
356
|
+
"ETF",
|
|
357
|
+
"Stock",
|
|
358
|
+
"ETC",
|
|
359
|
+
"GovtBondWL",
|
|
360
|
+
"GovtBondOther",
|
|
361
|
+
"CorpBond",
|
|
362
|
+
"Derivative",
|
|
363
|
+
"LeverageCert",
|
|
364
|
+
"CapProtectedCert"
|
|
365
|
+
]
|
|
366
|
+
},
|
|
367
|
+
"CarryForward": {
|
|
368
|
+
"type": "object",
|
|
369
|
+
"properties": {
|
|
370
|
+
"year": {
|
|
371
|
+
"type": "number"
|
|
372
|
+
},
|
|
373
|
+
"amount": {
|
|
374
|
+
"type": "number"
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
"required": [
|
|
378
|
+
"year",
|
|
379
|
+
"amount"
|
|
380
|
+
],
|
|
381
|
+
"additionalProperties": false
|
|
382
|
+
},
|
|
383
|
+
"IncomeRow": {
|
|
384
|
+
"type": "object",
|
|
385
|
+
"properties": {
|
|
386
|
+
"isin": {
|
|
387
|
+
"type": "string"
|
|
388
|
+
},
|
|
389
|
+
"product": {
|
|
390
|
+
"type": "string"
|
|
391
|
+
},
|
|
392
|
+
"date": {
|
|
393
|
+
"type": "string"
|
|
394
|
+
},
|
|
395
|
+
"incomeType": {
|
|
396
|
+
"type": "string",
|
|
397
|
+
"enum": [
|
|
398
|
+
"dividend",
|
|
399
|
+
"coupon"
|
|
400
|
+
]
|
|
401
|
+
},
|
|
402
|
+
"grossAmount": {
|
|
403
|
+
"type": "number"
|
|
404
|
+
},
|
|
405
|
+
"withholdingTax": {
|
|
406
|
+
"type": "number"
|
|
407
|
+
},
|
|
408
|
+
"currency": {
|
|
409
|
+
"type": "string"
|
|
410
|
+
},
|
|
411
|
+
"fxRate": {
|
|
412
|
+
"type": "number"
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
"required": [
|
|
416
|
+
"isin",
|
|
417
|
+
"product",
|
|
418
|
+
"date",
|
|
419
|
+
"incomeType",
|
|
420
|
+
"grossAmount",
|
|
421
|
+
"withholdingTax",
|
|
422
|
+
"currency"
|
|
423
|
+
],
|
|
424
|
+
"additionalProperties": false
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
// src/errors.ts
|
|
430
|
+
var ParseError = class extends Error {
|
|
431
|
+
code;
|
|
432
|
+
columnName;
|
|
433
|
+
constructor(code, columnName) {
|
|
434
|
+
const msg = code === "INVALID_CSV" ? "Invalid CSV: unable to parse" : `Missing required column: ${columnName}`;
|
|
435
|
+
super(msg);
|
|
436
|
+
this.name = "ParseError";
|
|
437
|
+
this.code = code;
|
|
438
|
+
this.columnName = columnName;
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
var CalculationError = class extends Error {
|
|
442
|
+
isin;
|
|
443
|
+
date;
|
|
444
|
+
constructor(isin, date) {
|
|
445
|
+
super(`No open lots for ISIN ${isin} on ${date}`);
|
|
446
|
+
this.name = "CalculationError";
|
|
447
|
+
this.isin = isin;
|
|
448
|
+
this.date = date;
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
var ClassificationError = class extends Error {
|
|
452
|
+
code;
|
|
453
|
+
constructor(code, message) {
|
|
454
|
+
super(
|
|
455
|
+
message ?? (code === "SIDECAR_NOT_FOUND" ? "Sidecar file not found" : code === "SIDECAR_VERSION" ? "Sidecar version mismatch" : code === "SIDECAR_MALFORMED" ? "Sidecar file is malformed JSON" : code === "NETWORK_ERROR" ? "Network error contacting OpenFIGI" : "Failed to write sidecar file")
|
|
456
|
+
);
|
|
457
|
+
this.name = "ClassificationError";
|
|
458
|
+
this.code = code;
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
// src/rates/index.ts
|
|
463
|
+
import { fileURLToPath } from "url";
|
|
464
|
+
import * as path from "path";
|
|
465
|
+
import * as fs from "fs";
|
|
466
|
+
var _bundled = void 0;
|
|
467
|
+
function getBundledSnapshot() {
|
|
468
|
+
if (_bundled !== void 0) return _bundled;
|
|
469
|
+
const __dirname2 = path.dirname(fileURLToPath(import.meta.url));
|
|
470
|
+
const bundledPath = path.join(__dirname2, "../data/ecb-rates.json");
|
|
471
|
+
try {
|
|
472
|
+
_bundled = JSON.parse(
|
|
473
|
+
fs.readFileSync(bundledPath, "utf8")
|
|
474
|
+
);
|
|
475
|
+
} catch {
|
|
476
|
+
_bundled = null;
|
|
477
|
+
}
|
|
478
|
+
return _bundled;
|
|
479
|
+
}
|
|
480
|
+
function getUserSnapshot() {
|
|
481
|
+
const platform = process.platform;
|
|
482
|
+
let configDir;
|
|
483
|
+
if (platform === "win32") {
|
|
484
|
+
configDir = process.env.APPDATA ?? path.join(process.env.USERPROFILE ?? "~", "AppData", "Roaming");
|
|
485
|
+
} else {
|
|
486
|
+
configDir = process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME ?? "~", ".config");
|
|
487
|
+
}
|
|
488
|
+
const userPath = path.join(configDir, "minus-tracker", "ecb-rates.json");
|
|
489
|
+
try {
|
|
490
|
+
return JSON.parse(fs.readFileSync(userPath, "utf8"));
|
|
491
|
+
} catch {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
function getActiveSnapshot() {
|
|
496
|
+
const bundled = getBundledSnapshot();
|
|
497
|
+
const user = getUserSnapshot();
|
|
498
|
+
if (!bundled && !user) {
|
|
499
|
+
throw new Error(
|
|
500
|
+
"No ECB rates snapshot available. Run `minus-tracker rates --update` to fetch rates."
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
if (!bundled) return user;
|
|
504
|
+
if (!user) return bundled;
|
|
505
|
+
const merged = {};
|
|
506
|
+
for (const ccy of /* @__PURE__ */ new Set([...Object.keys(bundled), ...Object.keys(user)])) {
|
|
507
|
+
merged[ccy] = { ...bundled[ccy] ?? {}, ...user[ccy] ?? {} };
|
|
508
|
+
}
|
|
509
|
+
return merged;
|
|
510
|
+
}
|
|
511
|
+
function subtractDays(isoDate, days) {
|
|
512
|
+
const d = /* @__PURE__ */ new Date(isoDate + "T00:00:00Z");
|
|
513
|
+
d.setUTCDate(d.getUTCDate() - days);
|
|
514
|
+
return d.toISOString().slice(0, 10);
|
|
515
|
+
}
|
|
516
|
+
function lookupRate(currency, date, snapshot) {
|
|
517
|
+
if (currency === "EUR") return 1;
|
|
518
|
+
const s = snapshot ?? getActiveSnapshot();
|
|
519
|
+
const currencyRates = s[currency];
|
|
520
|
+
if (!currencyRates) return null;
|
|
521
|
+
for (let i = 0; i <= 3; i++) {
|
|
522
|
+
const d = i === 0 ? date : subtractDays(date, i);
|
|
523
|
+
if (currencyRates[d] !== void 0) {
|
|
524
|
+
return currencyRates[d];
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// src/parser/warnings.ts
|
|
531
|
+
function warningToEnglish(w) {
|
|
532
|
+
switch (w.code) {
|
|
533
|
+
case "MISSING_ISIN":
|
|
534
|
+
return `Row ${w.row}: missing ISIN \u2014 skipped`;
|
|
535
|
+
case "UNSUPPORTED_CURRENCY":
|
|
536
|
+
return `Row ${w.row}: unsupported currency ${w.currency} \u2014 skipped`;
|
|
537
|
+
case "NO_ECB_RATE":
|
|
538
|
+
return `Row ${w.row}: no ECB rate for ${w.currency} on ${w.date} \u2014 skipped`;
|
|
539
|
+
case "QUANTITY_ZERO":
|
|
540
|
+
return `Row ${w.row}: quantity is 0 \u2014 skipped`;
|
|
541
|
+
case "MISSING_ISIN_INCOME":
|
|
542
|
+
return `Row ${w.row}: blank ISIN on income row \u2014 skipped`;
|
|
543
|
+
case "ORPHAN_WITHHOLDING":
|
|
544
|
+
return `Withholding row for ${w.isin} on ${w.date}: no matching income row \u2014 skipped`;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// src/parser/index.ts
|
|
549
|
+
var REQUIRED_COLUMNS = [
|
|
550
|
+
"ISIN",
|
|
551
|
+
"Quantity",
|
|
552
|
+
"Price",
|
|
553
|
+
"Date",
|
|
554
|
+
"Local value",
|
|
555
|
+
"Local value currency",
|
|
556
|
+
"Transaction costs",
|
|
557
|
+
"Transaction costs currency",
|
|
558
|
+
"Product"
|
|
559
|
+
];
|
|
560
|
+
var INCOME_KEYWORDS = ["DIVIDEND", "COUPON", "INTEREST", "CEDOLA"];
|
|
561
|
+
var TAX_KEYWORDS = ["DIVIDEND TAX", "WITHHOLDING", "RITENUTA"];
|
|
562
|
+
function parseCSVRow(line) {
|
|
563
|
+
const fields = [];
|
|
564
|
+
let i = 0;
|
|
565
|
+
while (i <= line.length) {
|
|
566
|
+
if (i === line.length) break;
|
|
567
|
+
if (line[i] === '"') {
|
|
568
|
+
let field = "";
|
|
569
|
+
i++;
|
|
570
|
+
while (i < line.length) {
|
|
571
|
+
if (line[i] === '"' && i + 1 < line.length && line[i + 1] === '"') {
|
|
572
|
+
field += '"';
|
|
573
|
+
i += 2;
|
|
574
|
+
} else if (line[i] === '"') {
|
|
575
|
+
i++;
|
|
576
|
+
break;
|
|
577
|
+
} else {
|
|
578
|
+
field += line[i++];
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
fields.push(field);
|
|
582
|
+
if (i < line.length && line[i] === ",") i++;
|
|
583
|
+
} else {
|
|
584
|
+
const start = i;
|
|
585
|
+
while (i < line.length && line[i] !== ",") i++;
|
|
586
|
+
fields.push(line.slice(start, i));
|
|
587
|
+
if (i < line.length) i++;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return fields;
|
|
591
|
+
}
|
|
592
|
+
function parseCSV(input) {
|
|
593
|
+
const normalized = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
594
|
+
return normalized.split("\n").map(parseCSVRow);
|
|
595
|
+
}
|
|
596
|
+
function parseDate(ddmmyyyy) {
|
|
597
|
+
const [dd, mm, yyyy] = ddmmyyyy.split("-");
|
|
598
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
599
|
+
}
|
|
600
|
+
var DEGIROParser = class {
|
|
601
|
+
_warningEntries = [];
|
|
602
|
+
_snapshot;
|
|
603
|
+
_incomeRows = [];
|
|
604
|
+
constructor(snapshot) {
|
|
605
|
+
this._snapshot = snapshot;
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Parse a DEGIRO Transactions CSV export string.
|
|
609
|
+
*
|
|
610
|
+
* @param csv - Raw UTF-8 string from the DEGIRO Transactions export.
|
|
611
|
+
* @returns Array of normalised Transaction objects. Empty if no data rows are found.
|
|
612
|
+
* @throws {ParseError} code `"INVALID_CSV"` — malformed CSV or binary content.
|
|
613
|
+
* @throws {ParseError} code `"MISSING_COLUMN"` (+ `columnName`) — required column absent.
|
|
614
|
+
*
|
|
615
|
+
* Rows with missing ISIN, unsupported currency, zero quantity, or no ECB rate within
|
|
616
|
+
* 3 trading days are skipped silently. Inspect `parser.warnings` for details.
|
|
617
|
+
*/
|
|
618
|
+
parse(csv) {
|
|
619
|
+
this._warningEntries = [];
|
|
620
|
+
this._incomeRows = [];
|
|
621
|
+
if (typeof csv !== "string" || csv.includes("\0")) {
|
|
622
|
+
throw new ParseError("INVALID_CSV");
|
|
623
|
+
}
|
|
624
|
+
let rows;
|
|
625
|
+
try {
|
|
626
|
+
rows = parseCSV(csv);
|
|
627
|
+
} catch {
|
|
628
|
+
throw new ParseError("INVALID_CSV");
|
|
629
|
+
}
|
|
630
|
+
if (rows.length === 0) {
|
|
631
|
+
throw new ParseError("INVALID_CSV");
|
|
632
|
+
}
|
|
633
|
+
const header = rows[0];
|
|
634
|
+
const colIndex = {};
|
|
635
|
+
for (let i = 0; i < header.length; i++) {
|
|
636
|
+
colIndex[header[i].trim()] = i;
|
|
637
|
+
}
|
|
638
|
+
for (const col of REQUIRED_COLUMNS) {
|
|
639
|
+
if (colIndex[col] === void 0) {
|
|
640
|
+
throw new ParseError("MISSING_COLUMN", col);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
const snapshot = this._snapshot ?? getActiveSnapshot();
|
|
644
|
+
const transactions = [];
|
|
645
|
+
const incomeCandidates = [];
|
|
646
|
+
const withholdingCandidates = [];
|
|
647
|
+
for (let r = 1; r < rows.length; r++) {
|
|
648
|
+
const row = rows[r];
|
|
649
|
+
const rowIndex = r + 1;
|
|
650
|
+
if (row.every((cell) => cell.trim() === "")) continue;
|
|
651
|
+
const get = (col) => (row[colIndex[col]] ?? "").trim();
|
|
652
|
+
const isin = get("ISIN");
|
|
653
|
+
const product = get("Product");
|
|
654
|
+
const qtyStr = get("Quantity");
|
|
655
|
+
const rawQty = parseFloat(qtyStr);
|
|
656
|
+
const qtyIsZeroOrBlank = qtyStr === "" || isNaN(rawQty) || rawQty === 0;
|
|
657
|
+
if (qtyIsZeroOrBlank) {
|
|
658
|
+
const upperProduct = product.toUpperCase();
|
|
659
|
+
const isTaxKeyword = TAX_KEYWORDS.some((k) => upperProduct.includes(k));
|
|
660
|
+
const isIncomeKeyword = !isTaxKeyword && INCOME_KEYWORDS.some((k) => upperProduct.includes(k));
|
|
661
|
+
if (isTaxKeyword || isIncomeKeyword) {
|
|
662
|
+
const rawLocalValue = parseFloat(get("Local value"));
|
|
663
|
+
const localValue = isNaN(rawLocalValue) ? 0 : rawLocalValue;
|
|
664
|
+
const isoDate2 = parseDate(get("Date"));
|
|
665
|
+
if (isTaxKeyword && localValue < 0) {
|
|
666
|
+
if (!isin) {
|
|
667
|
+
this._warningEntries.push({
|
|
668
|
+
code: "MISSING_ISIN_INCOME",
|
|
669
|
+
row: rowIndex
|
|
670
|
+
});
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
withholdingCandidates.push({
|
|
674
|
+
isin,
|
|
675
|
+
date: isoDate2,
|
|
676
|
+
amount: Math.abs(localValue),
|
|
677
|
+
row: rowIndex
|
|
678
|
+
});
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
if (isIncomeKeyword && localValue > 0) {
|
|
682
|
+
if (!isin) {
|
|
683
|
+
this._warningEntries.push({
|
|
684
|
+
code: "MISSING_ISIN_INCOME",
|
|
685
|
+
row: rowIndex
|
|
686
|
+
});
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
const incomeType = upperProduct.includes(
|
|
690
|
+
"DIVIDEND"
|
|
691
|
+
) ? "dividend" : "coupon";
|
|
692
|
+
incomeCandidates.push({
|
|
693
|
+
isin,
|
|
694
|
+
product,
|
|
695
|
+
date: isoDate2,
|
|
696
|
+
incomeType,
|
|
697
|
+
localValue,
|
|
698
|
+
currency: get("Local value currency"),
|
|
699
|
+
row: rowIndex
|
|
700
|
+
});
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
if (!isin) {
|
|
706
|
+
this._warningEntries.push({ code: "MISSING_ISIN", row: rowIndex });
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
if (isNaN(rawQty) || rawQty === 0) {
|
|
710
|
+
this._warningEntries.push({ code: "QUANTITY_ZERO", row: rowIndex });
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
const type = rawQty > 0 ? "BUY" : "SELL";
|
|
714
|
+
const quantity = Math.abs(rawQty);
|
|
715
|
+
const isoDate = parseDate(get("Date"));
|
|
716
|
+
const currency = get("Local value currency");
|
|
717
|
+
const rawTotalLocal = parseFloat(get("Local value"));
|
|
718
|
+
const totalLocal = isNaN(rawTotalLocal) ? 0 : rawTotalLocal;
|
|
719
|
+
let totalEUR;
|
|
720
|
+
let fxRate;
|
|
721
|
+
if (currency === "EUR") {
|
|
722
|
+
totalEUR = Math.abs(totalLocal);
|
|
723
|
+
fxRate = void 0;
|
|
724
|
+
} else {
|
|
725
|
+
const rate = lookupRate(currency, isoDate, snapshot);
|
|
726
|
+
if (rate === null) {
|
|
727
|
+
if (snapshot[currency] === void 0) {
|
|
728
|
+
this._warningEntries.push({
|
|
729
|
+
code: "UNSUPPORTED_CURRENCY",
|
|
730
|
+
row: rowIndex,
|
|
731
|
+
currency
|
|
732
|
+
});
|
|
733
|
+
} else {
|
|
734
|
+
this._warningEntries.push({
|
|
735
|
+
code: "NO_ECB_RATE",
|
|
736
|
+
row: rowIndex,
|
|
737
|
+
currency,
|
|
738
|
+
date: isoDate
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
totalEUR = Math.abs(totalLocal) / rate;
|
|
744
|
+
fxRate = rate;
|
|
745
|
+
}
|
|
746
|
+
const rawFees = parseFloat(get("Transaction costs"));
|
|
747
|
+
const feesEUR = Math.abs(isNaN(rawFees) ? 0 : rawFees);
|
|
748
|
+
const pricePerUnit = parseFloat(get("Price"));
|
|
749
|
+
transactions.push({
|
|
750
|
+
isin,
|
|
751
|
+
product,
|
|
752
|
+
date: isoDate,
|
|
753
|
+
type,
|
|
754
|
+
quantity,
|
|
755
|
+
pricePerUnit,
|
|
756
|
+
currency,
|
|
757
|
+
totalLocal,
|
|
758
|
+
totalEUR,
|
|
759
|
+
feesEUR,
|
|
760
|
+
fxRate
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
const withholdingByKey = /* @__PURE__ */ new Map();
|
|
764
|
+
for (const w of withholdingCandidates) {
|
|
765
|
+
const key = `${w.isin}|${w.date}`;
|
|
766
|
+
const existing = withholdingByKey.get(key);
|
|
767
|
+
withholdingByKey.set(key, {
|
|
768
|
+
isin: w.isin,
|
|
769
|
+
date: w.date,
|
|
770
|
+
amount: (existing?.amount ?? 0) + w.amount
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
const consumedKeys = /* @__PURE__ */ new Set();
|
|
774
|
+
const incomeRows = [];
|
|
775
|
+
for (const c of incomeCandidates) {
|
|
776
|
+
const key = `${c.isin}|${c.date}`;
|
|
777
|
+
const matchedWithholding = withholdingByKey.get(key);
|
|
778
|
+
let grossAmount;
|
|
779
|
+
let withholdingTax;
|
|
780
|
+
let fxRate;
|
|
781
|
+
if (c.currency === "EUR") {
|
|
782
|
+
grossAmount = Math.abs(c.localValue);
|
|
783
|
+
withholdingTax = matchedWithholding?.amount ?? 0;
|
|
784
|
+
fxRate = void 0;
|
|
785
|
+
} else {
|
|
786
|
+
const rate = lookupRate(c.currency, c.date, snapshot);
|
|
787
|
+
if (rate === null) {
|
|
788
|
+
if (snapshot[c.currency] === void 0) {
|
|
789
|
+
this._warningEntries.push({
|
|
790
|
+
code: "UNSUPPORTED_CURRENCY",
|
|
791
|
+
row: c.row,
|
|
792
|
+
currency: c.currency
|
|
793
|
+
});
|
|
794
|
+
} else {
|
|
795
|
+
this._warningEntries.push({
|
|
796
|
+
code: "NO_ECB_RATE",
|
|
797
|
+
row: c.row,
|
|
798
|
+
currency: c.currency,
|
|
799
|
+
date: c.date
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
grossAmount = Math.abs(c.localValue) / rate;
|
|
805
|
+
withholdingTax = matchedWithholding ? matchedWithholding.amount / rate : 0;
|
|
806
|
+
fxRate = rate;
|
|
807
|
+
}
|
|
808
|
+
if (matchedWithholding) consumedKeys.add(key);
|
|
809
|
+
incomeRows.push({
|
|
810
|
+
isin: c.isin,
|
|
811
|
+
product: c.product,
|
|
812
|
+
date: c.date,
|
|
813
|
+
incomeType: c.incomeType,
|
|
814
|
+
grossAmount,
|
|
815
|
+
withholdingTax,
|
|
816
|
+
currency: c.currency,
|
|
817
|
+
fxRate
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
for (const [key, w] of withholdingByKey) {
|
|
821
|
+
if (!consumedKeys.has(key)) {
|
|
822
|
+
this._warningEntries.push({
|
|
823
|
+
code: "ORPHAN_WITHHOLDING",
|
|
824
|
+
isin: w.isin,
|
|
825
|
+
date: w.date
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
this._incomeRows = incomeRows;
|
|
830
|
+
return transactions;
|
|
831
|
+
}
|
|
832
|
+
get warnings() {
|
|
833
|
+
return this._warningEntries.map(warningToEnglish);
|
|
834
|
+
}
|
|
835
|
+
get warningEntries() {
|
|
836
|
+
return this._warningEntries;
|
|
837
|
+
}
|
|
838
|
+
get incomeRows() {
|
|
839
|
+
return this._incomeRows;
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
// src/mcp/errors.ts
|
|
844
|
+
function toErrorResult(fields) {
|
|
845
|
+
return {
|
|
846
|
+
isError: true,
|
|
847
|
+
content: [{ type: "text", text: JSON.stringify(fields) }]
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
function toParseErrorResult(err) {
|
|
851
|
+
return toErrorResult({
|
|
852
|
+
code: err.code,
|
|
853
|
+
columnName: err.columnName,
|
|
854
|
+
message: err.message
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
function toClassificationErrorResult(err) {
|
|
858
|
+
return toErrorResult({
|
|
859
|
+
code: err.code,
|
|
860
|
+
message: err.message
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
function toCalculationErrorResult(err) {
|
|
864
|
+
return toErrorResult({
|
|
865
|
+
code: "CALCULATION_ERROR",
|
|
866
|
+
isin: err.isin,
|
|
867
|
+
date: err.date,
|
|
868
|
+
message: err.message
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// src/mcp/tools/parse-transactions.ts
|
|
873
|
+
async function handleParseTransactions(args) {
|
|
874
|
+
const parser = new DEGIROParser();
|
|
875
|
+
try {
|
|
876
|
+
const transactions = parser.parse(args.csv);
|
|
877
|
+
return {
|
|
878
|
+
content: [
|
|
879
|
+
{
|
|
880
|
+
type: "text",
|
|
881
|
+
text: JSON.stringify({
|
|
882
|
+
transactions,
|
|
883
|
+
warnings: parser.warnings,
|
|
884
|
+
incomeRows: parser.incomeRows
|
|
885
|
+
})
|
|
886
|
+
}
|
|
887
|
+
]
|
|
888
|
+
};
|
|
889
|
+
} catch (err) {
|
|
890
|
+
if (err instanceof ParseError) return toParseErrorResult(err);
|
|
891
|
+
throw err;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// src/classifier/index.ts
|
|
896
|
+
import * as fs2 from "fs";
|
|
897
|
+
import * as https from "https";
|
|
898
|
+
function httpsPost(url, body, timeoutMs) {
|
|
899
|
+
return new Promise((resolve, reject) => {
|
|
900
|
+
const u = new URL(url);
|
|
901
|
+
const req = https.request(
|
|
902
|
+
{
|
|
903
|
+
hostname: u.hostname,
|
|
904
|
+
path: u.pathname + u.search,
|
|
905
|
+
method: "POST",
|
|
906
|
+
headers: {
|
|
907
|
+
"Content-Type": "application/json",
|
|
908
|
+
"Content-Length": Buffer.byteLength(body)
|
|
909
|
+
}
|
|
910
|
+
},
|
|
911
|
+
(res) => {
|
|
912
|
+
let data = "";
|
|
913
|
+
res.on("data", (chunk) => {
|
|
914
|
+
data += chunk;
|
|
915
|
+
});
|
|
916
|
+
res.on("end", () => resolve({ status: res.statusCode ?? 0, data }));
|
|
917
|
+
}
|
|
918
|
+
);
|
|
919
|
+
req.setTimeout(timeoutMs, () => {
|
|
920
|
+
req.destroy();
|
|
921
|
+
reject(new Error("timeout"));
|
|
922
|
+
});
|
|
923
|
+
req.on("error", reject);
|
|
924
|
+
req.write(body);
|
|
925
|
+
req.end();
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
var GOVT_BOND_WHITELIST = /* @__PURE__ */ new Set([
|
|
929
|
+
"IT",
|
|
930
|
+
"DE",
|
|
931
|
+
"FR",
|
|
932
|
+
"AT",
|
|
933
|
+
"BE",
|
|
934
|
+
"NL",
|
|
935
|
+
"ES",
|
|
936
|
+
"PT",
|
|
937
|
+
"FI",
|
|
938
|
+
"IE",
|
|
939
|
+
"LU",
|
|
940
|
+
"GR",
|
|
941
|
+
"SK",
|
|
942
|
+
"SI",
|
|
943
|
+
"LT",
|
|
944
|
+
"LV",
|
|
945
|
+
"EE",
|
|
946
|
+
"MT",
|
|
947
|
+
"CY",
|
|
948
|
+
"US",
|
|
949
|
+
"GB",
|
|
950
|
+
"CH",
|
|
951
|
+
"NO",
|
|
952
|
+
"SE",
|
|
953
|
+
"DK",
|
|
954
|
+
"JP",
|
|
955
|
+
"CA",
|
|
956
|
+
"AU",
|
|
957
|
+
"NZ",
|
|
958
|
+
"SG",
|
|
959
|
+
"HK",
|
|
960
|
+
"KR"
|
|
961
|
+
]);
|
|
962
|
+
var SECURITY_TYPE_MAP = {
|
|
963
|
+
ETP: {
|
|
964
|
+
assetClass: "ETF",
|
|
965
|
+
bucketGain: "A",
|
|
966
|
+
bucketLoss: "B",
|
|
967
|
+
taxRate: 0.26,
|
|
968
|
+
whiteListed: null
|
|
969
|
+
},
|
|
970
|
+
ETF: {
|
|
971
|
+
assetClass: "ETF",
|
|
972
|
+
bucketGain: "A",
|
|
973
|
+
bucketLoss: "B",
|
|
974
|
+
taxRate: 0.26,
|
|
975
|
+
whiteListed: null
|
|
976
|
+
},
|
|
977
|
+
"Mutual Fund": {
|
|
978
|
+
assetClass: "ETF",
|
|
979
|
+
bucketGain: "A",
|
|
980
|
+
bucketLoss: "B",
|
|
981
|
+
taxRate: 0.26,
|
|
982
|
+
whiteListed: null
|
|
983
|
+
},
|
|
984
|
+
"Open-End Fund": {
|
|
985
|
+
assetClass: "ETF",
|
|
986
|
+
bucketGain: "A",
|
|
987
|
+
bucketLoss: "B",
|
|
988
|
+
taxRate: 0.26,
|
|
989
|
+
whiteListed: null
|
|
990
|
+
},
|
|
991
|
+
"Common Stock": {
|
|
992
|
+
assetClass: "Stock",
|
|
993
|
+
bucketGain: "B",
|
|
994
|
+
bucketLoss: "B",
|
|
995
|
+
taxRate: 0,
|
|
996
|
+
whiteListed: null
|
|
997
|
+
},
|
|
998
|
+
Equity: {
|
|
999
|
+
assetClass: "Stock",
|
|
1000
|
+
bucketGain: "B",
|
|
1001
|
+
bucketLoss: "B",
|
|
1002
|
+
taxRate: 0,
|
|
1003
|
+
whiteListed: null
|
|
1004
|
+
},
|
|
1005
|
+
"Exchange Traded Commodity": {
|
|
1006
|
+
assetClass: "ETC",
|
|
1007
|
+
bucketGain: "B",
|
|
1008
|
+
bucketLoss: "B",
|
|
1009
|
+
taxRate: 0,
|
|
1010
|
+
whiteListed: null
|
|
1011
|
+
},
|
|
1012
|
+
"Corporate Bond": {
|
|
1013
|
+
assetClass: "CorpBond",
|
|
1014
|
+
bucketGain: "B",
|
|
1015
|
+
bucketLoss: "B",
|
|
1016
|
+
taxRate: 0,
|
|
1017
|
+
whiteListed: null
|
|
1018
|
+
},
|
|
1019
|
+
Option: {
|
|
1020
|
+
assetClass: "Derivative",
|
|
1021
|
+
bucketGain: "B",
|
|
1022
|
+
bucketLoss: "B",
|
|
1023
|
+
taxRate: 0,
|
|
1024
|
+
whiteListed: null
|
|
1025
|
+
},
|
|
1026
|
+
Future: {
|
|
1027
|
+
assetClass: "Derivative",
|
|
1028
|
+
bucketGain: "B",
|
|
1029
|
+
bucketLoss: "B",
|
|
1030
|
+
taxRate: 0,
|
|
1031
|
+
whiteListed: null
|
|
1032
|
+
},
|
|
1033
|
+
Warrant: {
|
|
1034
|
+
assetClass: "Derivative",
|
|
1035
|
+
bucketGain: "B",
|
|
1036
|
+
bucketLoss: "B",
|
|
1037
|
+
taxRate: 0,
|
|
1038
|
+
whiteListed: null
|
|
1039
|
+
},
|
|
1040
|
+
CFD: {
|
|
1041
|
+
assetClass: "Derivative",
|
|
1042
|
+
bucketGain: "B",
|
|
1043
|
+
bucketLoss: "B",
|
|
1044
|
+
taxRate: 0,
|
|
1045
|
+
whiteListed: null
|
|
1046
|
+
},
|
|
1047
|
+
Swap: {
|
|
1048
|
+
assetClass: "Derivative",
|
|
1049
|
+
bucketGain: "B",
|
|
1050
|
+
bucketLoss: "B",
|
|
1051
|
+
taxRate: 0,
|
|
1052
|
+
whiteListed: null
|
|
1053
|
+
},
|
|
1054
|
+
"Leverage Certificate": {
|
|
1055
|
+
assetClass: "LeverageCert",
|
|
1056
|
+
bucketGain: "B",
|
|
1057
|
+
bucketLoss: "B",
|
|
1058
|
+
taxRate: 0,
|
|
1059
|
+
whiteListed: null
|
|
1060
|
+
},
|
|
1061
|
+
"Turbo Certificate": {
|
|
1062
|
+
assetClass: "LeverageCert",
|
|
1063
|
+
bucketGain: "B",
|
|
1064
|
+
bucketLoss: "B",
|
|
1065
|
+
taxRate: 0,
|
|
1066
|
+
whiteListed: null
|
|
1067
|
+
},
|
|
1068
|
+
"Capital Protected Certificate": {
|
|
1069
|
+
assetClass: "CapProtectedCert",
|
|
1070
|
+
bucketGain: "A",
|
|
1071
|
+
bucketLoss: "B",
|
|
1072
|
+
taxRate: 0.26,
|
|
1073
|
+
whiteListed: null
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
|
+
function isKnownType(t) {
|
|
1077
|
+
return t === "Government Bond" || t in SECURITY_TYPE_MAP;
|
|
1078
|
+
}
|
|
1079
|
+
function classifyByType(isin, securityType, product, warnings) {
|
|
1080
|
+
if (securityType === "Government Bond") {
|
|
1081
|
+
const prefix = isin.slice(0, 2).toUpperCase();
|
|
1082
|
+
const whiteListed = GOVT_BOND_WHITELIST.has(prefix);
|
|
1083
|
+
return {
|
|
1084
|
+
product,
|
|
1085
|
+
assetClass: whiteListed ? "GovtBondWL" : "GovtBondOther",
|
|
1086
|
+
bucketGain: "A",
|
|
1087
|
+
bucketLoss: "B",
|
|
1088
|
+
taxRate: whiteListed ? 0.125 : 0.26,
|
|
1089
|
+
whiteListed,
|
|
1090
|
+
confirmedByUser: false,
|
|
1091
|
+
source: "openfigi"
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
const mapped = SECURITY_TYPE_MAP[securityType];
|
|
1095
|
+
if (mapped) {
|
|
1096
|
+
return {
|
|
1097
|
+
product,
|
|
1098
|
+
assetClass: mapped.assetClass,
|
|
1099
|
+
bucketGain: mapped.bucketGain,
|
|
1100
|
+
bucketLoss: mapped.bucketLoss,
|
|
1101
|
+
taxRate: mapped.taxRate,
|
|
1102
|
+
whiteListed: mapped.whiteListed,
|
|
1103
|
+
confirmedByUser: false,
|
|
1104
|
+
source: "openfigi"
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
warnings.push(
|
|
1108
|
+
`Unrecognized type for ${isin}: ${securityType}. Please classify manually.`
|
|
1109
|
+
);
|
|
1110
|
+
return {
|
|
1111
|
+
product,
|
|
1112
|
+
assetClass: "Stock",
|
|
1113
|
+
bucketGain: "B",
|
|
1114
|
+
bucketLoss: "B",
|
|
1115
|
+
taxRate: 0,
|
|
1116
|
+
whiteListed: null,
|
|
1117
|
+
confirmedByUser: false,
|
|
1118
|
+
source: "user"
|
|
1119
|
+
};
|
|
1120
|
+
}
|
|
1121
|
+
var ASSET_CLASS_DEFAULTS = {
|
|
1122
|
+
ETF: {
|
|
1123
|
+
assetClass: "ETF",
|
|
1124
|
+
bucketGain: "A",
|
|
1125
|
+
bucketLoss: "B",
|
|
1126
|
+
taxRate: 0.26,
|
|
1127
|
+
whiteListed: null
|
|
1128
|
+
},
|
|
1129
|
+
Stock: {
|
|
1130
|
+
assetClass: "Stock",
|
|
1131
|
+
bucketGain: "B",
|
|
1132
|
+
bucketLoss: "B",
|
|
1133
|
+
taxRate: 0,
|
|
1134
|
+
whiteListed: null
|
|
1135
|
+
},
|
|
1136
|
+
ETC: {
|
|
1137
|
+
assetClass: "ETC",
|
|
1138
|
+
bucketGain: "B",
|
|
1139
|
+
bucketLoss: "B",
|
|
1140
|
+
taxRate: 0,
|
|
1141
|
+
whiteListed: null
|
|
1142
|
+
},
|
|
1143
|
+
GovtBondWL: {
|
|
1144
|
+
assetClass: "GovtBondWL",
|
|
1145
|
+
bucketGain: "A",
|
|
1146
|
+
bucketLoss: "B",
|
|
1147
|
+
taxRate: 0.125,
|
|
1148
|
+
whiteListed: true
|
|
1149
|
+
},
|
|
1150
|
+
GovtBondOther: {
|
|
1151
|
+
assetClass: "GovtBondOther",
|
|
1152
|
+
bucketGain: "A",
|
|
1153
|
+
bucketLoss: "B",
|
|
1154
|
+
taxRate: 0.26,
|
|
1155
|
+
whiteListed: false
|
|
1156
|
+
},
|
|
1157
|
+
CorpBond: {
|
|
1158
|
+
assetClass: "CorpBond",
|
|
1159
|
+
bucketGain: "B",
|
|
1160
|
+
bucketLoss: "B",
|
|
1161
|
+
taxRate: 0,
|
|
1162
|
+
whiteListed: null
|
|
1163
|
+
},
|
|
1164
|
+
Derivative: {
|
|
1165
|
+
assetClass: "Derivative",
|
|
1166
|
+
bucketGain: "B",
|
|
1167
|
+
bucketLoss: "B",
|
|
1168
|
+
taxRate: 0,
|
|
1169
|
+
whiteListed: null
|
|
1170
|
+
},
|
|
1171
|
+
LeverageCert: {
|
|
1172
|
+
assetClass: "LeverageCert",
|
|
1173
|
+
bucketGain: "B",
|
|
1174
|
+
bucketLoss: "B",
|
|
1175
|
+
taxRate: 0,
|
|
1176
|
+
whiteListed: null
|
|
1177
|
+
},
|
|
1178
|
+
CapProtectedCert: {
|
|
1179
|
+
assetClass: "CapProtectedCert",
|
|
1180
|
+
bucketGain: "A",
|
|
1181
|
+
bucketLoss: "B",
|
|
1182
|
+
taxRate: 0.26,
|
|
1183
|
+
whiteListed: null
|
|
1184
|
+
}
|
|
1185
|
+
};
|
|
1186
|
+
function buildEntryFromAssetClass(assetClass, product) {
|
|
1187
|
+
const mapped = ASSET_CLASS_DEFAULTS[assetClass];
|
|
1188
|
+
return {
|
|
1189
|
+
product,
|
|
1190
|
+
assetClass: mapped.assetClass,
|
|
1191
|
+
bucketGain: mapped.bucketGain,
|
|
1192
|
+
bucketLoss: mapped.bucketLoss,
|
|
1193
|
+
taxRate: mapped.taxRate,
|
|
1194
|
+
whiteListed: mapped.whiteListed,
|
|
1195
|
+
confirmedByUser: true,
|
|
1196
|
+
source: "user"
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
var Classifier = class {
|
|
1200
|
+
interactive;
|
|
1201
|
+
constructor(options) {
|
|
1202
|
+
this.interactive = options?.interactive ?? true;
|
|
1203
|
+
}
|
|
1204
|
+
async load(sidecarPath) {
|
|
1205
|
+
if (!fs2.existsSync(sidecarPath)) {
|
|
1206
|
+
throw new ClassificationError("SIDECAR_NOT_FOUND");
|
|
1207
|
+
}
|
|
1208
|
+
let parsed;
|
|
1209
|
+
try {
|
|
1210
|
+
const raw = fs2.readFileSync(sidecarPath, "utf-8");
|
|
1211
|
+
parsed = JSON.parse(raw);
|
|
1212
|
+
} catch {
|
|
1213
|
+
throw new ClassificationError("SIDECAR_MALFORMED");
|
|
1214
|
+
}
|
|
1215
|
+
if (typeof parsed !== "object" || parsed === null || parsed["version"] !== 1) {
|
|
1216
|
+
throw new ClassificationError("SIDECAR_VERSION");
|
|
1217
|
+
}
|
|
1218
|
+
return parsed.classifications;
|
|
1219
|
+
}
|
|
1220
|
+
async classify(transactions, sidecarPath, options, _httpPost = httpsPost) {
|
|
1221
|
+
const isinToProduct = /* @__PURE__ */ new Map();
|
|
1222
|
+
for (const tx of transactions) {
|
|
1223
|
+
if (tx.isin && !isinToProduct.has(tx.isin)) {
|
|
1224
|
+
isinToProduct.set(tx.isin, tx.product);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
const confirmed = {};
|
|
1228
|
+
if (sidecarPath !== void 0) {
|
|
1229
|
+
if (fs2.existsSync(sidecarPath)) {
|
|
1230
|
+
const existingMap = await this.load(sidecarPath);
|
|
1231
|
+
for (const [isin, entry] of Object.entries(existingMap)) {
|
|
1232
|
+
if (entry.confirmedByUser) {
|
|
1233
|
+
confirmed[isin] = entry;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
} else if (options?.existingClassification) {
|
|
1238
|
+
Object.assign(confirmed, options.existingClassification);
|
|
1239
|
+
}
|
|
1240
|
+
for (const [isin, assetClass] of Object.entries(options?.overrides ?? {})) {
|
|
1241
|
+
const product = isinToProduct.get(isin) ?? isin;
|
|
1242
|
+
confirmed[isin] = buildEntryFromAssetClass(assetClass, product);
|
|
1243
|
+
}
|
|
1244
|
+
const toProcess = [];
|
|
1245
|
+
for (const isin of isinToProduct.keys()) {
|
|
1246
|
+
if (!(isin in confirmed)) {
|
|
1247
|
+
toProcess.push(isin);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
const warnings = [];
|
|
1251
|
+
const newEntries = {};
|
|
1252
|
+
if (options?.offline) {
|
|
1253
|
+
for (const isin of toProcess) {
|
|
1254
|
+
const product = isinToProduct.get(isin) ?? isin;
|
|
1255
|
+
warnings.push(
|
|
1256
|
+
`Unrecognized type for ${isin}: unknown. Please classify manually.`
|
|
1257
|
+
);
|
|
1258
|
+
newEntries[isin] = {
|
|
1259
|
+
product,
|
|
1260
|
+
assetClass: "Stock",
|
|
1261
|
+
bucketGain: "B",
|
|
1262
|
+
bucketLoss: "B",
|
|
1263
|
+
taxRate: 0,
|
|
1264
|
+
whiteListed: null,
|
|
1265
|
+
confirmedByUser: false,
|
|
1266
|
+
source: "user"
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
} else {
|
|
1270
|
+
const BATCH_SIZE = 10;
|
|
1271
|
+
const totalBatches = Math.ceil(toProcess.length / BATCH_SIZE);
|
|
1272
|
+
for (let i = 0; i < toProcess.length; i += BATCH_SIZE) {
|
|
1273
|
+
if (i > 0) {
|
|
1274
|
+
await new Promise((resolve) => setTimeout(resolve, 6e3));
|
|
1275
|
+
}
|
|
1276
|
+
const batch = toProcess.slice(i, i + BATCH_SIZE);
|
|
1277
|
+
const requestBody = JSON.stringify(
|
|
1278
|
+
batch.map((isin) => ({ idType: "ID_ISIN", idValue: isin }))
|
|
1279
|
+
);
|
|
1280
|
+
let response = null;
|
|
1281
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1282
|
+
try {
|
|
1283
|
+
const r = await _httpPost(
|
|
1284
|
+
"https://api.openfigi.com/v3/mapping",
|
|
1285
|
+
requestBody,
|
|
1286
|
+
1e4
|
|
1287
|
+
);
|
|
1288
|
+
if (r.status < 500) {
|
|
1289
|
+
response = r;
|
|
1290
|
+
break;
|
|
1291
|
+
}
|
|
1292
|
+
} catch {
|
|
1293
|
+
throw new ClassificationError("NETWORK_ERROR");
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
if (response === null) {
|
|
1297
|
+
throw new ClassificationError("NETWORK_ERROR");
|
|
1298
|
+
}
|
|
1299
|
+
const results = JSON.parse(response.data);
|
|
1300
|
+
for (let j = 0; j < batch.length; j++) {
|
|
1301
|
+
const isin = batch[j];
|
|
1302
|
+
const result = results[j];
|
|
1303
|
+
const product = isinToProduct.get(isin) ?? isin;
|
|
1304
|
+
if (!result || result.error || !result.data || result.data.length === 0) {
|
|
1305
|
+
warnings.push(
|
|
1306
|
+
`Unrecognized type for ${isin}: unknown. Please classify manually.`
|
|
1307
|
+
);
|
|
1308
|
+
newEntries[isin] = {
|
|
1309
|
+
product,
|
|
1310
|
+
assetClass: "Stock",
|
|
1311
|
+
bucketGain: "B",
|
|
1312
|
+
bucketLoss: "B",
|
|
1313
|
+
taxRate: 0,
|
|
1314
|
+
whiteListed: null,
|
|
1315
|
+
confirmedByUser: false,
|
|
1316
|
+
source: "user"
|
|
1317
|
+
};
|
|
1318
|
+
continue;
|
|
1319
|
+
}
|
|
1320
|
+
const st = result.data[0]?.securityType;
|
|
1321
|
+
const st2 = result.data[0]?.securityType2;
|
|
1322
|
+
const typeToUse = st && isKnownType(st) ? st : st2 && isKnownType(st2) ? st2 : st ?? st2 ?? "Unknown";
|
|
1323
|
+
newEntries[isin] = classifyByType(isin, typeToUse, product, warnings);
|
|
1324
|
+
}
|
|
1325
|
+
const batchIndex = i / BATCH_SIZE;
|
|
1326
|
+
options?.onBatchProgress?.(batchIndex + 1, totalBatches);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
const mergedMap = { ...newEntries, ...confirmed };
|
|
1330
|
+
if (sidecarPath !== void 0) {
|
|
1331
|
+
const sidecarContent = JSON.stringify(
|
|
1332
|
+
{
|
|
1333
|
+
version: 1,
|
|
1334
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1335
|
+
classifications: mergedMap,
|
|
1336
|
+
warnings
|
|
1337
|
+
},
|
|
1338
|
+
null,
|
|
1339
|
+
2
|
|
1340
|
+
);
|
|
1341
|
+
try {
|
|
1342
|
+
fs2.writeFileSync(sidecarPath, sidecarContent, "utf-8");
|
|
1343
|
+
} catch {
|
|
1344
|
+
throw new ClassificationError("WRITE_ERROR");
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
return mergedMap;
|
|
1348
|
+
}
|
|
1349
|
+
};
|
|
1350
|
+
|
|
1351
|
+
// src/mcp/tools/classify-instruments.ts
|
|
1352
|
+
function buildUnresolvedWarnings(classification) {
|
|
1353
|
+
const warnings = [];
|
|
1354
|
+
for (const [isin, entry] of Object.entries(classification)) {
|
|
1355
|
+
if (!entry.confirmedByUser && entry.source === "user") {
|
|
1356
|
+
warnings.push(
|
|
1357
|
+
`Unrecognized type for ${isin}: unknown. Please classify manually.`
|
|
1358
|
+
);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
return warnings;
|
|
1362
|
+
}
|
|
1363
|
+
async function handleClassifyInstruments(args, extra, _httpPost) {
|
|
1364
|
+
const progressToken = extra?._meta?.progressToken;
|
|
1365
|
+
const sendNotification = extra?.sendNotification;
|
|
1366
|
+
const onBatchProgress = progressToken !== void 0 && sendNotification ? (done, total) => {
|
|
1367
|
+
void sendNotification({
|
|
1368
|
+
method: "notifications/progress",
|
|
1369
|
+
params: { progressToken, progress: done, total }
|
|
1370
|
+
});
|
|
1371
|
+
} : void 0;
|
|
1372
|
+
const classifier = new Classifier({ interactive: false });
|
|
1373
|
+
try {
|
|
1374
|
+
const classification = await classifier.classify(
|
|
1375
|
+
args.transactions,
|
|
1376
|
+
void 0,
|
|
1377
|
+
{
|
|
1378
|
+
existingClassification: args.existingClassification,
|
|
1379
|
+
overrides: args.overrides,
|
|
1380
|
+
offline: args.offline,
|
|
1381
|
+
onBatchProgress
|
|
1382
|
+
},
|
|
1383
|
+
_httpPost
|
|
1384
|
+
);
|
|
1385
|
+
return {
|
|
1386
|
+
content: [
|
|
1387
|
+
{
|
|
1388
|
+
type: "text",
|
|
1389
|
+
text: JSON.stringify({
|
|
1390
|
+
classification,
|
|
1391
|
+
warnings: buildUnresolvedWarnings(classification)
|
|
1392
|
+
})
|
|
1393
|
+
}
|
|
1394
|
+
]
|
|
1395
|
+
};
|
|
1396
|
+
} catch (err) {
|
|
1397
|
+
if (err instanceof ClassificationError)
|
|
1398
|
+
return toClassificationErrorResult(err);
|
|
1399
|
+
throw err;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// src/dichiarazione/engine.ts
|
|
1404
|
+
import { writeFile } from "fs/promises";
|
|
1405
|
+
function roundHalfUp(x) {
|
|
1406
|
+
return Math.sign(x) * Math.round(Math.abs(x) * 100) / 100;
|
|
1407
|
+
}
|
|
1408
|
+
function buildQuadroRT(bucketB, carryForward, taxYear) {
|
|
1409
|
+
const plusvalenze = bucketB.plusvalenze;
|
|
1410
|
+
const minusvalenze = bucketB.minusvalenze;
|
|
1411
|
+
const differenza = roundHalfUp(plusvalenze - minusvalenze);
|
|
1412
|
+
if (differenza > 0) {
|
|
1413
|
+
const sorted = [...carryForward].sort((a, b) => a.year - b.year);
|
|
1414
|
+
let remaining = differenza;
|
|
1415
|
+
const carryForwardApplied = [];
|
|
1416
|
+
for (const entry of sorted) {
|
|
1417
|
+
if (taxYear - entry.year > 4) continue;
|
|
1418
|
+
const consumed = Math.min(entry.amount, remaining);
|
|
1419
|
+
if (consumed > 0) {
|
|
1420
|
+
carryForwardApplied.push({
|
|
1421
|
+
annoOrigine: entry.year,
|
|
1422
|
+
importo: roundHalfUp(consumed)
|
|
1423
|
+
});
|
|
1424
|
+
remaining -= consumed;
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
const imponibileNetto = roundHalfUp(remaining);
|
|
1428
|
+
const imposta = roundHalfUp(imponibileNetto * 0.26);
|
|
1429
|
+
return {
|
|
1430
|
+
plusvalenze,
|
|
1431
|
+
minusvalenze,
|
|
1432
|
+
differenza,
|
|
1433
|
+
carryForwardApplied,
|
|
1434
|
+
imponibileNetto,
|
|
1435
|
+
imposta,
|
|
1436
|
+
carryForwardRiportato: []
|
|
1437
|
+
};
|
|
1438
|
+
} else if (differenza < 0) {
|
|
1439
|
+
return {
|
|
1440
|
+
plusvalenze,
|
|
1441
|
+
minusvalenze,
|
|
1442
|
+
differenza,
|
|
1443
|
+
carryForwardApplied: [],
|
|
1444
|
+
imponibileNetto: 0,
|
|
1445
|
+
imposta: 0,
|
|
1446
|
+
carryForwardRiportato: [
|
|
1447
|
+
{ annoOrigine: taxYear, importo: roundHalfUp(Math.abs(differenza)) }
|
|
1448
|
+
]
|
|
1449
|
+
};
|
|
1450
|
+
} else {
|
|
1451
|
+
return {
|
|
1452
|
+
plusvalenze,
|
|
1453
|
+
minusvalenze,
|
|
1454
|
+
differenza: 0,
|
|
1455
|
+
carryForwardApplied: [],
|
|
1456
|
+
imponibileNetto: 0,
|
|
1457
|
+
imposta: 0,
|
|
1458
|
+
carryForwardRiportato: []
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
function buildQuadroRM(bucketA, incomeRows, taxYear) {
|
|
1463
|
+
const groups = bucketA?.groups ?? [];
|
|
1464
|
+
const group26 = groups.find((g) => g.taxRate === 0.26);
|
|
1465
|
+
const group125 = groups.find((g) => g.taxRate === 0.125);
|
|
1466
|
+
const capitaleAliquota26 = {
|
|
1467
|
+
plusvalenze: group26?.plusvalenze ?? 0,
|
|
1468
|
+
imposta: group26?.imposta ?? 0
|
|
1469
|
+
};
|
|
1470
|
+
const capitaleAliquota125 = {
|
|
1471
|
+
plusvalenze: group125?.plusvalenze ?? 0,
|
|
1472
|
+
imposta: group125?.imposta ?? 0
|
|
1473
|
+
};
|
|
1474
|
+
const dividendiEsteri = incomeRows.filter((r) => r.incomeType === "dividend").map((r) => ({
|
|
1475
|
+
isin: r.isin,
|
|
1476
|
+
prodotto: r.product,
|
|
1477
|
+
lordo: r.grossAmount,
|
|
1478
|
+
rittenutaEstera: r.withholdingTax
|
|
1479
|
+
}));
|
|
1480
|
+
const cedole = incomeRows.filter((r) => r.incomeType === "coupon").map((r) => ({
|
|
1481
|
+
isin: r.isin,
|
|
1482
|
+
prodotto: r.product,
|
|
1483
|
+
importo: r.grossAmount,
|
|
1484
|
+
rittenutaEstera: r.withholdingTax
|
|
1485
|
+
}));
|
|
1486
|
+
return {
|
|
1487
|
+
capitaleAliquota26,
|
|
1488
|
+
capitaleAliquota125,
|
|
1489
|
+
dividendiEsteri,
|
|
1490
|
+
cedole
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
function buildDichiarazioneReport(quadroRT, quadroRM, taxYear) {
|
|
1494
|
+
const version = 1;
|
|
1495
|
+
const modello = "Redditi PF";
|
|
1496
|
+
const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1497
|
+
return {
|
|
1498
|
+
version,
|
|
1499
|
+
annoImposta: taxYear,
|
|
1500
|
+
modello,
|
|
1501
|
+
generatedAt,
|
|
1502
|
+
quadroRT,
|
|
1503
|
+
quadroRM,
|
|
1504
|
+
exportTo: async (path3) => {
|
|
1505
|
+
await writeFile(
|
|
1506
|
+
path3,
|
|
1507
|
+
JSON.stringify(
|
|
1508
|
+
{
|
|
1509
|
+
version,
|
|
1510
|
+
annoImposta: taxYear,
|
|
1511
|
+
modello,
|
|
1512
|
+
generatedAt,
|
|
1513
|
+
quadroRT,
|
|
1514
|
+
quadroRM
|
|
1515
|
+
},
|
|
1516
|
+
null,
|
|
1517
|
+
2
|
|
1518
|
+
)
|
|
1519
|
+
);
|
|
1520
|
+
}
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// src/calculator/index.ts
|
|
1525
|
+
function roundHalfUp2(x) {
|
|
1526
|
+
return Math.sign(x) * Math.round(Math.abs(x) * 100) / 100;
|
|
1527
|
+
}
|
|
1528
|
+
function inferTaxYear(transactions) {
|
|
1529
|
+
const counts = {};
|
|
1530
|
+
for (const t of transactions) {
|
|
1531
|
+
const y = t.date.slice(0, 4);
|
|
1532
|
+
counts[y] = (counts[y] ?? 0) + 1;
|
|
1533
|
+
}
|
|
1534
|
+
const years = Object.keys(counts);
|
|
1535
|
+
if (years.length === 0)
|
|
1536
|
+
return { year: (/* @__PURE__ */ new Date()).getFullYear(), multipleYears: false };
|
|
1537
|
+
const year = parseInt(
|
|
1538
|
+
years.reduce((a, b) => counts[a] >= counts[b] ? a : b)
|
|
1539
|
+
);
|
|
1540
|
+
return { year, multipleYears: years.length > 1 };
|
|
1541
|
+
}
|
|
1542
|
+
var Calculator = class {
|
|
1543
|
+
_transactions;
|
|
1544
|
+
_parseWarnings;
|
|
1545
|
+
_options;
|
|
1546
|
+
constructor(transactions, parseWarnings, options) {
|
|
1547
|
+
this._transactions = transactions;
|
|
1548
|
+
this._parseWarnings = parseWarnings ?? [];
|
|
1549
|
+
this._options = options ?? {};
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* Run LIFO or FIFO lot-matching over the transaction list.
|
|
1553
|
+
*
|
|
1554
|
+
* @param method - `"LIFO"` or `"FIFO"`.
|
|
1555
|
+
* @returns GainsReport with `plusvalenze`, `minusvalenze`, `netResult`, per-lot breakdown,
|
|
1556
|
+
* ECB rates used, and any accumulated warnings.
|
|
1557
|
+
* @throws {CalculationError} when a SELL has no matching open buy lots.
|
|
1558
|
+
* `error.isin` and `error.date` identify the problematic transaction.
|
|
1559
|
+
*/
|
|
1560
|
+
calculateGains(method) {
|
|
1561
|
+
const warnings = [...this._parseWarnings];
|
|
1562
|
+
const sorted = [...this._transactions].sort((a, b) => {
|
|
1563
|
+
if (a.date < b.date) return -1;
|
|
1564
|
+
if (a.date > b.date) return 1;
|
|
1565
|
+
if (a.type === "BUY" && b.type === "SELL") return -1;
|
|
1566
|
+
if (a.type === "SELL" && b.type === "BUY") return 1;
|
|
1567
|
+
return 0;
|
|
1568
|
+
});
|
|
1569
|
+
const { year: taxYear, multipleYears } = inferTaxYear(sorted);
|
|
1570
|
+
if (multipleYears) {
|
|
1571
|
+
warnings.push(
|
|
1572
|
+
"CSV contains transactions from multiple years \u2014 filter to a single year for accurate reporting."
|
|
1573
|
+
);
|
|
1574
|
+
}
|
|
1575
|
+
const openLots = /* @__PURE__ */ new Map();
|
|
1576
|
+
const matchedLots = [];
|
|
1577
|
+
const ratesUsed = {};
|
|
1578
|
+
for (const tx of sorted) {
|
|
1579
|
+
if (tx.fxRate !== void 0) {
|
|
1580
|
+
ratesUsed[`${tx.currency}:${tx.date}`] = tx.fxRate;
|
|
1581
|
+
}
|
|
1582
|
+
if (tx.type === "BUY") {
|
|
1583
|
+
const lot = {
|
|
1584
|
+
date: tx.date,
|
|
1585
|
+
quantity: tx.quantity,
|
|
1586
|
+
pricePerUnitEUR: tx.totalEUR / tx.quantity,
|
|
1587
|
+
feesEUR: tx.feesEUR,
|
|
1588
|
+
originalQty: tx.quantity,
|
|
1589
|
+
fxRate: tx.fxRate
|
|
1590
|
+
};
|
|
1591
|
+
if (!openLots.has(tx.isin)) openLots.set(tx.isin, []);
|
|
1592
|
+
openLots.get(tx.isin).push(lot);
|
|
1593
|
+
} else {
|
|
1594
|
+
const lots = openLots.get(tx.isin);
|
|
1595
|
+
if (!lots || lots.length === 0) {
|
|
1596
|
+
throw new CalculationError(tx.isin, tx.date);
|
|
1597
|
+
}
|
|
1598
|
+
const sellPricePerUnitEUR = tx.totalEUR / tx.quantity;
|
|
1599
|
+
let remainingSellQty = tx.quantity;
|
|
1600
|
+
while (remainingSellQty > 0) {
|
|
1601
|
+
if (!lots || lots.length === 0) {
|
|
1602
|
+
throw new CalculationError(tx.isin, tx.date);
|
|
1603
|
+
}
|
|
1604
|
+
const lot = method === "LIFO" ? lots[lots.length - 1] : lots[0];
|
|
1605
|
+
const matchedQty = Math.min(lot.quantity, remainingSellQty);
|
|
1606
|
+
const allocatedBuyFeesEUR = lot.feesEUR * (matchedQty / lot.originalQty);
|
|
1607
|
+
const allocatedSellFeesEUR = tx.feesEUR * (matchedQty / tx.quantity);
|
|
1608
|
+
const buyCostEUR = lot.pricePerUnitEUR * matchedQty + allocatedBuyFeesEUR;
|
|
1609
|
+
const sellProceedsEUR = sellPricePerUnitEUR * matchedQty - allocatedSellFeesEUR;
|
|
1610
|
+
const gainLossEUR = sellProceedsEUR - buyCostEUR;
|
|
1611
|
+
matchedLots.push({
|
|
1612
|
+
isin: tx.isin,
|
|
1613
|
+
product: tx.product,
|
|
1614
|
+
quantity: matchedQty,
|
|
1615
|
+
buyDate: lot.date,
|
|
1616
|
+
sellDate: tx.date,
|
|
1617
|
+
buyPriceEUR: lot.pricePerUnitEUR,
|
|
1618
|
+
sellPriceEUR: sellPricePerUnitEUR,
|
|
1619
|
+
buyCostEUR: roundHalfUp2(buyCostEUR),
|
|
1620
|
+
sellProceedsEUR: roundHalfUp2(sellProceedsEUR),
|
|
1621
|
+
gainLossEUR: roundHalfUp2(gainLossEUR),
|
|
1622
|
+
buyFxRate: lot.fxRate,
|
|
1623
|
+
sellFxRate: tx.fxRate
|
|
1624
|
+
});
|
|
1625
|
+
lot.quantity -= matchedQty;
|
|
1626
|
+
remainingSellQty -= matchedQty;
|
|
1627
|
+
if (lot.quantity <= 0) {
|
|
1628
|
+
if (method === "LIFO") {
|
|
1629
|
+
lots.pop();
|
|
1630
|
+
} else {
|
|
1631
|
+
lots.shift();
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
let plusvalenze = 0;
|
|
1638
|
+
let minusvalenze = 0;
|
|
1639
|
+
for (const lot of matchedLots) {
|
|
1640
|
+
if (lot.gainLossEUR > 0) plusvalenze += lot.gainLossEUR;
|
|
1641
|
+
else minusvalenze += Math.abs(lot.gainLossEUR);
|
|
1642
|
+
}
|
|
1643
|
+
if (this._options.classification) {
|
|
1644
|
+
const classification = this._options.classification;
|
|
1645
|
+
const unclassifiedIsins = /* @__PURE__ */ new Set();
|
|
1646
|
+
for (const lot of matchedLots) {
|
|
1647
|
+
const entry = classification[lot.isin];
|
|
1648
|
+
if (!entry) {
|
|
1649
|
+
lot.bucket = "B";
|
|
1650
|
+
unclassifiedIsins.add(lot.isin);
|
|
1651
|
+
} else if (entry.bucketGain === "A" && lot.gainLossEUR >= 0) {
|
|
1652
|
+
lot.bucket = "A";
|
|
1653
|
+
} else {
|
|
1654
|
+
lot.bucket = "B";
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
for (const isin of unclassifiedIsins) {
|
|
1658
|
+
warnings.push(
|
|
1659
|
+
`ISIN ${isin} not found in classification map \u2014 assigned to Bucket B.`
|
|
1660
|
+
);
|
|
1661
|
+
}
|
|
1662
|
+
const bucketALots = matchedLots.filter((l) => l.bucket === "A");
|
|
1663
|
+
const groupsByRate = /* @__PURE__ */ new Map();
|
|
1664
|
+
for (const lot of bucketALots) {
|
|
1665
|
+
const entry = classification[lot.isin];
|
|
1666
|
+
const rate = entry.taxRate;
|
|
1667
|
+
if (!groupsByRate.has(rate))
|
|
1668
|
+
groupsByRate.set(rate, { assetClasses: /* @__PURE__ */ new Set(), plusvalenze: 0 });
|
|
1669
|
+
const g = groupsByRate.get(rate);
|
|
1670
|
+
g.assetClasses.add(entry.assetClass);
|
|
1671
|
+
g.plusvalenze += lot.gainLossEUR;
|
|
1672
|
+
}
|
|
1673
|
+
const bucketAGroups = [...groupsByRate.entries()].map(([taxRate, g]) => ({
|
|
1674
|
+
taxRate,
|
|
1675
|
+
assetClasses: [...g.assetClasses],
|
|
1676
|
+
plusvalenze: roundHalfUp2(g.plusvalenze),
|
|
1677
|
+
imposta: roundHalfUp2(g.plusvalenze * taxRate)
|
|
1678
|
+
}));
|
|
1679
|
+
const bucketAReport = {
|
|
1680
|
+
groups: bucketAGroups,
|
|
1681
|
+
totalImposta: roundHalfUp2(
|
|
1682
|
+
bucketAGroups.reduce((s, g) => s + g.imposta, 0)
|
|
1683
|
+
)
|
|
1684
|
+
};
|
|
1685
|
+
const bucketBLots = matchedLots.filter((l) => l.bucket === "B");
|
|
1686
|
+
let bPlusvalenze = 0;
|
|
1687
|
+
let bMinusvalenze = 0;
|
|
1688
|
+
for (const lot of bucketBLots) {
|
|
1689
|
+
if (lot.gainLossEUR >= 0) bPlusvalenze += lot.gainLossEUR;
|
|
1690
|
+
else bMinusvalenze += Math.abs(lot.gainLossEUR);
|
|
1691
|
+
}
|
|
1692
|
+
bPlusvalenze = roundHalfUp2(bPlusvalenze);
|
|
1693
|
+
bMinusvalenze = roundHalfUp2(bMinusvalenze);
|
|
1694
|
+
const carryForwards = [...this._options.carryForward ?? []].sort(
|
|
1695
|
+
(a, b) => a.year - b.year
|
|
1696
|
+
);
|
|
1697
|
+
let remaining = bPlusvalenze - bMinusvalenze;
|
|
1698
|
+
let carryForwardApplied = 0;
|
|
1699
|
+
for (const entry of carryForwards) {
|
|
1700
|
+
if (taxYear - entry.year > 4) continue;
|
|
1701
|
+
if (remaining <= 0) break;
|
|
1702
|
+
const consumed = Math.min(entry.amount, remaining);
|
|
1703
|
+
carryForwardApplied += consumed;
|
|
1704
|
+
remaining -= consumed;
|
|
1705
|
+
}
|
|
1706
|
+
carryForwardApplied = roundHalfUp2(carryForwardApplied);
|
|
1707
|
+
const bNetResult = roundHalfUp2(
|
|
1708
|
+
bPlusvalenze - bMinusvalenze - carryForwardApplied
|
|
1709
|
+
);
|
|
1710
|
+
const carryForwardRemaining = roundHalfUp2(Math.max(0, -bNetResult));
|
|
1711
|
+
const bucketBReport = {
|
|
1712
|
+
plusvalenze: bPlusvalenze,
|
|
1713
|
+
minusvalenze: bMinusvalenze,
|
|
1714
|
+
carryForwardApplied,
|
|
1715
|
+
carryForwardRemaining,
|
|
1716
|
+
netResult: bNetResult
|
|
1717
|
+
};
|
|
1718
|
+
const allIncomeRows = this._options.incomeRows ?? [];
|
|
1719
|
+
const filteredIncomeRows = allIncomeRows.filter(
|
|
1720
|
+
(row) => new Date(row.date).getFullYear() === taxYear
|
|
1721
|
+
);
|
|
1722
|
+
if (filteredIncomeRows.length < allIncomeRows.length) {
|
|
1723
|
+
warnings.push(`Income rows outside tax year ${taxYear} were skipped.`);
|
|
1724
|
+
}
|
|
1725
|
+
const quadroRT = buildQuadroRT(
|
|
1726
|
+
bucketBReport,
|
|
1727
|
+
this._options.carryForward ?? [],
|
|
1728
|
+
taxYear
|
|
1729
|
+
);
|
|
1730
|
+
const quadroRM = buildQuadroRM(
|
|
1731
|
+
bucketAGroups.length > 0 ? bucketAReport : void 0,
|
|
1732
|
+
filteredIncomeRows,
|
|
1733
|
+
taxYear
|
|
1734
|
+
);
|
|
1735
|
+
const dichiarazioneReport = buildDichiarazioneReport(
|
|
1736
|
+
quadroRT,
|
|
1737
|
+
quadroRM,
|
|
1738
|
+
taxYear
|
|
1739
|
+
);
|
|
1740
|
+
return {
|
|
1741
|
+
method,
|
|
1742
|
+
taxYear,
|
|
1743
|
+
plusvalenze: roundHalfUp2(plusvalenze),
|
|
1744
|
+
minusvalenze: roundHalfUp2(minusvalenze),
|
|
1745
|
+
netResult: roundHalfUp2(plusvalenze - minusvalenze),
|
|
1746
|
+
lots: matchedLots,
|
|
1747
|
+
ratesUsed,
|
|
1748
|
+
warnings,
|
|
1749
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1750
|
+
bucketA: bucketAGroups.length > 0 ? bucketAReport : void 0,
|
|
1751
|
+
bucketB: bucketBReport,
|
|
1752
|
+
dichiarazione: dichiarazioneReport
|
|
1753
|
+
};
|
|
1754
|
+
} else {
|
|
1755
|
+
const allIsins = [...new Set(this._transactions.map((t) => t.isin))];
|
|
1756
|
+
const hasIePrefix = allIsins.some((isin) => isin.startsWith("IE"));
|
|
1757
|
+
const hasOther = allIsins.some((isin) => !isin.startsWith("IE"));
|
|
1758
|
+
if (hasIePrefix && hasOther) {
|
|
1759
|
+
warnings.push(
|
|
1760
|
+
"AVVISO: CSV contiene tipi di strumenti misti (es. ETF + Azioni).\n Il calcolo a bucket unico pu\xF2 non essere fiscalmente corretto.\n Esegui: minus-tracker classify trades.csv"
|
|
1761
|
+
);
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
return {
|
|
1765
|
+
method,
|
|
1766
|
+
taxYear,
|
|
1767
|
+
plusvalenze: roundHalfUp2(plusvalenze),
|
|
1768
|
+
minusvalenze: roundHalfUp2(minusvalenze),
|
|
1769
|
+
netResult: roundHalfUp2(plusvalenze - minusvalenze),
|
|
1770
|
+
lots: matchedLots,
|
|
1771
|
+
ratesUsed,
|
|
1772
|
+
warnings,
|
|
1773
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1774
|
+
};
|
|
1775
|
+
}
|
|
1776
|
+
};
|
|
1777
|
+
|
|
1778
|
+
// src/mcp/tools/calculate-gains.ts
|
|
1779
|
+
async function handleCalculateGains(args) {
|
|
1780
|
+
try {
|
|
1781
|
+
const report = new Calculator(args.transactions, args.parseWarnings ?? [], {
|
|
1782
|
+
classification: args.classification,
|
|
1783
|
+
carryForward: args.carryForward,
|
|
1784
|
+
incomeRows: args.incomeRows
|
|
1785
|
+
}).calculateGains(args.method);
|
|
1786
|
+
return {
|
|
1787
|
+
content: [
|
|
1788
|
+
{
|
|
1789
|
+
type: "text",
|
|
1790
|
+
text: JSON.stringify(report)
|
|
1791
|
+
}
|
|
1792
|
+
]
|
|
1793
|
+
};
|
|
1794
|
+
} catch (err) {
|
|
1795
|
+
if (err instanceof CalculationError) return toCalculationErrorResult(err);
|
|
1796
|
+
throw err;
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
// src/mcp/server.ts
|
|
1801
|
+
var __dirname = path2.dirname(fileURLToPath2(import.meta.url));
|
|
1802
|
+
function getPackageVersion() {
|
|
1803
|
+
const pkgPath = path2.join(__dirname, "../../package.json");
|
|
1804
|
+
const pkg = JSON.parse(fs3.readFileSync(pkgPath, "utf8"));
|
|
1805
|
+
return pkg.version;
|
|
1806
|
+
}
|
|
1807
|
+
var TOOLS = [
|
|
1808
|
+
{
|
|
1809
|
+
name: "parse_transactions",
|
|
1810
|
+
description: "Parse a DEGIRO transactions CSV export into Transaction[].",
|
|
1811
|
+
inputSchema: parseTransactionsInputSchema
|
|
1812
|
+
},
|
|
1813
|
+
{
|
|
1814
|
+
name: "classify_instruments",
|
|
1815
|
+
description: "Classify transactions' ISINs into asset classes (stateless mode).",
|
|
1816
|
+
inputSchema: classifyInstrumentsInputSchema
|
|
1817
|
+
},
|
|
1818
|
+
{
|
|
1819
|
+
name: "calculate_gains",
|
|
1820
|
+
description: "Calculate LIFO/FIFO capital gains from transactions.",
|
|
1821
|
+
inputSchema: calculateGainsInputSchema
|
|
1822
|
+
}
|
|
1823
|
+
];
|
|
1824
|
+
var TOOL_NAMES = new Set(TOOLS.map((t) => t.name));
|
|
1825
|
+
var ajv = new Ajv({ allErrors: true, strict: false });
|
|
1826
|
+
var VALIDATORS = {
|
|
1827
|
+
parse_transactions: ajv.compile(parseTransactionsInputSchema),
|
|
1828
|
+
classify_instruments: ajv.compile(classifyInstrumentsInputSchema),
|
|
1829
|
+
calculate_gains: ajv.compile(calculateGainsInputSchema)
|
|
1830
|
+
};
|
|
1831
|
+
var server = new Server(
|
|
1832
|
+
{ name: "minus-tracker-mcp", version: getPackageVersion() },
|
|
1833
|
+
{ capabilities: { tools: {} } }
|
|
1834
|
+
);
|
|
1835
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
1836
|
+
tools: TOOLS
|
|
1837
|
+
}));
|
|
1838
|
+
server.setRequestHandler(CallToolRequestSchema, async (request2, extra) => {
|
|
1839
|
+
const { name, arguments: args } = request2.params;
|
|
1840
|
+
if (!TOOL_NAMES.has(name)) {
|
|
1841
|
+
return {
|
|
1842
|
+
isError: true,
|
|
1843
|
+
content: [{ type: "text", text: `Unknown tool: ${name}` }]
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1846
|
+
const validate = VALIDATORS[name];
|
|
1847
|
+
if (validate && !validate(args)) {
|
|
1848
|
+
return {
|
|
1849
|
+
isError: true,
|
|
1850
|
+
content: [
|
|
1851
|
+
{
|
|
1852
|
+
type: "text",
|
|
1853
|
+
text: JSON.stringify({
|
|
1854
|
+
code: "VALIDATION_ERROR",
|
|
1855
|
+
errors: validate.errors
|
|
1856
|
+
})
|
|
1857
|
+
}
|
|
1858
|
+
]
|
|
1859
|
+
};
|
|
1860
|
+
}
|
|
1861
|
+
switch (name) {
|
|
1862
|
+
case "parse_transactions":
|
|
1863
|
+
return handleParseTransactions(args);
|
|
1864
|
+
case "classify_instruments":
|
|
1865
|
+
return handleClassifyInstruments(
|
|
1866
|
+
args,
|
|
1867
|
+
{
|
|
1868
|
+
_meta: request2.params._meta,
|
|
1869
|
+
sendNotification: extra.sendNotification
|
|
1870
|
+
}
|
|
1871
|
+
);
|
|
1872
|
+
case "calculate_gains":
|
|
1873
|
+
return handleCalculateGains(args);
|
|
1874
|
+
default:
|
|
1875
|
+
return {
|
|
1876
|
+
isError: true,
|
|
1877
|
+
content: [{ type: "text", text: `Unknown tool: ${name}` }]
|
|
1878
|
+
};
|
|
1879
|
+
}
|
|
1880
|
+
});
|
|
1881
|
+
|
|
1882
|
+
// src/mcp/index.ts
|
|
1883
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
1884
|
+
await server.connect(new StdioServerTransport());
|
|
1885
|
+
//# sourceMappingURL=index.js.map
|