@gabrielerandelli/minus-tracker 0.5.6 → 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -36,7 +36,7 @@ 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: (path4) => `Snapshot scritto in ${path4}`,
39
+ ratesSnapshotWritten: (path5) => `Snapshot scritto in ${path5}`,
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}`,
@@ -77,7 +77,7 @@ var en = {
77
77
  ratesGaps: (list) => `Gaps: ${list}`,
78
78
  ratesUpdateFetching: "Fetching ECB SDMX...",
79
79
  ratesUpdateDone: (n) => `Done. Added ${n} new dates.`,
80
- ratesSnapshotWritten: (path4) => `Snapshot written to ${path4}`,
80
+ ratesSnapshotWritten: (path5) => `Snapshot written to ${path5}`,
81
81
  ratesAutoUpdateStart: "ECB rates are stale. Auto-updating\u2026",
82
82
  ratesAutoUpdateFailed: "Auto-update failed. Proceeding with available rates.",
83
83
  configLangSet: (lang) => `Language set to: ${lang}`,
@@ -319,6 +319,17 @@ var DEGIROParser = class {
319
319
  constructor(snapshot) {
320
320
  this._snapshot = snapshot;
321
321
  }
322
+ /**
323
+ * Parse a DEGIRO Transactions CSV export string.
324
+ *
325
+ * @param csv - Raw UTF-8 string from the DEGIRO Transactions export.
326
+ * @returns Array of normalised Transaction objects. Empty if no data rows are found.
327
+ * @throws {ParseError} code `"INVALID_CSV"` — malformed CSV or binary content.
328
+ * @throws {ParseError} code `"MISSING_COLUMN"` (+ `columnName`) — required column absent.
329
+ *
330
+ * Rows with missing ISIN, unsupported currency, zero quantity, or no ECB rate within
331
+ * 3 trading days are skipped silently. Inspect `parser.warnings` for details.
332
+ */
322
333
  parse(csv) {
323
334
  this._warningEntries = [];
324
335
  if (typeof csv !== "string" || csv.includes("\0")) {
@@ -442,10 +453,23 @@ function inferTaxYear(transactions) {
442
453
  var Calculator = class {
443
454
  _transactions;
444
455
  _parseWarnings;
456
+ /**
457
+ * @param transactions - Normalised Transaction objects from the CSV import step.
458
+ * @param parseWarnings - Warnings collected during the import step; forwarded into the report.
459
+ */
445
460
  constructor(transactions, parseWarnings) {
446
461
  this._transactions = transactions;
447
462
  this._parseWarnings = parseWarnings ?? [];
448
463
  }
464
+ /**
465
+ * Run LIFO or FIFO lot-matching over the transaction list.
466
+ *
467
+ * @param method - `"LIFO"` or `"FIFO"`.
468
+ * @returns GainsReport with `plusvalenze`, `minusvalenze`, `netResult`, per-lot breakdown,
469
+ * ECB rates used, and any accumulated warnings.
470
+ * @throws {CalculationError} when a SELL has no matching open buy lots.
471
+ * `error.isin` and `error.date` identify the problematic transaction.
472
+ */
449
473
  calculateGains(method) {
450
474
  const warnings = [...this._parseWarnings];
451
475
  const sorted = [...this._transactions].sort((a, b) => {
@@ -843,6 +867,412 @@ async function runConfig(positional, flags, s, stdout, stderr) {
843
867
  return 2;
844
868
  }
845
869
 
870
+ // src/cli/commands/stress-test.ts
871
+ import * as fs6 from "fs";
872
+ import * as path4 from "path";
873
+ import * as os3 from "os";
874
+ import { fileURLToPath as fileURLToPath2 } from "url";
875
+ import { spawnSync as spawnSync2 } from "child_process";
876
+
877
+ // src/stress/generator.ts
878
+ function formatDate(isoDate) {
879
+ const [yyyy, mm, dd] = isoDate.split("-");
880
+ return `${dd}-${mm}-${yyyy}`;
881
+ }
882
+ function generateCsv(scenario) {
883
+ if (scenario.special === "invalid_csv") {
884
+ return "NOT_A_CSV\0\ngarbage data";
885
+ }
886
+ const allColumns = [
887
+ "Date",
888
+ "Time",
889
+ "Product",
890
+ "ISIN",
891
+ "Exchange",
892
+ "Execution centre",
893
+ "Quantity",
894
+ "Price",
895
+ "Local value",
896
+ "Local value currency",
897
+ "Value",
898
+ "Value currency",
899
+ "Exchange rate",
900
+ "Transaction costs",
901
+ "Transaction costs currency",
902
+ "Total",
903
+ "Total currency",
904
+ "Order ID"
905
+ ];
906
+ let columns = allColumns;
907
+ if (scenario.special === "missing_col_isin") {
908
+ columns = columns.filter((c) => c !== "ISIN");
909
+ } else if (scenario.special === "missing_col_quantity") {
910
+ columns = columns.filter((c) => c !== "Quantity");
911
+ } else if (scenario.special === "missing_col_price") {
912
+ columns = columns.filter((c) => c !== "Price");
913
+ }
914
+ const buildRow = (values, includeColumns) => {
915
+ return includeColumns.map((col) => values[col] ?? "").join(",");
916
+ };
917
+ const header = columns.join(",");
918
+ const rows = [header];
919
+ for (let rowIndex = 0; rowIndex < scenario.transactions.length; rowIndex++) {
920
+ const tx = scenario.transactions[rowIndex];
921
+ const localValue = -tx.qty * tx.price;
922
+ const total = localValue - tx.fee;
923
+ const rowValues = {
924
+ Date: formatDate(tx.date),
925
+ Time: "09:00",
926
+ Product: tx.product,
927
+ ISIN: tx.isin,
928
+ Exchange: "XEUR",
929
+ "Execution centre": "XEUR",
930
+ Quantity: String(tx.qty),
931
+ Price: tx.price.toFixed(2),
932
+ "Local value": localValue.toFixed(2),
933
+ "Local value currency": tx.currency,
934
+ Value: localValue.toFixed(2),
935
+ "Value currency": "EUR",
936
+ "Exchange rate": "1",
937
+ "Transaction costs": (-Math.abs(tx.fee)).toFixed(2),
938
+ "Transaction costs currency": "EUR",
939
+ Total: total.toFixed(2),
940
+ "Total currency": "EUR",
941
+ "Order ID": `stress-${scenario.id}-${rowIndex}`
942
+ };
943
+ const row = buildRow(rowValues, columns);
944
+ rows.push(row);
945
+ }
946
+ return rows.join("\n");
947
+ }
948
+
949
+ // src/stress/runner.ts
950
+ import { spawnSync } from "child_process";
951
+ import { writeFileSync as writeFileSync3 } from "fs";
952
+ import { join as join4 } from "path";
953
+ function countWarnings(text) {
954
+ if (!text) return 0;
955
+ const match = text.match(/(?:warnings|avvertenze)\s*:\s*(\d+)/i);
956
+ return match ? parseInt(match[1], 10) : 0;
957
+ }
958
+ function isValidJson(jsonStr) {
959
+ try {
960
+ const obj = JSON.parse(jsonStr);
961
+ return typeof obj === "object" && obj !== null && "plusvalenze" in obj && "minusvalenze" in obj && "lots" in obj;
962
+ } catch {
963
+ return false;
964
+ }
965
+ }
966
+ function runCommand(cliBin, args, cmdLabel, expectedExitCode, outputShapeCheck) {
967
+ const isJsFile = cliBin.endsWith(".js");
968
+ let result;
969
+ if (isJsFile) {
970
+ result = spawnSync("node", [cliBin, ...args], {
971
+ encoding: "utf8",
972
+ timeout: 15e3,
973
+ env: { ...process.env, MINUS_TRACKER_LANG: void 0 }
974
+ });
975
+ } else {
976
+ result = spawnSync(cliBin, args, {
977
+ encoding: "utf8",
978
+ timeout: 15e3,
979
+ env: { ...process.env, MINUS_TRACKER_LANG: void 0 }
980
+ });
981
+ }
982
+ const exitCode = result.status ?? 1;
983
+ const stdout = result.stdout || "";
984
+ const stderr = result.stderr || "";
985
+ const exitCodeMatch = exitCode === expectedExitCode;
986
+ const shapeCheck = outputShapeCheck(stdout, stderr, exitCode);
987
+ let pass = exitCodeMatch && shapeCheck.valid;
988
+ let failure;
989
+ if (!exitCodeMatch) {
990
+ failure = `exit code mismatch: expected ${expectedExitCode}, got ${exitCode}`;
991
+ } else if (!shapeCheck.valid) {
992
+ failure = shapeCheck.reason;
993
+ }
994
+ return {
995
+ cmd: cmdLabel,
996
+ exitCode,
997
+ stdout,
998
+ stderr,
999
+ pass,
1000
+ failure
1001
+ };
1002
+ }
1003
+ function runScenario(scenario, csvContent, outputDir, cliBin) {
1004
+ const csvFileName = `${scenario.id}-${scenario.slug}.csv`;
1005
+ const csvFile = join4(outputDir, csvFileName);
1006
+ writeFileSync3(csvFile, csvContent, "utf8");
1007
+ const results = [];
1008
+ results.push(
1009
+ runCommand(
1010
+ cliBin,
1011
+ ["calc", csvFile],
1012
+ "calc",
1013
+ scenario.expect.calc_exit,
1014
+ (stdout) => {
1015
+ if (scenario.expect.calc_exit === 0) {
1016
+ const hasPlus = /plusvalenze|plusvalues/i.test(stdout);
1017
+ return hasPlus ? { valid: true } : {
1018
+ valid: false,
1019
+ reason: "calc output missing plusvalenze/plusvalues (exit 0)"
1020
+ };
1021
+ }
1022
+ return { valid: true };
1023
+ }
1024
+ )
1025
+ );
1026
+ results.push(
1027
+ runCommand(
1028
+ cliBin,
1029
+ ["calc", "--method", "FIFO", csvFile],
1030
+ "calc --method FIFO",
1031
+ scenario.expect.fifo_exit,
1032
+ (stdout) => {
1033
+ if (scenario.expect.fifo_exit === 0) {
1034
+ const hasPlus = /plusvalenze|plusvalues/i.test(stdout);
1035
+ return hasPlus ? { valid: true } : {
1036
+ valid: false,
1037
+ reason: "calc --method FIFO output missing plusvalenze/plusvalues (exit 0)"
1038
+ };
1039
+ }
1040
+ return { valid: true };
1041
+ }
1042
+ )
1043
+ );
1044
+ results.push(
1045
+ runCommand(
1046
+ cliBin,
1047
+ ["calc", "--json", csvFile],
1048
+ "calc --json",
1049
+ scenario.expect.json_exit,
1050
+ (stdout) => {
1051
+ if (scenario.expect.json_exit === 0) {
1052
+ const isValid = isValidJson(stdout);
1053
+ return isValid ? { valid: true } : {
1054
+ valid: false,
1055
+ reason: "calc --json output is not valid JSON with required keys (exit 0)"
1056
+ };
1057
+ }
1058
+ return { valid: true };
1059
+ }
1060
+ )
1061
+ );
1062
+ results.push(
1063
+ runCommand(
1064
+ cliBin,
1065
+ ["calc", "--lang", "en", csvFile],
1066
+ "calc --lang en",
1067
+ scenario.expect.en_exit,
1068
+ (stdout) => {
1069
+ if (scenario.expect.en_exit === 0) {
1070
+ const nonEmpty = stdout.trim().length > 0;
1071
+ return nonEmpty ? { valid: true } : {
1072
+ valid: false,
1073
+ reason: "calc --lang en output is empty (exit 0)"
1074
+ };
1075
+ }
1076
+ return { valid: true };
1077
+ }
1078
+ )
1079
+ );
1080
+ results.push(
1081
+ runCommand(
1082
+ cliBin,
1083
+ ["validate", csvFile],
1084
+ "validate",
1085
+ scenario.expect.validate_exit,
1086
+ (stdout) => {
1087
+ if (scenario.expect.validate_exit !== 0) return { valid: true };
1088
+ const nonEmpty = stdout.trim().length > 0;
1089
+ return nonEmpty ? { valid: true } : {
1090
+ valid: false,
1091
+ reason: "validate output is empty"
1092
+ };
1093
+ }
1094
+ )
1095
+ );
1096
+ const enCalcResult = results[3];
1097
+ const actualWarningCount = countWarnings(enCalcResult.stdout);
1098
+ const warningCheckPass = actualWarningCount >= scenario.expect.warning_count;
1099
+ const pass = results.every((r) => r.pass) && warningCheckPass;
1100
+ return {
1101
+ id: scenario.id,
1102
+ category: scenario.category,
1103
+ slug: scenario.slug,
1104
+ description: scenario.description,
1105
+ csvFile,
1106
+ results,
1107
+ pass,
1108
+ warningCheckPass,
1109
+ expectedWarningCount: scenario.expect.warning_count,
1110
+ actualWarningCount
1111
+ };
1112
+ }
1113
+
1114
+ // src/stress/reporter.ts
1115
+ function buildReport(results, rangeStart, rangeEnd) {
1116
+ const passed = results.filter((r) => r.pass).length;
1117
+ const failed = results.length - passed;
1118
+ return {
1119
+ totalScenarios: results.length,
1120
+ passed,
1121
+ failed,
1122
+ results,
1123
+ rangeStart,
1124
+ rangeEnd
1125
+ };
1126
+ }
1127
+ function formatJson(report) {
1128
+ return JSON.stringify(report, null, 2);
1129
+ }
1130
+ function formatTable(report) {
1131
+ const lines = [];
1132
+ lines.push("STRESS TEST \u2014 minus-tracker");
1133
+ lines.push(
1134
+ `Scenari: ${report.totalScenarios} | Passati: ${report.passed} | Falliti: ${report.failed}`
1135
+ );
1136
+ lines.push("");
1137
+ const idCol = "ID";
1138
+ const categoryCol = "CATEGORIA";
1139
+ const descCol = "DESCRIZIONE";
1140
+ const resultCol = "ESITO";
1141
+ lines.push(formatRow(idCol, categoryCol, descCol, resultCol));
1142
+ for (const result of report.results) {
1143
+ const idStr = result.id.padStart(3, "0");
1144
+ const status = result.pass ? "\u2713 PASS" : "\u2717 FAIL";
1145
+ lines.push(formatRow(idStr, result.category, result.description, status));
1146
+ if (!result.pass) {
1147
+ for (const cmdResult of result.results) {
1148
+ if (!cmdResult.pass) {
1149
+ if (cmdResult.failure) {
1150
+ let failureText = "";
1151
+ if (cmdResult.failure.includes("exit code mismatch")) {
1152
+ const match = cmdResult.failure.match(
1153
+ /expected (\d+), got (\d+)/
1154
+ );
1155
+ if (match) {
1156
+ failureText = `atteso exit ${match[1]}, ottenuto exit ${match[2]}`;
1157
+ }
1158
+ } else {
1159
+ failureText = `output shape non valido \u2014 ${cmdResult.failure}`;
1160
+ }
1161
+ lines.push(` \u2192 ${cmdResult.cmd}: ${failureText}`);
1162
+ }
1163
+ }
1164
+ }
1165
+ if (!result.warningCheckPass) {
1166
+ lines.push(
1167
+ ` \u2192 avvertenze: attese \u2265${result.expectedWarningCount}, trovate ${result.actualWarningCount}`
1168
+ );
1169
+ }
1170
+ }
1171
+ }
1172
+ lines.push("");
1173
+ lines.push(`PASSATI: ${report.passed}`);
1174
+ lines.push(`FALLITI: ${report.failed}`);
1175
+ lines.push("");
1176
+ return lines.join("\n");
1177
+ }
1178
+ function formatRow(id, category, description, esito) {
1179
+ const idWidth = 4;
1180
+ const categoryWidth = 22;
1181
+ const descWidth = 40;
1182
+ const esitoWidth = 8;
1183
+ const idCol = id.padEnd(idWidth);
1184
+ const categoryCol = truncateOrPad(category, categoryWidth);
1185
+ const descCol = truncateOrPad(description, descWidth);
1186
+ const esitoCol = esito.padEnd(esitoWidth);
1187
+ return `${idCol} ${categoryCol} ${descCol} ${esitoCol}`;
1188
+ }
1189
+ function truncateOrPad(str, maxWidth) {
1190
+ if (str.length > maxWidth) {
1191
+ return str.slice(0, maxWidth - 2) + "..";
1192
+ }
1193
+ return str.padEnd(maxWidth);
1194
+ }
1195
+
1196
+ // src/cli/commands/stress-test.ts
1197
+ async function runStressTest(_positionals, flags, stdout, stderr) {
1198
+ const rangeStr = flags["range"] ?? "1-100";
1199
+ const rangeParts = rangeStr.split("-");
1200
+ if (rangeParts.length !== 2) {
1201
+ stderr.write(
1202
+ `Error: --range must be in the form N-M (e.g. 1-100), got: "${rangeStr}"
1203
+ `
1204
+ );
1205
+ return 2;
1206
+ }
1207
+ const rangeStart = parseInt(rangeParts[0], 10);
1208
+ const rangeEnd = parseInt(rangeParts[1], 10);
1209
+ if (isNaN(rangeStart) || isNaN(rangeEnd) || rangeStart <= 0 || rangeEnd <= 0 || rangeStart > rangeEnd || rangeStart > 100 || rangeEnd > 100) {
1210
+ stderr.write(
1211
+ `Error: --range values must be positive integers between 1 and 100 with start \u2264 end, got: "${rangeStr}"
1212
+ `
1213
+ );
1214
+ return 2;
1215
+ }
1216
+ const __dirname = path4.dirname(fileURLToPath2(import.meta.url));
1217
+ const manifestPath = path4.join(__dirname, "../data/stress-manifest.json");
1218
+ let manifest;
1219
+ try {
1220
+ const raw = fs6.readFileSync(manifestPath, "utf8");
1221
+ manifest = JSON.parse(raw);
1222
+ } catch {
1223
+ stderr.write(`Error: could not load stress manifest at ${manifestPath}
1224
+ `);
1225
+ return 1;
1226
+ }
1227
+ const cliBin = process.argv[1];
1228
+ let outputDir;
1229
+ const outputDirFlag = flags["output-dir"];
1230
+ if (outputDirFlag) {
1231
+ outputDir = outputDirFlag;
1232
+ fs6.mkdirSync(outputDir, { recursive: true });
1233
+ } else {
1234
+ outputDir = fs6.mkdtempSync(path4.join(os3.tmpdir(), "minus-tracker-stress-"));
1235
+ }
1236
+ const ratesCheck = spawnSync2("node", [cliBin, "rates", "--check"], {
1237
+ encoding: "utf8",
1238
+ timeout: 15e3
1239
+ });
1240
+ if ((ratesCheck.status ?? 1) !== 0) {
1241
+ stderr.write("Warning: rates --check failed; ECB rates may be stale.\n");
1242
+ }
1243
+ const configShow = spawnSync2("node", [cliBin, "config", "--show"], {
1244
+ encoding: "utf8",
1245
+ timeout: 15e3
1246
+ });
1247
+ if ((configShow.status ?? 1) !== 0) {
1248
+ stderr.write("Warning: config --show failed.\n");
1249
+ }
1250
+ const scenarios = manifest.scenarios.filter((s) => {
1251
+ const id = parseInt(s.id, 10);
1252
+ return id >= rangeStart && id <= rangeEnd;
1253
+ });
1254
+ const results = [];
1255
+ for (const scenario of scenarios) {
1256
+ const csv = generateCsv(scenario);
1257
+ const result = runScenario(scenario, csv, outputDir, cliBin);
1258
+ results.push(result);
1259
+ stdout.write(
1260
+ ` [${result.pass ? "\u2713" : "\u2717"}] ${scenario.id} ${scenario.slug}
1261
+ `
1262
+ );
1263
+ }
1264
+ const report = buildReport(results, rangeStart, rangeEnd);
1265
+ if (flags["json"]) {
1266
+ stdout.write(formatJson(report) + "\n");
1267
+ } else {
1268
+ stdout.write(formatTable(report) + "\n");
1269
+ }
1270
+ if (!flags["keep"]) {
1271
+ fs6.rmSync(outputDir, { recursive: true, force: true });
1272
+ }
1273
+ return report.failed === 0 ? 0 : 1;
1274
+ }
1275
+
846
1276
  // src/cli/index.ts
847
1277
  async function main() {
848
1278
  const { values, positionals } = parseArgs({
@@ -854,7 +1284,10 @@ async function main() {
854
1284
  json: { type: "boolean", default: false },
855
1285
  check: { type: "boolean", default: false },
856
1286
  update: { type: "boolean", default: false },
857
- show: { type: "boolean", default: false }
1287
+ show: { type: "boolean", default: false },
1288
+ range: { type: "string" },
1289
+ keep: { type: "boolean", default: false },
1290
+ "output-dir": { type: "string" }
858
1291
  },
859
1292
  allowPositionals: true,
860
1293
  strict: false
@@ -881,9 +1314,12 @@ async function main() {
881
1314
  case "config":
882
1315
  exitCode = await runConfig(restPositionals, flags, s, stdout, stderr);
883
1316
  break;
1317
+ case "stress-test":
1318
+ exitCode = await runStressTest(restPositionals, flags, stdout, stderr);
1319
+ break;
884
1320
  default:
885
1321
  stderr.write(
886
- "Usage: minus-tracker <calc|validate|rates|config> [options] [file]\n"
1322
+ "Usage: minus-tracker <calc|validate|rates|config|stress-test> [options] [file]\n"
887
1323
  );
888
1324
  exitCode = 2;
889
1325
  }