@fuzzle/opencode-accountant 0.10.0 → 0.10.1-next.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/dist/index.js +322 -101
- package/docs/configuration/symbol-map.md +70 -0
- package/docs/tools/import-pipeline.md +27 -7
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -29,6 +29,24 @@ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
|
29
29
|
var __require = import.meta.require;
|
|
30
30
|
|
|
31
31
|
// node_modules/js-yaml/dist/js-yaml.mjs
|
|
32
|
+
var exports_js_yaml = {};
|
|
33
|
+
__export(exports_js_yaml, {
|
|
34
|
+
types: () => types,
|
|
35
|
+
safeLoadAll: () => safeLoadAll,
|
|
36
|
+
safeLoad: () => safeLoad,
|
|
37
|
+
safeDump: () => safeDump,
|
|
38
|
+
loadAll: () => loadAll,
|
|
39
|
+
load: () => load,
|
|
40
|
+
dump: () => dump,
|
|
41
|
+
default: () => jsYaml,
|
|
42
|
+
YAMLException: () => YAMLException,
|
|
43
|
+
Type: () => Type,
|
|
44
|
+
Schema: () => Schema,
|
|
45
|
+
JSON_SCHEMA: () => JSON_SCHEMA,
|
|
46
|
+
FAILSAFE_SCHEMA: () => FAILSAFE_SCHEMA,
|
|
47
|
+
DEFAULT_SCHEMA: () => DEFAULT_SCHEMA,
|
|
48
|
+
CORE_SCHEMA: () => CORE_SCHEMA
|
|
49
|
+
});
|
|
32
50
|
function isNothing(subject) {
|
|
33
51
|
return typeof subject === "undefined" || subject === null;
|
|
34
52
|
}
|
|
@@ -17446,7 +17464,9 @@ function detectProvider(filename, content, config2) {
|
|
|
17446
17464
|
continue;
|
|
17447
17465
|
}
|
|
17448
17466
|
const actualHeader = normalizeHeader(fields);
|
|
17449
|
-
|
|
17467
|
+
const ruleHeaderSep = delimiter !== "," && rule.header.includes(delimiter) ? delimiter : ",";
|
|
17468
|
+
const expectedHeader = normalizeHeader(rule.header.split(ruleHeaderSep));
|
|
17469
|
+
if (actualHeader !== expectedHeader) {
|
|
17450
17470
|
continue;
|
|
17451
17471
|
}
|
|
17452
17472
|
if (!firstRow) {
|
|
@@ -24090,6 +24110,7 @@ Note: This tool requires a contextId from a prior classify/import step.`,
|
|
|
24090
24110
|
}
|
|
24091
24111
|
});
|
|
24092
24112
|
// src/tools/import-pipeline.ts
|
|
24113
|
+
import * as fs20 from "fs";
|
|
24093
24114
|
import * as path14 from "path";
|
|
24094
24115
|
|
|
24095
24116
|
// src/utils/accountDeclarations.ts
|
|
@@ -24949,24 +24970,6 @@ function adjustLotsForSplit(inventory, symbol2, ratio, logger) {
|
|
|
24949
24970
|
}
|
|
24950
24971
|
logger?.info(`Adjusted ${lots.length} lots for ${symbol2} split (ratio: ${ratio})`);
|
|
24951
24972
|
}
|
|
24952
|
-
function adjustLotsForMerger(inventory, oldSymbol, newSymbol, ratio, logger) {
|
|
24953
|
-
const oldLots = inventory[oldSymbol];
|
|
24954
|
-
if (!oldLots || oldLots.length === 0) {
|
|
24955
|
-
logger?.warn(`No lots found for ${oldSymbol} during merger adjustment`);
|
|
24956
|
-
return;
|
|
24957
|
-
}
|
|
24958
|
-
const newLots = oldLots.map((lot) => ({
|
|
24959
|
-
...lot,
|
|
24960
|
-
quantity: lot.quantity * ratio,
|
|
24961
|
-
costBasis: lot.costBasis / ratio
|
|
24962
|
-
}));
|
|
24963
|
-
if (!inventory[newSymbol]) {
|
|
24964
|
-
inventory[newSymbol] = [];
|
|
24965
|
-
}
|
|
24966
|
-
inventory[newSymbol].push(...newLots);
|
|
24967
|
-
delete inventory[oldSymbol];
|
|
24968
|
-
logger?.info(`Merger: ${oldLots.length} lots moved from ${oldSymbol} to ${newSymbol} (ratio: ${ratio})`);
|
|
24969
|
-
}
|
|
24970
24973
|
function removeLots(inventory, symbol2, logger) {
|
|
24971
24974
|
const lots = inventory[symbol2];
|
|
24972
24975
|
if (!lots || lots.length === 0) {
|
|
@@ -25086,27 +25089,6 @@ function generateDividendEntry(dividend, logger) {
|
|
|
25086
25089
|
`;
|
|
25087
25090
|
return entry;
|
|
25088
25091
|
}
|
|
25089
|
-
function generateMergerEntry(action, oldQuantity, newQuantity, logger) {
|
|
25090
|
-
const date5 = formatDate(action.date);
|
|
25091
|
-
const newSymbol = action.newSymbol || "UNKNOWN";
|
|
25092
|
-
const description = escapeDescription(`Merger: ${action.symbol} -> ${newSymbol}`);
|
|
25093
|
-
logger?.debug(`Generating Merger entry: ${oldQuantity} ${action.symbol} -> ${newQuantity} ${newSymbol}`);
|
|
25094
|
-
let entry = `${date5} ${description}
|
|
25095
|
-
`;
|
|
25096
|
-
entry += ` ; swissquote:order:${action.orderNum}
|
|
25097
|
-
`;
|
|
25098
|
-
entry += ` ; Old ISIN: ${action.isin}, New ISIN: ${action.newIsin || "unknown"}
|
|
25099
|
-
`;
|
|
25100
|
-
entry += ` assets:investments:stocks:${action.symbol} -${formatQuantity(oldQuantity)} ${action.symbol}
|
|
25101
|
-
`;
|
|
25102
|
-
entry += ` equity:conversion ${formatQuantity(oldQuantity)} ${action.symbol}
|
|
25103
|
-
`;
|
|
25104
|
-
entry += ` equity:conversion -${formatQuantity(newQuantity)} ${newSymbol}
|
|
25105
|
-
`;
|
|
25106
|
-
entry += ` assets:investments:stocks:${newSymbol} ${formatQuantity(newQuantity)} ${newSymbol}
|
|
25107
|
-
`;
|
|
25108
|
-
return entry;
|
|
25109
|
-
}
|
|
25110
25092
|
function generateSplitEntry(action, oldQuantity, newQuantity, logger) {
|
|
25111
25093
|
const date5 = formatDate(action.date);
|
|
25112
25094
|
const ratio = action.ratio || newQuantity / oldQuantity;
|
|
@@ -25152,6 +25134,53 @@ function generateWorthlessEntry(action, removedLots, logger) {
|
|
|
25152
25134
|
`;
|
|
25153
25135
|
return entry;
|
|
25154
25136
|
}
|
|
25137
|
+
function generateMultiWayMergerEntry(group, crossCurrencyOutgoingSymbols, logger) {
|
|
25138
|
+
const date5 = formatDate(group.date);
|
|
25139
|
+
const outSymbols = crossCurrencyOutgoingSymbols ?? group.outgoing.map((a) => a.symbol);
|
|
25140
|
+
const inSymbols = group.incoming.map((a) => a.symbol);
|
|
25141
|
+
const description = escapeDescription(`Merger: ${outSymbols.join(" + ")} -> ${inSymbols.join(" + ")}`);
|
|
25142
|
+
logger?.debug(`Generating multi-way merger entry: ${outSymbols.join(", ")} -> ${inSymbols.join(", ")}`);
|
|
25143
|
+
let entry = `${date5} ${description}
|
|
25144
|
+
`;
|
|
25145
|
+
entry += ` ; swissquote:order:${group.orderNum}
|
|
25146
|
+
`;
|
|
25147
|
+
const oldIsins = group.outgoing.map((a) => a.isin).filter(Boolean);
|
|
25148
|
+
const newIsins = group.incoming.map((a) => a.isin).filter(Boolean);
|
|
25149
|
+
if (oldIsins.length > 0 || newIsins.length > 0) {
|
|
25150
|
+
entry += ` ; Old ISIN: ${oldIsins.join(", ") || "n/a"}, New ISINs: ${newIsins.join(", ") || "n/a"}
|
|
25151
|
+
`;
|
|
25152
|
+
}
|
|
25153
|
+
for (const out of group.outgoing) {
|
|
25154
|
+
const qty = formatQuantity(Math.abs(out.quantity));
|
|
25155
|
+
entry += ` assets:investments:stocks:${out.symbol} -${qty} ${out.symbol}
|
|
25156
|
+
`;
|
|
25157
|
+
entry += ` equity:conversion ${qty} ${out.symbol}
|
|
25158
|
+
`;
|
|
25159
|
+
}
|
|
25160
|
+
for (const inc of group.incoming) {
|
|
25161
|
+
const qty = formatQuantity(Math.abs(inc.quantity));
|
|
25162
|
+
entry += ` equity:conversion -${qty} ${inc.symbol}
|
|
25163
|
+
`;
|
|
25164
|
+
entry += ` assets:investments:stocks:${inc.symbol} ${qty} ${inc.symbol}
|
|
25165
|
+
`;
|
|
25166
|
+
}
|
|
25167
|
+
return entry;
|
|
25168
|
+
}
|
|
25169
|
+
function generateRightsDistributionEntry(action, logger) {
|
|
25170
|
+
const date5 = formatDate(action.date);
|
|
25171
|
+
const qty = formatQuantity(Math.abs(action.quantity));
|
|
25172
|
+
const description = escapeDescription(`Rights Distribution: ${action.symbol} - ${action.name}`);
|
|
25173
|
+
logger?.debug(`Generating Rights Distribution entry: ${qty} ${action.symbol}`);
|
|
25174
|
+
let entry = `${date5} ${description}
|
|
25175
|
+
`;
|
|
25176
|
+
entry += ` ; swissquote:order:${action.orderNum} isin:${action.isin}
|
|
25177
|
+
`;
|
|
25178
|
+
entry += ` assets:investments:stocks:${action.symbol} ${qty} ${action.symbol} @ 0.00 CAD
|
|
25179
|
+
`;
|
|
25180
|
+
entry += ` income:capital-gains:rights-distribution 0.00 CAD
|
|
25181
|
+
`;
|
|
25182
|
+
return entry;
|
|
25183
|
+
}
|
|
25155
25184
|
function formatJournalFile(entries, year, currency) {
|
|
25156
25185
|
const header = `; Swissquote ${currency.toUpperCase()} investment transactions for ${year}
|
|
25157
25186
|
; Generated by opencode-accountant
|
|
@@ -25170,8 +25199,15 @@ var SIMPLE_TRANSACTION_TYPES = new Set([
|
|
|
25170
25199
|
"Forex debit",
|
|
25171
25200
|
"Interest on debits",
|
|
25172
25201
|
"Interest on credits",
|
|
25202
|
+
"Interest on deposits",
|
|
25173
25203
|
"Exchange fees",
|
|
25174
|
-
"Exchange fees rectif."
|
|
25204
|
+
"Exchange fees rectif.",
|
|
25205
|
+
"Payment",
|
|
25206
|
+
"Debit",
|
|
25207
|
+
"Fees Tax Statement",
|
|
25208
|
+
"Dividend (Reversed)",
|
|
25209
|
+
"Reversal (Dividend)",
|
|
25210
|
+
"Redemption"
|
|
25175
25211
|
]);
|
|
25176
25212
|
var TRADE_TYPES = new Set(["Buy", "Sell"]);
|
|
25177
25213
|
var DIVIDEND_TYPES = new Set(["Dividend"]);
|
|
@@ -25179,7 +25215,10 @@ var CORPORATE_ACTION_TYPES = new Set([
|
|
|
25179
25215
|
"Merger",
|
|
25180
25216
|
"Reverse Split",
|
|
25181
25217
|
"Worthless Liquidation",
|
|
25182
|
-
"Internal exchange of securities"
|
|
25218
|
+
"Internal exchange of securities",
|
|
25219
|
+
"Exchange of securities",
|
|
25220
|
+
"Corporate Action",
|
|
25221
|
+
"Rights Distribution"
|
|
25183
25222
|
]);
|
|
25184
25223
|
var SKIP_TYPES = new Set([
|
|
25185
25224
|
"Internal exchange",
|
|
@@ -25288,45 +25327,41 @@ function toCorporateActionEntry(txn) {
|
|
|
25288
25327
|
symbol: txn.symbol,
|
|
25289
25328
|
name: txn.name,
|
|
25290
25329
|
isin: txn.isin,
|
|
25291
|
-
quantity:
|
|
25330
|
+
quantity: parseNumber(txn.quantity)
|
|
25292
25331
|
};
|
|
25293
25332
|
}
|
|
25294
|
-
|
|
25333
|
+
var MERGER_LIKE_TYPES = new Set([
|
|
25334
|
+
"Merger",
|
|
25335
|
+
"Internal exchange of securities",
|
|
25336
|
+
"Exchange of securities",
|
|
25337
|
+
"Corporate Action"
|
|
25338
|
+
]);
|
|
25339
|
+
function processCorporateActions(actions, inventory, lotInventoryPath, projectDir, logger) {
|
|
25295
25340
|
const entries = [];
|
|
25296
|
-
const pendingMergers = new Map;
|
|
25297
25341
|
actions.sort((a, b) => a.date.localeCompare(b.date));
|
|
25342
|
+
const mergerActions = [];
|
|
25343
|
+
const otherActions = [];
|
|
25298
25344
|
for (const action of actions) {
|
|
25345
|
+
if (MERGER_LIKE_TYPES.has(action.type)) {
|
|
25346
|
+
mergerActions.push(action);
|
|
25347
|
+
} else {
|
|
25348
|
+
otherActions.push(action);
|
|
25349
|
+
}
|
|
25350
|
+
}
|
|
25351
|
+
const mergerGroups = groupMergerActions(mergerActions);
|
|
25352
|
+
for (const group of mergerGroups) {
|
|
25353
|
+
const groupEntries = processMultiWayMerger(group, inventory, lotInventoryPath, projectDir, logger);
|
|
25354
|
+
entries.push(...groupEntries);
|
|
25355
|
+
}
|
|
25356
|
+
for (const action of otherActions) {
|
|
25299
25357
|
switch (action.type) {
|
|
25300
|
-
case "Merger":
|
|
25301
|
-
case "Internal exchange of securities": {
|
|
25302
|
-
const key = `${action.date}-${action.orderNum}`;
|
|
25303
|
-
if (action.quantity < 0 || pendingMergers.has(key)) {
|
|
25304
|
-
const pending = pendingMergers.get(key);
|
|
25305
|
-
if (pending && pending.outgoing) {
|
|
25306
|
-
pending.incoming = action;
|
|
25307
|
-
const oldQty = Math.abs(parseNumber(String(pending.outgoing.quantity)));
|
|
25308
|
-
const newQty = Math.abs(parseNumber(String(action.quantity)));
|
|
25309
|
-
const ratio = newQty / oldQty;
|
|
25310
|
-
adjustLotsForMerger(inventory, pending.outgoing.symbol, action.symbol, ratio, logger);
|
|
25311
|
-
const entry = generateMergerEntry({
|
|
25312
|
-
...pending.outgoing,
|
|
25313
|
-
newSymbol: action.symbol,
|
|
25314
|
-
newIsin: action.isin,
|
|
25315
|
-
ratio
|
|
25316
|
-
}, oldQty, newQty, logger);
|
|
25317
|
-
entries.push(entry);
|
|
25318
|
-
pendingMergers.delete(key);
|
|
25319
|
-
} else {
|
|
25320
|
-
pendingMergers.set(key, { outgoing: action });
|
|
25321
|
-
}
|
|
25322
|
-
} else {
|
|
25323
|
-
pendingMergers.set(key, { outgoing: action });
|
|
25324
|
-
}
|
|
25325
|
-
break;
|
|
25326
|
-
}
|
|
25327
25358
|
case "Reverse Split": {
|
|
25359
|
+
const qty = action.quantity;
|
|
25360
|
+
if (qty < 0) {
|
|
25361
|
+
continue;
|
|
25362
|
+
}
|
|
25328
25363
|
const oldQty = getHeldQuantity(inventory, action.symbol);
|
|
25329
|
-
const newQty =
|
|
25364
|
+
const newQty = Math.abs(qty);
|
|
25330
25365
|
if (oldQty > 0) {
|
|
25331
25366
|
const ratio = newQty / oldQty;
|
|
25332
25367
|
adjustLotsForSplit(inventory, action.symbol, ratio, logger);
|
|
@@ -25347,14 +25382,170 @@ function processCorporateActions(actions, inventory, logger) {
|
|
|
25347
25382
|
}
|
|
25348
25383
|
break;
|
|
25349
25384
|
}
|
|
25385
|
+
case "Rights Distribution": {
|
|
25386
|
+
if (action.quantity > 0) {
|
|
25387
|
+
const entry = generateRightsDistributionEntry(action, logger);
|
|
25388
|
+
entries.push(entry);
|
|
25389
|
+
}
|
|
25390
|
+
break;
|
|
25391
|
+
}
|
|
25392
|
+
}
|
|
25393
|
+
}
|
|
25394
|
+
return entries;
|
|
25395
|
+
}
|
|
25396
|
+
function groupMergerActions(actions) {
|
|
25397
|
+
const groupMap = new Map;
|
|
25398
|
+
for (const action of actions) {
|
|
25399
|
+
const key = `${action.date}-${action.orderNum}`;
|
|
25400
|
+
if (!groupMap.has(key)) {
|
|
25401
|
+
groupMap.set(key, {
|
|
25402
|
+
key,
|
|
25403
|
+
date: action.date,
|
|
25404
|
+
orderNum: action.orderNum,
|
|
25405
|
+
outgoing: [],
|
|
25406
|
+
incoming: []
|
|
25407
|
+
});
|
|
25408
|
+
}
|
|
25409
|
+
const group = groupMap.get(key);
|
|
25410
|
+
if (action.quantity < 0) {
|
|
25411
|
+
group.outgoing.push(action);
|
|
25412
|
+
} else if (action.quantity > 0) {
|
|
25413
|
+
group.incoming.push(action);
|
|
25350
25414
|
}
|
|
25351
25415
|
}
|
|
25352
|
-
|
|
25353
|
-
|
|
25416
|
+
return Array.from(groupMap.values());
|
|
25417
|
+
}
|
|
25418
|
+
function processMultiWayMerger(group, inventory, lotInventoryPath, projectDir, logger) {
|
|
25419
|
+
const entries = [];
|
|
25420
|
+
if (group.outgoing.length === 0 && group.incoming.length === 0) {
|
|
25421
|
+
return entries;
|
|
25422
|
+
}
|
|
25423
|
+
if (group.outgoing.length === 0 && group.incoming.length > 0) {
|
|
25424
|
+
const pendingState = loadPendingMerger(projectDir, lotInventoryPath, group.key, logger);
|
|
25425
|
+
if (pendingState) {
|
|
25426
|
+
const totalIncomingQty2 = group.incoming.reduce((sum, a) => sum + Math.abs(a.quantity), 0);
|
|
25427
|
+
for (const inc of group.incoming) {
|
|
25428
|
+
const absQty = Math.abs(inc.quantity);
|
|
25429
|
+
const proportion = absQty / totalIncomingQty2;
|
|
25430
|
+
const allocatedCost = pendingState.totalCostBasis * proportion;
|
|
25431
|
+
const costBasisPerUnit = absQty > 0 ? allocatedCost / absQty : 0;
|
|
25432
|
+
const lot = {
|
|
25433
|
+
date: formatDate(inc.date),
|
|
25434
|
+
quantity: absQty,
|
|
25435
|
+
costBasis: costBasisPerUnit,
|
|
25436
|
+
currency: pendingState.currency,
|
|
25437
|
+
isin: inc.isin,
|
|
25438
|
+
orderNum: inc.orderNum
|
|
25439
|
+
};
|
|
25440
|
+
if (!inventory[inc.symbol]) {
|
|
25441
|
+
inventory[inc.symbol] = [];
|
|
25442
|
+
}
|
|
25443
|
+
inventory[inc.symbol].push(lot);
|
|
25444
|
+
logger?.info(`Cross-currency merger incoming: ${absQty} ${inc.symbol} @ ${costBasisPerUnit.toFixed(2)} ${pendingState.currency}`);
|
|
25445
|
+
}
|
|
25446
|
+
const entry2 = generateMultiWayMergerEntry(group, pendingState.outgoingSymbols, logger);
|
|
25447
|
+
entries.push(entry2);
|
|
25448
|
+
removePendingMerger(projectDir, lotInventoryPath, group.key, logger);
|
|
25449
|
+
} else {
|
|
25450
|
+
logger?.warn(`Incoming-only merger group ${group.key} with no pending state \u2014 skipping`);
|
|
25451
|
+
}
|
|
25452
|
+
return entries;
|
|
25453
|
+
}
|
|
25454
|
+
let totalCostBasis = 0;
|
|
25455
|
+
let outgoingCurrency = "";
|
|
25456
|
+
const outgoingSymbols = [];
|
|
25457
|
+
for (const out of group.outgoing) {
|
|
25458
|
+
outgoingSymbols.push(out.symbol);
|
|
25459
|
+
const lots = inventory[out.symbol];
|
|
25460
|
+
if (lots) {
|
|
25461
|
+
for (const lot of lots) {
|
|
25462
|
+
totalCostBasis += lot.quantity * lot.costBasis;
|
|
25463
|
+
if (!outgoingCurrency && lot.currency) {
|
|
25464
|
+
outgoingCurrency = lot.currency;
|
|
25465
|
+
}
|
|
25466
|
+
}
|
|
25467
|
+
}
|
|
25468
|
+
}
|
|
25469
|
+
if (group.outgoing.length > 0 && group.incoming.length === 0) {
|
|
25470
|
+
for (const out of group.outgoing) {
|
|
25471
|
+
removeLots(inventory, out.symbol, logger);
|
|
25472
|
+
}
|
|
25473
|
+
savePendingMerger(projectDir, lotInventoryPath, group.key, {
|
|
25474
|
+
date: group.date,
|
|
25475
|
+
orderNum: group.orderNum,
|
|
25476
|
+
outgoingSymbols,
|
|
25477
|
+
totalCostBasis,
|
|
25478
|
+
currency: outgoingCurrency || "CAD"
|
|
25479
|
+
}, logger);
|
|
25480
|
+
logger?.info(`Cross-currency merger outgoing: ${outgoingSymbols.join(", ")} -> pending (cost basis: ${totalCostBasis.toFixed(2)})`);
|
|
25481
|
+
return entries;
|
|
25482
|
+
}
|
|
25483
|
+
for (const out of group.outgoing) {
|
|
25484
|
+
const absQty = Math.abs(out.quantity);
|
|
25485
|
+
removeLots(inventory, out.symbol, logger);
|
|
25486
|
+
logger?.debug(`Merger outgoing: removed ${absQty} ${out.symbol}`);
|
|
25487
|
+
}
|
|
25488
|
+
const totalIncomingQty = group.incoming.reduce((sum, a) => sum + Math.abs(a.quantity), 0);
|
|
25489
|
+
for (const inc of group.incoming) {
|
|
25490
|
+
const absQty = Math.abs(inc.quantity);
|
|
25491
|
+
const proportion = totalIncomingQty > 0 ? absQty / totalIncomingQty : 0;
|
|
25492
|
+
const allocatedCost = totalCostBasis * proportion;
|
|
25493
|
+
const costBasisPerUnit = absQty > 0 ? allocatedCost / absQty : 0;
|
|
25494
|
+
const lot = {
|
|
25495
|
+
date: formatDate(inc.date),
|
|
25496
|
+
quantity: absQty,
|
|
25497
|
+
costBasis: costBasisPerUnit,
|
|
25498
|
+
currency: outgoingCurrency || "CAD",
|
|
25499
|
+
isin: inc.isin,
|
|
25500
|
+
orderNum: inc.orderNum
|
|
25501
|
+
};
|
|
25502
|
+
if (!inventory[inc.symbol]) {
|
|
25503
|
+
inventory[inc.symbol] = [];
|
|
25504
|
+
}
|
|
25505
|
+
inventory[inc.symbol].push(lot);
|
|
25506
|
+
logger?.debug(`Merger incoming: added ${absQty} ${inc.symbol} @ ${costBasisPerUnit.toFixed(2)}`);
|
|
25354
25507
|
}
|
|
25508
|
+
const entry = generateMultiWayMergerEntry(group, undefined, logger);
|
|
25509
|
+
entries.push(entry);
|
|
25355
25510
|
return entries;
|
|
25356
25511
|
}
|
|
25357
|
-
|
|
25512
|
+
function getPendingMergerDir(projectDir, lotInventoryPath) {
|
|
25513
|
+
const lotDir = lotInventoryPath.replace(/{symbol}[^/]*$/, "");
|
|
25514
|
+
return path13.join(projectDir, lotDir, "pending-mergers");
|
|
25515
|
+
}
|
|
25516
|
+
function savePendingMerger(projectDir, lotInventoryPath, key, state, logger) {
|
|
25517
|
+
const dir = getPendingMergerDir(projectDir, lotInventoryPath);
|
|
25518
|
+
if (!fs18.existsSync(dir)) {
|
|
25519
|
+
fs18.mkdirSync(dir, { recursive: true });
|
|
25520
|
+
}
|
|
25521
|
+
const filePath = path13.join(dir, `${key.replace(/[^a-zA-Z0-9-]/g, "_")}.json`);
|
|
25522
|
+
fs18.writeFileSync(filePath, JSON.stringify(state, null, 2));
|
|
25523
|
+
logger?.debug(`Saved pending merger state: ${key}`);
|
|
25524
|
+
}
|
|
25525
|
+
function loadPendingMerger(projectDir, lotInventoryPath, key, logger) {
|
|
25526
|
+
const dir = getPendingMergerDir(projectDir, lotInventoryPath);
|
|
25527
|
+
const filePath = path13.join(dir, `${key.replace(/[^a-zA-Z0-9-]/g, "_")}.json`);
|
|
25528
|
+
if (!fs18.existsSync(filePath)) {
|
|
25529
|
+
return null;
|
|
25530
|
+
}
|
|
25531
|
+
try {
|
|
25532
|
+
const content = fs18.readFileSync(filePath, "utf-8");
|
|
25533
|
+
return JSON.parse(content);
|
|
25534
|
+
} catch (error45) {
|
|
25535
|
+
const message = error45 instanceof Error ? error45.message : String(error45);
|
|
25536
|
+
logger?.warn(`Failed to load pending merger state for ${key}: ${message}`);
|
|
25537
|
+
return null;
|
|
25538
|
+
}
|
|
25539
|
+
}
|
|
25540
|
+
function removePendingMerger(projectDir, lotInventoryPath, key, logger) {
|
|
25541
|
+
const dir = getPendingMergerDir(projectDir, lotInventoryPath);
|
|
25542
|
+
const filePath = path13.join(dir, `${key.replace(/[^a-zA-Z0-9-]/g, "_")}.json`);
|
|
25543
|
+
if (fs18.existsSync(filePath)) {
|
|
25544
|
+
fs18.unlinkSync(filePath);
|
|
25545
|
+
logger?.debug(`Removed pending merger state: ${key}`);
|
|
25546
|
+
}
|
|
25547
|
+
}
|
|
25548
|
+
async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInventoryPath = DEFAULT_LOT_INVENTORY_PATH, symbolMap = {}, logger) {
|
|
25358
25549
|
logger?.startSection(`Swissquote Preprocessing: ${path13.basename(csvPath)}`);
|
|
25359
25550
|
const stats = {
|
|
25360
25551
|
totalRows: 0,
|
|
@@ -25390,14 +25581,22 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
|
|
|
25390
25581
|
};
|
|
25391
25582
|
}
|
|
25392
25583
|
const transactions = [];
|
|
25584
|
+
const hasSymbolMap = Object.keys(symbolMap).length > 0;
|
|
25393
25585
|
for (let i2 = 1;i2 < lines.length; i2++) {
|
|
25394
25586
|
const txn = parseTransaction(lines[i2]);
|
|
25395
25587
|
if (txn) {
|
|
25588
|
+
if (hasSymbolMap && txn.symbol) {
|
|
25589
|
+
txn.symbol = symbolMap[txn.symbol] ?? txn.symbol;
|
|
25590
|
+
}
|
|
25396
25591
|
transactions.push(txn);
|
|
25397
25592
|
}
|
|
25398
25593
|
}
|
|
25399
25594
|
stats.totalRows = transactions.length;
|
|
25400
|
-
|
|
25595
|
+
if (hasSymbolMap) {
|
|
25596
|
+
logger?.logStep("parse-csv", "success", `Parsed ${stats.totalRows} transactions (symbol map applied)`);
|
|
25597
|
+
} else {
|
|
25598
|
+
logger?.logStep("parse-csv", "success", `Parsed ${stats.totalRows} transactions`);
|
|
25599
|
+
}
|
|
25401
25600
|
logger?.logStep("load-inventory", "start", "Loading lot inventory");
|
|
25402
25601
|
const inventory = loadLotInventory(projectDir, lotInventoryPath, logger);
|
|
25403
25602
|
logger?.logStep("load-inventory", "success", "Lot inventory loaded");
|
|
@@ -25422,8 +25621,17 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
|
|
|
25422
25621
|
stats.dividends++;
|
|
25423
25622
|
break;
|
|
25424
25623
|
case "corporate":
|
|
25425
|
-
|
|
25426
|
-
|
|
25624
|
+
if (!txn.symbol || txn.symbol.trim() === "") {
|
|
25625
|
+
if (txn.netAmount && txn.netAmount !== "-") {
|
|
25626
|
+
simpleTransactions.push(txn);
|
|
25627
|
+
stats.simpleTransactions++;
|
|
25628
|
+
} else {
|
|
25629
|
+
stats.skipped++;
|
|
25630
|
+
}
|
|
25631
|
+
} else {
|
|
25632
|
+
corporateActions.push(toCorporateActionEntry(txn));
|
|
25633
|
+
stats.corporateActions++;
|
|
25634
|
+
}
|
|
25427
25635
|
break;
|
|
25428
25636
|
case "skip":
|
|
25429
25637
|
stats.skipped++;
|
|
@@ -25479,7 +25687,7 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
|
|
|
25479
25687
|
}
|
|
25480
25688
|
if (corporateActions.length > 0) {
|
|
25481
25689
|
logger?.startSection("Corporate Actions Processing", 2);
|
|
25482
|
-
const corpEntries = processCorporateActions(corporateActions, inventory, logger);
|
|
25690
|
+
const corpEntries = processCorporateActions(corporateActions, inventory, lotInventoryPath, projectDir, logger);
|
|
25483
25691
|
journalEntries.push(...corpEntries);
|
|
25484
25692
|
logger?.logStep("corporate", "success", `Processed ${corporateActions.length} corporate actions`);
|
|
25485
25693
|
logger?.endSection();
|
|
@@ -25735,14 +25943,10 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
|
|
|
25735
25943
|
const importCtx = loadContext(context.directory, contextId);
|
|
25736
25944
|
if (importCtx.provider === "swissquote") {
|
|
25737
25945
|
const csvPath = path14.join(context.directory, importCtx.filePath);
|
|
25738
|
-
const
|
|
25946
|
+
const toDateMatch = importCtx.filename.match(/to-\d{4}(\d{4})\./);
|
|
25739
25947
|
let year = new Date().getFullYear();
|
|
25740
|
-
if (
|
|
25741
|
-
|
|
25742
|
-
year = parseInt(filenameMatch[3], 10);
|
|
25743
|
-
} else if (filenameMatch[4]) {
|
|
25744
|
-
year = parseInt(filenameMatch[4], 10);
|
|
25745
|
-
}
|
|
25948
|
+
if (toDateMatch) {
|
|
25949
|
+
year = parseInt(toDateMatch[1], 10);
|
|
25746
25950
|
}
|
|
25747
25951
|
swissquoteContexts.push({
|
|
25748
25952
|
contextId,
|
|
@@ -25760,6 +25964,23 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
|
|
|
25760
25964
|
const config2 = context.configLoader(context.directory);
|
|
25761
25965
|
const swissquoteProvider = config2.providers?.["swissquote"];
|
|
25762
25966
|
const lotInventoryPath = swissquoteProvider?.lotInventoryPath ?? DEFAULT_LOT_INVENTORY_PATH;
|
|
25967
|
+
const symbolMapPath = swissquoteProvider?.symbolMapPath ?? "config/import/symbolMap.yaml";
|
|
25968
|
+
let symbolMap = {};
|
|
25969
|
+
const symbolMapFullPath = path14.join(context.directory, symbolMapPath);
|
|
25970
|
+
if (fs20.existsSync(symbolMapFullPath)) {
|
|
25971
|
+
try {
|
|
25972
|
+
const yaml = await Promise.resolve().then(() => (init_js_yaml(), exports_js_yaml));
|
|
25973
|
+
const content = fs20.readFileSync(symbolMapFullPath, "utf-8");
|
|
25974
|
+
const parsed = yaml.load(content);
|
|
25975
|
+
if (parsed && typeof parsed === "object") {
|
|
25976
|
+
symbolMap = parsed;
|
|
25977
|
+
logger?.info(`Loaded symbol map with ${Object.keys(symbolMap).length} entries`);
|
|
25978
|
+
}
|
|
25979
|
+
} catch (error45) {
|
|
25980
|
+
const message = error45 instanceof Error ? error45.message : String(error45);
|
|
25981
|
+
logger?.warn(`Failed to load symbol map from ${symbolMapPath}: ${message}`);
|
|
25982
|
+
}
|
|
25983
|
+
}
|
|
25763
25984
|
try {
|
|
25764
25985
|
let totalStats = {
|
|
25765
25986
|
totalRows: 0,
|
|
@@ -25772,7 +25993,7 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
|
|
|
25772
25993
|
let lastJournalFile = null;
|
|
25773
25994
|
for (const sqCtx of swissquoteContexts) {
|
|
25774
25995
|
logger?.logStep("Swissquote Preprocess", "start", `Processing ${path14.basename(sqCtx.csvPath)}`);
|
|
25775
|
-
const result = await preprocessSwissquote(sqCtx.csvPath, context.directory, sqCtx.currency, sqCtx.year, lotInventoryPath, logger);
|
|
25996
|
+
const result = await preprocessSwissquote(sqCtx.csvPath, context.directory, sqCtx.currency, sqCtx.year, lotInventoryPath, symbolMap, logger);
|
|
25776
25997
|
totalStats.totalRows += result.stats.totalRows;
|
|
25777
25998
|
totalStats.simpleTransactions += result.stats.simpleTransactions;
|
|
25778
25999
|
totalStats.trades += result.stats.trades;
|
|
@@ -25790,7 +26011,7 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
|
|
|
25790
26011
|
}
|
|
25791
26012
|
logger?.logStep("Swissquote Preprocess", "success", `Processed: ${result.stats.trades} trades, ${result.stats.dividends} dividends, ${result.stats.corporateActions} corporate actions`);
|
|
25792
26013
|
}
|
|
25793
|
-
const message = `Preprocessed ${totalStats.totalRows} rows:
|
|
26014
|
+
const message = `Preprocessed ${totalStats.totalRows} rows: ${totalStats.trades} trades, ${totalStats.dividends} dividends, ${totalStats.corporateActions} corporate actions, ${totalStats.simpleTransactions} simple`;
|
|
25794
26015
|
context.result.steps.swissquotePreprocess = buildStepResult(true, message, {
|
|
25795
26016
|
...totalStats,
|
|
25796
26017
|
journalFile: lastJournalFile
|
|
@@ -26201,7 +26422,7 @@ This tool orchestrates the full import workflow:
|
|
|
26201
26422
|
}
|
|
26202
26423
|
});
|
|
26203
26424
|
// src/tools/init-directories.ts
|
|
26204
|
-
import * as
|
|
26425
|
+
import * as fs21 from "fs";
|
|
26205
26426
|
import * as path15 from "path";
|
|
26206
26427
|
async function initDirectories(directory) {
|
|
26207
26428
|
try {
|
|
@@ -26209,8 +26430,8 @@ async function initDirectories(directory) {
|
|
|
26209
26430
|
const directoriesCreated = [];
|
|
26210
26431
|
const gitkeepFiles = [];
|
|
26211
26432
|
const importBase = path15.join(directory, "import");
|
|
26212
|
-
if (!
|
|
26213
|
-
|
|
26433
|
+
if (!fs21.existsSync(importBase)) {
|
|
26434
|
+
fs21.mkdirSync(importBase, { recursive: true });
|
|
26214
26435
|
directoriesCreated.push("import");
|
|
26215
26436
|
}
|
|
26216
26437
|
const pathsToCreate = [
|
|
@@ -26221,19 +26442,19 @@ async function initDirectories(directory) {
|
|
|
26221
26442
|
];
|
|
26222
26443
|
for (const { path: dirPath } of pathsToCreate) {
|
|
26223
26444
|
const fullPath = path15.join(directory, dirPath);
|
|
26224
|
-
if (!
|
|
26225
|
-
|
|
26445
|
+
if (!fs21.existsSync(fullPath)) {
|
|
26446
|
+
fs21.mkdirSync(fullPath, { recursive: true });
|
|
26226
26447
|
directoriesCreated.push(dirPath);
|
|
26227
26448
|
}
|
|
26228
26449
|
const gitkeepPath = path15.join(fullPath, ".gitkeep");
|
|
26229
|
-
if (!
|
|
26230
|
-
|
|
26450
|
+
if (!fs21.existsSync(gitkeepPath)) {
|
|
26451
|
+
fs21.writeFileSync(gitkeepPath, "");
|
|
26231
26452
|
gitkeepFiles.push(path15.join(dirPath, ".gitkeep"));
|
|
26232
26453
|
}
|
|
26233
26454
|
}
|
|
26234
26455
|
const gitignorePath = path15.join(importBase, ".gitignore");
|
|
26235
26456
|
let gitignoreCreated = false;
|
|
26236
|
-
if (!
|
|
26457
|
+
if (!fs21.existsSync(gitignorePath)) {
|
|
26237
26458
|
const gitignoreContent = `# Ignore CSV/PDF files in temporary directories
|
|
26238
26459
|
/incoming/*.csv
|
|
26239
26460
|
/incoming/*.pdf
|
|
@@ -26251,7 +26472,7 @@ async function initDirectories(directory) {
|
|
|
26251
26472
|
.DS_Store
|
|
26252
26473
|
Thumbs.db
|
|
26253
26474
|
`;
|
|
26254
|
-
|
|
26475
|
+
fs21.writeFileSync(gitignorePath, gitignoreContent);
|
|
26255
26476
|
gitignoreCreated = true;
|
|
26256
26477
|
}
|
|
26257
26478
|
const parts = [];
|
|
@@ -26328,13 +26549,13 @@ You can now drop CSV files into import/incoming/ and run import-pipeline.`);
|
|
|
26328
26549
|
});
|
|
26329
26550
|
// src/tools/generate-btc-purchases.ts
|
|
26330
26551
|
import * as path16 from "path";
|
|
26331
|
-
import * as
|
|
26552
|
+
import * as fs22 from "fs";
|
|
26332
26553
|
function findFiatCsvPaths(directory, pendingDir, provider) {
|
|
26333
26554
|
const providerDir = path16.join(directory, pendingDir, provider);
|
|
26334
|
-
if (!
|
|
26555
|
+
if (!fs22.existsSync(providerDir))
|
|
26335
26556
|
return [];
|
|
26336
26557
|
const csvPaths = [];
|
|
26337
|
-
const entries =
|
|
26558
|
+
const entries = fs22.readdirSync(providerDir, { withFileTypes: true });
|
|
26338
26559
|
for (const entry of entries) {
|
|
26339
26560
|
if (!entry.isDirectory())
|
|
26340
26561
|
continue;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Symbol Map Configuration
|
|
2
|
+
|
|
3
|
+
The symbol map allows you to map Swissquote's internal symbol names to canonical ticker symbols, matching your preferred naming convention (e.g., wealthfolio-style with exchange suffixes).
|
|
4
|
+
|
|
5
|
+
## Purpose
|
|
6
|
+
|
|
7
|
+
Swissquote CSV exports use various symbol formats:
|
|
8
|
+
- Long descriptive names for corporate actions: `GOLD ROYALTY RG`, `VIZSLA ROYAL RG`
|
|
9
|
+
- Short tickers without exchange suffixes: `FNV`, `FVI`, `VCU`
|
|
10
|
+
- Warrant/CVR names: `VIZSLA WT 12.25`, `KINROSS GLD CVR`
|
|
11
|
+
|
|
12
|
+
The symbol map normalizes these to canonical symbols used consistently across journal entries, lot inventory filenames, and account names.
|
|
13
|
+
|
|
14
|
+
## File Location
|
|
15
|
+
|
|
16
|
+
Default: `config/import/symbolMap.yaml` (relative to project root)
|
|
17
|
+
|
|
18
|
+
Configurable via `symbolMapPath` in `providers.yaml`:
|
|
19
|
+
|
|
20
|
+
```yaml
|
|
21
|
+
providers:
|
|
22
|
+
swissquote:
|
|
23
|
+
symbolMapPath: config/import/symbolMap.yaml
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Format
|
|
27
|
+
|
|
28
|
+
Simple YAML key-value pairs where keys are Swissquote symbols and values are canonical symbols:
|
|
29
|
+
|
|
30
|
+
```yaml
|
|
31
|
+
# Swissquote symbol -> canonical symbol
|
|
32
|
+
FNV: FNV.TO
|
|
33
|
+
FVI: FVI.TO
|
|
34
|
+
GOLD ROYALTY RG: GROY
|
|
35
|
+
VIZSLA ROYAL RG: VROY.V
|
|
36
|
+
KINROSS GLD CVR: KGC-CVR
|
|
37
|
+
VIZSLA WT 12.25: VROY-WT
|
|
38
|
+
VIZSLA WT. 12.2: VROY-WT
|
|
39
|
+
COLOSSUS MIN: CSI.TO
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Both keys and values are plain strings. Keys must exactly match the `Symbol` column in the Swissquote CSV.
|
|
43
|
+
|
|
44
|
+
## When Needed
|
|
45
|
+
|
|
46
|
+
A symbol map is required when:
|
|
47
|
+
- Swissquote uses long descriptive names (common in merger/corporate action entries) that contain spaces, which break hledger commodity parsing
|
|
48
|
+
- You want exchange suffixes (`.TO`, `.V`) for TSX/TSXV-listed stocks
|
|
49
|
+
- Multiple Swissquote names refer to the same security (e.g., `VIZSLA WT 12.25` and `VIZSLA WT. 12.2`)
|
|
50
|
+
- You want consistency with another portfolio tool (e.g., wealthfolio)
|
|
51
|
+
|
|
52
|
+
## Effect
|
|
53
|
+
|
|
54
|
+
The symbol map is applied **early** in preprocessing, immediately after CSV parsing and before any trade, lot, or journal processing. This means:
|
|
55
|
+
|
|
56
|
+
- **Journal entries** use mapped symbols in descriptions and account names
|
|
57
|
+
- **Lot inventory files** use mapped symbols in filenames (e.g., `GROY-lot.json` instead of `GOLD ROYALTY RG-lot.json`)
|
|
58
|
+
- **Dividend income accounts** use mapped symbols (e.g., `income:dividends:GROY`)
|
|
59
|
+
- **Capital gains tracking** uses mapped symbols throughout
|
|
60
|
+
|
|
61
|
+
## Example
|
|
62
|
+
|
|
63
|
+
Given a Swissquote CSV with:
|
|
64
|
+
```
|
|
65
|
+
15-03-2022;12345678;Merger;GOLD ROYALTY RG;GOLD ROYALTY CORP;CA38071H1064;-1000;...
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Without symbol map: creates `GOLD ROYALTY RG-lot.json` and journal entries with `GOLD ROYALTY RG` (breaks hledger).
|
|
69
|
+
|
|
70
|
+
With symbol map (`GOLD ROYALTY RG: GROY`): creates `GROY-lot.json` and journal entries with `GROY`.
|
|
@@ -208,13 +208,33 @@ See [classify-statements](classify-statements.md) for details.
|
|
|
208
208
|
**What happens**:
|
|
209
209
|
|
|
210
210
|
1. Finds Swissquote contexts from classification
|
|
211
|
-
2.
|
|
212
|
-
3.
|
|
213
|
-
4. For
|
|
214
|
-
5. For
|
|
215
|
-
6. For
|
|
216
|
-
7.
|
|
217
|
-
|
|
211
|
+
2. Loads **symbol map** from `config/import/symbolMap.yaml` (if present) and applies it to all transaction symbols before processing
|
|
212
|
+
3. Parses CSV to identify transaction types and routes them accordingly
|
|
213
|
+
4. For Buy transactions: adds lots to per-symbol inventory files (`ledger/investments/lot-inventory/{symbol}-lot.json`)
|
|
214
|
+
5. For Sell transactions: consumes lots using FIFO, calculates capital gains/losses
|
|
215
|
+
6. For Dividends: generates dividend income entries with withholding tax
|
|
216
|
+
7. For Corporate Actions:
|
|
217
|
+
- **Mergers** (including multi-way): groups by date+orderNum, partitions into outgoing/incoming, transfers cost basis proportionally
|
|
218
|
+
- **Reverse Splits**: adjusts lot quantities and cost basis
|
|
219
|
+
- **Worthless Liquidations**: removes lots and records capital loss
|
|
220
|
+
- **Rights Distributions**: adds shares at zero cost basis
|
|
221
|
+
- **Cross-currency mergers**: saves outgoing side to pending state, loads when incoming side is processed
|
|
222
|
+
8. Generates per-year investment journal entries (`ledger/investments/{year}-{currency}.journal`) based on the CSV filename's date range
|
|
223
|
+
9. Outputs filtered CSV (simple transactions only) for hledger rules import
|
|
224
|
+
|
|
225
|
+
**Symbol Map**: Maps Swissquote's internal symbol names (e.g., `GOLD ROYALTY RG`, `VIZSLA WT 12.25`) to canonical ticker symbols (e.g., `GROY`, `VROY-WT`). Applied before any processing, so journal entries, lot inventory filenames, and account names all use the mapped symbols. See [Symbol Map Configuration](../configuration/symbol-map.md).
|
|
226
|
+
|
|
227
|
+
**Per-Year Journals**: Each CSV writes to a journal file named `{year}-{currency}.journal` where the year is extracted from the `to-DDMMYYYY` portion of the filename (e.g., `swissquote-cad-transactions-01012013-to-31122013.csv` → `2013-cad.journal`).
|
|
228
|
+
|
|
229
|
+
**Multi-Way Mergers**: Mergers can involve multiple incoming/outgoing securities (1→N, N→1, N→M). The preprocessor groups all entries sharing the same date+orderNum, collects total cost basis from outgoing lots, and distributes it proportionally across incoming symbols.
|
|
230
|
+
|
|
231
|
+
**Cross-Currency Mergers**: When the outgoing and incoming sides of a merger appear in different currency CSV files, the outgoing side saves its state to `pending-mergers/` under the lot inventory directory. When the incoming side is processed later, it loads the pending state to create lots with the correct cost basis.
|
|
232
|
+
|
|
233
|
+
**Supported Transaction Types**:
|
|
234
|
+
- **Simple** (passed to rules import): Custody Fees, Forex credit/debit, Interest on debits/credits/deposits, Exchange fees, Payment, Debit, Fees Tax Statement, Dividend (Reversed), Reversal (Dividend), Redemption
|
|
235
|
+
- **Trades**: Buy, Sell
|
|
236
|
+
- **Dividends**: Dividend
|
|
237
|
+
- **Corporate Actions**: Merger, Internal exchange of securities, Exchange of securities, Corporate Action, Reverse Split, Worthless Liquidation, Rights Distribution
|
|
218
238
|
|
|
219
239
|
**Why**: hledger CSV rules can't perform FIFO lot matching, capital gains calculations, or handle complex corporate actions. The preprocessor handles these computations and generates proper journal entries, while passing through simple transactions (fees, forex, interest) for standard rules-based import.
|
|
220
240
|
|