@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,21 @@
1
+ import type { RawHolding } from "./contract";
2
+ import { canonicalSymbol } from "../store/symbols";
3
+ import type { HoldingRow } from "../store/repo";
4
+
5
+ export interface NormalizeCtx { providerId: string; accountId: string; asOf?: number }
6
+
7
+ export function normalizeHolding(ctx: NormalizeCtx, raw: RawHolding): HoldingRow & { lots?: { open_date: number; qty: number; cost_basis: number }[] } {
8
+ if (raw.quantity < 0) throw new Error(`negative quantity for ${raw.symbol}`);
9
+ const rounded = Math.round(raw.quantity * 1e6) / 1e6;
10
+ const lots = raw.lots?.map((l) => ({ open_date: l.openDate, qty: Math.round(l.qty * 1e6) / 1e6, cost_basis: l.costBasis }));
11
+ return {
12
+ account_id: ctx.accountId,
13
+ symbol: canonicalSymbol(raw.symbol, raw.assetClass),
14
+ quantity: rounded,
15
+ avg_cost: raw.avgCost ?? null,
16
+ asset_class: raw.assetClass,
17
+ subclass: raw.subclass ?? null,
18
+ as_of: ctx.asOf ?? Date.now(),
19
+ lots,
20
+ };
21
+ }
@@ -0,0 +1,99 @@
1
+ import type Database from "better-sqlite3";
2
+ import type { ProviderAdapter, Credentials } from "./contract";
3
+ import { normalizeHolding } from "./normalizer";
4
+ import { upsertAccount, upsertHolding, upsertLot, markStale, listHoldings, listAccounts } from "../store/repo";
5
+
6
+ export type AdapterRegistry = Map<string, ProviderAdapter>;
7
+
8
+ export interface IngestCreds { [providerId: string]: Credentials }
9
+
10
+ export interface IngestResult { accounts: number; holdings: number; transactions: number; errors: number }
11
+
12
+ export async function runIngest(db: Database.Database, registry: AdapterRegistry, creds: IngestCreds, log?: { warn: (m: string, ctx?: unknown) => void }): Promise<IngestResult> {
13
+ let accounts = 0, holdings = 0, transactions = 0, errors = 0;
14
+ for (const [providerId, adapter] of registry) {
15
+ const c = creds[providerId];
16
+ if (!c) continue;
17
+ let session;
18
+ try {
19
+ session = await adapter.authenticate(c);
20
+ } catch (e) {
21
+ // provider-level auth failure: log so the always-on daemon doesn't silently retry forever.
22
+ // No per-account row exists yet to mark stale; surfaced via the result + log.
23
+ log?.warn(`ingest auth failed`, { providerId, error: e instanceof Error ? e.message : String(e) });
24
+ errors++;
25
+ continue;
26
+ }
27
+ // Attach creds to the session so per-call methods (getHoldings/getTransactions/getBalances)
28
+ // receive them via `session.creds` — the agreed threading pattern for all adapters.
29
+ session = { ...session, creds: c };
30
+ try {
31
+ const accts = await adapter.listAccounts(session);
32
+ for (const acc of accts) {
33
+ const id = `${providerId}:${acc.providerAccountId}`;
34
+ upsertAccount(db, { id, provider_id: providerId, kind: acc.kind, name: acc.name, mask_last4: acc.maskLast4 ?? null, currency: acc.currency });
35
+ accounts++;
36
+ try {
37
+ const asOf = Date.now();
38
+ const raws = await adapter.getHoldings(session, acc.providerAccountId);
39
+
40
+ // Clear existing holdings for this account before re-inserting
41
+ // This prevents stale holdings from accumulating when positions are sold
42
+ // Wrap in a transaction so either all holdings are replaced or none are
43
+ const replaceHoldings = db.transaction(() => {
44
+ // Delete existing holdings and lots for this account
45
+ db.prepare("DELETE FROM holdings WHERE account_id=?").run(id);
46
+ db.prepare("DELETE FROM lots WHERE holding_key LIKE ?").run(`${id}:%`);
47
+
48
+ for (const raw of raws) {
49
+ try {
50
+ const n = normalizeHolding({ providerId, accountId: id, asOf }, raw);
51
+ const { lots, ...row } = n;
52
+ upsertHolding(db, row);
53
+ holdings++;
54
+ // Persist tax lots if provided
55
+ if (lots) {
56
+ for (const lot of lots) {
57
+ upsertLot(db, {
58
+ id: `${id}:${n.symbol}:${lot.open_date}`,
59
+ holding_key: `${id}:${n.symbol}`,
60
+ ...lot,
61
+ });
62
+ }
63
+ }
64
+ } catch (e) {
65
+ // Log individual holding errors but continue with others
66
+ log?.warn("holding ingest failed", { accountId: id, symbol: raw.symbol, error: e instanceof Error ? e.message : String(e) });
67
+ }
68
+ }
69
+ });
70
+ replaceHoldings();
71
+ } catch (e) {
72
+ markStale(db, id, Date.now(), e instanceof Error ? e.message : String(e));
73
+ errors++;
74
+ }
75
+ // Exercise getTransactions/getBalances to validate the adapter contract
76
+ // (persistence of transactions/balances deferred to later milestones)
77
+ try {
78
+ const txns = await adapter.getTransactions(session, acc.providerAccountId);
79
+ transactions += txns.length;
80
+ } catch {
81
+ // Transaction fetch failures are non-fatal for ingest
82
+ }
83
+ try {
84
+ await adapter.getBalances(session, acc.providerAccountId);
85
+ } catch {
86
+ // Balance fetch failures are non-fatal for ingest
87
+ }
88
+ }
89
+ } catch (e) {
90
+ // listAccounts-level failure: account rows may not exist yet to mark stale; log + count.
91
+ log?.warn(`ingest listAccounts failed`, { providerId, error: e instanceof Error ? e.message : String(e) });
92
+ errors++;
93
+ }
94
+ }
95
+ return { accounts, holdings, transactions, errors };
96
+ }
97
+
98
+ // re-exports for convenience
99
+ export { listHoldings, listAccounts };
@@ -0,0 +1,28 @@
1
+ import { readFileSync, writeFileSync, chmodSync } from "node:fs";
2
+ import type { IngestCreds } from "./registry";
3
+
4
+ /**
5
+ * Loads secrets from the secrets file. Returns empty object if file doesn't exist.
6
+ * When creating the file, sets permissions to 0600 for security.
7
+ */
8
+ export function loadSecrets(secretsPath: string): IngestCreds {
9
+ try {
10
+ const raw = readFileSync(secretsPath, "utf8");
11
+ return JSON.parse(raw) as IngestCreds;
12
+ } catch (e) {
13
+ if (e instanceof Error && "code" in e && (e as { code: string }).code === "ENOENT") {
14
+ return {};
15
+ }
16
+ throw e;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Saves secrets to the file with 0600 permissions.
22
+ * If file exists with broader permissions, tightens to 0600.
23
+ */
24
+ export function saveSecrets(secretsPath: string, creds: IngestCreds): void {
25
+ writeFileSync(secretsPath, JSON.stringify(creds, null, 2), { encoding: "utf8", mode: 0o600 });
26
+ // Ensure permissions are 0600 even if file already existed with broader perms
27
+ try { chmodSync(secretsPath, 0o600); } catch { /* best effort */ }
28
+ }
@@ -0,0 +1,30 @@
1
+ import { CRYPTO_PREFIX } from "../store/symbols";
2
+
3
+ export interface PriceDeps { fetcher?: typeof fetch; feed?: "stooq" | "yfinance" }
4
+
5
+ export async function fetchClose(symbol: string, deps: PriceDeps = {}): Promise<number> {
6
+ const fetcher = deps.fetcher ?? ((u: string, i?: RequestInit) => fetch(u, i));
7
+
8
+ if (symbol.startsWith(CRYPTO_PREFIX)) {
9
+ const coin = symbol.slice(CRYPTO_PREFIX.length);
10
+ const res = await fetcher(`https://api.coinbase.com/api/v3/brokerage/market/products/${coin}-USD/spot`, {});
11
+ if (!res.ok) throw new Error(`coinbase price ${symbol} ${res.status}`);
12
+ const body = (await res.json()) as { price: string };
13
+ return Number(body.price);
14
+ }
15
+
16
+ const feed = deps.feed ?? "stooq";
17
+ if (feed === "stooq") {
18
+ // Stooq requires .us suffix for US equities
19
+ const stooqSymbol = symbol.includes(".") ? symbol.toLowerCase() : `${symbol.toLowerCase()}.us`;
20
+ const res = await fetcher(`https://stooq.com/q/l/?s=${stooqSymbol}&f=sd2t2ohlcv&h&e=csv`, {});
21
+ if (!res.ok) throw new Error(`stooq ${symbol} ${res.status}`);
22
+ const text = await res.text();
23
+ const row = text.trim().split(/\r?\n/)[1]?.split(",") ?? [];
24
+ // stooq CSV layout: Symbol,Date,Time,Open,High,Low,Close,Volume
25
+ const close = Number(row[6]);
26
+ if (!Number.isFinite(close)) throw new Error(`stooq ${symbol}: no close`);
27
+ return close;
28
+ }
29
+ throw new Error(`yfinance feed not implemented in v1 (symbol ${symbol})`);
30
+ }
@@ -0,0 +1,37 @@
1
+ export type Session = "pre" | "intraday" | "post" | "closed";
2
+
3
+ // NYSE holiday closures, keyed "month-day". v1 hardcodes 2026 only.
4
+ // KNOWN LIMITATION: the daemon is always-on into 2027+, so this list MUST be
5
+ // refreshed annually or session classification will silently drift (treating
6
+ // future holidays as trading days). Add 2027+ entries before Jan 1 each year.
7
+ // TODO(future): fetch from a holiday-data source keyed by year. Tracked in Open Items.
8
+ const HOLIDAYS_2026 = new Set([
9
+ "1-1", "1-19", "2-16", "4-3", "5-25", "6-19", "7-3", "9-7", "11-26", "12-25",
10
+ ]);
11
+
12
+ function etParts(d: Date): { dow: number; month: number; day: number; minutes: number; year: number } {
13
+ // Convert to America/New_York via Intl (handles DST)
14
+ const parts = new Intl.DateTimeFormat("en-US", {
15
+ timeZone: "America/New_York", weekday: "short", month: "numeric", day: "numeric", year: "numeric",
16
+ hour: "numeric", minute: "numeric", hour12: false,
17
+ }).formatToParts(d);
18
+ const get = (t: string) => Number(parts.find((p) => p.type === t)?.value ?? "0");
19
+ const wd = parts.find((p) => p.type === "weekday")?.value ?? "";
20
+ const dow = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"].indexOf(wd);
21
+ return { dow, month: get("month"), day: get("day"), minutes: get("hour") * 60 + get("minute"), year: get("year") };
22
+ }
23
+
24
+ export function classifySession(d: Date): Session {
25
+ const { dow, month, day, minutes, year } = etParts(d);
26
+ if (dow === 0 || dow === 6) return "closed";
27
+ // Year-guard: if we're past the last year with known holidays, throw loudly
28
+ // so the annual-refresh regression can't pass silently.
29
+ // Use ET year (not UTC) for consistency with the holiday check.
30
+ // Callers (routes, daemon) wrap in try/catch to handle gracefully.
31
+ if (year > 2026) throw new Error(`session classifier has no holidays for year ${year} — refresh HOLIDAYS list annually`);
32
+ if (HOLIDAYS_2026.has(`${month}-${day}`)) return "closed";
33
+ if (minutes >= 7 * 60 && minutes < 9 * 60 + 30) return "pre";
34
+ if (minutes >= 9 * 60 + 30 && minutes < 16 * 60) return "intraday";
35
+ if (minutes >= 16 * 60 && minutes < 20 * 60) return "post";
36
+ return "closed";
37
+ }
@@ -0,0 +1,10 @@
1
+ export interface DcaConfig { amount: number; cadence: "weekly" | "biweekly" | "monthly"; lastBuyAt?: number }
2
+ export interface DcaResult { due: boolean; amount: number; nextDueAt: number }
3
+
4
+ const MS_DAY = 86_400_000;
5
+ export function nextDcaBuy(cfg: DcaConfig, now: number): DcaResult {
6
+ const interval = cfg.cadence === "weekly" ? 7 * MS_DAY : cfg.cadence === "biweekly" ? 14 * MS_DAY : 30 * MS_DAY;
7
+ const last = cfg.lastBuyAt ?? now - interval; // if never bought, treat as due
8
+ const nextDueAt = last + interval;
9
+ return { due: now >= nextDueAt, amount: cfg.amount, nextDueAt };
10
+ }
@@ -0,0 +1,18 @@
1
+ export interface HoldingValued { symbol: string; assetClass: string; quantity: number; price: number }
2
+
3
+ export interface DriftRow { class: string; currentPct: number; targetPct: number; deltaPct: number; value: number }
4
+
5
+ export function computeDrift(holdings: HoldingValued[], target: { targetAllocation: Record<string, number> }): DriftRow[] {
6
+ const total = holdings.reduce((a, h) => a + h.quantity * h.price, 0);
7
+ if (total === 0) return [];
8
+ const byClass = new Map<string, number>();
9
+ for (const h of holdings) byClass.set(h.assetClass, (byClass.get(h.assetClass) ?? 0) + h.quantity * h.price);
10
+
11
+ const classes = new Set([...byClass.keys(), ...Object.keys(target.targetAllocation)]);
12
+ return [...classes].map((c) => {
13
+ const value = byClass.get(c) ?? 0;
14
+ const currentPct = value / total;
15
+ const targetPct = target.targetAllocation[c] ?? 0;
16
+ return { class: c, currentPct, targetPct, deltaPct: currentPct - targetPct, value };
17
+ });
18
+ }
@@ -0,0 +1,10 @@
1
+ export interface BandInput { currentPrice: number; allowedSlippagePct: number; lowerBandPct: number }
2
+ export interface Band { buyUpTo: number; addAtLower: number }
3
+
4
+ // Target prices are LIMIT/ACCEPTANCE bands derived from the user's rules — NOT forecasts.
5
+ export function acceptanceBand(input: BandInput): Band {
6
+ return {
7
+ buyUpTo: input.currentPrice * (1 + input.allowedSlippagePct / 100),
8
+ addAtLower: input.currentPrice * (1 - input.lowerBandPct / 100),
9
+ };
10
+ }
@@ -0,0 +1,46 @@
1
+ import type { HoldingValued } from "./drift";
2
+
3
+ export interface RebalanceInput { cashAvailable: number; minTradeDollars: number; lotPolicy?: "fifo" | "ltf" }
4
+ export interface RebalanceOrder { symbol: string; side: "buy" | "sell"; dollars: number; estQty: number }
5
+
6
+ export function computeRebalance(
7
+ holdings: HoldingValued[],
8
+ target: { targetAllocation: Record<string, number> },
9
+ input: RebalanceInput,
10
+ ): RebalanceOrder[] {
11
+ const total = holdings.reduce((a, h) => a + h.quantity * h.price, 0) + input.cashAvailable;
12
+ const orders: RebalanceOrder[] = [];
13
+
14
+ // per-class target dollars
15
+ const targetByClass = new Map<string, number>();
16
+ for (const [c, pct] of Object.entries(target.targetAllocation)) targetByClass.set(c, pct * total);
17
+
18
+ // current by class
19
+ const currentByClass = new Map<string, number>();
20
+ for (const h of holdings) currentByClass.set(h.assetClass, (currentByClass.get(h.assetClass) ?? 0) + h.quantity * h.price);
21
+ currentByClass.set("cash", input.cashAvailable);
22
+
23
+ for (const [cls, targetDollars] of targetByClass) {
24
+ const current = currentByClass.get(cls) ?? 0;
25
+ const diff = targetDollars - current;
26
+ if (Math.abs(diff) < input.minTradeDollars) continue;
27
+ if (cls === "cash") continue; // cash is the residual; not a tradable order
28
+ // distribute the class diff proportionally across that class's holdings
29
+ const classHoldings = holdings.filter((h) => h.assetClass === cls);
30
+ const classValue = classHoldings.reduce((a, h) => a + h.quantity * h.price, 0) || 1;
31
+
32
+ if (classHoldings.length === 0 && diff > input.minTradeDollars) {
33
+ // No holdings in this class yet — emit a placeholder buy order
34
+ // The agent will need to recommend a specific instrument
35
+ orders.push({ symbol: `[${cls}]`, side: "buy", dollars: diff, estQty: 0 });
36
+ } else {
37
+ for (const h of classHoldings) {
38
+ const share = (h.quantity * h.price) / classValue;
39
+ const d = diff * share;
40
+ if (Math.abs(d) < input.minTradeDollars) continue;
41
+ orders.push({ symbol: h.symbol, side: d > 0 ? "buy" : "sell", dollars: Math.abs(d), estQty: Math.abs(d) / h.price });
42
+ }
43
+ }
44
+ }
45
+ return orders;
46
+ }
@@ -0,0 +1,23 @@
1
+ import type { HoldingValued } from "./drift";
2
+
3
+ export interface RiskFlag { kind: "concentration" | "cashDrag"; symbol?: string; value: number; limit: number }
4
+
5
+ export function checkRisk(
6
+ holdings: HoldingValued[],
7
+ goal: { riskLimits: Record<string, number>; cashAvailable?: number },
8
+ ): RiskFlag[] {
9
+ const flags: RiskFlag[] = [];
10
+ const total = holdings.reduce((a, h) => a + h.quantity * h.price, 0) + (goal.cashAvailable ?? 0);
11
+ if (total === 0) return flags;
12
+ const maxPos = goal.riskLimits.maxSinglePosition;
13
+ if (maxPos) {
14
+ for (const h of holdings) {
15
+ const pct = (h.quantity * h.price) / total;
16
+ if (pct > maxPos) flags.push({ kind: "concentration", symbol: h.symbol, value: pct, limit: maxPos });
17
+ }
18
+ }
19
+ const cashPct = (goal.cashAvailable ?? 0) / total;
20
+ const maxCash = goal.riskLimits.maxCashDrag;
21
+ if (maxCash && cashPct > maxCash) flags.push({ kind: "cashDrag", value: cashPct, limit: maxCash });
22
+ return flags;
23
+ }
@@ -0,0 +1,27 @@
1
+ import type { DriftRow } from "./drift";
2
+ import type { RebalanceOrder } from "./rebalance";
3
+ import type { RiskFlag } from "./risk";
4
+ import type { DcaResult } from "./dca";
5
+ import type { Session } from "../market/session";
6
+
7
+ export interface SuggestionRecord {
8
+ id: string; createdAt: number; marketSession: Session;
9
+ kind: "drift" | "rebalance" | "risk" | "cashDrag" | "dca"; payload: unknown;
10
+ }
11
+
12
+ export interface SuggestionInput {
13
+ drift: DriftRow[]; rebalance: RebalanceOrder[]; risk: RiskFlag[]; dca: DcaResult[];
14
+ session: Session; now: number;
15
+ }
16
+
17
+ export function buildSuggestions(input: SuggestionInput): SuggestionRecord[] {
18
+ const { drift, rebalance, risk, dca, session, now } = input;
19
+ const recs: SuggestionRecord[] = [];
20
+ let n = 0;
21
+ const mk = (kind: SuggestionRecord["kind"], payload: unknown): SuggestionRecord => ({ id: `s-${now}-${n++}`, createdAt: now, marketSession: session, kind, payload });
22
+ for (const d of drift) if (Math.abs(d.deltaPct) > 0.02) recs.push(mk("drift", d));
23
+ for (const r of rebalance) recs.push(mk("rebalance", r));
24
+ for (const r of risk) recs.push(mk(r.kind === "cashDrag" ? "cashDrag" : "risk", r));
25
+ for (const d of dca) if (d.due) recs.push(mk("dca", d));
26
+ return recs;
27
+ }
@@ -0,0 +1,11 @@
1
+ export interface GoalInput { targetAllocation: Record<string, number>; riskLimits: Record<string, number> }
2
+
3
+ export function validateGoal(goal: GoalInput): string[] {
4
+ const errs: string[] = [];
5
+ const entries = Object.entries(goal.targetAllocation);
6
+ for (const [, v] of entries) if (v < 0) errs.push("targetAllocation has a negative value (must be >= 0)");
7
+ const sum = entries.reduce((a, [, v]) => a + v, 0);
8
+ // Tolerance band: ±0.01 (1 percentage point). Explicit and tested — see validate.test.ts.
9
+ if (Math.abs(sum - 1) > 0.01) errs.push(`targetAllocation must sum to ~1.0 within ±1pp tolerance (got ${sum.toFixed(4)})`);
10
+ return errs;
11
+ }
@@ -0,0 +1,83 @@
1
+ import type Database from "better-sqlite3";
2
+ import type { AdapterRegistry, IngestCreds } from "../ingest/registry";
3
+ import { runTick } from "./tick";
4
+ import { classifySession } from "../market/session";
5
+ import type { Logger } from "../server/logger";
6
+
7
+ export interface DaemonDeps {
8
+ db: Database.Database;
9
+ registry: AdapterRegistry;
10
+ creds: IngestCreds;
11
+ fetcher?: typeof fetch;
12
+ log?: Logger;
13
+ dataFeed?: "stooq" | "yfinance";
14
+ }
15
+
16
+ export interface DaemonHandle {
17
+ stop: () => void;
18
+ }
19
+
20
+ const MS_MINUTE = 60_000;
21
+ const MS_HOUR = 3_600_000;
22
+
23
+ export function getNextTickDelay(session: string): number {
24
+ switch (session) {
25
+ case "pre":
26
+ return 30 * MS_MINUTE; // Light: every 30 min
27
+ case "intraday":
28
+ return 30 * MS_MINUTE; // Active: every 30 min
29
+ case "post":
30
+ return MS_HOUR; // Post-market: hourly
31
+ case "closed":
32
+ return 4 * MS_HOUR; // Closed: every 4 hours (crypto only)
33
+ default:
34
+ return MS_HOUR;
35
+ }
36
+ }
37
+
38
+ export function startDaemon(deps: DaemonDeps): DaemonHandle {
39
+ const { log } = deps;
40
+ let running = true;
41
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
42
+
43
+ async function tick() {
44
+ if (!running) return;
45
+
46
+ try {
47
+ const result = await runTick({ ...deps, dataFeed: deps.dataFeed });
48
+ log?.info("daemon tick complete", result);
49
+ } catch (err) {
50
+ log?.error("daemon tick failed", { error: err instanceof Error ? err.message : String(err) });
51
+ }
52
+
53
+ if (!running) return;
54
+
55
+ // Schedule next tick based on current session
56
+ try {
57
+ const session = classifySession(new Date());
58
+ const delay = getNextTickDelay(session);
59
+ log?.info("next tick scheduled", { session, delayMs: delay });
60
+ timeoutId = setTimeout(tick, delay);
61
+ } catch (err) {
62
+ // If session classification fails (e.g., unsupported year), fall back to hourly
63
+ log?.error("session classification failed, falling back to hourly", { error: err instanceof Error ? err.message : String(err) });
64
+ timeoutId = setTimeout(tick, MS_HOUR);
65
+ }
66
+ }
67
+
68
+ // Start first tick immediately
69
+ tick();
70
+
71
+ log?.info("daemon started");
72
+
73
+ return {
74
+ stop: () => {
75
+ running = false;
76
+ if (timeoutId) {
77
+ clearTimeout(timeoutId);
78
+ timeoutId = null;
79
+ }
80
+ log?.info("daemon stopped");
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,171 @@
1
+ import type Database from "better-sqlite3";
2
+ import type { AdapterRegistry, IngestCreds } from "../ingest/registry";
3
+ import { runIngest } from "../ingest/registry";
4
+ import { classifySession, type Session } from "../market/session";
5
+ import { fetchClose } from "../market/prices";
6
+ import { listHoldings, listAccounts, insertSuggestion, listGoals } from "../store/repo";
7
+ import { computeDrift, type HoldingValued } from "../quant/drift";
8
+ import { computeRebalance } from "../quant/rebalance";
9
+ import { checkRisk } from "../quant/risk";
10
+ // DCA not used until config is stored in schema
11
+ import { buildSuggestions } from "../quant/suggestions";
12
+ import { isCrypto } from "../store/symbols";
13
+ import type { Logger } from "../server/logger";
14
+
15
+ export interface TickDeps {
16
+ db: Database.Database;
17
+ registry: AdapterRegistry;
18
+ creds: IngestCreds;
19
+ fetcher?: typeof fetch;
20
+ log?: Logger;
21
+ now?: number;
22
+ dataFeed?: "stooq" | "yfinance";
23
+ }
24
+
25
+ export interface TickResult {
26
+ session: Session;
27
+ accountsIngested: number;
28
+ holdingsIngested: number;
29
+ pricesUpdated: number;
30
+ suggestionsCreated: number;
31
+ errors: number;
32
+ }
33
+
34
+ export async function runTick(deps: TickDeps): Promise<TickResult> {
35
+ const { db, registry, creds, log } = deps;
36
+ const now = deps.now ?? Date.now();
37
+ const fetcher = deps.fetcher ?? fetch;
38
+
39
+ // Classify current market session (fall back to "closed" if year-guard throws)
40
+ let session: Session;
41
+ try {
42
+ session = classifySession(new Date(now));
43
+ } catch (err) {
44
+ log?.warn("session classification failed, falling back to closed", { error: err instanceof Error ? err.message : String(err) });
45
+ session = "closed";
46
+ }
47
+ log?.info("tick start", { session, now });
48
+
49
+ // Ingest data from providers
50
+ const ingestResult = await runIngest(db, registry, creds, log);
51
+ log?.info("ingest complete", ingestResult);
52
+
53
+ // Refresh prices for held symbols
54
+ let pricesUpdated = 0;
55
+ const accounts = listAccounts(db);
56
+ const symbols = new Set<string>();
57
+ for (const a of accounts) {
58
+ const holdings = listHoldings(db, a.id);
59
+ for (const h of holdings) {
60
+ symbols.add(h.symbol);
61
+ }
62
+ }
63
+
64
+ // On closed sessions, only refresh crypto prices
65
+ const symbolsToRefresh = session === "closed"
66
+ ? [...symbols].filter(isCrypto)
67
+ : [...symbols];
68
+
69
+ for (const symbol of symbolsToRefresh) {
70
+ try {
71
+ const close = await fetchClose(symbol, { fetcher, feed: deps.dataFeed });
72
+ // Store price
73
+ db.prepare("INSERT OR REPLACE INTO prices (symbol, date, close, source) VALUES (?, ?, ?, ?)")
74
+ .run(symbol, Math.floor(now / 86400000), close, "tick");
75
+ pricesUpdated++;
76
+ } catch (err) {
77
+ log?.warn("price fetch failed", { symbol, error: err instanceof Error ? err.message : String(err) });
78
+ }
79
+ }
80
+
81
+ // Run quant engine
82
+ const holdingsValued: HoldingValued[] = [];
83
+ for (const a of accounts) {
84
+ const holdings = listHoldings(db, a.id);
85
+ for (const h of holdings) {
86
+ const priceRow = db.prepare("SELECT close FROM prices WHERE symbol=? ORDER BY date DESC LIMIT 1").get(h.symbol) as { close: number } | undefined;
87
+ const price = priceRow?.close ?? h.avg_cost ?? 0;
88
+ holdingsValued.push({
89
+ symbol: h.symbol,
90
+ assetClass: h.asset_class,
91
+ quantity: h.quantity,
92
+ price,
93
+ });
94
+ }
95
+ }
96
+
97
+ // Get goals for target allocation
98
+ const goals = listGoals(db);
99
+ const targetAllocation = goals.length > 0 ? JSON.parse(goals[0].target_allocation) : {};
100
+ const riskLimits = goals.length > 0 ? JSON.parse(goals[0].risk_limits) : {};
101
+
102
+ // Compute drift
103
+ const drift = computeDrift(holdingsValued, { targetAllocation });
104
+
105
+ // Compute rebalance
106
+ const rebalance = computeRebalance(holdingsValued, { targetAllocation }, {
107
+ cashAvailable: 0, // TODO: get from balances
108
+ minTradeDollars: 10,
109
+ });
110
+
111
+ // Check risk
112
+ const risk = checkRisk(holdingsValued, { riskLimits, cashAvailable: 0 });
113
+
114
+ // Check DCA - only if goals have DCA config (skip hardcoded defaults)
115
+ // For now, skip DCA suggestions until DCA config is stored in the schema
116
+ const dcaResults: { due: boolean; amount: number; nextDueAt: number }[] = [];
117
+
118
+ // Build suggestions
119
+ const suggestions = buildSuggestions({
120
+ drift,
121
+ rebalance,
122
+ risk,
123
+ dca: dcaResults,
124
+ session,
125
+ now,
126
+ });
127
+
128
+ // Persist suggestions
129
+ let suggestionsCreated = 0;
130
+ for (const s of suggestions) {
131
+ insertSuggestion(db, {
132
+ id: s.id,
133
+ created_at: s.createdAt,
134
+ market_session: s.marketSession,
135
+ kind: s.kind,
136
+ payload: JSON.stringify(s.payload),
137
+ status: "pending",
138
+ });
139
+ suggestionsCreated++;
140
+ }
141
+
142
+ // Persist market session snapshot
143
+ const sessionDate = new Date(now).toISOString().split("T")[0];
144
+ db.prepare("INSERT OR REPLACE INTO market_sessions (date, session, snapshot) VALUES (?, ?, ?)")
145
+ .run(sessionDate, session, JSON.stringify({
146
+ timestamp: now,
147
+ accountsIngested: ingestResult.accounts,
148
+ holdingsIngested: ingestResult.holdings,
149
+ pricesUpdated,
150
+ suggestionsCreated,
151
+ errors: ingestResult.errors,
152
+ }));
153
+
154
+ log?.info("tick complete", {
155
+ session,
156
+ accountsIngested: ingestResult.accounts,
157
+ holdingsIngested: ingestResult.holdings,
158
+ pricesUpdated,
159
+ suggestionsCreated,
160
+ errors: ingestResult.errors,
161
+ });
162
+
163
+ return {
164
+ session,
165
+ accountsIngested: ingestResult.accounts,
166
+ holdingsIngested: ingestResult.holdings,
167
+ pricesUpdated,
168
+ suggestionsCreated,
169
+ errors: ingestResult.errors,
170
+ };
171
+ }