diff --git a/ui/web/public/locales/en/pages.json b/ui/web/public/locales/en/pages.json index 360dda0c4..445da2765 100644 --- a/ui/web/public/locales/en/pages.json +++ b/ui/web/public/locales/en/pages.json @@ -15,6 +15,7 @@ "ExtensionPanel": "Extensions", "FileEditor": "{{ filename }}", "FinancialStatementsPanel": "Fund Statements", + "FundStatements": "Fund Statements {{ filename }}", "GeneralSpecificRelationList": "General Product Reletions", "HostList": "Hosts", "Login": "Log in", diff --git a/ui/web/public/locales/zh-Hans/pages.json b/ui/web/public/locales/zh-Hans/pages.json index abcc30014..c151e4b95 100644 --- a/ui/web/public/locales/zh-Hans/pages.json +++ b/ui/web/public/locales/zh-Hans/pages.json @@ -15,6 +15,7 @@ "ExtensionPanel": "应用拓展", "FileEditor": "{{ filename }}", "FinancialStatementsPanel": "基金结算", + "FundStatements": "基金结算 {{ filename }}", "GeneralSpecificRelationList": "标准品种关系列表", "HostList": "主机配置", "Login": "登录", diff --git a/ui/web/src/modules/Fund/FundStatements.tsx b/ui/web/src/modules/Fund/FundStatements.tsx new file mode 100644 index 000000000..e67cc2bda --- /dev/null +++ b/ui/web/src/modules/Fund/FundStatements.tsx @@ -0,0 +1,384 @@ +import { IconCode, IconEdit, IconRefresh, IconUser } from '@douyinfe/semi-icons'; +import { Collapse, Descriptions, Space, Toast } from '@douyinfe/semi-ui'; +import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'; +import { formatTime } from '@yuants/data-model'; +import { parse } from 'jsonc-parser'; +import { useObservable, useObservableState } from 'observable-hooks'; +import { useMemo, useReducer } from 'react'; +import { firstValueFrom, from, map, pipe, switchMap } from 'rxjs'; +import { accountIds$, useAccountInfo } from '../AccountInfo/model'; +import { executeCommand } from '../CommandCenter'; +import { fs } from '../FileSystem/api'; +import { showForm } from '../Form'; +import { Button, DataView } from '../Interactive'; +import { registerPage, usePageParams } from '../Pages'; + +interface IFundStatement { + type: string; + updated_at: string; + comment?: string; + /** 更新基金总资产的动作 */ + fund_equity?: { + equity: number; + }; + /** 更新投资人信息的动作 */ + order?: { + name: string; + /** 净入金 */ + deposit: number; + }; +} + +type IFundState = { + updated_at: number; + description: string; // 描述 + totalAssets: number; // 总资产 + totalShare: number; // 总份额 + unitPrice: number; // 份额净值 + investors: Record; // 投资人数据 + mapNameToDetail: Record; +}; + +type InvestorMeta = { + // input + name: string; // 投资人姓名 + share: number; // 投资人份额 + dividendBase: number; // 投资人分红基数 + deposit: number; // 投资人净入金 + dividendRate: number; // 计提分红比率 +}; + +// 投资人数据详情 +type InvestorDetails = { + // by compute + shareRate: number; // 投资人份额百分比 + assets: number; // 投资人账面资产 + profit: number; // 投资人账面盈利 + profitRate: number; // 投资人账面盈利率 + expectedDividend: number; // 投资人预期计提分红 + expectedDividendShare: number; // 投资人预期分红扣减份额 + expectedPostDividendShare: number; // 投资人预期分红后份额 + expectedPostDividendAssets: number; // 投资人预期分红后资产 + expectedProfit: number; // 投资人预期收益 + expectedProfitRate: number; // 投资人预期收益率 + accumulatedProfit: number; // 投资人累积收益 + accumulatedProfitRate: number; // 投资人累积收益率 +}; + +const initInvestor: InvestorDetails = { + shareRate: 0, + assets: 0, + profit: 0, + profitRate: 0, + expectedDividend: 0, + expectedDividendShare: 0, + expectedPostDividendShare: 0, + expectedPostDividendAssets: 0, + expectedProfit: 0, + expectedProfitRate: 0, + accumulatedProfit: 0, + accumulatedProfitRate: 0, +}; + +const initFundState: IFundState = { + updated_at: 0, + description: '', + totalAssets: 0, // 总资产 + totalShare: 0, // 总份额 + unitPrice: 1, // 份额净值 + investors: {}, + mapNameToDetail: {}, +}; + +const reduceStatement = (state: IFundState, statement: IFundStatement): IFundState => { + const nextState = structuredClone(state); + nextState.updated_at = new Date(statement.updated_at).getTime(); + nextState.description = statement.comment || ''; + + // 更新总资产 + if (statement.fund_equity) { + nextState.totalAssets = statement.fund_equity.equity; + } + // 投资人订单 + if (statement.order) { + const deposit = statement.order.deposit; + const investor = (nextState.investors[statement.order.name] ??= { + name: statement.order.name, + dividendBase: 0, + deposit: 0, + share: 0, + dividendRate: 0, + }); + investor.deposit += deposit; + investor.dividendBase += deposit; + investor.share += deposit / state.unitPrice; + nextState.totalAssets += deposit; + } + + { + const totalShare = Object.values(nextState.investors).reduce((acc, cur) => acc + cur.share, 0); // 总份额 + const unitPrice = totalShare === 0 ? 1 : nextState.totalAssets / totalShare; // 份额净值 + nextState.totalShare = totalShare; + nextState.unitPrice = unitPrice; + Object.values(nextState.investors).forEach((v) => { + const dividendRate = v.dividendRate; // 投资人计提分红比例 + const share = v.share; // 投资人份额 + const dividendBase = v.dividendBase; // 投资人分红基数 + const shareRate = totalShare !== 0 ? share / totalShare : 0; // 投资人份额百分比 + const assets = share * unitPrice; // 投资人账面资产 + const profit = assets - dividendBase; // 投资人账面盈利 + const profitRate = dividendBase !== 0 ? profit / dividendBase : 0; // 投资人账面盈利率 + const expectedDividend = profit > 0 ? profit * dividendRate : 0; // 投资人预期计提分红 + const expectedDividendShare = profit > 0 ? expectedDividend / unitPrice : 0; // 投资人预期分红扣减份额 + const expectedPostDividendShare = share - expectedDividendShare; // 投资人预期分红后份额 + const expectedPostDividendAssets = expectedPostDividendShare * unitPrice; // 投资人预期分红后资产 + const expectedProfit = expectedPostDividendAssets - dividendBase; // 投资人预期收益 + const expectedProfitRate = dividendBase !== 0 ? expectedProfit / dividendBase : 0; // 投资人预期收益率 + const deposit = v.deposit; // 投资人净入金 + const accumulatedProfit = expectedPostDividendAssets - deposit; // 投资人累积收益 + const accumulatedProfitRate = deposit !== 0 ? accumulatedProfit / deposit : 0; // 投资人累积收益率 + nextState.mapNameToDetail[v.name] = { + shareRate, + assets, + profit, + profitRate, + expectedDividend, + expectedDividendShare, + expectedPostDividendShare, + expectedPostDividendAssets, + expectedProfit, + expectedProfitRate, + accumulatedProfit, + accumulatedProfitRate, + }; + }); + } + + return nextState; +}; + +registerPage('FundStatements', () => { + const { filename } = usePageParams(); + const [refreshState, refresh] = useReducer(() => ({}), {}); + + const statements = useObservableState( + useObservable( + pipe( + switchMap(() => + from(fs.readFile(filename)).pipe( + // + map((x): IFundStatement[] => parse(x)), + map((arr) => + arr.sort((a, b) => new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime()), + ), + ), + ), + ), + [filename, refreshState], + ), + [], + ); + + const history = useMemo(() => { + const history: IFundState[] = []; + statements.forEach((statement) => { + history.push(reduceStatement(history[history.length - 1] || initFundState, statement)); + }); + return history; + }, [statements]); + + const state = history[history.length - 1] || initFundState; + + const investors = useMemo( + () => Object.values(state.investors).map((meta) => ({ meta, detail: state.mapNameToDetail[meta.name] })), + [state], + ); + + const columnsOfInvestor = useMemo(() => { + const columnHelper = createColumnHelper<{ + meta: InvestorMeta; + detail: InvestorDetails; + }>(); + return [ + columnHelper.accessor('meta.name', { + header: () => '投资人', + }), + columnHelper.accessor('detail.assets', { + header: () => '净资产', + }), + columnHelper.accessor('meta.share', { + header: () => '份额', + }), + columnHelper.accessor('meta.dividendBase', { + header: () => '分红水位', + }), + columnHelper.accessor('detail.accumulatedProfit', { + header: () => '累计收益', + }), + columnHelper.accessor('detail.accumulatedProfitRate', { + header: () => '累计收益率', + cell: (ctx) => `${(ctx.getValue() * 100).toFixed(2)}%`, + }), + ]; + }, []); + + const tableOfInvestors = useReactTable({ + columns: columnsOfInvestor, + data: investors, + getCoreRowModel: getCoreRowModel(), + }); + + const columnsOfStatement = useMemo(() => { + const columnHelper = createColumnHelper(); + return [ + columnHelper.accessor('updated_at', { + header: () => '时间', + cell: (ctx) => formatTime(ctx.getValue()), + }), + + columnHelper.accessor('fund_equity.equity', { + header: () => '基金总资产', + }), + columnHelper.accessor('order.name', { + header: () => '投资人', + }), + columnHelper.accessor('order.deposit', { + header: () => '净入金', + }), + columnHelper.accessor('comment', { + header: () => '备注', + }), + ]; + }, []); + + const tableOfStatement = useReactTable({ + columns: columnsOfStatement, + data: statements, + getCoreRowModel: getCoreRowModel(), + initialState: { + sorting: [{ id: 'updated_at', desc: true }], + }, + }); + + const columnsOfState = useMemo(() => { + const columnHelper = createColumnHelper(); + return [ + columnHelper.accessor('updated_at', { + header: () => '时间', + cell: (ctx) => formatTime(ctx.getValue()), + }), + + columnHelper.accessor('totalAssets', { + header: () => '基金总资产', + }), + columnHelper.accessor('totalShare', { + header: () => '基金总份额', + }), + columnHelper.accessor('unitPrice', { + header: () => '单位净值', + }), + ]; + }, []); + + const tableOfState = useReactTable({ + columns: columnsOfState, + data: history, + getCoreRowModel: getCoreRowModel(), + initialState: { + sorting: [{ id: 'updated_at', desc: true }], + }, + }); + + return ( + + + + + + + + + + + + + + + + + + + + + ); +}); diff --git a/ui/web/src/modules/Fund/index.ts b/ui/web/src/modules/Fund/index.ts index 780ed4ff1..80de87349 100644 --- a/ui/web/src/modules/Fund/index.ts +++ b/ui/web/src/modules/Fund/index.ts @@ -1,2 +1,3 @@ import './FinancialStatementsPanel'; +import './FundStatements'; import './RealtimeAsset'; diff --git a/ui/web/src/modules/Workspace/Explorer.tsx b/ui/web/src/modules/Workspace/Explorer.tsx index 3405899e7..56d825d38 100644 --- a/ui/web/src/modules/Workspace/Explorer.tsx +++ b/ui/web/src/modules/Workspace/Explorer.tsx @@ -91,6 +91,13 @@ const rules: IAssociationRule[] = [ executeCommand('RealtimeAsset', { filename: path }); }, }, + { + id: 'FundStatements', + match: ({ path, isFile }) => isFile && !!path.match(/\.statements\.json$/), + action: ({ path }) => { + executeCommand('FundStatements', { filename: path }); + }, + }, { id: 'FileEditor', match: ({ isFile }) => isFile,