feat: add broker investment performance
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@family-budget/backend",
|
"name": "@family-budget/backend",
|
||||||
"version": "0.13.0",
|
"version": "0.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/app.ts",
|
"dev": "tsx watch src/app.ts",
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"test:analytics:query": "tsx src/routes/analytics.test.ts",
|
"test:analytics:query": "tsx src/routes/analytics.test.ts",
|
||||||
"test:portfolio": "tsx src/services/portfolio.test.ts",
|
"test:portfolio": "tsx src/services/portfolio.test.ts",
|
||||||
"test:portfolio:overview": "tsx src/services/portfolioOverview.test.ts",
|
"test:portfolio:overview": "tsx src/services/portfolioOverview.test.ts",
|
||||||
|
"test:portfolio:performance": "tsx src/services/portfolioOverview.test.ts",
|
||||||
"test:portfolio:db": "NODE_ENV=test tsx src/services/portfolio.integration.test.ts",
|
"test:portfolio:db": "NODE_ENV=test tsx src/services/portfolio.integration.test.ts",
|
||||||
"test:transactions": "tsx src/services/transactions.test.ts",
|
"test:transactions": "tsx src/services/transactions.test.ts",
|
||||||
"test:analytics:db": "NODE_ENV=test tsx src/services/analytics.integration.test.ts",
|
"test:analytics:db": "NODE_ENV=test tsx src/services/analytics.integration.test.ts",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import analyticsRouter from './routes/analytics';
|
|||||||
import portfolioRouter from './routes/portfolio';
|
import portfolioRouter from './routes/portfolio';
|
||||||
import portfolioOverviewRouter from './routes/portfolioOverview';
|
import portfolioOverviewRouter from './routes/portfolioOverview';
|
||||||
import portfolioHistoryRouter from './routes/portfolioHistory';
|
import portfolioHistoryRouter from './routes/portfolioHistory';
|
||||||
|
import portfolioPerformanceRouter from './routes/portfolioPerformance';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.set('trust proxy', 1);
|
app.set('trust proxy', 1);
|
||||||
@@ -49,6 +50,7 @@ app.use('/api/analytics', analyticsRouter);
|
|||||||
app.use('/api/import/portfolio', portfolioRouter);
|
app.use('/api/import/portfolio', portfolioRouter);
|
||||||
app.use('/api/portfolio', portfolioOverviewRouter);
|
app.use('/api/portfolio', portfolioOverviewRouter);
|
||||||
app.use('/api/portfolio/history', portfolioHistoryRouter);
|
app.use('/api/portfolio/history', portfolioHistoryRouter);
|
||||||
|
app.use('/api/portfolio/performance', portfolioPerformanceRouter);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
(
|
(
|
||||||
|
|||||||
9
backend/src/routes/portfolioPerformance.ts
Normal file
9
backend/src/routes/portfolioPerformance.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { asyncHandler } from '../utils';
|
||||||
|
import { getPortfolioPerformance } from '../services/portfolio';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.get('/', asyncHandler(async (_req, res) => {
|
||||||
|
res.json(await getPortfolioPerformance());
|
||||||
|
}));
|
||||||
|
export default router;
|
||||||
@@ -2,7 +2,7 @@ import crypto from 'crypto';
|
|||||||
import type { PoolClient } from 'pg';
|
import type { PoolClient } from 'pg';
|
||||||
import { pool } from '../db/pool';
|
import { pool } from '../db/pool';
|
||||||
import { maskAccountNumber } from '../utils';
|
import { maskAccountNumber } from '../utils';
|
||||||
import type { ImportPortfolioResponse, PortfolioFile, PortfolioHistoryResponse, PortfolioOverviewResponse, PortfolioTrade } from '@family-budget/shared';
|
import type { ImportPortfolioResponse, PortfolioFile, PortfolioHistoryResponse, PortfolioOverviewResponse, PortfolioPerformanceResponse, PortfolioTrade } from '@family-budget/shared';
|
||||||
|
|
||||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
@@ -29,6 +29,10 @@ type PortfolioHistoryRow = {
|
|||||||
total_valuation: string | number | null;
|
total_valuation: string | number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PerformanceCashRow = { account_id: number | string; alias: string | null; bank: string; account_number: string; amount_signed: number | string; description: string };
|
||||||
|
type PerformanceTradeRow = { account_id: number | string; side: string; quantity: string | number; settlement_amount: string | number | null; settlement_commission: string | number | null; trade_commission: string | number | null; isin: string | null; instrument: string };
|
||||||
|
type PerformancePositionRow = { account_id: number | string; quantity: string | number; valuation: string | number | null; isin: string | null; instrument: string };
|
||||||
|
|
||||||
function decimalValue(value: unknown, field: string, required = false): string | null {
|
function decimalValue(value: unknown, field: string, required = false): string | null {
|
||||||
if (value == null || value === '') {
|
if (value == null || value === '') {
|
||||||
if (required) throw new Error(`${field} is required`);
|
if (required) throw new Error(`${field} is required`);
|
||||||
@@ -212,3 +216,80 @@ export async function getPortfolioHistory(): Promise<PortfolioHistoryResponse> {
|
|||||||
);
|
);
|
||||||
return toPortfolioHistory(rows);
|
return toPortfolioHistory(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Lot = { quantity: number; cost: number };
|
||||||
|
|
||||||
|
export function calculatePortfolioTradeResults(trades: PerformanceTradeRow[], positions: PerformancePositionRow[]): Map<number, { fees: number; realized: number; unrealized: number | null }> {
|
||||||
|
const result = new Map<number, { fees: number; realized: number; unrealized: number | null }>();
|
||||||
|
const lots = new Map<string, Lot[]>();
|
||||||
|
for (const trade of trades) {
|
||||||
|
const accountId = Number(trade.account_id);
|
||||||
|
const current = result.get(accountId) ?? { fees: 0, realized: 0, unrealized: null };
|
||||||
|
const quantity = Number(trade.quantity);
|
||||||
|
const amount = Number(trade.settlement_amount ?? 0);
|
||||||
|
const fees = Number(trade.settlement_commission ?? 0) + Number(trade.trade_commission ?? 0);
|
||||||
|
current.fees += fees;
|
||||||
|
const key = `${accountId}:${trade.isin ?? trade.instrument}`;
|
||||||
|
const queue = lots.get(key) ?? [];
|
||||||
|
if (/продаж/i.test(trade.side)) {
|
||||||
|
let remaining = quantity;
|
||||||
|
let matchedCost = 0;
|
||||||
|
while (remaining > 0 && queue.length > 0) {
|
||||||
|
const lot = queue[0];
|
||||||
|
const used = Math.min(remaining, lot.quantity);
|
||||||
|
const unitCost = lot.cost / lot.quantity;
|
||||||
|
matchedCost += used * unitCost;
|
||||||
|
lot.cost -= used * unitCost;
|
||||||
|
lot.quantity -= used;
|
||||||
|
remaining -= used;
|
||||||
|
if (lot.quantity <= 0) queue.shift();
|
||||||
|
}
|
||||||
|
const matched = quantity - remaining;
|
||||||
|
current.realized += matched > 0 ? (amount - fees) * (matched / quantity) - matchedCost : 0;
|
||||||
|
} else if (/покуп/i.test(trade.side) && quantity > 0) {
|
||||||
|
queue.push({ quantity, cost: amount + fees });
|
||||||
|
}
|
||||||
|
lots.set(key, queue);
|
||||||
|
result.set(accountId, current);
|
||||||
|
}
|
||||||
|
const positionCost = new Map<string, number>();
|
||||||
|
for (const [key, queue] of lots) positionCost.set(key, queue.reduce((sum, lot) => sum + lot.cost, 0));
|
||||||
|
for (const position of positions) {
|
||||||
|
const accountId = Number(position.account_id);
|
||||||
|
const current = result.get(accountId) ?? { fees: 0, realized: 0, unrealized: 0 };
|
||||||
|
if (position.valuation !== null) {
|
||||||
|
const key = `${accountId}:${position.isin ?? position.instrument}`;
|
||||||
|
current.unrealized = (current.unrealized ?? 0) + Number(position.valuation) - (positionCost.get(key) ?? 0);
|
||||||
|
}
|
||||||
|
result.set(accountId, current);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPortfolioPerformance(): Promise<PortfolioPerformanceResponse> {
|
||||||
|
const [cashResult, tradeResult, positionResult] = await Promise.all([
|
||||||
|
pool.query<PerformanceCashRow>(`SELECT a.id AS account_id, a.alias, a.bank, a.account_number, t.amount_signed, t.description FROM accounts a JOIN transactions t ON t.account_id = a.id WHERE a.account_type IN ('brokerage', 'iis') ORDER BY a.id, t.operation_at, t.id`),
|
||||||
|
pool.query<PerformanceTradeRow>(`SELECT account_id, side, quantity, settlement_amount, settlement_commission, trade_commission, isin, instrument FROM portfolio_trades ORDER BY account_id, concluded_at, id`),
|
||||||
|
pool.query<PerformancePositionRow>(`SELECT r.account_id, p.quantity, p.valuation, p.isin, p.instrument FROM portfolio_reports r JOIN portfolio_positions p ON p.report_id = r.id JOIN LATERAL (SELECT id FROM portfolio_reports WHERE account_id = r.account_id ORDER BY report_period_to DESC, imported_at DESC, id DESC LIMIT 1) latest ON latest.id = r.id`),
|
||||||
|
]);
|
||||||
|
const tradeMap = calculatePortfolioTradeResults(tradeResult.rows, positionResult.rows);
|
||||||
|
const accounts = new Map<number, PortfolioPerformanceResponse['accounts'][number]>();
|
||||||
|
for (const row of cashResult.rows) {
|
||||||
|
const accountId = Number(row.account_id);
|
||||||
|
const account = accounts.get(accountId) ?? { accountId, accountName: row.alias || `${row.bank} · ${maskAccountNumber(row.account_number)}`, contributions: 0, withdrawals: 0, income: 0, fees: 0, realizedResult: 0, unrealizedResult: null };
|
||||||
|
const amount = Number(row.amount_signed);
|
||||||
|
const description = row.description.toLowerCase();
|
||||||
|
if (amount > 0 && /дивиденд|купон|процент/.test(description)) account.income += amount;
|
||||||
|
else if (amount > 0 && description.includes('зачисление денежных средств')) account.contributions += amount;
|
||||||
|
else if (amount < 0 && /вывод денежных средств|вывод дс/.test(description) && !description.includes('под нерассчитанные сделки')) account.withdrawals += -amount;
|
||||||
|
accounts.set(accountId, account);
|
||||||
|
}
|
||||||
|
for (const [accountId, values] of tradeMap) {
|
||||||
|
const account = accounts.get(accountId) ?? { accountId, accountName: `Счёт ${accountId}`, contributions: 0, withdrawals: 0, income: 0, fees: 0, realizedResult: 0, unrealizedResult: null };
|
||||||
|
account.fees = Math.round(values.fees * 100);
|
||||||
|
account.realizedResult = Math.round(values.realized * 100);
|
||||||
|
account.unrealizedResult = values.unrealized === null ? null : Math.round(values.unrealized * 100);
|
||||||
|
accounts.set(accountId, account);
|
||||||
|
}
|
||||||
|
return { accounts: [...accounts.values()] };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { toPortfolioHistory, toPortfolioOverview } from './portfolio';
|
import { calculatePortfolioTradeResults, toPortfolioHistory, toPortfolioOverview } from './portfolio';
|
||||||
|
|
||||||
const result = toPortfolioOverview([
|
const result = toPortfolioOverview([
|
||||||
{ account_id: '1', alias: 'ИИС', bank: 'ВТБ', account_number: '123456', report_period_to: '2026-08-26', total_valuation: '150.50', instrument: 'Облигация', isin: 'RU0000000001', quantity: '1', price: '100', valuation: '100' },
|
{ account_id: '1', alias: 'ИИС', bank: 'ВТБ', account_number: '123456', report_period_to: '2026-08-26', total_valuation: '150.50', instrument: 'Облигация', isin: 'RU0000000001', quantity: '1', price: '100', valuation: '100' },
|
||||||
@@ -17,3 +17,12 @@ const history = toPortfolioHistory([
|
|||||||
]);
|
]);
|
||||||
assert.deepEqual(history.accounts[0].points.map((point) => point.totalValuation), ['100', '150.50']);
|
assert.deepEqual(history.accounts[0].points.map((point) => point.totalValuation), ['100', '150.50']);
|
||||||
console.log('portfolio history: OK');
|
console.log('portfolio history: OK');
|
||||||
|
|
||||||
|
const tradeResults = calculatePortfolioTradeResults([
|
||||||
|
{ account_id: 1, side: 'Покупка', quantity: '2', settlement_amount: '200', settlement_commission: '1', trade_commission: '1', isin: 'RU1', instrument: 'Фонд' },
|
||||||
|
{ account_id: 1, side: 'Продажа', quantity: '1', settlement_amount: '150', settlement_commission: '1', trade_commission: '0', isin: 'RU1', instrument: 'Фонд' },
|
||||||
|
], [{ account_id: 1, quantity: '1', valuation: '130', isin: 'RU1', instrument: 'Фонд' }]);
|
||||||
|
assert.equal(tradeResults.get(1)?.fees, 3);
|
||||||
|
assert.equal(tradeResults.get(1)?.realized, 48);
|
||||||
|
assert.equal(tradeResults.get(1)?.unrealized, 29);
|
||||||
|
console.log('portfolio performance: OK');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@family-budget/frontend",
|
"name": "@family-budget/frontend",
|
||||||
"version": "0.14.0",
|
"version": "0.15.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ImportBrokerReportResponse, ImportPortfolioResponse, PortfolioFile, PortfolioHistoryResponse, PortfolioOverviewResponse } from '@family-budget/shared';
|
import type { ImportBrokerReportResponse, ImportPortfolioResponse, PortfolioFile, PortfolioHistoryResponse, PortfolioOverviewResponse, PortfolioPerformanceResponse } from '@family-budget/shared';
|
||||||
import { api } from './client';
|
import { api } from './client';
|
||||||
|
|
||||||
export function importPortfolio(data: PortfolioFile): Promise<ImportPortfolioResponse> {
|
export function importPortfolio(data: PortfolioFile): Promise<ImportPortfolioResponse> {
|
||||||
@@ -18,3 +18,7 @@ export function getPortfolioOverview(): Promise<PortfolioOverviewResponse> {
|
|||||||
export function getPortfolioHistory(): Promise<PortfolioHistoryResponse> {
|
export function getPortfolioHistory(): Promise<PortfolioHistoryResponse> {
|
||||||
return api.get('/api/portfolio/history');
|
return api.get('/api/portfolio/history');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPortfolioPerformance(): Promise<PortfolioPerformanceResponse> {
|
||||||
|
return api.get('/api/portfolio/performance');
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
import type { PortfolioHistoryResponse, PortfolioOverviewResponse } from '@family-budget/shared';
|
import type { PortfolioHistoryResponse, PortfolioOverviewResponse, PortfolioPerformanceResponse } from '@family-budget/shared';
|
||||||
import { getPortfolioHistory, getPortfolioOverview } from '../api/portfolio';
|
import { getPortfolioHistory, getPortfolioOverview, getPortfolioPerformance } from '../api/portfolio';
|
||||||
import { formatDate } from '../utils/format';
|
import { formatDate } from '../utils/format';
|
||||||
|
|
||||||
const money = new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB', minimumFractionDigits: 2 });
|
const money = new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB', minimumFractionDigits: 2 });
|
||||||
@@ -10,10 +10,12 @@ const number = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 6 });
|
|||||||
export function PortfolioPage() {
|
export function PortfolioPage() {
|
||||||
const [data, setData] = useState<PortfolioOverviewResponse | null>(null);
|
const [data, setData] = useState<PortfolioOverviewResponse | null>(null);
|
||||||
const [history, setHistory] = useState<PortfolioHistoryResponse | null>(null);
|
const [history, setHistory] = useState<PortfolioHistoryResponse | null>(null);
|
||||||
|
const [performance, setPerformance] = useState<PortfolioPerformanceResponse | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getPortfolioOverview().then(setData).catch(() => {});
|
getPortfolioOverview().then(setData).catch(() => {});
|
||||||
getPortfolioHistory().then(setHistory).catch(() => {});
|
getPortfolioHistory().then(setHistory).catch(() => {});
|
||||||
|
getPortfolioPerformance().then(setPerformance).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -75,6 +77,20 @@ export function PortfolioPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{performance?.accounts.map((account) => (
|
||||||
|
<section className="portfolio" key={`performance-${account.accountId}`} aria-labelledby={`performance-${account.accountId}`}>
|
||||||
|
<h2 id={`performance-${account.accountId}`} className="portfolio__title">Результат · {account.accountName}</h2>
|
||||||
|
<div className="summary portfolio__performance">
|
||||||
|
<div className="summary__card summary__card--investments"><div className="summary__label">Пополнения</div><div className="summary__value">{money.format(account.contributions / 100)}</div></div>
|
||||||
|
<div className="summary__card summary__card--investments"><div className="summary__label">Выводы</div><div className="summary__value">{money.format(account.withdrawals / 100)}</div></div>
|
||||||
|
<div className="summary__card summary__card--income"><div className="summary__label">Купоны и дивиденды</div><div className="summary__value">{money.format(account.income / 100)}</div></div>
|
||||||
|
<div className="summary__card summary__card--expense"><div className="summary__label">Комиссии</div><div className="summary__value">{money.format(account.fees / 100)}</div></div>
|
||||||
|
<div className="summary__card summary__card--positive"><div className="summary__label">Результат продаж</div><div className="summary__value">{money.format(account.realizedResult / 100)}</div></div>
|
||||||
|
{account.unrealizedResult !== null && <div className="summary__card summary__card--positive"><div className="summary__label">Нереализованный результат</div><div className="summary__value">{money.format(account.unrealizedResult / 100)}</div></div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@family-budget/shared",
|
"name": "@family-budget/shared",
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|||||||
@@ -133,3 +133,18 @@ export interface PortfolioHistoryAccount {
|
|||||||
export interface PortfolioHistoryResponse {
|
export interface PortfolioHistoryResponse {
|
||||||
accounts: PortfolioHistoryAccount[];
|
accounts: PortfolioHistoryAccount[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PortfolioPerformanceAccount {
|
||||||
|
accountId: number;
|
||||||
|
accountName: string;
|
||||||
|
contributions: number;
|
||||||
|
withdrawals: number;
|
||||||
|
income: number;
|
||||||
|
fees: number;
|
||||||
|
realizedResult: number;
|
||||||
|
unrealizedResult: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortfolioPerformanceResponse {
|
||||||
|
accounts: PortfolioPerformanceAccount[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export type {
|
|||||||
PortfolioHistoryPoint,
|
PortfolioHistoryPoint,
|
||||||
PortfolioHistoryAccount,
|
PortfolioHistoryAccount,
|
||||||
PortfolioHistoryResponse,
|
PortfolioHistoryResponse,
|
||||||
|
PortfolioPerformanceAccount,
|
||||||
|
PortfolioPerformanceResponse,
|
||||||
} from './import';
|
} from './import';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
Reference in New Issue
Block a user