From 13b30e0d8f607e70393a189a035303f606a84bf6 Mon Sep 17 00:00:00 2001 From: apecode Date: Tue, 15 Sep 2026 13:00:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9C=80=E6=96=B0=E6=B1=87?= =?UTF-8?q?=E7=8E=87=E4=B8=8E=E5=A4=9A=E5=B8=81=E7=A7=8D=E8=B4=A6=E6=88=B7?= =?UTF-8?q?=E5=AF=B9=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次变更: - 读取汇率时按 rate_date 与 created_at 选择最新记录 - 同一天更新汇率时保留历史日期记录 - 支持通过配置的基准币推导交叉汇率 - 为混合币种账户提供按目标币种估值 - 对账时转换现金和每个持仓后再比较券商总额 - 增加 FX、历史保留、混合币种估值和 CLI 对账回归测试 验证: - pnpm -r test - pnpm typecheck - pnpm -r build - CI skill-drift check - git diff --check 范围: - 仅处理 FX 与多币种估值/对账 - 不包含个人数据、九坤、内部 endpoint、截图 OCR、release 或安全强化 --- packages/cli/src/__tests__/reconcile.test.ts | 140 ++++++++++++++++ packages/cli/src/commands/reconcile.ts | 44 ++--- packages/core/src/__tests__/analytics.test.ts | 150 ++++++++++++++++++ packages/core/src/services/analytics.ts | 83 ++++++++-- 4 files changed, 381 insertions(+), 36 deletions(-) create mode 100644 packages/cli/src/__tests__/reconcile.test.ts diff --git a/packages/cli/src/__tests__/reconcile.test.ts b/packages/cli/src/__tests__/reconcile.test.ts new file mode 100644 index 0000000..6264ab4 --- /dev/null +++ b/packages/cli/src/__tests__/reconcile.test.ts @@ -0,0 +1,140 @@ +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { getDb } from "@finsight/core"; +import { afterEach, describe, expect, it } from "vitest"; + +const tempDirs: string[] = []; +const cliEntry = fileURLToPath(new URL("../index.ts", import.meta.url)); + +function makeTempDir(): string { + const dir = mkdtempSync(path.join(tmpdir(), "finsight-reconcile-test-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("reconcile mixed-currency account", () => { + it("converts each position before comparing the broker total", () => { + const home = makeTempDir(); + const databasePath = path.join(home, "authoritative.db"); + const db = getDb(databasePath); + db.$client + .prepare( + `INSERT INTO accounts + (id, name, type, currency, balance, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + "mixed-account", + "Mixed Broker", + "brokerage", + "CNY", + 100, + 1, + "2026-09-15T00:00:00.000Z", + "2026-09-15T00:00:00.000Z", + ); + db.$client + .prepare( + `INSERT INTO exchange_rates + (id, from_currency, to_currency, rate, rate_date, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + .run( + "usd-cny", + "USD", + "CNY", + 7, + "2026-09-15", + "2026-09-15T00:00:00.000Z", + ); + db.$client + .prepare( + `INSERT INTO exchange_rates + (id, from_currency, to_currency, rate, rate_date, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + .run( + "hkd-cny", + "HKD", + "CNY", + 0.9, + "2026-09-15", + "2026-09-15T00:00:00.000Z", + ); + db.$client + .prepare( + `INSERT INTO positions + (id, account_id, symbol, quantity, avg_cost, current_price, currency, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + "usd-position", + "mixed-account", + "US-ASSET", + 10, + 20, + 20, + "USD", + "2026-09-15T00:00:00.000Z", + "2026-09-15T00:00:00.000Z", + ); + db.$client + .prepare( + `INSERT INTO positions + (id, account_id, symbol, quantity, avg_cost, current_price, currency, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + "hkd-position", + "mixed-account", + "HK-ASSET", + 100, + 10, + 10, + "HKD", + "2026-09-15T00:00:00.000Z", + "2026-09-15T00:00:00.000Z", + ); + db.$client.close(); + const configDir = path.join(home, ".finsight"); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + path.join(configDir, "config.json"), + `${JSON.stringify({ base_currency: "CNY" })}\n`, + ); + + const result = spawnSync( + process.execPath, + ["--import", "tsx", cliEntry, "reconcile", "Mixed Broker", "--broker-total", "2400", "--json"], + { + cwd: path.dirname(cliEntry), + encoding: "utf8", + env: { ...process.env, HOME: home, FINSIGHT_DB_PATH: databasePath }, + }, + ); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout.trim())).toMatchObject({ + ok: true, + account: "Mixed Broker", + currency: "CNY", + delta_pct: 0, + computed_total_base: 2400, + reconciliation: { + computed_total: 2400, + broker_total: 2400, + currency: "CNY", + }, + }); + }); +}); diff --git a/packages/cli/src/commands/reconcile.ts b/packages/cli/src/commands/reconcile.ts index 8b375e7..b2bdb3a 100644 --- a/packages/cli/src/commands/reconcile.ts +++ b/packages/cli/src/commands/reconcile.ts @@ -4,8 +4,8 @@ import { input } from "@inquirer/prompts"; import { listAccounts, listPositions, - getNetWorth, - toBase, + getAccountValuation, + getBaseCurrency, recordReconciliation, listReconciliations, } from "@finsight/core"; @@ -28,16 +28,16 @@ export const reconcileCmd = new Command("reconcile") accs.find((a) => a.name.toLowerCase().includes(lower)); if (!acc) fail("NOT_FOUND", `Account not found: ${accountName}`, { json: opts.json }); - // Compute account total in account currency + // Convert cash and every position into one currency before comparing with + // the broker's total. A broker account may hold multiple currencies. const accPositions = listPositions(db, acc.id); - const positionsNative = accPositions.reduce( - (s, p) => s + p.current_price * p.quantity, - 0, - ); - const computedNative = acc.balance + positionsNative; - const nw = getNetWorth(db); - const slice = nw.byAccount.find((a) => a.id === acc.id); - const computedBase = slice?.balance_base ?? toBase(db, computedNative, acc.currency); + const valuation = getAccountValuation(db, acc.id, acc.currency); + if (!valuation) { + fail("NOT_FOUND", `Account not found: ${accountName}`, { json: opts.json }); + } + const computedTotal = valuation.amount; + const reconcileCurrency = valuation.currency; + const baseValuation = getAccountValuation(db, acc.id, getBaseCurrency()); // Get broker_total let brokerTotal: number; @@ -52,25 +52,25 @@ export const reconcileCmd = new Command("reconcile") console.log(chalk.bold(` Reconcile · ${acc.name}`)); console.log( chalk.dim( - ` FinSight computed: ${formatCurrency(computedNative, acc.currency)} (${acc.currency})`, + ` FinSight computed: ${formatCurrency(computedTotal, reconcileCurrency)} (${reconcileCurrency})`, ), ); console.log(); const raw = await input({ - message: `Total shown in your broker (${acc.currency}):`, + message: `Total shown in your broker (${reconcileCurrency}):`, }); brokerTotal = Number(raw); if (Number.isNaN(brokerTotal)) fail("USER_ERROR", `Invalid amount: ${raw}`, { json: opts.json }); } - const delta = brokerTotal - computedNative; - const pct = computedNative !== 0 ? delta / computedNative : 0; + const delta = brokerTotal - computedTotal; + const pct = computedTotal !== 0 ? delta / computedTotal : 0; const row = recordReconciliation(db, { account_id: acc.id, - currency: acc.currency, - computed_total: computedNative, + currency: reconcileCurrency, + computed_total: computedTotal, broker_total: brokerTotal, notes: opts.note, }); @@ -80,9 +80,9 @@ export const reconcileCmd = new Command("reconcile") ok: true, reconciliation: row, account: acc.name, - currency: acc.currency, + currency: reconcileCurrency, delta_pct: pct, - computed_total_base: computedBase, + computed_total_base: baseValuation?.amount ?? computedTotal, positions_with_fallback_price: accPositions.filter((p) => p.current_price === 0) .length, }); @@ -90,13 +90,13 @@ export const reconcileCmd = new Command("reconcile") } const t = createTable(["", "Amount"]); - t.push(["Broker shows", formatCurrency(brokerTotal, acc.currency)]); - t.push(["FinSight computed", formatCurrency(computedNative, acc.currency)]); + t.push(["Broker shows", formatCurrency(brokerTotal, reconcileCurrency)]); + t.push(["FinSight computed", formatCurrency(computedTotal, reconcileCurrency)]); const color = Math.abs(pct) < 0.01 ? chalk.green : Math.abs(pct) < 0.05 ? chalk.yellow : chalk.red; t.push([ "Δ", color( - `${delta >= 0 ? "+" : ""}${formatCurrency(delta, acc.currency)} (${(pct * 100).toFixed(2)}%)`, + `${delta >= 0 ? "+" : ""}${formatCurrency(delta, reconcileCurrency)} (${(pct * 100).toFixed(2)}%)`, ), ]); console.log(t.toString()); diff --git a/packages/core/src/__tests__/analytics.test.ts b/packages/core/src/__tests__/analytics.test.ts index e4413b0..a77f427 100644 --- a/packages/core/src/__tests__/analytics.test.ts +++ b/packages/core/src/__tests__/analytics.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach } from "vitest"; import { getTestDb } from "../db/connection.js"; +import { exchange_rates } from "../db/schema.js"; import type { AppDatabase } from "../db/connection.js"; import { createAccount, updateBalance } from "../services/account.js"; import { createPosition } from "../services/position.js"; @@ -8,6 +9,8 @@ import { getAllocation, getPositionPnL, getOverview, + getAccountValuation, + upsertFxRate, } from "../services/analytics.js"; describe("analytics service", () => { @@ -36,6 +39,153 @@ describe("analytics service", () => { expect(nw.byAccount).toHaveLength(2); }); + it("should use the latest dated FX rate when several rows exist", () => { + db.insert(exchange_rates) + .values([ + { + id: "usd-cny-old", + from_currency: "USD", + to_currency: "CNY", + rate: 6, + rate_date: "2026-05-29", + created_at: "2026-05-29T00:00:00.000Z", + }, + { + id: "usd-cny-new", + from_currency: "USD", + to_currency: "CNY", + rate: 7, + rate_date: "2026-07-14", + created_at: "2026-07-14T00:00:00.000Z", + }, + ]) + .run(); + const usd = createAccount(db, { + name: "USD Broker", + type: "brokerage", + currency: "USD", + }); + updateBalance(db, usd.id, 100); + + const nw = getNetWorth(db); + expect(nw.total).toBe(700); + expect(nw.byAccount[0].balance_base).toBe(700); + }); + + it("should preserve FX history while updating today's rate", () => { + db.insert(exchange_rates) + .values({ + id: "usd-cny-history", + from_currency: "USD", + to_currency: "CNY", + rate: 6, + rate_date: "2000-01-01", + created_at: "2000-01-01T00:00:00.000Z", + }) + .run(); + + const first = upsertFxRate(db, "USD", "CNY", 7); + const second = upsertFxRate(db, "USD", "CNY", 8); + const rows = db.select().from(exchange_rates).all(); + + expect(first.previous).toBe(6); + expect(second.previous).toBe(7); + expect(rows).toHaveLength(2); + expect(rows.map((row) => row.rate)).toContain(6); + expect(rows.map((row) => row.rate)).toContain(8); + }); + + it("should value mixed-currency account cash and positions in a target currency", () => { + db.insert(exchange_rates) + .values([ + { + id: "usd-cny-mixed", + from_currency: "USD", + to_currency: "CNY", + rate: 7, + rate_date: "2026-07-14", + created_at: "2026-07-14T00:00:00.000Z", + }, + { + id: "hkd-cny-mixed", + from_currency: "HKD", + to_currency: "CNY", + rate: 0.9, + rate_date: "2026-07-14", + created_at: "2026-07-14T00:00:00.000Z", + }, + ]) + .run(); + const broker = createAccount(db, { + name: "Mixed Broker", + type: "brokerage", + currency: "CNY", + }); + updateBalance(db, broker.id, 100); + createPosition(db, { + account_id: broker.id, + symbol: "US-ASSET", + quantity: 10, + avg_cost: 20, + current_price: 20, + currency: "USD", + }); + createPosition(db, { + account_id: broker.id, + symbol: "HK-ASSET", + quantity: 100, + avg_cost: 10, + current_price: 10, + currency: "HKD", + }); + + expect(getAccountValuation(db, broker.id, "CNY")).toEqual({ + amount: 2400, + currency: "CNY", + }); + }); + + it("should derive a cross-currency valuation through the configured base", () => { + db.insert(exchange_rates) + .values([ + { + id: "usd-cny-cross", + from_currency: "USD", + to_currency: "CNY", + rate: 7, + rate_date: "2026-07-14", + created_at: "2026-07-14T00:00:00.000Z", + }, + { + id: "eur-cny-cross", + from_currency: "EUR", + to_currency: "CNY", + rate: 8.4, + rate_date: "2026-07-14", + created_at: "2026-07-14T00:00:00.000Z", + }, + ]) + .run(); + const broker = createAccount(db, { + name: "USD Broker With EUR Asset", + type: "brokerage", + currency: "USD", + }); + createPosition(db, { + account_id: broker.id, + symbol: "EUR-ASSET", + quantity: 100, + avg_cost: 1, + current_price: 1, + currency: "EUR", + }); + + expect(getAccountValuation(db, broker.id, "USD")).toEqual({ + amount: 120, + currency: "USD", + }); + }); + it("should calculate allocation by account type", () => { const cash = createAccount(db, { name: "Bank", type: "cash" }); updateBalance(db, cash.id, 50000); diff --git a/packages/core/src/services/analytics.ts b/packages/core/src/services/analytics.ts index 08e59cf..259c7dd 100644 --- a/packages/core/src/services/analytics.ts +++ b/packages/core/src/services/analytics.ts @@ -1,4 +1,4 @@ -import { and, eq } from "drizzle-orm"; +import { and, desc, eq } from "drizzle-orm"; import { ulid } from "ulid"; import { exchange_rates } from "../db/schema.js"; import type { AppDatabase } from "../db/connection.js"; @@ -42,19 +42,31 @@ export function upsertFxRate( to: string, rate: number, ): { previous?: number } { - const existing = db + const date = new Date().toISOString().slice(0, 10); + const ts = new Date().toISOString(); + const latest = db .select() .from(exchange_rates) .where(and(eq(exchange_rates.from_currency, from), eq(exchange_rates.to_currency, to))) + .orderBy(desc(exchange_rates.rate_date), desc(exchange_rates.created_at)) .get(); - const date = new Date().toISOString().slice(0, 10); - const ts = new Date().toISOString(); - if (existing) { + const existingToday = db + .select() + .from(exchange_rates) + .where( + and( + eq(exchange_rates.from_currency, from), + eq(exchange_rates.to_currency, to), + eq(exchange_rates.rate_date, date), + ), + ) + .get(); + if (existingToday) { db.update(exchange_rates) - .set({ rate, rate_date: date }) - .where(eq(exchange_rates.id, existing.id)) + .set({ rate, created_at: ts }) + .where(eq(exchange_rates.id, existingToday.id)) .run(); - return { previous: existing.rate }; + return { previous: latest?.rate }; } db.insert(exchange_rates) .values({ @@ -66,7 +78,7 @@ export function upsertFxRate( created_at: ts, }) .run(); - return {}; + return { previous: latest?.rate }; } /** Public: convert an amount in `currency` to the configured base currency. */ @@ -88,20 +100,43 @@ function fallbackRate(from: string, to: string): number | undefined { return undefined; } -function getRate(db: AppDatabase, from: string, to: string): number { - if (from === to) return 1; - const rate = db +function getStoredRate( + db: AppDatabase, + from: string, + to: string, +): number | undefined { + const direct = db .select() .from(exchange_rates) .where(and(eq(exchange_rates.from_currency, from), eq(exchange_rates.to_currency, to))) + .orderBy(desc(exchange_rates.rate_date), desc(exchange_rates.created_at)) .get(); - if (rate) return rate.rate; + if (direct) return direct.rate; + const reverse = db .select() .from(exchange_rates) .where(and(eq(exchange_rates.from_currency, to), eq(exchange_rates.to_currency, from))) + .orderBy(desc(exchange_rates.rate_date), desc(exchange_rates.created_at)) .get(); - if (reverse) return 1 / reverse.rate; + return reverse ? 1 / reverse.rate : undefined; +} + +function getRate(db: AppDatabase, from: string, to: string): number { + if (from === to) return 1; + + const stored = getStoredRate(db, from, to); + if (stored !== undefined) return stored; + + const base = getBaseCurrency(); + if (from !== base && to !== base) { + const fromToBase = getStoredRate(db, from, base); + const toToBase = getStoredRate(db, to, base); + if (fromToBase !== undefined && toToBase !== undefined) { + return fromToBase / toToBase; + } + } + const fb = fallbackRate(from, to); if (fb !== undefined) return fb; return 1; @@ -124,6 +159,26 @@ export interface NetWorth { byAccount: NetWorthAccount[]; } +/** Value one account in a target currency, including mixed-currency positions. */ +export function getAccountValuation( + db: AppDatabase, + accountId: string, + targetCurrency: string, +): { amount: number; currency: string } | null { + const acc = listAccounts(db, { includeInactive: true }).find( + (candidate) => candidate.id === accountId, + ); + if (!acc) return null; + + const cashValue = acc.balance * getRate(db, acc.currency, targetCurrency); + const positionsValue = listPositions(db, acc.id).reduce((sum, position) => { + const rate = getRate(db, position.currency, targetCurrency); + return sum + position.current_price * position.quantity * rate; + }, 0); + + return { amount: cashValue + positionsValue, currency: targetCurrency }; +} + export function getNetWorth(db: AppDatabase): NetWorth { const base = getBaseCurrency(); const accs = listAccounts(db);