Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>` 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

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,11 +261,15 @@ finsight trade buy <acc> PDD 100 # auto-fetches today's price
finsight trade buy <acc> 110020 --amount 1000 # fund: buy by amount, --amount required
finsight trade buy <acc> PDD 100 --price 82.5 # explicit price
finsight trade buy <acc> PDD 100 --date 2025-11-15
finsight trade buy <acc> 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 <id> --price 84 # replace a guessed price with the real one
finsight balance update <acc> 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)
Expand Down
4 changes: 4 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,11 +238,15 @@ finsight trade buy <acc> PDD 100 # 自动抓今天的收盘价
finsight trade buy <acc> 110020 --amount 1000 # 基金:按金额买,--amount 必填
finsight trade buy <acc> PDD 100 --price 82.5 # 指定成本价
finsight trade buy <acc> PDD 100 --date 2025-11-15
finsight trade buy <acc> PDD 100 --traded-at 2025-11-15T09:30:00+08:00 # 可选:保存完整成交时间
finsight trade list --needs-review # 价格是自动猜的,回头确认一下
finsight transaction confirm <id> --price 84 # 把猜的价格替换成真实值
finsight balance update <acc> 250000
```

完整成交时间是可选的:`--traded-at` 会原样保存 ISO 时间戳;只使用日期的
`--date` 工作流仍然有效。

**刷新价格**
```bash
finsight quote update # 全量:所有持仓 + 汇率
Expand Down
206 changes: 206 additions & 0 deletions packages/cli/src/__tests__/trade.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
/**
* [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,
"--no-warn",
"--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",
"--no-warn",
"--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);
});
});
44 changes: 44 additions & 0 deletions packages/cli/src/__tests__/traded-at.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading
Loading