From 628bb439eb277bb73300268717130606172fc758 Mon Sep 17 00:00:00 2001 From: ApeCode Date: Tue, 15 Sep 2026 19:19:14 +0800 Subject: [PATCH 1/3] feat(cli): preserve optional full trade timestamps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 公开交易 CLI 现在支持可选的 ISO 执行时间,并原样保存到现有 traded_at 字段;没有可靠时间时仍可只使用日期。日期筛选、行情查询、交易列表和 review 输出保持向后兼容。 验证: - pnpm test - pnpm typecheck - pnpm build - git diff --check - CLI buy/sell --help smoke test 范围:仅公开 core/CLI、测试和文档;不包含个人数据、截图或私有脚本。 --- CHANGELOG.md | 1 + README.md | 4 + README.zh-CN.md | 4 + packages/cli/src/__tests__/trade.test.ts | 204 ++++++++++++++++++ packages/cli/src/__tests__/traded-at.test.ts | 44 ++++ packages/cli/src/commands/trade.ts | 44 +++- packages/cli/src/commands/transaction.ts | 20 +- packages/cli/src/utils/traded-at.ts | 58 +++++ .../core/src/__tests__/transaction.test.ts | 56 +++++ packages/core/src/services/transaction.ts | 25 ++- skills/finsight/SKILL.md | 8 + 11 files changed, 456 insertions(+), 12 deletions(-) create mode 100644 packages/cli/src/__tests__/trade.test.ts create mode 100644 packages/cli/src/__tests__/traded-at.test.ts create mode 100644 packages/cli/src/utils/traded-at.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2678cf3..5bac013 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ related changes, breaking changes can land in `0.x → 0.y`. with SHA-256 metadata and restrictive local file permissions. - `finsight backup verify ` verifies an existing native SQLite backup. - Mixed-currency account valuation and reconciliation now have regression tests. +- `trade buy` and `trade sell` accept optional verbatim ISO execution timestamps via `--traded-at`; date-only workflows remain supported. ## [0.1.0] — 2026-06-02 — Initial public release diff --git a/README.md b/README.md index e8c2e12..db048f5 100644 --- a/README.md +++ b/README.md @@ -261,11 +261,15 @@ finsight trade buy PDD 100 # auto-fetches today's price finsight trade buy 110020 --amount 1000 # fund: buy by amount, --amount required finsight trade buy PDD 100 --price 82.5 # explicit price finsight trade buy PDD 100 --date 2025-11-15 +finsight trade buy PDD 100 --traded-at 2025-11-15T09:30:00+08:00 # optional full execution time finsight trade list --needs-review # trades where the price was guessed — confirm later finsight transaction confirm --price 84 # replace a guessed price with the real one finsight balance update 250000 ``` +Full execution timestamps are optional: `--traded-at` preserves the supplied +ISO timestamp, while date-only `--date` workflows remain valid. + **Refresh prices** ```bash finsight quote update # everything (all positions + exchange rates) diff --git a/README.zh-CN.md b/README.zh-CN.md index 7dcdccc..09b6925 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -238,11 +238,15 @@ finsight trade buy PDD 100 # 自动抓今天的收盘价 finsight trade buy 110020 --amount 1000 # 基金:按金额买,--amount 必填 finsight trade buy PDD 100 --price 82.5 # 指定成本价 finsight trade buy PDD 100 --date 2025-11-15 +finsight trade buy PDD 100 --traded-at 2025-11-15T09:30:00+08:00 # 可选:保存完整成交时间 finsight trade list --needs-review # 价格是自动猜的,回头确认一下 finsight transaction confirm --price 84 # 把猜的价格替换成真实值 finsight balance update 250000 ``` +完整成交时间是可选的:`--traded-at` 会原样保存 ISO 时间戳;只使用日期的 +`--date` 工作流仍然有效。 + **刷新价格** ```bash finsight quote update # 全量:所有持仓 + 汇率 diff --git a/packages/cli/src/__tests__/trade.test.ts b/packages/cli/src/__tests__/trade.test.ts new file mode 100644 index 0000000..7e97d3a --- /dev/null +++ b/packages/cli/src/__tests__/trade.test.ts @@ -0,0 +1,204 @@ +/** + * [INPUT]: the public trade command definitions and temporary SQLite stores. + * [OUTPUT]: regression coverage for optional full trade timestamps and display. + * [POS]: CLI command-surface and human-readable output tests. + * [RUNTIME]: test. + * [PROTOCOL]: keep the public `--traded-at` contract aligned with both commands; + * date-only traded_at values remain readable as before. + */ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createAccount, getDb, recordBuy } from "@finsight/core"; +import { afterEach, describe, expect, it } from "vitest"; +import { tradeCmd } from "../commands/trade.js"; + +const cliEntry = fileURLToPath(new URL("../index.ts", import.meta.url)); +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = mkdtempSync(path.join(tmpdir(), "finsight-trade-test-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function seedBroker(root: string): string { + const databasePath = path.join(root, "finsight.db"); + const db = getDb(databasePath); + createAccount(db, { + name: "Broker", + type: "brokerage", + currency: "USD", + }); + db.$client.close(); + return databasePath; +} + +function runCli(databasePath: string, ...args: string[]) { + return spawnSync( + process.execPath, + ["--import", "tsx", cliEntry, ...args], + { + cwd: path.dirname(cliEntry), + encoding: "utf8", + env: { + ...process.env, + HOME: path.dirname(databasePath), + FINSIGHT_DB_PATH: databasePath, + }, + }, + ); +} + +function findTradeCommand(name: "buy" | "sell") { + const command = tradeCmd.commands.find((candidate) => candidate.name() === name); + if (!command) throw new Error(`Missing trade ${name} command`); + return command; +} + +describe("trade timestamp option", () => { + it.each(["buy", "sell"] as const)("exposes --traded-at for %s", (name) => { + expect( + findTradeCommand(name).options.some((option) => option.long === "--traded-at"), + ).toBe(true); + }); + + it("stores a supplied full timestamp verbatim through trade buy", () => { + const root = makeTempDir(); + const databasePath = seedBroker(root); + const tradedAt = "2026-09-14T15:37:42-04:00"; + + const result = runCli( + databasePath, + "trade", + "buy", + "Broker", + "AAPL", + "1", + "--no-quote", + "--price", + "200", + "--traded-at", + tradedAt, + "--json", + ); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout.trim()).transaction.traded_at).toBe(tradedAt); + }); + + it("keeps the existing date-only --date storage behavior", () => { + const root = makeTempDir(); + const databasePath = seedBroker(root); + + const result = runCli( + databasePath, + "trade", + "buy", + "Broker", + "AAPL", + "1", + "--no-quote", + "--price", + "200", + "--date", + "2025-11-15", + "--json", + ); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout.trim()).transaction.traded_at).toBe("2025-11-15"); + }); + + it("rejects a malformed --traded-at value as a user error", () => { + const root = makeTempDir(); + const databasePath = seedBroker(root); + + const result = runCli( + databasePath, + "trade", + "buy", + "Broker", + "AAPL", + "1", + "--no-quote", + "--price", + "200", + "--traded-at", + "2026-09-14", + "--json", + ); + + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(JSON.parse(result.stderr.trim())).toMatchObject({ + code: "USER_ERROR", + error: expect.stringContaining("Invalid --traded-at"), + }); + }); + + it("shows a supplied full timestamp in human-readable trade list output", () => { + const root = makeTempDir(); + const databasePath = path.join(root, "finsight.db"); + const db = getDb(databasePath); + const account = createAccount(db, { + name: "Broker", + type: "brokerage", + currency: "USD", + }); + const tradedAt = "2026-09-14T15:37:42-04:00"; + recordBuy(db, { + account_id: account.id, + symbol: "AAPL", + quantity: 1, + price: 200, + currency: "USD", + traded_at: tradedAt, + }); + db.$client.close(); + + const result = runCli(databasePath, "trade", "list"); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain(tradedAt); + }); + + it("shows a supplied full timestamp in transaction review output", () => { + const root = makeTempDir(); + const databasePath = path.join(root, "finsight.db"); + const db = getDb(databasePath); + const account = createAccount(db, { + name: "Broker", + type: "brokerage", + currency: "USD", + }); + const tradedAt = "2026-09-14T15:37:42-04:00"; + recordBuy(db, { + account_id: account.id, + symbol: "AAPL", + quantity: 1, + price: 200, + currency: "USD", + traded_at: tradedAt, + needs_review: 1, + }); + db.$client.close(); + + const result = runCli(databasePath, "transaction", "review"); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain(tradedAt); + }); +}); diff --git a/packages/cli/src/__tests__/traded-at.test.ts b/packages/cli/src/__tests__/traded-at.test.ts new file mode 100644 index 0000000..d0b3672 --- /dev/null +++ b/packages/cli/src/__tests__/traded-at.test.ts @@ -0,0 +1,44 @@ +/** + * [INPUT]: optional --date and --traded-at values. + * [OUTPUT]: validation and lookup/storage date behavior assertions. + * [POS]: unit tests for the CLI's shared trade timestamp seam. + * [RUNTIME]: test. + * [PROTOCOL]: full timestamps are optional, verbatim, and never replace an + * explicitly supplied historical quote date. + */ +import { describe, expect, it } from "vitest"; +import { resolveTradeDates, validateTradedAt } from "../utils/traded-at.js"; + +describe("trade timestamp date handling", () => { + it("preserves normal ISO timestamps and derives their quote date", () => { + const tradedAt = "2026-09-14T15:37:42.123-04:00"; + + expect(validateTradedAt(tradedAt)).toBe(tradedAt); + expect(resolveTradeDates(undefined, tradedAt)).toEqual({ + quoteDate: "2026-09-14", + tradedAt, + }); + }); + + it("lets --date control quote lookup while --traded-at controls storage", () => { + const tradedAt = "2026-09-14T19:37:42Z"; + + expect(resolveTradeDates("2026-09-12", tradedAt)).toEqual({ + quoteDate: "2026-09-12", + tradedAt, + }); + }); + + it("keeps date-only workflows unchanged", () => { + expect(resolveTradeDates("2025-11-15")).toEqual({ + quoteDate: "2025-11-15", + tradedAt: "2025-11-15", + }); + }); + + it("rejects malformed full timestamps clearly", () => { + expect(() => validateTradedAt("2026-09-14")).toThrow(/Invalid --traded-at/); + expect(() => validateTradedAt("2026-09-14T15:37:42")).toThrow(/Invalid --traded-at/); + expect(() => validateTradedAt("2026-02-30T15:37:42Z")).toThrow(/Invalid --traded-at/); + }); +}); diff --git a/packages/cli/src/commands/trade.ts b/packages/cli/src/commands/trade.ts index f8fe1a0..7899303 100644 --- a/packages/cli/src/commands/trade.ts +++ b/packages/cli/src/commands/trade.ts @@ -1,3 +1,11 @@ +/** + * [INPUT]: Commander arguments, core transaction services, and quote connectors. + * [OUTPUT]: stock/fund trade, cashflow, transfer, and transaction-list CLI commands. + * [POS]: public CLI command layer; delegates persistence and valuation to core. + * [RUNTIME]: server / CLI. + * [PROTOCOL]: --date remains date-only compatible; --traded-at is optional and + * is stored verbatim while its date prefix drives quote lookup. + */ import { Command } from "commander"; import chalk from "chalk"; import { confirm } from "@inquirer/prompts"; @@ -22,6 +30,7 @@ import { fetchFundNav, fetchFundNavAt } from "@finsight/connector-tiantian"; import { initDb } from "../utils/config.js"; import { createTable, formatCurrency, printSuccess, printInfo } from "../utils/display.js"; import { emitJson, fail, ExitCode } from "../utils/exit.js"; +import { resolveTradeDates } from "../utils/traded-at.js"; export const tradeCmd = new Command("trade").description("Record and list trades"); @@ -156,6 +165,7 @@ interface BuyOpts { price?: string; amount?: string; date?: string; + tradedAt?: string; noQuote?: boolean; noWarn?: boolean; yes?: boolean; @@ -232,6 +242,7 @@ tradeCmd .option("--amount ", "Trade amount; for funds, qty = amount / NAV") .option("--price

", "Explicit unit price (stocks only; funds always use NAV)") .option("--date ", "Historical trade date — fetches the close/NAV on that day") + .option("--traded-at ", "Optional full ISO execution timestamp; stored verbatim") .option("--fee ", "Transaction fee", "0") .option("--note ", "Trade note") .option("--braindump-id ", "Braindump note ID") @@ -250,6 +261,13 @@ tradeCmd const acc = findAccountByName(db, accountName); if (!acc) fail("NOT_FOUND", `Account not found: ${accountName}`, { json: opts.json }); + let tradeDates: ReturnType; + try { + tradeDates = resolveTradeDates(opts.date, opts.tradedAt); + } catch (e) { + fail("USER_ERROR", e instanceof Error ? e.message : String(e), { json: opts.json }); + } + const isFund = isFundCode(symbol); // ── Resolve price ────────────────────────────────────────────────────── @@ -272,7 +290,7 @@ tradeCmd needs_review: 0, }; } else if (isFund) { - const out = await resolveFundPrice({ symbol, date: opts.date, userPrice }); + const out = await resolveFundPrice({ symbol, date: tradeDates.quoteDate, userPrice }); if (!out.ok) { fail("INTERNAL", `Could not fetch NAV: ${out.reason}`, { json: opts.json, @@ -286,7 +304,7 @@ tradeCmd acc.currency === "USDT" || acc.currency === "BTC" ? ("crypto" as const) : undefined; const out = await resolveStockPrice({ symbol, - date: opts.date, + date: tradeDates.quoteDate, userPrice, hint, }); @@ -342,7 +360,7 @@ tradeCmd } const fee = Number(opts.fee ?? "0"); - const tradedAt = opts.date ?? new Date().toISOString(); + const tradedAt = tradeDates.tradedAt ?? new Date().toISOString(); const tx = recordBuy(db, { account_id: acc.id, @@ -417,6 +435,7 @@ tradeCmd .option("--amount ", "Trade amount; for funds, qty = amount / NAV") .option("--price

", "Explicit unit price (stocks only)") .option("--date ", "Historical trade date") + .option("--traded-at ", "Optional full ISO execution timestamp; stored verbatim") .option("--fee ", "Transaction fee", "0") .option("--note ", "Trade note") .option("--braindump-id ", "Braindump note ID") @@ -435,7 +454,16 @@ tradeCmd const acc = findAccountByName(db, accountName); if (!acc) fail("NOT_FOUND", `Account not found: ${accountName}`, { json: opts.json }); + let tradeDates: ReturnType; + try { + tradeDates = resolveTradeDates(opts.date, opts.tradedAt); + } catch (e) { + fail("USER_ERROR", e instanceof Error ? e.message : String(e), { json: opts.json }); + } + const isFund = isFundCode(symbol); + + // ── Resolve price ────────────────────────────────────────────────────── const userPrice = opts.price != null ? Number(opts.price) : undefined; if (opts.price != null && Number.isNaN(userPrice)) { fail("USER_ERROR", `Invalid --price: ${opts.price}`, { json: opts.json }); @@ -455,7 +483,7 @@ tradeCmd needs_review: 0, }; } else if (isFund) { - const out = await resolveFundPrice({ symbol, date: opts.date, userPrice }); + const out = await resolveFundPrice({ symbol, date: tradeDates.quoteDate, userPrice }); if (!out.ok) fail("INTERNAL", `Could not fetch NAV: ${out.reason}`, { json: opts.json }); resolved = out.resolved; @@ -463,7 +491,7 @@ tradeCmd } else { const hint = acc.currency === "USDT" || acc.currency === "BTC" ? ("crypto" as const) : undefined; - const out = await resolveStockPrice({ symbol, date: opts.date, userPrice, hint }); + const out = await resolveStockPrice({ symbol, date: tradeDates.quoteDate, userPrice, hint }); if (!out.ok) fail("INTERNAL", out.reason, { json: opts.json }); resolved = out.resolved; } @@ -500,7 +528,7 @@ tradeCmd } const fee = Number(opts.fee ?? "0"); - const tradedAt = opts.date ?? new Date().toISOString(); + const tradedAt = tradeDates.tradedAt ?? new Date().toISOString(); try { const tx = recordSell(db, { @@ -719,7 +747,7 @@ tradeCmd "Price", "Source", "Fee", - "Date", + "Traded At", "Notes", ]); for (const tx of txs) { @@ -731,7 +759,7 @@ tradeCmd tx.price ? formatCurrency(tx.price, tx.currency) : "-", priceSourceShort(tx.price_source) + (tx.needs_review ? chalk.yellow("*") : ""), formatCurrency(tx.fee, tx.currency), - tx.traded_at.slice(0, 10), + tx.traded_at, (tx.notes ?? "-").slice(0, 30), ]); } diff --git a/packages/cli/src/commands/transaction.ts b/packages/cli/src/commands/transaction.ts index 5b1bc8e..4156327 100644 --- a/packages/cli/src/commands/transaction.ts +++ b/packages/cli/src/commands/transaction.ts @@ -1,3 +1,11 @@ +/** + * [INPUT]: transaction service rows and confirmation arguments. + * [OUTPUT]: human-readable and JSON transaction review/confirmation commands. + * [POS]: public CLI transaction inspection layer. + * [RUNTIME]: server / CLI. + * [PROTOCOL]: review output preserves the complete traded_at value when one + * was supplied; date-only rows remain unchanged. + */ import { Command } from "commander"; import chalk from "chalk"; import { @@ -28,7 +36,15 @@ transactionCmd printSuccess("All transactions are confirmed."); return; } - const t = createTable(["ID", "Type", "Symbol", "Qty", "Price", "Source", "Date"]); + const t = createTable([ + "ID", + "Type", + "Symbol", + "Qty", + "Price", + "Source", + "Traded At", + ]); for (const tx of pending) { t.push([ tx.id.slice(-12), @@ -37,7 +53,7 @@ transactionCmd tx.quantity?.toString() ?? "—", tx.price ? formatCurrency(tx.price, tx.currency) : "—", tx.price_source ?? "—", - tx.traded_at.slice(0, 10), + tx.traded_at, ]); } console.log(t.toString()); diff --git a/packages/cli/src/utils/traded-at.ts b/packages/cli/src/utils/traded-at.ts new file mode 100644 index 0000000..95a1019 --- /dev/null +++ b/packages/cli/src/utils/traded-at.ts @@ -0,0 +1,58 @@ +/** + * [INPUT]: optional CLI date and full execution timestamp strings. + * [OUTPUT]: validated timestamp and the date used for historical quote lookup. + * [POS]: shared date handling for stock/fund trade commands. + * [RUNTIME]: shared / CLI. + * [PROTOCOL]: preserve valid `traded_at` values verbatim; derive quote dates + * from the timestamp's YYYY-MM-DD prefix only when --date is absent. + */ + +const ISO_TIMESTAMP_PATTERN = + /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})$/; + +const INVALID_TIMESTAMP_MESSAGE = + "Expected an ISO timestamp such as 2026-09-14T15:37:42-04:00 or 2026-09-14T19:37:42Z"; + +/** Validate --traded-at without normalizing the user's original string. */ +export function validateTradedAt(value: string): string { + const match = ISO_TIMESTAMP_PATTERN.exec(value); + if (!match) { + throw new Error(`Invalid --traded-at: ${value}. ${INVALID_TIMESTAMP_MESSAGE}.`); + } + + const [, date, hours, minutes, seconds, timezone] = match; + const dateAtUtc = Date.parse(`${date}T00:00:00Z`); + const validDate = + !Number.isNaN(dateAtUtc) && new Date(dateAtUtc).toISOString().slice(0, 10) === date; + const validTime = + Number(hours) <= 23 && Number(minutes) <= 59 && Number(seconds ?? "0") <= 59; + const validOffset = + timezone === "Z" || + (Number(timezone.slice(1, 3)) <= 23 && Number(timezone.slice(4, 6)) <= 59); + + if (!validDate || !validTime || !validOffset || Number.isNaN(Date.parse(value))) { + throw new Error(`Invalid --traded-at: ${value}. ${INVALID_TIMESTAMP_MESSAGE}.`); + } + + return value; +} + +export interface ResolvedTradeDates { + /** Date passed to the historical quote/NAV connector, if any. */ + quoteDate?: string; + /** Value passed to transactions.traded_at, if the CLI supplied one. */ + tradedAt?: string; +} + +/** Resolve lookup and storage dates while preserving date-only behavior. */ +export function resolveTradeDates(date?: string, tradedAt?: string): ResolvedTradeDates { + if (tradedAt == null) { + return { quoteDate: date, tradedAt: date }; + } + + const validated = validateTradedAt(tradedAt); + return { + quoteDate: date ?? validated.slice(0, 10), + tradedAt: validated, + }; +} diff --git a/packages/core/src/__tests__/transaction.test.ts b/packages/core/src/__tests__/transaction.test.ts index 6bc9eca..2bcdec1 100644 --- a/packages/core/src/__tests__/transaction.test.ts +++ b/packages/core/src/__tests__/transaction.test.ts @@ -1,3 +1,10 @@ +/** + * [INPUT]: isolated SQLite fixtures and transaction service functions. + * [OUTPUT]: regression coverage for transaction recording and date filtering. + * [POS]: core service tests for the authoritative transactions table. + * [RUNTIME]: test. + * [PROTOCOL]: traded_at remains a single verbatim date-or-timestamp field. + */ import { describe, it, expect, beforeEach } from "vitest"; import { getTestDb } from "../db/connection.js"; import type { AppDatabase } from "../db/connection.js"; @@ -45,6 +52,19 @@ describe("transaction service", () => { expect(pos!.avg_cost).toBe(380); }); + it("preserves a supplied full timestamp on a buy", () => { + const tradedAt = "2026-09-14T15:37:42.123-04:00"; + const tx = recordBuy(db, { + account_id: accountId, + symbol: "AAPL", + quantity: 2, + price: 200, + traded_at: tradedAt, + }); + + expect(tx.traded_at).toBe(tradedAt); + }); + it("should record buy and update existing position avg_cost", () => { recordBuy(db, { account_id: accountId, @@ -83,6 +103,25 @@ describe("transaction service", () => { expect(pos!.quantity).toBe(50); }); + it("preserves a supplied full timestamp on a sell", () => { + recordBuy(db, { + account_id: accountId, + symbol: "AAPL", + quantity: 2, + price: 200, + }); + const tradedAt = "2026-09-14T15:37:42Z"; + const tx = recordSell(db, { + account_id: accountId, + symbol: "AAPL", + quantity: 1, + price: 210, + traded_at: tradedAt, + }); + + expect(tx.traded_at).toBe(tradedAt); + }); + it("should throw when selling without position", () => { expect(() => recordSell(db, { @@ -152,4 +191,21 @@ describe("transaction service", () => { const byAccount = listTransactions(db, { account_id: accountId }); expect(byAccount).toHaveLength(2); }); + + it("includes full timestamps when a date-only upper bound is used", () => { + recordBuy(db, { + account_id: accountId, + symbol: "AAPL", + quantity: 1, + price: 200, + traded_at: "2026-09-14T15:37:42-04:00", + }); + + const sameDay = listTransactions(db, { + from: "2026-09-14", + to: "2026-09-14", + }); + + expect(sameDay).toHaveLength(1); + }); }); diff --git a/packages/core/src/services/transaction.ts b/packages/core/src/services/transaction.ts index 9110777..90c1f43 100644 --- a/packages/core/src/services/transaction.ts +++ b/packages/core/src/services/transaction.ts @@ -1,4 +1,12 @@ -import { eq, and, gte, lte } from "drizzle-orm"; +/** + * [INPUT]: transaction/account database handles and record/filter inputs. + * [OUTPUT]: authoritative transaction rows plus position side effects. + * [POS]: core transaction service used by CLI, web, and ledger adapters. + * [RUNTIME]: shared / server. + * [PROTOCOL]: keep traded_at as the single date-or-timestamp field; date-only + * list bounds include timestamps on the requested calendar day. + */ +import { eq, and, gte, lt, lte } from "drizzle-orm"; import { ulid } from "ulid"; import { accounts, transactions } from "../db/schema.js"; import type { AppDatabase } from "../db/connection.js"; @@ -19,6 +27,14 @@ function now() { return new Date().toISOString(); } +function nextDateForExclusiveUpperBound(value: string): string | null { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null; + const date = new Date(`${value}T00:00:00Z`); + if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value) return null; + date.setUTCDate(date.getUTCDate() + 1); + return date.toISOString().slice(0, 10); +} + /** Provenance of a transaction's `price` field. See schema for semantics. */ export type PriceSource = | "user_provided" @@ -274,7 +290,12 @@ export function listTransactions( conditions.push(gte(transactions.traded_at, filters.from)); } if (filters?.to) { - conditions.push(lte(transactions.traded_at, filters.to)); + const nextDate = nextDateForExclusiveUpperBound(filters.to); + conditions.push( + nextDate + ? lt(transactions.traded_at, nextDate) + : lte(transactions.traded_at, filters.to), + ); } if (conditions.length === 0) { diff --git a/skills/finsight/SKILL.md b/skills/finsight/SKILL.md index aa19b75..6688c7b 100644 --- a/skills/finsight/SKILL.md +++ b/skills/finsight/SKILL.md @@ -106,6 +106,10 @@ finsight trade buy \ finsight trade sell \ --price 195.00 --json + +# Optional full execution timestamp; date-only --date remains valid +finsight trade buy \ + --traded-at 2026-06-10T09:30:00+08:00 --json ``` - For funds (6-digit Chinese codes): use `--amount` instead of `--price`; NAV is @@ -113,6 +117,10 @@ finsight trade sell \ - For stocks: pass `--price` if you know it (settlement price). Otherwise the closing quote for the trade date is used and the row is flagged `needs_review = 1` so the user can fix it later. +- Full timestamps are optional. `--traded-at` is stored verbatim; without + `--date`, its `YYYY-MM-DD` prefix selects the historical quote/NAV date. If + both are supplied, `--date` controls lookup and `--traded-at` controls the + stored event time. Date-only workflows remain valid. ### When the user wants to record reasoning From 9fe5000b5deb100fb1c63fcce43acb679196d947 Mon Sep 17 00:00:00 2001 From: ApeCode Date: Tue, 15 Sep 2026 19:23:41 +0800 Subject: [PATCH 2/3] test(cli): make timestamp tests network-stable Disable the normal price-deviation warning in timestamp persistence tests so CI verifies timestamp behavior without depending on the runner's live historical quote. --- packages/cli/src/__tests__/trade.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/src/__tests__/trade.test.ts b/packages/cli/src/__tests__/trade.test.ts index 7e97d3a..c9ee180 100644 --- a/packages/cli/src/__tests__/trade.test.ts +++ b/packages/cli/src/__tests__/trade.test.ts @@ -88,6 +88,7 @@ describe("trade timestamp option", () => { "200", "--traded-at", tradedAt, + "--no-warn", "--json", ); @@ -112,6 +113,7 @@ describe("trade timestamp option", () => { "200", "--date", "2025-11-15", + "--no-warn", "--json", ); From a39b7eb4a1cfdef19532d42e660637220a444f45 Mon Sep 17 00:00:00 2001 From: ApeCode Date: Tue, 15 Sep 2026 19:31:52 +0800 Subject: [PATCH 3/3] fix(cli): honor negated trade options in Commander Commander exposes `--no-quote` and `--no-warn` as `quote=false` and `warn=false`. Read those canonical option fields so non-interactive trade tests and callers bypass market lookup/warnings as documented. --- packages/cli/src/commands/trade.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/trade.ts b/packages/cli/src/commands/trade.ts index 7899303..ef19260 100644 --- a/packages/cli/src/commands/trade.ts +++ b/packages/cli/src/commands/trade.ts @@ -167,7 +167,9 @@ interface BuyOpts { date?: string; tradedAt?: string; noQuote?: boolean; + quote?: boolean; noWarn?: boolean; + warn?: boolean; yes?: boolean; json?: boolean; // Decision integration @@ -213,7 +215,7 @@ async function checkDeviationOk( ): Promise { if (resolved.source !== "user_provided") return true; if (!resolved.reference) return true; - if (opts.noWarn) return true; + if (opts.noWarn || opts.warn === false) return true; const diff = Math.abs(resolved.price - resolved.reference); const pct = diff / resolved.reference; if (pct < DEFAULT_DEVIATION_THRESHOLD) return true; @@ -278,7 +280,7 @@ tradeCmd let resolved: ResolvedPrice; let userPriceIgnored = false; - if (opts.noQuote) { + if (opts.noQuote || opts.quote === false) { if (userPrice == null) { fail("USER_ERROR", "--no-quote requires --price", { json: opts.json }); } @@ -471,7 +473,7 @@ tradeCmd let resolved: ResolvedPrice; let userPriceIgnored = false; - if (opts.noQuote) { + if (opts.noQuote || opts.quote === false) { if (userPrice == null) { fail("USER_ERROR", "--no-quote requires --price", { json: opts.json }); }