Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions scripts/update-crypto-symbols.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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<string>();

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);
});
57 changes: 57 additions & 0 deletions src/parsers/crypto-symbols.ts
Original file line number Diff line number Diff line change
@@ -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",
]);
31 changes: 5 additions & 26 deletions src/parsers/revolut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -66,36 +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([
"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",
]);

/** 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";
// 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";
if (KNOWN_CRYPTO_SYMBOLS.has(upper)) return "CRYPTO";
return "STK";
}

Expand Down
10 changes: 9 additions & 1 deletion tests/parsers/revolut.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,14 +342,22 @@ 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"],
]);
const statement = await parseRevolutXlsx(data);
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"],
Expand Down
Loading