@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
@@ -0,0 +1,68 @@
1
+ import type Database from "better-sqlite3";
2
+
3
+ export interface AccountRow { id: string; provider_id: string; kind: string; name: string; mask_last4?: string | null; currency?: string; }
4
+ export interface HoldingRow { account_id: string; symbol: string; quantity: number; avg_cost?: number | null; asset_class: string; subclass?: string | null; as_of: number; }
5
+
6
+ export function upsertAccount(db: Database.Database, a: AccountRow): void {
7
+ db.prepare(`INSERT INTO accounts (id,provider_id,kind,name,mask_last4,currency,stale_at,stale_reason)
8
+ VALUES (@id,@provider_id,@kind,@name,@mask_last4,@currency,NULL,NULL)
9
+ ON CONFLICT(id) DO UPDATE SET provider_id=@provider_id, kind=@kind, name=@name, mask_last4=@mask_last4, currency=@currency, stale_at=NULL, stale_reason=NULL`)
10
+ .run({ ...a, mask_last4: a.mask_last4 ?? null, currency: a.currency ?? "USD" });
11
+ }
12
+
13
+ export function markStale(db: Database.Database, id: string, staleAt: number, reason: string): void {
14
+ db.prepare("UPDATE accounts SET stale_at=?, stale_reason=? WHERE id=?").run(staleAt, reason, id);
15
+ }
16
+
17
+ export function upsertHolding(db: Database.Database, h: HoldingRow): void {
18
+ db.prepare(`INSERT INTO holdings (account_id,symbol,quantity,avg_cost,asset_class,subclass,as_of)
19
+ VALUES (@account_id,@symbol,@quantity,@avg_cost,@asset_class,@subclass,@as_of)
20
+ ON CONFLICT(account_id,symbol) DO UPDATE SET quantity=@quantity,avg_cost=@avg_cost,asset_class=@asset_class,subclass=@subclass,as_of=@as_of`)
21
+ .run({ ...h, avg_cost: h.avg_cost ?? null, subclass: h.subclass ?? null });
22
+ }
23
+
24
+ export function listHoldings(db: Database.Database, accountId: string): HoldingRow[] {
25
+ return db.prepare("SELECT * FROM holdings WHERE account_id=?").all(accountId) as HoldingRow[];
26
+ }
27
+
28
+ export function listAccounts(db: Database.Database): AccountRow[] {
29
+ return db.prepare("SELECT * FROM accounts").all() as AccountRow[];
30
+ }
31
+
32
+ export interface LotRow { id: string; holding_key: string; open_date: number; qty: number; cost_basis: number }
33
+
34
+ export function upsertLot(db: Database.Database, lot: LotRow): void {
35
+ db.prepare(`INSERT INTO lots (id, holding_key, open_date, qty, cost_basis)
36
+ VALUES (@id, @holding_key, @open_date, @qty, @cost_basis)
37
+ ON CONFLICT(id) DO UPDATE SET qty=@qty, cost_basis=@cost_basis`)
38
+ .run(lot);
39
+ }
40
+
41
+ export interface SuggestionRow { id: string; created_at: number; market_session: string; kind: string; payload: string; status: string }
42
+
43
+ export function insertSuggestion(db: Database.Database, s: SuggestionRow): void {
44
+ db.prepare(`INSERT INTO suggestion_records (id, created_at, market_session, kind, payload, status)
45
+ VALUES (@id, @created_at, @market_session, @kind, @payload, @status)`)
46
+ .run(s);
47
+ }
48
+
49
+ export function listPendingSuggestions(db: Database.Database): SuggestionRow[] {
50
+ return db.prepare("SELECT * FROM suggestion_records WHERE status='pending' ORDER BY created_at").all() as SuggestionRow[];
51
+ }
52
+
53
+ export function dismissSuggestion(db: Database.Database, id: string): void {
54
+ db.prepare("UPDATE suggestion_records SET status='dismissed' WHERE id=?").run(id);
55
+ }
56
+
57
+ export interface GoalRow { id: string; name: string; target_allocation: string; risk_limits: string; horizon_years?: number | null }
58
+
59
+ export function upsertGoal(db: Database.Database, g: GoalRow): void {
60
+ db.prepare(`INSERT INTO goals (id, name, target_allocation, risk_limits, horizon_years)
61
+ VALUES (@id, @name, @target_allocation, @risk_limits, @horizon_years)
62
+ ON CONFLICT(id) DO UPDATE SET name=@name, target_allocation=@target_allocation, risk_limits=@risk_limits, horizon_years=@horizon_years`)
63
+ .run({ ...g, horizon_years: g.horizon_years ?? null });
64
+ }
65
+
66
+ export function listGoals(db: Database.Database): GoalRow[] {
67
+ return db.prepare("SELECT * FROM goals").all() as GoalRow[];
68
+ }
@@ -0,0 +1,40 @@
1
+ export interface Migration { version: number; statement: string }
2
+
3
+ // v1 — initial schema. Future schema changes ADD new {version, statement} entries
4
+ // (e.g. ALTER TABLE ...) rather than editing these. The runner (migrations.ts)
5
+ // applies only migrations with version > current recorded version.
6
+ export const MIGRATIONS_V1: Migration[] = [
7
+ { version: 1, statement:
8
+ `CREATE TABLE IF NOT EXISTS accounts (
9
+ id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, kind TEXT NOT NULL,
10
+ name TEXT NOT NULL, mask_last4 TEXT, currency TEXT NOT NULL DEFAULT 'USD',
11
+ stale_at INTEGER, stale_reason TEXT)` },
12
+ { version: 2, statement:
13
+ `CREATE TABLE IF NOT EXISTS holdings (
14
+ account_id TEXT NOT NULL, symbol TEXT NOT NULL, quantity REAL NOT NULL,
15
+ avg_cost REAL, asset_class TEXT NOT NULL, subclass TEXT, as_of INTEGER NOT NULL,
16
+ PRIMARY KEY (account_id, symbol))` },
17
+ { version: 3, statement:
18
+ `CREATE TABLE IF NOT EXISTS transactions (
19
+ id TEXT PRIMARY KEY, account_id TEXT NOT NULL, date INTEGER NOT NULL,
20
+ symbol TEXT, qty REAL, price REAL, type TEXT, fees REAL DEFAULT 0)` },
21
+ { version: 4, statement:
22
+ `CREATE TABLE IF NOT EXISTS prices (
23
+ symbol TEXT NOT NULL, date INTEGER NOT NULL, close REAL NOT NULL, source TEXT NOT NULL,
24
+ PRIMARY KEY (symbol, date))` },
25
+ { version: 5, statement:
26
+ `CREATE TABLE IF NOT EXISTS lots (
27
+ id TEXT PRIMARY KEY, holding_key TEXT NOT NULL, open_date INTEGER NOT NULL,
28
+ qty REAL NOT NULL, cost_basis REAL NOT NULL)` },
29
+ { version: 6, statement:
30
+ `CREATE TABLE IF NOT EXISTS goals (
31
+ id TEXT PRIMARY KEY, name TEXT NOT NULL, target_allocation TEXT NOT NULL,
32
+ risk_limits TEXT NOT NULL, horizon_years INTEGER)` },
33
+ { version: 7, statement:
34
+ `CREATE TABLE IF NOT EXISTS suggestion_records (
35
+ id TEXT PRIMARY KEY, created_at INTEGER NOT NULL, market_session TEXT NOT NULL,
36
+ kind TEXT NOT NULL, payload TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending')` },
37
+ { version: 8, statement:
38
+ `CREATE TABLE IF NOT EXISTS market_sessions (
39
+ date TEXT PRIMARY KEY, session TEXT NOT NULL, snapshot TEXT NOT NULL)` },
40
+ ];
@@ -0,0 +1,11 @@
1
+ export const CRYPTO_PREFIX = "CRYPTO:";
2
+
3
+ export function canonicalSymbol(rawSymbol: string, assetClass: string): string {
4
+ const s = rawSymbol.trim().toUpperCase();
5
+ if (assetClass === "crypto") return `${CRYPTO_PREFIX}${s}`;
6
+ return s; // equities/etfs/mutual funds: plain uppercased ticker; CUSIP→ticker mapping added in M4
7
+ }
8
+
9
+ export function isCrypto(symbol: string): boolean {
10
+ return symbol.startsWith(CRYPTO_PREFIX);
11
+ }