@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
|
@@ -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
|
+
}
|
package/src/quant/dca.ts
ADDED
|
@@ -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
|
+
}
|