From f037eba7db64cd5da6f4f15d5641d72a4ed6ff5a Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 6 May 2026 08:59:27 +0200 Subject: [PATCH 1/3] fix(revolut): remove blanket digit-prefix crypto classification Stock tickers like 65C were incorrectly classified as crypto because the heuristic assumed all symbols starting with digits are crypto. 1INCH is already in the KNOWN_CRYPTO set, making the regex redundant. --- src/parsers/revolut.ts | 4 ---- tests/parsers/revolut.test.ts | 10 +++++++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/parsers/revolut.ts b/src/parsers/revolut.ts index 9810d46..ae48d6b 100644 --- a/src/parsers/revolut.ts +++ b/src/parsers/revolut.ts @@ -92,10 +92,6 @@ function detectAssetCategory(symbol: string): AssetCategory { const upper = symbol.toUpperCase(); if (KNOWN_CRYPTO.has(upper)) return "CRYPTO"; if (KNOWN_SHORT_STOCKS.has(upper)) return "STK"; - // Heuristic: stock tickers on Revolut typically have dots (BRK.B) or are - // well-known 1-5 letter tickers. Crypto symbols on Revolut never have dots. - // Symbols starting with digits (like 1INCH) are always crypto. - if (/^\d/.test(upper)) return "CRYPTO"; return "STK"; } diff --git a/tests/parsers/revolut.test.ts b/tests/parsers/revolut.test.ts index a4647bc..476a07c 100644 --- a/tests/parsers/revolut.test.ts +++ b/tests/parsers/revolut.test.ts @@ -342,7 +342,7 @@ describe("revolutParser", () => { expect(statement.trades.length).toBe(0); }); - it("should detect crypto symbols starting with digits", async () => { + it("should detect known crypto symbols starting with digits (1INCH)", async () => { const data = buildRevolutWorkbook([ ["2025-01-01", "2025-06-01", "1INCH", "100", "50", "60", "10", "0", "10", "USD"], ]); @@ -350,6 +350,14 @@ describe("revolutParser", () => { expect(statement.trades[0]!.assetCategory).toBe("CRYPTO"); }); + it("should NOT classify digit-starting stock tickers as crypto (65C)", async () => { + const data = buildRevolutWorkbook([ + ["2025-01-01", "2025-06-01", "65C", "10", "100", "110", "10", "0", "10", "EUR"], + ]); + const statement = await parseRevolutXlsx(data); + expect(statement.trades[0]!.assetCategory).toBe("STK"); + }); + it("should detect well-known short stock tickers as STK", async () => { const data = buildRevolutWorkbook([ ["2025-01-01", "2025-06-01", "T", "10", "200", "220", "20", "0", "20", "USD"], From 282c963d83bdb400de358f2c60587a593ae44e1a Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 6 May 2026 09:15:19 +0200 Subject: [PATCH 2/3] fix(revolut): expand KNOWN_CRYPTO to 125 symbols for robust detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sourced from Revolut's published crypto trading list. Covers DeFi, infrastructure tokens, stablecoins, rebrands (MATIC→POL), and historically-available tokens needed for older exports. --- src/parsers/revolut.ts | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/parsers/revolut.ts b/src/parsers/revolut.ts index ae48d6b..89b3597 100644 --- a/src/parsers/revolut.ts +++ b/src/parsers/revolut.ts @@ -73,12 +73,26 @@ const TXN_TYPE_CASH_OUT = /^CASH\s*WITHDRAWAL$/i; // --------------------------------------------------------------------------- const KNOWN_CRYPTO = new Set([ - "BTC", "ETH", "XRP", "SOL", "ADA", "DOGE", "DOT", "AVAX", "MATIC", "LINK", - "LTC", "BCH", "XLM", "ATOM", "UNI", "SHIB", "FIL", "APT", "ARB", "NEAR", - "OP", "ICP", "ALGO", "SAND", "MANA", "AXS", "ENJ", "1INCH", "COMP", "AAVE", - "CRV", "GRT", "SNX", "MKR", "LDO", "PEPE", "FLOKI", "BONK", "WIF", "JUP", - "SUI", "SEI", "TIA", "WLD", "RENDER", "FET", "TAO", "PENDLE", "TON", "TRX", - "HBAR", "VET", "EOS", "XTZ", "THETA", "ZIL", "IOTA", "EGLD", "FLOW", "ROSE", + // Top coins + "BTC", "ETH", "XRP", "SOL", "ADA", "DOGE", "DOT", "AVAX", "LINK", "LTC", + "BCH", "XLM", "ATOM", "UNI", "SHIB", "FIL", "APT", "ARB", "NEAR", "OP", + "ICP", "ALGO", "SAND", "MANA", "AXS", "ENJ", "COMP", "AAVE", "CRV", "GRT", + "SNX", "MKR", "LDO", "PEPE", "FLOKI", "BONK", "WIF", "JUP", "SUI", "SEI", + "TIA", "WLD", "RENDER", "FET", "TAO", "PENDLE", "TON", "TRX", "HBAR", "VET", + "EOS", "XTZ", "THETA", "ZIL", "IOTA", "EGLD", "FLOW", "ROSE", "INJ", "QNT", + // DeFi & infrastructure + "1INCH", "ACH", "AMP", "ANKR", "BAL", "BAND", "BAT", "BICO", "BLZ", "BNT", + "BOND", "CELO", "CHZ", "CLV", "COTI", "CRO", "CTSI", "ENS", "ETC", "FIDA", + "FORTH", "FTM", "GALA", "GMT", "GODS", "GST", "IDEX", "IMX", "JASMY", "KNC", + "LPT", "LRC", "MASK", "MINA", "MLN", "NKN", "NMR", "OGN", "OMG", "OXT", + "PERP", "RAD", "REN", "REQ", "RLC", "SKL", "SPELL", "STORJ", "SUPER", "SUSHI", + "TRB", "UMA", "UNFI", "YFI", "ZRX", + // Stablecoins (appear in Revolut crypto statements) + "USDC", "USDT", + // Rebrands — keep old + new for backward compat with older exports + "MATIC", "POL", "RNDR", + // Possibly delisted but needed for historical data + "DASH", "KEEP", "MIR", "NU", "APE", ]); /** Common short stock tickers that could be confused with crypto */ From a25377e25b29a2fe95e957cb2f991d6dc920f983 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 6 May 2026 09:24:51 +0200 Subject: [PATCH 3/3] fix(revolut): use auto-generated CoinGecko crypto list (487 symbols) Replace hardcoded KNOWN_CRYPTO set with a generated file sourced from CoinGecko's top 500 coins by market cap. Stock ticker conflicts (A, B, T, etc.) are excluded at generation time. Run `npx tsx scripts/update-crypto-symbols.ts` to refresh the list. --- scripts/update-crypto-symbols.ts | 78 ++++++++++++++++++++++++++++++++ src/parsers/crypto-symbols.ts | 57 +++++++++++++++++++++++ src/parsers/revolut.ts | 41 ++--------------- 3 files changed, 140 insertions(+), 36 deletions(-) create mode 100644 scripts/update-crypto-symbols.ts create mode 100644 src/parsers/crypto-symbols.ts diff --git a/scripts/update-crypto-symbols.ts b/scripts/update-crypto-symbols.ts new file mode 100644 index 0000000..190d7ce --- /dev/null +++ b/scripts/update-crypto-symbols.ts @@ -0,0 +1,78 @@ +/** + * Fetches the top crypto symbols by market cap from CoinGecko and generates + * src/parsers/crypto-symbols.ts. Run manually or in CI to keep the list fresh. + * + * Usage: npx tsx scripts/update-crypto-symbols.ts + * + * CoinGecko's free API requires no key. Rate limit: ~30 req/min. + * We fetch top 500 coins by market cap — covers everything any retail + * exchange (Revolut, eToro, etc.) could realistically offer. + */ + +const COINGECKO_URL = "https://api.coingecko.com/api/v3/coins/markets"; +const PAGES = 2; // 250 per page × 2 = 500 coins +const PER_PAGE = 250; +const OUTPUT_PATH = "src/parsers/crypto-symbols.ts"; + +// Symbols that exist as both crypto AND stock tickers — on retail brokers like +// Revolut these are overwhelmingly stocks. Exclude from the crypto set so the +// parser defaults them to STK (the correct behavior for Revolut users). +const STOCK_TICKER_CONFLICTS = new Set([ + "A", "B", "C", "D", "F", "G", "K", "T", "V", "X", + "AA", "AB", "AG", "AI", "AL", "AM", "AN", "AR", "AS", "AT", "AU", "AV", + "BA", "BP", "BT", "DB", "GE", "GM", "HP", +]); + +async function fetchPage(page: number): Promise { + const url = `${COINGECKO_URL}?vs_currency=usd&order=market_cap_desc&per_page=${PER_PAGE}&page=${page}&sparkline=false`; + const res = await fetch(url); + if (!res.ok) throw new Error(`CoinGecko API error: ${res.status} ${res.statusText}`); + const data = (await res.json()) as Array<{ symbol: string }>; + return data.map((coin) => coin.symbol.toUpperCase()); +} + +async function main() { + const allSymbols = new Set(); + + for (let page = 1; page <= PAGES; page++) { + const symbols = await fetchPage(page); + for (const s of symbols) allSymbols.add(s); + if (page < PAGES) await new Promise((r) => setTimeout(r, 1500)); + } + + // Filter: alphanumeric 1-10 chars, exclude known stock ticker conflicts + const filtered = [...allSymbols] + .filter((s) => /^[A-Z0-9]{1,10}$/.test(s) && !STOCK_TICKER_CONFLICTS.has(s)) + .sort(); + + // Format as TypeScript + const lines: string[] = []; + lines.push("// Auto-generated by scripts/update-crypto-symbols.ts — do not edit manually."); + lines.push(`// Last updated: ${new Date().toISOString().slice(0, 10)}`); + lines.push(`// Source: CoinGecko top ${PAGES * PER_PAGE} coins by market cap`); + lines.push("//"); + lines.push("// Run: npx tsx scripts/update-crypto-symbols.ts"); + lines.push(""); + lines.push("export const KNOWN_CRYPTO_SYMBOLS = new Set(["); + + // Write in rows of 10 for readability + for (let i = 0; i < filtered.length; i += 10) { + const chunk = filtered.slice(i, i + 10).map((s) => `"${s}"`).join(", "); + lines.push(` ${chunk},`); + } + + lines.push("]);"); + lines.push(""); + + const { writeFileSync } = await import("fs"); + const { resolve } = await import("path"); + const outPath = resolve(process.cwd(), OUTPUT_PATH); + writeFileSync(outPath, lines.join("\n")); + + console.log(`✓ Wrote ${filtered.length} crypto symbols to ${OUTPUT_PATH}`); +} + +main().catch((err) => { + console.error("Failed to update crypto symbols:", err); + process.exit(1); +}); diff --git a/src/parsers/crypto-symbols.ts b/src/parsers/crypto-symbols.ts new file mode 100644 index 0000000..299245f --- /dev/null +++ b/src/parsers/crypto-symbols.ts @@ -0,0 +1,57 @@ +// Auto-generated by scripts/update-crypto-symbols.ts — do not edit manually. +// Last updated: 2026-05-06 +// Source: CoinGecko top 500 coins by market cap +// +// Run: npx tsx scripts/update-crypto-symbols.ts + +export const KNOWN_CRYPTO_SYMBOLS = new Set([ + "0G", "1INCH", "2Z", "9BIT", "A7A5", "AAVE", "ACRED", "ADA", "ADI", "AERO", + "AGENTFUN", "AIOZ", "AKT", "ALCH", "ALGO", "ALT", "AMP", "ANKR", "APE", "APEPE", + "APES", "API3", "APT", "APXUSD", "APYUSD", "ARB", "ARC", "ARKM", "ASTER", "ASTEROID", + "ASTR", "ATH", "ATOM", "AUSD", "AUSDT", "AVAX", "AVUSD", "AWE", "AXL", "AXS", + "AZTEC", "BABY", "BABYDOGE", "BAN", "BANANAS31", "BARD", "BASEDHYPE", "BAT", "BC", "BCAP", + "BCE", "BCH", "BDCA", "BDX", "BEAM", "BEAT", "BERA", "BFUSD", "BGB", "BILL", + "BIO", "BLUR", "BMX", "BNB", "BONK", "BORG", "BRETT", "BRLV", "BRZ", "BSB", + "BSV", "BTC", "BTSE", "BTT", "BUIDL", "CAKE", "CASH", "CC", "CCD", "CELO", + "CET", "CFG", "CFX", "CGUSD", "CHEEMS", "CHIP", "CHZ", "CKB", "COAI", "COMP", + "COW", "CRCLON", "CRCLX", "CRO", "CRV", "CRVUSD", "CTC", "CUSD", "CVX", "CYS", + "DAI", "DASH", "DBR", "DCR", "DEEP", "DEXE", "DGB", "DIEM", "DOG", "DOGE", + "DOLA", "DOT", "DRV", "DUAL", "DUSD", "DUSK", "DYDX", "EARNETH", "EDGE", "EGLD", + "EIGEN", "ELF", "ELG", "ENA", "ENJ", "ENS", "ESPORTS", "ETC", "ETH", "ETHFI", + "EURC", "EURCV", "EURI", "EURS", "EUTBL", "EV", "EXOD", "FARTCOIN", "FDIT", "FDUSD", + "FET", "FEUSD", "FF", "FIDD", "FIL", "FLOKI", "FLOW", "FLR", "FLUID", "FOGO", + "FORM", "FRAX", "FRXUSD", "FT", "GALA", "GAS", "GENIUS", "GEOD", "GHO", "GLM", + "GMRT", "GMX", "GNO", "GOMINING", "GRASS", "GRT", "GRX", "GT", "GTUSDA", "GUSD", + "GWEI", "H", "HASH", "HBAR", "HNT", "HOME", "HOT", "HSK", "HTX", "HUNT", + "HYPE", "ICNT", "ICP", "IMX", "INI", "INJ", "IO", "IOTA", "IP", "IRYS", + "IUSD", "JAAA", "JASMY", "JELLYJELLY", "JST", "JTO", "JTRSY", "JUP", "JUPUSD", "JUSD", + "KAG", "KAIA", "KAITO", "KAS", "KAU", "KAVA", "KCS", "KITE", "KMNO", "KOGE", + "KSM", "KTA", "KUB", "LAB", "LB", "LBT", "LDO", "LEO", "LINEA", "LINK", + "LION", "LISUSD", "LIT", "LPT", "LTC", "LUNA", "LUNC", "LUX", "M", "MANA", + "MANTRA", "MASK", "MBG", "MBTC", "ME", "MEGA", "MELANIA", "MET", "META", "METAL", + "MEW", "MINA", "MNEE", "MNT", "MOCA", "MOG", "MON", "MOODENG", "MORPHO", "MOVE", + "MSUSD", "MSY", "MTBILL", "MWC", "MX", "NAORIS", "NAT", "NEAR", "NEO", "NEXO", + "NFT", "NIGHT", "NILA", "NMR", "NOCK", "NOT", "NPC", "NUSD", "NXM", "NXPC", + "OHM", "OKB", "ONDO", "ONT", "ONYC", "OP", "OPG", "ORCA", "ORDI", "OUSG", + "OZO", "PAXG", "PC0000015", "PC0000019", "PC0000023", "PC0000031", "PC0000033", "PC0000049", "PC0000077", "PC0000081", + "PC0000085", "PC0000097", "PC0000101", "PCI", "PENDLE", "PENGU", "PEPE", "PGOLD", "PI", "PIEVERSE", + "PLUME", "PMUSD", "PNUT", "POL", "POLYX", "POPCAT", "PRIME", "PROS", "PROVE", "PUFF", + "PUMP", "PUSD", "PYTH", "PYTHIA", "PYUSD", "QFI", "QNT", "QRL", "QTUM", "QUBIC", + "RAIL", "RAIN", "RAVE", "RAY", "REAL", "RED", "REKT", "RENDER", "REQ", "REUSD", + "RIF", "RIVER", "RLB", "RLUSD", "RON", "ROSE", "RSR", "RUNE", "RUSD", "RVN", + "S", "SAFE", "SAHARA", "SAND", "SATUSD", "SEI", "SENT", "SFP", "SHFL", "SHIB", + "SIERRA", "SIREN", "SKR", "SKY", "SKYAI", "SN120", "SN4", "SN44", "SN51", "SN64", + "SNX", "SOL", "SOON", "SOSO", "SPK", "SPX", "STABLE", "STAC", "STAU", "STRCX", + "STRK", "STX", "SUI", "SUN", "SUPER", "SUSHI", "SWOP", "SYRUP", "TAC", "TAG", + "TAO", "TBK", "TDCCP", "TEL", "TEMPLE", "TFUEL", "THBILL", "THETA", "TIA", "TIBBIR", + "TKX", "TON", "TOSHI", "TRAC", "TRB", "TRIA", "TROLL", "TRUMP", "TRX", "TSLAX", + "TURBO", "TUSD", "TWT", "U", "UAI", "UB", "UDS", "ULTIMA", "UMXM", "UNI", + "UPUMP", "USAT", "USD0", "USD1", "USDA", "USDAI", "USDAT", "USDC", "USDD", "USDE", + "USDF", "USDG", "USDGO", "USDKG", "USDM", "USDON", "USDR", "USDS", "USDT", "USDTB", + "USDU", "USDX", "USDY", "USTB", "USTBL", "USX", "USYC", "UUSD", "VBILL", "VCNT", + "VELO", "VET", "VIRTUAL", "VRSC", "VSN", "VTHO", "VVS", "VVV", "W", "WAL", + "WBT", "WEMIX", "WFI", "WIF", "WLD", "WLFI", "WMTX", "WOULD", "XAUM", "XAUT", + "XCN", "XDAI", "XDC", "XEC", "XLM", "XMR", "XNO", "XPL", "XPR", "XRP", + "XTZ", "XUSD", "XVG", "XYO", "YFI", "YLDS", "ZAMA", "ZANO", "ZBCN", "ZEC", + "ZEN", "ZETA", "ZIL", "ZK", "ZORA", "ZRO", "ZRX", +]); diff --git a/src/parsers/revolut.ts b/src/parsers/revolut.ts index 89b3597..76ff6a1 100644 --- a/src/parsers/revolut.ts +++ b/src/parsers/revolut.ts @@ -27,6 +27,7 @@ import Decimal from "decimal.js"; import type { BrokerParser, Statement } from "../types/broker.js"; import type { Trade, CashTransaction, OpenPosition, AssetCategory } from "../types/ibkr.js"; import { findColumn, parseNumber } from "./csv-utils.js"; +import { KNOWN_CRYPTO_SYMBOLS } from "./crypto-symbols.js"; type WorkSheet = import("xlsx").WorkSheet; @@ -66,46 +67,14 @@ const TXN_TYPE_CASH_OUT = /^CASH\s*WITHDRAWAL$/i; // --------------------------------------------------------------------------- // Crypto detection — Revolut mixes stocks and crypto in the same sheet. -// Uses a known-crypto set plus a heuristic for unlisted tokens: symbols -// that are all-uppercase without dots and 1-5 chars that aren't in the -// known-stock set are flagged as crypto. This isn't perfect but covers -// the vast majority of Revolut's tradeable assets. +// Uses KNOWN_CRYPTO_SYMBOLS (auto-generated from CoinGecko top 500 by market +// cap, refreshed via `npx tsx scripts/update-crypto-symbols.ts`). +// Stock ticker conflicts (A, B, T, etc.) are excluded at generation time. // --------------------------------------------------------------------------- -const KNOWN_CRYPTO = new Set([ - // Top coins - "BTC", "ETH", "XRP", "SOL", "ADA", "DOGE", "DOT", "AVAX", "LINK", "LTC", - "BCH", "XLM", "ATOM", "UNI", "SHIB", "FIL", "APT", "ARB", "NEAR", "OP", - "ICP", "ALGO", "SAND", "MANA", "AXS", "ENJ", "COMP", "AAVE", "CRV", "GRT", - "SNX", "MKR", "LDO", "PEPE", "FLOKI", "BONK", "WIF", "JUP", "SUI", "SEI", - "TIA", "WLD", "RENDER", "FET", "TAO", "PENDLE", "TON", "TRX", "HBAR", "VET", - "EOS", "XTZ", "THETA", "ZIL", "IOTA", "EGLD", "FLOW", "ROSE", "INJ", "QNT", - // DeFi & infrastructure - "1INCH", "ACH", "AMP", "ANKR", "BAL", "BAND", "BAT", "BICO", "BLZ", "BNT", - "BOND", "CELO", "CHZ", "CLV", "COTI", "CRO", "CTSI", "ENS", "ETC", "FIDA", - "FORTH", "FTM", "GALA", "GMT", "GODS", "GST", "IDEX", "IMX", "JASMY", "KNC", - "LPT", "LRC", "MASK", "MINA", "MLN", "NKN", "NMR", "OGN", "OMG", "OXT", - "PERP", "RAD", "REN", "REQ", "RLC", "SKL", "SPELL", "STORJ", "SUPER", "SUSHI", - "TRB", "UMA", "UNFI", "YFI", "ZRX", - // Stablecoins (appear in Revolut crypto statements) - "USDC", "USDT", - // Rebrands — keep old + new for backward compat with older exports - "MATIC", "POL", "RNDR", - // Possibly delisted but needed for historical data - "DASH", "KEEP", "MIR", "NU", "APE", -]); - -/** Common short stock tickers that could be confused with crypto */ -const KNOWN_SHORT_STOCKS = new Set([ - "A", "B", "C", "D", "F", "G", "K", "T", "V", "X", - "AA", "AI", "BA", "BP", "BT", "DB", "GE", "GM", "HP", - "AB", "AG", "AL", "AM", "AN", "AR", "AS", "AT", "AU", "AV", -]); - function detectAssetCategory(symbol: string): AssetCategory { const upper = symbol.toUpperCase(); - if (KNOWN_CRYPTO.has(upper)) return "CRYPTO"; - if (KNOWN_SHORT_STOCKS.has(upper)) return "STK"; + if (KNOWN_CRYPTO_SYMBOLS.has(upper)) return "CRYPTO"; return "STK"; }