diff --git a/backend/package.json b/backend/package.json index bab1264..9b98b97 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "@family-budget/backend", - "version": "0.13.0", + "version": "0.14.0", "private": true, "scripts": { "dev": "tsx watch src/app.ts", @@ -12,6 +12,7 @@ "test:analytics:query": "tsx src/routes/analytics.test.ts", "test:portfolio": "tsx src/services/portfolio.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:transactions": "tsx src/services/transactions.test.ts", "test:analytics:db": "NODE_ENV=test tsx src/services/analytics.integration.test.ts", diff --git a/backend/src/app.ts b/backend/src/app.ts index f62874b..dc46e1c 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -22,6 +22,7 @@ import analyticsRouter from './routes/analytics'; import portfolioRouter from './routes/portfolio'; import portfolioOverviewRouter from './routes/portfolioOverview'; import portfolioHistoryRouter from './routes/portfolioHistory'; +import portfolioPerformanceRouter from './routes/portfolioPerformance'; const app = express(); app.set('trust proxy', 1); @@ -49,6 +50,7 @@ app.use('/api/analytics', analyticsRouter); app.use('/api/import/portfolio', portfolioRouter); app.use('/api/portfolio', portfolioOverviewRouter); app.use('/api/portfolio/history', portfolioHistoryRouter); +app.use('/api/portfolio/performance', portfolioPerformanceRouter); app.use( ( diff --git a/backend/src/routes/portfolioPerformance.ts b/backend/src/routes/portfolioPerformance.ts new file mode 100644 index 0000000..8f5cffc --- /dev/null +++ b/backend/src/routes/portfolioPerformance.ts @@ -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; diff --git a/backend/src/services/portfolio.ts b/backend/src/services/portfolio.ts index 1244ebc..c059f35 100644 --- a/backend/src/services/portfolio.ts +++ b/backend/src/services/portfolio.ts @@ -2,7 +2,7 @@ import crypto from 'crypto'; import type { PoolClient } from 'pg'; import { pool } from '../db/pool'; 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; @@ -29,6 +29,10 @@ type PortfolioHistoryRow = { 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 { if (value == null || value === '') { if (required) throw new Error(`${field} is required`); @@ -212,3 +216,80 @@ export async function getPortfolioHistory(): Promise { ); return toPortfolioHistory(rows); } + +type Lot = { quantity: number; cost: number }; + +export function calculatePortfolioTradeResults(trades: PerformanceTradeRow[], positions: PerformancePositionRow[]): Map { + const result = new Map(); + const lots = new Map(); + 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(); + 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 { + const [cashResult, tradeResult, positionResult] = await Promise.all([ + pool.query(`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(`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(`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(); + 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()] }; +} diff --git a/backend/src/services/portfolioOverview.test.ts b/backend/src/services/portfolioOverview.test.ts index 7b4ea78..64fce33 100644 --- a/backend/src/services/portfolioOverview.test.ts +++ b/backend/src/services/portfolioOverview.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { toPortfolioHistory, toPortfolioOverview } from './portfolio'; +import { calculatePortfolioTradeResults, toPortfolioHistory, toPortfolioOverview } from './portfolio'; 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' }, @@ -17,3 +17,12 @@ const history = toPortfolioHistory([ ]); assert.deepEqual(history.accounts[0].points.map((point) => point.totalValuation), ['100', '150.50']); 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'); diff --git a/frontend/package.json b/frontend/package.json index cd36830..4ffae18 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@family-budget/frontend", - "version": "0.14.0", + "version": "0.15.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/api/portfolio.ts b/frontend/src/api/portfolio.ts index a30583e..22d6840 100644 --- a/frontend/src/api/portfolio.ts +++ b/frontend/src/api/portfolio.ts @@ -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'; export function importPortfolio(data: PortfolioFile): Promise { @@ -18,3 +18,7 @@ export function getPortfolioOverview(): Promise { export function getPortfolioHistory(): Promise { return api.get('/api/portfolio/history'); } + +export function getPortfolioPerformance(): Promise { + return api.get('/api/portfolio/performance'); +} diff --git a/frontend/src/pages/PortfolioPage.tsx b/frontend/src/pages/PortfolioPage.tsx index 27c6ede..3acd78a 100644 --- a/frontend/src/pages/PortfolioPage.tsx +++ b/frontend/src/pages/PortfolioPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; -import type { PortfolioHistoryResponse, PortfolioOverviewResponse } from '@family-budget/shared'; -import { getPortfolioHistory, getPortfolioOverview } from '../api/portfolio'; +import type { PortfolioHistoryResponse, PortfolioOverviewResponse, PortfolioPerformanceResponse } from '@family-budget/shared'; +import { getPortfolioHistory, getPortfolioOverview, getPortfolioPerformance } from '../api/portfolio'; import { formatDate } from '../utils/format'; 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() { const [data, setData] = useState(null); const [history, setHistory] = useState(null); + const [performance, setPerformance] = useState(null); useEffect(() => { getPortfolioOverview().then(setData).catch(() => {}); getPortfolioHistory().then(setHistory).catch(() => {}); + getPortfolioPerformance().then(setPerformance).catch(() => {}); }, []); return ( @@ -75,6 +77,20 @@ export function PortfolioPage() { ))} + + {performance?.accounts.map((account) => ( +
+

Результат · {account.accountName}

+
+
Пополнения
{money.format(account.contributions / 100)}
+
Выводы
{money.format(account.withdrawals / 100)}
+
Купоны и дивиденды
{money.format(account.income / 100)}
+
Комиссии
{money.format(account.fees / 100)}
+
Результат продаж
{money.format(account.realizedResult / 100)}
+ {account.unrealizedResult !== null &&
Нереализованный результат
{money.format(account.unrealizedResult / 100)}
} +
+
+ ))} ); } diff --git a/shared/package.json b/shared/package.json index f719d61..f0abe09 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@family-budget/shared", - "version": "0.8.0", + "version": "0.9.0", "private": true, "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/shared/src/types/import.ts b/shared/src/types/import.ts index 123a14a..7e3e6d4 100644 --- a/shared/src/types/import.ts +++ b/shared/src/types/import.ts @@ -133,3 +133,18 @@ export interface PortfolioHistoryAccount { export interface PortfolioHistoryResponse { 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[]; +} diff --git a/shared/src/types/index.ts b/shared/src/types/index.ts index aea7908..94f18f5 100644 --- a/shared/src/types/index.ts +++ b/shared/src/types/index.ts @@ -50,6 +50,8 @@ export type { PortfolioHistoryPoint, PortfolioHistoryAccount, PortfolioHistoryResponse, + PortfolioPerformanceAccount, + PortfolioPerformanceResponse, } from './import'; export type {