@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.
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/bin/finance-api.ts +71 -0
- package/docker/Dockerfile +44 -0
- package/docker/README.md +3 -0
- package/docker/docker-compose.yml +26 -0
- package/package.json +46 -0
- package/src/config/load.ts +42 -0
- package/src/config/types.ts +11 -0
- package/src/index.ts +14 -0
- package/src/ingest/aggregator/simplefin.ts +18 -0
- package/src/ingest/aggregator/snaptrade.ts +19 -0
- package/src/ingest/aggregator/teller.ts +19 -0
- package/src/ingest/contract.ts +22 -0
- package/src/ingest/direct/coinbase.ts +44 -0
- package/src/ingest/file/csv.ts +30 -0
- package/src/ingest/file/index.ts +64 -0
- package/src/ingest/file/ofx.ts +17 -0
- package/src/ingest/matrix.ts +17 -0
- package/src/ingest/normalizer.ts +21 -0
- package/src/ingest/registry.ts +99 -0
- package/src/ingest/secrets.ts +28 -0
- package/src/market/prices.ts +30 -0
- package/src/market/session.ts +37 -0
- package/src/quant/dca.ts +10 -0
- package/src/quant/drift.ts +18 -0
- package/src/quant/limits.ts +10 -0
- package/src/quant/rebalance.ts +46 -0
- package/src/quant/risk.ts +23 -0
- package/src/quant/suggestions.ts +27 -0
- package/src/quant/validate.ts +11 -0
- package/src/scheduler/daemon.ts +83 -0
- package/src/scheduler/tick.ts +171 -0
- package/src/server/app.ts +54 -0
- package/src/server/auth.ts +25 -0
- package/src/server/bootstrap.ts +32 -0
- package/src/server/errors.ts +20 -0
- package/src/server/health.ts +14 -0
- package/src/server/logger.ts +29 -0
- package/src/server/routes/allocation.ts +26 -0
- package/src/server/routes/drift.ts +37 -0
- package/src/server/routes/export.ts +32 -0
- package/src/server/routes/goals.ts +44 -0
- package/src/server/routes/history.ts +27 -0
- package/src/server/routes/holdings.ts +16 -0
- package/src/server/routes/import-route.ts +29 -0
- package/src/server/routes/market-status.ts +17 -0
- package/src/server/routes/net-worth.ts +23 -0
- package/src/server/routes/suggestions.ts +22 -0
- package/src/server/routes/sync.ts +27 -0
- package/src/server/start.ts +54 -0
- package/src/store/backup.ts +26 -0
- package/src/store/db.ts +8 -0
- package/src/store/migrations.ts +18 -0
- package/src/store/repo.ts +68 -0
- package/src/store/schema.ts +40 -0
- 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"]
|
package/docker/README.md
ADDED
|
@@ -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
|
+
}
|