diff --git a/ui/web/src/modules/Fund/FundStatements.tsx b/ui/web/src/modules/Fund/FundStatements.tsx index 68e65209..090deb1f 100644 --- a/ui/web/src/modules/Fund/FundStatements.tsx +++ b/ui/web/src/modules/Fund/FundStatements.tsx @@ -114,8 +114,16 @@ registerPage('FundStatements', () => { columnHelper.accessor('detail.after_tax_profit', { header: () => '收益', }), + columnHelper.accessor('detail.holding_days', { + header: () => '持有天数', + cell: (ctx) => `${Math.ceil(ctx.getValue())}`, + }), columnHelper.accessor('detail.after_tax_profit_rate', { - header: () => '收益率', + header: () => '简单收益率', + cell: (ctx) => `${(ctx.getValue() * 100).toFixed(2)}%`, + }), + columnHelper.accessor('detail.after_tax_IRR', { + header: () => '内部收益率', cell: (ctx) => `${(ctx.getValue() * 100).toFixed(2)}%`, }), columnHelper.accessor('meta.share', { diff --git a/ui/web/src/modules/Fund/model.ts b/ui/web/src/modules/Fund/model.ts index ecc95128..f7eecb86 100644 --- a/ui/web/src/modules/Fund/model.ts +++ b/ui/web/src/modules/Fund/model.ts @@ -52,10 +52,16 @@ export interface IFundState { total_profit: number; }; investors: Record; // 投资人数据 + investor_cashflow: Record; investor_derived: Record; events: IFundEvent[]; } +export interface InvestorCashFlowItem { + updated_at: number; + deposit: number; +} + export interface InvestorMeta { /** 姓名 */ name: string; @@ -67,12 +73,18 @@ export interface InvestorMeta { deposit: number; /** 税率 */ tax_rate: number; + /** 创建时间 */ + created_at: number; } /** * 投资人信息的计算衍生数据 */ export interface InvestorInfoDerived { + /** 持有时间 */ + holding_days: number; + /** 资产在时间上的积分 */ + timed_assets: number; /** 税前资产 */ pre_tax_assets: number; /** 应税额 */ @@ -85,6 +97,7 @@ export interface InvestorInfoDerived { after_tax_profit: number; /** 税后收益率 */ after_tax_profit_rate: number; + after_tax_IRR: number; /** 税后份额 */ after_tax_share: number; diff --git a/ui/web/src/modules/Fund/utils.ts b/ui/web/src/modules/Fund/utils.ts index e243892c..76d58e1d 100644 --- a/ui/web/src/modules/Fund/utils.ts +++ b/ui/web/src/modules/Fund/utils.ts @@ -1,4 +1,4 @@ -import { IFundEvent, IFundState } from './model'; +import { IFundEvent, IFundState, InvestorCashFlowItem } from './model'; export const reduceState = (state: IFundState, event: IFundEvent): IFundState => { const nextState = structuredClone(state); @@ -24,6 +24,7 @@ export const reduceState = (state: IFundState, event: IFundEvent): IFundState => const deposit = event.order.deposit; const investor = (nextState.investors[event.order.name] ??= { name: event.order.name, + created_at: nextState.updated_at, deposit: 0, share: 0, tax_threshold: 0, @@ -33,6 +34,8 @@ export const reduceState = (state: IFundState, event: IFundEvent): IFundState => investor.tax_threshold += deposit; investor.share += deposit / state.summary_derived.unit_price; nextState.total_assets += deposit; + const cashflow = (nextState.investor_cashflow[event.order.name] ??= []); + cashflow.push({ updated_at: nextState.updated_at, deposit }); } // 更新投资人信息 if (event.investor) { @@ -66,6 +69,8 @@ export const reduceState = (state: IFundState, event: IFundEvent): IFundState => // 投资人衍生数据 Object.values(nextState.investors).forEach((v) => { + const investor = state.investor_derived[v.name]; + const share_ratio = nextState.summary_derived.total_share !== 0 ? v.share / nextState.summary_derived.total_share : 0; const pre_tax_assets = v.share * nextState.summary_derived.unit_price; @@ -73,16 +78,40 @@ export const reduceState = (state: IFundState, event: IFundEvent): IFundState => const tax = Math.max(0, taxable * v.tax_rate); const after_tax_assets = pre_tax_assets - tax; const after_tax_profit = after_tax_assets - v.deposit; - // Assert: 税后收益和净入金不可能同时为负数 - const after_tax_profit_rate = after_tax_profit / (v.deposit >= 0 ? v.deposit : after_tax_assets); + const timed_assets = investor + ? investor.timed_assets + + ((after_tax_assets + investor.after_tax_assets) * (nextState.updated_at - state.updated_at)) / + 2 / + (365 * 86400_000) + : 0; + const holding_days = (nextState.updated_at - v.created_at) / 86400_000; + const avg_assets = (timed_assets / holding_days) * 365; + const after_tax_profit_rate = after_tax_profit / avg_assets; const after_tax_share = after_tax_assets / nextState.summary_derived.unit_price; + const cashflow = nextState.investor_cashflow[v.name]; + const after_tax_IRR = + nextState.updated_at - v.created_at > 0 + ? Math.pow( + 1 + + XIRR( + cashflow.concat({ + updated_at: nextState.updated_at, + deposit: -after_tax_assets, + }), + ), + holding_days / 365, + ) - 1 + : 0; nextState.investor_derived[v.name] = { + holding_days, share_ratio, taxable, tax, pre_tax_assets, after_tax_assets, after_tax_profit, + timed_assets, + after_tax_IRR, after_tax_profit_rate, after_tax_share, }; @@ -121,9 +150,45 @@ export const getInitFundState = (): IFundState => ({ }, investors: {}, investor_derived: {}, + investor_cashflow: {}, events: [], }); export const fromFundEvents = (events: IFundEvent[]): IFundState => { return events.reduce(reduceState, getInitFundState()); }; + +const XIRR = function xirr(cashflow: InvestorCashFlowItem[], guess = 0.05) { + // console.info('XIRR calculation started', cashflow); + const maxIterations = 100; + const tolerance = 1e-5; + + let x0 = guess; + let x1; + + for (let i = 0; i < maxIterations; i++) { + // console.info(`Iteration ${i}: ${x0}`); + let fValue = 0; + let fDerivative = 0; + + for (let j = 0; j < cashflow.length; j++) { + const t = (cashflow[j].updated_at - cashflow[0].updated_at) / (1000 * 60 * 60 * 24 * 365); // Convert milliseconds to years + const expTerm = Math.exp(-x0 * t); + fValue -= cashflow[j].deposit * expTerm; + fDerivative += cashflow[j].deposit * expTerm * t; + } + + fValue = fValue / cashflow.length; + fDerivative = fDerivative / cashflow.length; + + x1 = x0 - fValue / fDerivative; + + if (Math.abs(x1 - x0) < tolerance) { + return x1; + } + + x0 = x1; + } + + throw new Error('XIRR calculation did not converge'); +}; diff --git a/ui/web/src/modules/Workbench/HostList.tsx b/ui/web/src/modules/Workbench/HostList.tsx index 945ac265..9f9c021d 100644 --- a/ui/web/src/modules/Workbench/HostList.tsx +++ b/ui/web/src/modules/Workbench/HostList.tsx @@ -368,7 +368,7 @@ registerPage('HostList', () => { if (cryptoHosts$.value === undefined) return; const label = (await showForm({ type: 'string', title: 'Label' })) || ''; const keyPair = createKeyPair(); - const url = new URL(`https://hosts.ntnl.io`); + const url = new URL(`wss://hosts.ntnl.io`); url.searchParams.set('public_key', keyPair.public_key); const signature = signMessage('', keyPair.private_key); url.searchParams.set('signature', signature);