@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,54 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type Database from "better-sqlite3";
|
|
3
|
+
import { bearerAuth } from "./auth";
|
|
4
|
+
import { marketStatusRoutes } from "./routes/market-status";
|
|
5
|
+
import { holdingsRoutes } from "./routes/holdings";
|
|
6
|
+
import { netWorthRoutes } from "./routes/net-worth";
|
|
7
|
+
import { allocationRoutes } from "./routes/allocation";
|
|
8
|
+
import { goalsRoutes } from "./routes/goals";
|
|
9
|
+
import { suggestionsRoutes } from "./routes/suggestions";
|
|
10
|
+
import { syncRoutes } from "./routes/sync";
|
|
11
|
+
import { importRoutes } from "./routes/import-route";
|
|
12
|
+
import { historyRoutes } from "./routes/history";
|
|
13
|
+
import { healthRoutes } from "./health";
|
|
14
|
+
import { exportRoutes } from "./routes/export";
|
|
15
|
+
import { driftRoutes } from "./routes/drift";
|
|
16
|
+
|
|
17
|
+
export interface AppDeps {
|
|
18
|
+
db: Database.Database;
|
|
19
|
+
token: string;
|
|
20
|
+
registry?: import("../ingest/registry").AdapterRegistry;
|
|
21
|
+
creds?: import("../ingest/registry").IngestCreds;
|
|
22
|
+
fetcher?: typeof fetch;
|
|
23
|
+
dataFeed?: "stooq" | "yfinance";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createApp(deps: AppDeps): Hono {
|
|
27
|
+
const app = new Hono();
|
|
28
|
+
|
|
29
|
+
// Health endpoint is public (no auth)
|
|
30
|
+
app.route("/v1/health", healthRoutes());
|
|
31
|
+
|
|
32
|
+
// All other /v1 routes require bearer token
|
|
33
|
+
app.use("/v1/*", bearerAuth(deps.token));
|
|
34
|
+
|
|
35
|
+
// Mount routes
|
|
36
|
+
app.route("/v1/market-status", marketStatusRoutes());
|
|
37
|
+
app.route("/v1/holdings", holdingsRoutes(deps.db));
|
|
38
|
+
app.route("/v1/net-worth", netWorthRoutes(deps.db));
|
|
39
|
+
app.route("/v1/allocation", allocationRoutes(deps.db));
|
|
40
|
+
app.route("/v1/goals", goalsRoutes(deps.db));
|
|
41
|
+
app.route("/v1/suggestions", suggestionsRoutes(deps.db));
|
|
42
|
+
app.route("/v1/sync", syncRoutes(deps.db, {
|
|
43
|
+
registry: deps.registry ?? new Map(),
|
|
44
|
+
creds: deps.creds ?? {},
|
|
45
|
+
fetcher: deps.fetcher,
|
|
46
|
+
dataFeed: deps.dataFeed,
|
|
47
|
+
}));
|
|
48
|
+
app.route("/v1/import", importRoutes(deps.db));
|
|
49
|
+
app.route("/v1/history", historyRoutes(deps.db));
|
|
50
|
+
app.route("/v1/export", exportRoutes(deps.db));
|
|
51
|
+
app.route("/v1/drift", driftRoutes(deps.db));
|
|
52
|
+
|
|
53
|
+
return app;
|
|
54
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import type { Context, Next } from "hono";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Bearer-token auth middleware for Hono.
|
|
6
|
+
* Compares the Authorization header against the expected token using constant-time comparison.
|
|
7
|
+
*/
|
|
8
|
+
export function bearerAuth(token: string) {
|
|
9
|
+
return async (c: Context, next: Next): Promise<Response | void> => {
|
|
10
|
+
const auth = c.req.header("Authorization") ?? "";
|
|
11
|
+
const match = auth.match(/^Bearer\s+(.+)$/i);
|
|
12
|
+
if (!match) {
|
|
13
|
+
return c.json({ ok: false, error: { code: "unauthorized", message: "Missing or invalid Authorization header" } }, 401);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const provided = Buffer.from(match[1]);
|
|
17
|
+
const expected = Buffer.from(token);
|
|
18
|
+
|
|
19
|
+
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
|
|
20
|
+
return c.json({ ok: false, error: { code: "unauthorized", message: "Invalid token" } }, 401);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
await next();
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { openSync, closeSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Ensures a bearer token exists at the given path. Race-safe via O_EXCL:
|
|
7
|
+
* the first caller creates the file; concurrent callers catch EEXIST and read.
|
|
8
|
+
*/
|
|
9
|
+
export async function ensureToken(tokenPath: string): Promise<string> {
|
|
10
|
+
const dir = dirname(tokenPath);
|
|
11
|
+
mkdirSync(dir, { recursive: true });
|
|
12
|
+
|
|
13
|
+
const token = randomUUID();
|
|
14
|
+
try {
|
|
15
|
+
// Atomic create-exclusive: O_CREAT | O_EXCL fails with EEXIST if file exists
|
|
16
|
+
const fd = openSync(tokenPath, "wx", 0o600);
|
|
17
|
+
try {
|
|
18
|
+
writeFileSync(fd, token, "utf8");
|
|
19
|
+
} finally {
|
|
20
|
+
closeSync(fd);
|
|
21
|
+
}
|
|
22
|
+
// Ensure permissions are restrictive (in case umask modified them)
|
|
23
|
+
try { chmodSync(tokenPath, 0o600); } catch { /* best effort */ }
|
|
24
|
+
return token;
|
|
25
|
+
} catch (e) {
|
|
26
|
+
if (e instanceof Error && "code" in e && (e as { code: string }).code === "EEXIST") {
|
|
27
|
+
// Another caller won the race; read their token
|
|
28
|
+
return readFileSync(tokenPath, "utf8").trim();
|
|
29
|
+
}
|
|
30
|
+
throw e;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function ok<T>(data: T) {
|
|
2
|
+
return { ok: true as const, data };
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function fail(code: string, message: string) {
|
|
6
|
+
return { ok: false as const, error: { code, message } };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface StalenessInfo {
|
|
10
|
+
staleAt?: number | null;
|
|
11
|
+
staleReason?: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function withStaleness<T extends Record<string, unknown>>(data: T, stale?: StalenessInfo): T & { staleAt?: number | null; staleReason?: string | null } {
|
|
15
|
+
return {
|
|
16
|
+
...data,
|
|
17
|
+
staleAt: stale?.staleAt ?? null,
|
|
18
|
+
staleReason: stale?.staleReason ?? null,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { ok } from "./errors";
|
|
3
|
+
|
|
4
|
+
export function healthRoutes() {
|
|
5
|
+
const r = new Hono();
|
|
6
|
+
const startTime = Date.now();
|
|
7
|
+
|
|
8
|
+
r.get("/", (c) => {
|
|
9
|
+
const uptimeS = Math.floor((Date.now() - startTime) / 1000);
|
|
10
|
+
return c.json(ok({ status: "ok", uptimeS }));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
return r;
|
|
14
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const SENSITIVE_KEYS = new Set(["token", "privateKey", "consumerKey", "accessKey", "secret", "password"]);
|
|
2
|
+
|
|
3
|
+
function redact(obj: unknown): unknown {
|
|
4
|
+
if (typeof obj !== "object" || obj === null) return obj;
|
|
5
|
+
if (Array.isArray(obj)) return obj.map(redact);
|
|
6
|
+
const result: Record<string, unknown> = {};
|
|
7
|
+
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
|
8
|
+
result[k] = SENSITIVE_KEYS.has(k) ? "[REDACTED]" : redact(v);
|
|
9
|
+
}
|
|
10
|
+
return result;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Logger {
|
|
14
|
+
info(msg: string, ctx?: unknown): void;
|
|
15
|
+
warn(msg: string, ctx?: unknown): void;
|
|
16
|
+
error(msg: string, ctx?: unknown): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createLogger(): Logger {
|
|
20
|
+
const log = (level: string, msg: string, ctx?: unknown) => {
|
|
21
|
+
const entry = { level, msg, ts: new Date().toISOString(), ...(ctx ? { ctx: redact(ctx) } : {}) };
|
|
22
|
+
process.stderr.write(JSON.stringify(entry) + "\n");
|
|
23
|
+
};
|
|
24
|
+
return {
|
|
25
|
+
info: (msg, ctx) => log("info", msg, ctx),
|
|
26
|
+
warn: (msg, ctx) => log("warn", msg, ctx),
|
|
27
|
+
error: (msg, ctx) => log("error", msg, ctx),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type Database from "better-sqlite3";
|
|
3
|
+
// listHoldings not needed - using direct SQL query
|
|
4
|
+
import { ok } from "../errors";
|
|
5
|
+
|
|
6
|
+
export function allocationRoutes(db: Database.Database) {
|
|
7
|
+
const r = new Hono();
|
|
8
|
+
r.get("/", (c) => {
|
|
9
|
+
const allHoldings = db.prepare("SELECT * FROM holdings").all() as { account_id: string; symbol: string; quantity: number; avg_cost: number | null; asset_class: string }[];
|
|
10
|
+
const byClass = new Map<string, number>();
|
|
11
|
+
let total = 0;
|
|
12
|
+
for (const h of allHoldings) {
|
|
13
|
+
// Use latest price from prices table if available, otherwise fall back to avg_cost
|
|
14
|
+
const priceRow = db.prepare("SELECT close FROM prices WHERE symbol=? ORDER BY date DESC LIMIT 1").get(h.symbol) as { close: number } | undefined;
|
|
15
|
+
const price = priceRow?.close ?? h.avg_cost ?? 0;
|
|
16
|
+
const value = h.quantity * price;
|
|
17
|
+
byClass.set(h.asset_class, (byClass.get(h.asset_class) ?? 0) + value);
|
|
18
|
+
total += value;
|
|
19
|
+
}
|
|
20
|
+
const allocation = Object.fromEntries(
|
|
21
|
+
[...byClass.entries()].map(([cls, value]) => [cls, total > 0 ? value / total : 0])
|
|
22
|
+
);
|
|
23
|
+
return c.json(ok({ allocation, totalValue: total }));
|
|
24
|
+
});
|
|
25
|
+
return r;
|
|
26
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type Database from "better-sqlite3";
|
|
3
|
+
import { listHoldings } from "../../store/repo";
|
|
4
|
+
import { computeDrift, type HoldingValued } from "../../quant/drift";
|
|
5
|
+
import { ok } from "../errors";
|
|
6
|
+
|
|
7
|
+
export function driftRoutes(db: Database.Database) {
|
|
8
|
+
const r = new Hono();
|
|
9
|
+
r.get("/", (c) => {
|
|
10
|
+
// Get all holdings with latest prices
|
|
11
|
+
const accounts = db.prepare("SELECT id FROM accounts").all() as { id: string }[];
|
|
12
|
+
const holdingsValued: HoldingValued[] = [];
|
|
13
|
+
|
|
14
|
+
for (const a of accounts) {
|
|
15
|
+
const holdings = listHoldings(db, a.id);
|
|
16
|
+
for (const h of holdings) {
|
|
17
|
+
// Get latest price from prices table, fall back to avg_cost
|
|
18
|
+
const priceRow = db.prepare("SELECT close FROM prices WHERE symbol=? ORDER BY date DESC LIMIT 1").get(h.symbol) as { close: number } | undefined;
|
|
19
|
+
const price = priceRow?.close ?? h.avg_cost ?? 0;
|
|
20
|
+
holdingsValued.push({
|
|
21
|
+
symbol: h.symbol,
|
|
22
|
+
assetClass: h.asset_class,
|
|
23
|
+
quantity: h.quantity,
|
|
24
|
+
price,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Get goals for target allocation
|
|
30
|
+
const goals = db.prepare("SELECT target_allocation FROM goals LIMIT 1").get() as { target_allocation: string } | undefined;
|
|
31
|
+
const targetAllocation = goals ? JSON.parse(goals.target_allocation) : {};
|
|
32
|
+
|
|
33
|
+
const drift = computeDrift(holdingsValued, { targetAllocation });
|
|
34
|
+
return c.json(ok({ drift }));
|
|
35
|
+
});
|
|
36
|
+
return r;
|
|
37
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type Database from "better-sqlite3";
|
|
3
|
+
import { exportJson, backupDb } from "../../store/backup";
|
|
4
|
+
import { ok, fail } from "../errors";
|
|
5
|
+
import { globalDir } from "@pi-stef/paths";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { mkdirSync } from "node:fs";
|
|
8
|
+
|
|
9
|
+
export function exportRoutes(db: Database.Database) {
|
|
10
|
+
const r = new Hono();
|
|
11
|
+
r.post("/", async (c) => {
|
|
12
|
+
const { format, path: requestedPath } = await c.req.json();
|
|
13
|
+
if (format === "json") {
|
|
14
|
+
const data = exportJson(db);
|
|
15
|
+
return c.json(ok(data));
|
|
16
|
+
}
|
|
17
|
+
if (format === "sqlite") {
|
|
18
|
+
// Restrict backup path to the finance directory for security
|
|
19
|
+
const backupDir = globalDir("finance");
|
|
20
|
+
const filename = requestedPath ? path.basename(requestedPath) : `backup-${Date.now()}.db`;
|
|
21
|
+
const backupPath = path.join(backupDir, "backups", filename);
|
|
22
|
+
|
|
23
|
+
// Ensure backup directory exists
|
|
24
|
+
mkdirSync(path.dirname(backupPath), { recursive: true });
|
|
25
|
+
|
|
26
|
+
await backupDb(db, backupPath);
|
|
27
|
+
return c.json(ok({ backupPath }));
|
|
28
|
+
}
|
|
29
|
+
return c.json(fail("bad_request", "Invalid format (must be json or sqlite)"), 400);
|
|
30
|
+
});
|
|
31
|
+
return r;
|
|
32
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type Database from "better-sqlite3";
|
|
3
|
+
import { upsertGoal, listGoals } from "../../store/repo";
|
|
4
|
+
import { validateGoal } from "../../quant/validate";
|
|
5
|
+
import { ok, fail } from "../errors";
|
|
6
|
+
|
|
7
|
+
export function goalsRoutes(db: Database.Database) {
|
|
8
|
+
const r = new Hono();
|
|
9
|
+
r.get("/", (c) => {
|
|
10
|
+
const goals = listGoals(db).map((g) => ({
|
|
11
|
+
...g,
|
|
12
|
+
targetAllocation: JSON.parse(g.target_allocation),
|
|
13
|
+
riskLimits: JSON.parse(g.risk_limits),
|
|
14
|
+
}));
|
|
15
|
+
return c.json(ok({ goals }));
|
|
16
|
+
});
|
|
17
|
+
r.post("/", async (c) => {
|
|
18
|
+
const body = await c.req.json();
|
|
19
|
+
|
|
20
|
+
// Validate required fields
|
|
21
|
+
if (!body.id || !body.name) {
|
|
22
|
+
return c.json(fail("bad_request", "Missing required fields: id, name"), 400);
|
|
23
|
+
}
|
|
24
|
+
if (!body.targetAllocation || typeof body.targetAllocation !== "object") {
|
|
25
|
+
return c.json(fail("bad_request", "Missing or invalid targetAllocation"), 400);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Validate goal configuration
|
|
29
|
+
const errors = validateGoal({ targetAllocation: body.targetAllocation, riskLimits: body.riskLimits ?? {} });
|
|
30
|
+
if (errors.length > 0) {
|
|
31
|
+
return c.json(fail("validation_error", errors.join("; ")), 400);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
upsertGoal(db, {
|
|
35
|
+
id: body.id,
|
|
36
|
+
name: body.name,
|
|
37
|
+
target_allocation: JSON.stringify(body.targetAllocation),
|
|
38
|
+
risk_limits: JSON.stringify(body.riskLimits),
|
|
39
|
+
horizon_years: body.horizonYears ?? null,
|
|
40
|
+
});
|
|
41
|
+
return c.json(ok({ id: body.id }));
|
|
42
|
+
});
|
|
43
|
+
return r;
|
|
44
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type Database from "better-sqlite3";
|
|
3
|
+
import { ok, fail } from "../errors";
|
|
4
|
+
|
|
5
|
+
export function historyRoutes(db: Database.Database) {
|
|
6
|
+
const r = new Hono();
|
|
7
|
+
r.get("/", (c) => {
|
|
8
|
+
const symbol = c.req.query("symbol");
|
|
9
|
+
const accountId = c.req.query("accountId");
|
|
10
|
+
if (!symbol) return c.json(fail("bad_request", "Missing symbol query param"), 400);
|
|
11
|
+
|
|
12
|
+
let rows;
|
|
13
|
+
if (accountId) {
|
|
14
|
+
// Filter by account: join through holdings to get account-specific prices
|
|
15
|
+
rows = db.prepare(`
|
|
16
|
+
SELECT DISTINCT p.* FROM prices p
|
|
17
|
+
JOIN holdings h ON h.symbol = p.symbol
|
|
18
|
+
WHERE p.symbol = ? AND h.account_id = ?
|
|
19
|
+
ORDER BY p.date DESC
|
|
20
|
+
`).all(symbol, accountId);
|
|
21
|
+
} else {
|
|
22
|
+
rows = db.prepare("SELECT * FROM prices WHERE symbol=? ORDER BY date DESC").all(symbol);
|
|
23
|
+
}
|
|
24
|
+
return c.json(ok({ history: rows }));
|
|
25
|
+
});
|
|
26
|
+
return r;
|
|
27
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type Database from "better-sqlite3";
|
|
3
|
+
import { listAccounts, listHoldings } from "../../store/repo";
|
|
4
|
+
import { ok } from "../errors";
|
|
5
|
+
|
|
6
|
+
export function holdingsRoutes(db: Database.Database) {
|
|
7
|
+
const r = new Hono();
|
|
8
|
+
r.get("/", (c) => {
|
|
9
|
+
const accounts = listAccounts(db).map((a) => ({
|
|
10
|
+
...a,
|
|
11
|
+
holdings: listHoldings(db, a.id),
|
|
12
|
+
}));
|
|
13
|
+
return c.json(ok({ accounts }));
|
|
14
|
+
});
|
|
15
|
+
return r;
|
|
16
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type Database from "better-sqlite3";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { runIngest, type AdapterRegistry } from "../../ingest/registry";
|
|
5
|
+
import { createFileAdapter } from "../../ingest/file";
|
|
6
|
+
import { ok, fail } from "../errors";
|
|
7
|
+
|
|
8
|
+
export function importRoutes(db: Database.Database) {
|
|
9
|
+
const r = new Hono();
|
|
10
|
+
r.post("/", async (c) => {
|
|
11
|
+
const { filePath } = await c.req.json();
|
|
12
|
+
if (!filePath) return c.json(fail("bad_request", "Missing filePath"), 400);
|
|
13
|
+
|
|
14
|
+
// Security: reject directory traversal (but allow absolute paths for local file imports)
|
|
15
|
+
if (filePath.includes("..") && !path.isAbsolute(filePath)) {
|
|
16
|
+
return c.json(fail("bad_request", "Directory traversal is not allowed"), 400);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Create a one-shot file adapter for this import
|
|
20
|
+
const fileRegistry: AdapterRegistry = new Map([
|
|
21
|
+
["import", createFileAdapter("import", "brokerage")],
|
|
22
|
+
]);
|
|
23
|
+
const creds = { import: { filePath } };
|
|
24
|
+
|
|
25
|
+
const result = await runIngest(db, fileRegistry, creds);
|
|
26
|
+
return c.json(ok({ message: "Import complete", filePath, ...result }));
|
|
27
|
+
});
|
|
28
|
+
return r;
|
|
29
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { classifySession } from "../../market/session";
|
|
3
|
+
import { ok, fail } from "../errors";
|
|
4
|
+
|
|
5
|
+
export function marketStatusRoutes() {
|
|
6
|
+
const r = new Hono();
|
|
7
|
+
r.get("/", (c) => {
|
|
8
|
+
try {
|
|
9
|
+
const session = classifySession(new Date());
|
|
10
|
+
return c.json(ok({ session, timestamp: Date.now() }));
|
|
11
|
+
} catch (err) {
|
|
12
|
+
// Handle year-guard throw gracefully
|
|
13
|
+
return c.json(fail("session_unavailable", err instanceof Error ? err.message : "Session classification failed"), 503);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
return r;
|
|
17
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type Database from "better-sqlite3";
|
|
3
|
+
import { listAccounts, listHoldings } from "../../store/repo";
|
|
4
|
+
import { ok } from "../errors";
|
|
5
|
+
|
|
6
|
+
export function netWorthRoutes(db: Database.Database) {
|
|
7
|
+
const r = new Hono();
|
|
8
|
+
r.get("/", (c) => {
|
|
9
|
+
const accounts = listAccounts(db);
|
|
10
|
+
let totalValue = 0;
|
|
11
|
+
for (const a of accounts) {
|
|
12
|
+
const holdings = listHoldings(db, a.id);
|
|
13
|
+
for (const h of holdings) {
|
|
14
|
+
// Use latest price from prices table if available, otherwise fall back to avg_cost
|
|
15
|
+
const priceRow = db.prepare("SELECT close FROM prices WHERE symbol=? ORDER BY date DESC LIMIT 1").get(h.symbol) as { close: number } | undefined;
|
|
16
|
+
const price = priceRow?.close ?? h.avg_cost ?? 0;
|
|
17
|
+
totalValue += h.quantity * price;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return c.json(ok({ netWorth: totalValue, accountCount: accounts.length }));
|
|
21
|
+
});
|
|
22
|
+
return r;
|
|
23
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type Database from "better-sqlite3";
|
|
3
|
+
import { listPendingSuggestions, dismissSuggestion } from "../../store/repo";
|
|
4
|
+
import { ok, fail } from "../errors";
|
|
5
|
+
|
|
6
|
+
export function suggestionsRoutes(db: Database.Database) {
|
|
7
|
+
const r = new Hono();
|
|
8
|
+
r.get("/", (c) => {
|
|
9
|
+
const suggestions = listPendingSuggestions(db).map((s) => ({
|
|
10
|
+
...s,
|
|
11
|
+
payload: JSON.parse(s.payload),
|
|
12
|
+
}));
|
|
13
|
+
return c.json(ok({ suggestions }));
|
|
14
|
+
});
|
|
15
|
+
r.post("/dismiss", async (c) => {
|
|
16
|
+
const { id } = await c.req.json();
|
|
17
|
+
if (!id) return c.json(fail("bad_request", "Missing id"), 400);
|
|
18
|
+
dismissSuggestion(db, id);
|
|
19
|
+
return c.json(ok({ dismissed: id }));
|
|
20
|
+
});
|
|
21
|
+
return r;
|
|
22
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type Database from "better-sqlite3";
|
|
3
|
+
import type { AdapterRegistry, IngestCreds } from "../../ingest/registry";
|
|
4
|
+
import { runTick } from "../../scheduler/tick";
|
|
5
|
+
import { ok } from "../errors";
|
|
6
|
+
|
|
7
|
+
export interface SyncDeps {
|
|
8
|
+
registry: AdapterRegistry;
|
|
9
|
+
creds: IngestCreds;
|
|
10
|
+
fetcher?: typeof fetch;
|
|
11
|
+
dataFeed?: "stooq" | "yfinance";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function syncRoutes(db: Database.Database, deps: SyncDeps) {
|
|
15
|
+
const r = new Hono();
|
|
16
|
+
r.post("/", async (c) => {
|
|
17
|
+
const result = await runTick({
|
|
18
|
+
db,
|
|
19
|
+
registry: deps.registry,
|
|
20
|
+
creds: deps.creds,
|
|
21
|
+
fetcher: deps.fetcher,
|
|
22
|
+
dataFeed: deps.dataFeed,
|
|
23
|
+
});
|
|
24
|
+
return c.json(ok({ message: "Sync complete", ...result }));
|
|
25
|
+
});
|
|
26
|
+
return r;
|
|
27
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { serve } from "@hono/node-server";
|
|
2
|
+
import type Database from "better-sqlite3";
|
|
3
|
+
import { createApp } from "./app";
|
|
4
|
+
import type { Logger } from "./logger";
|
|
5
|
+
|
|
6
|
+
export interface StartServerDeps {
|
|
7
|
+
db: Database.Database;
|
|
8
|
+
token: string;
|
|
9
|
+
host?: string;
|
|
10
|
+
port?: number;
|
|
11
|
+
log?: Logger;
|
|
12
|
+
registry?: import("../ingest/registry").AdapterRegistry;
|
|
13
|
+
creds?: import("../ingest/registry").IngestCreds;
|
|
14
|
+
fetcher?: typeof fetch;
|
|
15
|
+
dataFeed?: "stooq" | "yfinance";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ServerHandle {
|
|
19
|
+
close: () => void;
|
|
20
|
+
port: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function startServer(deps: StartServerDeps): Promise<ServerHandle> {
|
|
24
|
+
const host = deps.host ?? "127.0.0.1";
|
|
25
|
+
const port = deps.port ?? 7780;
|
|
26
|
+
|
|
27
|
+
const app = createApp({ db: deps.db, token: deps.token, registry: deps.registry, creds: deps.creds, fetcher: deps.fetcher, dataFeed: deps.dataFeed });
|
|
28
|
+
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
try {
|
|
31
|
+
const server = serve({
|
|
32
|
+
fetch: app.fetch,
|
|
33
|
+
hostname: host,
|
|
34
|
+
port,
|
|
35
|
+
}, (info) => {
|
|
36
|
+
deps.log?.info("server started", { host, port: info.port });
|
|
37
|
+
resolve({
|
|
38
|
+
close: () => server.close(),
|
|
39
|
+
port: info.port,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
server.on("error", (err: NodeJS.ErrnoException) => {
|
|
44
|
+
if (err.code === "EADDRINUSE") {
|
|
45
|
+
reject(new Error(`Port ${port} is already in use (EADDRINUSE)`));
|
|
46
|
+
} else {
|
|
47
|
+
reject(err);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
} catch (err) {
|
|
51
|
+
reject(err);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates a byte-identical, openable copy of the SQLite database using better-sqlite3's online backup API.
|
|
5
|
+
* Safe to call while the database is in use (WAL mode).
|
|
6
|
+
*/
|
|
7
|
+
export async function backupDb(db: Database.Database, destPath: string): Promise<void> {
|
|
8
|
+
await db.backup(destPath);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Returns a serializable snapshot of all tables for the /v1/export route.
|
|
13
|
+
*/
|
|
14
|
+
export function exportJson(db: Database.Database): Record<string, unknown[]> {
|
|
15
|
+
const tables = ["accounts", "holdings", "transactions", "prices", "lots", "goals", "suggestion_records", "market_sessions"];
|
|
16
|
+
const result: Record<string, unknown[]> = {};
|
|
17
|
+
for (const table of tables) {
|
|
18
|
+
try {
|
|
19
|
+
result[table] = db.prepare(`SELECT * FROM ${table}`).all();
|
|
20
|
+
} catch {
|
|
21
|
+
// Table might not exist yet
|
|
22
|
+
result[table] = [];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
package/src/store/db.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type Database from "better-sqlite3";
|
|
2
|
+
import { MIGRATIONS_V1, type Migration } from "./schema";
|
|
3
|
+
|
|
4
|
+
export function applyMigrations(db: Database.Database, all: Migration[] = MIGRATIONS_V1): void {
|
|
5
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
6
|
+
db.exec(`CREATE TABLE IF NOT EXISTS schema_versions (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)`);
|
|
7
|
+
const applied = new Set(
|
|
8
|
+
(db.prepare("SELECT version FROM schema_versions").all() as { version: number }[]).map((r) => r.version),
|
|
9
|
+
);
|
|
10
|
+
const apply = db.transaction((toApply: Migration[]) => {
|
|
11
|
+
for (const m of toApply) {
|
|
12
|
+
db.exec(m.statement);
|
|
13
|
+
db.prepare("INSERT INTO schema_versions (version, applied_at) VALUES (?, ?)").run(m.version, Date.now());
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
const pending = all.filter((m) => !applied.has(m.version)).sort((a, b) => a.version - b.version);
|
|
17
|
+
apply(pending);
|
|
18
|
+
}
|