diff --git a/Memory.md b/Memory.md index 300d2ba..2848744 100644 --- a/Memory.md +++ b/Memory.md @@ -11,11 +11,13 @@ - 完成核心 `transactions` (交易流水) 表的建立,并严格运用了 `numeric(36,18)` 的高精度配置。 - `assets` 表完成多次业务演进:新增 `latestPrice` (支持现价追踪)、`exchange` (显式交易所绑定) 以及 `name` (中文名称解析) 字段。 - `exchange_rates` (汇率表) 已建立,支持联合主键与跨币种交叉汇率架构。 +- **引入 `portfolio_snapshots` 表**:用于每日记录投资组合快照,字段包括 `date` (唯一日期)、`total_value_cny` (当日总市值)、`total_cost_cny` (当日总投入本金),为历史净值走势图奠定底层数据结构。 ## 核心业务与服务端逻辑 (Server Actions) - 完成高精度交易流水与资产的 Server Actions 开发,成功实现字符串级别的高精度防腐层拦截(基于 Zod & Big.js)。 - 补全资产与流水的全栈增删改查 (CRUD) 操作,`createTransaction` 现已支持根据 `exchange` 自动判定并锁定 `txCurrency`。 - **估值与 P&L 引擎:** 完成底层估值引擎升级,打通交叉汇率换算逻辑;实现原币种 (Native) 与本位币 (CNY Base) 双轨制的历史成本追溯与真实盈亏 (P&L) 计算引擎。 +- **快照记录引擎:** 新增 `src/actions/snapshots.ts`,`recordDailySnapshot()` 函数基于 `getPortfolioPositions()` 实时计算总市值与总成本,使用 `Asia/Shanghai` 时区获取当日日期,执行 Upsert 逻辑确保每天仅存一条记录;`getSnapshots()` 支持按日期范围与数量限制查询历史快照数据。 ## 外部行情接口与网络 (Market Data Engines) - **股票行情引擎:** 彻底抛弃低效海外接口,自主研发智能路由接入腾讯财经 (`qt.gtimg.cn`) 极速接口。引入原生 `ArrayBuffer` 与 `TextDecoder(gbk)` 彻底解决历史中文乱码问题,实现沪、深、港、美四大市场毫秒级实时同步。 diff --git a/drizzle/0003_watery_xorn.sql b/drizzle/0003_watery_xorn.sql new file mode 100644 index 0000000..5e57c19 --- /dev/null +++ b/drizzle/0003_watery_xorn.sql @@ -0,0 +1,24 @@ +CREATE TABLE "exchange_rates" ( + "id" uuid PRIMARY KEY NOT NULL, + "from_currency" varchar(10) NOT NULL, + "to_currency" varchar(10) NOT NULL, + "rate" numeric(20, 8) NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() +); +--> statement-breakpoint +CREATE TABLE "portfolio_snapshots" ( + "id" uuid PRIMARY KEY NOT NULL, + "date" date NOT NULL, + "total_value_cny" numeric(36, 18) NOT NULL, + "total_cost_cny" numeric(36, 18) NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "portfolio_snapshots_date_unique" UNIQUE("date") +); +--> statement-breakpoint +ALTER TABLE "assets" ALTER COLUMN "id" DROP DEFAULT;--> statement-breakpoint +ALTER TABLE "users" ALTER COLUMN "id" DROP DEFAULT;--> statement-breakpoint +ALTER TABLE "assets" ADD COLUMN "name" varchar(100);--> statement-breakpoint +ALTER TABLE "assets" ADD COLUMN "exchange" varchar(10) DEFAULT 'US';--> statement-breakpoint +ALTER TABLE "assets" ADD COLUMN "latest_price" numeric(36, 18) DEFAULT '0' NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "currency_pair_idx" ON "exchange_rates" USING btree ("from_currency","to_currency"); \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..b9bef91 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,375 @@ +{ + "id": "26b392bf-03db-4f71-86d5-395d5c07fae5", + "prevId": "18d8b1e1-2700-4d54-adf8-493d526d5bf5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "symbol": { + "name": "symbol", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "asset_type_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "exchange": { + "name": "exchange", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'US'" + }, + "base_currency": { + "name": "base_currency", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "latest_price": { + "name": "latest_price", + "type": "numeric(36, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "assets_symbol_unique": { + "name": "assets_symbol_unique", + "nullsNotDistinct": false, + "columns": [ + "symbol" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.exchange_rates": { + "name": "exchange_rates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "from_currency": { + "name": "from_currency", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "to_currency": { + "name": "to_currency", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "rate": { + "name": "rate", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "currency_pair_idx": { + "name": "currency_pair_idx", + "columns": [ + { + "expression": "from_currency", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "to_currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.portfolio_snapshots": { + "name": "portfolio_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "total_value_cny": { + "name": "total_value_cny", + "type": "numeric(36, 18)", + "primaryKey": false, + "notNull": true + }, + "total_cost_cny": { + "name": "total_cost_cny", + "type": "numeric(36, 18)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "portfolio_snapshots_date_unique": { + "name": "portfolio_snapshots_date_unique", + "nullsNotDistinct": false, + "columns": [ + "date" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transactions": { + "name": "transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tx_type": { + "name": "tx_type", + "type": "transaction_type_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(36, 18)", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "numeric(36, 18)", + "primaryKey": false, + "notNull": true + }, + "fee": { + "name": "fee", + "type": "numeric(36, 18)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "tx_currency": { + "name": "tx_currency", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "exchange_rate": { + "name": "exchange_rate", + "type": "numeric(20, 8)", + "primaryKey": false, + "notNull": true, + "default": "'1'" + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "transactions_asset_id_assets_id_fk": { + "name": "transactions_asset_id_assets_id_fk", + "tableFrom": "transactions", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.asset_type_enum": { + "name": "asset_type_enum", + "schema": "public", + "values": [ + "STOCK", + "CRYPTO", + "CASH" + ] + }, + "public.transaction_type_enum": { + "name": "transaction_type_enum", + "schema": "public", + "values": [ + "BUY", + "SELL", + "DIVIDEND", + "AIRDROP", + "FEE" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 423a1f8..bf6f9a0 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1777289592183, "tag": "0002_rapid_invaders", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1777433725731, + "tag": "0003_watery_xorn", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/actions/snapshots.ts b/src/actions/snapshots.ts new file mode 100644 index 0000000..7d9c2d5 --- /dev/null +++ b/src/actions/snapshots.ts @@ -0,0 +1,103 @@ +'use server'; + +import { db } from '@/db'; +import { portfolioSnapshots } from '@/db/schema'; +import { getPortfolioPositions } from './portfolio'; +import { eq, sql } from 'drizzle-orm'; + +function getTodayInShanghai(): string { + const now = new Date(); + const utcStr = now.toLocaleString('en-US', { timeZone: 'UTC' }); + const utcDate = new Date(utcStr); + const shanghaiOffset = 8 * 60 * 60 * 1000; + const shanghaiDate = new Date(utcDate.getTime() + shanghaiOffset); + const year = shanghaiDate.getFullYear(); + const month = String(shanghaiDate.getMonth() + 1).padStart(2, '0'); + const day = String(shanghaiDate.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +export async function recordDailySnapshot() { + const positions = await getPortfolioPositions(); + + const totalValueCny = positions.reduce( + (sum, pos) => sum.plus(pos.cnyValue || '0'), + 0 as number | string + ); + + const totalCostCny = positions.reduce( + (sum, pos) => sum.plus(pos.totalCostCny || '0'), + 0 as number | string + ); + + const dateStr = getTodayInShanghai(); + + const existing = await db + .select() + .from(portfolioSnapshots) + .where(eq(portfolioSnapshots.date, dateStr)) + .limit(1); + + const now = new Date(); + + if (existing.length > 0) { + await db + .update(portfolioSnapshots) + .set({ + totalValueCny: String(totalValueCny), + totalCostCny: String(totalCostCny), + updatedAt: now, + }) + .where(eq(portfolioSnapshots.date, dateStr)); + + return { + success: true, + action: 'updated', + date: dateStr, + totalValueCny: String(totalValueCny), + totalCostCny: String(totalCostCny), + }; + } + + await db + .insert(portfolioSnapshots) + .values({ + date: dateStr, + totalValueCny: String(totalValueCny), + totalCostCny: String(totalCostCny), + createdAt: now, + updatedAt: now, + }); + + return { + success: true, + action: 'inserted', + date: dateStr, + totalValueCny: String(totalValueCny), + totalCostCny: String(totalCostCny), + }; +} + +export async function getSnapshots(params?: { + limit?: number; + startDate?: string; + endDate?: string; +}) { + const { limit = 365, startDate, endDate } = params || {}; + + let query = db + .select() + .from(portfolioSnapshots) + .orderBy(sql`"${date}" DESC`); + + if (startDate) { + query = query.where(sql`"${date}" >= ${startDate}`); + } + if (endDate) { + query = query.where(sql`"${date}" <= ${endDate}`); + } + + const snapshots = await query.limit(limit); + + return snapshots.reverse(); +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 6214fe8..c2c8722 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,4 @@ -import { pgTable, uuid, varchar, timestamp, pgEnum, numeric, uniqueIndex } from "drizzle-orm/pg-core"; +import { pgTable, uuid, varchar, timestamp, pgEnum, numeric, uniqueIndex, date } from "drizzle-orm/pg-core"; export const users = pgTable("users", { id: uuid("id").primaryKey().$defaultFn(() => crypto.randomUUID()), @@ -65,3 +65,16 @@ export const exchangeRates = pgTable("exchange_rates", { }, (table) => [ uniqueIndex("currency_pair_idx").on(table.fromCurrency, table.toCurrency), ]); + +export const portfolioSnapshots = pgTable("portfolio_snapshots", { + id: uuid("id").primaryKey().$defaultFn(() => crypto.randomUUID()), + date: date("date", { mode: "string" }).notNull().unique(), + totalValueCny: numeric("total_value_cny", { precision: 36, scale: 18 }).notNull(), + totalCostCny: numeric("total_cost_cny", { precision: 36, scale: 18 }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }) + .defaultNow() + .notNull(), +});