@gabrielerandelli/minus-tracker 0.5.7 → 0.6.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/README.md +321 -46
- package/dist/cli/index.js +1181 -25
- package/dist/cli/index.js.map +1 -1
- package/dist/data/stress-manifest.json +13387 -0
- package/dist/index.cjs +481 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +91 -2
- package/dist/index.d.ts +91 -2
- package/dist/index.js +479 -1
- package/dist/index.js.map +1 -1
- package/package.json +18 -1
package/dist/cli/index.js
CHANGED
|
@@ -36,11 +36,34 @@ var it = {
|
|
|
36
36
|
ratesGaps: (list) => `Lacune: ${list}`,
|
|
37
37
|
ratesUpdateFetching: "Recupero dati BCE SDMX in corso...",
|
|
38
38
|
ratesUpdateDone: (n) => `Completato. Aggiunte ${n} nuove date.`,
|
|
39
|
-
ratesSnapshotWritten: (
|
|
39
|
+
ratesSnapshotWritten: (path7) => `Snapshot scritto in ${path7}`,
|
|
40
40
|
ratesAutoUpdateStart: "Le rate BCE non sono aggiornate. Aggiornamento automatico in corso...",
|
|
41
41
|
ratesAutoUpdateFailed: "Aggiornamento automatico fallito. Si procede con le rate disponibili.",
|
|
42
42
|
configLangSet: (lang) => `Lingua impostata su: ${lang}`,
|
|
43
43
|
configCurrentLang: (lang) => `Lingua corrente: ${lang}`,
|
|
44
|
+
classifyStarting: (n) => `Classificazione ISIN in corso... (${n} strumenti)`,
|
|
45
|
+
classifyDetected: (isin, product, bucket, rate) => `Rilevato: ${product} (${isin}) \u2192 ${bucket} | ${rate}`,
|
|
46
|
+
classifyConfirm: "[Y/n/?]: ",
|
|
47
|
+
classifyOverridePrompt: "Tipo di strumento:",
|
|
48
|
+
classifyDone: (confirmed, total) => `Classificazione completata (${confirmed}/${total} confermati).`,
|
|
49
|
+
classifyWritten: (path7) => `Scritto: ${path7}`,
|
|
50
|
+
classifyMergePrompt: (existing) => `File gi\xE0 esistente (${existing} ISIN confermati). Aggiungere i nuovi ISIN? [Y/n]: `,
|
|
51
|
+
classifyOfflineWarning: "Modalit\xE0 offline: ricerca OpenFIGI saltata.",
|
|
52
|
+
classifyUnknownType: (type) => `Tipo non riconosciuto: ${type}. Classificare manualmente.`,
|
|
53
|
+
classifyNonTtyError: "Modalit\xE0 interattiva richiede un terminale (TTY). Usa --offline per classificare senza terminale.",
|
|
54
|
+
bucketAHeader: "BUCKET A \u2014 REDDITI DA CAPITALE (non compensabili)",
|
|
55
|
+
bucketBHeader: "BUCKET B \u2014 REDDITI DIVERSI",
|
|
56
|
+
bucketAEtf: "ETF (OICR)",
|
|
57
|
+
bucketABtpWl: "BTP/WL",
|
|
58
|
+
bucketATotalTax: "TOTALE IMPOSTA",
|
|
59
|
+
bucketBCarryApplied: (year) => `RIPORTO ${year}`,
|
|
60
|
+
bucketBResult: "RISULTATO",
|
|
61
|
+
bucketBCarryNote: "(riportabile ai prossimi 4 anni)",
|
|
62
|
+
warnMixedBuckets: "Le perdite in Bucket B non compensano le plusvalenze in Bucket A.",
|
|
63
|
+
warnMixedAssets: "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",
|
|
64
|
+
headerBucket: "BUCKET",
|
|
65
|
+
warnUnclassifiedIsin: (isin) => `ISIN ${isin} non trovato nella mappa di classificazione \u2014 assegnato a Bucket B.`,
|
|
66
|
+
carryForwardInvalidFormat: "Formato --carry-forward non valido. Usa: AAAA:importo (es. 2023:2500)",
|
|
44
67
|
disclaimer: "minus-tracker \xE8 un ausilio al calcolo, non consulenza fiscale."
|
|
45
68
|
};
|
|
46
69
|
|
|
@@ -77,11 +100,34 @@ var en = {
|
|
|
77
100
|
ratesGaps: (list) => `Gaps: ${list}`,
|
|
78
101
|
ratesUpdateFetching: "Fetching ECB SDMX...",
|
|
79
102
|
ratesUpdateDone: (n) => `Done. Added ${n} new dates.`,
|
|
80
|
-
ratesSnapshotWritten: (
|
|
103
|
+
ratesSnapshotWritten: (path7) => `Snapshot written to ${path7}`,
|
|
81
104
|
ratesAutoUpdateStart: "ECB rates are stale. Auto-updating\u2026",
|
|
82
105
|
ratesAutoUpdateFailed: "Auto-update failed. Proceeding with available rates.",
|
|
83
106
|
configLangSet: (lang) => `Language set to: ${lang}`,
|
|
84
107
|
configCurrentLang: (lang) => `Current language: ${lang}`,
|
|
108
|
+
classifyStarting: (n) => `Classifying ISINs... (${n} instruments)`,
|
|
109
|
+
classifyDetected: (isin, product, bucket, rate) => `Detected: ${product} (${isin}) \u2192 ${bucket} | ${rate}`,
|
|
110
|
+
classifyConfirm: "[Y/n/?]: ",
|
|
111
|
+
classifyOverridePrompt: "Instrument type:",
|
|
112
|
+
classifyDone: (confirmed, total) => `Classification complete (${confirmed}/${total} confirmed).`,
|
|
113
|
+
classifyWritten: (path7) => `Written: ${path7}`,
|
|
114
|
+
classifyMergePrompt: (existing) => `File already exists (${existing} confirmed ISINs). Add new ISINs? [Y/n]: `,
|
|
115
|
+
classifyOfflineWarning: "Offline mode: OpenFIGI lookup skipped.",
|
|
116
|
+
classifyUnknownType: (type) => `Unrecognized type: ${type}. Please classify manually.`,
|
|
117
|
+
classifyNonTtyError: "Interactive mode requires a TTY. Use --offline to classify without a terminal.",
|
|
118
|
+
bucketAHeader: "BUCKET A \u2014 CAPITAL INCOME (non-offsettable)",
|
|
119
|
+
bucketBHeader: "BUCKET B \u2014 MISCELLANEOUS INCOME",
|
|
120
|
+
bucketAEtf: "ETF (OICR)",
|
|
121
|
+
bucketABtpWl: "Govt Bond WL",
|
|
122
|
+
bucketATotalTax: "TOTAL TAX",
|
|
123
|
+
bucketBCarryApplied: (year) => `CARRY ${year}`,
|
|
124
|
+
bucketBResult: "RESULT",
|
|
125
|
+
bucketBCarryNote: "(carriable over the next 4 years)",
|
|
126
|
+
warnMixedBuckets: "Bucket B losses do not offset Bucket A plusvalenze.",
|
|
127
|
+
warnMixedAssets: "WARNING: CSV contains mixed instrument types (e.g. ETFs + Stocks).\n Single-bucket calculation may not be fiscally correct.\n Run: minus-tracker classify trades.csv",
|
|
128
|
+
headerBucket: "BUCKET",
|
|
129
|
+
warnUnclassifiedIsin: (isin) => `ISIN ${isin} not found in classification map \u2014 assigned to Bucket B.`,
|
|
130
|
+
carryForwardInvalidFormat: "Invalid --carry-forward format. Use: YYYY:amount (e.g. 2023:2500)",
|
|
85
131
|
disclaimer: "minus-tracker \xE8 un ausilio al calcolo, non consulenza fiscale."
|
|
86
132
|
};
|
|
87
133
|
|
|
@@ -165,9 +211,21 @@ var CalculationError = class extends Error {
|
|
|
165
211
|
this.date = date;
|
|
166
212
|
}
|
|
167
213
|
};
|
|
214
|
+
var ClassificationError = class extends Error {
|
|
215
|
+
code;
|
|
216
|
+
constructor(code, message) {
|
|
217
|
+
super(
|
|
218
|
+
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")
|
|
219
|
+
);
|
|
220
|
+
this.name = "ClassificationError";
|
|
221
|
+
this.code = code;
|
|
222
|
+
}
|
|
223
|
+
};
|
|
168
224
|
|
|
169
225
|
// src/cli/commands/calc.ts
|
|
170
|
-
import * as
|
|
226
|
+
import * as fs5 from "fs";
|
|
227
|
+
import * as path4 from "path";
|
|
228
|
+
import * as os3 from "os";
|
|
171
229
|
|
|
172
230
|
// src/rates/index.ts
|
|
173
231
|
import { fileURLToPath } from "url";
|
|
@@ -319,6 +377,17 @@ var DEGIROParser = class {
|
|
|
319
377
|
constructor(snapshot) {
|
|
320
378
|
this._snapshot = snapshot;
|
|
321
379
|
}
|
|
380
|
+
/**
|
|
381
|
+
* Parse a DEGIRO Transactions CSV export string.
|
|
382
|
+
*
|
|
383
|
+
* @param csv - Raw UTF-8 string from the DEGIRO Transactions export.
|
|
384
|
+
* @returns Array of normalised Transaction objects. Empty if no data rows are found.
|
|
385
|
+
* @throws {ParseError} code `"INVALID_CSV"` — malformed CSV or binary content.
|
|
386
|
+
* @throws {ParseError} code `"MISSING_COLUMN"` (+ `columnName`) — required column absent.
|
|
387
|
+
*
|
|
388
|
+
* Rows with missing ISIN, unsupported currency, zero quantity, or no ECB rate within
|
|
389
|
+
* 3 trading days are skipped silently. Inspect `parser.warnings` for details.
|
|
390
|
+
*/
|
|
322
391
|
parse(csv) {
|
|
323
392
|
this._warningEntries = [];
|
|
324
393
|
if (typeof csv !== "string" || csv.includes("\0")) {
|
|
@@ -442,10 +511,21 @@ function inferTaxYear(transactions) {
|
|
|
442
511
|
var Calculator = class {
|
|
443
512
|
_transactions;
|
|
444
513
|
_parseWarnings;
|
|
445
|
-
|
|
514
|
+
_options;
|
|
515
|
+
constructor(transactions, parseWarnings, options) {
|
|
446
516
|
this._transactions = transactions;
|
|
447
517
|
this._parseWarnings = parseWarnings ?? [];
|
|
518
|
+
this._options = options ?? {};
|
|
448
519
|
}
|
|
520
|
+
/**
|
|
521
|
+
* Run LIFO or FIFO lot-matching over the transaction list.
|
|
522
|
+
*
|
|
523
|
+
* @param method - `"LIFO"` or `"FIFO"`.
|
|
524
|
+
* @returns GainsReport with `plusvalenze`, `minusvalenze`, `netResult`, per-lot breakdown,
|
|
525
|
+
* ECB rates used, and any accumulated warnings.
|
|
526
|
+
* @throws {CalculationError} when a SELL has no matching open buy lots.
|
|
527
|
+
* `error.isin` and `error.date` identify the problematic transaction.
|
|
528
|
+
*/
|
|
449
529
|
calculateGains(method) {
|
|
450
530
|
const warnings = [...this._parseWarnings];
|
|
451
531
|
const sorted = [...this._transactions].sort((a, b) => {
|
|
@@ -529,6 +609,104 @@ var Calculator = class {
|
|
|
529
609
|
if (lot.gainLossEUR > 0) plusvalenze += lot.gainLossEUR;
|
|
530
610
|
else minusvalenze += Math.abs(lot.gainLossEUR);
|
|
531
611
|
}
|
|
612
|
+
if (this._options.classification) {
|
|
613
|
+
const classification = this._options.classification;
|
|
614
|
+
const unclassifiedIsins = /* @__PURE__ */ new Set();
|
|
615
|
+
for (const lot of matchedLots) {
|
|
616
|
+
const entry = classification[lot.isin];
|
|
617
|
+
if (!entry) {
|
|
618
|
+
lot.bucket = "B";
|
|
619
|
+
unclassifiedIsins.add(lot.isin);
|
|
620
|
+
} else if (entry.bucketGain === "A" && lot.gainLossEUR >= 0) {
|
|
621
|
+
lot.bucket = "A";
|
|
622
|
+
} else {
|
|
623
|
+
lot.bucket = "B";
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
for (const isin of unclassifiedIsins) {
|
|
627
|
+
warnings.push(
|
|
628
|
+
`ISIN ${isin} not found in classification map \u2014 assigned to Bucket B.`
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
const bucketALots = matchedLots.filter((l) => l.bucket === "A");
|
|
632
|
+
const groupsByRate = /* @__PURE__ */ new Map();
|
|
633
|
+
for (const lot of bucketALots) {
|
|
634
|
+
const entry = classification[lot.isin];
|
|
635
|
+
const rate = entry.taxRate;
|
|
636
|
+
if (!groupsByRate.has(rate))
|
|
637
|
+
groupsByRate.set(rate, { assetClasses: /* @__PURE__ */ new Set(), plusvalenze: 0 });
|
|
638
|
+
const g = groupsByRate.get(rate);
|
|
639
|
+
g.assetClasses.add(entry.assetClass);
|
|
640
|
+
g.plusvalenze += lot.gainLossEUR;
|
|
641
|
+
}
|
|
642
|
+
const bucketAGroups = [...groupsByRate.entries()].map(([taxRate, g]) => ({
|
|
643
|
+
taxRate,
|
|
644
|
+
assetClasses: [...g.assetClasses],
|
|
645
|
+
plusvalenze: roundHalfUp(g.plusvalenze),
|
|
646
|
+
imposta: roundHalfUp(g.plusvalenze * taxRate)
|
|
647
|
+
}));
|
|
648
|
+
const bucketAReport = {
|
|
649
|
+
groups: bucketAGroups,
|
|
650
|
+
totalImposta: roundHalfUp(
|
|
651
|
+
bucketAGroups.reduce((s, g) => s + g.imposta, 0)
|
|
652
|
+
)
|
|
653
|
+
};
|
|
654
|
+
const bucketBLots = matchedLots.filter((l) => l.bucket === "B");
|
|
655
|
+
let bPlusvalenze = 0;
|
|
656
|
+
let bMinusvalenze = 0;
|
|
657
|
+
for (const lot of bucketBLots) {
|
|
658
|
+
if (lot.gainLossEUR >= 0) bPlusvalenze += lot.gainLossEUR;
|
|
659
|
+
else bMinusvalenze += Math.abs(lot.gainLossEUR);
|
|
660
|
+
}
|
|
661
|
+
bPlusvalenze = roundHalfUp(bPlusvalenze);
|
|
662
|
+
bMinusvalenze = roundHalfUp(bMinusvalenze);
|
|
663
|
+
const carryForwards = [...this._options.carryForward ?? []].sort(
|
|
664
|
+
(a, b) => a.year - b.year
|
|
665
|
+
);
|
|
666
|
+
let remaining = bPlusvalenze - bMinusvalenze;
|
|
667
|
+
let carryForwardApplied = 0;
|
|
668
|
+
for (const entry of carryForwards) {
|
|
669
|
+
if (taxYear - entry.year > 4) continue;
|
|
670
|
+
if (remaining <= 0) break;
|
|
671
|
+
const consumed = Math.min(entry.amount, remaining);
|
|
672
|
+
carryForwardApplied += consumed;
|
|
673
|
+
remaining -= consumed;
|
|
674
|
+
}
|
|
675
|
+
carryForwardApplied = roundHalfUp(carryForwardApplied);
|
|
676
|
+
const bNetResult = roundHalfUp(
|
|
677
|
+
bPlusvalenze - bMinusvalenze - carryForwardApplied
|
|
678
|
+
);
|
|
679
|
+
const carryForwardRemaining = roundHalfUp(Math.max(0, -bNetResult));
|
|
680
|
+
const bucketBReport = {
|
|
681
|
+
plusvalenze: bPlusvalenze,
|
|
682
|
+
minusvalenze: bMinusvalenze,
|
|
683
|
+
carryForwardApplied,
|
|
684
|
+
carryForwardRemaining,
|
|
685
|
+
netResult: bNetResult
|
|
686
|
+
};
|
|
687
|
+
return {
|
|
688
|
+
method,
|
|
689
|
+
taxYear,
|
|
690
|
+
plusvalenze: roundHalfUp(plusvalenze),
|
|
691
|
+
minusvalenze: roundHalfUp(minusvalenze),
|
|
692
|
+
netResult: roundHalfUp(plusvalenze - minusvalenze),
|
|
693
|
+
lots: matchedLots,
|
|
694
|
+
ratesUsed,
|
|
695
|
+
warnings,
|
|
696
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
697
|
+
bucketA: bucketAGroups.length > 0 ? bucketAReport : void 0,
|
|
698
|
+
bucketB: bucketBReport
|
|
699
|
+
};
|
|
700
|
+
} else {
|
|
701
|
+
const allIsins = [...new Set(this._transactions.map((t) => t.isin))];
|
|
702
|
+
const hasIePrefix = allIsins.some((isin) => isin.startsWith("IE"));
|
|
703
|
+
const hasOther = allIsins.some((isin) => !isin.startsWith("IE"));
|
|
704
|
+
if (hasIePrefix && hasOther) {
|
|
705
|
+
warnings.push(
|
|
706
|
+
"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"
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
532
710
|
return {
|
|
533
711
|
method,
|
|
534
712
|
taxYear,
|
|
@@ -543,6 +721,352 @@ var Calculator = class {
|
|
|
543
721
|
}
|
|
544
722
|
};
|
|
545
723
|
|
|
724
|
+
// src/classifier/index.ts
|
|
725
|
+
import * as fs3 from "fs";
|
|
726
|
+
import * as https from "https";
|
|
727
|
+
function httpsPost(url, body, timeoutMs) {
|
|
728
|
+
return new Promise((resolve2, reject) => {
|
|
729
|
+
const u = new URL(url);
|
|
730
|
+
const req = https.request(
|
|
731
|
+
{
|
|
732
|
+
hostname: u.hostname,
|
|
733
|
+
path: u.pathname + u.search,
|
|
734
|
+
method: "POST",
|
|
735
|
+
headers: {
|
|
736
|
+
"Content-Type": "application/json",
|
|
737
|
+
"Content-Length": Buffer.byteLength(body)
|
|
738
|
+
}
|
|
739
|
+
},
|
|
740
|
+
(res) => {
|
|
741
|
+
let data = "";
|
|
742
|
+
res.on("data", (chunk) => {
|
|
743
|
+
data += chunk;
|
|
744
|
+
});
|
|
745
|
+
res.on("end", () => resolve2({ status: res.statusCode ?? 0, data }));
|
|
746
|
+
}
|
|
747
|
+
);
|
|
748
|
+
req.setTimeout(timeoutMs, () => {
|
|
749
|
+
req.destroy();
|
|
750
|
+
reject(new Error("timeout"));
|
|
751
|
+
});
|
|
752
|
+
req.on("error", reject);
|
|
753
|
+
req.write(body);
|
|
754
|
+
req.end();
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
var GOVT_BOND_WHITELIST = /* @__PURE__ */ new Set([
|
|
758
|
+
"IT",
|
|
759
|
+
"DE",
|
|
760
|
+
"FR",
|
|
761
|
+
"AT",
|
|
762
|
+
"BE",
|
|
763
|
+
"NL",
|
|
764
|
+
"ES",
|
|
765
|
+
"PT",
|
|
766
|
+
"FI",
|
|
767
|
+
"IE",
|
|
768
|
+
"LU",
|
|
769
|
+
"GR",
|
|
770
|
+
"SK",
|
|
771
|
+
"SI",
|
|
772
|
+
"LT",
|
|
773
|
+
"LV",
|
|
774
|
+
"EE",
|
|
775
|
+
"MT",
|
|
776
|
+
"CY",
|
|
777
|
+
"US",
|
|
778
|
+
"GB",
|
|
779
|
+
"CH",
|
|
780
|
+
"NO",
|
|
781
|
+
"SE",
|
|
782
|
+
"DK",
|
|
783
|
+
"JP",
|
|
784
|
+
"CA",
|
|
785
|
+
"AU",
|
|
786
|
+
"NZ",
|
|
787
|
+
"SG",
|
|
788
|
+
"HK",
|
|
789
|
+
"KR"
|
|
790
|
+
]);
|
|
791
|
+
var SECURITY_TYPE_MAP = {
|
|
792
|
+
ETP: {
|
|
793
|
+
assetClass: "ETF",
|
|
794
|
+
bucketGain: "A",
|
|
795
|
+
bucketLoss: "B",
|
|
796
|
+
taxRate: 0.26,
|
|
797
|
+
whiteListed: null
|
|
798
|
+
},
|
|
799
|
+
ETF: {
|
|
800
|
+
assetClass: "ETF",
|
|
801
|
+
bucketGain: "A",
|
|
802
|
+
bucketLoss: "B",
|
|
803
|
+
taxRate: 0.26,
|
|
804
|
+
whiteListed: null
|
|
805
|
+
},
|
|
806
|
+
"Mutual Fund": {
|
|
807
|
+
assetClass: "ETF",
|
|
808
|
+
bucketGain: "A",
|
|
809
|
+
bucketLoss: "B",
|
|
810
|
+
taxRate: 0.26,
|
|
811
|
+
whiteListed: null
|
|
812
|
+
},
|
|
813
|
+
"Open-End Fund": {
|
|
814
|
+
assetClass: "ETF",
|
|
815
|
+
bucketGain: "A",
|
|
816
|
+
bucketLoss: "B",
|
|
817
|
+
taxRate: 0.26,
|
|
818
|
+
whiteListed: null
|
|
819
|
+
},
|
|
820
|
+
"Common Stock": {
|
|
821
|
+
assetClass: "Stock",
|
|
822
|
+
bucketGain: "B",
|
|
823
|
+
bucketLoss: "B",
|
|
824
|
+
taxRate: 0,
|
|
825
|
+
whiteListed: null
|
|
826
|
+
},
|
|
827
|
+
Equity: {
|
|
828
|
+
assetClass: "Stock",
|
|
829
|
+
bucketGain: "B",
|
|
830
|
+
bucketLoss: "B",
|
|
831
|
+
taxRate: 0,
|
|
832
|
+
whiteListed: null
|
|
833
|
+
},
|
|
834
|
+
"Exchange Traded Commodity": {
|
|
835
|
+
assetClass: "ETC",
|
|
836
|
+
bucketGain: "B",
|
|
837
|
+
bucketLoss: "B",
|
|
838
|
+
taxRate: 0,
|
|
839
|
+
whiteListed: null
|
|
840
|
+
},
|
|
841
|
+
"Corporate Bond": {
|
|
842
|
+
assetClass: "CorpBond",
|
|
843
|
+
bucketGain: "B",
|
|
844
|
+
bucketLoss: "B",
|
|
845
|
+
taxRate: 0,
|
|
846
|
+
whiteListed: null
|
|
847
|
+
},
|
|
848
|
+
Option: {
|
|
849
|
+
assetClass: "Derivative",
|
|
850
|
+
bucketGain: "B",
|
|
851
|
+
bucketLoss: "B",
|
|
852
|
+
taxRate: 0,
|
|
853
|
+
whiteListed: null
|
|
854
|
+
},
|
|
855
|
+
Future: {
|
|
856
|
+
assetClass: "Derivative",
|
|
857
|
+
bucketGain: "B",
|
|
858
|
+
bucketLoss: "B",
|
|
859
|
+
taxRate: 0,
|
|
860
|
+
whiteListed: null
|
|
861
|
+
},
|
|
862
|
+
Warrant: {
|
|
863
|
+
assetClass: "Derivative",
|
|
864
|
+
bucketGain: "B",
|
|
865
|
+
bucketLoss: "B",
|
|
866
|
+
taxRate: 0,
|
|
867
|
+
whiteListed: null
|
|
868
|
+
},
|
|
869
|
+
CFD: {
|
|
870
|
+
assetClass: "Derivative",
|
|
871
|
+
bucketGain: "B",
|
|
872
|
+
bucketLoss: "B",
|
|
873
|
+
taxRate: 0,
|
|
874
|
+
whiteListed: null
|
|
875
|
+
},
|
|
876
|
+
Swap: {
|
|
877
|
+
assetClass: "Derivative",
|
|
878
|
+
bucketGain: "B",
|
|
879
|
+
bucketLoss: "B",
|
|
880
|
+
taxRate: 0,
|
|
881
|
+
whiteListed: null
|
|
882
|
+
},
|
|
883
|
+
"Leverage Certificate": {
|
|
884
|
+
assetClass: "LeverageCert",
|
|
885
|
+
bucketGain: "B",
|
|
886
|
+
bucketLoss: "B",
|
|
887
|
+
taxRate: 0,
|
|
888
|
+
whiteListed: null
|
|
889
|
+
},
|
|
890
|
+
"Turbo Certificate": {
|
|
891
|
+
assetClass: "LeverageCert",
|
|
892
|
+
bucketGain: "B",
|
|
893
|
+
bucketLoss: "B",
|
|
894
|
+
taxRate: 0,
|
|
895
|
+
whiteListed: null
|
|
896
|
+
},
|
|
897
|
+
"Capital Protected Certificate": {
|
|
898
|
+
assetClass: "CapProtectedCert",
|
|
899
|
+
bucketGain: "A",
|
|
900
|
+
bucketLoss: "B",
|
|
901
|
+
taxRate: 0.26,
|
|
902
|
+
whiteListed: null
|
|
903
|
+
}
|
|
904
|
+
};
|
|
905
|
+
function isKnownType(t) {
|
|
906
|
+
return t === "Government Bond" || t in SECURITY_TYPE_MAP;
|
|
907
|
+
}
|
|
908
|
+
function classifyByType(isin, securityType, product, warnings) {
|
|
909
|
+
if (securityType === "Government Bond") {
|
|
910
|
+
const prefix = isin.slice(0, 2).toUpperCase();
|
|
911
|
+
const whiteListed = GOVT_BOND_WHITELIST.has(prefix);
|
|
912
|
+
return {
|
|
913
|
+
product,
|
|
914
|
+
assetClass: whiteListed ? "GovtBondWL" : "GovtBondOther",
|
|
915
|
+
bucketGain: "A",
|
|
916
|
+
bucketLoss: "B",
|
|
917
|
+
taxRate: whiteListed ? 0.125 : 0.26,
|
|
918
|
+
whiteListed,
|
|
919
|
+
confirmedByUser: false,
|
|
920
|
+
source: "openfigi"
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
const mapped = SECURITY_TYPE_MAP[securityType];
|
|
924
|
+
if (mapped) {
|
|
925
|
+
return {
|
|
926
|
+
product,
|
|
927
|
+
assetClass: mapped.assetClass,
|
|
928
|
+
bucketGain: mapped.bucketGain,
|
|
929
|
+
bucketLoss: mapped.bucketLoss,
|
|
930
|
+
taxRate: mapped.taxRate,
|
|
931
|
+
whiteListed: mapped.whiteListed,
|
|
932
|
+
confirmedByUser: false,
|
|
933
|
+
source: "openfigi"
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
warnings.push(
|
|
937
|
+
`Unrecognized type: ${securityType}. Please classify manually.`
|
|
938
|
+
);
|
|
939
|
+
return {
|
|
940
|
+
product,
|
|
941
|
+
assetClass: "Stock",
|
|
942
|
+
bucketGain: "B",
|
|
943
|
+
bucketLoss: "B",
|
|
944
|
+
taxRate: 0,
|
|
945
|
+
whiteListed: null,
|
|
946
|
+
confirmedByUser: false,
|
|
947
|
+
source: "user"
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
var Classifier = class {
|
|
951
|
+
interactive;
|
|
952
|
+
constructor(options) {
|
|
953
|
+
this.interactive = options?.interactive ?? true;
|
|
954
|
+
}
|
|
955
|
+
async load(sidecarPath) {
|
|
956
|
+
if (!fs3.existsSync(sidecarPath)) {
|
|
957
|
+
throw new ClassificationError("SIDECAR_NOT_FOUND");
|
|
958
|
+
}
|
|
959
|
+
let parsed;
|
|
960
|
+
try {
|
|
961
|
+
const raw = fs3.readFileSync(sidecarPath, "utf-8");
|
|
962
|
+
parsed = JSON.parse(raw);
|
|
963
|
+
} catch {
|
|
964
|
+
throw new ClassificationError("SIDECAR_MALFORMED");
|
|
965
|
+
}
|
|
966
|
+
if (typeof parsed !== "object" || parsed === null || parsed["version"] !== 1) {
|
|
967
|
+
throw new ClassificationError("SIDECAR_VERSION");
|
|
968
|
+
}
|
|
969
|
+
return parsed.classifications;
|
|
970
|
+
}
|
|
971
|
+
async classify(transactions, sidecarPath, _httpPost = httpsPost) {
|
|
972
|
+
const isinToProduct = /* @__PURE__ */ new Map();
|
|
973
|
+
for (const tx of transactions) {
|
|
974
|
+
if (tx.isin && !isinToProduct.has(tx.isin)) {
|
|
975
|
+
isinToProduct.set(tx.isin, tx.product);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
const confirmed = {};
|
|
979
|
+
if (fs3.existsSync(sidecarPath)) {
|
|
980
|
+
const existingMap = await this.load(sidecarPath);
|
|
981
|
+
for (const [isin, entry] of Object.entries(existingMap)) {
|
|
982
|
+
if (entry.confirmedByUser) {
|
|
983
|
+
confirmed[isin] = entry;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
const toProcess = [];
|
|
988
|
+
for (const isin of isinToProduct.keys()) {
|
|
989
|
+
if (!(isin in confirmed)) {
|
|
990
|
+
toProcess.push(isin);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
const warnings = [];
|
|
994
|
+
const newEntries = {};
|
|
995
|
+
const BATCH_SIZE = 10;
|
|
996
|
+
for (let i = 0; i < toProcess.length; i += BATCH_SIZE) {
|
|
997
|
+
if (i > 0) {
|
|
998
|
+
await new Promise((resolve2) => setTimeout(resolve2, 6e3));
|
|
999
|
+
}
|
|
1000
|
+
const batch = toProcess.slice(i, i + BATCH_SIZE);
|
|
1001
|
+
const requestBody = JSON.stringify(
|
|
1002
|
+
batch.map((isin) => ({ idType: "ID_ISIN", idValue: isin }))
|
|
1003
|
+
);
|
|
1004
|
+
let response = null;
|
|
1005
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1006
|
+
try {
|
|
1007
|
+
const r = await _httpPost(
|
|
1008
|
+
"https://api.openfigi.com/v3/mapping",
|
|
1009
|
+
requestBody,
|
|
1010
|
+
1e4
|
|
1011
|
+
);
|
|
1012
|
+
if (r.status < 500) {
|
|
1013
|
+
response = r;
|
|
1014
|
+
break;
|
|
1015
|
+
}
|
|
1016
|
+
} catch {
|
|
1017
|
+
throw new ClassificationError("NETWORK_ERROR");
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
if (response === null) {
|
|
1021
|
+
throw new ClassificationError("NETWORK_ERROR");
|
|
1022
|
+
}
|
|
1023
|
+
const results = JSON.parse(response.data);
|
|
1024
|
+
for (let j = 0; j < batch.length; j++) {
|
|
1025
|
+
const isin = batch[j];
|
|
1026
|
+
const result = results[j];
|
|
1027
|
+
const product = isinToProduct.get(isin) ?? isin;
|
|
1028
|
+
if (!result || result.error || !result.data || result.data.length === 0) {
|
|
1029
|
+
warnings.push(
|
|
1030
|
+
`Unrecognized type: unknown. Please classify manually.`
|
|
1031
|
+
);
|
|
1032
|
+
newEntries[isin] = {
|
|
1033
|
+
product,
|
|
1034
|
+
assetClass: "Stock",
|
|
1035
|
+
bucketGain: "B",
|
|
1036
|
+
bucketLoss: "B",
|
|
1037
|
+
taxRate: 0,
|
|
1038
|
+
whiteListed: null,
|
|
1039
|
+
confirmedByUser: false,
|
|
1040
|
+
source: "user"
|
|
1041
|
+
};
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
1044
|
+
const st = result.data[0]?.securityType;
|
|
1045
|
+
const st2 = result.data[0]?.securityType2;
|
|
1046
|
+
const typeToUse = st && isKnownType(st) ? st : st2 && isKnownType(st2) ? st2 : st ?? st2 ?? "Unknown";
|
|
1047
|
+
newEntries[isin] = classifyByType(isin, typeToUse, product, warnings);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
const mergedMap = { ...newEntries, ...confirmed };
|
|
1051
|
+
const sidecarContent = JSON.stringify(
|
|
1052
|
+
{
|
|
1053
|
+
version: 1,
|
|
1054
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1055
|
+
classifications: mergedMap,
|
|
1056
|
+
warnings
|
|
1057
|
+
},
|
|
1058
|
+
null,
|
|
1059
|
+
2
|
|
1060
|
+
);
|
|
1061
|
+
try {
|
|
1062
|
+
fs3.writeFileSync(sidecarPath, sidecarContent, "utf-8");
|
|
1063
|
+
} catch {
|
|
1064
|
+
throw new ClassificationError("WRITE_ERROR");
|
|
1065
|
+
}
|
|
1066
|
+
return mergedMap;
|
|
1067
|
+
}
|
|
1068
|
+
};
|
|
1069
|
+
|
|
546
1070
|
// src/cli/renderer.ts
|
|
547
1071
|
var SEPARATOR = "\u2500".repeat(72);
|
|
548
1072
|
function formatEUR(amount, locale) {
|
|
@@ -555,14 +1079,26 @@ function formatGainLoss(amount, locale) {
|
|
|
555
1079
|
const formatted = formatEUR(Math.abs(amount), locale);
|
|
556
1080
|
return amount >= 0 ? `+${formatted}` : `-${formatted}`;
|
|
557
1081
|
}
|
|
1082
|
+
function assetClassLabel(ac, s) {
|
|
1083
|
+
if (ac === "ETF") return s.bucketAEtf;
|
|
1084
|
+
if (ac === "GovtBondWL" || ac === "GovtBondOther") return s.bucketABtpWl;
|
|
1085
|
+
return ac;
|
|
1086
|
+
}
|
|
558
1087
|
function renderReport(report, s) {
|
|
559
1088
|
const fmt = (n) => formatEUR(n, s.numberLocale);
|
|
560
1089
|
const lines = [];
|
|
1090
|
+
const hasBuckets = !!(report.bucketA || report.bucketB);
|
|
561
1091
|
lines.push(
|
|
562
1092
|
`${s.headerMethod}: ${report.method} | ${s.headerTaxYear}: ${report.taxYear}`
|
|
563
1093
|
);
|
|
564
1094
|
lines.push("");
|
|
565
|
-
const
|
|
1095
|
+
for (const w of report.warnings) {
|
|
1096
|
+
if (w.startsWith("AVVISO:") || w.startsWith("WARNING:")) {
|
|
1097
|
+
lines.push(w);
|
|
1098
|
+
lines.push("");
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
const headerCols = [
|
|
566
1102
|
s.headerIsin.padEnd(14),
|
|
567
1103
|
s.headerProduct.padEnd(20),
|
|
568
1104
|
s.headerQty.padStart(5),
|
|
@@ -571,10 +1107,13 @@ function renderReport(report, s) {
|
|
|
571
1107
|
s.headerBuyEur.padStart(14),
|
|
572
1108
|
s.headerSellEur.padStart(13),
|
|
573
1109
|
s.headerGainLoss.padStart(17)
|
|
574
|
-
]
|
|
575
|
-
|
|
1110
|
+
];
|
|
1111
|
+
if (hasBuckets) {
|
|
1112
|
+
headerCols.push(s.headerBucket.padStart(8));
|
|
1113
|
+
}
|
|
1114
|
+
lines.push(headerCols.join(" "));
|
|
576
1115
|
for (const lot of report.lots) {
|
|
577
|
-
const
|
|
1116
|
+
const rowCols = [
|
|
578
1117
|
lot.isin.padEnd(14),
|
|
579
1118
|
lot.product.substring(0, 20).padEnd(20),
|
|
580
1119
|
String(lot.quantity).padStart(5),
|
|
@@ -583,8 +1122,11 @@ function renderReport(report, s) {
|
|
|
583
1122
|
fmt(lot.buyCostEUR).padStart(14),
|
|
584
1123
|
fmt(lot.sellProceedsEUR).padStart(13),
|
|
585
1124
|
formatGainLoss(lot.gainLossEUR, s.numberLocale).padStart(17)
|
|
586
|
-
]
|
|
587
|
-
|
|
1125
|
+
];
|
|
1126
|
+
if (hasBuckets) {
|
|
1127
|
+
rowCols.push((lot.bucket ?? "").padStart(8));
|
|
1128
|
+
}
|
|
1129
|
+
lines.push(rowCols.join(" "));
|
|
588
1130
|
}
|
|
589
1131
|
lines.push("");
|
|
590
1132
|
lines.push(SEPARATOR);
|
|
@@ -595,15 +1137,51 @@ function renderReport(report, s) {
|
|
|
595
1137
|
lines.push(`${s.summaryWarnings}: ${report.warnings.length}`);
|
|
596
1138
|
lines.push(`${s.summaryGenerated}: ${report.generatedAt}`);
|
|
597
1139
|
lines.push("");
|
|
1140
|
+
if (report.bucketA && report.bucketA.groups.length > 0) {
|
|
1141
|
+
lines.push(SEPARATOR);
|
|
1142
|
+
lines.push(s.bucketAHeader);
|
|
1143
|
+
lines.push(SEPARATOR);
|
|
1144
|
+
for (const g of report.bucketA.groups) {
|
|
1145
|
+
const label = g.assetClasses.map((ac) => assetClassLabel(ac, s)).join(", ");
|
|
1146
|
+
const rateDisplay = (g.taxRate * 100).toFixed(0);
|
|
1147
|
+
lines.push(
|
|
1148
|
+
`${label}: ${fmt(g.plusvalenze)} EUR \u2192 imposta: ${fmt(g.imposta)} EUR (${rateDisplay}%)`
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
lines.push(`${s.bucketATotalTax}: ${fmt(report.bucketA.totalImposta)} EUR`);
|
|
1152
|
+
lines.push("");
|
|
1153
|
+
}
|
|
1154
|
+
if (report.bucketB) {
|
|
1155
|
+
lines.push(SEPARATOR);
|
|
1156
|
+
lines.push(s.bucketBHeader);
|
|
1157
|
+
lines.push(SEPARATOR);
|
|
1158
|
+
lines.push(`PLUSVALENZE: ${fmt(report.bucketB.plusvalenze)} EUR`);
|
|
1159
|
+
lines.push(`MINUSVALENZE: ${fmt(report.bucketB.minusvalenze)} EUR`);
|
|
1160
|
+
if (report.bucketB.carryForwardApplied > 0) {
|
|
1161
|
+
lines.push(
|
|
1162
|
+
`${s.bucketBCarryApplied(0)}: ${fmt(report.bucketB.carryForwardApplied)} EUR`
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
let resultLine = `${s.bucketBResult}: ${fmt(report.bucketB.netResult)} EUR`;
|
|
1166
|
+
if (report.bucketB.carryForwardRemaining > 0) {
|
|
1167
|
+
resultLine += ` ${s.bucketBCarryNote}`;
|
|
1168
|
+
}
|
|
1169
|
+
lines.push(resultLine);
|
|
1170
|
+
lines.push("");
|
|
1171
|
+
}
|
|
1172
|
+
if (hasBuckets) {
|
|
1173
|
+
lines.push(s.warnMixedBuckets);
|
|
1174
|
+
lines.push("");
|
|
1175
|
+
}
|
|
598
1176
|
lines.push(s.disclaimer);
|
|
599
1177
|
return lines.join("\n");
|
|
600
1178
|
}
|
|
601
1179
|
|
|
602
1180
|
// src/cli/commands/rates.ts
|
|
603
|
-
import * as
|
|
1181
|
+
import * as fs4 from "fs";
|
|
604
1182
|
import * as path3 from "path";
|
|
605
1183
|
import * as os2 from "os";
|
|
606
|
-
import * as
|
|
1184
|
+
import * as https2 from "https";
|
|
607
1185
|
function getSnapshotPath() {
|
|
608
1186
|
const platform = process.platform;
|
|
609
1187
|
let configDir;
|
|
@@ -628,9 +1206,9 @@ function getCoverage(snapshot) {
|
|
|
628
1206
|
return { start, end, currencies: currencies.sort().join(", ") };
|
|
629
1207
|
}
|
|
630
1208
|
async function fetchEcbData(currency) {
|
|
631
|
-
return new Promise((
|
|
1209
|
+
return new Promise((resolve2, reject) => {
|
|
632
1210
|
const url = `https://data-api.ecb.europa.eu/service/data/EXR/D.${currency}.EUR.SP00.A?format=csvdata&startPeriod=2019-01-01`;
|
|
633
|
-
|
|
1211
|
+
https2.get(url, (res) => {
|
|
634
1212
|
let data = "";
|
|
635
1213
|
res.on("data", (chunk) => data += chunk);
|
|
636
1214
|
res.on("end", () => {
|
|
@@ -645,7 +1223,7 @@ async function fetchEcbData(currency) {
|
|
|
645
1223
|
rates[date] = rate;
|
|
646
1224
|
}
|
|
647
1225
|
}
|
|
648
|
-
|
|
1226
|
+
resolve2(rates);
|
|
649
1227
|
});
|
|
650
1228
|
res.on("error", reject);
|
|
651
1229
|
}).on("error", reject);
|
|
@@ -656,7 +1234,7 @@ async function updateRates(snapshotPath, stdout, stderr, s) {
|
|
|
656
1234
|
let existing = {};
|
|
657
1235
|
try {
|
|
658
1236
|
existing = JSON.parse(
|
|
659
|
-
|
|
1237
|
+
fs4.readFileSync(snapshotPath, "utf8")
|
|
660
1238
|
);
|
|
661
1239
|
} catch {
|
|
662
1240
|
}
|
|
@@ -677,8 +1255,8 @@ async function updateRates(snapshotPath, stdout, stderr, s) {
|
|
|
677
1255
|
`);
|
|
678
1256
|
}
|
|
679
1257
|
}
|
|
680
|
-
|
|
681
|
-
|
|
1258
|
+
fs4.mkdirSync(path3.dirname(snapshotPath), { recursive: true });
|
|
1259
|
+
fs4.writeFileSync(
|
|
682
1260
|
snapshotPath,
|
|
683
1261
|
JSON.stringify(existing, null, 2) + "\n",
|
|
684
1262
|
"utf8"
|
|
@@ -713,7 +1291,7 @@ async function runCalc(positional, flags, s, stdout, stderr) {
|
|
|
713
1291
|
}
|
|
714
1292
|
let csv;
|
|
715
1293
|
try {
|
|
716
|
-
csv =
|
|
1294
|
+
csv = fs5.readFileSync(filePath, "utf8");
|
|
717
1295
|
} catch {
|
|
718
1296
|
stderr.write(`Cannot read file: ${filePath}
|
|
719
1297
|
`);
|
|
@@ -742,6 +1320,51 @@ async function runCalc(positional, flags, s, stdout, stderr) {
|
|
|
742
1320
|
stderr.write("--method must be LIFO or FIFO\n");
|
|
743
1321
|
return 2;
|
|
744
1322
|
}
|
|
1323
|
+
const sidecarPath = filePath.replace(/\.csv$/i, "") + ".classify.json";
|
|
1324
|
+
let classification;
|
|
1325
|
+
if (fs5.existsSync(sidecarPath)) {
|
|
1326
|
+
try {
|
|
1327
|
+
const classifier = new Classifier({ interactive: false });
|
|
1328
|
+
classification = await classifier.load(sidecarPath);
|
|
1329
|
+
} catch {
|
|
1330
|
+
stderr.write(`Cannot load sidecar: ${sidecarPath}
|
|
1331
|
+
`);
|
|
1332
|
+
return 1;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
const rawCf = flags["carry-forward"];
|
|
1336
|
+
const rawCarryForwards = Array.isArray(rawCf) ? rawCf : rawCf ? [rawCf] : [];
|
|
1337
|
+
const cfRegex = /^\d{4}:\d+(\.\d+)?$/;
|
|
1338
|
+
for (const cf of rawCarryForwards) {
|
|
1339
|
+
if (!cfRegex.test(cf)) {
|
|
1340
|
+
stderr.write(s.carryForwardInvalidFormat + "\n");
|
|
1341
|
+
return 2;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
const xdgConfig = process.env["XDG_CONFIG_HOME"] ?? path4.join(os3.homedir(), ".config");
|
|
1345
|
+
const cfConfigPath = path4.join(
|
|
1346
|
+
xdgConfig,
|
|
1347
|
+
"minus-tracker",
|
|
1348
|
+
"carryforward.json"
|
|
1349
|
+
);
|
|
1350
|
+
const cfFromFile = {};
|
|
1351
|
+
if (fs5.existsSync(cfConfigPath)) {
|
|
1352
|
+
try {
|
|
1353
|
+
const raw = JSON.parse(fs5.readFileSync(cfConfigPath, "utf-8"));
|
|
1354
|
+
for (const entry of raw.losses ?? []) {
|
|
1355
|
+
cfFromFile[entry.year] = entry.amount;
|
|
1356
|
+
}
|
|
1357
|
+
} catch {
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
const cfMerged = { ...cfFromFile };
|
|
1361
|
+
for (const cf of rawCarryForwards) {
|
|
1362
|
+
const [yearStr, amountStr] = cf.split(":");
|
|
1363
|
+
cfMerged[parseInt(yearStr, 10)] = parseFloat(amountStr);
|
|
1364
|
+
}
|
|
1365
|
+
const carryForward = Object.entries(cfMerged).map(
|
|
1366
|
+
([year, amount]) => ({ year: parseInt(year, 10), amount })
|
|
1367
|
+
);
|
|
745
1368
|
const parser = new DEGIROParser();
|
|
746
1369
|
let transactions;
|
|
747
1370
|
try {
|
|
@@ -757,7 +1380,10 @@ async function runCalc(positional, flags, s, stdout, stderr) {
|
|
|
757
1380
|
}
|
|
758
1381
|
throw err;
|
|
759
1382
|
}
|
|
760
|
-
const calculator = new Calculator(transactions, parser.warnings
|
|
1383
|
+
const calculator = new Calculator(transactions, parser.warnings, {
|
|
1384
|
+
classification,
|
|
1385
|
+
carryForward: carryForward.length > 0 ? carryForward : void 0
|
|
1386
|
+
});
|
|
761
1387
|
const report = calculator.calculateGains(method);
|
|
762
1388
|
if (flags["json"]) {
|
|
763
1389
|
stdout.write(JSON.stringify(report, null, 2) + "\n");
|
|
@@ -768,7 +1394,7 @@ async function runCalc(positional, flags, s, stdout, stderr) {
|
|
|
768
1394
|
}
|
|
769
1395
|
|
|
770
1396
|
// src/cli/commands/validate.ts
|
|
771
|
-
import * as
|
|
1397
|
+
import * as fs6 from "fs";
|
|
772
1398
|
async function runValidate(positional, flags, s, stdout, stderr) {
|
|
773
1399
|
const filePath = positional[0];
|
|
774
1400
|
if (!filePath) {
|
|
@@ -777,7 +1403,7 @@ async function runValidate(positional, flags, s, stdout, stderr) {
|
|
|
777
1403
|
}
|
|
778
1404
|
let csv;
|
|
779
1405
|
try {
|
|
780
|
-
csv =
|
|
1406
|
+
csv = fs6.readFileSync(filePath, "utf8");
|
|
781
1407
|
} catch {
|
|
782
1408
|
stderr.write(`Cannot read file: ${filePath}
|
|
783
1409
|
`);
|
|
@@ -843,6 +1469,523 @@ async function runConfig(positional, flags, s, stdout, stderr) {
|
|
|
843
1469
|
return 2;
|
|
844
1470
|
}
|
|
845
1471
|
|
|
1472
|
+
// src/cli/commands/stress-test.ts
|
|
1473
|
+
import * as fs7 from "fs";
|
|
1474
|
+
import * as path5 from "path";
|
|
1475
|
+
import * as os4 from "os";
|
|
1476
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1477
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
1478
|
+
|
|
1479
|
+
// src/stress/generator.ts
|
|
1480
|
+
function formatDate(isoDate) {
|
|
1481
|
+
const [yyyy, mm, dd] = isoDate.split("-");
|
|
1482
|
+
return `${dd}-${mm}-${yyyy}`;
|
|
1483
|
+
}
|
|
1484
|
+
function generateCsv(scenario) {
|
|
1485
|
+
if (scenario.special === "invalid_csv") {
|
|
1486
|
+
return "NOT_A_CSV\0\ngarbage data";
|
|
1487
|
+
}
|
|
1488
|
+
const allColumns = [
|
|
1489
|
+
"Date",
|
|
1490
|
+
"Time",
|
|
1491
|
+
"Product",
|
|
1492
|
+
"ISIN",
|
|
1493
|
+
"Exchange",
|
|
1494
|
+
"Execution centre",
|
|
1495
|
+
"Quantity",
|
|
1496
|
+
"Price",
|
|
1497
|
+
"Local value",
|
|
1498
|
+
"Local value currency",
|
|
1499
|
+
"Value",
|
|
1500
|
+
"Value currency",
|
|
1501
|
+
"Exchange rate",
|
|
1502
|
+
"Transaction costs",
|
|
1503
|
+
"Transaction costs currency",
|
|
1504
|
+
"Total",
|
|
1505
|
+
"Total currency",
|
|
1506
|
+
"Order ID"
|
|
1507
|
+
];
|
|
1508
|
+
let columns = allColumns;
|
|
1509
|
+
if (scenario.special === "missing_col_isin") {
|
|
1510
|
+
columns = columns.filter((c) => c !== "ISIN");
|
|
1511
|
+
} else if (scenario.special === "missing_col_quantity") {
|
|
1512
|
+
columns = columns.filter((c) => c !== "Quantity");
|
|
1513
|
+
} else if (scenario.special === "missing_col_price") {
|
|
1514
|
+
columns = columns.filter((c) => c !== "Price");
|
|
1515
|
+
}
|
|
1516
|
+
const buildRow = (values, includeColumns) => {
|
|
1517
|
+
return includeColumns.map((col) => values[col] ?? "").join(",");
|
|
1518
|
+
};
|
|
1519
|
+
const header = columns.join(",");
|
|
1520
|
+
const rows = [header];
|
|
1521
|
+
for (let rowIndex = 0; rowIndex < scenario.transactions.length; rowIndex++) {
|
|
1522
|
+
const tx = scenario.transactions[rowIndex];
|
|
1523
|
+
const localValue = -tx.qty * tx.price;
|
|
1524
|
+
const total = localValue - tx.fee;
|
|
1525
|
+
const rowValues = {
|
|
1526
|
+
Date: formatDate(tx.date),
|
|
1527
|
+
Time: "09:00",
|
|
1528
|
+
Product: tx.product,
|
|
1529
|
+
ISIN: tx.isin,
|
|
1530
|
+
Exchange: "XEUR",
|
|
1531
|
+
"Execution centre": "XEUR",
|
|
1532
|
+
Quantity: String(tx.qty),
|
|
1533
|
+
Price: tx.price.toFixed(2),
|
|
1534
|
+
"Local value": localValue.toFixed(2),
|
|
1535
|
+
"Local value currency": tx.currency,
|
|
1536
|
+
Value: localValue.toFixed(2),
|
|
1537
|
+
"Value currency": "EUR",
|
|
1538
|
+
"Exchange rate": "1",
|
|
1539
|
+
"Transaction costs": (-Math.abs(tx.fee)).toFixed(2),
|
|
1540
|
+
"Transaction costs currency": "EUR",
|
|
1541
|
+
Total: total.toFixed(2),
|
|
1542
|
+
"Total currency": "EUR",
|
|
1543
|
+
"Order ID": `stress-${scenario.id}-${rowIndex}`
|
|
1544
|
+
};
|
|
1545
|
+
const row = buildRow(rowValues, columns);
|
|
1546
|
+
rows.push(row);
|
|
1547
|
+
}
|
|
1548
|
+
return rows.join("\n");
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// src/stress/runner.ts
|
|
1552
|
+
import { spawnSync } from "child_process";
|
|
1553
|
+
import { writeFileSync as writeFileSync4 } from "fs";
|
|
1554
|
+
import { join as join5 } from "path";
|
|
1555
|
+
function countWarnings(text) {
|
|
1556
|
+
if (!text) return 0;
|
|
1557
|
+
const match = text.match(/(?:warnings|avvertenze)\s*:\s*(\d+)/i);
|
|
1558
|
+
return match ? parseInt(match[1], 10) : 0;
|
|
1559
|
+
}
|
|
1560
|
+
function isValidJson(jsonStr) {
|
|
1561
|
+
try {
|
|
1562
|
+
const obj = JSON.parse(jsonStr);
|
|
1563
|
+
return typeof obj === "object" && obj !== null && "plusvalenze" in obj && "minusvalenze" in obj && "lots" in obj;
|
|
1564
|
+
} catch {
|
|
1565
|
+
return false;
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
function runCommand(cliBin, args, cmdLabel, expectedExitCode, outputShapeCheck) {
|
|
1569
|
+
const isJsFile = cliBin.endsWith(".js");
|
|
1570
|
+
let result;
|
|
1571
|
+
if (isJsFile) {
|
|
1572
|
+
result = spawnSync("node", [cliBin, ...args], {
|
|
1573
|
+
encoding: "utf8",
|
|
1574
|
+
timeout: 15e3,
|
|
1575
|
+
env: { ...process.env, MINUS_TRACKER_LANG: void 0 }
|
|
1576
|
+
});
|
|
1577
|
+
} else {
|
|
1578
|
+
result = spawnSync(cliBin, args, {
|
|
1579
|
+
encoding: "utf8",
|
|
1580
|
+
timeout: 15e3,
|
|
1581
|
+
env: { ...process.env, MINUS_TRACKER_LANG: void 0 }
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1584
|
+
const exitCode = result.status ?? 1;
|
|
1585
|
+
const stdout = result.stdout || "";
|
|
1586
|
+
const stderr = result.stderr || "";
|
|
1587
|
+
const exitCodeMatch = exitCode === expectedExitCode;
|
|
1588
|
+
const shapeCheck = outputShapeCheck(stdout, stderr, exitCode);
|
|
1589
|
+
let pass = exitCodeMatch && shapeCheck.valid;
|
|
1590
|
+
let failure;
|
|
1591
|
+
if (!exitCodeMatch) {
|
|
1592
|
+
failure = `exit code mismatch: expected ${expectedExitCode}, got ${exitCode}`;
|
|
1593
|
+
} else if (!shapeCheck.valid) {
|
|
1594
|
+
failure = shapeCheck.reason;
|
|
1595
|
+
}
|
|
1596
|
+
return {
|
|
1597
|
+
cmd: cmdLabel,
|
|
1598
|
+
exitCode,
|
|
1599
|
+
stdout,
|
|
1600
|
+
stderr,
|
|
1601
|
+
pass,
|
|
1602
|
+
failure
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
function runScenario(scenario, csvContent, outputDir, cliBin) {
|
|
1606
|
+
const csvFileName = `${scenario.id}-${scenario.slug}.csv`;
|
|
1607
|
+
const csvFile = join5(outputDir, csvFileName);
|
|
1608
|
+
writeFileSync4(csvFile, csvContent, "utf8");
|
|
1609
|
+
const results = [];
|
|
1610
|
+
results.push(
|
|
1611
|
+
runCommand(
|
|
1612
|
+
cliBin,
|
|
1613
|
+
["calc", csvFile],
|
|
1614
|
+
"calc",
|
|
1615
|
+
scenario.expect.calc_exit,
|
|
1616
|
+
(stdout) => {
|
|
1617
|
+
if (scenario.expect.calc_exit === 0) {
|
|
1618
|
+
const hasPlus = /plusvalenze|plusvalues/i.test(stdout);
|
|
1619
|
+
return hasPlus ? { valid: true } : {
|
|
1620
|
+
valid: false,
|
|
1621
|
+
reason: "calc output missing plusvalenze/plusvalues (exit 0)"
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
return { valid: true };
|
|
1625
|
+
}
|
|
1626
|
+
)
|
|
1627
|
+
);
|
|
1628
|
+
results.push(
|
|
1629
|
+
runCommand(
|
|
1630
|
+
cliBin,
|
|
1631
|
+
["calc", "--method", "FIFO", csvFile],
|
|
1632
|
+
"calc --method FIFO",
|
|
1633
|
+
scenario.expect.fifo_exit,
|
|
1634
|
+
(stdout) => {
|
|
1635
|
+
if (scenario.expect.fifo_exit === 0) {
|
|
1636
|
+
const hasPlus = /plusvalenze|plusvalues/i.test(stdout);
|
|
1637
|
+
return hasPlus ? { valid: true } : {
|
|
1638
|
+
valid: false,
|
|
1639
|
+
reason: "calc --method FIFO output missing plusvalenze/plusvalues (exit 0)"
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
return { valid: true };
|
|
1643
|
+
}
|
|
1644
|
+
)
|
|
1645
|
+
);
|
|
1646
|
+
results.push(
|
|
1647
|
+
runCommand(
|
|
1648
|
+
cliBin,
|
|
1649
|
+
["calc", "--json", csvFile],
|
|
1650
|
+
"calc --json",
|
|
1651
|
+
scenario.expect.json_exit,
|
|
1652
|
+
(stdout) => {
|
|
1653
|
+
if (scenario.expect.json_exit === 0) {
|
|
1654
|
+
const isValid = isValidJson(stdout);
|
|
1655
|
+
return isValid ? { valid: true } : {
|
|
1656
|
+
valid: false,
|
|
1657
|
+
reason: "calc --json output is not valid JSON with required keys (exit 0)"
|
|
1658
|
+
};
|
|
1659
|
+
}
|
|
1660
|
+
return { valid: true };
|
|
1661
|
+
}
|
|
1662
|
+
)
|
|
1663
|
+
);
|
|
1664
|
+
results.push(
|
|
1665
|
+
runCommand(
|
|
1666
|
+
cliBin,
|
|
1667
|
+
["calc", "--lang", "en", csvFile],
|
|
1668
|
+
"calc --lang en",
|
|
1669
|
+
scenario.expect.en_exit,
|
|
1670
|
+
(stdout) => {
|
|
1671
|
+
if (scenario.expect.en_exit === 0) {
|
|
1672
|
+
const nonEmpty = stdout.trim().length > 0;
|
|
1673
|
+
return nonEmpty ? { valid: true } : {
|
|
1674
|
+
valid: false,
|
|
1675
|
+
reason: "calc --lang en output is empty (exit 0)"
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
return { valid: true };
|
|
1679
|
+
}
|
|
1680
|
+
)
|
|
1681
|
+
);
|
|
1682
|
+
results.push(
|
|
1683
|
+
runCommand(
|
|
1684
|
+
cliBin,
|
|
1685
|
+
["validate", csvFile],
|
|
1686
|
+
"validate",
|
|
1687
|
+
scenario.expect.validate_exit,
|
|
1688
|
+
(stdout) => {
|
|
1689
|
+
if (scenario.expect.validate_exit !== 0) return { valid: true };
|
|
1690
|
+
const nonEmpty = stdout.trim().length > 0;
|
|
1691
|
+
return nonEmpty ? { valid: true } : {
|
|
1692
|
+
valid: false,
|
|
1693
|
+
reason: "validate output is empty"
|
|
1694
|
+
};
|
|
1695
|
+
}
|
|
1696
|
+
)
|
|
1697
|
+
);
|
|
1698
|
+
const enCalcResult = results[3];
|
|
1699
|
+
const actualWarningCount = countWarnings(enCalcResult.stdout);
|
|
1700
|
+
const warningCheckPass = actualWarningCount >= scenario.expect.warning_count;
|
|
1701
|
+
const pass = results.every((r) => r.pass) && warningCheckPass;
|
|
1702
|
+
return {
|
|
1703
|
+
id: scenario.id,
|
|
1704
|
+
category: scenario.category,
|
|
1705
|
+
slug: scenario.slug,
|
|
1706
|
+
description: scenario.description,
|
|
1707
|
+
csvFile,
|
|
1708
|
+
results,
|
|
1709
|
+
pass,
|
|
1710
|
+
warningCheckPass,
|
|
1711
|
+
expectedWarningCount: scenario.expect.warning_count,
|
|
1712
|
+
actualWarningCount
|
|
1713
|
+
};
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// src/stress/reporter.ts
|
|
1717
|
+
function buildReport(results, rangeStart, rangeEnd) {
|
|
1718
|
+
const passed = results.filter((r) => r.pass).length;
|
|
1719
|
+
const failed = results.length - passed;
|
|
1720
|
+
return {
|
|
1721
|
+
totalScenarios: results.length,
|
|
1722
|
+
passed,
|
|
1723
|
+
failed,
|
|
1724
|
+
results,
|
|
1725
|
+
rangeStart,
|
|
1726
|
+
rangeEnd
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
function formatJson(report) {
|
|
1730
|
+
return JSON.stringify(report, null, 2);
|
|
1731
|
+
}
|
|
1732
|
+
function formatTable(report) {
|
|
1733
|
+
const lines = [];
|
|
1734
|
+
lines.push("STRESS TEST \u2014 minus-tracker");
|
|
1735
|
+
lines.push(
|
|
1736
|
+
`Scenari: ${report.totalScenarios} | Passati: ${report.passed} | Falliti: ${report.failed}`
|
|
1737
|
+
);
|
|
1738
|
+
lines.push("");
|
|
1739
|
+
const idCol = "ID";
|
|
1740
|
+
const categoryCol = "CATEGORIA";
|
|
1741
|
+
const descCol = "DESCRIZIONE";
|
|
1742
|
+
const resultCol = "ESITO";
|
|
1743
|
+
lines.push(formatRow(idCol, categoryCol, descCol, resultCol));
|
|
1744
|
+
for (const result of report.results) {
|
|
1745
|
+
const idStr = result.id.padStart(3, "0");
|
|
1746
|
+
const status = result.pass ? "\u2713 PASS" : "\u2717 FAIL";
|
|
1747
|
+
lines.push(formatRow(idStr, result.category, result.description, status));
|
|
1748
|
+
if (!result.pass) {
|
|
1749
|
+
for (const cmdResult of result.results) {
|
|
1750
|
+
if (!cmdResult.pass) {
|
|
1751
|
+
if (cmdResult.failure) {
|
|
1752
|
+
let failureText = "";
|
|
1753
|
+
if (cmdResult.failure.includes("exit code mismatch")) {
|
|
1754
|
+
const match = cmdResult.failure.match(
|
|
1755
|
+
/expected (\d+), got (\d+)/
|
|
1756
|
+
);
|
|
1757
|
+
if (match) {
|
|
1758
|
+
failureText = `atteso exit ${match[1]}, ottenuto exit ${match[2]}`;
|
|
1759
|
+
}
|
|
1760
|
+
} else {
|
|
1761
|
+
failureText = `output shape non valido \u2014 ${cmdResult.failure}`;
|
|
1762
|
+
}
|
|
1763
|
+
lines.push(` \u2192 ${cmdResult.cmd}: ${failureText}`);
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
if (!result.warningCheckPass) {
|
|
1768
|
+
lines.push(
|
|
1769
|
+
` \u2192 avvertenze: attese \u2265${result.expectedWarningCount}, trovate ${result.actualWarningCount}`
|
|
1770
|
+
);
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
lines.push("");
|
|
1775
|
+
lines.push(`PASSATI: ${report.passed}`);
|
|
1776
|
+
lines.push(`FALLITI: ${report.failed}`);
|
|
1777
|
+
lines.push("");
|
|
1778
|
+
return lines.join("\n");
|
|
1779
|
+
}
|
|
1780
|
+
function formatRow(id, category, description, esito) {
|
|
1781
|
+
const idWidth = 4;
|
|
1782
|
+
const categoryWidth = 22;
|
|
1783
|
+
const descWidth = 40;
|
|
1784
|
+
const esitoWidth = 8;
|
|
1785
|
+
const idCol = id.padEnd(idWidth);
|
|
1786
|
+
const categoryCol = truncateOrPad(category, categoryWidth);
|
|
1787
|
+
const descCol = truncateOrPad(description, descWidth);
|
|
1788
|
+
const esitoCol = esito.padEnd(esitoWidth);
|
|
1789
|
+
return `${idCol} ${categoryCol} ${descCol} ${esitoCol}`;
|
|
1790
|
+
}
|
|
1791
|
+
function truncateOrPad(str, maxWidth) {
|
|
1792
|
+
if (str.length > maxWidth) {
|
|
1793
|
+
return str.slice(0, maxWidth - 2) + "..";
|
|
1794
|
+
}
|
|
1795
|
+
return str.padEnd(maxWidth);
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
// src/cli/commands/stress-test.ts
|
|
1799
|
+
async function runStressTest(_positionals, flags, stdout, stderr) {
|
|
1800
|
+
const rangeStr = flags["range"] ?? "1-100";
|
|
1801
|
+
const rangeParts = rangeStr.split("-");
|
|
1802
|
+
if (rangeParts.length !== 2) {
|
|
1803
|
+
stderr.write(
|
|
1804
|
+
`Error: --range must be in the form N-M (e.g. 1-100), got: "${rangeStr}"
|
|
1805
|
+
`
|
|
1806
|
+
);
|
|
1807
|
+
return 2;
|
|
1808
|
+
}
|
|
1809
|
+
const rangeStart = parseInt(rangeParts[0], 10);
|
|
1810
|
+
const rangeEnd = parseInt(rangeParts[1], 10);
|
|
1811
|
+
if (isNaN(rangeStart) || isNaN(rangeEnd) || rangeStart <= 0 || rangeEnd <= 0 || rangeStart > rangeEnd || rangeStart > 100 || rangeEnd > 100) {
|
|
1812
|
+
stderr.write(
|
|
1813
|
+
`Error: --range values must be positive integers between 1 and 100 with start \u2264 end, got: "${rangeStr}"
|
|
1814
|
+
`
|
|
1815
|
+
);
|
|
1816
|
+
return 2;
|
|
1817
|
+
}
|
|
1818
|
+
const __dirname = path5.dirname(fileURLToPath2(import.meta.url));
|
|
1819
|
+
const manifestPath = path5.join(__dirname, "../data/stress-manifest.json");
|
|
1820
|
+
let manifest;
|
|
1821
|
+
try {
|
|
1822
|
+
const raw = fs7.readFileSync(manifestPath, "utf8");
|
|
1823
|
+
manifest = JSON.parse(raw);
|
|
1824
|
+
} catch {
|
|
1825
|
+
stderr.write(`Error: could not load stress manifest at ${manifestPath}
|
|
1826
|
+
`);
|
|
1827
|
+
return 1;
|
|
1828
|
+
}
|
|
1829
|
+
const cliBin = process.argv[1];
|
|
1830
|
+
let outputDir;
|
|
1831
|
+
const outputDirFlag = flags["output-dir"];
|
|
1832
|
+
if (outputDirFlag) {
|
|
1833
|
+
outputDir = outputDirFlag;
|
|
1834
|
+
fs7.mkdirSync(outputDir, { recursive: true });
|
|
1835
|
+
} else {
|
|
1836
|
+
outputDir = fs7.mkdtempSync(path5.join(os4.tmpdir(), "minus-tracker-stress-"));
|
|
1837
|
+
}
|
|
1838
|
+
const ratesCheck = spawnSync2("node", [cliBin, "rates", "--check"], {
|
|
1839
|
+
encoding: "utf8",
|
|
1840
|
+
timeout: 15e3
|
|
1841
|
+
});
|
|
1842
|
+
if ((ratesCheck.status ?? 1) !== 0) {
|
|
1843
|
+
stderr.write("Warning: rates --check failed; ECB rates may be stale.\n");
|
|
1844
|
+
}
|
|
1845
|
+
const configShow = spawnSync2("node", [cliBin, "config", "--show"], {
|
|
1846
|
+
encoding: "utf8",
|
|
1847
|
+
timeout: 15e3
|
|
1848
|
+
});
|
|
1849
|
+
if ((configShow.status ?? 1) !== 0) {
|
|
1850
|
+
stderr.write("Warning: config --show failed.\n");
|
|
1851
|
+
}
|
|
1852
|
+
const scenarios = manifest.scenarios.filter((s) => {
|
|
1853
|
+
const id = parseInt(s.id, 10);
|
|
1854
|
+
return id >= rangeStart && id <= rangeEnd;
|
|
1855
|
+
});
|
|
1856
|
+
const results = [];
|
|
1857
|
+
for (const scenario of scenarios) {
|
|
1858
|
+
const csv = generateCsv(scenario);
|
|
1859
|
+
const result = runScenario(scenario, csv, outputDir, cliBin);
|
|
1860
|
+
results.push(result);
|
|
1861
|
+
stdout.write(
|
|
1862
|
+
` [${result.pass ? "\u2713" : "\u2717"}] ${scenario.id} ${scenario.slug}
|
|
1863
|
+
`
|
|
1864
|
+
);
|
|
1865
|
+
}
|
|
1866
|
+
const report = buildReport(results, rangeStart, rangeEnd);
|
|
1867
|
+
if (flags["json"]) {
|
|
1868
|
+
stdout.write(formatJson(report) + "\n");
|
|
1869
|
+
} else {
|
|
1870
|
+
stdout.write(formatTable(report) + "\n");
|
|
1871
|
+
}
|
|
1872
|
+
if (!flags["keep"]) {
|
|
1873
|
+
fs7.rmSync(outputDir, { recursive: true, force: true });
|
|
1874
|
+
}
|
|
1875
|
+
return report.failed === 0 ? 0 : 1;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
// src/cli/commands/classify.ts
|
|
1879
|
+
import * as fs8 from "fs";
|
|
1880
|
+
import * as path6 from "path";
|
|
1881
|
+
async function runClassify(positional, flags, s, stdout, stderr) {
|
|
1882
|
+
const offline = Boolean(flags["offline"]);
|
|
1883
|
+
if (!process.stdin.isTTY && !offline) {
|
|
1884
|
+
stderr.write(s.classifyNonTtyError + "\n");
|
|
1885
|
+
return 2;
|
|
1886
|
+
}
|
|
1887
|
+
const csvPath = positional[0];
|
|
1888
|
+
if (!csvPath) {
|
|
1889
|
+
stderr.write("Usage: minus-tracker classify [--offline] <file.csv>\n");
|
|
1890
|
+
return 2;
|
|
1891
|
+
}
|
|
1892
|
+
let csv;
|
|
1893
|
+
try {
|
|
1894
|
+
csv = fs8.readFileSync(csvPath, "utf8");
|
|
1895
|
+
} catch {
|
|
1896
|
+
stderr.write(`Cannot read file: ${csvPath}
|
|
1897
|
+
`);
|
|
1898
|
+
return 1;
|
|
1899
|
+
}
|
|
1900
|
+
const parser = new DEGIROParser();
|
|
1901
|
+
let transactions;
|
|
1902
|
+
try {
|
|
1903
|
+
transactions = parser.parse(csv);
|
|
1904
|
+
} catch (err) {
|
|
1905
|
+
if (err instanceof ParseError) {
|
|
1906
|
+
if (err.code === "INVALID_CSV") {
|
|
1907
|
+
stderr.write(s.errorInvalidCsv + "\n");
|
|
1908
|
+
} else {
|
|
1909
|
+
stderr.write(s.errorMissingColumn(err.columnName) + "\n");
|
|
1910
|
+
}
|
|
1911
|
+
return 1;
|
|
1912
|
+
}
|
|
1913
|
+
throw err;
|
|
1914
|
+
}
|
|
1915
|
+
const base = csvPath.replace(/\.csv$/i, "");
|
|
1916
|
+
const sidecarPath = base + ".classify.json";
|
|
1917
|
+
if (offline) {
|
|
1918
|
+
stdout.write(s.classifyOfflineWarning + "\n");
|
|
1919
|
+
const isinToProduct = /* @__PURE__ */ new Map();
|
|
1920
|
+
for (const tx of transactions) {
|
|
1921
|
+
if (tx.isin && !isinToProduct.has(tx.isin)) {
|
|
1922
|
+
isinToProduct.set(tx.isin, tx.product);
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
const classifications = {};
|
|
1926
|
+
for (const [isin, product] of isinToProduct.entries()) {
|
|
1927
|
+
classifications[isin] = {
|
|
1928
|
+
product,
|
|
1929
|
+
assetClass: "ETF",
|
|
1930
|
+
bucketGain: "A",
|
|
1931
|
+
bucketLoss: "B",
|
|
1932
|
+
taxRate: 0.26,
|
|
1933
|
+
whiteListed: null,
|
|
1934
|
+
confirmedByUser: true,
|
|
1935
|
+
source: "user"
|
|
1936
|
+
};
|
|
1937
|
+
}
|
|
1938
|
+
const sidecarContent = JSON.stringify(
|
|
1939
|
+
{
|
|
1940
|
+
version: 1,
|
|
1941
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1942
|
+
classifications,
|
|
1943
|
+
warnings: []
|
|
1944
|
+
},
|
|
1945
|
+
null,
|
|
1946
|
+
2
|
|
1947
|
+
);
|
|
1948
|
+
try {
|
|
1949
|
+
fs8.writeFileSync(sidecarPath, sidecarContent, "utf-8");
|
|
1950
|
+
} catch {
|
|
1951
|
+
stderr.write(`Cannot write sidecar: ${sidecarPath}
|
|
1952
|
+
`);
|
|
1953
|
+
return 1;
|
|
1954
|
+
}
|
|
1955
|
+
stdout.write(s.classifyWritten(sidecarPath) + "\n");
|
|
1956
|
+
return 0;
|
|
1957
|
+
}
|
|
1958
|
+
const classifier = new Classifier({ interactive: true });
|
|
1959
|
+
if (fs8.existsSync(sidecarPath)) {
|
|
1960
|
+
const existingMap = await classifier.load(sidecarPath);
|
|
1961
|
+
const confirmedCount = Object.values(existingMap).filter(
|
|
1962
|
+
(e) => e.confirmedByUser
|
|
1963
|
+
).length;
|
|
1964
|
+
stdout.write(s.classifyMergePrompt(confirmedCount));
|
|
1965
|
+
const answer = await readLine(process.stdin);
|
|
1966
|
+
if (answer.trim().toLowerCase() === "n") {
|
|
1967
|
+
return 0;
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
await classifier.classify(transactions, sidecarPath);
|
|
1971
|
+
stdout.write(s.classifyWritten(path6.resolve(sidecarPath)) + "\n");
|
|
1972
|
+
return 0;
|
|
1973
|
+
}
|
|
1974
|
+
function readLine(stream) {
|
|
1975
|
+
return new Promise((resolve2) => {
|
|
1976
|
+
let buf = "";
|
|
1977
|
+
const onData = (chunk) => {
|
|
1978
|
+
buf += chunk.toString();
|
|
1979
|
+
const nl = buf.indexOf("\n");
|
|
1980
|
+
if (nl !== -1) {
|
|
1981
|
+
stream.removeListener("data", onData);
|
|
1982
|
+
resolve2(buf.slice(0, nl));
|
|
1983
|
+
}
|
|
1984
|
+
};
|
|
1985
|
+
stream.on("data", onData);
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1988
|
+
|
|
846
1989
|
// src/cli/index.ts
|
|
847
1990
|
async function main() {
|
|
848
1991
|
const { values, positionals } = parseArgs({
|
|
@@ -854,7 +1997,11 @@ async function main() {
|
|
|
854
1997
|
json: { type: "boolean", default: false },
|
|
855
1998
|
check: { type: "boolean", default: false },
|
|
856
1999
|
update: { type: "boolean", default: false },
|
|
857
|
-
show: { type: "boolean", default: false }
|
|
2000
|
+
show: { type: "boolean", default: false },
|
|
2001
|
+
range: { type: "string" },
|
|
2002
|
+
keep: { type: "boolean", default: false },
|
|
2003
|
+
"output-dir": { type: "string" },
|
|
2004
|
+
offline: { type: "boolean", default: false }
|
|
858
2005
|
},
|
|
859
2006
|
allowPositionals: true,
|
|
860
2007
|
strict: false
|
|
@@ -881,14 +2028,23 @@ async function main() {
|
|
|
881
2028
|
case "config":
|
|
882
2029
|
exitCode = await runConfig(restPositionals, flags, s, stdout, stderr);
|
|
883
2030
|
break;
|
|
2031
|
+
case "stress-test":
|
|
2032
|
+
exitCode = await runStressTest(restPositionals, flags, stdout, stderr);
|
|
2033
|
+
break;
|
|
2034
|
+
case "classify":
|
|
2035
|
+
exitCode = await runClassify(restPositionals, flags, s, stdout, stderr);
|
|
2036
|
+
break;
|
|
884
2037
|
default:
|
|
885
2038
|
stderr.write(
|
|
886
|
-
"Usage: minus-tracker <calc|validate|rates|config> [options] [file]\n"
|
|
2039
|
+
"Usage: minus-tracker <calc|validate|rates|config|stress-test|classify> [options] [file]\n"
|
|
887
2040
|
);
|
|
888
2041
|
exitCode = 2;
|
|
889
2042
|
}
|
|
890
2043
|
} catch (err) {
|
|
891
|
-
if (err instanceof
|
|
2044
|
+
if (err instanceof ClassificationError) {
|
|
2045
|
+
stderr.write(err.message + "\n");
|
|
2046
|
+
exitCode = 1;
|
|
2047
|
+
} else if (err instanceof ParseError) {
|
|
892
2048
|
if (err.code === "INVALID_CSV") {
|
|
893
2049
|
stderr.write(s.errorInvalidCsv + "\n");
|
|
894
2050
|
} else {
|