From e865abf4eac0e10c8eb340f09f10a4cb0122865f Mon Sep 17 00:00:00 2001 From: Abhishek Jain Date: Sun, 11 Jan 2026 16:09:59 +0100 Subject: [PATCH 01/13] feat: Add yahoo finance exporter --- src/exporters/types.ts | 12 +++++++ src/exporters/yahoo.ts | 59 +++++++++++++++++++++++++++++++++ src/index.ts | 4 +++ tests/exporters/yahoo.test.ts | 62 +++++++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+) create mode 100644 src/exporters/types.ts create mode 100644 src/exporters/yahoo.ts create mode 100644 tests/exporters/yahoo.test.ts diff --git a/src/exporters/types.ts b/src/exporters/types.ts new file mode 100644 index 0000000..058fe1d --- /dev/null +++ b/src/exporters/types.ts @@ -0,0 +1,12 @@ +import { ParsedTransaction } from '../parsers/types'; + +export interface ExportResult { + filename: string; + content: string; // CSV content or JSON string + mimeType: string; +} + +export interface PortfolioExporter { + name: string; + export(transactions: ParsedTransaction[]): ExportResult; +} diff --git a/src/exporters/yahoo.ts b/src/exporters/yahoo.ts new file mode 100644 index 0000000..1595b17 --- /dev/null +++ b/src/exporters/yahoo.ts @@ -0,0 +1,59 @@ +import { ParsedTransaction } from '../parsers/types'; +import { PortfolioExporter, ExportResult } from './types'; + +export const YahooFinanceExporter: PortfolioExporter={ + name: 'Yahoo Finance', + export(transactions: ParsedTransaction[]): ExportResult { + const headers=[ + 'Symbol', + 'Trade Date', // YYYYMMDD + 'Purchase Price', + 'Quantity', + 'Commission', + 'Comment', + ]; + + const rows=transactions + .map((t) => { + // We need a Symbol/Ticker. Prefer 'ticker', fallback to 'symbol', fallback to 'isin' + let symbol=t.ticker||t.symbol; + + // Skip non-trade types? + if (t.type!=='BUY'&&t.type!=='SELL') { + return null; + } + + const dateStr=t.date.toISOString().slice(0, 10).replace(/-/g, ''); // YYYYMMDD + const qty=t.type==='SELL'? -Math.abs(t.quantity):Math.abs(t.quantity); + const price=t.price||0; + const commission=t.fee||0; + const comment=`Imported from ${t.originalSource||'Broker'}`; + + // Escape helper + const escape=(val: string) => { + if (val.includes(',')||val.includes('"')) { + return `"${val.replace(/"/g, '""')}"`; + } + return val; + }; + + return [ + escape(symbol), + dateStr, + price.toFixed(4), + qty.toString(), + commission.toFixed(4), + escape(comment), + ].join(','); + }) + .filter((row): row is string => row!==null); + + const content=[headers.join(','), ...rows].join('\n'); + + return { + filename: 'yahoo_finance_import.csv', + content, + mimeType: 'text/csv', + }; + }, +}; diff --git a/src/index.ts b/src/index.ts index 664f09d..8908e80 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,10 @@ import { parseNumber, normalizeType } from './parsers/utils'; export type { ParsedTransaction, BrokerParser, BrokerFormat }; export { parseNumber, normalizeType }; +import { YahooFinanceExporter } from './exporters/yahoo'; +export * from './exporters/types'; +export { YahooFinanceExporter }; + export function getParsers(): Record { return { Avanza: AvanzaParser, diff --git a/tests/exporters/yahoo.test.ts b/tests/exporters/yahoo.test.ts new file mode 100644 index 0000000..bb23572 --- /dev/null +++ b/tests/exporters/yahoo.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { YahooFinanceExporter } from '../../src/exporters/yahoo'; +import { ParsedTransaction } from '../../src/parsers/types'; + +describe('YahooFinanceExporter', () => { + it('should export transactions to CSV', () => { + const transactions: ParsedTransaction[]=[ + { + date: new Date('2023-01-01'), + type: 'BUY', + symbol: 'Apple Inc', + ticker: 'AAPL', + quantity: 10, + price: 150.0, + total: 1500, + currency: 'USD', + fee: 5.0, + originalSource: 'TestBroker', + } as ParsedTransaction, + { + date: new Date('2023-02-01'), + type: 'SELL', + symbol: 'Apple Inc', + ticker: 'AAPL', + quantity: 5, + price: 160.0, + total: 800, + currency: 'USD', + fee: 5.0, + originalSource: 'TestBroker', + } as ParsedTransaction + ]; + + const result=YahooFinanceExporter.export(transactions); + + expect(result.filename).toBe('yahoo_finance_import.csv'); + expect(result.mimeType).toBe('text/csv'); + + const lines=result.content.split('\n'); + expect(lines.length).toBe(3); // Header + 2 rows + + expect(lines[0]).toBe('Symbol,Trade Date,Purchase Price,Quantity,Commission,Comment'); + expect(lines[1]).toContain('AAPL'); + expect(lines[1]).toContain('10'); + expect(lines[2]).toContain('-5'); + }); + + it('should fallback to symbol if ticker is missing', () => { + const t=[{ + date: new Date('2023-01-01'), + type: 'BUY', + symbol: 'Unknown Stock', + quantity: 1, + price: 100, + fee: 0, + originalSource: 'Test' + }] as ParsedTransaction[]; + + const result=YahooFinanceExporter.export(t); + expect(result.content).toContain('Unknown Stock'); + }); +}); From 6b1ad5e454e453bf3df850c516c09badf1cf8e8e Mon Sep 17 00:00:00 2001 From: Abhishek Jain Date: Sun, 11 Jan 2026 16:10:09 +0100 Subject: [PATCH 02/13] feat: Add enricher interface --- README.md | 39 ++++++++++++++++++++++++++++++++ src/enricher.ts | 51 ++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 1 + tests/enricher.test.ts | 47 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+) create mode 100644 src/enricher.ts create mode 100644 tests/enricher.test.ts diff --git a/README.md b/README.md index fd952e1..bea898f 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ A robust, standalone TypeScript library for parsing transaction CSV exports from - **Normalization**: Unifies transaction types (BUY, SELL, DIVIDEND, etc.) across brokers. - **Currency Handling**: Extracts account currency, native currency, and exchange rates. - **ISIN Extraction**: Reliably finds ISIN codes for accurate instrument identification. +- **Export Support**: Convert parsed transactions into formats like Yahoo Finance CSV. +- **Data Enrichment**: Helper utilities to resolve Tickers (e.g. from ISIN) before export. - **Type Safe**: Written in TypeScript with full type definitions. ## Supported Brokers @@ -78,6 +80,43 @@ const accounts = identifyAccounts(allRows); // Returns: [{ id: '12345', name: 'My ISK', count: 50 }, ...] ``` +### Exporting Data + +You can export normalized transactions to various formats (e.g., for importing into other tools). + +```typescript +import { YahooFinanceExporter } from "@logkat/broker-parser"; + +// Convert transactions to Yahoo Finance CSV +const result = YahooFinanceExporter.export(parsedTransactions); +console.log(result.content); // CSV string +``` + +### Enriching Data (Tickers) + +Brokers outputs (Avanza/Nordnet) often lack the actual Ticker Symbol required by Yahoo Finance (they provide ISIN or Name instead). +To fix this, you can use `enrichTransactions` with your own resolver logic (e.g., using `yahoo-finance2`). + +```typescript +import { + enrichTransactions, + YahooFinanceExporter, +} from "@logkat/broker-parser"; + +// Your custom resolver (could check a DB or call an API) +const myResolver = async (isin: string, name: string) => { + if (isin === "US0378331005") return "AAPL"; + // ... call external API ... + return null; +}; + +// 1. Parse +// 2. Enrich +const enriched = await enrichTransactions(parsedTransactions, myResolver); +// 3. Export +const csv = YahooFinanceExporter.export(enriched); +``` + ## API Reference ### `parseTransaction(row: Record, format?: BrokerFormat): ParsedTransaction | null` diff --git a/src/enricher.ts b/src/enricher.ts new file mode 100644 index 0000000..141cb92 --- /dev/null +++ b/src/enricher.ts @@ -0,0 +1,51 @@ +import { ParsedTransaction } from './parsers/types'; + +export type TickerResolver=(isin: string, symbol: string) => Promise; + +/** + * Enriches transactions by resolving tickers using a provided resolver function. + * This allows the library to remain dependency-free while enabling data enrichment. + */ +export async function enrichTransactions( + transactions: ParsedTransaction[], + resolver: TickerResolver +): Promise { + const enriched: ParsedTransaction[]=[]; + + // Cache resolutions to avoid redundant calls for the same ISIN + const cache=new Map(); + + for (const t of transactions) { + // If already has ticker, skip + if (t.ticker) { + enriched.push(t); + continue; + } + + const key=t.isin||t.symbol; + if (!key) { + enriched.push(t); + continue; + } + + let ticker: string|null=null; + + if (cache.has(key)) { + ticker=cache.get(key)||null; + } else { + try { + ticker=await resolver(t.isin||'', t.symbol); + cache.set(key, ticker); + } catch (e) { + console.warn(`Failed to resolve ticker for ${key}`, e); + } + } + + enriched.push({ + ...t, + ticker: ticker||undefined, + }); + } + + return enriched; +} diff --git a/src/index.ts b/src/index.ts index 8908e80..fb85fd0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ export { parseNumber, normalizeType }; import { YahooFinanceExporter } from './exporters/yahoo'; export * from './exporters/types'; export { YahooFinanceExporter }; +export * from './enricher'; export function getParsers(): Record { return { diff --git a/tests/enricher.test.ts b/tests/enricher.test.ts new file mode 100644 index 0000000..db67b15 --- /dev/null +++ b/tests/enricher.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, vi } from 'vitest'; +import { enrichTransactions } from '../src/enricher'; +import { ParsedTransaction } from '../src/parsers/types'; + +describe('Enricher', () => { + it('should enrich transactions with tickers using resolver', async () => { + const transactions=[ + { symbol: 'Apple', isin: 'US0001', ticker: undefined } as ParsedTransaction, + { symbol: 'Microsoft', isin: 'US0002', ticker: undefined } as ParsedTransaction, + ]; + + const resolver=vi.fn().mockImplementation(async (isin, name) => { + if (isin==='US0001') return 'AAPL'; + if (name==='Microsoft') return 'MSFT'; + return null; + }); + + const enriched=await enrichTransactions(transactions, resolver); + + expect(enriched[0].ticker).toBe('AAPL'); + expect(enriched[1].ticker).toBe('MSFT'); + expect(resolver).toHaveBeenCalledTimes(2); + }); + + it('should use cache for repeated ISINs', async () => { + const transactions=[ + { symbol: 'Apple', isin: 'US0001' } as ParsedTransaction, + { symbol: 'Apple', isin: 'US0001' } as ParsedTransaction, + ]; + + const resolver=vi.fn().mockResolvedValue('AAPL'); + await enrichTransactions(transactions, resolver); + + expect(resolver).toHaveBeenCalledTimes(1); + }); + + it('should skip transactions that already have tickers', async () => { + const transactions=[ + { symbol: 'Apple', isin: 'US0001', ticker: 'EXISTING' } as ParsedTransaction, + ]; + const resolver=vi.fn(); + const enriched=await enrichTransactions(transactions, resolver); + + expect(enriched[0].ticker).toBe('EXISTING'); + expect(resolver).not.toHaveBeenCalled(); + }); +}); From dabc7d03c718982e73ae3f836e1d62c68e7ad2fe Mon Sep 17 00:00:00 2001 From: Abhishek Jain Date: Sun, 11 Jan 2026 17:02:03 +0100 Subject: [PATCH 03/13] feat: Add CLI option --- README.md | 18 ++++++++++++ package.json | 9 ++++++ pnpm-lock.yaml | 28 ++++++++++++++++++ src/cli.ts | 66 ++++++++++++++++++++++++++++++++++++++++++ src/exporters/yahoo.ts | 2 +- test-avanza.csv | 3 ++ test-output.csv | 3 ++ 7 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 src/cli.ts create mode 100644 test-avanza.csv create mode 100644 test-output.csv diff --git a/README.md b/README.md index bea898f..05aeb12 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A robust, standalone TypeScript library for parsing transaction CSV exports from - **Currency Handling**: Extracts account currency, native currency, and exchange rates. - **ISIN Extraction**: Reliably finds ISIN codes for accurate instrument identification. - **Export Support**: Convert parsed transactions into formats like Yahoo Finance CSV. +- **CLI Support**: Command-line interface for bulk processing and exporting without writing code. - **Data Enrichment**: Helper utilities to resolve Tickers (e.g. from ISIN) before export. - **Type Safe**: Written in TypeScript with full type definitions. @@ -20,6 +21,8 @@ A robust, standalone TypeScript library for parsing transaction CSV exports from ## Installation +### Library Usage + To install from GitHub Packages, you need to configure your `.npmrc` file: ```bash @@ -34,8 +37,23 @@ npm install @logkat/broker-parser pnpm add @logkat/broker-parser ``` +### CLI Usage + +You can also use the library directly from your terminal to convert broker CSVs to other formats (e.g. Yahoo Finance). + +```bash +# Run without installing +npx @logkat/broker-parser export input.csv -o output.csv + +# Or install globally +npm install -g @logkat/broker-parser +broker-parser export my_transactions.csv --exporter yahoo --output yahoo_import.csv +``` + ## Usage +### Library Usage (TypeScript) + ### Parsing a Single Transaction ```typescript diff --git a/package.json b/package.json index f0ba365..fd309a3 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,9 @@ "homepage": "https://github.com/logkat/broker-parser#readme", "main": "dist/index.js", "types": "dist/index.d.ts", + "bin": { + "broker-parser": "./dist/cli.js" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -34,6 +37,7 @@ }, "scripts": { "build": "tsc", + "watch": "tsc -w", "type-check": "tsc --noEmit", "test": "vitest", "test:coverage": "vitest run --coverage", @@ -46,10 +50,15 @@ "devDependencies": { "@eslint/js": "^9.0.0", "@types/node": "^22.0.0", + "@types/papaparse": "^5.5.2", "@vitest/coverage-v8": "^1.6.1", "eslint": "^9.0.0", "typescript": "^5.0.0", "typescript-eslint": "^8.0.0", "vitest": "1.6.1" + }, + "dependencies": { + "commander": "^14.0.2", + "papaparse": "^5.5.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7ee929..0d510b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,13 @@ settings: importers: .: + dependencies: + commander: + specifier: ^14.0.2 + version: 14.0.2 + papaparse: + specifier: ^5.5.3 + version: 5.5.3 devDependencies: '@eslint/js': specifier: ^9.0.0 @@ -14,6 +21,9 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.19.5 + '@types/papaparse': + specifier: ^5.5.2 + version: 5.5.2 '@vitest/coverage-v8': specifier: ^1.6.1 version: 1.6.1(vitest@1.6.1(@types/node@22.19.5)) @@ -406,6 +416,9 @@ packages: '@types/node@22.19.5': resolution: {integrity: sha512-HfF8+mYcHPcPypui3w3mvzuIErlNOh2OAG+BCeBZCEwyiD5ls2SiCwEyT47OELtf7M3nHxBdu0FsmzdKxkN52Q==} + '@types/papaparse@5.5.2': + resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==} + '@typescript-eslint/eslint-plugin@8.52.0': resolution: {integrity: sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -551,6 +564,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@14.0.2: + resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} + engines: {node: '>=20'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -866,6 +883,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + papaparse@5.5.3: + resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1371,6 +1391,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/papaparse@5.5.2': + dependencies: + '@types/node': 22.19.5 + '@typescript-eslint/eslint-plugin@8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -1577,6 +1601,8 @@ snapshots: color-name@1.1.4: {} + commander@14.0.2: {} + concat-map@0.0.1: {} confbox@0.1.8: {} @@ -1922,6 +1948,8 @@ snapshots: dependencies: p-limit: 3.1.0 + papaparse@5.5.3: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..b3ea693 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,66 @@ +#!/usr/bin/env node +import { Command } from 'commander'; +import fs from 'fs'; +import path from 'path'; +import Papa from 'papaparse'; +import { parseTransaction, YahooFinanceExporter } from './index'; +import { BrokerFormat } from './parsers/types'; + +const program=new Command(); + +program + .name('broker-parser') + .description('Parse broker transaction CSVs and export to other formats') + .version('0.0.1'); + +program + .command('export') + .description('Export broker CSV to another format') + .argument('', 'Path to the broker CSV file') + .option('-f, --format ', 'Broker format (Auto, Avanza, Nordnet)', 'Auto') + .option('-e, --exporter ', 'Exporter to use (yahoo)', 'yahoo') + .option('-o, --output ', 'Output file path') + .action((file, options) => { + const filePath=path.resolve(file); + if (!fs.existsSync(filePath)) { + console.error(`Error: File not found at ${filePath}`); + process.exit(1); + } + + const csvData=fs.readFileSync(filePath, 'utf8'); + const parsedCsv=Papa.parse(csvData, { + header: true, + skipEmptyLines: true, + }); + + if (parsedCsv.errors.length>0) { + console.warn('Warning: Some errors occurred during CSV parsing:'); + console.warn(parsedCsv.errors); + } + + const data=parsedCsv.data as Record[]; + const transactions=data + .map((row) => parseTransaction(row, options.format as BrokerFormat)) + .filter((t): t is any => t!==null); + + if (transactions.length===0) { + console.error('Error: No transactions could be parsed from the file.'); + process.exit(1); + } + + console.log(`Successfully parsed ${transactions.length} transactions.`); + + let result; + if (options.exporter==='yahoo') { + result=YahooFinanceExporter.export(transactions); + } else { + console.error(`Error: Unknown exporter "${options.exporter}"`); + process.exit(1); + } + + const outputPath=options.output||path.resolve(result.filename); + fs.writeFileSync(outputPath, result.content); + console.log(`Success! Exported to ${outputPath}`); + }); + +program.parse(); diff --git a/src/exporters/yahoo.ts b/src/exporters/yahoo.ts index 1595b17..79ce91a 100644 --- a/src/exporters/yahoo.ts +++ b/src/exporters/yahoo.ts @@ -16,7 +16,7 @@ export const YahooFinanceExporter: PortfolioExporter={ const rows=transactions .map((t) => { // We need a Symbol/Ticker. Prefer 'ticker', fallback to 'symbol', fallback to 'isin' - let symbol=t.ticker||t.symbol; + const symbol=t.ticker||t.symbol; // Skip non-trade types? if (t.type!=='BUY'&&t.type!=='SELL') { diff --git a/test-avanza.csv b/test-avanza.csv new file mode 100644 index 0000000..c902bf2 --- /dev/null +++ b/test-avanza.csv @@ -0,0 +1,3 @@ +Datum,Konto,Typ av transaktion,Värdepapper/beskrivning,Antal,Kurs,Avgift,Belopp,Valuta,ISIN +2024-01-01,ISK,Köp,AAPL,10,150,1,1501,USD,US0378331005 +2024-01-02,ISK,Sälj,AAPL,5,160,1,799,USD,US0378331005 diff --git a/test-output.csv b/test-output.csv new file mode 100644 index 0000000..103bb05 --- /dev/null +++ b/test-output.csv @@ -0,0 +1,3 @@ +Symbol,Trade Date,Purchase Price,Quantity,Commission,Comment +AAPL,20240101,150.0000,10,0.0000,Imported from Avanza +AAPL,20240102,160.0000,-5,0.0000,Imported from Avanza \ No newline at end of file From 3f07269ff8b647e78192f255870227151e1c0d10 Mon Sep 17 00:00:00 2001 From: Abhishek Jain Date: Sun, 11 Jan 2026 17:08:56 +0100 Subject: [PATCH 04/13] test: add tests --- .gitignore | 2 ++ test-avanza.csv | 3 --- test-output.csv | 3 --- tests/integration.test.ts | 45 +++++++++++++++++++++++++++++++++++++++ tests/mocks/avanza.csv | 6 ++++++ tests/mocks/nordnet.csv | 3 +++ 6 files changed, 56 insertions(+), 6 deletions(-) delete mode 100644 test-avanza.csv delete mode 100644 test-output.csv create mode 100644 tests/integration.test.ts create mode 100644 tests/mocks/avanza.csv create mode 100644 tests/mocks/nordnet.csv diff --git a/.gitignore b/.gitignore index 98e2fbe..cc0dfdb 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,5 @@ __snapshots__ # Turborepo .turbo + +/transactions \ No newline at end of file diff --git a/test-avanza.csv b/test-avanza.csv deleted file mode 100644 index c902bf2..0000000 --- a/test-avanza.csv +++ /dev/null @@ -1,3 +0,0 @@ -Datum,Konto,Typ av transaktion,Värdepapper/beskrivning,Antal,Kurs,Avgift,Belopp,Valuta,ISIN -2024-01-01,ISK,Köp,AAPL,10,150,1,1501,USD,US0378331005 -2024-01-02,ISK,Sälj,AAPL,5,160,1,799,USD,US0378331005 diff --git a/test-output.csv b/test-output.csv deleted file mode 100644 index 103bb05..0000000 --- a/test-output.csv +++ /dev/null @@ -1,3 +0,0 @@ -Symbol,Trade Date,Purchase Price,Quantity,Commission,Comment -AAPL,20240101,150.0000,10,0.0000,Imported from Avanza -AAPL,20240102,160.0000,-5,0.0000,Imported from Avanza \ No newline at end of file diff --git a/tests/integration.test.ts b/tests/integration.test.ts new file mode 100644 index 0000000..ffd3e5d --- /dev/null +++ b/tests/integration.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import Papa from 'papaparse'; +import { parseTransaction } from '../src/index'; + +describe('Integration Tests with Mocks', () => { + const mocksDir=path.resolve(__dirname, 'mocks'); + + it('should parse the Avanza mock CSV correctly', () => { + const csvData=fs.readFileSync(path.join(mocksDir, 'avanza.csv'), 'utf8'); + const parsed=Papa.parse(csvData, { header: true, skipEmptyLines: true }); + + const transactions=(parsed.data as Record[]) + .map(row => parseTransaction(row, 'Avanza')) + .filter(t => t!==null); + + expect(transactions.length).toBeGreaterThan(0); + expect(transactions[0]?.originalSource).toBe('Avanza'); + expect(transactions[0]?.symbol).toBe('Meta Platforms A'); + }); + + it('should parse the Nordnet mock CSV correctly', () => { + const csvData=fs.readFileSync(path.join(mocksDir, 'nordnet.csv'), 'utf8'); + const parsed=Papa.parse(csvData, { header: true, skipEmptyLines: true }); + + const transactions=(parsed.data as Record[]) + .map(row => parseTransaction(row, 'Nordnet')) + .filter(t => t!==null); + + expect(transactions.length).toBeGreaterThan(0); + expect(transactions[0]?.originalSource).toBe('Nordnet'); + expect(transactions[0]?.symbol).toBe('Netflix'); + }); + + it('should auto-detect formats from mock files', () => { + const avanzaCsv=fs.readFileSync(path.join(mocksDir, 'avanza.csv'), 'utf8'); + const avanzaRow=Papa.parse(avanzaCsv, { header: true }).data[0] as any; + expect(parseTransaction(avanzaRow, 'Auto')?.originalSource).toBe('Avanza'); + + const nordnetCsv=fs.readFileSync(path.join(mocksDir, 'nordnet.csv'), 'utf8'); + const nordnetRow=Papa.parse(nordnetCsv, { header: true }).data[0] as any; + expect(parseTransaction(nordnetRow, 'Auto')?.originalSource).toBe('Nordnet'); + }); +}); diff --git a/tests/mocks/avanza.csv b/tests/mocks/avanza.csv new file mode 100644 index 0000000..a8b52f8 --- /dev/null +++ b/tests/mocks/avanza.csv @@ -0,0 +1,6 @@ +Datum,Konto,Typ av transaktion,Värdepapper/beskrivning,Antal,Kurs,Belopp,Transaktionsvaluta,Instrumentvaluta,ISIN,Courtage,Valutakurs +2025-12-26,Pension,Köp,Meta Platforms A,1,"666,89","-6129,85",SEK,USD,US30303M1027,"15,31","9,168745" +2025-12-29,Pension,Utdelning,Meta Platforms A,10,"0,525","48,1",SEK,USD,US30303M1027,, +2025-12-30,Pension,Sälj,Meta Platforms A,-5,700,35000,SEK,USD,US30303M1027,15,10 +2023-03-10,Pension,Byte,DNB Fund Technology A SEK Acc,0,1649,,,,,LU2553959045,, +2023-03-10,Pension,Byte,DNB TECHNOLOGY,-0,1649,,,,,LU0302296495,, diff --git a/tests/mocks/nordnet.csv b/tests/mocks/nordnet.csv new file mode 100644 index 0000000..6e62ab9 --- /dev/null +++ b/tests/mocks/nordnet.csv @@ -0,0 +1,3 @@ +Bokföringsdag,Transaktionstyp,Instrument,Antal,Kurs,Total Avgift,Valuta,Belopp,Valuta,Inköpsvärde,Valuta,Växlingskurs,ISIN +2025-12-04,KÖPT,Netflix,50,"102,98","121,43",SEK,"-48694,8",SEK,"5161,87",USD,"9,4335",US64110L1061 +2025-12-05,SÅLT,Netflix,-10,105,,SEK,10000,SEK,,,, From 99b0d9bbc8ed992f42ab5bb9fac0764c7d00df94 Mon Sep 17 00:00:00 2001 From: Abhishek Jain Date: Sun, 11 Jan 2026 17:22:54 +0100 Subject: [PATCH 05/13] feat: Add ticker resolver --- .github/workflows/ci.yml | 8 +- .github/workflows/release.yml | 8 +- .prettierignore | 8 + .prettierrc | 7 + CONTRIBUTING.md | 1 - README.md | 107 +++++--- codecov.yml | 2 +- eslint.config copy.mjs | 32 +++ package.json | 8 +- pnpm-lock.yaml | 203 +++++++++++++++ src/cache.ts | 39 +++ src/cli.ts | 145 +++++++---- src/enricher.ts | 112 +++++--- src/exporters/types.ts | 10 +- src/exporters/yahoo.ts | 109 ++++---- src/index.ts | 99 +++---- src/parsers/avanza.ts | 108 ++++---- src/parsers/nordnet.ts | 100 ++++---- src/parsers/types.ts | 46 ++-- src/parsers/utils.ts | 90 +++---- src/resolvers/file.ts | 68 +++++ src/resolvers/yahoo.ts | 92 +++++++ tests/enricher.test.ts | 104 +++++--- tests/exporters/yahoo.test.ts | 104 ++++---- tests/file_resolver.test.ts | 45 ++++ tests/integration.test.ts | 82 +++--- tests/parser_brokers.test.ts | 468 +++++++++++++++++----------------- tests/utils.test.ts | 104 ++++---- vitest.config.ts | 24 +- 29 files changed, 1496 insertions(+), 837 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 eslint.config copy.mjs create mode 100644 src/cache.ts create mode 100644 src/resolvers/file.ts create mode 100644 src/resolvers/yahoo.ts create mode 100644 tests/file_resolver.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c72a3f..e22f422 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: ["main"] + branches: ['main'] pull_request: - branches: ["main"] + branches: ['main'] jobs: test: @@ -17,8 +17,8 @@ jobs: version: 9 - uses: actions/setup-node@v4 with: - node-version: "22" - cache: "pnpm" + node-version: '22' + cache: 'pnpm' - run: pnpm install --frozen-lockfile - run: pnpm run type-check - run: pnpm run lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a23e0b6..a4627fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,8 +5,8 @@ on: branches: - main paths-ignore: - - "**.md" - - ".gitignore" + - '**.md' + - '.gitignore' permissions: contents: write # to be able to publish a GitHub release @@ -31,8 +31,8 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: "22" - cache: "pnpm" + node-version: '22' + cache: 'pnpm' - run: pnpm install --frozen-lockfile diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..b878634 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +node_modules +.next +dist +coverage +*.db +pnpm-lock.yaml +package-lock.json +yarn.lock diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..b0ec5b3 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 80 +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e0f311b..d6c0a60 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,7 +28,6 @@ We love support for new brokers! To add one: Create a new file in `src/parsers/` (e.g., `src/parsers/mybroker.ts`) implementing the `BrokerParser` interface. 2. ** Implement Logic**: - - `name`: Unique name of the broker. - `canParse(row)`: A function returning `true` if this row belongs to this broker. Be specific (check unique headers). - `parse(row)`: Map the CSV row to the `ParsedTransaction` interface. diff --git a/README.md b/README.md index 05aeb12..73e1a0a 100644 --- a/README.md +++ b/README.md @@ -39,15 +39,17 @@ pnpm add @logkat/broker-parser ### CLI Usage -You can also use the library directly from your terminal to convert broker CSVs to other formats (e.g. Yahoo Finance). +You can also use the library directly from your terminal to convert broker CSVs to other formats (e.g. Yahoo Finance) and automatically resolve tickers. ```bash -# Run without installing -npx @logkat/broker-parser export input.csv -o output.csv +# Basic export +broker-parser export input.csv -o output.csv -# Or install globally -npm install -g @logkat/broker-parser -broker-parser export my_transactions.csv --exporter yahoo --output yahoo_import.csv +# Export with automatic Yahoo Finance ticker resolution and caching +broker-parser export input.csv --yahoo --cache .tickers.json + +# Use a local mapping file for tickers (supports JSON and CSV) +broker-parser export input.csv --ticker-file my-tickers.json ``` ## Usage @@ -57,25 +59,40 @@ broker-parser export my_transactions.csv --exporter yahoo --output yahoo_import. ### Parsing a Single Transaction ```typescript -import { parseTransaction } from "@logkat/broker-parser"; +import { parseTransaction } from '@logkat/broker-parser'; const row = { - "Typ av transaktion": "Köp", - "Värdepapper/beskrivning": "Apple Inc", - Antal: "10", - Kurs: "150", - Belopp: "-1500", - Transaktionsvaluta: "USD", - // ... other broker specific fields + 'Typ av transaktion': 'Köp', + 'Värdepapper/beskrivning': 'Apple Inc', + Antal: '10', + Kurs: '150', + Belopp: '-1500', + Transaktionsvaluta: 'USD', }; const transaction = parseTransaction(row); +``` -if (transaction) { - console.log(transaction.type); // 'BUY' - console.log(transaction.quantity); // 10 - console.log(transaction.symbol); // 'Apple Inc' -} +### Automatic Ticker Resolution + +The library provides built-in resolvers for Yahoo Finance and local files. + +```typescript +import { + enrichTransactions, + YahooTickerResolver, + LocalFileTickerCache, +} from '@logkat/broker-parser'; + +// 1. Setup resolver and optional persistent cache +const resolver = new YahooTickerResolver(); +const cache = new LocalFileTickerCache('./ticker-cache.json'); + +// 2. Enrich your parsed transactions +const enriched = await enrichTransactions(parsedTransactions, { + resolver, + cache, +}); ``` ### Auto-Detecting Broker Format @@ -84,7 +101,7 @@ The library automatically detects the format based on unique headers (e.g., "Typ ```typescript // Force Avanza parser -const txn = parseTransaction(row, "Avanza"); +const txn = parseTransaction(row, 'Avanza'); ``` ### Identifying Accounts @@ -92,7 +109,7 @@ const txn = parseTransaction(row, "Avanza"); If you are parsing a large CSV with multiple accounts, you can extract unique account identifiers: ```typescript -import { identifyAccounts } from "@logkat/broker-parser"; +import { identifyAccounts } from '@logkat/broker-parser'; const accounts = identifyAccounts(allRows); // Returns: [{ id: '12345', name: 'My ISK', count: 50 }, ...] @@ -103,7 +120,7 @@ const accounts = identifyAccounts(allRows); You can export normalized transactions to various formats (e.g., for importing into other tools). ```typescript -import { YahooFinanceExporter } from "@logkat/broker-parser"; +import { YahooFinanceExporter } from '@logkat/broker-parser'; // Convert transactions to Yahoo Finance CSV const result = YahooFinanceExporter.export(parsedTransactions); @@ -113,28 +130,42 @@ console.log(result.content); // CSV string ### Enriching Data (Tickers) Brokers outputs (Avanza/Nordnet) often lack the actual Ticker Symbol required by Yahoo Finance (they provide ISIN or Name instead). -To fix this, you can use `enrichTransactions` with your own resolver logic (e.g., using `yahoo-finance2`). +To fix this, you can use `enrichTransactions` with a resolver. ```typescript import { enrichTransactions, YahooFinanceExporter, -} from "@logkat/broker-parser"; - -// Your custom resolver (could check a DB or call an API) -const myResolver = async (isin: string, name: string) => { - if (isin === "US0378331005") return "AAPL"; - // ... call external API ... - return null; +} from '@logkat/broker-parser'; + +// 1. Define or use a built-in resolver +const myResolver = { + resolve: async (isin: string, symbol: string) => { + if (isin === 'US0378331005') return { ticker: 'AAPL' }; + return { ticker: null }; + }, }; -// 1. Parse // 2. Enrich -const enriched = await enrichTransactions(parsedTransactions, myResolver); +const enriched = await enrichTransactions(parsedTransactions, { + resolver: myResolver, +}); + // 3. Export const csv = YahooFinanceExporter.export(enriched); ``` +#### Custom Caching + +The library provides a `TickerCache` interface. You can implement your own (e.g., using Redis or a Database) to persist resolutions. + +```typescript +interface TickerCache { + get(key: string): Promise; + set(key: string, value: TickerResolution): Promise; +} +``` + ## API Reference ### `parseTransaction(row: Record, format?: BrokerFormat): ParsedTransaction | null` @@ -184,17 +215,17 @@ We welcome contributions! To add support for a new broker: Create a new file (e.g., `src/parsers/mybroker.ts`) implementing the `BrokerParser` interface. ```typescript - import { BrokerParser } from "./types"; - import { parseNumber, normalizeType } from "./utils"; + import { BrokerParser } from './types'; + import { parseNumber, normalizeType } from './utils'; export const MyBrokerParser: BrokerParser = { - name: "MyBroker", - canParse: (row) => !!(row["UniqueHeader"] && row["AnotherHeader"]), + name: 'MyBroker', + canParse: (row) => !!(row['UniqueHeader'] && row['AnotherHeader']), parse: (row) => { // ... parsing logic mapping to ParsedTransaction return { - date: new Date(row["Date"]), - type: normalizeType(row["Type"]), + date: new Date(row['Date']), + type: normalizeType(row['Type']), // ... }; }, diff --git a/codecov.yml b/codecov.yml index 5a37e9e..cbecfb5 100644 --- a/codecov.yml +++ b/codecov.yml @@ -14,6 +14,6 @@ coverage: threshold: 1% comment: - layout: "header, diff, flags, files" + layout: 'header, diff, flags, files' behavior: default require_changes: false diff --git a/eslint.config copy.mjs b/eslint.config copy.mjs new file mode 100644 index 0000000..99e9e99 --- /dev/null +++ b/eslint.config copy.mjs @@ -0,0 +1,32 @@ +import { defineConfig, globalIgnores } from 'eslint/config'; +import nextVitals from 'eslint-config-next/core-web-vitals'; +import nextTs from 'eslint-config-next/typescript'; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + '.next/**', + 'out/**', + 'build/**', + 'next-env.d.ts', + 'node_modules/**', + ]), + { + rules: { + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }, + ], + quotes: ['error', 'single', { avoidEscape: true }], + }, + }, +]); + +export default eslintConfig; diff --git a/package.json b/package.json index fd309a3..7f15f24 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,9 @@ "type-check": "tsc --noEmit", "test": "vitest", "test:coverage": "vitest run --coverage", - "lint": "eslint ." + "lint": "eslint .", + "format": "prettier --write .", + "format:check": "prettier --check ." }, "publishConfig": { "access": "public", @@ -53,12 +55,14 @@ "@types/papaparse": "^5.5.2", "@vitest/coverage-v8": "^1.6.1", "eslint": "^9.0.0", + "prettier": "^3.4.2", "typescript": "^5.0.0", "typescript-eslint": "^8.0.0", "vitest": "1.6.1" }, "dependencies": { "commander": "^14.0.2", - "papaparse": "^5.5.3" + "papaparse": "^5.5.3", + "yahoo-finance2": "^3.11.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d510b2..abe0203 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: papaparse: specifier: ^5.5.3 version: 5.5.3 + yahoo-finance2: + specifier: ^3.11.2 + version: 3.11.2 devDependencies: '@eslint/js': specifier: ^9.0.0 @@ -30,6 +33,9 @@ importers: eslint: specifier: ^9.0.0 version: 9.39.2 + prettier: + specifier: ^3.4.2 + version: 3.7.4 typescript: specifier: ^5.0.0 version: 5.9.3 @@ -66,6 +72,12 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@deno/shim-deno-test@0.5.0': + resolution: {integrity: sha512-4nMhecpGlPi0cSzT67L+Tm+GOJqvuk8gqHBziqcUQOarnuIax1z96/gJHCSIz2Z0zhxE6Rzwb3IZXPtFh51j+w==} + + '@deno/shim-deno@0.18.2': + resolution: {integrity: sha512-oQ0CVmOio63wlhwQF75zA4ioolPvOwAoK0yuzcS5bDC1JUvH3y1GS8xPh8EOpcoDQRU4FTG8OQfxhpR+c6DrzA==} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -603,6 +615,10 @@ packages: engines: {node: '>=12'} hasBin: true + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -674,10 +690,25 @@ packages: picomatch: optional: true + fetch-mock-cache@2.3.1: + resolution: {integrity: sha512-hDk+Nbt0Y8Aq7KTEU6ASQAcpB34UjhkpD3QjzD6yvEKP4xVElAqXrjQ7maL+LYMGafx51Zq6qUfDM57PNu/qMw==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + filename-reserved-regex@2.0.0: + resolution: {integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==} + engines: {node: '>=4'} + + filenamify-url@2.1.2: + resolution: {integrity: sha512-3rMbAr7vDNMOGsj1aMniQFl749QjgM+lMJ/77ZRSPTIgxvolZwoQbn8dXLs7xfd+hAdli+oTnSWZNkJJLWQFEQ==} + engines: {node: '>=8'} + + filenamify@4.3.0: + resolution: {integrity: sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -727,6 +758,10 @@ packages: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} + humanize-url@2.1.1: + resolution: {integrity: sha512-V4nxsPGNE7mPjr1qDp471YfW8nhBiTRWrG/4usZlpvFU8I7gsV7Jvrrzv/snbLm5dWO3dr1ennu2YqnhTWFmYA==} + engines: {node: '>=8'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -765,6 +800,10 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isexe@3.1.1: + resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} + engines: {node: '>=16'} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -794,6 +833,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -856,6 +898,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + normalize-url@4.5.1: + resolution: {integrity: sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==} + engines: {node: '>=8'} + npm-run-path@5.3.0: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -933,17 +979,31 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier@3.7.4: + resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==} + engines: {node: '>=14'} + hasBin: true + pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -994,6 +1054,10 @@ packages: strip-literal@2.1.1: resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + strip-outer@1.0.1: + resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==} + engines: {node: '>=0.10.0'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1017,6 +1081,29 @@ packages: resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} engines: {node: '>=14.0.0'} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tough-cookie-file-store@2.0.3: + resolution: {integrity: sha512-sMpZVcmFf6EYFHFFl+SYH4W1/OnXBYMGDsv2IlbQ2caHyFElW/UR/gpj/KYU1JwmP4dE9xqwv2+vWcmlXHojSw==} + engines: {node: '>=6'} + + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + trim-repeated@1.0.0: + resolution: {integrity: sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==} + engines: {node: '>=0.10.0'} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -1049,9 +1136,16 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + vite-node@1.6.1: resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -1118,6 +1212,11 @@ packages: engines: {node: '>= 8'} hasBin: true + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -1130,6 +1229,11 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + yahoo-finance2@3.11.2: + resolution: {integrity: sha512-SIvMXjrOktBRD8m+qXAGCK+vR1vwBKuMgCnvmbxv29+t6LTDu0vAUxNYfbigsMRTmBzS4F9TQwbYF90g3Om4HA==} + engines: {node: '>=20.0.0'} + hasBin: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1160,6 +1264,13 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@deno/shim-deno-test@0.5.0': {} + + '@deno/shim-deno@0.18.2': + dependencies: + '@deno/shim-deno-test': 0.5.0 + which: 4.0.0 + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -1651,6 +1762,8 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 + escape-string-regexp@1.0.5: {} + escape-string-regexp@4.0.0: {} eslint-scope@8.4.0: @@ -1745,10 +1858,30 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fetch-mock-cache@2.3.1: + dependencies: + debug: 4.4.3 + filenamify-url: 2.1.2 + transitivePeerDependencies: + - supports-color + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 + filename-reserved-regex@2.0.0: {} + + filenamify-url@2.1.2: + dependencies: + filenamify: 4.3.0 + humanize-url: 2.1.1 + + filenamify@4.3.0: + dependencies: + filename-reserved-regex: 2.0.0 + strip-outer: 1.0.1 + trim-repeated: 1.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -1791,6 +1924,10 @@ snapshots: human-signals@5.0.0: {} + humanize-url@2.1.1: + dependencies: + normalize-url: 4.5.1 + ignore@5.3.2: {} ignore@7.0.5: {} @@ -1819,6 +1956,8 @@ snapshots: isexe@2.0.0: {} + isexe@3.1.1: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -1850,6 +1989,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} keyv@4.5.4: @@ -1915,6 +2056,8 @@ snapshots: natural-compare@1.4.0: {} + normalize-url@4.5.1: {} + npm-run-path@5.3.0: dependencies: path-key: 4.0.0 @@ -1986,16 +2129,26 @@ snapshots: prelude-ls@1.2.1: {} + prettier@3.7.4: {} + pretty-format@29.7.0: dependencies: '@jest/schemas': 29.6.3 ansi-styles: 5.2.0 react-is: 18.3.1 + psl@1.15.0: + dependencies: + punycode: 2.3.1 + punycode@2.3.1: {} + querystringify@2.2.0: {} + react-is@18.3.1: {} + requires-port@1.0.0: {} + resolve-from@4.0.0: {} rollup@4.55.1: @@ -2055,6 +2208,10 @@ snapshots: dependencies: js-tokens: 9.0.1 + strip-outer@1.0.1: + dependencies: + escape-string-regexp: 1.0.5 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -2076,6 +2233,31 @@ snapshots: tinyspy@2.2.1: {} + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tough-cookie-file-store@2.0.3: + dependencies: + tough-cookie: 4.1.4 + + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + trim-repeated@1.0.0: + dependencies: + escape-string-regexp: 1.0.5 + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -2103,10 +2285,17 @@ snapshots: undici-types@6.21.0: {} + universalify@0.2.0: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + vite-node@1.6.1(@types/node@22.19.5): dependencies: cac: 6.7.14 @@ -2172,6 +2361,10 @@ snapshots: dependencies: isexe: 2.0.0 + which@4.0.0: + dependencies: + isexe: 3.1.1 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -2181,6 +2374,16 @@ snapshots: wrappy@1.0.2: {} + yahoo-finance2@3.11.2: + dependencies: + '@deno/shim-deno': 0.18.2 + fetch-mock-cache: 2.3.1 + json-schema: 0.4.0 + tough-cookie: 5.1.2 + tough-cookie-file-store: 2.0.3 + transitivePeerDependencies: + - supports-color + yocto-queue@0.1.0: {} yocto-queue@1.2.2: {} diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..e671e85 --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,39 @@ +import fs from 'fs'; +import { TickerCache, TickerResolution } from './enricher'; + +export class LocalFileTickerCache implements TickerCache { + private cache: Record = {}; + private filePath: string; + + constructor(filePath: string) { + this.filePath = filePath; + this.load(); + } + + private load() { + if (fs.existsSync(this.filePath)) { + try { + this.cache = JSON.parse(fs.readFileSync(this.filePath, 'utf8')); + } catch (e) { + console.warn(`Failed to load ticker cache from ${this.filePath}`); + } + } + } + + private save() { + try { + fs.writeFileSync(this.filePath, JSON.stringify(this.cache, null, 2)); + } catch (e) { + console.warn(`Failed to save ticker cache to ${this.filePath}`); + } + } + + async get(key: string): Promise { + return this.cache[key]; + } + + async set(key: string, value: TickerResolution): Promise { + this.cache[key] = value; + this.save(); + } +} diff --git a/src/cli.ts b/src/cli.ts index b3ea693..8f4d354 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,64 +3,119 @@ import { Command } from 'commander'; import fs from 'fs'; import path from 'path'; import Papa from 'papaparse'; -import { parseTransaction, YahooFinanceExporter } from './index'; +import { + parseTransaction, + YahooFinanceExporter, + enrichTransactions, + YahooTickerResolver, + FileTickerResolver, + LocalFileTickerCache, +} from './index'; import { BrokerFormat } from './parsers/types'; -const program=new Command(); +const program = new Command(); program - .name('broker-parser') - .description('Parse broker transaction CSVs and export to other formats') - .version('0.0.1'); + .name('broker-parser') + .description('Parse broker transaction CSVs and export to other formats') + .version('0.0.1'); program - .command('export') - .description('Export broker CSV to another format') - .argument('', 'Path to the broker CSV file') - .option('-f, --format ', 'Broker format (Auto, Avanza, Nordnet)', 'Auto') - .option('-e, --exporter ', 'Exporter to use (yahoo)', 'yahoo') - .option('-o, --output ', 'Output file path') - .action((file, options) => { - const filePath=path.resolve(file); - if (!fs.existsSync(filePath)) { - console.error(`Error: File not found at ${filePath}`); - process.exit(1); - } + .command('export') + .description('Export broker CSV to another format') + .argument('', 'Path to the broker CSV file') + .option( + '-f, --format ', + 'Broker format (Auto, Avanza, Nordnet)', + 'Auto' + ) + .option('-e, --exporter ', 'Exporter to use (yahoo)', 'yahoo') + .option('-o, --output ', 'Output file path') + .option('--yahoo', 'Use Yahoo Finance for ticker resolution', false) + .option( + '--ticker-file ', + 'Path to a JSON or CSV file for ticker resolution' + ) + .option( + '--cache ', + 'Path to a local JSON file for caching ticker resolutions', + '.ticker-cache.json' + ) + .option('--no-cache', 'Disable caching') + .action(async (file, options) => { + const filePath = path.resolve(file); + if (!fs.existsSync(filePath)) { + console.error(`Error: File not found at ${filePath}`); + process.exit(1); + } - const csvData=fs.readFileSync(filePath, 'utf8'); - const parsedCsv=Papa.parse(csvData, { - header: true, - skipEmptyLines: true, - }); + const csvData = fs.readFileSync(filePath, 'utf8'); + const parsedCsv = Papa.parse(csvData, { + header: true, + skipEmptyLines: true, + }); - if (parsedCsv.errors.length>0) { - console.warn('Warning: Some errors occurred during CSV parsing:'); - console.warn(parsedCsv.errors); - } + if (parsedCsv.errors.length > 0) { + console.warn('Warning: Some errors occurred during CSV parsing:'); + console.warn(parsedCsv.errors); + } - const data=parsedCsv.data as Record[]; - const transactions=data - .map((row) => parseTransaction(row, options.format as BrokerFormat)) - .filter((t): t is any => t!==null); + const data = parsedCsv.data as Record[]; + const transactions = data + .map((row) => parseTransaction(row, options.format as BrokerFormat)) + .filter((t): t is any => t !== null); - if (transactions.length===0) { - console.error('Error: No transactions could be parsed from the file.'); - process.exit(1); - } + if (transactions.length === 0) { + console.error('Error: No transactions could be parsed from the file.'); + process.exit(1); + } + + console.log(`Successfully parsed ${transactions.length} transactions.`); + + let processedTransactions = transactions; - console.log(`Successfully parsed ${transactions.length} transactions.`); + // Ticker resolution + if (options.yahoo || options.tickerFile) { + console.log('Resolving tickers...'); - let result; - if (options.exporter==='yahoo') { - result=YahooFinanceExporter.export(transactions); - } else { - console.error(`Error: Unknown exporter "${options.exporter}"`); - process.exit(1); + let resolver; + if (options.tickerFile) { + resolver = new FileTickerResolver(path.resolve(options.tickerFile)); + } else if (options.yahoo) { + resolver = new YahooTickerResolver(); + } + + if (resolver) { + let cache; + if (options.cache !== false) { + cache = new LocalFileTickerCache(path.resolve(options.cache)); } - const outputPath=options.output||path.resolve(result.filename); - fs.writeFileSync(outputPath, result.content); - console.log(`Success! Exported to ${outputPath}`); - }); + processedTransactions = await enrichTransactions(transactions, { + resolver, + cache, + }); + + const resolvedCount = processedTransactions.filter( + (t) => t.ticker + ).length; + console.log( + `Resolved tickers for ${resolvedCount}/${processedTransactions.length} transactions.` + ); + } + } + + let result; + if (options.exporter === 'yahoo') { + result = YahooFinanceExporter.export(processedTransactions); + } else { + console.error(`Error: Unknown exporter "${options.exporter}"`); + process.exit(1); + } + + const outputPath = options.output || path.resolve(result.filename); + fs.writeFileSync(outputPath, result.content); + console.log(`Success! Exported to ${outputPath}`); + }); program.parse(); diff --git a/src/enricher.ts b/src/enricher.ts index 141cb92..92ff00d 100644 --- a/src/enricher.ts +++ b/src/enricher.ts @@ -1,51 +1,91 @@ import { ParsedTransaction } from './parsers/types'; -export type TickerResolver=(isin: string, symbol: string) => Promise; +export type TickerResolution = { + ticker: string | null; + currency?: string | null; +}; + +export interface TickerResolver { + resolve(isin: string, symbol: string): Promise; +} + +export interface TickerCache { + get(key: string): Promise; + set(key: string, value: TickerResolution): Promise; +} + +export interface EnrichmentOptions { + resolver: TickerResolver; + cache?: TickerCache; + /** + * If true, will not attempt to resolve if a ticker is already present. + * Default: true + */ + skipIfPresent?: boolean; +} /** - * Enriches transactions by resolving tickers using a provided resolver function. - * This allows the library to remain dependency-free while enabling data enrichment. + * Enriches transactions by resolving tickers using a provided resolver and optional cache. */ export async function enrichTransactions( - transactions: ParsedTransaction[], - resolver: TickerResolver + transactions: ParsedTransaction[], + options: EnrichmentOptions ): Promise { - const enriched: ParsedTransaction[]=[]; + const { resolver, cache, skipIfPresent = true } = options; + const enriched: ParsedTransaction[] = []; - // Cache resolutions to avoid redundant calls for the same ISIN - const cache=new Map(); + for (const t of transactions) { + if (skipIfPresent && t.ticker) { + enriched.push(t); + continue; + } - for (const t of transactions) { - // If already has ticker, skip - if (t.ticker) { - enriched.push(t); - continue; - } + const key = t.isin || t.symbol; + if (!key) { + enriched.push(t); + continue; + } - const key=t.isin||t.symbol; - if (!key) { - enriched.push(t); - continue; - } + let resolution: TickerResolution | undefined; - let ticker: string|null=null; - - if (cache.has(key)) { - ticker=cache.get(key)||null; - } else { - try { - ticker=await resolver(t.isin||'', t.symbol); - cache.set(key, ticker); - } catch (e) { - console.warn(`Failed to resolve ticker for ${key}`, e); - } - } + if (cache) { + resolution = await cache.get(key); + } - enriched.push({ - ...t, - ticker: ticker||undefined, - }); + if (!resolution) { + try { + resolution = await resolver.resolve(t.isin || '', t.symbol); + if (cache && resolution) { + await cache.set(key, resolution); + } + } catch (e) { + console.warn(`Failed to resolve ticker for ${key}`, e); + resolution = { ticker: null }; + } } - return enriched; + enriched.push({ + ...t, + ticker: resolution.ticker || t.ticker, + // If the resolver found a currency, we could potentially update it too + // 但 ParsedTransaction has multiple currency fields. + }); + } + + return enriched; +} + +/** + * Simple in-memory cache implementation + */ +export class MemoryTickerCache implements TickerCache { + private cache = new Map(); + + async get(key: string): Promise { + return this.cache.get(key); + } + + async set(key: string, value: TickerResolution): Promise { + this.cache.set(key, value); + } } diff --git a/src/exporters/types.ts b/src/exporters/types.ts index 058fe1d..f4de9e2 100644 --- a/src/exporters/types.ts +++ b/src/exporters/types.ts @@ -1,12 +1,12 @@ import { ParsedTransaction } from '../parsers/types'; export interface ExportResult { - filename: string; - content: string; // CSV content or JSON string - mimeType: string; + filename: string; + content: string; // CSV content or JSON string + mimeType: string; } export interface PortfolioExporter { - name: string; - export(transactions: ParsedTransaction[]): ExportResult; + name: string; + export(transactions: ParsedTransaction[]): ExportResult; } diff --git a/src/exporters/yahoo.ts b/src/exporters/yahoo.ts index 79ce91a..b148b92 100644 --- a/src/exporters/yahoo.ts +++ b/src/exporters/yahoo.ts @@ -1,59 +1,60 @@ import { ParsedTransaction } from '../parsers/types'; import { PortfolioExporter, ExportResult } from './types'; -export const YahooFinanceExporter: PortfolioExporter={ - name: 'Yahoo Finance', - export(transactions: ParsedTransaction[]): ExportResult { - const headers=[ - 'Symbol', - 'Trade Date', // YYYYMMDD - 'Purchase Price', - 'Quantity', - 'Commission', - 'Comment', - ]; - - const rows=transactions - .map((t) => { - // We need a Symbol/Ticker. Prefer 'ticker', fallback to 'symbol', fallback to 'isin' - const symbol=t.ticker||t.symbol; - - // Skip non-trade types? - if (t.type!=='BUY'&&t.type!=='SELL') { - return null; - } - - const dateStr=t.date.toISOString().slice(0, 10).replace(/-/g, ''); // YYYYMMDD - const qty=t.type==='SELL'? -Math.abs(t.quantity):Math.abs(t.quantity); - const price=t.price||0; - const commission=t.fee||0; - const comment=`Imported from ${t.originalSource||'Broker'}`; - - // Escape helper - const escape=(val: string) => { - if (val.includes(',')||val.includes('"')) { - return `"${val.replace(/"/g, '""')}"`; - } - return val; - }; - - return [ - escape(symbol), - dateStr, - price.toFixed(4), - qty.toString(), - commission.toFixed(4), - escape(comment), - ].join(','); - }) - .filter((row): row is string => row!==null); - - const content=[headers.join(','), ...rows].join('\n'); - - return { - filename: 'yahoo_finance_import.csv', - content, - mimeType: 'text/csv', +export const YahooFinanceExporter: PortfolioExporter = { + name: 'Yahoo Finance', + export(transactions: ParsedTransaction[]): ExportResult { + const headers = [ + 'Symbol', + 'Trade Date', // YYYYMMDD + 'Purchase Price', + 'Quantity', + 'Commission', + 'Comment', + ]; + + const rows = transactions + .map((t) => { + // We need a Symbol/Ticker. Prefer 'ticker', fallback to 'symbol', fallback to 'isin' + const symbol = t.ticker || t.symbol; + + // Skip non-trade types? + if (t.type !== 'BUY' && t.type !== 'SELL') { + return null; + } + + const dateStr = t.date.toISOString().slice(0, 10).replace(/-/g, ''); // YYYYMMDD + const qty = + t.type === 'SELL' ? -Math.abs(t.quantity) : Math.abs(t.quantity); + const price = t.price || 0; + const commission = t.fee || 0; + const comment = `Imported from ${t.originalSource || 'Broker'}`; + + // Escape helper + const escape = (val: string) => { + if (val.includes(',') || val.includes('"')) { + return `"${val.replace(/"/g, '""')}"`; + } + return val; }; - }, + + return [ + escape(symbol), + dateStr, + price.toFixed(4), + qty.toString(), + commission.toFixed(4), + escape(comment), + ].join(','); + }) + .filter((row): row is string => row !== null); + + const content = [headers.join(','), ...rows].join('\n'); + + return { + filename: 'yahoo_finance_import.csv', + content, + mimeType: 'text/csv', + }; + }, }; diff --git a/src/index.ts b/src/index.ts index fb85fd0..3cea022 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,69 +11,72 @@ import { YahooFinanceExporter } from './exporters/yahoo'; export * from './exporters/types'; export { YahooFinanceExporter }; export * from './enricher'; +export * from './cache'; +export * from './resolvers/yahoo'; +export * from './resolvers/file'; export function getParsers(): Record { - return { - Avanza: AvanzaParser, - Nordnet: NordnetParser, - }; + return { + Avanza: AvanzaParser, + Nordnet: NordnetParser, + }; } export function parseTransaction( - row: Record, - format: BrokerFormat='Auto' -): ParsedTransaction|null { - let parser: BrokerParser|undefined; + row: Record, + format: BrokerFormat = 'Auto' +): ParsedTransaction | null { + let parser: BrokerParser | undefined; - const parsers=getParsers(); + const parsers = getParsers(); - if (format==='Auto') { - // Find first parser that can parse the row - const found=Object.values(parsers).find(p => p.canParse(row)); - if (found) parser=found; - } else { - // Direct lookup - parser=parsers[format]; - } + if (format === 'Auto') { + // Find first parser that can parse the row + const found = Object.values(parsers).find((p) => p.canParse(row)); + if (found) parser = found; + } else { + // Direct lookup + parser = parsers[format]; + } - if (parser&&parser.canParse(row)) { - const t=parser.parse(row); - if (t&&t.symbol&&t.date&&!isNaN(t.date.getTime())) { - return t; - } + if (parser && parser.canParse(row)) { + const t = parser.parse(row); + if (t && t.symbol && t.date && !isNaN(t.date.getTime())) { + return t; } + } - // Fallback / legacy check if Auto failed or specific parser failed (though unlikely if canParse checks keys) - if (format==='Auto'&&!parser) { - // Try generic best effort if any new formats appear, but for now return null - } + // Fallback / legacy check if Auto failed or specific parser failed (though unlikely if canParse checks keys) + if (format === 'Auto' && !parser) { + // Try generic best effort if any new formats appear, but for now return null + } - return null; + return null; } export function identifyAccounts( - data: Record[] + data: Record[] ): { id: string; name: string; count: number }[] { - const accounts=new Map< - string, - { id: string; name: string; count: number } - >(); + const accounts = new Map< + string, + { id: string; name: string; count: number } + >(); - data.forEach((row) => { - const t=parseTransaction(row); - if (t&&t.accountId) { - const existing=accounts.get(t.accountId); - if (existing) { - existing.count++; - } else { - accounts.set(t.accountId, { - id: t.accountId, - name: t.accountId, - count: 1, - }); - } - } - }); + data.forEach((row) => { + const t = parseTransaction(row); + if (t && t.accountId) { + const existing = accounts.get(t.accountId); + if (existing) { + existing.count++; + } else { + accounts.set(t.accountId, { + id: t.accountId, + name: t.accountId, + count: 1, + }); + } + } + }); - return Array.from(accounts.values()); + return Array.from(accounts.values()); } diff --git a/src/parsers/avanza.ts b/src/parsers/avanza.ts index c263cac..936ab2a 100644 --- a/src/parsers/avanza.ts +++ b/src/parsers/avanza.ts @@ -1,64 +1,64 @@ import { BrokerParser } from './types'; import { parseNumber, normalizeType } from './utils'; -export const AvanzaParser: BrokerParser={ - name: 'Avanza', - canParse: (row) => - !!( - row['Typ av transaktion']&& - (row['Värdepapper/beskrivning']||row['Värdepapper']) - ), - parse: (row) => { - const qty=parseNumber(row['Antal']); - const total=parseNumber(row['Belopp']); - const fee=parseNumber(row['Courtage']); - const price=parseNumber(row['Kurs']); - const date=new Date(row['Datum']); +export const AvanzaParser: BrokerParser = { + name: 'Avanza', + canParse: (row) => + !!( + row['Typ av transaktion'] && + (row['Värdepapper/beskrivning'] || row['Värdepapper']) + ), + parse: (row) => { + const qty = parseNumber(row['Antal']); + const total = parseNumber(row['Belopp']); + const fee = parseNumber(row['Courtage']); + const price = parseNumber(row['Kurs']); + const date = new Date(row['Datum']); - let type=normalizeType(row['Typ av transaktion']); - const rawType=(row['Typ av transaktion']||'').trim(); + let type = normalizeType(row['Typ av transaktion']); + const rawType = (row['Typ av transaktion'] || '').trim(); - // Special handling for "Byte" (Exchange/Switch) which normalizes to OTHER - // We use the sign of Quantity to determine direction - if (rawType.toLowerCase()==='byte') { - if (qty>0) type='BUY'; - else if (qty<0) type='SELL'; - } + // Special handling for "Byte" (Exchange/Switch) which normalizes to OTHER + // We use the sign of Quantity to determine direction + if (rawType.toLowerCase() === 'byte') { + if (qty > 0) type = 'BUY'; + else if (qty < 0) type = 'SELL'; + } - // Find ISIN key case-insensitively - const isinKey=Object.keys(row).find( - (k) => k.trim().toUpperCase()==='ISIN' - ); - const isin=isinKey? row[isinKey]:undefined; + // Find ISIN key case-insensitively + const isinKey = Object.keys(row).find( + (k) => k.trim().toUpperCase() === 'ISIN' + ); + const isin = isinKey ? row[isinKey] : undefined; - // Avanza specific: "Transaktionsvaluta" is account currency usually (SEK) - // "Instrumentvaluta" is the asset currency - const accountCurrency=row['Transaktionsvaluta']||'SEK'; - const nativeCurrency=row['Instrumentvaluta']||accountCurrency; + // Avanza specific: "Transaktionsvaluta" is account currency usually (SEK) + // "Instrumentvaluta" is the asset currency + const accountCurrency = row['Transaktionsvaluta'] || 'SEK'; + const nativeCurrency = row['Instrumentvaluta'] || accountCurrency; - let exchangeRate=parseNumber(row['Valutakurs']); - if (exchangeRate===0&&accountCurrency===nativeCurrency) { - exchangeRate=1; - } + let exchangeRate = parseNumber(row['Valutakurs']); + if (exchangeRate === 0 && accountCurrency === nativeCurrency) { + exchangeRate = 1; + } - return { - date, - type, - symbol: row['Värdepapper/beskrivning']||row['Värdepapper'], - quantity: Math.abs(qty), - price: price, - currency: accountCurrency, - fee, - total, - originalSource: 'Avanza', - accountId: row['Konto'], - accountCurrency, - priceInAccountCurrency: - qty!==0&&total!==0? Math.abs(total/qty):price, // Approximate if total is available including fees - nativePrice: price, - nativeCurrency, - isin, - exchangeRate, - }; - }, + return { + date, + type, + symbol: row['Värdepapper/beskrivning'] || row['Värdepapper'], + quantity: Math.abs(qty), + price: price, + currency: accountCurrency, + fee, + total, + originalSource: 'Avanza', + accountId: row['Konto'], + accountCurrency, + priceInAccountCurrency: + qty !== 0 && total !== 0 ? Math.abs(total / qty) : price, // Approximate if total is available including fees + nativePrice: price, + nativeCurrency, + isin, + exchangeRate, + }; + }, }; diff --git a/src/parsers/nordnet.ts b/src/parsers/nordnet.ts index 0a3e6cc..024e646 100644 --- a/src/parsers/nordnet.ts +++ b/src/parsers/nordnet.ts @@ -1,59 +1,59 @@ import { BrokerParser } from './types'; import { parseNumber, normalizeType } from './utils'; -export const NordnetParser: BrokerParser={ - name: 'Nordnet', - canParse: (row) => - !!( - row['Transaktionstyp']&& - (row['Instrument']||row['Värdepapper'])&& - row['Bokföringsdag'] - ), - parse: (row) => { - const qty=parseNumber(row['Antal']); - const total=parseNumber(row['Belopp']); - const fee=parseNumber(row['Total Avgift']||row['Courtage']); // Check both potential headers if they vary - const price=parseNumber(row['Kurs']); - const date=new Date(row['Transaktionsdag']||row['Bokföringsdag']); - const type=normalizeType(row['Transaktionstyp']); - const isin=row['ISIN']; - const exchangeRate=parseNumber(row['Växlingskurs']); +export const NordnetParser: BrokerParser = { + name: 'Nordnet', + canParse: (row) => + !!( + row['Transaktionstyp'] && + (row['Instrument'] || row['Värdepapper']) && + row['Bokföringsdag'] + ), + parse: (row) => { + const qty = parseNumber(row['Antal']); + const total = parseNumber(row['Belopp']); + const fee = parseNumber(row['Total Avgift'] || row['Courtage']); // Check both potential headers if they vary + const price = parseNumber(row['Kurs']); + const date = new Date(row['Transaktionsdag'] || row['Bokföringsdag']); + const type = normalizeType(row['Transaktionstyp']); + const isin = row['ISIN']; + const exchangeRate = parseNumber(row['Växlingskurs']); - // Handle Nordnet's multiple Valuta columns - // Usually: - // Valuta (0) -> Fee check? - // Belopp -> Valuta (or Valuta_1) -> Account Currency - // Inköpsvärde -> Valuta (or Valuta_2) -> Native Currency + // Handle Nordnet's multiple Valuta columns + // Usually: + // Valuta (0) -> Fee check? + // Belopp -> Valuta (or Valuta_1) -> Account Currency + // Inköpsvärde -> Valuta (or Valuta_2) -> Native Currency - const accountCurrency=row['Valuta_1']||row['Valuta']||'SEK'; // Fallback to first if only one - const nativeCurrency=row['Valuta_2']||row['Valuta']||'SEK'; + const accountCurrency = row['Valuta_1'] || row['Valuta'] || 'SEK'; // Fallback to first if only one + const nativeCurrency = row['Valuta_2'] || row['Valuta'] || 'SEK'; - // In Nordnet export: - // 'Belopp' is valid for BUY/SELL/DIVIDEND + // In Nordnet export: + // 'Belopp' is valid for BUY/SELL/DIVIDEND - return { - date, - type, - symbol: row['Instrument']||row['Värdepapper'], - quantity: Math.abs(qty), - price: price, // This is usually native price - currency: accountCurrency, - fee, - total, - originalSource: 'Nordnet', - accountId: row['Depå']||row['Konto']||row['Kontonummer'], // 'Depå' in the file - accountCurrency, - priceInAccountCurrency: - qty!==0&&total!==0 - ? Math.abs((total+(type==='BUY'? fee:-fee))/qty) - :0, // Approx price paid in account currency - // Note on total: For BUY, Total is negative (-Cost -Fee). So Price*Qty = Total + Fee (abs). - // Actually Total = -(Price*Qty*Rate + Fee). So (Total + Fee) is -(Price*Qty*Rate) roughly. + return { + date, + type, + symbol: row['Instrument'] || row['Värdepapper'], + quantity: Math.abs(qty), + price: price, // This is usually native price + currency: accountCurrency, + fee, + total, + originalSource: 'Nordnet', + accountId: row['Depå'] || row['Konto'] || row['Kontonummer'], // 'Depå' in the file + accountCurrency, + priceInAccountCurrency: + qty !== 0 && total !== 0 + ? Math.abs((total + (type === 'BUY' ? fee : -fee)) / qty) + : 0, // Approx price paid in account currency + // Note on total: For BUY, Total is negative (-Cost -Fee). So Price*Qty = Total + Fee (abs). + // Actually Total = -(Price*Qty*Rate + Fee). So (Total + Fee) is -(Price*Qty*Rate) roughly. - nativePrice: price, - nativeCurrency, - isin, - exchangeRate: exchangeRate||1, - }; - }, + nativePrice: price, + nativeCurrency, + isin, + exchangeRate: exchangeRate || 1, + }; + }, }; diff --git a/src/parsers/types.ts b/src/parsers/types.ts index b0e4da6..5e2e3a9 100644 --- a/src/parsers/types.ts +++ b/src/parsers/types.ts @@ -1,28 +1,28 @@ -export type ParsedTransaction={ - date: Date; - type: string; // "BUY", "SELL" - symbol: string; - quantity: number; - price: number; - currency: string; - fee: number; - total: number; - originalSource: string|null; - ticker?: string|null; - accountId?: string; - accountType?: string; - priceInAccountCurrency?: number; - accountCurrency?: string; - nativePrice?: number; - nativeCurrency?: string; - isin?: string; - exchangeRate?: number; +export type ParsedTransaction = { + date: Date; + type: string; // "BUY", "SELL" + symbol: string; + quantity: number; + price: number; + currency: string; + fee: number; + total: number; + originalSource: string | null; + ticker?: string | null; + accountId?: string; + accountType?: string; + priceInAccountCurrency?: number; + accountCurrency?: string; + nativePrice?: number; + nativeCurrency?: string; + isin?: string; + exchangeRate?: number; }; -export type BrokerFormat='Avanza'|'Nordnet'|'Auto'|(string&{}); +export type BrokerFormat = 'Avanza' | 'Nordnet' | 'Auto' | (string & {}); export interface BrokerParser { - name: string; - canParse(row: Record): boolean; - parse(row: Record): ParsedTransaction|null; + name: string; + canParse(row: Record): boolean; + parse(row: Record): ParsedTransaction | null; } diff --git a/src/parsers/utils.ts b/src/parsers/utils.ts index fd926e3..2869655 100644 --- a/src/parsers/utils.ts +++ b/src/parsers/utils.ts @@ -1,49 +1,49 @@ -export function parseNumber(val: string|number|null|undefined): number { - if (typeof val==='number') return val; - if (!val) return 0; - // Handle Swedish format "1 234,50" -> 1234.50 - // Remove non-breaking spaces if any, regular spaces - return parseFloat(val.toString().trim().replace(/\s/g, '').replace(',', '.')); +export function parseNumber(val: string | number | null | undefined): number { + if (typeof val === 'number') return val; + if (!val) return 0; + // Handle Swedish format "1 234,50" -> 1234.50 + // Remove non-breaking spaces if any, regular spaces + return parseFloat(val.toString().trim().replace(/\s/g, '').replace(',', '.')); } export function normalizeType(type: string) { - if (!type) return 'OTHER'; - type=type.toUpperCase(); - if ( - type.includes('KÖP')|| - type.includes('KÖPT')|| - type.includes('BUY')|| - type.includes('INBOKNING') - ) - return 'BUY'; - if ( - type.includes('SÄLJ')|| - type.includes('SÅLT')|| - type.includes('SELL')|| - type.includes('INLÖSEN')|| - type.includes('REDEMPTION')|| - type.includes('UTBOKNING')|| - type.includes('FUSION')|| - type.includes('MERGER')|| - type.includes('MAKULERING') - ) - return 'SELL'; - if (type.includes('UTDELNING')||type.includes('DIVIDEND')) - return 'DIVIDEND'; - if ( - type.includes('INSÄTTNING')|| - type.includes('DEPOSIT')|| - type.includes('INS. KREDIT')|| - type.includes('REALTIDSINSÄTTNING') - ) - return 'DEPOSIT'; - if (type.includes('UTTAG')||type.includes('WITHDRAW')) return 'WITHDRAW'; - if ( - type.includes('RÄNTA')|| - type.includes('INTEREST')|| - type.includes('AVKASTNINGSSKATT') - ) - return 'INTEREST'; // Treating taxes as interest/fee category often - if (type.includes('SKATT')||type.includes('TAX')) return 'TAX'; - return 'OTHER'; + if (!type) return 'OTHER'; + type = type.toUpperCase(); + if ( + type.includes('KÖP') || + type.includes('KÖPT') || + type.includes('BUY') || + type.includes('INBOKNING') + ) + return 'BUY'; + if ( + type.includes('SÄLJ') || + type.includes('SÅLT') || + type.includes('SELL') || + type.includes('INLÖSEN') || + type.includes('REDEMPTION') || + type.includes('UTBOKNING') || + type.includes('FUSION') || + type.includes('MERGER') || + type.includes('MAKULERING') + ) + return 'SELL'; + if (type.includes('UTDELNING') || type.includes('DIVIDEND')) + return 'DIVIDEND'; + if ( + type.includes('INSÄTTNING') || + type.includes('DEPOSIT') || + type.includes('INS. KREDIT') || + type.includes('REALTIDSINSÄTTNING') + ) + return 'DEPOSIT'; + if (type.includes('UTTAG') || type.includes('WITHDRAW')) return 'WITHDRAW'; + if ( + type.includes('RÄNTA') || + type.includes('INTEREST') || + type.includes('AVKASTNINGSSKATT') + ) + return 'INTEREST'; // Treating taxes as interest/fee category often + if (type.includes('SKATT') || type.includes('TAX')) return 'TAX'; + return 'OTHER'; } diff --git a/src/resolvers/file.ts b/src/resolvers/file.ts new file mode 100644 index 0000000..771c9db --- /dev/null +++ b/src/resolvers/file.ts @@ -0,0 +1,68 @@ +import fs from 'fs'; +import path from 'path'; +import Papa from 'papaparse'; +import { TickerResolver, TickerResolution } from '../enricher'; + +export interface FileResolverOptions { + filePath: string; + /** + * Map of ISIN or Symbol to Ticker + * For JSON, it should be an object or an array of objects. + * For CSV, it should have headers like 'isin', 'symbol', 'ticker'. + */ +} + +export class FileTickerResolver implements TickerResolver { + private mappings: Map = new Map(); + + constructor(filePath: string) { + this.load(filePath); + } + + private load(filePath: string) { + if (!fs.existsSync(filePath)) { + console.warn(`Ticker file not found: ${filePath}`); + return; + } + + const ext = path.extname(filePath).toLowerCase(); + const content = fs.readFileSync(filePath, 'utf8'); + + if (ext === '.json') { + try { + const data = JSON.parse(content); + if (Array.isArray(data)) { + data.forEach((item: any) => { + const key = item.isin || item.symbol; + if (key && item.ticker) this.mappings.set(key, item.ticker); + }); + } else { + Object.entries(data).forEach(([key, value]) => { + if (typeof value === 'string') this.mappings.set(key, value); + }); + } + } catch (e) { + console.error(`Error parsing JSON ticker file: ${e}`); + } + } else if (ext === '.csv') { + try { + const results = Papa.parse(content, { + header: true, + skipEmptyLines: true, + }); + (results.data as any[]).forEach((row) => { + const key = row.isin || row.symbol || row.ISIN || row.Symbol; + const ticker = row.ticker || row.Ticker; + if (key && ticker) this.mappings.set(key, ticker); + }); + } catch (e) { + console.error(`Error parsing CSV ticker file: ${e}`); + } + } + } + + async resolve(isin: string, symbol: string): Promise { + const ticker = this.mappings.get(isin) || this.mappings.get(symbol) || null; + return { ticker }; + } +} diff --git a/src/resolvers/yahoo.ts b/src/resolvers/yahoo.ts new file mode 100644 index 0000000..e6696de --- /dev/null +++ b/src/resolvers/yahoo.ts @@ -0,0 +1,92 @@ +import YahooFinance from 'yahoo-finance2'; +import { TickerResolver, TickerResolution } from '../enricher'; + +export class YahooTickerResolver implements TickerResolver { + private yahooFinance: typeof YahooFinance; + + constructor() { + // @ts-ignore - yahoo-finance2 export can be tricky depending on build + this.yahooFinance = YahooFinance.default || YahooFinance; + try { + (this.yahooFinance as any).suppressNotices(['yahooSurvey']); + } catch (e) {} + } + + async resolve(isin: string, symbol: string): Promise { + let resolvedTicker: string | null = null; + let resolvedCurrency: string | null = null; + + try { + // 1. ISIN Search + if (isin) { + const isinResults: any = await this.yahooFinance.search(isin); + if ( + isinResults && + isinResults.quotes && + isinResults.quotes.length > 0 + ) { + const equityMatch = isinResults.quotes.find( + (q: any) => q.quoteType === 'EQUITY' || q.quoteType === 'ETF' + ); + if (equityMatch) { + resolvedTicker = equityMatch.symbol; + resolvedCurrency = equityMatch.currency || null; + } else { + resolvedTicker = isinResults.quotes[0].symbol; + resolvedCurrency = (isinResults.quotes[0] as any).currency || null; + } + } + } + + // 2. Name Search (Fallback) + if (!resolvedTicker) { + let cleanName = symbol; + const classMatch = + symbol.match(/\s+Class\s+([A-Z])$/i) || symbol.match(/\s+([A-Z])$/); + if (classMatch) { + cleanName = symbol.substring(0, classMatch.index).trim(); + } + + if (cleanName.toLowerCase() === 'alphabet') { + cleanName = 'Alphabet Inc'; + } + + const results: any = await this.yahooFinance.search(cleanName); + if (results && results.quotes && results.quotes.length > 0) { + const candidates = results.quotes.filter( + (q: any) => + (q.exchange === 'NMS' || + q.exchange === 'NYQ' || + q.exchange === 'NGM') && + (q.quoteType === 'EQUITY' || q.quoteType === 'ETF') + ); + + if (candidates.length > 0) { + // Simple logic for Alphabet + if (cleanName.toLowerCase().includes('alphabet')) { + if (symbol.includes('Class C')) { + const found = candidates.find((q: any) => q.symbol === 'GOOG'); + if (found) resolvedTicker = found.symbol; + } else { + const found = candidates.find((q: any) => q.symbol === 'GOOGL'); + if (found) resolvedTicker = found.symbol; + } + } + + if (!resolvedTicker) { + resolvedTicker = candidates[0].symbol; + resolvedCurrency = candidates[0].currency || null; + } + } else { + resolvedTicker = results.quotes[0].symbol; + resolvedCurrency = results.quotes[0].currency || null; + } + } + } + } catch (error) { + console.warn(`Yahoo resolve error for ${symbol}:`, error); + } + + return { ticker: resolvedTicker, currency: resolvedCurrency }; + } +} diff --git a/tests/enricher.test.ts b/tests/enricher.test.ts index db67b15..b50374a 100644 --- a/tests/enricher.test.ts +++ b/tests/enricher.test.ts @@ -1,47 +1,67 @@ import { describe, it, expect, vi } from 'vitest'; -import { enrichTransactions } from '../src/enricher'; +import { enrichTransactions, MemoryTickerCache } from '../src/enricher'; import { ParsedTransaction } from '../src/parsers/types'; describe('Enricher', () => { - it('should enrich transactions with tickers using resolver', async () => { - const transactions=[ - { symbol: 'Apple', isin: 'US0001', ticker: undefined } as ParsedTransaction, - { symbol: 'Microsoft', isin: 'US0002', ticker: undefined } as ParsedTransaction, - ]; - - const resolver=vi.fn().mockImplementation(async (isin, name) => { - if (isin==='US0001') return 'AAPL'; - if (name==='Microsoft') return 'MSFT'; - return null; - }); - - const enriched=await enrichTransactions(transactions, resolver); - - expect(enriched[0].ticker).toBe('AAPL'); - expect(enriched[1].ticker).toBe('MSFT'); - expect(resolver).toHaveBeenCalledTimes(2); - }); - - it('should use cache for repeated ISINs', async () => { - const transactions=[ - { symbol: 'Apple', isin: 'US0001' } as ParsedTransaction, - { symbol: 'Apple', isin: 'US0001' } as ParsedTransaction, - ]; - - const resolver=vi.fn().mockResolvedValue('AAPL'); - await enrichTransactions(transactions, resolver); - - expect(resolver).toHaveBeenCalledTimes(1); - }); - - it('should skip transactions that already have tickers', async () => { - const transactions=[ - { symbol: 'Apple', isin: 'US0001', ticker: 'EXISTING' } as ParsedTransaction, - ]; - const resolver=vi.fn(); - const enriched=await enrichTransactions(transactions, resolver); - - expect(enriched[0].ticker).toBe('EXISTING'); - expect(resolver).not.toHaveBeenCalled(); - }); + it('should enrich transactions with tickers using resolver', async () => { + const transactions = [ + { + symbol: 'Apple', + isin: 'US0001', + ticker: undefined, + } as ParsedTransaction, + { + symbol: 'Microsoft', + isin: 'US0002', + ticker: undefined, + } as ParsedTransaction, + ]; + + const resolver = { + resolve: vi.fn().mockImplementation(async (isin, name) => { + if (isin === 'US0001') return { ticker: 'AAPL' }; + if (name === 'Microsoft') return { ticker: 'MSFT' }; + return { ticker: null }; + }), + }; + + const enriched = await enrichTransactions(transactions, { resolver }); + + expect(enriched[0].ticker).toBe('AAPL'); + expect(enriched[1].ticker).toBe('MSFT'); + expect(resolver.resolve).toHaveBeenCalledTimes(2); + }); + + it('should use cache for repeated symbols', async () => { + const transactions = [ + { symbol: 'Apple', isin: 'US0001' } as ParsedTransaction, + { symbol: 'Apple', isin: 'US0001' } as ParsedTransaction, + ]; + + const resolver = { + resolve: vi.fn().mockResolvedValue({ ticker: 'AAPL' }), + }; + const cache = new MemoryTickerCache(); + + await enrichTransactions(transactions, { resolver, cache }); + + expect(resolver.resolve).toHaveBeenCalledTimes(1); + }); + + it('should skip transactions that already have tickers', async () => { + const transactions = [ + { + symbol: 'Apple', + isin: 'US0001', + ticker: 'EXISTING', + } as ParsedTransaction, + ]; + const resolver = { + resolve: vi.fn(), + }; + const enriched = await enrichTransactions(transactions, { resolver }); + + expect(enriched[0].ticker).toBe('EXISTING'); + expect(resolver.resolve).not.toHaveBeenCalled(); + }); }); diff --git a/tests/exporters/yahoo.test.ts b/tests/exporters/yahoo.test.ts index bb23572..92a10f4 100644 --- a/tests/exporters/yahoo.test.ts +++ b/tests/exporters/yahoo.test.ts @@ -3,60 +3,64 @@ import { YahooFinanceExporter } from '../../src/exporters/yahoo'; import { ParsedTransaction } from '../../src/parsers/types'; describe('YahooFinanceExporter', () => { - it('should export transactions to CSV', () => { - const transactions: ParsedTransaction[]=[ - { - date: new Date('2023-01-01'), - type: 'BUY', - symbol: 'Apple Inc', - ticker: 'AAPL', - quantity: 10, - price: 150.0, - total: 1500, - currency: 'USD', - fee: 5.0, - originalSource: 'TestBroker', - } as ParsedTransaction, - { - date: new Date('2023-02-01'), - type: 'SELL', - symbol: 'Apple Inc', - ticker: 'AAPL', - quantity: 5, - price: 160.0, - total: 800, - currency: 'USD', - fee: 5.0, - originalSource: 'TestBroker', - } as ParsedTransaction - ]; + it('should export transactions to CSV', () => { + const transactions: ParsedTransaction[] = [ + { + date: new Date('2023-01-01'), + type: 'BUY', + symbol: 'Apple Inc', + ticker: 'AAPL', + quantity: 10, + price: 150.0, + total: 1500, + currency: 'USD', + fee: 5.0, + originalSource: 'TestBroker', + } as ParsedTransaction, + { + date: new Date('2023-02-01'), + type: 'SELL', + symbol: 'Apple Inc', + ticker: 'AAPL', + quantity: 5, + price: 160.0, + total: 800, + currency: 'USD', + fee: 5.0, + originalSource: 'TestBroker', + } as ParsedTransaction, + ]; - const result=YahooFinanceExporter.export(transactions); + const result = YahooFinanceExporter.export(transactions); - expect(result.filename).toBe('yahoo_finance_import.csv'); - expect(result.mimeType).toBe('text/csv'); + expect(result.filename).toBe('yahoo_finance_import.csv'); + expect(result.mimeType).toBe('text/csv'); - const lines=result.content.split('\n'); - expect(lines.length).toBe(3); // Header + 2 rows + const lines = result.content.split('\n'); + expect(lines.length).toBe(3); // Header + 2 rows - expect(lines[0]).toBe('Symbol,Trade Date,Purchase Price,Quantity,Commission,Comment'); - expect(lines[1]).toContain('AAPL'); - expect(lines[1]).toContain('10'); - expect(lines[2]).toContain('-5'); - }); + expect(lines[0]).toBe( + 'Symbol,Trade Date,Purchase Price,Quantity,Commission,Comment' + ); + expect(lines[1]).toContain('AAPL'); + expect(lines[1]).toContain('10'); + expect(lines[2]).toContain('-5'); + }); - it('should fallback to symbol if ticker is missing', () => { - const t=[{ - date: new Date('2023-01-01'), - type: 'BUY', - symbol: 'Unknown Stock', - quantity: 1, - price: 100, - fee: 0, - originalSource: 'Test' - }] as ParsedTransaction[]; + it('should fallback to symbol if ticker is missing', () => { + const t = [ + { + date: new Date('2023-01-01'), + type: 'BUY', + symbol: 'Unknown Stock', + quantity: 1, + price: 100, + fee: 0, + originalSource: 'Test', + }, + ] as ParsedTransaction[]; - const result=YahooFinanceExporter.export(t); - expect(result.content).toContain('Unknown Stock'); - }); + const result = YahooFinanceExporter.export(t); + expect(result.content).toContain('Unknown Stock'); + }); }); diff --git a/tests/file_resolver.test.ts b/tests/file_resolver.test.ts new file mode 100644 index 0000000..711e8fd --- /dev/null +++ b/tests/file_resolver.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { FileTickerResolver } from '../src/resolvers/file'; + +describe('FileTickerResolver', () => { + const tempJson = path.join(__dirname, 'temp_tickers.json'); + const tempCsv = path.join(__dirname, 'temp_tickers.csv'); + + it('should resolve tickers from a JSON file', async () => { + fs.writeFileSync( + tempJson, + JSON.stringify({ + US0378331005: 'AAPL', + Microsoft: 'MSFT', + }) + ); + + const resolver = new FileTickerResolver(tempJson); + + expect((await resolver.resolve('US0378331005', 'Apple')).ticker).toBe( + 'AAPL' + ); + expect((await resolver.resolve('', 'Microsoft')).ticker).toBe('MSFT'); + expect((await resolver.resolve('UNKNOWN', 'Unknown')).ticker).toBe(null); + + fs.unlinkSync(tempJson); + }); + + it('should resolve tickers from a CSV file', async () => { + fs.writeFileSync( + tempCsv, + 'isin,symbol,ticker\nUS0378331005,Apple,AAPL\n,Microsoft,MSFT' + ); + + const resolver = new FileTickerResolver(tempCsv); + + expect((await resolver.resolve('US0378331005', 'Apple')).ticker).toBe( + 'AAPL' + ); + expect((await resolver.resolve('', 'Microsoft')).ticker).toBe('MSFT'); + + fs.unlinkSync(tempCsv); + }); +}); diff --git a/tests/integration.test.ts b/tests/integration.test.ts index ffd3e5d..fa36c04 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -5,41 +5,49 @@ import Papa from 'papaparse'; import { parseTransaction } from '../src/index'; describe('Integration Tests with Mocks', () => { - const mocksDir=path.resolve(__dirname, 'mocks'); - - it('should parse the Avanza mock CSV correctly', () => { - const csvData=fs.readFileSync(path.join(mocksDir, 'avanza.csv'), 'utf8'); - const parsed=Papa.parse(csvData, { header: true, skipEmptyLines: true }); - - const transactions=(parsed.data as Record[]) - .map(row => parseTransaction(row, 'Avanza')) - .filter(t => t!==null); - - expect(transactions.length).toBeGreaterThan(0); - expect(transactions[0]?.originalSource).toBe('Avanza'); - expect(transactions[0]?.symbol).toBe('Meta Platforms A'); - }); - - it('should parse the Nordnet mock CSV correctly', () => { - const csvData=fs.readFileSync(path.join(mocksDir, 'nordnet.csv'), 'utf8'); - const parsed=Papa.parse(csvData, { header: true, skipEmptyLines: true }); - - const transactions=(parsed.data as Record[]) - .map(row => parseTransaction(row, 'Nordnet')) - .filter(t => t!==null); - - expect(transactions.length).toBeGreaterThan(0); - expect(transactions[0]?.originalSource).toBe('Nordnet'); - expect(transactions[0]?.symbol).toBe('Netflix'); - }); - - it('should auto-detect formats from mock files', () => { - const avanzaCsv=fs.readFileSync(path.join(mocksDir, 'avanza.csv'), 'utf8'); - const avanzaRow=Papa.parse(avanzaCsv, { header: true }).data[0] as any; - expect(parseTransaction(avanzaRow, 'Auto')?.originalSource).toBe('Avanza'); - - const nordnetCsv=fs.readFileSync(path.join(mocksDir, 'nordnet.csv'), 'utf8'); - const nordnetRow=Papa.parse(nordnetCsv, { header: true }).data[0] as any; - expect(parseTransaction(nordnetRow, 'Auto')?.originalSource).toBe('Nordnet'); - }); + const mocksDir = path.resolve(__dirname, 'mocks'); + + it('should parse the Avanza mock CSV correctly', () => { + const csvData = fs.readFileSync(path.join(mocksDir, 'avanza.csv'), 'utf8'); + const parsed = Papa.parse(csvData, { header: true, skipEmptyLines: true }); + + const transactions = (parsed.data as Record[]) + .map((row) => parseTransaction(row, 'Avanza')) + .filter((t) => t !== null); + + expect(transactions.length).toBeGreaterThan(0); + expect(transactions[0]?.originalSource).toBe('Avanza'); + expect(transactions[0]?.symbol).toBe('Meta Platforms A'); + }); + + it('should parse the Nordnet mock CSV correctly', () => { + const csvData = fs.readFileSync(path.join(mocksDir, 'nordnet.csv'), 'utf8'); + const parsed = Papa.parse(csvData, { header: true, skipEmptyLines: true }); + + const transactions = (parsed.data as Record[]) + .map((row) => parseTransaction(row, 'Nordnet')) + .filter((t) => t !== null); + + expect(transactions.length).toBeGreaterThan(0); + expect(transactions[0]?.originalSource).toBe('Nordnet'); + expect(transactions[0]?.symbol).toBe('Netflix'); + }); + + it('should auto-detect formats from mock files', () => { + const avanzaCsv = fs.readFileSync( + path.join(mocksDir, 'avanza.csv'), + 'utf8' + ); + const avanzaRow = Papa.parse(avanzaCsv, { header: true }).data[0] as any; + expect(parseTransaction(avanzaRow, 'Auto')?.originalSource).toBe('Avanza'); + + const nordnetCsv = fs.readFileSync( + path.join(mocksDir, 'nordnet.csv'), + 'utf8' + ); + const nordnetRow = Papa.parse(nordnetCsv, { header: true }).data[0] as any; + expect(parseTransaction(nordnetRow, 'Auto')?.originalSource).toBe( + 'Nordnet' + ); + }); }); diff --git a/tests/parser_brokers.test.ts b/tests/parser_brokers.test.ts index c71dd8b..a474b2f 100644 --- a/tests/parser_brokers.test.ts +++ b/tests/parser_brokers.test.ts @@ -2,247 +2,247 @@ import { describe, it, expect } from 'vitest'; import { parseTransaction, getParsers, identifyAccounts } from '../src/index'; describe('Broker Parsers', () => { - describe('Avanza Parser', () => { - it('should parse a buy transaction', () => { - const row={ - Datum: '2025-12-26', - Konto: 'Pension', - 'Typ av transaktion': 'Köp', - 'Värdepapper/beskrivning': 'Meta Platforms A', - Antal: '1', - Kurs: '666,89', - Belopp: '-6129,85', - Transaktionsvaluta: 'SEK', - Courtage: '15,31', - Valutakurs: '9,168745', - Instrumentvaluta: 'USD', - ISIN: 'US30303M1027', - }; - - const result=parseTransaction(row, 'Avanza'); - - expect(result).not.toBeNull(); - expect(result?.symbol).toBe('Meta Platforms A'); - expect(result?.type).toBe('BUY'); - expect(result?.quantity).toBe(1); - expect(result?.price).toBe(666.89); - expect(result?.total).toBe(-6129.85); - expect(result?.fee).toBe(15.31); - expect(result?.currency).toBe('SEK'); // Account currency - expect(result?.nativeCurrency).toBe('USD'); - expect(result?.exchangeRate).toBe(9.168745); - }); - - it('should parse a dividend', () => { - const row={ - Datum: '2025-12-29', - 'Typ av transaktion': 'Utdelning', - 'Värdepapper/beskrivning': 'Meta Platforms A', - Antal: '10', - Kurs: '0,525', - Belopp: '48,1', - Transaktionsvaluta: 'SEK', - Instrumentvaluta: 'USD', - }; - - const result=parseTransaction(row, 'Avanza'); - expect(result?.type).toBe('DIVIDEND'); - expect(result?.total).toBe(48.1); - }); - - it('should parse a sell transaction with negative quantity', () => { - const row={ - Datum: '2025-12-30', - 'Typ av transaktion': 'Sälj', - 'Värdepapper/beskrivning': 'Meta Platforms A', - Antal: '-5', - Kurs: '700', - Belopp: '35000', - Transaktionsvaluta: 'SEK', - Courtage: '15', - Valutakurs: '10', - }; - - const result=parseTransaction(row, 'Avanza'); - expect(result?.type).toBe('SELL'); - expect(result?.quantity).toBe(5); // Absolute value - }); - - it('should parse a "Byte" transaction correctly', () => { - const rowIn={ - Datum: '2023-03-10', - Konto: 'Pension', - 'Typ av transaktion': 'Byte', - 'Värdepapper/beskrivning': 'DNB Fund Technology A SEK Acc', - Antal: '0,1649', - ISIN: 'LU2553959045', - }; - - const rowOut={ - Datum: '2023-03-10', - Konto: 'Pension', - 'Typ av transaktion': 'Byte', - 'Värdepapper/beskrivning': 'DNB TECHNOLOGY', - Antal: '-0,1649', // Negative means OUT - ISIN: 'LU0302296495', - }; - - const resultIn=parseTransaction(rowIn, 'Avanza'); - const resultOut=parseTransaction(rowOut, 'Avanza'); - - // "Byte" In (positive quantity) should act as BUY - expect(resultIn?.type).toBe('BUY'); - expect(resultIn?.quantity).toBe(0.1649); - - // "Byte" Out (negative quantity) should act as SELL - expect(resultOut?.type).toBe('SELL'); - expect(resultOut?.quantity).toBe(0.1649); - }); + describe('Avanza Parser', () => { + it('should parse a buy transaction', () => { + const row = { + Datum: '2025-12-26', + Konto: 'Pension', + 'Typ av transaktion': 'Köp', + 'Värdepapper/beskrivning': 'Meta Platforms A', + Antal: '1', + Kurs: '666,89', + Belopp: '-6129,85', + Transaktionsvaluta: 'SEK', + Courtage: '15,31', + Valutakurs: '9,168745', + Instrumentvaluta: 'USD', + ISIN: 'US30303M1027', + }; + + const result = parseTransaction(row, 'Avanza'); + + expect(result).not.toBeNull(); + expect(result?.symbol).toBe('Meta Platforms A'); + expect(result?.type).toBe('BUY'); + expect(result?.quantity).toBe(1); + expect(result?.price).toBe(666.89); + expect(result?.total).toBe(-6129.85); + expect(result?.fee).toBe(15.31); + expect(result?.currency).toBe('SEK'); // Account currency + expect(result?.nativeCurrency).toBe('USD'); + expect(result?.exchangeRate).toBe(9.168745); }); - describe('Avanza Legacy Parser', () => { - it('should parse legacy format with Värdepapper header', () => { - const row={ - Datum: '2023-01-01', - 'Typ av transaktion': 'Köp', - 'Värdepapper': 'Legacy Stock', // Old header - Antal: '1', - text: 'Some text', - }; - const result=parseTransaction(row, 'Avanza'); - expect(result?.symbol).toBe('Legacy Stock'); - }); + it('should parse a dividend', () => { + const row = { + Datum: '2025-12-29', + 'Typ av transaktion': 'Utdelning', + 'Värdepapper/beskrivning': 'Meta Platforms A', + Antal: '10', + Kurs: '0,525', + Belopp: '48,1', + Transaktionsvaluta: 'SEK', + Instrumentvaluta: 'USD', + }; + + const result = parseTransaction(row, 'Avanza'); + expect(result?.type).toBe('DIVIDEND'); + expect(result?.total).toBe(48.1); }); - describe('Nordnet Parser', () => { - // ... existing Nordnet tests ... - // Add legacy too inside the describe block - it('should parse legacy format with Värdepapper/Courtage', () => { - const row={ - Bokföringsdag: '2023-01-01', - Transaktionstyp: 'KÖPT', - Värdepapper: 'Legacy Nordnet', // Old header - Courtage: '10', // Instead of Total Avgift - Transaktionsdag: '2023-01-01', // Explicit date - }; - const result=parseTransaction(row, 'Nordnet'); - expect(result?.symbol).toBe('Legacy Nordnet'); - expect(result?.fee).toBe(10); - }); - - // Mocking the row structure based on my analysis of Nordnet CSV - // Note: PapaParse handles duplicate headers by appending _1, _2 etc. - // I need to simulate what PapaParse produces. - // ... Total Avgift | Valuta | Belopp | Valuta | Inköpsvärde | Valuta ... - // -> 'Total Avgift', 'Valuta', 'Belopp', 'Valuta_1', 'Inköpsvärde', 'Valuta_2' - - it('should parse a buy transaction', () => { - const row={ - Bokföringsdag: '2025-12-04', - Transaktionstyp: 'KÖPT', - Instrument: 'Netflix', - Antal: '50', - Kurs: '102,98', // USD price - 'Total Avgift': '121,43', - Valuta: 'SEK', // Fee currency - Belopp: '-48694,8', - Valuta_1: 'SEK', // Account currency (Belopp currency) - Inköpsvärde: '5161,87', - Valuta_2: 'USD', // Native currency - Växlingskurs: '9,4335', - ISIN: 'US64110L1061', - }; - - const result=parseTransaction(row, 'Nordnet'); - - expect(result).not.toBeNull(); - expect(result?.symbol).toBe('Netflix'); - expect(result?.type).toBe('BUY'); - expect(result?.quantity).toBe(50); - expect(result?.price).toBe(102.98); // Native price - expect(result?.total).toBe(-48694.8); // Total in account currency - expect(result?.currency).toBe('SEK'); // Account currency - expect(result?.nativeCurrency).toBe('USD'); - expect(result?.exchangeRate).toBe(9.4335); - }); - - it('should parse a sell transaction with negative quantity', () => { - const row={ - Bokföringsdag: '2025-12-05', - Transaktionstyp: 'SÅLT', - Instrument: 'Netflix', - Antal: '-10', - Kurs: '105', - Belopp: '10000', - Valuta: 'SEK', - }; - - const result=parseTransaction(row, 'Nordnet'); - expect(result?.type).toBe('SELL'); - expect(result?.quantity).toBe(10); // Absolute value - }); + it('should parse a sell transaction with negative quantity', () => { + const row = { + Datum: '2025-12-30', + 'Typ av transaktion': 'Sälj', + 'Värdepapper/beskrivning': 'Meta Platforms A', + Antal: '-5', + Kurs: '700', + Belopp: '35000', + Transaktionsvaluta: 'SEK', + Courtage: '15', + Valutakurs: '10', + }; + + const result = parseTransaction(row, 'Avanza'); + expect(result?.type).toBe('SELL'); + expect(result?.quantity).toBe(5); // Absolute value }); - describe('Auto Detection', () => { - it('should detect Avanza', () => { - const row={ - 'Typ av transaktion': 'Köp', - 'Värdepapper/beskrivning': 'Test', - Datum: '2023-01-01', - }; - const result=parseTransaction(row, 'Auto'); - expect(result?.originalSource).toBe('Avanza'); - }); - - it('should detect Nordnet', () => { - const row={ - Transaktionstyp: 'KÖPT', - Instrument: 'Test', - Bokföringsdag: '2023-01-01', - }; - const result=parseTransaction(row, 'Auto'); - expect(result?.originalSource).toBe('Nordnet'); - }); - - it('should return null for unknown formats', () => { - const row={ Unknown: 'Field' }; - const result=parseTransaction(row, 'Auto'); - expect(result).toBeNull(); - }); + it('should parse a "Byte" transaction correctly', () => { + const rowIn = { + Datum: '2023-03-10', + Konto: 'Pension', + 'Typ av transaktion': 'Byte', + 'Värdepapper/beskrivning': 'DNB Fund Technology A SEK Acc', + Antal: '0,1649', + ISIN: 'LU2553959045', + }; + + const rowOut = { + Datum: '2023-03-10', + Konto: 'Pension', + 'Typ av transaktion': 'Byte', + 'Värdepapper/beskrivning': 'DNB TECHNOLOGY', + Antal: '-0,1649', // Negative means OUT + ISIN: 'LU0302296495', + }; + + const resultIn = parseTransaction(rowIn, 'Avanza'); + const resultOut = parseTransaction(rowOut, 'Avanza'); + + // "Byte" In (positive quantity) should act as BUY + expect(resultIn?.type).toBe('BUY'); + expect(resultIn?.quantity).toBe(0.1649); + + // "Byte" Out (negative quantity) should act as SELL + expect(resultOut?.type).toBe('SELL'); + expect(resultOut?.quantity).toBe(0.1649); + }); + }); + + describe('Avanza Legacy Parser', () => { + it('should parse legacy format with Värdepapper header', () => { + const row = { + Datum: '2023-01-01', + 'Typ av transaktion': 'Köp', + Värdepapper: 'Legacy Stock', // Old header + Antal: '1', + text: 'Some text', + }; + const result = parseTransaction(row, 'Avanza'); + expect(result?.symbol).toBe('Legacy Stock'); + }); + }); + + describe('Nordnet Parser', () => { + // ... existing Nordnet tests ... + // Add legacy too inside the describe block + it('should parse legacy format with Värdepapper/Courtage', () => { + const row = { + Bokföringsdag: '2023-01-01', + Transaktionstyp: 'KÖPT', + Värdepapper: 'Legacy Nordnet', // Old header + Courtage: '10', // Instead of Total Avgift + Transaktionsdag: '2023-01-01', // Explicit date + }; + const result = parseTransaction(row, 'Nordnet'); + expect(result?.symbol).toBe('Legacy Nordnet'); + expect(result?.fee).toBe(10); + }); + + // Mocking the row structure based on my analysis of Nordnet CSV + // Note: PapaParse handles duplicate headers by appending _1, _2 etc. + // I need to simulate what PapaParse produces. + // ... Total Avgift | Valuta | Belopp | Valuta | Inköpsvärde | Valuta ... + // -> 'Total Avgift', 'Valuta', 'Belopp', 'Valuta_1', 'Inköpsvärde', 'Valuta_2' + + it('should parse a buy transaction', () => { + const row = { + Bokföringsdag: '2025-12-04', + Transaktionstyp: 'KÖPT', + Instrument: 'Netflix', + Antal: '50', + Kurs: '102,98', // USD price + 'Total Avgift': '121,43', + Valuta: 'SEK', // Fee currency + Belopp: '-48694,8', + Valuta_1: 'SEK', // Account currency (Belopp currency) + Inköpsvärde: '5161,87', + Valuta_2: 'USD', // Native currency + Växlingskurs: '9,4335', + ISIN: 'US64110L1061', + }; + + const result = parseTransaction(row, 'Nordnet'); + + expect(result).not.toBeNull(); + expect(result?.symbol).toBe('Netflix'); + expect(result?.type).toBe('BUY'); + expect(result?.quantity).toBe(50); + expect(result?.price).toBe(102.98); // Native price + expect(result?.total).toBe(-48694.8); // Total in account currency + expect(result?.currency).toBe('SEK'); // Account currency + expect(result?.nativeCurrency).toBe('USD'); + expect(result?.exchangeRate).toBe(9.4335); + }); + + it('should parse a sell transaction with negative quantity', () => { + const row = { + Bokföringsdag: '2025-12-05', + Transaktionstyp: 'SÅLT', + Instrument: 'Netflix', + Antal: '-10', + Kurs: '105', + Belopp: '10000', + Valuta: 'SEK', + }; + + const result = parseTransaction(row, 'Nordnet'); + expect(result?.type).toBe('SELL'); + expect(result?.quantity).toBe(10); // Absolute value + }); + }); + + describe('Auto Detection', () => { + it('should detect Avanza', () => { + const row = { + 'Typ av transaktion': 'Köp', + 'Värdepapper/beskrivning': 'Test', + Datum: '2023-01-01', + }; + const result = parseTransaction(row, 'Auto'); + expect(result?.originalSource).toBe('Avanza'); + }); + + it('should detect Nordnet', () => { + const row = { + Transaktionstyp: 'KÖPT', + Instrument: 'Test', + Bokföringsdag: '2023-01-01', + }; + const result = parseTransaction(row, 'Auto'); + expect(result?.originalSource).toBe('Nordnet'); + }); + + it('should return null for unknown formats', () => { + const row = { Unknown: 'Field' }; + const result = parseTransaction(row, 'Auto'); + expect(result).toBeNull(); + }); + }); + + describe('Parser Utils', () => { + it('should return all parsers', () => { + const parsers = getParsers(); + expect(parsers).toHaveProperty('Avanza'); + expect(parsers).toHaveProperty('Nordnet'); }); - describe('Parser Utils', () => { - it('should return all parsers', () => { - const parsers=getParsers(); - expect(parsers).toHaveProperty('Avanza'); - expect(parsers).toHaveProperty('Nordnet'); - }); - - it('should identify accounts', () => { - const data=[ - { - 'Typ av transaktion': 'Köp', - 'Värdepapper/beskrivning': 'Test', - Datum: '2023-01-01', - Konto: 'Account1', - }, - { - 'Typ av transaktion': 'Köp', - 'Värdepapper/beskrivning': 'Test', - Datum: '2023-01-02', - Konto: 'Account1', - }, - { - 'Typ av transaktion': 'Köp', - 'Värdepapper/beskrivning': 'Test', - Datum: '2023-01-03', - Konto: 'Account2', - }, - ]; - const accounts=identifyAccounts(data); - expect(accounts).toHaveLength(2); - expect(accounts.find((a) => a.id==='Account1')?.count).toBe(2); - expect(accounts.find((a) => a.id==='Account2')?.count).toBe(1); - }); + it('should identify accounts', () => { + const data = [ + { + 'Typ av transaktion': 'Köp', + 'Värdepapper/beskrivning': 'Test', + Datum: '2023-01-01', + Konto: 'Account1', + }, + { + 'Typ av transaktion': 'Köp', + 'Värdepapper/beskrivning': 'Test', + Datum: '2023-01-02', + Konto: 'Account1', + }, + { + 'Typ av transaktion': 'Köp', + 'Värdepapper/beskrivning': 'Test', + Datum: '2023-01-03', + Konto: 'Account2', + }, + ]; + const accounts = identifyAccounts(data); + expect(accounts).toHaveLength(2); + expect(accounts.find((a) => a.id === 'Account1')?.count).toBe(2); + expect(accounts.find((a) => a.id === 'Account2')?.count).toBe(1); }); + }); }); diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 2bc4373..8b7eeed 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -2,68 +2,68 @@ import { describe, it, expect } from 'vitest'; import { parseNumber, normalizeType } from '../src/index'; describe('Parser Utils', () => { - describe('parseNumber', () => { - it('should handle numbers', () => { - expect(parseNumber(123.45)).toBe(123.45); - }); + describe('parseNumber', () => { + it('should handle numbers', () => { + expect(parseNumber(123.45)).toBe(123.45); + }); - it('should handle strings with Swedish formatting', () => { - expect(parseNumber('1 234,50')).toBe(1234.5); - expect(parseNumber('10,5')).toBe(10.5); - }); + it('should handle strings with Swedish formatting', () => { + expect(parseNumber('1 234,50')).toBe(1234.5); + expect(parseNumber('10,5')).toBe(10.5); + }); - it('should handle empty/null values', () => { - expect(parseNumber(null)).toBe(0); - expect(parseNumber(undefined)).toBe(0); - expect(parseNumber('')).toBe(0); - }); + it('should handle empty/null values', () => { + expect(parseNumber(null)).toBe(0); + expect(parseNumber(undefined)).toBe(0); + expect(parseNumber('')).toBe(0); }); + }); - describe('normalizeType', () => { - it('should normalize buy types', () => { - expect(normalizeType('KÖP')).toBe('BUY'); - expect(normalizeType('KÖPT')).toBe('BUY'); - expect(normalizeType('buy')).toBe('BUY'); - }); + describe('normalizeType', () => { + it('should normalize buy types', () => { + expect(normalizeType('KÖP')).toBe('BUY'); + expect(normalizeType('KÖPT')).toBe('BUY'); + expect(normalizeType('buy')).toBe('BUY'); + }); - it('should normalize sell types', () => { - expect(normalizeType('SÄLJ')).toBe('SELL'); - expect(normalizeType('SÅLT')).toBe('SELL'); - expect(normalizeType('Sell')).toBe('SELL'); - }); + it('should normalize sell types', () => { + expect(normalizeType('SÄLJ')).toBe('SELL'); + expect(normalizeType('SÅLT')).toBe('SELL'); + expect(normalizeType('Sell')).toBe('SELL'); + }); - it('should normalize dividend types', () => { - expect(normalizeType('UTDELNING')).toBe('DIVIDEND'); - expect(normalizeType('Dividend')).toBe('DIVIDEND'); - }); + it('should normalize dividend types', () => { + expect(normalizeType('UTDELNING')).toBe('DIVIDEND'); + expect(normalizeType('Dividend')).toBe('DIVIDEND'); + }); - it('should normalize deposit types', () => { - expect(normalizeType('INSÄTTNING')).toBe('DEPOSIT'); - expect(normalizeType('DEPOSIT')).toBe('DEPOSIT'); - expect(normalizeType('INS. KREDIT')).toBe('DEPOSIT'); - expect(normalizeType('REALTIDSINSÄTTNING')).toBe('DEPOSIT'); - }); + it('should normalize deposit types', () => { + expect(normalizeType('INSÄTTNING')).toBe('DEPOSIT'); + expect(normalizeType('DEPOSIT')).toBe('DEPOSIT'); + expect(normalizeType('INS. KREDIT')).toBe('DEPOSIT'); + expect(normalizeType('REALTIDSINSÄTTNING')).toBe('DEPOSIT'); + }); - it('should normalize withdraw types', () => { - expect(normalizeType('UTTAG')).toBe('WITHDRAW'); - expect(normalizeType('Withdraw')).toBe('WITHDRAW'); - }); + it('should normalize withdraw types', () => { + expect(normalizeType('UTTAG')).toBe('WITHDRAW'); + expect(normalizeType('Withdraw')).toBe('WITHDRAW'); + }); - it('should normalize interest types', () => { - expect(normalizeType('RÄNTA')).toBe('INTEREST'); - expect(normalizeType('Interest')).toBe('INTEREST'); - expect(normalizeType('AVKASTNINGSSKATT')).toBe('INTEREST'); - }); + it('should normalize interest types', () => { + expect(normalizeType('RÄNTA')).toBe('INTEREST'); + expect(normalizeType('Interest')).toBe('INTEREST'); + expect(normalizeType('AVKASTNINGSSKATT')).toBe('INTEREST'); + }); - it('should normalize tax types', () => { - expect(normalizeType('SKATT')).toBe('TAX'); - expect(normalizeType('Tax')).toBe('TAX'); - }); + it('should normalize tax types', () => { + expect(normalizeType('SKATT')).toBe('TAX'); + expect(normalizeType('Tax')).toBe('TAX'); + }); - it('should handle unknown types', () => { - expect(normalizeType('UNKNOWN')).toBe('OTHER'); - expect(normalizeType('')).toBe('OTHER'); - expect(normalizeType(null as unknown as string)).toBe('OTHER'); - }); + it('should handle unknown types', () => { + expect(normalizeType('UNKNOWN')).toBe('OTHER'); + expect(normalizeType('')).toBe('OTHER'); + expect(normalizeType(null as unknown as string)).toBe('OTHER'); }); + }); }); diff --git a/vitest.config.ts b/vitest.config.ts index 383c38b..3b38bda 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,17 +1,17 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ - test: { - globals: true, - environment: 'node', - include: ['tests/**/*.test.ts'], - coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html', 'lcov'], - }, - reporters: ['default', 'junit'], - outputFile: { - junit: './junit.xml', - }, + test: { + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], }, + reporters: ['default', 'junit'], + outputFile: { + junit: './junit.xml', + }, + }, }); From 630a9fb556c60eba35a78f5db510bac6a35dd874 Mon Sep 17 00:00:00 2001 From: Abhishek Jain Date: Sun, 11 Jan 2026 17:36:13 +0100 Subject: [PATCH 06/13] fix: Fix the ticker resolver --- README.md | 55 +++++----- src/cli.ts | 74 ++++++++----- src/enricher.ts | 46 +++++--- src/resolvers/file.ts | 1 + src/resolvers/yahoo.ts | 233 ++++++++++++++++++++++++++++++----------- 5 files changed, 276 insertions(+), 133 deletions(-) diff --git a/README.md b/README.md index 73e1a0a..ce1fd9e 100644 --- a/README.md +++ b/README.md @@ -39,59 +39,60 @@ pnpm add @logkat/broker-parser ### CLI Usage -You can also use the library directly from your terminal to convert broker CSVs to other formats (e.g. Yahoo Finance) and automatically resolve tickers. +The library provides a powerful CLI for bulk processing and ticker resolution. ```bash -# Basic export +# Basic export (defaults to Yahoo resolution) broker-parser export input.csv -o output.csv -# Export with automatic Yahoo Finance ticker resolution and caching -broker-parser export input.csv --yahoo --cache .tickers.json +# Specific resolvers (stacked in order) +broker-parser export input.csv --ticker-file custom.json --yahoo -# Use a local mapping file for tickers (supports JSON and CSV) -broker-parser export input.csv --ticker-file my-tickers.json +# Control over resolution strategies +broker-parser export input.csv --yahoo-isin --yahoo-name ``` +| Flag | Description | +| ---------------- | -------------------------------------------------------- | +| `--yahoo` | Use both ISIN and Name search (Default: true) | +| `--yahoo-isin` | Trigger only ISIN-based search | +| `--yahoo-name` | Trigger only Name-based search (fuzzy matching) | +| `--ticker-file` | Use a local JSON/CSV mapping file (priority) | +| `--no-yahoo` | Disable all automatic lookups | +| `--cache ` | Path to resolution cache (Default: `.ticker-cache.json`) | + ## Usage ### Library Usage (TypeScript) -### Parsing a Single Transaction +### Parsing transactions ```typescript import { parseTransaction } from '@logkat/broker-parser'; -const row = { - 'Typ av transaktion': 'Köp', - 'Värdepapper/beskrivning': 'Apple Inc', - Antal: '10', - Kurs: '150', - Belopp: '-1500', - Transaktionsvaluta: 'USD', -}; - const transaction = parseTransaction(row); ``` -### Automatic Ticker Resolution +### Advanced Ticker Resolution -The library provides built-in resolvers for Yahoo Finance and local files. +The library and CLI support "stacked" resolvers. You can prioritize local data and then fall back to various cloud providers. ```typescript import { enrichTransactions, - YahooTickerResolver, + YahooISINResolver, + YahooNameResolver, + FileTickerResolver, LocalFileTickerCache, } from '@logkat/broker-parser'; -// 1. Setup resolver and optional persistent cache -const resolver = new YahooTickerResolver(); -const cache = new LocalFileTickerCache('./ticker-cache.json'); - -// 2. Enrich your parsed transactions -const enriched = await enrichTransactions(parsedTransactions, { - resolver, - cache, +const enriched = await enrichTransactions(transactions, { + resolvers: [ + new FileTickerResolver('./manual-mapping.json'), + new YahooISINResolver(), + new YahooNameResolver(), + ], + cache: new LocalFileTickerCache('./cache.json'), }); ``` diff --git a/src/cli.ts b/src/cli.ts index 8f4d354..8780136 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,7 +7,8 @@ import { parseTransaction, YahooFinanceExporter, enrichTransactions, - YahooTickerResolver, + YahooISINResolver, + YahooNameResolver, FileTickerResolver, LocalFileTickerCache, } from './index'; @@ -31,7 +32,13 @@ program ) .option('-e, --exporter ', 'Exporter to use (yahoo)', 'yahoo') .option('-o, --output ', 'Output file path') - .option('--yahoo', 'Use Yahoo Finance for ticker resolution', false) + .option( + '--yahoo', + 'Use Yahoo Finance (ISIN + Name) for ticker resolution (default)', + true + ) + .option('--yahoo-isin', 'Use Yahoo Finance ISIN search', false) + .option('--yahoo-name', 'Use Yahoo Finance name search', false) .option( '--ticker-file ', 'Path to a JSON or CSV file for ticker resolution' @@ -41,6 +48,7 @@ program 'Path to a local JSON file for caching ticker resolutions', '.ticker-cache.json' ) + .option('--no-yahoo', 'Disable automatic Yahoo Finance resolution') .option('--no-cache', 'Disable caching') .action(async (file, options) => { const filePath = path.resolve(file); @@ -74,35 +82,45 @@ program let processedTransactions = transactions; - // Ticker resolution - if (options.yahoo || options.tickerFile) { - console.log('Resolving tickers...'); + // resolver stacking + const resolvers = []; - let resolver; - if (options.tickerFile) { - resolver = new FileTickerResolver(path.resolve(options.tickerFile)); - } else if (options.yahoo) { - resolver = new YahooTickerResolver(); - } + // 1. File Resolver (Highest priority if provided) + if (options.tickerFile) { + resolvers.push(new FileTickerResolver(path.resolve(options.tickerFile))); + } + + // 2. Yahoo ISIN Resolver (High accuracy) + if (options.yahoo !== false || options.yahooIsin) { + resolvers.push(new YahooISINResolver()); + } + + // 3. Yahoo Name Resolver (Fallback) + if (options.yahoo !== false || options.yahooName) { + resolvers.push(new YahooNameResolver()); + } - if (resolver) { - let cache; - if (options.cache !== false) { - cache = new LocalFileTickerCache(path.resolve(options.cache)); - } - - processedTransactions = await enrichTransactions(transactions, { - resolver, - cache, - }); - - const resolvedCount = processedTransactions.filter( - (t) => t.ticker - ).length; - console.log( - `Resolved tickers for ${resolvedCount}/${processedTransactions.length} transactions.` - ); + if (resolvers.length > 0) { + console.log( + `Resolving tickers using: ${resolvers.map((r) => r.name).join(', ')}...` + ); + + let cache; + if (options.cache !== false) { + cache = new LocalFileTickerCache(path.resolve(options.cache)); } + + processedTransactions = await enrichTransactions(transactions, { + resolvers, + cache, + }); + + const resolvedCount = processedTransactions.filter( + (t) => t.ticker + ).length; + console.log( + `Resolved tickers for ${resolvedCount}/${processedTransactions.length} transactions.` + ); } let result; diff --git a/src/enricher.ts b/src/enricher.ts index 92ff00d..5434c45 100644 --- a/src/enricher.ts +++ b/src/enricher.ts @@ -6,6 +6,7 @@ export type TickerResolution = { }; export interface TickerResolver { + name: string; resolve(isin: string, symbol: string): Promise; } @@ -15,23 +16,33 @@ export interface TickerCache { } export interface EnrichmentOptions { - resolver: TickerResolver; + resolvers: TickerResolver[]; cache?: TickerCache; /** * If true, will not attempt to resolve if a ticker is already present. * Default: true */ skipIfPresent?: boolean; + /** + * If true, will stop trying resolvers for a transaction once one returns a ticker. + * Default: true + */ + stopOnFirstMatch?: boolean; } /** - * Enriches transactions by resolving tickers using a provided resolver and optional cache. + * Enriches transactions by resolving tickers using one or more resolvers in sequence. */ export async function enrichTransactions( transactions: ParsedTransaction[], options: EnrichmentOptions ): Promise { - const { resolver, cache, skipIfPresent = true } = options; + const { + resolvers, + cache, + skipIfPresent = true, + stopOnFirstMatch = true, + } = options; const enriched: ParsedTransaction[] = []; for (const t of transactions) { @@ -48,27 +59,34 @@ export async function enrichTransactions( let resolution: TickerResolution | undefined; + // 1. Check Cache if (cache) { resolution = await cache.get(key); } - if (!resolution) { - try { - resolution = await resolver.resolve(t.isin || '', t.symbol); - if (cache && resolution) { - await cache.set(key, resolution); + // 2. Run Resolvers + if (!resolution || !resolution.ticker) { + for (const resolver of resolvers) { + try { + const res = await resolver.resolve(t.isin || '', t.symbol); + if (res && res.ticker) { + resolution = res; + if (stopOnFirstMatch) break; + } + } catch (e) { + console.warn(`Resolver ${resolver.name} failed for ${key}`, e); } - } catch (e) { - console.warn(`Failed to resolve ticker for ${key}`, e); - resolution = { ticker: null }; } } + // 3. Update Cache if we found something new + if (cache && resolution && resolution.ticker) { + await cache.set(key, resolution); + } + enriched.push({ ...t, - ticker: resolution.ticker || t.ticker, - // If the resolver found a currency, we could potentially update it too - // 但 ParsedTransaction has multiple currency fields. + ticker: resolution?.ticker || t.ticker, }); } diff --git a/src/resolvers/file.ts b/src/resolvers/file.ts index 771c9db..10e1116 100644 --- a/src/resolvers/file.ts +++ b/src/resolvers/file.ts @@ -13,6 +13,7 @@ export interface FileResolverOptions { } export class FileTickerResolver implements TickerResolver { + name = 'File Resolver'; private mappings: Map = new Map(); constructor(filePath: string) { diff --git a/src/resolvers/yahoo.ts b/src/resolvers/yahoo.ts index e6696de..53cfe8c 100644 --- a/src/resolvers/yahoo.ts +++ b/src/resolvers/yahoo.ts @@ -1,92 +1,197 @@ import YahooFinance from 'yahoo-finance2'; import { TickerResolver, TickerResolution } from '../enricher'; -export class YahooTickerResolver implements TickerResolver { - private yahooFinance: typeof YahooFinance; +// Create a single instance to be used across resolvers +const yahooFinance = new YahooFinance(); +try { + (yahooFinance as any).suppressNotices(['yahooSurvey']); +} catch (e) {} - constructor() { - // @ts-ignore - yahoo-finance2 export can be tricky depending on build - this.yahooFinance = YahooFinance.default || YahooFinance; +abstract class YahooBaseResolver { + protected yahooFinance = yahooFinance; + + protected async enrichCurrency(ticker: string): Promise { try { - (this.yahooFinance as any).suppressNotices(['yahooSurvey']); - } catch (e) {} + const quote: any = await this.yahooFinance.quote(ticker); + let currency = quote.currency || null; + if (!currency) { + const summary: any = await this.yahooFinance.quoteSummary(ticker, { + modules: ['summaryDetail', 'price'], + }); + currency = + summary.price?.currency || summary.summaryDetail?.currency || null; + } + return currency; + } catch { + return null; + } } +} + +/** + * Resolves tickers using Yahoo Finance ISIN search. + */ +export class YahooISINResolver + extends YahooBaseResolver + implements TickerResolver +{ + name = 'Yahoo ISIN'; async resolve(isin: string, symbol: string): Promise { - let resolvedTicker: string | null = null; - let resolvedCurrency: string | null = null; + if (!isin) return { ticker: null }; try { - // 1. ISIN Search - if (isin) { - const isinResults: any = await this.yahooFinance.search(isin); - if ( - isinResults && - isinResults.quotes && - isinResults.quotes.length > 0 - ) { - const equityMatch = isinResults.quotes.find( - (q: any) => q.quoteType === 'EQUITY' || q.quoteType === 'ETF' - ); - if (equityMatch) { - resolvedTicker = equityMatch.symbol; - resolvedCurrency = equityMatch.currency || null; - } else { - resolvedTicker = isinResults.quotes[0].symbol; - resolvedCurrency = (isinResults.quotes[0] as any).currency || null; - } + const results: any = await this.yahooFinance.search(isin); + if (results && results.quotes && results.quotes.length > 0) { + const equityMatch = results.quotes.find( + (q: any) => q.quoteType === 'EQUITY' || q.quoteType === 'ETF' + ); + const match = equityMatch || results.quotes[0]; + const ticker = match.symbol; + let currency = match.currency || null; + + if (ticker && !currency) { + currency = await this.enrichCurrency(ticker); } + + return { ticker, currency }; } + } catch (error) { + console.warn(`Yahoo ISIN failed for ${symbol}:`, error); + } - // 2. Name Search (Fallback) - if (!resolvedTicker) { - let cleanName = symbol; - const classMatch = - symbol.match(/\s+Class\s+([A-Z])$/i) || symbol.match(/\s+([A-Z])$/); - if (classMatch) { - cleanName = symbol.substring(0, classMatch.index).trim(); - } + return { ticker: null }; + } +} - if (cleanName.toLowerCase() === 'alphabet') { - cleanName = 'Alphabet Inc'; - } +/** + * Resolves tickers using Yahoo Finance name search with advanced matching. + */ +export class YahooNameResolver + extends YahooBaseResolver + implements TickerResolver +{ + name = 'Yahoo Name'; - const results: any = await this.yahooFinance.search(cleanName); - if (results && results.quotes && results.quotes.length > 0) { - const candidates = results.quotes.filter( - (q: any) => - (q.exchange === 'NMS' || - q.exchange === 'NYQ' || - q.exchange === 'NGM') && - (q.quoteType === 'EQUITY' || q.quoteType === 'ETF') - ); + async resolve(isin: string, symbol: string): Promise { + let cleanName = symbol; + let preferredClass: string | null = null; - if (candidates.length > 0) { - // Simple logic for Alphabet - if (cleanName.toLowerCase().includes('alphabet')) { - if (symbol.includes('Class C')) { - const found = candidates.find((q: any) => q.symbol === 'GOOG'); - if (found) resolvedTicker = found.symbol; - } else { - const found = candidates.find((q: any) => q.symbol === 'GOOGL'); - if (found) resolvedTicker = found.symbol; + const classMatch = + symbol.match(/\s+Class\s+([A-Z])$/i) || symbol.match(/\s+([A-Z])$/); + if (classMatch) { + preferredClass = classMatch[1].toUpperCase(); + cleanName = symbol.substring(0, classMatch.index).trim(); + } + + if (cleanName.toLowerCase() === 'alphabet') { + cleanName = 'Alphabet Inc'; + } + + try { + const results: any = await this.yahooFinance.search(cleanName); + if (results && results.quotes && results.quotes.length > 0) { + const candidates = results.quotes.filter( + (q: any) => + (q.exchange === 'NMS' || + q.exchange === 'NYQ' || + q.exchange === 'NGM') && + (q.quoteType === 'EQUITY' || q.quoteType === 'ETF') + ); + + let resolvedTicker: string | null = null; + let resolvedCurrency: string | null = null; + + if (candidates.length > 0) { + // 1. Special case: Alphabet + if (cleanName.toLowerCase().includes('alphabet')) { + if (preferredClass === 'C') { + const found = candidates.find((q: any) => q.symbol === 'GOOG'); + if (found) { + resolvedTicker = found.symbol; + resolvedCurrency = found.currency || 'USD'; + } + } else if (preferredClass === 'A') { + const found = candidates.find((q: any) => q.symbol === 'GOOGL'); + if (found) { + resolvedTicker = found.symbol; + resolvedCurrency = found.currency || 'USD'; } } + } + + // 2. Exact match starting with name + if (!resolvedTicker) { + const matches = candidates + .filter((q: any) => + (q.longname || q.shortname || '') + .toLowerCase() + .startsWith(cleanName.toLowerCase()) + ) + .sort( + (a: any, b: any) => + (a.longname || a.shortname || '').length - + (b.longname || b.shortname || '').length + ); - if (!resolvedTicker) { - resolvedTicker = candidates[0].symbol; - resolvedCurrency = candidates[0].currency || null; + if (matches.length > 0) { + resolvedTicker = matches[0].symbol; + resolvedCurrency = matches[0].currency || null; } - } else { - resolvedTicker = results.quotes[0].symbol; - resolvedCurrency = results.quotes[0].currency || null; } + + // 3. First candidate + if (!resolvedTicker) { + resolvedTicker = candidates[0].symbol; + resolvedCurrency = candidates[0].currency || null; + } + } + + // 4. Any equity match if no candidates matched the preferred exchanges + if (!resolvedTicker) { + const anyEquity = results.quotes.find( + (q: any) => q.quoteType === 'EQUITY' || q.quoteType === 'ETF' + ); + if (anyEquity) { + resolvedTicker = anyEquity.symbol; + resolvedCurrency = anyEquity.currency || null; + } + } + + // 5. Absolute fallback + if (!resolvedTicker) { + resolvedTicker = results.quotes[0].symbol; + resolvedCurrency = results.quotes[0].currency || null; } + + if (resolvedTicker && !resolvedCurrency) { + resolvedCurrency = await this.enrichCurrency(resolvedTicker); + } + + return { ticker: resolvedTicker, currency: resolvedCurrency }; } } catch (error) { - console.warn(`Yahoo resolve error for ${symbol}:`, error); + console.warn(`Yahoo Name failed for ${symbol}:`, error); } - return { ticker: resolvedTicker, currency: resolvedCurrency }; + return { ticker: null }; + } +} + +/** + * Combined resolver that mimics the original Yahoo resolution logic. + */ +export class YahooFullResolver + extends YahooBaseResolver + implements TickerResolver +{ + name = 'Yahoo Full'; + private isinResolver = new YahooISINResolver(); + private nameResolver = new YahooNameResolver(); + + async resolve(isin: string, symbol: string): Promise { + const isinRes = await this.isinResolver.resolve(isin, symbol); + if (isinRes.ticker) return isinRes; + return this.nameResolver.resolve(isin, symbol); } } From 5b073b9289bc69c0360ab090c9fd5f0ea43c9bc7 Mon Sep 17 00:00:00 2001 From: Abhishek Jain Date: Sun, 11 Jan 2026 17:59:05 +0100 Subject: [PATCH 07/13] feat: implement multi-resolver ticker enrichment with strict terminology --- CODING_GUIDELINES.md | 60 +++++ README.md | 46 +++- src/cli.ts | 3 +- src/enricher.ts | 6 +- src/exporters/yahoo.ts | 4 +- src/index.ts | 2 +- src/parsers/avanza.ts | 2 +- src/parsers/nordnet.ts | 2 +- src/parsers/types.ts | 2 +- src/resolvers/file.ts | 12 +- src/resolvers/yahoo.ts | 76 ++++--- tests/cache.test.ts | 96 ++++++++ tests/enricher.test.ts | 42 ++-- tests/enricher_advanced.test.ts | 279 +++++++++++++++++++++++ tests/exporters/yahoo.test.ts | 8 +- tests/file_resolver.test.ts | 2 +- tests/file_resolver_extended.test.ts | 220 ++++++++++++++++++ tests/integration.test.ts | 4 +- tests/parser_brokers.test.ts | 8 +- tests/resolvers/yahoo.test.ts | 324 +++++++++++++++++++++++++++ 20 files changed, 1129 insertions(+), 69 deletions(-) create mode 100644 CODING_GUIDELINES.md create mode 100644 tests/cache.test.ts create mode 100644 tests/enricher_advanced.test.ts create mode 100644 tests/file_resolver_extended.test.ts create mode 100644 tests/resolvers/yahoo.test.ts diff --git a/CODING_GUIDELINES.md b/CODING_GUIDELINES.md new file mode 100644 index 0000000..b416d6f --- /dev/null +++ b/CODING_GUIDELINES.md @@ -0,0 +1,60 @@ +# Coding Guidelines + +## Terminology Standards + +### Ticker vs Name vs Symbol + +To maintain consistency across the codebase, always adhere to these naming conventions: + +- **`ticker`**: Use this for stock ticker symbols (e.g., "AAPL", "META", "GOOGL") + - Variable names: `ticker`, `resolvedTicker`, `stockTicker` + - Field names: `ticker` + - Function parameters: `ticker` + +- **`name`**: Use this for company/security names (e.g., "Apple Inc", "Meta Platforms A") + - Variable names: `name`, `companyName`, `securityName` + - Field names: `name` + - Function parameters: `name` + +- **`symbol`**: **DEPRECATED** - Do not use this term for company names + - If you encounter `symbol` in legacy code or external APIs, treat it as a `ticker` + - When refactoring, always replace `symbol` with either `ticker` or `name` based on context + - Exception: External API responses where `symbol` is the field name (cast/map to `ticker`) + +### Examples + +✅ **Correct:** + +```typescript +interface Transaction { + name: string; // "Apple Inc" + ticker: string; // "AAPL" + isin: string; // "US0378331005" +} + +function resolveTicker(isin: string, name: string): string { + // ... +} +``` + +❌ **Incorrect:** + +```typescript +interface Transaction { + symbol: string; // Ambiguous - is this a ticker or name? +} + +function resolveSymbol(isin: string, symbol: string): string { + // ... +} +``` + +### Rationale + +The term "symbol" is ambiguous in financial contexts. It can refer to: + +- A ticker symbol (AAPL) +- A company name (Apple Inc) +- A trading symbol on an exchange + +By strictly using `ticker` for stock symbols and `name` for company names, we eliminate confusion and make the codebase more maintainable. diff --git a/README.md b/README.md index ce1fd9e..c1965ad 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,36 @@ const transaction = parseTransaction(row); The library and CLI support "stacked" resolvers. You can prioritize local data and then fall back to various cloud providers. +#### Ticker Mapping Formats + +When using `--ticker-file` or `FileTickerResolver`, you can use **JSON** or **CSV** files. + +**JSON Format (Object or Array):** + +```json +// Simple object (Key can be ISIN or Security Name) +{ + "US0378331005": "AAPL", + "Meta Platforms A": "META" +} + +// Or an array of objects +[ + { "isin": "US0378331005", "ticker": "AAPL" }, + { "name": "Microsoft", "ticker": "MSFT" } +] +``` + +**CSV Format:** + +```csv +isin,name,ticker +US0378331005,,AAPL +,Microsoft,MSFT +``` + +#### Library Example + ```typescript import { enrichTransactions, @@ -141,7 +171,8 @@ import { // 1. Define or use a built-in resolver const myResolver = { - resolve: async (isin: string, symbol: string) => { + name: 'My Custom Resolver', + resolve: async (isin: string, name: string) => { if (isin === 'US0378331005') return { ticker: 'AAPL' }; return { ticker: null }; }, @@ -181,7 +212,8 @@ Scans a dataset to find all unique account IDs present in the file. - `date`: Date object - `type`: 'BUY' | 'SELL' | 'DIVIDEND' | 'DEPOSIT' | 'WITHDRAW' | 'INTEREST' | 'TAX' | 'OTHER' -- `symbol`: string +- `name`: string (Security name, e.g. "Apple Inc") +- `ticker`: string (Ticker symbol, e.g. "AAPL") - `quantity`: number - `price`: number - `total`: number @@ -208,6 +240,16 @@ If you are moving from an internal implementation to this library: 3. **Dependencies**: This library has zero runtime dependencies (except standard JS/TS features). +## Coding Guidelines + +This project follows strict naming conventions for financial terminology. See [CODING_GUIDELINES.md](./CODING_GUIDELINES.md) for details. + +**Key terminology:** + +- `ticker`: Stock ticker symbol (e.g., "AAPL", "META") +- `name`: Company/security name (e.g., "Apple Inc") +- `symbol`: **DEPRECATED** - do not use; treat as `ticker` if encountered in external APIs + ## Adding a New Broker We welcome contributions! To add support for a new broker: diff --git a/src/cli.ts b/src/cli.ts index 8780136..55dd7c7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,6 +11,7 @@ import { YahooNameResolver, FileTickerResolver, LocalFileTickerCache, + ParsedTransaction, } from './index'; import { BrokerFormat } from './parsers/types'; @@ -71,7 +72,7 @@ program const data = parsedCsv.data as Record[]; const transactions = data .map((row) => parseTransaction(row, options.format as BrokerFormat)) - .filter((t): t is any => t !== null); + .filter((t): t is ParsedTransaction => t !== null); if (transactions.length === 0) { console.error('Error: No transactions could be parsed from the file.'); diff --git a/src/enricher.ts b/src/enricher.ts index 5434c45..333bc1d 100644 --- a/src/enricher.ts +++ b/src/enricher.ts @@ -7,7 +7,7 @@ export type TickerResolution = { export interface TickerResolver { name: string; - resolve(isin: string, symbol: string): Promise; + resolve(isin: string, name: string): Promise; } export interface TickerCache { @@ -51,7 +51,7 @@ export async function enrichTransactions( continue; } - const key = t.isin || t.symbol; + const key = t.isin || t.name; if (!key) { enriched.push(t); continue; @@ -68,7 +68,7 @@ export async function enrichTransactions( if (!resolution || !resolution.ticker) { for (const resolver of resolvers) { try { - const res = await resolver.resolve(t.isin || '', t.symbol); + const res = await resolver.resolve(t.isin || '', t.name); if (res && res.ticker) { resolution = res; if (stopOnFirstMatch) break; diff --git a/src/exporters/yahoo.ts b/src/exporters/yahoo.ts index b148b92..7a08008 100644 --- a/src/exporters/yahoo.ts +++ b/src/exporters/yahoo.ts @@ -15,8 +15,8 @@ export const YahooFinanceExporter: PortfolioExporter = { const rows = transactions .map((t) => { - // We need a Symbol/Ticker. Prefer 'ticker', fallback to 'symbol', fallback to 'isin' - const symbol = t.ticker || t.symbol; + // Yahoo Finance expects a ticker symbol. Prefer resolved 'ticker', fallback to 'name' + const symbol = t.ticker || t.name; // Skip non-trade types? if (t.type !== 'BUY' && t.type !== 'SELL') { diff --git a/src/index.ts b/src/index.ts index 3cea022..5e987e7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,7 +41,7 @@ export function parseTransaction( if (parser && parser.canParse(row)) { const t = parser.parse(row); - if (t && t.symbol && t.date && !isNaN(t.date.getTime())) { + if (t && t.name && t.date && !isNaN(t.date.getTime())) { return t; } } diff --git a/src/parsers/avanza.ts b/src/parsers/avanza.ts index 936ab2a..cad708d 100644 --- a/src/parsers/avanza.ts +++ b/src/parsers/avanza.ts @@ -44,7 +44,7 @@ export const AvanzaParser: BrokerParser = { return { date, type, - symbol: row['Värdepapper/beskrivning'] || row['Värdepapper'], + name: row['Värdepapper/beskrivning'] || row['Värdepapper'], quantity: Math.abs(qty), price: price, currency: accountCurrency, diff --git a/src/parsers/nordnet.ts b/src/parsers/nordnet.ts index 024e646..6576545 100644 --- a/src/parsers/nordnet.ts +++ b/src/parsers/nordnet.ts @@ -34,7 +34,7 @@ export const NordnetParser: BrokerParser = { return { date, type, - symbol: row['Instrument'] || row['Värdepapper'], + name: row['Instrument'] || row['Värdepapper'], quantity: Math.abs(qty), price: price, // This is usually native price currency: accountCurrency, diff --git a/src/parsers/types.ts b/src/parsers/types.ts index 5e2e3a9..e4828f5 100644 --- a/src/parsers/types.ts +++ b/src/parsers/types.ts @@ -1,7 +1,7 @@ export type ParsedTransaction = { date: Date; type: string; // "BUY", "SELL" - symbol: string; + name: string; quantity: number; price: number; currency: string; diff --git a/src/resolvers/file.ts b/src/resolvers/file.ts index 10e1116..5b09f26 100644 --- a/src/resolvers/file.ts +++ b/src/resolvers/file.ts @@ -6,9 +6,9 @@ import { TickerResolver, TickerResolution } from '../enricher'; export interface FileResolverOptions { filePath: string; /** - * Map of ISIN or Symbol to Ticker + * Map of ISIN or Name to Ticker * For JSON, it should be an object or an array of objects. - * For CSV, it should have headers like 'isin', 'symbol', 'ticker'. + * For CSV, it should have headers like 'isin', 'name', 'ticker'. */ } @@ -34,7 +34,7 @@ export class FileTickerResolver implements TickerResolver { const data = JSON.parse(content); if (Array.isArray(data)) { data.forEach((item: any) => { - const key = item.isin || item.symbol; + const key = item.isin || item.name; if (key && item.ticker) this.mappings.set(key, item.ticker); }); } else { @@ -52,7 +52,7 @@ export class FileTickerResolver implements TickerResolver { skipEmptyLines: true, }); (results.data as any[]).forEach((row) => { - const key = row.isin || row.symbol || row.ISIN || row.Symbol; + const key = row.isin || row.name || row.ISIN; const ticker = row.ticker || row.Ticker; if (key && ticker) this.mappings.set(key, ticker); }); @@ -62,8 +62,8 @@ export class FileTickerResolver implements TickerResolver { } } - async resolve(isin: string, symbol: string): Promise { - const ticker = this.mappings.get(isin) || this.mappings.get(symbol) || null; + async resolve(isin: string, name: string): Promise { + const ticker = this.mappings.get(isin) || this.mappings.get(name) || null; return { ticker }; } } diff --git a/src/resolvers/yahoo.ts b/src/resolvers/yahoo.ts index 53cfe8c..5795bf7 100644 --- a/src/resolvers/yahoo.ts +++ b/src/resolvers/yahoo.ts @@ -1,23 +1,43 @@ import YahooFinance from 'yahoo-finance2'; import { TickerResolver, TickerResolution } from '../enricher'; +// Yahoo Finance API types (simplified) +interface YahooQuote { + symbol: string; + quoteType?: string; + exchange?: string; + currency?: string; + longname?: string; + shortname?: string; +} + +interface YahooSearchResult { + quotes: YahooQuote[]; +} + +interface YahooQuoteResult { + currency?: string; +} + +interface YahooQuoteSummaryResult { + price?: { currency?: string }; + summaryDetail?: { currency?: string }; +} + // Create a single instance to be used across resolvers -const yahooFinance = new YahooFinance(); -try { - (yahooFinance as any).suppressNotices(['yahooSurvey']); -} catch (e) {} +const yahooFinance = new YahooFinance({ suppressNotices: ['yahooSurvey'] }); abstract class YahooBaseResolver { protected yahooFinance = yahooFinance; protected async enrichCurrency(ticker: string): Promise { try { - const quote: any = await this.yahooFinance.quote(ticker); + const quote = (await this.yahooFinance.quote(ticker)) as YahooQuoteResult; let currency = quote.currency || null; if (!currency) { - const summary: any = await this.yahooFinance.quoteSummary(ticker, { + const summary = (await this.yahooFinance.quoteSummary(ticker, { modules: ['summaryDetail', 'price'], - }); + })) as YahooQuoteSummaryResult; currency = summary.price?.currency || summary.summaryDetail?.currency || null; } @@ -37,14 +57,16 @@ export class YahooISINResolver { name = 'Yahoo ISIN'; - async resolve(isin: string, symbol: string): Promise { + async resolve(isin: string, name: string): Promise { if (!isin) return { ticker: null }; try { - const results: any = await this.yahooFinance.search(isin); + const results = (await this.yahooFinance.search( + isin + )) as YahooSearchResult; if (results && results.quotes && results.quotes.length > 0) { const equityMatch = results.quotes.find( - (q: any) => q.quoteType === 'EQUITY' || q.quoteType === 'ETF' + (q) => q.quoteType === 'EQUITY' || q.quoteType === 'ETF' ); const match = equityMatch || results.quotes[0]; const ticker = match.symbol; @@ -57,7 +79,7 @@ export class YahooISINResolver return { ticker, currency }; } } catch (error) { - console.warn(`Yahoo ISIN failed for ${symbol}:`, error); + console.warn(`Yahoo ISIN failed for ${name}:`, error); } return { ticker: null }; @@ -73,15 +95,15 @@ export class YahooNameResolver { name = 'Yahoo Name'; - async resolve(isin: string, symbol: string): Promise { - let cleanName = symbol; + async resolve(isin: string, name: string): Promise { + let cleanName = name; let preferredClass: string | null = null; const classMatch = - symbol.match(/\s+Class\s+([A-Z])$/i) || symbol.match(/\s+([A-Z])$/); + name.match(/\s+Class\s+([A-Z])$/i) || name.match(/\s+([A-Z])$/); if (classMatch) { preferredClass = classMatch[1].toUpperCase(); - cleanName = symbol.substring(0, classMatch.index).trim(); + cleanName = name.substring(0, classMatch.index).trim(); } if (cleanName.toLowerCase() === 'alphabet') { @@ -89,10 +111,12 @@ export class YahooNameResolver } try { - const results: any = await this.yahooFinance.search(cleanName); + const results = (await this.yahooFinance.search( + cleanName + )) as YahooSearchResult; if (results && results.quotes && results.quotes.length > 0) { const candidates = results.quotes.filter( - (q: any) => + (q) => (q.exchange === 'NMS' || q.exchange === 'NYQ' || q.exchange === 'NGM') && @@ -106,13 +130,13 @@ export class YahooNameResolver // 1. Special case: Alphabet if (cleanName.toLowerCase().includes('alphabet')) { if (preferredClass === 'C') { - const found = candidates.find((q: any) => q.symbol === 'GOOG'); + const found = candidates.find((q) => q.symbol === 'GOOG'); if (found) { resolvedTicker = found.symbol; resolvedCurrency = found.currency || 'USD'; } } else if (preferredClass === 'A') { - const found = candidates.find((q: any) => q.symbol === 'GOOGL'); + const found = candidates.find((q) => q.symbol === 'GOOGL'); if (found) { resolvedTicker = found.symbol; resolvedCurrency = found.currency || 'USD'; @@ -123,13 +147,13 @@ export class YahooNameResolver // 2. Exact match starting with name if (!resolvedTicker) { const matches = candidates - .filter((q: any) => + .filter((q) => (q.longname || q.shortname || '') .toLowerCase() .startsWith(cleanName.toLowerCase()) ) .sort( - (a: any, b: any) => + (a, b) => (a.longname || a.shortname || '').length - (b.longname || b.shortname || '').length ); @@ -150,7 +174,7 @@ export class YahooNameResolver // 4. Any equity match if no candidates matched the preferred exchanges if (!resolvedTicker) { const anyEquity = results.quotes.find( - (q: any) => q.quoteType === 'EQUITY' || q.quoteType === 'ETF' + (q) => q.quoteType === 'EQUITY' || q.quoteType === 'ETF' ); if (anyEquity) { resolvedTicker = anyEquity.symbol; @@ -171,7 +195,7 @@ export class YahooNameResolver return { ticker: resolvedTicker, currency: resolvedCurrency }; } } catch (error) { - console.warn(`Yahoo Name failed for ${symbol}:`, error); + console.warn(`Yahoo Name failed for ${name}:`, error); } return { ticker: null }; @@ -189,9 +213,9 @@ export class YahooFullResolver private isinResolver = new YahooISINResolver(); private nameResolver = new YahooNameResolver(); - async resolve(isin: string, symbol: string): Promise { - const isinRes = await this.isinResolver.resolve(isin, symbol); + async resolve(isin: string, name: string): Promise { + const isinRes = await this.isinResolver.resolve(isin, name); if (isinRes.ticker) return isinRes; - return this.nameResolver.resolve(isin, symbol); + return this.nameResolver.resolve(isin, name); } } diff --git a/tests/cache.test.ts b/tests/cache.test.ts new file mode 100644 index 0000000..6379e39 --- /dev/null +++ b/tests/cache.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { LocalFileTickerCache } from '../src/cache'; +import fs from 'fs'; +import path from 'path'; + +describe('LocalFileTickerCache', () => { + const tempCachePath = path.join(__dirname, 'temp_cache.json'); + + afterEach(() => { + if (fs.existsSync(tempCachePath)) { + fs.unlinkSync(tempCachePath); + } + }); + + it('should create cache file if it does not exist', async () => { + const cache = new LocalFileTickerCache(tempCachePath); + await cache.set('US0378331005', { ticker: 'AAPL', currency: 'USD' }); + + expect(fs.existsSync(tempCachePath)).toBe(true); + }); + + it('should store and retrieve ticker resolutions', async () => { + const cache = new LocalFileTickerCache(tempCachePath); + + await cache.set('US0378331005', { ticker: 'AAPL', currency: 'USD' }); + const result = await cache.get('US0378331005'); + + expect(result).toEqual({ ticker: 'AAPL', currency: 'USD' }); + }); + + it('should return undefined for non-existent keys', async () => { + const cache = new LocalFileTickerCache(tempCachePath); + const result = await cache.get('NONEXISTENT'); + + expect(result).toBeUndefined(); + }); + + it('should persist data across instances', async () => { + const cache1 = new LocalFileTickerCache(tempCachePath); + await cache1.set('US0378331005', { ticker: 'AAPL', currency: 'USD' }); + + const cache2 = new LocalFileTickerCache(tempCachePath); + const result = await cache2.get('US0378331005'); + + expect(result).toEqual({ ticker: 'AAPL', currency: 'USD' }); + }); + + it('should handle multiple entries', async () => { + const cache = new LocalFileTickerCache(tempCachePath); + + await cache.set('US0378331005', { ticker: 'AAPL', currency: 'USD' }); + await cache.set('US30303M1027', { ticker: 'META', currency: 'USD' }); + await cache.set('US64110L1061', { ticker: 'NFLX', currency: 'USD' }); + + expect(await cache.get('US0378331005')).toEqual({ + ticker: 'AAPL', + currency: 'USD', + }); + expect(await cache.get('US30303M1027')).toEqual({ + ticker: 'META', + currency: 'USD', + }); + expect(await cache.get('US64110L1061')).toEqual({ + ticker: 'NFLX', + currency: 'USD', + }); + }); + + it('should overwrite existing entries', async () => { + const cache = new LocalFileTickerCache(tempCachePath); + + await cache.set('US0378331005', { ticker: 'AAPL', currency: 'USD' }); + await cache.set('US0378331005', { ticker: 'AAPL', currency: 'EUR' }); + + const result = await cache.get('US0378331005'); + expect(result?.currency).toBe('EUR'); + }); + + it('should handle empty cache file gracefully', async () => { + fs.writeFileSync(tempCachePath, '{}'); + const cache = new LocalFileTickerCache(tempCachePath); + + const result = await cache.get('US0378331005'); + expect(result).toBeUndefined(); + }); + + it('should handle corrupted cache file gracefully', async () => { + fs.writeFileSync(tempCachePath, 'invalid json'); + const cache = new LocalFileTickerCache(tempCachePath); + + await cache.set('US0378331005', { ticker: 'AAPL', currency: 'USD' }); + const result = await cache.get('US0378331005'); + + expect(result).toEqual({ ticker: 'AAPL', currency: 'USD' }); + }); +}); diff --git a/tests/enricher.test.ts b/tests/enricher.test.ts index b50374a..13b1087 100644 --- a/tests/enricher.test.ts +++ b/tests/enricher.test.ts @@ -1,23 +1,28 @@ import { describe, it, expect, vi } from 'vitest'; -import { enrichTransactions, MemoryTickerCache } from '../src/enricher'; +import { + enrichTransactions, + MemoryTickerCache, + TickerResolver, +} from '../src/enricher'; import { ParsedTransaction } from '../src/parsers/types'; describe('Enricher', () => { - it('should enrich transactions with tickers using resolver', async () => { + it('should enrich transactions with tickers using multiple resolvers', async () => { const transactions = [ { - symbol: 'Apple', + name: 'Apple', isin: 'US0001', ticker: undefined, } as ParsedTransaction, { - symbol: 'Microsoft', + name: 'Microsoft', isin: 'US0002', ticker: undefined, } as ParsedTransaction, ]; - const resolver = { + const resolver: TickerResolver = { + name: 'Test Resolver', resolve: vi.fn().mockImplementation(async (isin, name) => { if (isin === 'US0001') return { ticker: 'AAPL' }; if (name === 'Microsoft') return { ticker: 'MSFT' }; @@ -25,25 +30,31 @@ describe('Enricher', () => { }), }; - const enriched = await enrichTransactions(transactions, { resolver }); + const enriched = await enrichTransactions(transactions, { + resolvers: [resolver], + }); expect(enriched[0].ticker).toBe('AAPL'); expect(enriched[1].ticker).toBe('MSFT'); expect(resolver.resolve).toHaveBeenCalledTimes(2); }); - it('should use cache for repeated symbols', async () => { + it('should use cache for repeated names', async () => { const transactions = [ - { symbol: 'Apple', isin: 'US0001' } as ParsedTransaction, - { symbol: 'Apple', isin: 'US0001' } as ParsedTransaction, + { name: 'Apple', isin: 'US0001' } as ParsedTransaction, + { name: 'Apple', isin: 'US0001' } as ParsedTransaction, ]; - const resolver = { + const resolver: TickerResolver = { + name: 'Test Resolver', resolve: vi.fn().mockResolvedValue({ ticker: 'AAPL' }), }; const cache = new MemoryTickerCache(); - await enrichTransactions(transactions, { resolver, cache }); + await enrichTransactions(transactions, { + resolvers: [resolver], + cache, + }); expect(resolver.resolve).toHaveBeenCalledTimes(1); }); @@ -51,15 +62,18 @@ describe('Enricher', () => { it('should skip transactions that already have tickers', async () => { const transactions = [ { - symbol: 'Apple', + name: 'Apple', isin: 'US0001', ticker: 'EXISTING', } as ParsedTransaction, ]; - const resolver = { + const resolver: TickerResolver = { + name: 'Test', resolve: vi.fn(), }; - const enriched = await enrichTransactions(transactions, { resolver }); + const enriched = await enrichTransactions(transactions, { + resolvers: [resolver], + }); expect(enriched[0].ticker).toBe('EXISTING'); expect(resolver.resolve).not.toHaveBeenCalled(); diff --git a/tests/enricher_advanced.test.ts b/tests/enricher_advanced.test.ts new file mode 100644 index 0000000..54de41c --- /dev/null +++ b/tests/enricher_advanced.test.ts @@ -0,0 +1,279 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + enrichTransactions, + MemoryTickerCache, + TickerResolver, +} from '../src/enricher'; +import { ParsedTransaction } from '../src/parsers/types'; + +describe('Enricher - Advanced Scenarios', () => { + describe('Multiple Resolver Stacking', () => { + it('should try resolvers in order until one succeeds', async () => { + const transactions = [ + { + name: 'Apple Inc', + isin: 'US0378331005', + ticker: undefined, + } as ParsedTransaction, + ]; + + const resolver1: TickerResolver = { + name: 'Resolver 1', + resolve: vi.fn().mockResolvedValue({ ticker: null }), + }; + + const resolver2: TickerResolver = { + name: 'Resolver 2', + resolve: vi.fn().mockResolvedValue({ ticker: 'AAPL', currency: 'USD' }), + }; + + const resolver3: TickerResolver = { + name: 'Resolver 3', + resolve: vi.fn().mockResolvedValue({ ticker: 'AAPL2' }), + }; + + const enriched = await enrichTransactions(transactions, { + resolvers: [resolver1, resolver2, resolver3], + }); + + expect(enriched[0].ticker).toBe('AAPL'); + expect(resolver1.resolve).toHaveBeenCalledTimes(1); + expect(resolver2.resolve).toHaveBeenCalledTimes(1); + expect(resolver3.resolve).not.toHaveBeenCalled(); // Should stop after resolver2 + }); + + it('should continue to next resolver if stopOnFirstMatch is false', async () => { + const transactions = [ + { + name: 'Apple Inc', + isin: 'US0378331005', + ticker: undefined, + } as ParsedTransaction, + ]; + + const resolver1: TickerResolver = { + name: 'Resolver 1', + resolve: vi.fn().mockResolvedValue({ ticker: 'AAPL1' }), + }; + + const resolver2: TickerResolver = { + name: 'Resolver 2', + resolve: vi.fn().mockResolvedValue({ ticker: 'AAPL2' }), + }; + + await enrichTransactions(transactions, { + resolvers: [resolver1, resolver2], + stopOnFirstMatch: false, + }); + + expect(resolver1.resolve).toHaveBeenCalledTimes(1); + expect(resolver2.resolve).toHaveBeenCalledTimes(1); + }); + + it('should handle resolver errors gracefully', async () => { + const transactions = [ + { + name: 'Apple Inc', + isin: 'US0378331005', + ticker: undefined, + } as ParsedTransaction, + ]; + + const resolver1: TickerResolver = { + name: 'Resolver 1', + resolve: vi.fn().mockRejectedValue(new Error('API Error')), + }; + + const resolver2: TickerResolver = { + name: 'Resolver 2', + resolve: vi.fn().mockResolvedValue({ ticker: 'AAPL' }), + }; + + const enriched = await enrichTransactions(transactions, { + resolvers: [resolver1, resolver2], + }); + + expect(enriched[0].ticker).toBe('AAPL'); + }); + }); + + describe('Cache Integration', () => { + it('should use cached value and skip resolvers', async () => { + const transactions = [ + { + name: 'Apple Inc', + isin: 'US0378331005', + ticker: undefined, + } as ParsedTransaction, + ]; + + const cache = new MemoryTickerCache(); + await cache.set('US0378331005', { ticker: 'AAPL', currency: 'USD' }); + + const resolver: TickerResolver = { + name: 'Test Resolver', + resolve: vi.fn().mockResolvedValue({ ticker: 'SHOULD_NOT_USE' }), + }; + + const enriched = await enrichTransactions(transactions, { + resolvers: [resolver], + cache, + }); + + expect(enriched[0].ticker).toBe('AAPL'); + expect(resolver.resolve).not.toHaveBeenCalled(); + }); + + it('should cache newly resolved tickers', async () => { + const transactions = [ + { + name: 'Apple Inc', + isin: 'US0378331005', + ticker: undefined, + } as ParsedTransaction, + ]; + + const cache = new MemoryTickerCache(); + const resolver: TickerResolver = { + name: 'Test Resolver', + resolve: vi.fn().mockResolvedValue({ ticker: 'AAPL', currency: 'USD' }), + }; + + await enrichTransactions(transactions, { + resolvers: [resolver], + cache, + }); + + const cached = await cache.get('US0378331005'); + expect(cached).toEqual({ ticker: 'AAPL', currency: 'USD' }); + }); + + it('should use name as cache key if ISIN is missing', async () => { + const transactions = [ + { + name: 'Apple Inc', + ticker: undefined, + } as ParsedTransaction, + ]; + + const cache = new MemoryTickerCache(); + const resolver: TickerResolver = { + name: 'Test Resolver', + resolve: vi.fn().mockResolvedValue({ ticker: 'AAPL' }), + }; + + await enrichTransactions(transactions, { + resolvers: [resolver], + cache, + }); + + const cached = await cache.get('Apple Inc'); + expect(cached?.ticker).toBe('AAPL'); + }); + }); + + describe('Edge Cases', () => { + it('should skip transactions without name or ISIN', async () => { + const transactions = [ + { + ticker: undefined, + } as ParsedTransaction, + ]; + + const resolver: TickerResolver = { + name: 'Test Resolver', + resolve: vi.fn(), + }; + + const enriched = await enrichTransactions(transactions, { + resolvers: [resolver], + }); + + expect(enriched[0].ticker).toBeUndefined(); + expect(resolver.resolve).not.toHaveBeenCalled(); + }); + + it('should skip transactions with skipIfPresent=true (default)', async () => { + const transactions = [ + { + name: 'Apple Inc', + isin: 'US0378331005', + ticker: 'EXISTING', + } as ParsedTransaction, + ]; + + const resolver: TickerResolver = { + name: 'Test Resolver', + resolve: vi.fn().mockResolvedValue({ ticker: 'AAPL' }), + }; + + const enriched = await enrichTransactions(transactions, { + resolvers: [resolver], + }); + + expect(enriched[0].ticker).toBe('EXISTING'); + expect(resolver.resolve).not.toHaveBeenCalled(); + }); + + it('should re-resolve if skipIfPresent=false', async () => { + const transactions = [ + { + name: 'Apple Inc', + isin: 'US0378331005', + ticker: 'EXISTING', + } as ParsedTransaction, + ]; + + const resolver: TickerResolver = { + name: 'Test Resolver', + resolve: vi.fn().mockResolvedValue({ ticker: 'AAPL' }), + }; + + const enriched = await enrichTransactions(transactions, { + resolvers: [resolver], + skipIfPresent: false, + }); + + expect(enriched[0].ticker).toBe('AAPL'); + expect(resolver.resolve).toHaveBeenCalled(); + }); + + it('should preserve original ticker if all resolvers fail', async () => { + const transactions = [ + { + name: 'Unknown Stock', + isin: 'UNKNOWN', + ticker: 'ORIGINAL', + } as ParsedTransaction, + ]; + + const resolver: TickerResolver = { + name: 'Test Resolver', + resolve: vi.fn().mockResolvedValue({ ticker: null }), + }; + + const enriched = await enrichTransactions(transactions, { + resolvers: [resolver], + skipIfPresent: false, + }); + + expect(enriched[0].ticker).toBe('ORIGINAL'); + }); + + it('should handle empty resolver array', async () => { + const transactions = [ + { + name: 'Apple Inc', + isin: 'US0378331005', + ticker: undefined, + } as ParsedTransaction, + ]; + + const enriched = await enrichTransactions(transactions, { + resolvers: [], + }); + + expect(enriched[0].ticker).toBeUndefined(); + }); + }); +}); diff --git a/tests/exporters/yahoo.test.ts b/tests/exporters/yahoo.test.ts index 92a10f4..dbfcf52 100644 --- a/tests/exporters/yahoo.test.ts +++ b/tests/exporters/yahoo.test.ts @@ -8,7 +8,7 @@ describe('YahooFinanceExporter', () => { { date: new Date('2023-01-01'), type: 'BUY', - symbol: 'Apple Inc', + name: 'Apple Inc', ticker: 'AAPL', quantity: 10, price: 150.0, @@ -20,7 +20,7 @@ describe('YahooFinanceExporter', () => { { date: new Date('2023-02-01'), type: 'SELL', - symbol: 'Apple Inc', + name: 'Apple Inc', ticker: 'AAPL', quantity: 5, price: 160.0, @@ -47,12 +47,12 @@ describe('YahooFinanceExporter', () => { expect(lines[2]).toContain('-5'); }); - it('should fallback to symbol if ticker is missing', () => { + it('should fallback to name if ticker is missing', () => { const t = [ { date: new Date('2023-01-01'), type: 'BUY', - symbol: 'Unknown Stock', + name: 'Unknown Stock', quantity: 1, price: 100, fee: 0, diff --git a/tests/file_resolver.test.ts b/tests/file_resolver.test.ts index 711e8fd..5da0812 100644 --- a/tests/file_resolver.test.ts +++ b/tests/file_resolver.test.ts @@ -30,7 +30,7 @@ describe('FileTickerResolver', () => { it('should resolve tickers from a CSV file', async () => { fs.writeFileSync( tempCsv, - 'isin,symbol,ticker\nUS0378331005,Apple,AAPL\n,Microsoft,MSFT' + 'isin,name,ticker\nUS0378331005,Apple,AAPL\n,Microsoft,MSFT' ); const resolver = new FileTickerResolver(tempCsv); diff --git a/tests/file_resolver_extended.test.ts b/tests/file_resolver_extended.test.ts new file mode 100644 index 0000000..48f152d --- /dev/null +++ b/tests/file_resolver_extended.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { FileTickerResolver } from '../src/resolvers/file'; + +describe('FileTickerResolver - Extended', () => { + const tempDir = path.join(__dirname, 'temp_resolver_files'); + + beforeEach(() => { + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe('JSON Array Format', () => { + it('should resolve from array with isin field', async () => { + const filePath = path.join(tempDir, 'array_isin.json'); + fs.writeFileSync( + filePath, + JSON.stringify([ + { isin: 'US0378331005', ticker: 'AAPL' }, + { isin: 'US30303M1027', ticker: 'META' }, + ]) + ); + + const resolver = new FileTickerResolver(filePath); + const result = await resolver.resolve('US0378331005', 'Apple Inc'); + + expect(result.ticker).toBe('AAPL'); + }); + + it('should resolve from array with name field', async () => { + const filePath = path.join(tempDir, 'array_name.json'); + fs.writeFileSync( + filePath, + JSON.stringify([ + { name: 'Apple Inc', ticker: 'AAPL' }, + { name: 'Meta Platforms A', ticker: 'META' }, + ]) + ); + + const resolver = new FileTickerResolver(filePath); + const result = await resolver.resolve('', 'Apple Inc'); + + expect(result.ticker).toBe('AAPL'); + }); + + it('should prioritize isin over name in array', async () => { + const filePath = path.join(tempDir, 'array_priority.json'); + fs.writeFileSync( + filePath, + JSON.stringify([ + { isin: 'US0378331005', name: 'Apple Inc', ticker: 'AAPL' }, + ]) + ); + + const resolver = new FileTickerResolver(filePath); + const result = await resolver.resolve('US0378331005', 'Wrong Name'); + + expect(result.ticker).toBe('AAPL'); + }); + + it('should skip entries without ticker', async () => { + const filePath = path.join(tempDir, 'array_no_ticker.json'); + fs.writeFileSync( + filePath, + JSON.stringify([ + { isin: 'US0378331005' }, // No ticker + { name: 'Apple Inc', ticker: 'AAPL' }, + ]) + ); + + const resolver = new FileTickerResolver(filePath); + const result1 = await resolver.resolve('US0378331005', ''); + const result2 = await resolver.resolve('', 'Apple Inc'); + + expect(result1.ticker).toBeNull(); + expect(result2.ticker).toBe('AAPL'); + }); + }); + + describe('JSON Object Format', () => { + it('should resolve from simple object', async () => { + const filePath = path.join(tempDir, 'object.json'); + fs.writeFileSync( + filePath, + JSON.stringify({ + US0378331005: 'AAPL', + 'Meta Platforms A': 'META', + }) + ); + + const resolver = new FileTickerResolver(filePath); + const result1 = await resolver.resolve('US0378331005', ''); + const result2 = await resolver.resolve('', 'Meta Platforms A'); + + expect(result1.ticker).toBe('AAPL'); + expect(result2.ticker).toBe('META'); + }); + + it('should skip non-string values in object', async () => { + const filePath = path.join(tempDir, 'object_invalid.json'); + fs.writeFileSync( + filePath, + JSON.stringify({ + US0378331005: 'AAPL', + INVALID: 123, // Not a string + ANOTHER: { nested: 'object' }, // Not a string + }) + ); + + const resolver = new FileTickerResolver(filePath); + const result1 = await resolver.resolve('US0378331005', ''); + const result2 = await resolver.resolve('INVALID', ''); + const result3 = await resolver.resolve('ANOTHER', ''); + + expect(result1.ticker).toBe('AAPL'); + expect(result2.ticker).toBeNull(); + expect(result3.ticker).toBeNull(); + }); + }); + + describe('CSV Format', () => { + it('should resolve from CSV with lowercase headers', async () => { + const filePath = path.join(tempDir, 'lowercase.csv'); + fs.writeFileSync( + filePath, + 'isin,name,ticker\nUS0378331005,Apple Inc,AAPL\n,Meta Platforms A,META' + ); + + const resolver = new FileTickerResolver(filePath); + const result1 = await resolver.resolve('US0378331005', ''); + const result2 = await resolver.resolve('', 'Meta Platforms A'); + + expect(result1.ticker).toBe('AAPL'); + expect(result2.ticker).toBe('META'); + }); + + it('should resolve from CSV with Ticker header (capitalized)', async () => { + const filePath = path.join(tempDir, 'capitalized.csv'); + fs.writeFileSync( + filePath, + 'isin,name,Ticker\nUS0378331005,Apple Inc,AAPL' + ); + + const resolver = new FileTickerResolver(filePath); + const result = await resolver.resolve('US0378331005', ''); + + expect(result.ticker).toBe('AAPL'); + }); + + it('should skip rows without ticker', async () => { + const filePath = path.join(tempDir, 'missing_ticker.csv'); + fs.writeFileSync( + filePath, + 'isin,name,ticker\nUS0378331005,Apple Inc,\n,Meta Platforms A,META' + ); + + const resolver = new FileTickerResolver(filePath); + const result1 = await resolver.resolve('US0378331005', ''); + const result2 = await resolver.resolve('', 'Meta Platforms A'); + + expect(result1.ticker).toBeNull(); + expect(result2.ticker).toBe('META'); + }); + }); + + describe('Error Handling', () => { + it('should handle non-existent file gracefully', async () => { + const resolver = new FileTickerResolver('/non/existent/file.json'); + const result = await resolver.resolve('US0378331005', 'Apple Inc'); + + expect(result.ticker).toBeNull(); + }); + + it('should handle invalid JSON gracefully', async () => { + const filePath = path.join(tempDir, 'invalid.json'); + fs.writeFileSync(filePath, 'invalid json {]'); + + const resolver = new FileTickerResolver(filePath); + const result = await resolver.resolve('US0378331005', 'Apple Inc'); + + expect(result.ticker).toBeNull(); + }); + + it('should handle malformed CSV gracefully', async () => { + const filePath = path.join(tempDir, 'malformed.csv'); + fs.writeFileSync(filePath, 'isin,name,ticker\n"unclosed quote'); + + const resolver = new FileTickerResolver(filePath); + const result = await resolver.resolve('US0378331005', 'Apple Inc'); + + // Should not throw, just return null + expect(result.ticker).toBeNull(); + }); + + it('should handle unsupported file extensions', async () => { + const filePath = path.join(tempDir, 'data.txt'); + fs.writeFileSync(filePath, 'some text data'); + + const resolver = new FileTickerResolver(filePath); + const result = await resolver.resolve('US0378331005', 'Apple Inc'); + + expect(result.ticker).toBeNull(); + }); + }); + + describe('Resolver Name', () => { + it('should have correct name property', () => { + const resolver = new FileTickerResolver('/dummy/path.json'); + expect(resolver.name).toBe('File Resolver'); + }); + }); +}); diff --git a/tests/integration.test.ts b/tests/integration.test.ts index fa36c04..19246a4 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -17,7 +17,7 @@ describe('Integration Tests with Mocks', () => { expect(transactions.length).toBeGreaterThan(0); expect(transactions[0]?.originalSource).toBe('Avanza'); - expect(transactions[0]?.symbol).toBe('Meta Platforms A'); + expect(transactions[0]?.name).toBe('Meta Platforms A'); }); it('should parse the Nordnet mock CSV correctly', () => { @@ -30,7 +30,7 @@ describe('Integration Tests with Mocks', () => { expect(transactions.length).toBeGreaterThan(0); expect(transactions[0]?.originalSource).toBe('Nordnet'); - expect(transactions[0]?.symbol).toBe('Netflix'); + expect(transactions[0]?.name).toBe('Netflix'); }); it('should auto-detect formats from mock files', () => { diff --git a/tests/parser_brokers.test.ts b/tests/parser_brokers.test.ts index a474b2f..7ac388a 100644 --- a/tests/parser_brokers.test.ts +++ b/tests/parser_brokers.test.ts @@ -22,7 +22,7 @@ describe('Broker Parsers', () => { const result = parseTransaction(row, 'Avanza'); expect(result).not.toBeNull(); - expect(result?.symbol).toBe('Meta Platforms A'); + expect(result?.name).toBe('Meta Platforms A'); expect(result?.type).toBe('BUY'); expect(result?.quantity).toBe(1); expect(result?.price).toBe(666.89); @@ -110,7 +110,7 @@ describe('Broker Parsers', () => { text: 'Some text', }; const result = parseTransaction(row, 'Avanza'); - expect(result?.symbol).toBe('Legacy Stock'); + expect(result?.name).toBe('Legacy Stock'); }); }); @@ -126,7 +126,7 @@ describe('Broker Parsers', () => { Transaktionsdag: '2023-01-01', // Explicit date }; const result = parseTransaction(row, 'Nordnet'); - expect(result?.symbol).toBe('Legacy Nordnet'); + expect(result?.name).toBe('Legacy Nordnet'); expect(result?.fee).toBe(10); }); @@ -156,7 +156,7 @@ describe('Broker Parsers', () => { const result = parseTransaction(row, 'Nordnet'); expect(result).not.toBeNull(); - expect(result?.symbol).toBe('Netflix'); + expect(result?.name).toBe('Netflix'); expect(result?.type).toBe('BUY'); expect(result?.quantity).toBe(50); expect(result?.price).toBe(102.98); // Native price diff --git a/tests/resolvers/yahoo.test.ts b/tests/resolvers/yahoo.test.ts new file mode 100644 index 0000000..a291626 --- /dev/null +++ b/tests/resolvers/yahoo.test.ts @@ -0,0 +1,324 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + YahooISINResolver, + YahooNameResolver, + YahooFullResolver, +} from '../../src/resolvers/yahoo'; + +// Mock yahoo-finance2 +vi.mock('yahoo-finance2', () => { + return { + default: vi.fn().mockImplementation(() => ({ + search: vi.fn(), + quote: vi.fn(), + quoteSummary: vi.fn(), + })), + }; +}); + +// Type for accessing protected yahooFinance property in tests +type ResolverWithYahooFinance = { + yahooFinance: { + search: ReturnType; + quote: ReturnType; + quoteSummary: ReturnType; + }; +}; + +describe('Yahoo Resolvers', () => { + describe('YahooISINResolver', () => { + let resolver: YahooISINResolver; + + beforeEach(() => { + resolver = new YahooISINResolver(); + }); + + it('should have correct name', () => { + expect(resolver.name).toBe('Yahoo ISIN'); + }); + + it('should return null ticker if no ISIN provided', async () => { + const result = await resolver.resolve('', 'Apple Inc'); + expect(result.ticker).toBeNull(); + }); + + it('should resolve ticker from ISIN search with equity match', async () => { + const mockSearch = vi.fn().mockResolvedValue({ + quotes: [ + { symbol: 'OTHER', quoteType: 'INDEX', currency: 'USD' }, + { symbol: 'AAPL', quoteType: 'EQUITY', currency: 'USD' }, + ], + }); + (resolver as unknown as ResolverWithYahooFinance).yahooFinance.search = + mockSearch; + + const result = await resolver.resolve('US0378331005', 'Apple Inc'); + + expect(result.ticker).toBe('AAPL'); + expect(result.currency).toBe('USD'); + expect(mockSearch).toHaveBeenCalledWith('US0378331005'); + }); + + it('should fallback to first result if no equity match', async () => { + const mockSearch = vi.fn().mockResolvedValue({ + quotes: [{ symbol: 'INDEX1', quoteType: 'INDEX', currency: 'USD' }], + }); + (resolver as unknown as ResolverWithYahooFinance).yahooFinance.search = + mockSearch; + + const result = await resolver.resolve('US0001', 'Test'); + + expect(result.ticker).toBe('INDEX1'); + }); + + it('should enrich currency if missing', async () => { + const mockSearch = vi.fn().mockResolvedValue({ + quotes: [{ symbol: 'AAPL', quoteType: 'EQUITY' }], + }); + const mockQuote = vi.fn().mockResolvedValue({ currency: 'USD' }); + (resolver as unknown as ResolverWithYahooFinance).yahooFinance.search = + mockSearch; + (resolver as unknown as ResolverWithYahooFinance).yahooFinance.quote = + mockQuote; + + const result = await resolver.resolve('US0378331005', 'Apple Inc'); + + expect(result.ticker).toBe('AAPL'); + expect(result.currency).toBe('USD'); + expect(mockQuote).toHaveBeenCalledWith('AAPL'); + }); + + it('should handle search errors gracefully', async () => { + const mockSearch = vi.fn().mockRejectedValue(new Error('API Error')); + (resolver as unknown as ResolverWithYahooFinance).yahooFinance.search = + mockSearch; + + const result = await resolver.resolve('INVALID', 'Test'); + + expect(result.ticker).toBeNull(); + }); + }); + + describe('YahooNameResolver', () => { + let resolver: YahooNameResolver; + + beforeEach(() => { + resolver = new YahooNameResolver(); + }); + + it('should have correct name', () => { + expect(resolver.name).toBe('Yahoo Name'); + }); + + it('should handle Alphabet Class C correctly', async () => { + const mockSearch = vi.fn().mockResolvedValue({ + quotes: [ + { + symbol: 'GOOG', + quoteType: 'EQUITY', + exchange: 'NMS', + currency: 'USD', + }, + { + symbol: 'GOOGL', + quoteType: 'EQUITY', + exchange: 'NMS', + currency: 'USD', + }, + ], + }); + (resolver as unknown as ResolverWithYahooFinance).yahooFinance.search = + mockSearch; + + const result = await resolver.resolve('', 'Alphabet Inc Class C'); + + expect(result.ticker).toBe('GOOG'); + expect(mockSearch).toHaveBeenCalledWith('Alphabet Inc'); + }); + + it('should handle Alphabet Class A correctly', async () => { + const mockSearch = vi.fn().mockResolvedValue({ + quotes: [ + { + symbol: 'GOOG', + quoteType: 'EQUITY', + exchange: 'NMS', + currency: 'USD', + }, + { + symbol: 'GOOGL', + quoteType: 'EQUITY', + exchange: 'NMS', + currency: 'USD', + }, + ], + }); + (resolver as unknown as ResolverWithYahooFinance).yahooFinance.search = + mockSearch; + + const result = await resolver.resolve('', 'Alphabet Inc Class A'); + + expect(result.ticker).toBe('GOOGL'); + }); + + it('should filter by preferred exchanges', async () => { + const mockSearch = vi.fn().mockResolvedValue({ + quotes: [ + { + symbol: 'AAPL.L', + quoteType: 'EQUITY', + exchange: 'LSE', + longname: 'Apple Inc', + }, + { + symbol: 'AAPL', + quoteType: 'EQUITY', + exchange: 'NMS', + longname: 'Apple Inc', + currency: 'USD', + }, + ], + }); + (resolver as unknown as ResolverWithYahooFinance).yahooFinance.search = + mockSearch; + + const result = await resolver.resolve('', 'Apple Inc'); + + expect(result.ticker).toBe('AAPL'); + }); + + it('should match by name prefix', async () => { + const mockSearch = vi.fn().mockResolvedValue({ + quotes: [ + { + symbol: 'AAPL', + quoteType: 'EQUITY', + exchange: 'NMS', + longname: 'Apple Inc', + currency: 'USD', + }, + { + symbol: 'AAPL2', + quoteType: 'EQUITY', + exchange: 'NMS', + longname: 'Apple Inc Long Name', + currency: 'USD', + }, + ], + }); + (resolver as unknown as ResolverWithYahooFinance).yahooFinance.search = + mockSearch; + + const result = await resolver.resolve('', 'Apple Inc'); + + expect(result.ticker).toBe('AAPL'); + }); + + it('should fallback to any equity if no preferred exchange match', async () => { + const mockSearch = vi.fn().mockResolvedValue({ + quotes: [ + { symbol: 'INDEX', quoteType: 'INDEX', exchange: 'OTHER' }, + { + symbol: 'STOCK', + quoteType: 'EQUITY', + exchange: 'OTHER', + currency: 'EUR', + }, + ], + }); + (resolver as unknown as ResolverWithYahooFinance).yahooFinance.search = + mockSearch; + + const result = await resolver.resolve('', 'Some Stock'); + + expect(result.ticker).toBe('STOCK'); + }); + + it('should enrich currency using quoteSummary if quote fails', async () => { + const mockSearch = vi.fn().mockResolvedValue({ + quotes: [ + { + symbol: 'AAPL', + quoteType: 'EQUITY', + exchange: 'NMS', + longname: 'Apple Inc', + }, + ], + }); + const mockQuote = vi.fn().mockResolvedValue({}); + const mockQuoteSummary = vi.fn().mockResolvedValue({ + price: { currency: 'USD' }, + }); + (resolver as unknown as ResolverWithYahooFinance).yahooFinance.search = + mockSearch; + (resolver as unknown as ResolverWithYahooFinance).yahooFinance.quote = + mockQuote; + ( + resolver as unknown as ResolverWithYahooFinance + ).yahooFinance.quoteSummary = mockQuoteSummary; + + const result = await resolver.resolve('', 'Apple Inc'); + + expect(result.currency).toBe('USD'); + }); + + it('should handle search errors gracefully', async () => { + const mockSearch = vi.fn().mockRejectedValue(new Error('API Error')); + (resolver as unknown as ResolverWithYahooFinance).yahooFinance.search = + mockSearch; + + const result = await resolver.resolve('', 'Test'); + + expect(result.ticker).toBeNull(); + }); + }); + + describe('YahooFullResolver', () => { + let resolver: YahooFullResolver; + + beforeEach(() => { + resolver = new YahooFullResolver(); + }); + + it('should have correct name', () => { + expect(resolver.name).toBe('Yahoo Full'); + }); + + it('should use ISIN resolver first', async () => { + const mockSearch = vi.fn().mockResolvedValue({ + quotes: [{ symbol: 'AAPL', quoteType: 'EQUITY', currency: 'USD' }], + }); + (resolver as unknown as ResolverWithYahooFinance).yahooFinance.search = + mockSearch; + + const result = await resolver.resolve('US0378331005', 'Apple Inc'); + + expect(result.ticker).toBe('AAPL'); + expect(mockSearch).toHaveBeenCalledWith('US0378331005'); + }); + + it('should fallback to name resolver if ISIN fails', async () => { + const mockSearch = vi + .fn() + .mockResolvedValueOnce({ quotes: [] }) + .mockResolvedValueOnce({ + quotes: [ + { + symbol: 'AAPL', + quoteType: 'EQUITY', + exchange: 'NMS', + longname: 'Apple Inc', + currency: 'USD', + }, + ], + }); + (resolver as unknown as ResolverWithYahooFinance).yahooFinance.search = + mockSearch; + + const result = await resolver.resolve('INVALID', 'Apple Inc'); + + expect(result.ticker).toBe('AAPL'); + expect(mockSearch).toHaveBeenCalledTimes(2); + }); + }); +}); From aa67f91453cad1efc80d85284f0385579d598ed3 Mon Sep 17 00:00:00 2001 From: Abhishek Jain Date: Sun, 11 Jan 2026 18:04:36 +0100 Subject: [PATCH 08/13] fix: Remove unnecessary file --- eslint.config copy.mjs | 32 -------------------------------- 1 file changed, 32 deletions(-) delete mode 100644 eslint.config copy.mjs diff --git a/eslint.config copy.mjs b/eslint.config copy.mjs deleted file mode 100644 index 99e9e99..0000000 --- a/eslint.config copy.mjs +++ /dev/null @@ -1,32 +0,0 @@ -import { defineConfig, globalIgnores } from 'eslint/config'; -import nextVitals from 'eslint-config-next/core-web-vitals'; -import nextTs from 'eslint-config-next/typescript'; - -const eslintConfig = defineConfig([ - ...nextVitals, - ...nextTs, - // Override default ignores of eslint-config-next. - globalIgnores([ - // Default ignores of eslint-config-next: - '.next/**', - 'out/**', - 'build/**', - 'next-env.d.ts', - 'node_modules/**', - ]), - { - rules: { - '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/no-unused-vars': [ - 'warn', - { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - }, - ], - quotes: ['error', 'single', { avoidEscape: true }], - }, - }, -]); - -export default eslintConfig; From 09e5e65c8d4281353e4d50552cd5cea6d897663c Mon Sep 17 00:00:00 2001 From: Abhishek Jain Date: Sun, 11 Jan 2026 18:07:59 +0100 Subject: [PATCH 09/13] docs: Fix incorrect doc --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c1965ad..3141cd4 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ const myResolver = { // 2. Enrich const enriched = await enrichTransactions(parsedTransactions, { - resolver: myResolver, + resolvers: [myResolver], }); // 3. Export From ad363b2492e5dcea592eab35f7f766e9b3db711b Mon Sep 17 00:00:00 2001 From: Abhishek Jain Date: Sun, 11 Jan 2026 18:14:27 +0100 Subject: [PATCH 10/13] docs: Add changelog --- CHANGELOG.md | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54c2326..e7e80d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,67 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.0] - 2026-01-11 + +### Added + +- **Multi-Resolver Ticker Enrichment**: Implemented stacked resolver architecture allowing multiple ticker resolution strategies with fallback logic +- **Specialized Yahoo Finance Resolvers**: + - `YahooISINResolver`: High-accuracy ISIN-based ticker resolution + - `YahooNameResolver`: Advanced name-based search with Alphabet Inc Class A/C handling + - `YahooFullResolver`: Combined resolver using both strategies +- **CLI Enhancements**: + - `--yahoo-isin`: Enable ISIN-only resolution + - `--yahoo-name`: Enable name-only resolution + - `--no-yahoo`: Disable automatic Yahoo Finance resolution + - Yahoo Finance resolution now enabled by default +- **Coding Guidelines**: Added `CODING_GUIDELINES.md` with strict terminology standards for `ticker`, `name`, and `symbol` +- **Comprehensive Test Suite**: + - Added 50+ new tests across 5 new test files + - `tests/cache.test.ts`: LocalFileTickerCache tests + - `tests/enricher_advanced.test.ts`: Multi-resolver stacking and edge cases + - `tests/file_resolver_extended.test.ts`: All file format variations + - `tests/resolvers/yahoo.test.ts`: Complete Yahoo resolver coverage + - Achieved 95%+ code coverage on resolvers and enrichment logic + +### Changed + +- **BREAKING**: Renamed `symbol` field to `name` in `ParsedTransaction` type to clearly distinguish company names from ticker symbols +- **BREAKING**: `enrichTransactions` now accepts `resolvers: TickerResolver[]` (array) instead of `resolver: TickerResolver` (singular) +- Refactored `TickerResolver` interface to require `name` property for better logging and identification +- Updated all parsers (Avanza, Nordnet) to use `name` field instead of `symbol` +- Enhanced Yahoo Finance integration with proper TypeScript types, eliminating all `any` usages +- Improved currency enrichment with fallback chain: `quote()` → `quoteSummary()` +- Updated documentation with resolver stacking examples and terminology reference + +### Fixed + +- Fixed documentation example showing incorrect `resolver` usage (now correctly uses `resolvers` array) +- Removed backward compatibility for deprecated `symbol` field in `FileTickerResolver` +- Fixed all ESLint warnings by adding proper TypeScript types throughout codebase + +### Removed + +- Removed `eslint.config copy.mjs` (leftover file with Next.js-specific config) +- Removed backward compatibility for `symbol` field in ticker mapping files (JSON/CSV) + +### Documentation + +- Updated README with comprehensive examples for: + - Multi-resolver stacking + - All supported ticker mapping formats (JSON object/array, CSV) + - CLI usage with new resolver options +- Added terminology standards section linking to `CODING_GUIDELINES.md` +- Clarified distinction between `name` (company name) and `ticker` (stock symbol) + +### Technical Improvements + +- All 84 tests passing with zero linting warnings +- 100% function coverage across all modules +- Type-safe Yahoo Finance API integration +- Proper error handling in all resolvers +- Exchange filtering (NMS, NYQ, NGM) for higher quality ticker matches + ## [0.0.1] - 2024-01-11 ### Added diff --git a/package.json b/package.json index 7f15f24..93a4039 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@logkat/broker-parser", - "version": "0.0.1", + "version": "0.1.0", "description": "A robust, standalone TypeScript library for parsing transaction CSV exports from various stock brokers.", "keywords": [ "broker", From d82c559b4da115690042e3ea003c66b41f9d4902 Mon Sep 17 00:00:00 2001 From: Abhishek Jain Date: Sun, 11 Jan 2026 18:17:30 +0100 Subject: [PATCH 11/13] chore: Update the workflow for lint and format --- .github/workflows/ci.yml | 14 ++++++++++---- CHANGELOG.md | 4 ++++ README.md | 34 ++++++++++++++++++++++++++++++++-- 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e22f422..3b82c6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,10 +20,16 @@ jobs: node-version: '22' cache: 'pnpm' - run: pnpm install --frozen-lockfile - - run: pnpm run type-check - - run: pnpm run lint - - run: pnpm run test:coverage - - run: pnpm run build + - name: Check code formatting + run: pnpm run format:check + - name: Type check + run: pnpm run type-check + - name: Lint + run: pnpm run lint + - name: Build + run: pnpm run build + - name: Run tests with coverage + run: pnpm run test:coverage - name: Verify Changelog on Version Change if: github.event_name == 'pull_request' run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index e7e80d9..0405014 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `tests/file_resolver_extended.test.ts`: All file format variations - `tests/resolvers/yahoo.test.ts`: Complete Yahoo resolver coverage - Achieved 95%+ code coverage on resolvers and enrichment logic +- **CI/CD Enhancements**: + - Added Prettier formatting check to CI pipeline + - Reorganized CI steps for fail-fast approach (format → type-check → lint → build → test) + - All quality gates now enforced automatically on every PR and push ### Changed diff --git a/README.md b/README.md index 3141cd4..8ddbe3f 100644 --- a/README.md +++ b/README.md @@ -284,18 +284,48 @@ We welcome contributions! To add support for a new broker: ## Development & Testing 1. **Install Dependencies**: + ```bash pnpm install ``` -2. **Run Tests**: + +2. **Run Quality Checks**: + + ```bash + # Format code + pnpm format + + # Check formatting + pnpm format:check + + # Type check + pnpm type-check + + # Lint + pnpm lint + ``` + +3. **Run Tests**: + ```bash + # Run tests in watch mode pnpm test + + # Run tests with coverage + pnpm test:coverage ``` -3. **Build**: + +4. **Build**: + ```bash pnpm build ``` +5. **Run All Checks** (same as CI): + ```bash + pnpm format:check && pnpm type-check && pnpm lint && pnpm build && pnpm test:coverage + ``` + ## License MIT From c3ac95a2b93298c7280b5e9cbbfa8d3f472f96de Mon Sep 17 00:00:00 2001 From: Abhishek Jain Date: Sun, 11 Jan 2026 18:28:02 +0100 Subject: [PATCH 12/13] fix: Fix CLI version --- src/cache.ts | 42 ++++++++++++++++++++++++++++++------- src/cli.ts | 49 ++++++++++++++++++++++++------------------- src/resolvers/file.ts | 39 +++++++++++++--------------------- tests/cache.test.ts | 34 ++++++++++++++++-------------- tsconfig.json | 3 ++- 5 files changed, 97 insertions(+), 70 deletions(-) diff --git a/src/cache.ts b/src/cache.ts index e671e85..3a075df 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -2,38 +2,66 @@ import fs from 'fs'; import { TickerCache, TickerResolution } from './enricher'; export class LocalFileTickerCache implements TickerCache { - private cache: Record = {}; + private cache: Record={}; private filePath: string; + private dirty=false; + private saveTimer: NodeJS.Timeout|null=null; constructor(filePath: string) { - this.filePath = filePath; + this.filePath=filePath; this.load(); + + // Ensure cache is saved on process exit + process.on('beforeExit', () => { + this.flush(); + }); } private load() { if (fs.existsSync(this.filePath)) { try { - this.cache = JSON.parse(fs.readFileSync(this.filePath, 'utf8')); + this.cache=JSON.parse(fs.readFileSync(this.filePath, 'utf8')); } catch (e) { console.warn(`Failed to load ticker cache from ${this.filePath}`); } } } - private save() { + private scheduleSave() { + // Debounce saves: only write to disk after 1 second of no new writes + if (this.saveTimer) { + clearTimeout(this.saveTimer); + } + this.saveTimer=setTimeout(() => { + this.flush(); + }, 1000); + } + + /** + * Immediately flush the cache to disk + */ + flush() { + if (!this.dirty) return; + try { fs.writeFileSync(this.filePath, JSON.stringify(this.cache, null, 2)); + this.dirty=false; + if (this.saveTimer) { + clearTimeout(this.saveTimer); + this.saveTimer=null; + } } catch (e) { console.warn(`Failed to save ticker cache to ${this.filePath}`); } } - async get(key: string): Promise { + async get(key: string): Promise { return this.cache[key]; } async set(key: string, value: TickerResolution): Promise { - this.cache[key] = value; - this.save(); + this.cache[key]=value; + this.dirty=true; + this.scheduleSave(); } } diff --git a/src/cli.ts b/src/cli.ts index 55dd7c7..5cc98ec 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,12 +15,17 @@ import { } from './index'; import { BrokerFormat } from './parsers/types'; -const program = new Command(); +// Read version from package.json at runtime +const packageJson=JSON.parse( + fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8') +); + +const program=new Command(); program .name('broker-parser') .description('Parse broker transaction CSVs and export to other formats') - .version('0.0.1'); + .version(packageJson.version); program .command('export') @@ -52,39 +57,39 @@ program .option('--no-yahoo', 'Disable automatic Yahoo Finance resolution') .option('--no-cache', 'Disable caching') .action(async (file, options) => { - const filePath = path.resolve(file); + const filePath=path.resolve(file); if (!fs.existsSync(filePath)) { console.error(`Error: File not found at ${filePath}`); process.exit(1); } - const csvData = fs.readFileSync(filePath, 'utf8'); - const parsedCsv = Papa.parse(csvData, { + const csvData=fs.readFileSync(filePath, 'utf8'); + const parsedCsv=Papa.parse(csvData, { header: true, skipEmptyLines: true, }); - if (parsedCsv.errors.length > 0) { + if (parsedCsv.errors.length>0) { console.warn('Warning: Some errors occurred during CSV parsing:'); console.warn(parsedCsv.errors); } - const data = parsedCsv.data as Record[]; - const transactions = data + const data=parsedCsv.data as Record[]; + const transactions=data .map((row) => parseTransaction(row, options.format as BrokerFormat)) - .filter((t): t is ParsedTransaction => t !== null); + .filter((t): t is ParsedTransaction => t!==null); - if (transactions.length === 0) { + if (transactions.length===0) { console.error('Error: No transactions could be parsed from the file.'); process.exit(1); } console.log(`Successfully parsed ${transactions.length} transactions.`); - let processedTransactions = transactions; + let processedTransactions=transactions; // resolver stacking - const resolvers = []; + const resolvers=[]; // 1. File Resolver (Highest priority if provided) if (options.tickerFile) { @@ -92,31 +97,31 @@ program } // 2. Yahoo ISIN Resolver (High accuracy) - if (options.yahoo !== false || options.yahooIsin) { + if (options.yahoo!==false||options.yahooIsin) { resolvers.push(new YahooISINResolver()); } // 3. Yahoo Name Resolver (Fallback) - if (options.yahoo !== false || options.yahooName) { + if (options.yahoo!==false||options.yahooName) { resolvers.push(new YahooNameResolver()); } - if (resolvers.length > 0) { + if (resolvers.length>0) { console.log( `Resolving tickers using: ${resolvers.map((r) => r.name).join(', ')}...` ); let cache; - if (options.cache !== false) { - cache = new LocalFileTickerCache(path.resolve(options.cache)); + if (options.cache!==false) { + cache=new LocalFileTickerCache(path.resolve(options.cache)); } - processedTransactions = await enrichTransactions(transactions, { + processedTransactions=await enrichTransactions(transactions, { resolvers, cache, }); - const resolvedCount = processedTransactions.filter( + const resolvedCount=processedTransactions.filter( (t) => t.ticker ).length; console.log( @@ -125,14 +130,14 @@ program } let result; - if (options.exporter === 'yahoo') { - result = YahooFinanceExporter.export(processedTransactions); + if (options.exporter==='yahoo') { + result=YahooFinanceExporter.export(processedTransactions); } else { console.error(`Error: Unknown exporter "${options.exporter}"`); process.exit(1); } - const outputPath = options.output || path.resolve(result.filename); + const outputPath=options.output||path.resolve(result.filename); fs.writeFileSync(outputPath, result.content); console.log(`Success! Exported to ${outputPath}`); }); diff --git a/src/resolvers/file.ts b/src/resolvers/file.ts index 5b09f26..8412ddb 100644 --- a/src/resolvers/file.ts +++ b/src/resolvers/file.ts @@ -3,18 +3,9 @@ import path from 'path'; import Papa from 'papaparse'; import { TickerResolver, TickerResolution } from '../enricher'; -export interface FileResolverOptions { - filePath: string; - /** - * Map of ISIN or Name to Ticker - * For JSON, it should be an object or an array of objects. - * For CSV, it should have headers like 'isin', 'name', 'ticker'. - */ -} - export class FileTickerResolver implements TickerResolver { - name = 'File Resolver'; - private mappings: Map = new Map(); + name='File Resolver'; + private mappings: Map=new Map(); constructor(filePath: string) { this.load(filePath); @@ -26,35 +17,35 @@ export class FileTickerResolver implements TickerResolver { return; } - const ext = path.extname(filePath).toLowerCase(); - const content = fs.readFileSync(filePath, 'utf8'); + const ext=path.extname(filePath).toLowerCase(); + const content=fs.readFileSync(filePath, 'utf8'); - if (ext === '.json') { + if (ext==='.json') { try { - const data = JSON.parse(content); + const data=JSON.parse(content); if (Array.isArray(data)) { data.forEach((item: any) => { - const key = item.isin || item.name; - if (key && item.ticker) this.mappings.set(key, item.ticker); + const key=item.isin||item.name; + if (key&&item.ticker) this.mappings.set(key, item.ticker); }); } else { Object.entries(data).forEach(([key, value]) => { - if (typeof value === 'string') this.mappings.set(key, value); + if (typeof value==='string') this.mappings.set(key, value); }); } } catch (e) { console.error(`Error parsing JSON ticker file: ${e}`); } - } else if (ext === '.csv') { + } else if (ext==='.csv') { try { - const results = Papa.parse(content, { + const results=Papa.parse(content, { header: true, skipEmptyLines: true, }); (results.data as any[]).forEach((row) => { - const key = row.isin || row.name || row.ISIN; - const ticker = row.ticker || row.Ticker; - if (key && ticker) this.mappings.set(key, ticker); + const key=row.isin||row.name||row.ISIN; + const ticker=row.ticker||row.Ticker; + if (key&&ticker) this.mappings.set(key, ticker); }); } catch (e) { console.error(`Error parsing CSV ticker file: ${e}`); @@ -63,7 +54,7 @@ export class FileTickerResolver implements TickerResolver { } async resolve(isin: string, name: string): Promise { - const ticker = this.mappings.get(isin) || this.mappings.get(name) || null; + const ticker=this.mappings.get(isin)||this.mappings.get(name)||null; return { ticker }; } } diff --git a/tests/cache.test.ts b/tests/cache.test.ts index 6379e39..c9c2b23 100644 --- a/tests/cache.test.ts +++ b/tests/cache.test.ts @@ -4,7 +4,7 @@ import fs from 'fs'; import path from 'path'; describe('LocalFileTickerCache', () => { - const tempCachePath = path.join(__dirname, 'temp_cache.json'); + const tempCachePath=path.join(__dirname, 'temp_cache.json'); afterEach(() => { if (fs.existsSync(tempCachePath)) { @@ -13,40 +13,42 @@ describe('LocalFileTickerCache', () => { }); it('should create cache file if it does not exist', async () => { - const cache = new LocalFileTickerCache(tempCachePath); + const cache=new LocalFileTickerCache(tempCachePath); await cache.set('US0378331005', { ticker: 'AAPL', currency: 'USD' }); + cache.flush(); // Ensure file is written expect(fs.existsSync(tempCachePath)).toBe(true); }); it('should store and retrieve ticker resolutions', async () => { - const cache = new LocalFileTickerCache(tempCachePath); + const cache=new LocalFileTickerCache(tempCachePath); await cache.set('US0378331005', { ticker: 'AAPL', currency: 'USD' }); - const result = await cache.get('US0378331005'); + const result=await cache.get('US0378331005'); expect(result).toEqual({ ticker: 'AAPL', currency: 'USD' }); }); it('should return undefined for non-existent keys', async () => { - const cache = new LocalFileTickerCache(tempCachePath); - const result = await cache.get('NONEXISTENT'); + const cache=new LocalFileTickerCache(tempCachePath); + const result=await cache.get('NONEXISTENT'); expect(result).toBeUndefined(); }); it('should persist data across instances', async () => { - const cache1 = new LocalFileTickerCache(tempCachePath); + const cache1=new LocalFileTickerCache(tempCachePath); await cache1.set('US0378331005', { ticker: 'AAPL', currency: 'USD' }); + cache1.flush(); // Ensure data is written to disk - const cache2 = new LocalFileTickerCache(tempCachePath); - const result = await cache2.get('US0378331005'); + const cache2=new LocalFileTickerCache(tempCachePath); + const result=await cache2.get('US0378331005'); expect(result).toEqual({ ticker: 'AAPL', currency: 'USD' }); }); it('should handle multiple entries', async () => { - const cache = new LocalFileTickerCache(tempCachePath); + const cache=new LocalFileTickerCache(tempCachePath); await cache.set('US0378331005', { ticker: 'AAPL', currency: 'USD' }); await cache.set('US30303M1027', { ticker: 'META', currency: 'USD' }); @@ -67,29 +69,29 @@ describe('LocalFileTickerCache', () => { }); it('should overwrite existing entries', async () => { - const cache = new LocalFileTickerCache(tempCachePath); + const cache=new LocalFileTickerCache(tempCachePath); await cache.set('US0378331005', { ticker: 'AAPL', currency: 'USD' }); await cache.set('US0378331005', { ticker: 'AAPL', currency: 'EUR' }); - const result = await cache.get('US0378331005'); + const result=await cache.get('US0378331005'); expect(result?.currency).toBe('EUR'); }); it('should handle empty cache file gracefully', async () => { fs.writeFileSync(tempCachePath, '{}'); - const cache = new LocalFileTickerCache(tempCachePath); + const cache=new LocalFileTickerCache(tempCachePath); - const result = await cache.get('US0378331005'); + const result=await cache.get('US0378331005'); expect(result).toBeUndefined(); }); it('should handle corrupted cache file gracefully', async () => { fs.writeFileSync(tempCachePath, 'invalid json'); - const cache = new LocalFileTickerCache(tempCachePath); + const cache=new LocalFileTickerCache(tempCachePath); await cache.set('US0378331005', { ticker: 'AAPL', currency: 'USD' }); - const result = await cache.get('US0378331005'); + const result=await cache.get('US0378331005'); expect(result).toEqual({ ticker: 'AAPL', currency: 'USD' }); }); diff --git a/tsconfig.json b/tsconfig.json index 5b0c7c5..a6cce00 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,8 @@ "outDir": "./dist", "strict": true, "esModuleInterop": true, - "skipLibCheck": true + "skipLibCheck": true, + "resolveJsonModule": true }, "include": ["src"] } From 8571f7d329097015fad5290bd15b5fbd2fc8ed0b Mon Sep 17 00:00:00 2001 From: Abhishek Jain Date: Sun, 11 Jan 2026 18:33:28 +0100 Subject: [PATCH 13/13] chore: FIx format --- src/cache.ts | 22 +++++++++++----------- src/cli.ts | 44 +++++++++++++++++++++---------------------- src/resolvers/file.ts | 30 ++++++++++++++--------------- tests/cache.test.ts | 32 +++++++++++++++---------------- 4 files changed, 64 insertions(+), 64 deletions(-) diff --git a/src/cache.ts b/src/cache.ts index 3a075df..8ab10e2 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -2,13 +2,13 @@ import fs from 'fs'; import { TickerCache, TickerResolution } from './enricher'; export class LocalFileTickerCache implements TickerCache { - private cache: Record={}; + private cache: Record = {}; private filePath: string; - private dirty=false; - private saveTimer: NodeJS.Timeout|null=null; + private dirty = false; + private saveTimer: NodeJS.Timeout | null = null; constructor(filePath: string) { - this.filePath=filePath; + this.filePath = filePath; this.load(); // Ensure cache is saved on process exit @@ -20,7 +20,7 @@ export class LocalFileTickerCache implements TickerCache { private load() { if (fs.existsSync(this.filePath)) { try { - this.cache=JSON.parse(fs.readFileSync(this.filePath, 'utf8')); + this.cache = JSON.parse(fs.readFileSync(this.filePath, 'utf8')); } catch (e) { console.warn(`Failed to load ticker cache from ${this.filePath}`); } @@ -32,7 +32,7 @@ export class LocalFileTickerCache implements TickerCache { if (this.saveTimer) { clearTimeout(this.saveTimer); } - this.saveTimer=setTimeout(() => { + this.saveTimer = setTimeout(() => { this.flush(); }, 1000); } @@ -45,23 +45,23 @@ export class LocalFileTickerCache implements TickerCache { try { fs.writeFileSync(this.filePath, JSON.stringify(this.cache, null, 2)); - this.dirty=false; + this.dirty = false; if (this.saveTimer) { clearTimeout(this.saveTimer); - this.saveTimer=null; + this.saveTimer = null; } } catch (e) { console.warn(`Failed to save ticker cache to ${this.filePath}`); } } - async get(key: string): Promise { + async get(key: string): Promise { return this.cache[key]; } async set(key: string, value: TickerResolution): Promise { - this.cache[key]=value; - this.dirty=true; + this.cache[key] = value; + this.dirty = true; this.scheduleSave(); } } diff --git a/src/cli.ts b/src/cli.ts index 5cc98ec..56b2881 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,11 +16,11 @@ import { import { BrokerFormat } from './parsers/types'; // Read version from package.json at runtime -const packageJson=JSON.parse( +const packageJson = JSON.parse( fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8') ); -const program=new Command(); +const program = new Command(); program .name('broker-parser') @@ -57,39 +57,39 @@ program .option('--no-yahoo', 'Disable automatic Yahoo Finance resolution') .option('--no-cache', 'Disable caching') .action(async (file, options) => { - const filePath=path.resolve(file); + const filePath = path.resolve(file); if (!fs.existsSync(filePath)) { console.error(`Error: File not found at ${filePath}`); process.exit(1); } - const csvData=fs.readFileSync(filePath, 'utf8'); - const parsedCsv=Papa.parse(csvData, { + const csvData = fs.readFileSync(filePath, 'utf8'); + const parsedCsv = Papa.parse(csvData, { header: true, skipEmptyLines: true, }); - if (parsedCsv.errors.length>0) { + if (parsedCsv.errors.length > 0) { console.warn('Warning: Some errors occurred during CSV parsing:'); console.warn(parsedCsv.errors); } - const data=parsedCsv.data as Record[]; - const transactions=data + const data = parsedCsv.data as Record[]; + const transactions = data .map((row) => parseTransaction(row, options.format as BrokerFormat)) - .filter((t): t is ParsedTransaction => t!==null); + .filter((t): t is ParsedTransaction => t !== null); - if (transactions.length===0) { + if (transactions.length === 0) { console.error('Error: No transactions could be parsed from the file.'); process.exit(1); } console.log(`Successfully parsed ${transactions.length} transactions.`); - let processedTransactions=transactions; + let processedTransactions = transactions; // resolver stacking - const resolvers=[]; + const resolvers = []; // 1. File Resolver (Highest priority if provided) if (options.tickerFile) { @@ -97,31 +97,31 @@ program } // 2. Yahoo ISIN Resolver (High accuracy) - if (options.yahoo!==false||options.yahooIsin) { + if (options.yahoo !== false || options.yahooIsin) { resolvers.push(new YahooISINResolver()); } // 3. Yahoo Name Resolver (Fallback) - if (options.yahoo!==false||options.yahooName) { + if (options.yahoo !== false || options.yahooName) { resolvers.push(new YahooNameResolver()); } - if (resolvers.length>0) { + if (resolvers.length > 0) { console.log( `Resolving tickers using: ${resolvers.map((r) => r.name).join(', ')}...` ); let cache; - if (options.cache!==false) { - cache=new LocalFileTickerCache(path.resolve(options.cache)); + if (options.cache !== false) { + cache = new LocalFileTickerCache(path.resolve(options.cache)); } - processedTransactions=await enrichTransactions(transactions, { + processedTransactions = await enrichTransactions(transactions, { resolvers, cache, }); - const resolvedCount=processedTransactions.filter( + const resolvedCount = processedTransactions.filter( (t) => t.ticker ).length; console.log( @@ -130,14 +130,14 @@ program } let result; - if (options.exporter==='yahoo') { - result=YahooFinanceExporter.export(processedTransactions); + if (options.exporter === 'yahoo') { + result = YahooFinanceExporter.export(processedTransactions); } else { console.error(`Error: Unknown exporter "${options.exporter}"`); process.exit(1); } - const outputPath=options.output||path.resolve(result.filename); + const outputPath = options.output || path.resolve(result.filename); fs.writeFileSync(outputPath, result.content); console.log(`Success! Exported to ${outputPath}`); }); diff --git a/src/resolvers/file.ts b/src/resolvers/file.ts index 8412ddb..1e0fe2f 100644 --- a/src/resolvers/file.ts +++ b/src/resolvers/file.ts @@ -4,8 +4,8 @@ import Papa from 'papaparse'; import { TickerResolver, TickerResolution } from '../enricher'; export class FileTickerResolver implements TickerResolver { - name='File Resolver'; - private mappings: Map=new Map(); + name = 'File Resolver'; + private mappings: Map = new Map(); constructor(filePath: string) { this.load(filePath); @@ -17,35 +17,35 @@ export class FileTickerResolver implements TickerResolver { return; } - const ext=path.extname(filePath).toLowerCase(); - const content=fs.readFileSync(filePath, 'utf8'); + const ext = path.extname(filePath).toLowerCase(); + const content = fs.readFileSync(filePath, 'utf8'); - if (ext==='.json') { + if (ext === '.json') { try { - const data=JSON.parse(content); + const data = JSON.parse(content); if (Array.isArray(data)) { data.forEach((item: any) => { - const key=item.isin||item.name; - if (key&&item.ticker) this.mappings.set(key, item.ticker); + const key = item.isin || item.name; + if (key && item.ticker) this.mappings.set(key, item.ticker); }); } else { Object.entries(data).forEach(([key, value]) => { - if (typeof value==='string') this.mappings.set(key, value); + if (typeof value === 'string') this.mappings.set(key, value); }); } } catch (e) { console.error(`Error parsing JSON ticker file: ${e}`); } - } else if (ext==='.csv') { + } else if (ext === '.csv') { try { - const results=Papa.parse(content, { + const results = Papa.parse(content, { header: true, skipEmptyLines: true, }); (results.data as any[]).forEach((row) => { - const key=row.isin||row.name||row.ISIN; - const ticker=row.ticker||row.Ticker; - if (key&&ticker) this.mappings.set(key, ticker); + const key = row.isin || row.name || row.ISIN; + const ticker = row.ticker || row.Ticker; + if (key && ticker) this.mappings.set(key, ticker); }); } catch (e) { console.error(`Error parsing CSV ticker file: ${e}`); @@ -54,7 +54,7 @@ export class FileTickerResolver implements TickerResolver { } async resolve(isin: string, name: string): Promise { - const ticker=this.mappings.get(isin)||this.mappings.get(name)||null; + const ticker = this.mappings.get(isin) || this.mappings.get(name) || null; return { ticker }; } } diff --git a/tests/cache.test.ts b/tests/cache.test.ts index c9c2b23..660f92f 100644 --- a/tests/cache.test.ts +++ b/tests/cache.test.ts @@ -4,7 +4,7 @@ import fs from 'fs'; import path from 'path'; describe('LocalFileTickerCache', () => { - const tempCachePath=path.join(__dirname, 'temp_cache.json'); + const tempCachePath = path.join(__dirname, 'temp_cache.json'); afterEach(() => { if (fs.existsSync(tempCachePath)) { @@ -13,7 +13,7 @@ describe('LocalFileTickerCache', () => { }); it('should create cache file if it does not exist', async () => { - const cache=new LocalFileTickerCache(tempCachePath); + const cache = new LocalFileTickerCache(tempCachePath); await cache.set('US0378331005', { ticker: 'AAPL', currency: 'USD' }); cache.flush(); // Ensure file is written @@ -21,34 +21,34 @@ describe('LocalFileTickerCache', () => { }); it('should store and retrieve ticker resolutions', async () => { - const cache=new LocalFileTickerCache(tempCachePath); + const cache = new LocalFileTickerCache(tempCachePath); await cache.set('US0378331005', { ticker: 'AAPL', currency: 'USD' }); - const result=await cache.get('US0378331005'); + const result = await cache.get('US0378331005'); expect(result).toEqual({ ticker: 'AAPL', currency: 'USD' }); }); it('should return undefined for non-existent keys', async () => { - const cache=new LocalFileTickerCache(tempCachePath); - const result=await cache.get('NONEXISTENT'); + const cache = new LocalFileTickerCache(tempCachePath); + const result = await cache.get('NONEXISTENT'); expect(result).toBeUndefined(); }); it('should persist data across instances', async () => { - const cache1=new LocalFileTickerCache(tempCachePath); + const cache1 = new LocalFileTickerCache(tempCachePath); await cache1.set('US0378331005', { ticker: 'AAPL', currency: 'USD' }); cache1.flush(); // Ensure data is written to disk - const cache2=new LocalFileTickerCache(tempCachePath); - const result=await cache2.get('US0378331005'); + const cache2 = new LocalFileTickerCache(tempCachePath); + const result = await cache2.get('US0378331005'); expect(result).toEqual({ ticker: 'AAPL', currency: 'USD' }); }); it('should handle multiple entries', async () => { - const cache=new LocalFileTickerCache(tempCachePath); + const cache = new LocalFileTickerCache(tempCachePath); await cache.set('US0378331005', { ticker: 'AAPL', currency: 'USD' }); await cache.set('US30303M1027', { ticker: 'META', currency: 'USD' }); @@ -69,29 +69,29 @@ describe('LocalFileTickerCache', () => { }); it('should overwrite existing entries', async () => { - const cache=new LocalFileTickerCache(tempCachePath); + const cache = new LocalFileTickerCache(tempCachePath); await cache.set('US0378331005', { ticker: 'AAPL', currency: 'USD' }); await cache.set('US0378331005', { ticker: 'AAPL', currency: 'EUR' }); - const result=await cache.get('US0378331005'); + const result = await cache.get('US0378331005'); expect(result?.currency).toBe('EUR'); }); it('should handle empty cache file gracefully', async () => { fs.writeFileSync(tempCachePath, '{}'); - const cache=new LocalFileTickerCache(tempCachePath); + const cache = new LocalFileTickerCache(tempCachePath); - const result=await cache.get('US0378331005'); + const result = await cache.get('US0378331005'); expect(result).toBeUndefined(); }); it('should handle corrupted cache file gracefully', async () => { fs.writeFileSync(tempCachePath, 'invalid json'); - const cache=new LocalFileTickerCache(tempCachePath); + const cache = new LocalFileTickerCache(tempCachePath); await cache.set('US0378331005', { ticker: 'AAPL', currency: 'USD' }); - const result=await cache.get('US0378331005'); + const result = await cache.get('US0378331005'); expect(result).toEqual({ ticker: 'AAPL', currency: 'USD' }); });