diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c72a3f..3b82c6d 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,13 +17,19 @@ 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 - - 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/.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/.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/.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/CHANGELOG.md b/CHANGELOG.md index 54c2326..0405014 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,71 @@ 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 +- **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 + +- **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/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/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 fd952e1..8ddbe3f 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@ 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. +- **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. ## Supported Brokers @@ -18,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 @@ -32,30 +37,93 @@ npm install @logkat/broker-parser pnpm add @logkat/broker-parser ``` +### CLI Usage + +The library provides a powerful CLI for bulk processing and ticker resolution. + +```bash +# Basic export (defaults to Yahoo resolution) +broker-parser export input.csv -o output.csv + +# Specific resolvers (stacked in order) +broker-parser export input.csv --ticker-file custom.json --yahoo + +# 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 -### Parsing a Single Transaction +### Library Usage (TypeScript) + +### 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", - // ... other broker specific fields -}; +import { parseTransaction } from '@logkat/broker-parser'; const transaction = parseTransaction(row); +``` + +### Advanced Ticker Resolution + +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):** -if (transaction) { - console.log(transaction.type); // 'BUY' - console.log(transaction.quantity); // 10 - console.log(transaction.symbol); // 'Apple Inc' +```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, + YahooISINResolver, + YahooNameResolver, + FileTickerResolver, + LocalFileTickerCache, +} from '@logkat/broker-parser'; + +const enriched = await enrichTransactions(transactions, { + resolvers: [ + new FileTickerResolver('./manual-mapping.json'), + new YahooISINResolver(), + new YahooNameResolver(), + ], + cache: new LocalFileTickerCache('./cache.json'), +}); ``` ### Auto-Detecting Broker Format @@ -64,7 +132,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 @@ -72,12 +140,64 @@ 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 }, ...] ``` +### 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 a resolver. + +```typescript +import { + enrichTransactions, + YahooFinanceExporter, +} from '@logkat/broker-parser'; + +// 1. Define or use a built-in resolver +const myResolver = { + name: 'My Custom Resolver', + resolve: async (isin: string, name: string) => { + if (isin === 'US0378331005') return { ticker: 'AAPL' }; + return { ticker: null }; + }, +}; + +// 2. Enrich +const enriched = await enrichTransactions(parsedTransactions, { + resolvers: [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` @@ -92,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 @@ -119,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: @@ -127,17 +258,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']), // ... }; }, @@ -153,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 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/package.json b/package.json index f0ba365..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", @@ -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,10 +37,13 @@ }, "scripts": { "build": "tsc", + "watch": "tsc -w", "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", @@ -46,10 +52,17 @@ "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", + "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", + "yahoo-finance2": "^3.11.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7ee929..abe0203 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,16 @@ settings: importers: .: + dependencies: + commander: + specifier: ^14.0.2 + version: 14.0.2 + 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 @@ -14,12 +24,18 @@ 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)) 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 @@ -56,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'} @@ -406,6 +428,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 +576,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==} @@ -586,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'} @@ -657,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'} @@ -710,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'} @@ -748,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'} @@ -777,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==} @@ -839,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} @@ -866,6 +929,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'} @@ -913,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'} @@ -974,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'} @@ -997,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'} @@ -1029,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} @@ -1098,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'} @@ -1110,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'} @@ -1140,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 @@ -1371,6 +1502,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 +1712,8 @@ snapshots: color-name@1.1.4: {} + commander@14.0.2: {} + concat-map@0.0.1: {} confbox@0.1.8: {} @@ -1625,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: @@ -1719,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 @@ -1765,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: {} @@ -1793,6 +1956,8 @@ snapshots: isexe@2.0.0: {} + isexe@3.1.1: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -1824,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: @@ -1889,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 @@ -1922,6 +2091,8 @@ snapshots: dependencies: p-limit: 3.1.0 + papaparse@5.5.3: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -1958,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: @@ -2027,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 @@ -2048,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 @@ -2075,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 @@ -2144,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 @@ -2153,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..8ab10e2 --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,67 @@ +import fs from 'fs'; +import { TickerCache, TickerResolution } from './enricher'; + +export class LocalFileTickerCache implements TickerCache { + private cache: Record = {}; + private filePath: string; + private dirty = false; + private saveTimer: NodeJS.Timeout | null = null; + + constructor(filePath: string) { + 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')); + } catch (e) { + console.warn(`Failed to load ticker cache from ${this.filePath}`); + } + } + } + + 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 { + return this.cache[key]; + } + + async set(key: string, value: TickerResolution): Promise { + this.cache[key] = value; + this.dirty = true; + this.scheduleSave(); + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..56b2881 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,145 @@ +#!/usr/bin/env node +import { Command } from 'commander'; +import fs from 'fs'; +import path from 'path'; +import Papa from 'papaparse'; +import { + parseTransaction, + YahooFinanceExporter, + enrichTransactions, + YahooISINResolver, + YahooNameResolver, + FileTickerResolver, + LocalFileTickerCache, + ParsedTransaction, +} from './index'; +import { BrokerFormat } from './parsers/types'; + +// 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(packageJson.version); + +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') + .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' + ) + .option( + '--cache ', + '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); + 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 ParsedTransaction => 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 processedTransactions = transactions; + + // resolver stacking + const resolvers = []; + + // 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 (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; + 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 new file mode 100644 index 0000000..333bc1d --- /dev/null +++ b/src/enricher.ts @@ -0,0 +1,109 @@ +import { ParsedTransaction } from './parsers/types'; + +export type TickerResolution = { + ticker: string | null; + currency?: string | null; +}; + +export interface TickerResolver { + name: string; + resolve(isin: string, name: string): Promise; +} + +export interface TickerCache { + get(key: string): Promise; + set(key: string, value: TickerResolution): Promise; +} + +export interface EnrichmentOptions { + 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 one or more resolvers in sequence. + */ +export async function enrichTransactions( + transactions: ParsedTransaction[], + options: EnrichmentOptions +): Promise { + const { + resolvers, + cache, + skipIfPresent = true, + stopOnFirstMatch = true, + } = options; + const enriched: ParsedTransaction[] = []; + + for (const t of transactions) { + if (skipIfPresent && t.ticker) { + enriched.push(t); + continue; + } + + const key = t.isin || t.name; + if (!key) { + enriched.push(t); + continue; + } + + let resolution: TickerResolution | undefined; + + // 1. Check Cache + if (cache) { + resolution = await cache.get(key); + } + + // 2. Run Resolvers + if (!resolution || !resolution.ticker) { + for (const resolver of resolvers) { + try { + const res = await resolver.resolve(t.isin || '', t.name); + if (res && res.ticker) { + resolution = res; + if (stopOnFirstMatch) break; + } + } catch (e) { + console.warn(`Resolver ${resolver.name} failed for ${key}`, e); + } + } + } + + // 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, + }); + } + + 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 new file mode 100644 index 0000000..f4de9e2 --- /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..7a08008 --- /dev/null +++ b/src/exporters/yahoo.ts @@ -0,0 +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) => { + // 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') { + 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..5e987e7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,68 +7,76 @@ 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 * 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.name && 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..cad708d 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, + name: 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..6576545 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, + name: 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..e4828f5 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" + name: 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..1e0fe2f --- /dev/null +++ b/src/resolvers/file.ts @@ -0,0 +1,60 @@ +import fs from 'fs'; +import path from 'path'; +import Papa from 'papaparse'; +import { TickerResolver, TickerResolution } from '../enricher'; + +export class FileTickerResolver implements TickerResolver { + name = 'File Resolver'; + 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.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); + }); + } + } 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.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}`); + } + } + } + + 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 new file mode 100644 index 0000000..5795bf7 --- /dev/null +++ b/src/resolvers/yahoo.ts @@ -0,0 +1,221 @@ +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({ suppressNotices: ['yahooSurvey'] }); + +abstract class YahooBaseResolver { + protected yahooFinance = yahooFinance; + + protected async enrichCurrency(ticker: string): Promise { + try { + const quote = (await this.yahooFinance.quote(ticker)) as YahooQuoteResult; + let currency = quote.currency || null; + if (!currency) { + const summary = (await this.yahooFinance.quoteSummary(ticker, { + modules: ['summaryDetail', 'price'], + })) as YahooQuoteSummaryResult; + 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, name: string): Promise { + if (!isin) return { ticker: null }; + + try { + const results = (await this.yahooFinance.search( + isin + )) as YahooSearchResult; + if (results && results.quotes && results.quotes.length > 0) { + const equityMatch = results.quotes.find( + (q) => 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 ${name}:`, error); + } + + return { ticker: null }; + } +} + +/** + * Resolves tickers using Yahoo Finance name search with advanced matching. + */ +export class YahooNameResolver + extends YahooBaseResolver + implements TickerResolver +{ + name = 'Yahoo Name'; + + async resolve(isin: string, name: string): Promise { + let cleanName = name; + let preferredClass: string | null = null; + + const classMatch = + name.match(/\s+Class\s+([A-Z])$/i) || name.match(/\s+([A-Z])$/); + if (classMatch) { + preferredClass = classMatch[1].toUpperCase(); + cleanName = name.substring(0, classMatch.index).trim(); + } + + if (cleanName.toLowerCase() === 'alphabet') { + cleanName = 'Alphabet Inc'; + } + + try { + const results = (await this.yahooFinance.search( + cleanName + )) as YahooSearchResult; + if (results && results.quotes && results.quotes.length > 0) { + const candidates = results.quotes.filter( + (q) => + (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) => q.symbol === 'GOOG'); + if (found) { + resolvedTicker = found.symbol; + resolvedCurrency = found.currency || 'USD'; + } + } else if (preferredClass === 'A') { + const found = candidates.find((q) => 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) => + (q.longname || q.shortname || '') + .toLowerCase() + .startsWith(cleanName.toLowerCase()) + ) + .sort( + (a, b) => + (a.longname || a.shortname || '').length - + (b.longname || b.shortname || '').length + ); + + if (matches.length > 0) { + resolvedTicker = matches[0].symbol; + resolvedCurrency = matches[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) => 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 Name failed for ${name}:`, error); + } + + 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, name: string): Promise { + const isinRes = await this.isinResolver.resolve(isin, name); + if (isinRes.ticker) return isinRes; + return this.nameResolver.resolve(isin, name); + } +} diff --git a/tests/cache.test.ts b/tests/cache.test.ts new file mode 100644 index 0000000..660f92f --- /dev/null +++ b/tests/cache.test.ts @@ -0,0 +1,98 @@ +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' }); + 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); + + 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' }); + cache1.flush(); // Ensure data is written to disk + + 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 new file mode 100644 index 0000000..13b1087 --- /dev/null +++ b/tests/enricher.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + enrichTransactions, + MemoryTickerCache, + TickerResolver, +} from '../src/enricher'; +import { ParsedTransaction } from '../src/parsers/types'; + +describe('Enricher', () => { + it('should enrich transactions with tickers using multiple resolvers', async () => { + const transactions = [ + { + name: 'Apple', + isin: 'US0001', + ticker: undefined, + } as ParsedTransaction, + { + name: 'Microsoft', + isin: 'US0002', + ticker: undefined, + } as ParsedTransaction, + ]; + + 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' }; + return { ticker: null }; + }), + }; + + 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 names', async () => { + const transactions = [ + { name: 'Apple', isin: 'US0001' } as ParsedTransaction, + { name: 'Apple', isin: 'US0001' } as ParsedTransaction, + ]; + + const resolver: TickerResolver = { + name: 'Test Resolver', + resolve: vi.fn().mockResolvedValue({ ticker: 'AAPL' }), + }; + const cache = new MemoryTickerCache(); + + await enrichTransactions(transactions, { + resolvers: [resolver], + cache, + }); + + expect(resolver.resolve).toHaveBeenCalledTimes(1); + }); + + it('should skip transactions that already have tickers', async () => { + const transactions = [ + { + name: 'Apple', + isin: 'US0001', + ticker: 'EXISTING', + } as ParsedTransaction, + ]; + const resolver: TickerResolver = { + name: 'Test', + resolve: vi.fn(), + }; + 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 new file mode 100644 index 0000000..dbfcf52 --- /dev/null +++ b/tests/exporters/yahoo.test.ts @@ -0,0 +1,66 @@ +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', + name: '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', + name: '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 name if ticker is missing', () => { + const t = [ + { + date: new Date('2023-01-01'), + type: 'BUY', + name: 'Unknown Stock', + quantity: 1, + price: 100, + fee: 0, + originalSource: 'Test', + }, + ] as ParsedTransaction[]; + + 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..5da0812 --- /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,name,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/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 new file mode 100644 index 0000000..19246a4 --- /dev/null +++ b/tests/integration.test.ts @@ -0,0 +1,53 @@ +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]?.name).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]?.name).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,,,, diff --git a/tests/parser_brokers.test.ts b/tests/parser_brokers.test.ts index c71dd8b..7ac388a 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?.name).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?.name).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?.name).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?.name).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/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); + }); + }); +}); 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/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"] } 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', + }, + }, });