@pi-stef/finance-api 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +107 -0
  3. package/bin/finance-api.ts +71 -0
  4. package/docker/Dockerfile +44 -0
  5. package/docker/README.md +3 -0
  6. package/docker/docker-compose.yml +26 -0
  7. package/package.json +46 -0
  8. package/src/config/load.ts +42 -0
  9. package/src/config/types.ts +11 -0
  10. package/src/index.ts +14 -0
  11. package/src/ingest/aggregator/simplefin.ts +18 -0
  12. package/src/ingest/aggregator/snaptrade.ts +19 -0
  13. package/src/ingest/aggregator/teller.ts +19 -0
  14. package/src/ingest/contract.ts +22 -0
  15. package/src/ingest/direct/coinbase.ts +44 -0
  16. package/src/ingest/file/csv.ts +30 -0
  17. package/src/ingest/file/index.ts +64 -0
  18. package/src/ingest/file/ofx.ts +17 -0
  19. package/src/ingest/matrix.ts +17 -0
  20. package/src/ingest/normalizer.ts +21 -0
  21. package/src/ingest/registry.ts +99 -0
  22. package/src/ingest/secrets.ts +28 -0
  23. package/src/market/prices.ts +30 -0
  24. package/src/market/session.ts +37 -0
  25. package/src/quant/dca.ts +10 -0
  26. package/src/quant/drift.ts +18 -0
  27. package/src/quant/limits.ts +10 -0
  28. package/src/quant/rebalance.ts +46 -0
  29. package/src/quant/risk.ts +23 -0
  30. package/src/quant/suggestions.ts +27 -0
  31. package/src/quant/validate.ts +11 -0
  32. package/src/scheduler/daemon.ts +83 -0
  33. package/src/scheduler/tick.ts +171 -0
  34. package/src/server/app.ts +54 -0
  35. package/src/server/auth.ts +25 -0
  36. package/src/server/bootstrap.ts +32 -0
  37. package/src/server/errors.ts +20 -0
  38. package/src/server/health.ts +14 -0
  39. package/src/server/logger.ts +29 -0
  40. package/src/server/routes/allocation.ts +26 -0
  41. package/src/server/routes/drift.ts +37 -0
  42. package/src/server/routes/export.ts +32 -0
  43. package/src/server/routes/goals.ts +44 -0
  44. package/src/server/routes/history.ts +27 -0
  45. package/src/server/routes/holdings.ts +16 -0
  46. package/src/server/routes/import-route.ts +29 -0
  47. package/src/server/routes/market-status.ts +17 -0
  48. package/src/server/routes/net-worth.ts +23 -0
  49. package/src/server/routes/suggestions.ts +22 -0
  50. package/src/server/routes/sync.ts +27 -0
  51. package/src/server/start.ts +54 -0
  52. package/src/store/backup.ts +26 -0
  53. package/src/store/db.ts +8 -0
  54. package/src/store/migrations.ts +18 -0
  55. package/src/store/repo.ts +68 -0
  56. package/src/store/schema.ts +40 -0
  57. package/src/store/symbols.ts +11 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stefano Fiorini
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # @pi-stef/finance-api
2
+
3
+ Always-on local service for financial data ingestion, storage, and deterministic quant analysis.
4
+
5
+ ## Install
6
+
7
+ ### Docker (Recommended)
8
+
9
+ ```bash
10
+ cd packages/finance-api/docker
11
+ docker compose up --build
12
+ ```
13
+
14
+ The service will be available at `http://127.0.0.1:7780`.
15
+
16
+ ### Native
17
+
18
+ ```bash
19
+ pnpm install
20
+ pnpm serve
21
+ ```
22
+
23
+ See [docs/native-run.md](docs/native-run.md) for launchd/systemd setup.
24
+
25
+ ## Configuration
26
+
27
+ Environment variables (prefix `SF_FINANCE_`):
28
+
29
+ | Variable | Default | Description |
30
+ |----------|---------|-------------|
31
+ | `SF_FINANCE_HOST` | `127.0.0.1` | Server host (use `0.0.0.0` for Docker) |
32
+ | `SF_FINANCE_PORT` | `7780` | Server port |
33
+ | `SF_FINANCE_DB` | `~/.pi/sf/finance/finance.db` | SQLite database path |
34
+ | `SF_FINANCE_DATA_FEED` | `stooq` | Price data feed (`stooq` or `yfinance`) |
35
+
36
+ ## Secrets
37
+
38
+ Create `~/.pi/sf/finance/secrets.json` with provider credentials:
39
+
40
+ ```json
41
+ {
42
+ "coinbase": {
43
+ "keyName": "your-api-key",
44
+ "privateKey": "your-private-key"
45
+ },
46
+ "fidelity": {
47
+ "filePath": "~/Downloads/fidelity-positions.csv"
48
+ },
49
+ "boa": {
50
+ "filePath": "~/Downloads/boa-transactions.ofx"
51
+ }
52
+ }
53
+ ```
54
+
55
+ The file is automatically `chmod 600` on creation.
56
+
57
+ ## Providers
58
+
59
+ | Provider | Kind | Auth | Status |
60
+ |----------|------|------|--------|
61
+ | File Import (CSV/OFX) | brokerage/banking | `filePath` | ✅ Working |
62
+ | Coinbase | crypto | `keyName` + `privateKey` | ⚠️ Stub (HMAC not implemented) |
63
+ | SnapTrade | brokerage | `clientId` + `consumerKey` | ⚠️ Stub |
64
+ | SimpleFIN | banking | `accessKey` | ⚠️ Stub |
65
+ | Teller | banking | `token` | ⚠️ Stub |
66
+
67
+ ## First Run
68
+
69
+ 1. Start the service
70
+ 2. Import holdings: `POST /v1/import {"filePath": "positions.csv"}`
71
+ 3. Set investment goal: `POST /v1/goals {"id": "g1", "name": "Growth", "targetAllocation": {"equity": 0.8, "bonds": 0.2}}`
72
+ 4. Check drift: `GET /v1/drift`
73
+
74
+ ## API
75
+
76
+ All endpoints (except `/v1/health`) require `Authorization: Bearer <token>` header.
77
+
78
+ | Method | Path | Description |
79
+ |--------|------|-------------|
80
+ | GET | `/v1/health` | Health check (public) |
81
+ | GET | `/v1/market-status` | Current market session |
82
+ | GET | `/v1/holdings` | All holdings |
83
+ | GET | `/v1/net-worth` | Total portfolio value |
84
+ | GET | `/v1/drift` | Allocation drift |
85
+ | GET | `/v1/allocation` | Current allocation |
86
+ | GET | `/v1/goals` | Investment goals |
87
+ | POST | `/v1/goals` | Create/update goal |
88
+ | GET | `/v1/suggestions` | Pending suggestions |
89
+ | POST | `/v1/suggestions/dismiss` | Dismiss suggestion |
90
+ | POST | `/v1/sync` | Trigger sync |
91
+ | POST | `/v1/import` | Import from file |
92
+ | GET | `/v1/history` | Price history |
93
+ | POST | `/v1/export` | Export data |
94
+
95
+ ## Cost
96
+
97
+ - **Free tier**: File imports (CSV/OFX) — no API costs
98
+ - **Optional**: Coinbase API (free, view-only scope)
99
+ - **Optional**: SnapTrade/SimpleFIN/Teller aggregators (may have fees)
100
+
101
+ ## Disclaimer
102
+
103
+ **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.
104
+
105
+ ## License
106
+
107
+ [MIT](../../LICENSE)
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env tsx
2
+ // finance-api service entry point
3
+ import { loadFinanceApiConfig, ensureToken, openDb, startServer, createLogger, loadSecrets, buildDefaultRegistry, startDaemon } from "../src/index";
4
+
5
+ const log = createLogger();
6
+
7
+ async function main() {
8
+ try {
9
+ log.info("Starting finance-api service...");
10
+
11
+ // Load config
12
+ const config = await loadFinanceApiConfig();
13
+ log.info("Config loaded", { port: config.port, dbPath: config.dbPath });
14
+
15
+ // Ensure bearer token
16
+ const token = await ensureToken(config.tokenPath);
17
+ log.info("Token ready");
18
+
19
+ // Open database
20
+ const db = openDb(config.dbPath);
21
+ log.info("Database opened");
22
+
23
+ // Load secrets
24
+ const secrets = loadSecrets(config.secretsPath);
25
+ log.info("Secrets loaded", { providerCount: Object.keys(secrets).length });
26
+
27
+ // Build provider registry
28
+ const registry = buildDefaultRegistry();
29
+
30
+ // Start server
31
+ const server = await startServer({
32
+ db,
33
+ token,
34
+ host: config.host,
35
+ port: config.port,
36
+ log,
37
+ registry,
38
+ creds: secrets,
39
+ });
40
+
41
+ log.info("Server started", { host: config.host, port: server.port });
42
+
43
+ // Start scheduler daemon
44
+ const daemon = startDaemon({
45
+ db,
46
+ registry,
47
+ creds: secrets,
48
+ log,
49
+ dataFeed: config.dataFeed,
50
+ });
51
+ log.info("Daemon started");
52
+
53
+ // Graceful shutdown
54
+ const shutdown = () => {
55
+ log.info("Shutting down...");
56
+ daemon.stop();
57
+ server.close();
58
+ db.close();
59
+ process.exit(0);
60
+ };
61
+
62
+ process.on("SIGINT", shutdown);
63
+ process.on("SIGTERM", shutdown);
64
+
65
+ } catch (err) {
66
+ log.error("Failed to start", { error: err instanceof Error ? err.message : String(err) });
67
+ process.exit(1);
68
+ }
69
+ }
70
+
71
+ main();
@@ -0,0 +1,44 @@
1
+ # ---- build stage: install + compile native deps (better-sqlite3) ----
2
+ FROM node:20-slim AS build
3
+ WORKDIR /app
4
+
5
+ # Install build tools for native deps
6
+ RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
7
+
8
+ # Enable pnpm
9
+ RUN corepack enable && corepack prepare pnpm@10 --activate
10
+
11
+ # Copy workspace root files (build context = repo root)
12
+ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
13
+ COPY packages/finance-api/package.json ./packages/finance-api/package.json
14
+ COPY packages/paths/package.json ./packages/paths/package.json
15
+
16
+ # Install dependencies
17
+ RUN pnpm install --prod --frozen-lockfile --filter @pi-stef/finance-api
18
+
19
+ # Copy source
20
+ COPY packages/finance-api/src ./packages/finance-api/src
21
+ COPY packages/finance-api/bin ./packages/finance-api/bin
22
+ COPY packages/paths/src ./packages/paths/src
23
+
24
+ # ---- runtime stage: slim, no build tooling ----
25
+ FROM node:20-slim AS runtime
26
+ WORKDIR /app
27
+
28
+ # Install curl for healthcheck
29
+ RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
30
+
31
+ # Copy built app
32
+ COPY --from=build /app /app
33
+
34
+ # Set environment
35
+ ENV SF_FINANCE_HOST=0.0.0.0
36
+ ENV SF_FINANCE_PORT=7780
37
+ EXPOSE 7780
38
+
39
+ # Healthcheck
40
+ HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
41
+ CMD curl -fsS http://127.0.0.1:7780/v1/health || exit 1
42
+
43
+ # Run with tsx for TypeScript support
44
+ CMD ["npx", "tsx", "packages/finance-api/bin/finance-api.ts"]
@@ -0,0 +1,3 @@
1
+ # Docker packaging for finance-api
2
+
3
+ Dockerfile and docker-compose.yml will be added in M10 (Docker packaging).
@@ -0,0 +1,26 @@
1
+ version: "3.8"
2
+
3
+ services:
4
+ finance-api:
5
+ build:
6
+ context: ../.. # Repo root
7
+ dockerfile: packages/finance-api/docker/Dockerfile
8
+ ports:
9
+ - "127.0.0.1:7780:7780" # Localhost only for security
10
+ volumes:
11
+ - finance-data:/data
12
+ - finance-config:/root/.pi/sf/finance
13
+ environment:
14
+ - SF_FINANCE_DB=/data/finance.db
15
+ - SF_FINANCE_HOST=0.0.0.0
16
+ - SF_FINANCE_PORT=7780
17
+ restart: unless-stopped
18
+ healthcheck:
19
+ test: ["CMD", "curl", "-fsS", "http://127.0.0.1:7780/v1/health"]
20
+ interval: 30s
21
+ timeout: 5s
22
+ retries: 3
23
+
24
+ volumes:
25
+ finance-data:
26
+ finance-config:
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@pi-stef/finance-api",
3
+ "version": "0.1.1",
4
+ "description": "Always-on local service for @pi-stef/finance: ingests financial-account data, stores locally, runs a deterministic quant engine and market-aware scheduler.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/sfiorini/pi-stef.git",
10
+ "directory": "packages/finance-api"
11
+ },
12
+ "homepage": "https://sfiorini.github.io/pi-stef/packages/finance-api.html",
13
+ "files": [
14
+ "src/",
15
+ "bin/",
16
+ "docker/"
17
+ ],
18
+ "exports": {
19
+ ".": "./src/index.ts",
20
+ "./package.json": "./package.json"
21
+ },
22
+ "keywords": [
23
+ "pi-package",
24
+ "pi-library",
25
+ "finance-api",
26
+ "portfolio",
27
+ "sqlite"
28
+ ],
29
+ "dependencies": {
30
+ "@hono/node-server": "^2.0.6",
31
+ "@pi-stef/paths": "^0.3.0",
32
+ "@sinclair/typebox": "*",
33
+ "better-sqlite3": "^12.11.1",
34
+ "hono": "^4.6.0",
35
+ "tsx": "^4.0.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/better-sqlite3": "^7.6.11"
39
+ },
40
+ "pi": {},
41
+ "scripts": {
42
+ "test": "vitest run",
43
+ "typecheck": "tsc --noEmit -p tsconfig.json",
44
+ "serve": "tsx bin/finance-api.ts"
45
+ }
46
+ }
@@ -0,0 +1,42 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { globalConfig, globalDir } from "@pi-stef/paths";
4
+ import type { FinanceApiConfig, DataFeed } from "./types";
5
+
6
+ export async function loadFinanceApiConfig(
7
+ env: Record<string, string | undefined> = process.env,
8
+ homeDir: string = process.env.HOME ?? process.cwd(),
9
+ ): Promise<FinanceApiConfig> {
10
+ const dir = globalDir("finance", homeDir);
11
+ const defaults: FinanceApiConfig = {
12
+ host: "127.0.0.1",
13
+ port: 7780,
14
+ dbPath: path.join(dir, "finance.db"),
15
+ secretsPath: path.join(dir, "secrets.json"),
16
+ tokenPath: path.join(dir, "token"),
17
+ dataFeed: "stooq",
18
+ timezone: "America/New_York",
19
+ };
20
+
21
+ let fileConfig: Partial<FinanceApiConfig> = {};
22
+ try {
23
+ const file = env.SF_FINANCE_CONFIG ?? globalConfig("finance", homeDir);
24
+ fileConfig = JSON.parse(await readFile(file, "utf8")) as Partial<FinanceApiConfig>;
25
+ } catch (e) {
26
+ if (!(e instanceof Error && "code" in e && (e as { code: string }).code === "ENOENT")) throw e;
27
+ }
28
+
29
+ const envFeed: DataFeed | undefined = env.SF_FINANCE_DATA_FEED === "yfinance" ? "yfinance" : env.SF_FINANCE_DATA_FEED === "stooq" ? "stooq" : undefined;
30
+ const rawPort = env.SF_FINANCE_PORT ? Number(env.SF_FINANCE_PORT) : undefined;
31
+ const envPort = Number.isFinite(rawPort) && rawPort! > 0 ? rawPort : undefined;
32
+
33
+ return {
34
+ host: env.SF_FINANCE_HOST ?? fileConfig.host ?? defaults.host,
35
+ port: envPort ?? fileConfig.port ?? defaults.port,
36
+ dbPath: env.SF_FINANCE_DB ?? fileConfig.dbPath ?? defaults.dbPath,
37
+ secretsPath: fileConfig.secretsPath ?? defaults.secretsPath,
38
+ tokenPath: fileConfig.tokenPath ?? defaults.tokenPath,
39
+ dataFeed: envFeed ?? fileConfig.dataFeed ?? defaults.dataFeed,
40
+ timezone: fileConfig.timezone ?? defaults.timezone,
41
+ };
42
+ }
@@ -0,0 +1,11 @@
1
+ export type DataFeed = "stooq" | "yfinance";
2
+
3
+ export interface FinanceApiConfig {
4
+ host: string; // always 127.0.0.1
5
+ port: number; // default 7780
6
+ dbPath: string; // ~/.pi/sf/finance/finance.db
7
+ secretsPath: string; // ~/.pi/sf/finance/secrets.json
8
+ tokenPath: string; // ~/.pi/sf/finance/token (generated bearer token)
9
+ dataFeed: DataFeed;
10
+ timezone: string; // default "America/New_York" (US market)
11
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export const FINANCE_API_VERSION = "0.1.0";
2
+
3
+ // Core exports
4
+ export { startServer } from "./server/start";
5
+ export { ensureToken } from "./server/bootstrap";
6
+ export { loadFinanceApiConfig } from "./config/load";
7
+ export { openDb } from "./store/db";
8
+ export { createApp } from "./server/app";
9
+ export { createLogger } from "./server/logger";
10
+ export { loadSecrets } from "./ingest/secrets";
11
+ export { buildDefaultRegistry } from "./ingest/matrix";
12
+ export { runIngest } from "./ingest/registry";
13
+ export { startDaemon } from "./scheduler/daemon";
14
+ export { runTick } from "./scheduler/tick";
@@ -0,0 +1,18 @@
1
+ import type { ProviderAdapter, Credentials, Session, RawAccount, RawHolding, RawTxn, RawBalance } from "../contract";
2
+
3
+ // SimpleFIN aggregates banking accounts (incl. Bank of America) via access tokens.
4
+ // Live calls require a SimpleFIN accessKey (provisioned via simplefin.org).
5
+ // Endpoints: https://bridge.simplefin.org/simplefin/accounts
6
+ export function createSimplefinAdapter(): ProviderAdapter {
7
+ return {
8
+ kind: "banking", providerId: "boa-simplefin",
9
+ authenticate: async (creds: Credentials): Promise<Session> => {
10
+ if (!creds.accessKey) throw new Error("simplefin requires accessKey");
11
+ return { providerId: "boa-simplefin", creds };
12
+ },
13
+ listAccounts: async (): Promise<RawAccount[]> => [], // GET /accounts — populated when live creds provided
14
+ getHoldings: async (): Promise<RawHolding[]> => [], // Banking accounts have no equity holdings
15
+ getTransactions: async (): Promise<RawTxn[]> => [],
16
+ getBalances: async (): Promise<RawBalance> => ({ cash: 0, marketValue: 0, asOf: Date.now() }),
17
+ };
18
+ }
@@ -0,0 +1,19 @@
1
+ import type { ProviderAdapter, Credentials, Session, RawAccount, RawHolding, RawTxn, RawBalance } from "../contract";
2
+
3
+ // SnapTrade aggregates brokerage accounts (incl. Fidelity) via OAuth-style user connections.
4
+ // Live calls require SnapTrade clientId + consumerKey (developer-tier provisioning — open item).
5
+ // Endpoints: https://api.snaptrade.com/api/v1/{accounts,positions,balances}
6
+ export function createSnaptradeAdapter(): ProviderAdapter {
7
+ return {
8
+ kind: "brokerage", providerId: "fidelity-snaptrade",
9
+ authenticate: async (creds: Credentials): Promise<Session> => {
10
+ if (!creds.clientId || !creds.consumerKey) throw new Error("snaptrade requires clientId + consumerKey");
11
+ if (!creds.userSecret) throw new Error("snaptrade requires a registered userSecret (connection not established)");
12
+ return { providerId: "fidelity-snaptrade", creds };
13
+ },
14
+ listAccounts: async (): Promise<RawAccount[]> => [], // GET /accounts — populated when live creds provided
15
+ getHoldings: async (): Promise<RawHolding[]> => [], // GET /positions
16
+ getTransactions: async (): Promise<RawTxn[]> => [],
17
+ getBalances: async (): Promise<RawBalance> => ({ cash: 0, marketValue: 0, asOf: Date.now() }),
18
+ };
19
+ }
@@ -0,0 +1,19 @@
1
+ import type { ProviderAdapter, Credentials, Session, RawAccount, RawHolding, RawTxn, RawBalance } from "../contract";
2
+
3
+ // Teller aggregates banking accounts (incl. Bank of America) via device-based authentication.
4
+ // NOTE: Teller uses device-based scraping which may have reliability/ToS implications.
5
+ // Live calls require a Teller token (provisioned via teller.io).
6
+ // Endpoints: https://api.teller.io/{accounts,balances,transactions}
7
+ export function createTellerAdapter(): ProviderAdapter {
8
+ return {
9
+ kind: "banking", providerId: "boa-teller",
10
+ authenticate: async (creds: Credentials): Promise<Session> => {
11
+ if (!creds.token) throw new Error("teller requires token");
12
+ return { providerId: "boa-teller", creds };
13
+ },
14
+ listAccounts: async (): Promise<RawAccount[]> => [], // GET /accounts — populated when live creds provided
15
+ getHoldings: async (): Promise<RawHolding[]> => [], // Banking accounts have no equity holdings
16
+ getTransactions: async (): Promise<RawTxn[]> => [],
17
+ getBalances: async (): Promise<RawBalance> => ({ cash: 0, marketValue: 0, asOf: Date.now() }),
18
+ };
19
+ }
@@ -0,0 +1,22 @@
1
+ export type ProviderKind = "brokerage" | "retirement" | "banking" | "crypto";
2
+
3
+ export interface Credentials { [key: string]: string }
4
+ export interface Session { providerId: string; expiresAt?: number; creds?: Credentials }
5
+
6
+ export interface RawAccount { providerAccountId: string; kind: ProviderKind; name: string; maskLast4?: string; currency: string }
7
+ export interface RawHolding {
8
+ symbol: string; quantity: number; avgCost?: number; assetClass: string; subclass?: string;
9
+ lots?: { openDate: number; qty: number; costBasis: number }[];
10
+ }
11
+ export interface RawTxn { id: string; date: number; symbol?: string; qty?: number; price?: number; type: string; fees?: number }
12
+ export interface RawBalance { cash: number; marketValue: number; asOf: number }
13
+
14
+ export interface ProviderAdapter {
15
+ readonly kind: ProviderKind;
16
+ readonly providerId: string;
17
+ authenticate(creds: Credentials): Promise<Session>;
18
+ listAccounts(s: Session): Promise<RawAccount[]>;
19
+ getHoldings(s: Session, accountId: string): Promise<RawHolding[]>;
20
+ getTransactions(s: Session, accountId: string, since?: number): Promise<RawTxn[]>;
21
+ getBalances(s: Session, accountId: string): Promise<RawBalance>;
22
+ }
@@ -0,0 +1,44 @@
1
+ import type { ProviderAdapter, Credentials, Session, RawAccount, RawHolding, RawTxn, RawBalance } from "../contract";
2
+
3
+ const BASE = "https://api.coinbase.com/api/v3/brokerage";
4
+
5
+ interface FetchLike { (url: string, init?: RequestInit): Promise<Response> }
6
+
7
+ export interface CoinbaseDeps { fetcher?: FetchLike; now?: () => number }
8
+
9
+ export function createCoinbaseAdapter(deps: CoinbaseDeps = {}): ProviderAdapter {
10
+ const fetcher = deps.fetcher ?? ((url: string, init?: RequestInit) => fetch(url, init));
11
+ const now = deps.now ?? (() => Date.now());
12
+
13
+ async function signedRequest(creds: Credentials, path: string): Promise<unknown> {
14
+ const timestamp = Math.floor(now() / 1000).toString();
15
+ // Real signing uses HMAC-SHA256 over timestamp+method+path+body with privateKey.
16
+ // This stub passes keyName as CB-ACCESS-KEY; full HMAC signing added when wiring real creds.
17
+ const res = await fetcher(`${BASE}${path}`, {
18
+ headers: {
19
+ "CB-ACCESS-KEY": creds.keyName,
20
+ "CB-ACCESS-TIMESTAMP": timestamp,
21
+ },
22
+ });
23
+ if (!res.ok) throw new Error(`coinbase ${path} ${res.status}`);
24
+ return res.json();
25
+ }
26
+
27
+ return {
28
+ kind: "crypto", providerId: "coinbase",
29
+ authenticate: async (creds: Credentials): Promise<Session> => {
30
+ if (!creds.keyName || !creds.privateKey) throw new Error("coinbase requires keyName + privateKey");
31
+ return { providerId: "coinbase", creds };
32
+ },
33
+ listAccounts: async (_s: Session): Promise<RawAccount[]> => [{ providerAccountId: "spot", kind: "crypto", name: "Coinbase Spot", currency: "USD" }],
34
+ getHoldings: async (s: Session): Promise<RawHolding[]> => {
35
+ const creds = s.creds ?? {}; // creds attached to Session by runIngest (see contract.ts Session.creds)
36
+ const body = (await signedRequest(creds, "/accounts")) as { accounts?: { currency: string; available_balance?: { value: string } }[] };
37
+ return (body.accounts ?? [])
38
+ .filter((a) => a.currency !== "USD")
39
+ .map((a) => ({ symbol: a.currency, quantity: Number(a.available_balance?.value ?? "0"), assetClass: "crypto" }));
40
+ },
41
+ getTransactions: async (): Promise<RawTxn[]> => [],
42
+ getBalances: async (): Promise<RawBalance> => ({ cash: 0, marketValue: 0, asOf: now() }),
43
+ };
44
+ }
@@ -0,0 +1,30 @@
1
+ import type { RawHolding } from "../contract";
2
+
3
+ const EQUITY_HINTS = /^(FX|Fidelity|Vanguard|SW|VTI|SPY|AAPL|MSFT|GOOG|AMZN)/i; // crude; refined in 4.x if needed
4
+
5
+ export function parsePositionsCsv(csv: string): RawHolding[] {
6
+ const lines = csv.split(/\r?\n/).filter((l) => l.trim());
7
+ if (lines.length < 2) return [];
8
+ const header = lines[0].split(",").map((h) => h.trim().toLowerCase());
9
+ const symIdx = header.findIndex((h) => h === "symbol");
10
+ const qtyIdx = header.findIndex((h) => h === "quantity" || h === "shares" || h === "qty");
11
+ const priceIdx = header.findIndex((h) => h === "last price" || h === "price");
12
+ if (symIdx === -1 || qtyIdx === -1) throw new Error("CSV missing Symbol or Quantity column");
13
+
14
+ const out: RawHolding[] = [];
15
+ for (let i = 1; i < lines.length; i++) {
16
+ const cols = lines[i].split(",");
17
+ const symbol = (cols[symIdx] ?? "").trim();
18
+ const qty = Number((cols[qtyIdx] ?? "").replace(/[^0-9.\-]/g, ""));
19
+ if (!symbol || !Number.isFinite(qty) || qty === 0) continue;
20
+ const price = priceIdx >= 0 ? Number((cols[priceIdx] ?? "").replace(/[^0-9.\-]/g, "")) : undefined;
21
+ out.push({
22
+ symbol,
23
+ quantity: Math.abs(qty),
24
+ avgCost: Number.isFinite(price) ? price : undefined,
25
+ assetClass: "equity", // file import defaults to equity; cash rows handled by OFX/txns
26
+ subclass: EQUITY_HINTS.test(symbol) ? "us" : "us",
27
+ });
28
+ }
29
+ return out;
30
+ }
@@ -0,0 +1,64 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { extname } from "node:path";
3
+ import type { ProviderAdapter, ProviderKind, Credentials, Session, RawAccount, RawHolding, RawTxn, RawBalance } from "../contract";
4
+ import { parsePositionsCsv } from "./csv";
5
+ import { parseOfx } from "./ofx";
6
+
7
+ /**
8
+ * Parse OFX date format (YYYYMMDD or YYYYMMDDHHMMSS) into unix ms.
9
+ * OFX dates are in the format: 20260101 or 20260101120000
10
+ */
11
+ function parseOfxDate(dateStr: string): number {
12
+ if (!dateStr || dateStr.length < 8) return 0;
13
+ const year = parseInt(dateStr.slice(0, 4), 10);
14
+ const month = parseInt(dateStr.slice(4, 6), 10) - 1; // 0-indexed
15
+ const day = parseInt(dateStr.slice(6, 8), 10);
16
+ return new Date(year, month, day).getTime();
17
+ }
18
+
19
+ export function createFileAdapter(providerId: string, kind: ProviderKind): ProviderAdapter {
20
+ return {
21
+ kind, providerId,
22
+ authenticate: async (creds: Credentials): Promise<Session> => ({ providerId, expiresAt: undefined, creds }),
23
+ listAccounts: async (): Promise<RawAccount[]> => [{ providerAccountId: "file", kind, name: `${providerId} file import`, currency: "USD" }],
24
+ getHoldings: async (_s: Session, _id: string): Promise<RawHolding[]> => {
25
+ // filePath is passed via creds at authenticate time; stored on session.creds by runIngest
26
+ const creds = _s.creds ?? {};
27
+ const filePath = creds.filePath ?? "";
28
+ if (!filePath) return [];
29
+ const buf = await readFile(filePath, "utf8");
30
+ if (extname(filePath).toLowerCase() === ".ofx" || buf.includes("OFXHEADER")) {
31
+ // OFX is banking txns, not holdings; cash handled via getBalances
32
+ return [];
33
+ }
34
+ return parsePositionsCsv(buf);
35
+ },
36
+ getTransactions: async (_s: Session, _id: string): Promise<RawTxn[]> => {
37
+ const creds = _s.creds ?? {};
38
+ const filePath = creds.filePath ?? "";
39
+ if (!filePath) return [];
40
+ const buf = await readFile(filePath, "utf8");
41
+ if (buf.includes("OFXHEADER")) {
42
+ const ofx = parseOfx(buf);
43
+ return ofx.transactions.map((t, i) => ({
44
+ id: `${i}`,
45
+ date: parseOfxDate(t.date),
46
+ type: t.amount >= 0 ? "credit" : "debit",
47
+ fees: 0,
48
+ }));
49
+ }
50
+ return [];
51
+ },
52
+ getBalances: async (_s: Session, _id: string): Promise<RawBalance> => {
53
+ const creds = _s.creds ?? {};
54
+ const filePath = creds.filePath ?? "";
55
+ if (!filePath) return { cash: 0, marketValue: 0, asOf: Date.now() };
56
+ const buf = await readFile(filePath, "utf8");
57
+ if (buf.includes("OFXHEADER")) {
58
+ const ofx = parseOfx(buf);
59
+ return { cash: ofx.balance, marketValue: 0, asOf: Date.now() };
60
+ }
61
+ return { cash: 0, marketValue: 0, asOf: Date.now() };
62
+ },
63
+ };
64
+ }
@@ -0,0 +1,17 @@
1
+ export interface OfxResult { accountId: string; balance: number; transactions: { amount: number; date: string; name: string }[] }
2
+
3
+ function tag(s: string, name: string): string | undefined {
4
+ const m = s.match(new RegExp(`<${name}>([^<]*)</${name}>`));
5
+ return m ? m[1] : undefined;
6
+ }
7
+
8
+ export function parseOfx(ofx: string): OfxResult {
9
+ const accountId = tag(ofx, "ACCTID") ?? "unknown";
10
+ const balance = Number(tag(ofx, "BALAMT") ?? "0");
11
+ const transactions = [...ofx.matchAll(/<STMTTRN>([\s\S]*?)<\/STMTTRN>/g)].map((m) => ({
12
+ amount: Number(tag(m[1], "TRNAMT") ?? "0"),
13
+ date: tag(m[1], "DTPOSTED") ?? "",
14
+ name: tag(m[1], "NAME") ?? "",
15
+ }));
16
+ return { accountId, balance, transactions };
17
+ }
@@ -0,0 +1,17 @@
1
+ import type { AdapterRegistry } from "./registry";
2
+ import { createFileAdapter } from "./file";
3
+ import { createCoinbaseAdapter } from "./direct/coinbase";
4
+ import { createSnaptradeAdapter } from "./aggregator/snaptrade";
5
+ import { createSimplefinAdapter } from "./aggregator/simplefin";
6
+ import { createTellerAdapter } from "./aggregator/teller";
7
+
8
+ export function buildDefaultRegistry(): AdapterRegistry {
9
+ return new Map([
10
+ ["fidelity", createFileAdapter("fidelity", "brokerage")],
11
+ ["boa", createFileAdapter("boa", "banking")],
12
+ ["coinbase", createCoinbaseAdapter()],
13
+ ["fidelity-snaptrade", createSnaptradeAdapter()],
14
+ ["boa-simplefin", createSimplefinAdapter()],
15
+ ["boa-teller", createTellerAdapter()],
16
+ ]);
17
+ }