@pi-stef/finance-api
Always-on local service for @pi-stef/finance: ingests financial-account data, stores locally, runs a deterministic quant engine and market-aware scheduler.
Package details
Install @pi-stef/finance-api from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@pi-stef/finance-api- Package
@pi-stef/finance-api- Version
0.5.1- Published
- Jul 27, 2026
- Downloads
- 1,703/mo · 397/wk
- Author
- sfiorini
- License
- MIT
- Types
- package
- Size
- 158.5 KB
- Dependencies
- 11 dependencies · 0 peers
Pi manifest JSON
{}Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
@pi-stef/finance-api
Always-on local service for financial data ingestion, storage, and deterministic quant analysis. Backed by SQLite; serves a bearer-token-authenticated HTTP API to the @pi-stef/finance extension and any other client.
Quick start
Docker (recommended)
cd packages/finance-api/docker
docker compose up -d
Pulls ghcr.io/sfiorini/pi-stef/finance-api:latest and starts the service. By default it binds to 127.0.0.1:7780 (localhost only) — if the pi client runs on a different machine, change the port mapping to "7780:7780" in docker-compose.yml. See the Docker guide for details, image tags, volumes, and retrieving the token.
Native
pnpm install
pnpm serve
See docs/native-run.md for launchd/systemd setup.
Verify
curl http://127.0.0.1:7780/v1/health
# {"ok":true,"data":{"status":"ok","uptimeS":0}}
Architecture: server vs client
finance-api is a server that typically runs in Docker on a machine you control. The client is the finance extension, which runs wherever you use pi. Their files live on different machines:
| Component | Runs where | Files |
|---|---|---|
| finance-api (server) | Docker container or native on a server | Token, SQLite DB, secrets.json — inside the container (/root/.pi/sf/finance/ or /data/) or on the server (~/.pi/sf/finance/) |
| finance (client) | Inside pi, on your workstation | config.json — at ~/.pi/sf/finance/config.json on your machine |
When the docs say ~/.pi/sf/finance/, check whether they're referring to the server (inside the Docker container) or the client (your workstation). The sections below make this explicit.
Authentication
All endpoints except /v1/health require a bearer token via the Authorization header:
Authorization: Bearer <token>
Token lifecycle:
- On first start, the service generates a random UUID token and writes it to
~/.pi/sf/finance/token(chmod 600), created atomically and race-safe viaO_EXCL. - The token is stable across restarts as long as the token file persists.
- In Docker, the token is stored inside the container at
/root/.pi/sf/finance/tokenand persists via thefinance-configvolume. Retrieve it with:docker compose exec finance-api cat /root/.pi/sf/finance/token
Override: Set SF_FINANCE_TOKEN to pin a specific token (useful for CI or sharing across hosts).
The @pi-stef/finance extension reads this token automatically when co-located on the same host.
Configuration
All configuration is via environment variables (prefix SF_FINANCE_):
| Variable | Default | Description |
|---|---|---|
SF_FINANCE_HOST |
127.0.0.1 (0.0.0.0 in Docker) |
Server bind host |
SF_FINANCE_PORT |
7780 |
Server port |
SF_FINANCE_DB |
~/.pi/sf/finance/finance.db (/data/finance.db in Docker) |
SQLite database path |
SF_FINANCE_TOKEN |
(auto-generated) | Bearer token (overrides the token file) |
SF_FINANCE_DATA_FEED |
stooq |
Price data feed (stooq) |
Provider credentials
Working providers in this release do not use the server's secrets.json:
- SnapTrade — credentials live in the client's
config.jsonon your workstation and are sent per-request. See SnapTrade setup below. - SimpleFIN — credentials live in the client's
config.jsonon your workstation and are sent per-request. See SimpleFIN setup below. - File Import — no stored credentials; the file path is provided per-request.
The secrets.json file (at ~/.pi/sf/finance/secrets.json on the server) is for server-side providers. Coinbase uses server-side CDP API keys (see the Coinbase guide):
{
"coinbase": {
"keyName": "your-api-key",
"privateKey": "your-private-key"
}
}
Each provider's required credentials are documented under Providers.
Providers
| Provider | Kind | Auth | Status |
|---|---|---|---|
| File Import (CSV/OFX) | brokerage/banking | filePath or content (per-request) |
✅ Working |
| Coinbase | crypto | keyName + privateKey (in secrets.json) |
✅ Working (CDP ES256 JWT) |
| SnapTrade | brokerage | clientId + consumerKey (in client config.json, passed per-request) |
✅ Working |
| SimpleFIN | banking | setupToken → accessUrl (in client config.json, auto-persisted after first sync) |
✅ Working |
Provider setup:
Providers are co-equal — you can enable any combination, and multiple providers can run side by side (e.g. SnapTrade for live brokerage sync and File Import for a bank OFX export). Each provider is documented on its own page:
- File Import — manual CSV/OFX uploads via
/v1/import - SnapTrade — live brokerage aggregation (30+ brokers)
- SimpleFIN — live banking data (balances + transactions)
⚠️ Cross-provider deduplication is not supported yet. If the same real-world account surfaces through two providers (e.g. imported via CSV and synced via SnapTrade), it appears as two separate accounts — there is no mechanism today to recognize and merge them. This is tracked for a future release. For now, use one provider per account to avoid double-counting.
SimpleFIN — SimpleFIN is a working provider — see SimpleFIN setup below.
SnapTrade setup
SnapTrade aggregates brokerage accounts (Fidelity, Vanguard, Schwab, Robinhood, and 30+ others) behind a unified API. This integration uses a Personal API key — your brokerage connections live under your own SnapTrade Personal account, and identity is resolved from the signed consumerKey on every request.
v1 supports Personal accounts only. The Commercial model (
userId/userSecretviaregisterSnapTradeUser) is intentionally not supported.
Credentials live in the client config on the machine where you run pi (~/.pi/sf/finance/config.json on your workstation, not the finance-api server), not in the server's secrets.json. This keeps one finance-api deployment able to serve different SnapTrade users — each caller passes its own key per request.
{
"apiUrl": "http://127.0.0.1:7780",
"token": "<service bearer token>",
"providers": {
"snaptrade": {
"clientId": "PERS-...",
"consumerKey": "<your personal consumer key>"
}
}
}
Self-provision once at snaptrade.com:
- Create a Personal account.
- Open the Connection Portal and connect each of your brokerage accounts (Fidelity, Vanguard, Schwab, …). Connections are managed out-of-band on the SnapTrade dashboard — there are no in-service endpoints for it.
- Copy your Personal
clientId+consumerKeyfrom the dashboard. - Add them to the client
config.jsonunderproviders.snaptradeas above.
How a sync works: SnapTrade is on-demand only — the always-on server daemon does not poll SnapTrade on its own (it has no server-side key). Trigger a sync from the finance extension:
sf_fin_sync_now(no args) → syncs all providers, attaching your Personal SnapTrade key.sf_fin_sync_now({ provider: "snaptrade" })→ syncs only SnapTrade.
The client sends clientId + consumerKey in the request body of /v1/sync; the server uses them for that single tick and stores nothing.
What is synced: positions (equities/ETFs/mutual funds/crypto — options/futures are out of scope), transactions (incremental, id-keyed — only new activity since the last sync is fetched), and cash balance.
Polling & rate limits: each sync serializes accounts; SnapTrade's customer-level limit is 250 requests/minute. A 429 surfaces as a normal ingest error and is retried on the next sync — no special throttling is required for v1.
Limitations (v1): short positions are skipped (the data model cannot represent negative quantity); the SnapTrade payee/description field is not captured on transactions; connection management (connect/revoke) happens out-of-band at snaptrade.com. The imported balance.marketValue is the SnapTrade-reported total account value (cash + positions), not the position-only market value. Only Personal accounts are supported.
SimpleFIN setup
SimpleFIN aggregates bank account data (checking, savings, credit cards) via the SimpleFIN Bridge. Unlike SnapTrade (investment positions), SimpleFIN provides balances and transactions — no holdings/positions (banking accounts have no equity).
Credentials live in the client config on the machine where you run pi (~/.pi/sf/finance/config.json on your workstation, not the finance-api server), not in the server's secrets.json.
Auth flow (setup token → access URL)
SimpleFIN uses a one-time token exchange:
- You obtain a setup token from the SimpleFIN Bridge (a base64-encoded URL, one-time use).
- On the first sync, the finance-api server POSTs to the decoded URL and receives an access URL (persistent, embeds Basic Auth credentials).
- The server returns the access URL in the sync response. The finance extension automatically writes it to your
config.json, replacing the setup token. - Future syncs use the access URL directly — no re-exchange.
{
"apiUrl": "http://127.0.0.1:7780",
"token": "<service bearer token>",
"providers": {
"simplefin": {
"setupToken": "aHR0cHM6Ly9iZXRhLWJyaWRnZS5zaW1wbGVmaW4ub3JnL3NpbXBsZWZpbi9jbGFpbS8uLi4="
}
}
}
After the first sync, config is automatically updated:
{
"providers": {
"simplefin": {
"accessUrl": "https://user:pass@bridge.simplefin.org/simplefin"
}
}
}
Note: Setup tokens are one-time use. Once exchanged, the token is dead. The finance extension handles persistence automatically — you never need to manually update the config.
Self-provision once
- Visit beta-bridge.simplefin.org and create a SimpleFIN account.
- Generate a setup token from the SimpleFIN Bridge dashboard.
- Add it to your client
config.jsonunderproviders.simplefin.setupToken. - Run
sf_fin_sync_now— the adapter exchanges the token and persists the access URL.
What is synced
- Accounts — bank accounts (checking, savings, credit cards) with balances.
- Balances — current balance per account, persisted as cash.
- Transactions — deposits, withdrawals, payments (credit/debit classification). Pending transactions are excluded.
- No holdings — banking accounts have no equity positions.
Rate limits
SimpleFIN limits to 24 requests per day. Each sync uses ~1 request (one /accounts call that returns all accounts with balances and transactions). This is well within the limit for daily or even hourly syncs.
The transaction date range is limited to 90 days by the SimpleFIN server. Historical transactions beyond 90 days are not available.
curl examples
# Sync SimpleFIN (first time — exchanges setup token)
curl -X POST http://127.0.0.1:7780/v1/sync \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"providers":["simplefin"],"credentials":{"simplefin":{"setupToken":"your-setup-token"}}}'
# Sync SimpleFIN (subsequent — uses persisted access URL)
curl -X POST http://127.0.0.1:7780/v1/sync \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"providers":["simplefin"],"credentials":{"simplefin":{"accessUrl":"https://user:pass@bridge.simplefin.org/simplefin"}}}'
# Check banking holdings (balances)
curl -X GET http://127.0.0.1:7780/v1/holdings \
-H "Authorization: Bearer $TOKEN"
# Check banking transactions
curl -X GET http://127.0.0.1:7780/v1/history?symbol=USD \
-H "Authorization: Bearer $TOKEN"
File Import (CSV/OFX)
File Import is a co-equal provider that ingests holdings and transactions from manual file uploads via POST /v1/import. It covers two formats: CSV (for holdings/positions from any brokerage export) and OFX (for transactions + cash balance from bank exports). This is a good fit for institutions not covered by SnapTrade, or when you prefer not to grant API access.
This section covers everything you need to know — the exact formats accepted, how to export from common brokerages and banks, and what to do when your export isn't directly supported.
Supported formats
| Format | Extension | Data imported | Use case |
|---|---|---|---|
| CSV | .csv |
Holdings (positions) | Brokerage exports (any broker that exports a positions CSV) |
| OFX | .ofx, .qfx |
Transactions + balances | Bank exports (checking, savings) |
The service detects the format automatically: .csv → positions, OFX → transactions + cash balance.
CSV format specification
The CSV parser (src/ingest/file/csv.ts) expects a positions-style CSV — one row per holding, with a header row. The parser is flexible on column names but strict about what data it extracts.
Required columns
| Column | Accepted header names (case-insensitive, trimmed) | Example value | Notes |
|---|---|---|---|
| Symbol | symbol |
AAPL, FXAIX, VTI |
Must be non-empty. Any string works (no ticker validation). |
| Quantity | quantity, shares, qty |
10, 5.123, 100 |
Must be a non-zero number. Whitespace/symbols stripped (see below). |
Optional columns
| Column | Accepted header names | Example value | Notes |
|---|---|---|---|
| Price | last price, price |
190.50, $3,450.00 |
Stored as the holding's price (a sync-time snapshot), separate from avg_cost. The valuation layer prefers the latest price from the prices table, falling back to this price, then avg_cost. |
| Cost basis | average cost basis, cost basis |
$4,164.66, 180.00 |
Stored as avgCost. When absent, equities fall back to the Price column; crypto leaves avgCost unset. |
BOM handling: a leading UTF-8 BOM (
\uFEFF) is automatically stripped before parsing, so exports from Excel/Windows that include a BOM import correctly.
How numeric values are parsed
Numeric parsing differs by field:
- Quantity — stripped to digits/dot/hyphen and forced positive:
A value ofcols[idx].replace(/[^0-9.\-]/g, "") → Number(...) → Math.abs()-10becomes10; short positions cannot be imported. - Price and Cost basis — parsed by
parseCurrency():
This stripsval.replace(/[$,\s]/g, "").replace(/^\((.+)\)$/, "-$1") → Number(...)$, commas, and whitespace, and converts accounting-style parenthesized negatives to a leading minus:($4,164.66)→-4164.66. So($190.50)is preserved as a negative price/cost.
| Input | Quantity (strip + abs) |
Price / Cost basis (parseCurrency) |
|---|---|---|
$1,234.56 |
1234.56 |
1234.56 |
10 |
10 |
10 |
5.123 |
5.123 |
5.123 |
($4,164.66) |
4164.66 |
-4164.66 |
(190.50) |
190.50 |
-190.50 |
-10 |
10 |
-10 |
⚠️ Negative quantities become positive.
Math.abs()forces quantity positive, so-10(a short) imports as10. Short positions are not currently supported. This applies to quantity only — parenthesized prices and cost bases keep their sign.
Notes & limitations
Quoted fields are supported. The parser uses a proper state machine (
parseCsvLine) that handles values wrapped in double quotes, including embedded commas (e.g."Apple, Inc.") and escaped doubled quotes (""). You no longer need to strip commas from text fields.Crypto auto-detection. Symbols matching
XXX/USD,XXX/USDT,XXX/EUR, orXXX/GBP(e.g.BTC/USD,ETH/USD) are classified asassetClass: "crypto"withsecurityType: "crypto"; the stored symbol is the base currency (e.g.BTC). All other symbols import asequitywithsubclass: "us". Bonds and cash are not auto-detected from CSV.Any column beyond the 4 recognized ones is ignored. Extra columns like
Description,Account, andLast Price Changeare silently discarded.Empty/zero-quantity rows are silently skipped. If a row has an empty symbol or a zero/NaN quantity, it's dropped with no warning. A CSV with only a header and no data rows returns
[].
Minimum valid CSV
Symbol,Quantity
AAPL,10
FXAIX,5.123
Fully specified CSV (typical brokerage export)
Account,Symbol,Description,Quantity,Last Price,Cost Basis
Brokerage,AAPL,"Apple, Inc.",10,190.50,150.25
Brokerage,VTI,Total Stock Market,5.123,180.00,170.00
Brokerage,BTC/USD,Bitcoin,0.05,64000.00,45000.00
Most brokerages export positions with a header row like Account,Symbol,Description,Quantity,Last Price (sometimes Shares instead of Quantity, or Price instead of Last Price). The parser accepts all of these. Cost Basis / Average Cost Basis is optional and is stored as avgCost. Symbols of the form BTC/USD are auto-classified as crypto.
OFX format specification
The OFX parser (src/ingest/file/ofx.ts) supports standard OFX 1.x / QFX files (the format used by most banks for transaction downloads).
What the parser extracts
| XML element | Mapped to | Default if absent |
|---|---|---|
<ACCTID> |
Account ID | "unknown" |
<BALAMT> |
Cash balance | 0 |
<STMTTRN> → <TRNAMT> |
Transaction amount | 0 |
<STMTTRN> → <DTPOSTED> |
Transaction date (YYYYMMDD or YYYYMMDDHHMMSS) | unix epoch 0 (1970-01-01) |
<STMTTRN> → <NAME> |
Payee name (parsed by parseOfx) |
"" (empty) |
What reaches the API response
The file adapter (src/ingest/file/index.ts) transforms the parsed OFX data before it reaches the API:
| Field | Parser layer | Adapter layer (API response) |
|---|---|---|
Balance → RawBalance |
parseOfx().balance |
{ cash: balance, marketValue: 0, asOf: timestamp } — treated as cash |
Transactions → RawTxn[] |
parseOfx().transactions |
{ id: "${arrayIndex}", date: unixMs, type: "credit"|"debit", fees: 0 } |
Payee (<NAME>) |
✅ Parsed by parseOfx |
❌ Discarded — not present in API response |
| Fees | Not parsed | Hardcoded to 0 |
| Symbol/quantity | N/A | N/A — OFX imports transactions, not holdings |
⚠️
.qfxdetection is by content (OFXHEADER literal), not by extension. The parser detects OFX when the file has a.ofxextension OR when the file content contains the literal stringOFXHEADER(which standard.qfxexports always include). A.qfxfile withoutOFXHEADERwould not import.
⚠️ OFX holdings are always empty. OFX is for banking transactions. To import positions (stocks/ETFs), use CSV.
Date parsing
OFX dates are parsed as YYYYMMDD or YYYYMMDDHHMMSS. If the date string is empty or shorter than 8 characters, the date defaults to unix epoch 0 (displayed as 1970-01-01). In practice this never happens with real OFX exports.
Detection
- The file adapter checks for
.ofxextension for the initial routing (ingetHoldings). - Files containing the literal string
OFXHEADERanywhere in the content are treated as OFX (ingetTransactionsandgetBalances; this is how.qfxfiles work — they always containOFXHEADER). - A file without
.ofxextension AND withoutOFXHEADERcontent will not be parsed as OFX.
⚠️ The payee name (
<NAME>) is parsed from the OFX file but discarded by the adapter. Imported transactions have no payee/description field. This is a known gap.
How to export from your brokerage
The CSV parser accepts any positions export with a Symbol column and a Quantity/Shares/Qty column (an optional Last Price/Price column is read as average cost). The exact menu path varies by brokerage, but the steps are the same everywhere:
- Log into your brokerage's website and navigate to your Portfolio / Positions / Holdings page.
- Select the account you want to export.
- Look for Download / Export — typically a down-arrow icon or a link near the positions table header.
- Choose CSV format (not PDF, not Excel).
- Save the file — it typically downloads as
Positions_<date>.csvor similar. - Check the headers — you need
Symboland one ofQuantity/Shares/Qty.Last Price/PriceandCost Basis/Average Cost Basisare optional. Extra columns (Description, Account, …) are ignored. - Import the file:
curl -X POST http://127.0.0.1:7780/v1/import \ -H "Authorization: Bearer $(cat ~/.pi/sf/finance/token)" \ -H "Content-Type: application/json" \ -d '{"filePath":"/Users/me/Downloads/positions.csv"}' - Verify the import worked:
curl -X GET http://127.0.0.1:7780/v1/holdings \ -H "Authorization: Bearer $(cat ~/.pi/sf/finance/token)"
Tip:
Last Price/Priceis stored as the holding'sprice, andCost Basis/Average Cost BasisasavgCost(equities fall back to the price column when no cost basis is present). The/v1/net-worthendpoint prefers the latest price from the price feed, falling back topricethenavg_cost. So a missing or stale price column is largely harmless.Prefer live sync? If your brokerage is one of the 30+ supported by SnapTrade (Fidelity, Vanguard, Schwab, Robinhood, …), SnapTrade gives you automatic live sync with no manual exports.
Exports that need adjustment
Some institutions export data in a shape the CSV parser can't read directly. This is not an exhaustive list — the same patterns apply to any similar export.
Crypto exchanges (e.g. Coinbase)
Reason: Crypto exchanges export transaction history, not portfolio positions. A typical CSV has columns like:
Timestamp,Transaction Type,Asset,Quantity Transacted,Spot Price,Subtotal,Total,Notes
There is no Symbol column (the parser's required key). Even if renamed, the data represents transactions (buys/sells/transfers), not current holdings. The parser would need significant changes to aggregate transactions into a portfolio snapshot.
What you can do today:
- Manually create a CSV with
Symbol,Quantitycolumns from your exchange balances. - Get your current balances from the exchange UI (Dashboard → each asset → balance).
- Write a CSV like:
Use theSymbol,Quantity BTC/USD,0.05 ETH/USD,2.0BASE/USDform so the parser classifies the row as crypto (assetClass: "crypto",securityType: "crypto"). A bareBTCwould import as equity. - Import with
POST /v1/import.
Note: A direct Coinbase API provider (src/ingest/direct/coinbase.ts) is implemented and pulls positions via CDP ES256-JWT API keys. See the Coinbase guide.
Banks (e.g. Bank of America)
Reason: Banks export account activity (transactions), not portfolio positions. A typical bank CSV has columns like:
Date,Description,Amount,Running Bal.
No Symbol column. No Quantity column. The data is a transaction ledger, not a position list.
What you can do today:
- OFX/QFX: Most banks support OFX download (via "Download Transactions" → choose "Microsoft Money" or "Quicken" format). This is the recommended path — the OFX parser handles these files correctly for transaction history and cash balance.
curl -X POST http://127.0.0.1:7780/v1/import \ -H "Authorization: Bearer $(cat ~/.pi/sf/finance/token)" \ -H "Content-Type: application/json" \ -d '{"filePath":"/Users/me/Downloads/bank-activity.ofx"}' - For positions held in a bank's investing arm (e.g. Merrill Edge for BoA), download a positions export from the investment section — these typically follow the
Symbol,Quantity,Last Priceformat the parser accepts. - Live sync: If the brokerage side is supported by SnapTrade, SnapTrade gives you automatic sync.
If your CSV doesn't match
Most brokerages export positions with Symbol, Quantity/Shares, and optionally a Last Price/Price column. If your export has these columns under any accepted header name, it will import. If the import returns empty holdings, check that your CSV has Symbol and Quantity/Shares/Qty in the header row (head -1 your-file.csv).
curl examples
Import CSV (holdings):
curl -X POST http://127.0.0.1:7780/v1/import \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"filePath":"/absolute/path/to/positions.csv"}'
Import OFX (transactions):
curl -X POST http://127.0.0.1:7780/v1/import \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"filePath":"/absolute/path/to/transactions.ofx"}'
Import via content (remote deployments): When the file is not reachable from the server's filesystem — e.g. the finance extension runs on a different machine than finance-api — send the file contents directly instead of a path. content is the raw (UTF-8) file text; filename is optional and used only to detect the format (.csv vs .ofx):
# Use jq to safely embed the file as a JSON string
jq -n --rawfile content /absolute/path/to/positions.csv \
'{filename:"positions.csv", content:$content}' |
curl -X POST http://127.0.0.1:7780/v1/import \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d @-
financeextension: the extension always reads the file locally on the pi machine and sends{ content, filename }to the server — the file never needs to exist on the server. UsefilePathonly for direct API calls when the file lives on the server's filesystem.
Verify after import:
# Check holdings
curl -X GET http://127.0.0.1:7780/v1/holdings \
-H "Authorization: Bearer YOUR_TOKEN"
# Check net worth (uses latest prices, not CSV avg costs)
curl -X GET http://127.0.0.1:7780/v1/net-worth \
-H "Authorization: Bearer YOUR_TOKEN"
# Trigger a full sync (recompute suggestions from new data)
curl -X POST http://127.0.0.1:7780/v1/sync \
-H "Authorization: Bearer YOUR_TOKEN"
Troubleshooting imports
| Symptom | Likely cause | Fix |
|---|---|---|
{"ok":false,"error":{"code":"bad_request","message":"Either filePath or content is required"}} |
Neither filePath nor content in the JSON body |
Add "filePath": "/path/to/file.csv" (file on the server) or "content": "..." with "filename": "..." (file contents) to your request body |
{"ok":false,"error":{"code":"bad_request","message":"Directory traversal is not allowed"}} |
Relative path contains .. (e.g., ../../data.csv) |
Use an absolute path (/Users/me/...) or a relative path without .. (./data.csv) — absolute paths are always allowed |
Import succeeds but holdings are empty ([]) |
CSV is missing Symbol or Quantity/Shares/Qty column in the header, or all rows have zero/empty quantities |
Check your CSV header row — it must contain one of the accepted column names. Run head -1 your-file.csv to inspect |
| Imported quantities are wrong | CSV uses (value) for negatives (accounting convention) or -value for short positions |
Short/negative quantities cannot be imported — Math.abs() forces all quantities positive. No workaround in the current parser. Remove negative rows or accept them as positive. |
| Import succeeds but prices are wrong or missing | CSV has no Last Price/Price column, or the column has non-numeric characters parseCurrency can't parse |
Add a price column or accept that holdings import without a stored price; valuation falls back to avg_cost, then the price feed |
Imported OFX transactions have date 1970-01-01 |
OFX date field is missing or malformed (<DTPOSTED> empty or < 8 chars) |
Verify the OFX file is valid — real bank exports always include dates. If this happens, the file may be corrupted |
| Imported OFX transactions have no merchant name | Payee <NAME> is parsed by parseOfx but discarded by the adapter |
This is a known limitation — transaction descriptions are not persisted. Tracked for a future release |
401 Unauthorized on import |
Token is missing or wrong | Retrieve the token: cat ~/.pi/sf/finance/token (native) or docker compose exec finance-api cat /root/.pi/sf/finance/token (Docker). Include Authorization: Bearer <token> in your request |
HTTP API reference
Base URL: http://127.0.0.1:7780. All endpoints return { "ok": true, "data": {...} } on success or { "ok": false, "error": { "code": "...", "message": "..." } } on failure.
Interactive docs
The API ships with auto-generated OpenAPI 3.1 documentation:
| Resource | URL | Description |
|---|---|---|
| Swagger UI | http://127.0.0.1:7780/docs |
Interactive API explorer — try requests live |
| OpenAPI JSON | http://127.0.0.1:7780/openapi.json |
Raw OpenAPI 3.1 spec (import into Postman, Insomnia, etc.) |
Both endpoints are public (no auth required) and are generated from the Zod route schemas, so they're always in sync with the code.
Postman collection
A ready-to-import Postman collection and environment template are in the postman/ directory:
- Open Postman → Import
- Select
postman/finance-api.postman_collection.json - Import
postman/finance-api.postman_environment.jsonas an environment - Set the
tokenvariable to your bearer token - All requests are pre-configured with
{{base_url}}and{{token}}variables
To regenerate the collection after adding/changing routes:
npx tsx packages/finance-api/scripts/gen-postman.mjs
GET /v1/health (public)
Health check; no auth required.
{ "ok": true, "data": { "status": "ok", "uptimeS": 123 } }
GET /v1/market-status
Returns the current US market session classification.
{ "ok": true, "data": { "session": "regular", "timestamp": 1782000000000 } }
session is one of pre, regular, post, closed. Holiday list currently covers 2026.
GET /v1/holdings
Accounts and their holdings, valued at the latest known price.
{ "ok": true, "data": { "accounts": [
{ "id": "snaptrade:acct-1", "provider_id": "snaptrade", "kind": "brokerage", "name": "Brokerage",
"total_value": 5105.00,
"holdings": [
{ "account_id": "snaptrade:acct-1", "symbol": "AAPL", "quantity": 10, "avg_cost": 150.25, "asset_class": "equity", "subclass": "us", "price": 190.50, "security_type": null, "market_value": 1905.00, "gain_loss": 402.50, "as_of": 1782000000000 },
{ "account_id": "snaptrade:acct-1", "symbol": "BTC", "quantity": 0.05, "avg_cost": 45000.00, "asset_class": "crypto", "subclass": null, "price": 64000.00, "security_type": "crypto", "market_value": 3200.00, "gain_loss": 950.00, "as_of": 1782000000000 }
] }
] } }
Per-holding fields: price (latest price used for valuation), market_value (quantity × price), gain_loss (market_value − quantity × avg_cost, or null when avg_cost is unknown), avg_cost, and security_type (e.g. "crypto", else null). Each account includes total_value = Σ holding market_value + unbilled cash.
GET /v1/net-worth
Total portfolio value using latest prices (falls back to average cost).
{ "ok": true, "data": { "netWorth": 123456.78, "accountCount": 3 } }
GET /v1/allocation
Current asset allocation as flat weights by asset class.
{ "ok": true, "data": { "allocation": { "equity": 0.72, "bonds": 0.18, "cash": 0.10 }, "totalValue": 123456.78 } }
GET /v1/drift
Allocation drift vs the configured goal's target allocation.
{ "ok": true, "data": { "drift": [
{ "class": "equity", "currentPct": 0.72, "targetPct": 0.80, "deltaPct": -0.08, "value": 88888.0 }
] } }
GET /v1/goals
List investment goals (target allocation is parsed from stored JSON).
{ "ok": true, "data": { "goals": [
{ "id": "g1", "name": "Growth", "targetAllocation": { "equity": 0.8, "bonds": 0.2 }, "riskLimits": {}, "horizon_years": 10 }
] } }
Note:
target_allocationandrisk_limitsare camelCased in the response (targetAllocation/riskLimits, parsed from JSON);horizon_yearskeeps its snake_case DB form.
POST /v1/goals
Create or update (UPSERT) an investment goal. Validates that the target allocation sums to ~1.0.
Request body:
{
"id": "g1",
"name": "Growth",
"targetAllocation": { "equity": 0.8, "bonds": 0.2 },
"riskLimits": { "maxConcentration": 0.25 },
"horizonYears": 10
}
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | yes | Goal identifier |
name |
string | yes | Display name |
targetAllocation |
object | yes | Asset-class weights (must sum to ~1.0) |
riskLimits |
object | no | Risk limits (e.g. maxConcentration) |
horizonYears |
number | no | Investment horizon |
{ "ok": true, "data": { "id": "g1" } }
GET /v1/suggestions
Pending rebalance/risk/drift suggestions computed by the quant engine. Each suggestion's payload is parsed from stored JSON.
{ "ok": true, "data": { "suggestions": [
{ "id": "s-...-0", "kind": "rebalance", "status": "pending", "payload": { "symbol": "AAPL", "action": "buy", "amount": 500 } }
] } }
POST /v1/suggestions/dismiss
Dismiss a suggestion by id.
Request body: { "id": "s-...-0" }
{ "ok": true, "data": { "dismissed": "s-...-0" } }
POST /v1/sync
Trigger a scheduler tick: ingest from providers, refresh prices, recompute suggestions.
Optional request body:
| Field | Type | Description |
|---|---|---|
providers |
string[] |
Scope ingest to a subset of providers (e.g. ["snaptrade"]). Omit to ingest from all configured providers. |
credentials |
object |
Per-provider credentials supplied per-call (request creds override server-side secrets.json creds for this tick; nothing is persisted). Used for Personal SnapTrade keys. |
Example — sync SnapTrade with a per-call Personal key:
curl -X POST http://127.0.0.1:7780/v1/sync \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"providers":["snaptrade"],"credentials":{"snaptrade":{"clientId":"PERS-...","consumerKey":"..."}}}'
{ "ok": true, "data": {
"message": "Sync complete",
"session": "regular",
"accountsIngested": 3,
"holdingsIngested": 12,
"pricesUpdated": 12,
"suggestionsCreated": 2,
"errors": []
} }
POST /v1/import
Import holdings from a local file (CSV or OFX). Accepts either mode in the request body:
filePath— the server reads the file from its own filesystem (local deployments). Absolute paths are allowed (single-user local service); relative paths containing..are rejected.content(+ optionalfilename) — the file contents are sent directly as a UTF-8 string (remote deployments). Thefinanceextension always uses this mode: it reads the file locally and sendscontent, so the file never needs to exist on the server.
Request body: { "filePath": "/Users/me/Downloads/positions.csv" } or { "filename": "positions.csv", "content": "Symbol,Quantity\nAAPL,10\n" }
{ "ok": true, "data": { "message": "Import complete", "filePath": "...", "accounts": 1 } }
GET /v1/history?symbol=AAPL[&accountId=...]
Price history for a symbol, newest first. accountId optionally filters to prices relevant to a holding in that account.
{ "ok": true, "data": { "history": [
{ "symbol": "AAPL", "date": 1782000000000, "close": 210.5, "source": "stooq" }
] } }
POST /v1/export
Export data. format: json returns all tables inline; format: sqlite writes a backup copy of the database (restricted to the finance backup directory).
Request body: { "format": "json" } or { "format": "sqlite", "path": "backup.db" }
// json
{ "ok": true, "data": { "holdings": [...], "prices": [...], ... } }
// sqlite
{ "ok": true, "data": { "backupPath": "/home/.pi/sf/finance/backups/backup.db" } }
Data model
SQLite, stored at SF_FINANCE_DB. Versioned migrations (see src/store/schema.ts); future changes add migration entries rather than mutating existing tables.
| Table | Purpose |
|---|---|
accounts |
Linked accounts (provider, kind, name, mask, currency, staleness) |
holdings |
Current holdings per account/symbol (quantity, avg cost, price, asset class, security type, as-of) |
transactions |
Transactions (date, symbol, qty, price, type, fees) |
prices |
Price history per symbol/date (close, source) |
lots |
Tax lots per holding (open date, qty, cost basis) |
goals |
Investment goals (target allocation, risk limits, horizon) |
suggestion_records |
Persisted suggestions (kind, payload, status) |
market_sessions |
Cached market-session snapshots per date |
Scheduler & quant engine
The built-in scheduler (src/scheduler/) runs a periodic tick whose cadence depends on the market session: more frequent intraday, hourly after hours, and every few hours when closed. Each tick:
- Ingests fresh data from configured providers via the provider registry.
- Refreshes prices from the configured data feed (default
stooq). - Recomputes suggestions deterministically through the quant engine:
- Drift — current vs target allocation deltas
- Rebalance — buy/sell amounts to return to target
- Risk — concentration and cash-drag checks against
riskLimits - DCA — dollar-cost-averaging recommendations (where configured)
Determinism: all numbers are computed by pure functions in src/quant/. The LLM client applies judgment but never recomputes the figures. This keeps suggestions reproducible and auditable.
POST /v1/sync triggers a tick on demand.
Backup & restore
- JSON export:
POST /v1/export {"format":"json"}returns all data inline. - SQLite backup:
POST /v1/export {"format":"sqlite"}writes a timestamped.dbcopy to~/.pi/sf/finance/backups/(path is sandboxed to that directory). - Restore: stop the service, replace
SF_FINANCE_DBwith the backup file, restart.
Observability
Structured logs are emitted to stdout (JSON) with level, msg, and contextual fields. Key events: server start, ingest results, staleness warnings, tick summaries. Increase verbosity via your process supervisor's log level (the service logs at info by default).
Security model
- Local-first: bind to
127.0.0.1by default. Docker maps127.0.0.1:7780:7780(localhost only) so the service is not exposed to the LAN. For remote-server deployments, change to"7780:7780"— see the Docker guide. - Bearer auth: every non-health endpoint requires a token; compared with
timingSafeEqual. - Secrets:
secrets.jsonischmod 600; provider credentials never leave the host. - File imports: absolute paths are allowed (local file access by design); relative
..traversal is rejected. - Backups: the export route sandboxes SQLite backups to the finance backup directory.
Troubleshooting
| Symptom | Fix |
|---|---|
401 Unauthorized |
Retrieve/regenerate the token (see Authentication); check SF_FINANCE_TOKEN |
| Port already in use | Change SF_FINANCE_PORT and the compose port mapping |
| Stale holdings | Run POST /v1/sync; check provider credentials (SnapTrade → client config.json, others → secrets.json) |
better-sqlite3 build fails (native) |
Use the Docker image, or ensure python3 make g++ are installed |
| No suggestions after sync | Set a goal via POST /v1/goals — drift/rebalance need a target |
Cost
- Free tier: File imports (CSV/OFX) and
stooqprices — no API costs. - Optional: Coinbase API (free, view-only scope) — currently a stub.
- SnapTrade: live brokerage aggregation (Fidelity, Vanguard, Schwab, Robinhood, 30+ others — see SnapTrade setup).
- SimpleFIN: live banking data (balances + transactions — see SimpleFIN setup). Free tier available; premium features may have fees.
Disclaimer
This is not financial advice. The service provides deterministic calculations based on your data and configured goals. Suggestions are informational only — no trades are executed automatically. Always consult a qualified financial advisor before making investment decisions.