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
14 changes: 12 additions & 2 deletions src/lib/simplefin/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,19 @@ describe("processHoldings", () => {
expect(result[0].type).toBe("bond");
});

it("falls back to type stock for an unrecognized ticker", () => {
it("falls back to type other, not stock, for an unrecognized ticker", () => {
const result = processHoldings([makeAccount({ holdings: [makeHolding({ symbol: "NVDA" })] })]);
expect(result[0].type).toBe("stock");
expect(result[0].type).toBe("other");
});

it("does not chart an unlisted ETF as equity", () => {
// IBIT is a spot-bitcoin ETF absent from KNOWN_ETF_SYMBOLS. Guessing
// "stock" inflated the equity slice of the allocation chart; "other"
// reports the uncertainty instead of misstating it.
const result = processHoldings([
makeAccount({ holdings: [makeHolding({ symbol: "IBIT", description: "iShares Bitcoin Trust" })] }),
]);
expect(result[0].type).toBe("other");
});

it("classifies a SimpleFIN currency symbol as cash, not stock", () => {
Expand Down
13 changes: 8 additions & 5 deletions src/lib/simplefin/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,17 @@ import { withHousehold } from "@/lib/household-context";

// SimpleFIN brokerages don't send a security type the way Plaid does. These
// static lists cover the tickers common enough to be worth a dedicated
// allocation bucket; anything else with a ticker is assumed to be an
// individual stock, and anything with no ticker at all falls back to "other".
// allocation bucket. Anything we cannot place -- an unlisted ticker, or no
// ticker at all -- falls back to "other" rather than being guessed at.
const KNOWN_CRYPTO_SYMBOLS = new Set(["BTC", "ETH", "SOL", "DOGE", "LTC", "BCH", "ADA", "XRP", "USDC", "USDT"]);
const KNOWN_BOND_SYMBOLS = new Set(["BND", "AGG", "TLT", "IEF", "SHY", "LQD", "HYG", "MUB", "BNDX", "VCIT", "VCSH"]);
const KNOWN_ETF_SYMBOLS = new Set([
"VOO", "VTI", "SPY", "IVV", "QQQ", "QQQM", "VXUS", "VUG", "VYM", "VEA", "VWO",
"SCHD", "ARKK", "IWM", "DIA", "GLD", "SLV", "XLK", "XLF", "XLE", "XLV",
]);
// Sweep/money-market positions. Without these they'd fall through to the
// "stock" default and be charted as equity exposure, which is the opposite of
// what they are.
// "other" default and sit in the unclassified bucket, when they are in fact
// known cash and belong in the cash slice.
const KNOWN_CASH_SYMBOLS = new Set([
"SPAXX", "VMFXX", "SWVXX", "FDRXX", "VMRXX", "SPRXX", "FZFXX", "SNVXX", "SNSXX",
]);
Expand All @@ -47,7 +47,10 @@ function inferHoldingType(symbol: string | null): HoldingRow["type"] {
if (KNOWN_CRYPTO_SYMBOLS.has(ticker)) return "crypto";
if (KNOWN_BOND_SYMBOLS.has(ticker)) return "bond";
if (KNOWN_ETF_SYMBOLS.has(ticker)) return "etf";
return "stock";
// Unrecognized ticker. The allowlists above cannot cover the long tail, so
// guessing "stock" turns an unknown into a confident misclassification --
// IBIT (a spot-bitcoin ETF) was charted as equity. "other" is honest.
return "other";
}

// ---------------------------------------------------------------------------
Expand Down
Loading