Compare commits

..

16 Commits

Author SHA1 Message Date
be1c2cc701 docs: note broker income label 2026-08-27 08:43:25 +03:00
b94340823b fix: label broker income metrics 2026-08-27 08:42:47 +03:00
c69e55e045 docs: note portfolio API test 2026-08-27 08:40:13 +03:00
94506e38c2 test: cover portfolio UI API contract 2026-08-27 08:39:20 +03:00
19d3c36c9e docs: note portfolio overlap coverage 2026-08-27 08:36:23 +03:00
491a51cfb8 test: cover overlapping portfolio imports 2026-08-27 08:34:49 +03:00
5adbcbd7bd docs: note broker performance 2026-08-27 08:31:04 +03:00
c82b26acf9 fix: avoid incomplete unrealized result 2026-08-27 08:29:22 +03:00
ba2ed6a049 feat: add broker investment performance 2026-08-27 08:27:40 +03:00
4d9663a906 Merge pull request 'feat: добавить динамику стоимости портфеля' (#48) from feature/portfolio-history into main
Reviewed-on: #48
2026-08-27 05:22:35 +00:00
e0723b3ab6 docs: note portfolio history 2026-08-27 08:19:55 +03:00
29ba82f4bf feat: add portfolio valuation history 2026-08-27 08:17:33 +03:00
5d478ff06c Merge pull request 'feat: обзор последнего брокерского портфеля' (#47) from feature/portfolio-overview into main
Reviewed-on: #47
2026-08-27 05:12:18 +00:00
e7233d85ee docs: note portfolio overview 2026-08-27 08:10:21 +03:00
b38346dd19 feat: add portfolio overview 2026-08-27 00:07:25 +03:00
1e25a0152a Merge pull request 'Добавить загрузку XLSX-отчёта ВТБ Брокер' (#46) from feature/broker-xlsx-upload into main
Reviewed-on: #46
2026-08-26 20:59:15 +00:00
19 changed files with 567 additions and 5 deletions

View File

@@ -1,5 +1,41 @@
# Changelog
## [Frontend 0.15.2] - 2026-08-27
### Fixed
- Clarified that the broker income metric includes coupons, dividends, and interest.
## [Frontend 0.15.1] - 2026-08-27
### Added
- Added a frontend API contract check for portfolio overview, history, and performance requests.
## [Backend 0.14.1] - 2026-08-27
### Added
- Added SQL coverage for repeated and overlapping broker portfolio report imports.
## [Frontend 0.15.0 / Backend 0.14.0 / Shared 0.9.0] - 2026-08-27
### Added
- Added separate broker investment performance metrics: contributions, withdrawals, income, fees, realized and unrealized results.
## [Frontend 0.14.0 / Backend 0.13.0 / Shared 0.8.0] - 2026-08-27
### Added
- Added portfolio valuation history by imported broker report dates.
## [Frontend 0.13.0 / Backend 0.12.0 / Shared 0.7.0] - 2026-08-27
### Added
- Added a separate portfolio view with the latest broker and IIS positions and total valuation.
## [Frontend 0.12.0 / Backend 0.11.0 / Shared 0.6.0] - 2026-08-26
### Added

View File

@@ -1,6 +1,6 @@
{
"name": "@family-budget/backend",
"version": "0.11.0",
"version": "0.14.1",
"private": true,
"scripts": {
"dev": "tsx watch src/app.ts",
@@ -11,6 +11,8 @@
"test:analytics": "tsx src/services/analyticsSemantics.test.ts",
"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",

View File

@@ -20,6 +20,9 @@ import categoriesRouter from './routes/categories';
import categoryRulesRouter from './routes/categoryRules';
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);
@@ -45,6 +48,9 @@ app.use('/api/categories', categoriesRouter);
app.use('/api/category-rules', categoryRulesRouter);
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(
(

View File

@@ -0,0 +1,14 @@
import { Router } from 'express';
import { asyncHandler } from '../utils';
import { getPortfolioHistory } from '../services/portfolio';
const router = Router();
router.get(
'/',
asyncHandler(async (_req, res) => {
res.json(await getPortfolioHistory());
}),
);
export default router;

View File

@@ -0,0 +1,14 @@
import { Router } from 'express';
import { asyncHandler } from '../utils';
import { getPortfolioOverview } from '../services/portfolio';
const router = Router();
router.get(
'/',
asyncHandler(async (_req, res) => {
res.json(await getPortfolioOverview());
}),
);
export default router;

View 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;

View File

@@ -8,6 +8,12 @@ const payload = {
positions: [{ instrument: 'Test Bond', isin: 'RU0000000001', quantity: '1.000', price: '100.123456', valuation: '100.123456' }],
trades: [{ instrument: 'Test Bond', isin: 'RU0000000001', concludedAt: '2026-08-10T10:00:00+03:00', side: 'Покупка', quantity: '1.000', settlementAmount: '100.123456' }],
};
const overlappingPayload = {
...payload,
reportPeriod: { from: '2026-08-10', to: '2026-08-25' },
positions: [{ ...payload.positions[0], valuation: '120.000000' }],
trades: [{ ...payload.trades[0], sourceId: 'second-trade', concludedAt: '2026-08-15T10:00:00+03:00', side: 'Продажа', quantity: '0.500', settlementAmount: '60.000000' }],
};
async function run(): Promise<void> {
try {
@@ -17,6 +23,12 @@ async function run(): Promise<void> {
assert.equal(second.duplicateTrades, 1);
const rows = await pool.query('SELECT COUNT(*)::int AS count FROM portfolio_trades WHERE account_id = $1', [first.accountId]);
assert.equal(rows.rows[0].count, 1);
const overlapping = await importPortfolio(overlappingPayload);
assert.equal(overlapping.importedTrades, 1);
const reports = await pool.query('SELECT COUNT(*)::int AS count FROM portfolio_reports WHERE account_id = $1', [first.accountId]);
assert.equal(reports.rows[0].count, 2);
const trades = await pool.query('SELECT COUNT(*)::int AS count FROM portfolio_trades WHERE account_id = $1', [first.accountId]);
assert.equal(trades.rows[0].count, 2);
console.log('portfolio import SQL: OK');
} finally {
await pool.query("DELETE FROM portfolio_trades WHERE account_id IN (SELECT id FROM accounts WHERE bank = 'PORTFOLIO_TEST' AND account_number = 'portfolio-test')");

View File

@@ -1,10 +1,38 @@
import crypto from 'crypto';
import type { PoolClient } from 'pg';
import { pool } from '../db/pool';
import type { ImportPortfolioResponse, PortfolioFile, PortfolioTrade } from '@family-budget/shared';
import { maskAccountNumber } from '../utils';
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;
type PortfolioOverviewRow = {
account_id: number | string;
alias: string | null;
bank: string;
account_number: string;
report_period_to: string;
total_valuation: string | number | null;
instrument: string | null;
isin: string | null;
quantity: string | number | null;
price: string | number | null;
valuation: string | number | null;
};
type PortfolioHistoryRow = {
account_id: number | string;
alias: string | null;
bank: string;
account_number: string;
report_period_to: string;
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`);
@@ -103,3 +131,178 @@ export async function importPortfolio(body: unknown, db: Pick<typeof pool, 'conn
if (ownsTransaction) client.release();
}
}
export function toPortfolioOverview(rows: PortfolioOverviewRow[]): PortfolioOverviewResponse {
const accounts = new Map<number, PortfolioOverviewResponse['accounts'][number]>();
for (const row of rows) {
const accountId = Number(row.account_id);
let account = accounts.get(accountId);
if (!account) {
account = {
accountId,
accountName: row.alias || `${row.bank} · ${maskAccountNumber(row.account_number)}`,
reportPeriodTo: row.report_period_to,
totalValuation: row.total_valuation === null ? null : String(row.total_valuation),
positions: [],
};
accounts.set(accountId, account);
}
if (row.instrument !== null) {
const valuation = row.valuation === null ? null : String(row.valuation);
account.positions.push({
instrument: row.instrument,
isin: row.isin,
quantity: String(row.quantity),
price: row.price === null ? null : String(row.price),
valuation,
});
}
}
return { accounts: [...accounts.values()] };
}
export async function getPortfolioOverview(): Promise<PortfolioOverviewResponse> {
const { rows } = await pool.query<PortfolioOverviewRow>(
`SELECT a.id AS account_id, a.alias, a.bank, a.account_number,
r.report_period_to,
SUM(p.valuation) OVER (PARTITION BY a.id) AS total_valuation,
p.instrument, p.isin, p.quantity, p.price, p.valuation
FROM accounts a
JOIN LATERAL (
SELECT id, report_period_to, reported_at, imported_at
FROM portfolio_reports
WHERE account_id = a.id
ORDER BY report_period_to DESC, imported_at DESC, id DESC
LIMIT 1
) r ON TRUE
LEFT JOIN portfolio_positions p ON p.report_id = r.id
WHERE a.account_type IN ('brokerage', 'iis')
ORDER BY a.id, p.valuation DESC NULLS LAST, p.id`,
);
return toPortfolioOverview(rows);
}
export function toPortfolioHistory(rows: PortfolioHistoryRow[]): PortfolioHistoryResponse {
const accounts = new Map<number, PortfolioHistoryResponse['accounts'][number]>();
for (const row of rows) {
const accountId = Number(row.account_id);
let account = accounts.get(accountId);
if (!account) {
account = {
accountId,
accountName: row.alias || `${row.bank} · ${maskAccountNumber(row.account_number)}`,
points: [],
};
accounts.set(accountId, account);
}
account.points.push({
reportPeriodTo: row.report_period_to,
totalValuation: row.total_valuation === null ? null : String(row.total_valuation),
});
}
return { accounts: [...accounts.values()] };
}
export async function getPortfolioHistory(): Promise<PortfolioHistoryResponse> {
const { rows } = await pool.query<PortfolioHistoryRow>(
`SELECT a.id AS account_id, a.alias, a.bank, a.account_number,
r.report_period_to, SUM(p.valuation) AS total_valuation
FROM accounts a
JOIN portfolio_reports r ON r.account_id = a.id
LEFT JOIN portfolio_positions p ON p.report_id = r.id
WHERE a.account_type IN ('brokerage', 'iis')
GROUP BY a.id, a.alias, a.bank, a.account_number, r.id, r.report_period_to, r.reported_at, r.imported_at
ORDER BY a.id, r.report_period_to, r.reported_at, r.imported_at, r.id`,
);
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 incomplete = new Set<number>();
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}`;
const queue = lots.get(key) ?? [];
let remaining = Number(position.quantity);
let cost = 0;
for (const lot of queue) {
if (remaining <= 0) break;
const used = Math.min(remaining, lot.quantity);
cost += used * (lot.cost / lot.quantity);
remaining -= used;
}
if (remaining > 0.0000001) incomplete.add(accountId);
else if (!incomplete.has(accountId)) current.unrealized = (current.unrealized ?? 0) + Number(position.valuation) - cost;
}
result.set(accountId, current);
}
for (const accountId of incomplete) {
const current = result.get(accountId);
if (current) current.unrealized = null;
}
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()] };
}

View File

@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
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' },
{ account_id: '1', alias: 'ИИС', bank: 'ВТБ', account_number: '123456', report_period_to: '2026-08-26', total_valuation: '150.50', instrument: 'Фонд', isin: null, quantity: '2', price: '25.25', valuation: '50.50' },
]);
assert.equal(result.accounts[0].accountName, 'ИИС');
assert.equal(result.accounts[0].totalValuation, '150.50');
assert.equal(result.accounts[0].positions.length, 2);
console.log('portfolio overview: OK');
const history = toPortfolioHistory([
{ account_id: '1', alias: 'ИИС', bank: 'ВТБ', account_number: '123456', report_period_to: '2026-08-20', total_valuation: '100' },
{ account_id: '1', alias: 'ИИС', bank: 'ВТБ', account_number: '123456', report_period_to: '2026-08-26', total_valuation: '150.50' },
]);
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');

View File

@@ -1,11 +1,12 @@
{
"name": "@family-budget/frontend",
"version": "0.12.0",
"version": "0.15.2",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test:portfolio": "tsx src/api/portfolio.test.ts",
"preview": "vite preview"
},
"dependencies": {

View File

@@ -5,6 +5,7 @@ import { LoginPage } from './pages/LoginPage';
import { HistoryPage } from './pages/HistoryPage';
import { AnalyticsPage } from './pages/AnalyticsPage';
import { SettingsPage } from './pages/SettingsPage';
import { PortfolioPage } from './pages/PortfolioPage';
export function App() {
const { user, loading } = useAuth();
@@ -23,6 +24,7 @@ export function App() {
<Route path="/" element={<Navigate to="/history" replace />} />
<Route path="/history" element={<HistoryPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/portfolio" element={<PortfolioPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/history" replace />} />
</Routes>

View File

@@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import { getPortfolioHistory, getPortfolioOverview, getPortfolioPerformance } from './portfolio';
const calls: string[] = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input) => {
calls.push(String(input));
return new Response(JSON.stringify({ accounts: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
try {
await Promise.all([getPortfolioOverview(), getPortfolioHistory(), getPortfolioPerformance()]);
assert.deepEqual(calls.sort(), ['/api/portfolio', '/api/portfolio/history', '/api/portfolio/performance']);
console.log('portfolio API contract: OK');
} finally {
globalThis.fetch = originalFetch;
}

View File

@@ -1,4 +1,4 @@
import type { ImportBrokerReportResponse, ImportPortfolioResponse, PortfolioFile } 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<ImportPortfolioResponse> {
@@ -10,3 +10,15 @@ export function importBrokerReport(file: File): Promise<ImportBrokerReportRespon
formData.append('file', file);
return api.postFormData('/api/import/broker', formData);
}
export function getPortfolioOverview(): Promise<PortfolioOverviewResponse> {
return api.get('/api/portfolio');
}
export function getPortfolioHistory(): Promise<PortfolioHistoryResponse> {
return api.get('/api/portfolio/history');
}
export function getPortfolioPerformance(): Promise<PortfolioPerformanceResponse> {
return api.get('/api/portfolio/performance');
}

View File

@@ -63,6 +63,21 @@ export function Layout({ children }: { children: ReactNode }) {
Операции
</NavLink>
<NavLink
to="/portfolio"
className={({ isActive }) =>
`sidebar__nav-link${isActive ? ' sidebar__nav-link--active' : ''}`
}
onClick={closeDrawer}
>
<svg className="sidebar__nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 21h18" />
<path d="M5 21V10l7-5 7 5v11" />
<path d="M9 21v-6h6v6" />
</svg>
Портфель
</NavLink>
<NavLink
to="/analytics"
className={({ isActive }) =>

View File

@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
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 });
const number = new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 6 });
export function PortfolioPage() {
const [data, setData] = useState<PortfolioOverviewResponse | null>(null);
const [history, setHistory] = useState<PortfolioHistoryResponse | null>(null);
const [performance, setPerformance] = useState<PortfolioPerformanceResponse | null>(null);
useEffect(() => {
getPortfolioOverview().then(setData).catch(() => {});
getPortfolioHistory().then(setHistory).catch(() => {});
getPortfolioPerformance().then(setPerformance).catch(() => {});
}, []);
return (
<div className="page">
<div className="page__header">
<div>
<p className="page__eyebrow">Инвестиции</p>
<h1 className="page__title">Портфель</h1>
</div>
</div>
{!data ? <div className="state">Загрузка...</div> : data.accounts.length === 0 ? (
<div className="state state--empty">Портфель пока пуст. Загрузите XLSX-отчёт брокера в разделе «Операции».</div>
) : data.accounts.map((account) => (
<section className="portfolio" key={account.accountId} aria-labelledby={`portfolio-${account.accountId}`}>
<div className="portfolio__header">
<div>
<h2 id={`portfolio-${account.accountId}`} className="portfolio__title">{account.accountName}</h2>
<p className="portfolio__meta">Состояние на {formatDate(account.reportPeriodTo)}</p>
</div>
{account.totalValuation !== null && <strong className="portfolio__total">{money.format(Number(account.totalValuation))}</strong>}
</div>
{account.positions.length === 0 ? <div className="state state--empty">В последнем отчёте нет открытых позиций.</div> : (
<div className="table-shell">
<table className="data-table">
<thead><tr><th className="data-table__head-cell" scope="col">Инструмент</th><th className="data-table__head-cell" scope="col">Количество</th><th className="data-table__head-cell" scope="col">Цена</th><th className="data-table__head-cell" scope="col">Стоимость</th></tr></thead>
<tbody className="data-table__body">
{account.positions.map((position, index) => <tr className="data-table__row" key={`${position.isin ?? position.instrument}-${index}`}>
<td className="data-table__cell"><div className="data-table__description">{position.instrument}</div>{position.isin && <div className="data-table__subtext">{position.isin}</div>}</td>
<td className="data-table__cell data-table__cell--nowrap">{number.format(Number(position.quantity))}</td>
<td className="data-table__cell data-table__cell--nowrap">{position.price === null ? '—' : money.format(Number(position.price))}</td>
<td className="data-table__cell data-table__cell--nowrap money-amount">{position.valuation === null ? '—' : money.format(Number(position.valuation))}</td>
</tr>)}
</tbody>
</table>
</div>
)}
</section>
))}
{history?.accounts.map((account) => account.points.length > 1 && (
<section className="portfolio" key={`history-${account.accountId}`} aria-labelledby={`history-${account.accountId}`}>
<div className="portfolio__header">
<div>
<h2 id={`history-${account.accountId}`} className="portfolio__title">Динамика · {account.accountName}</h2>
<p className="portfolio__meta">Стоимость по загруженным отчётам</p>
</div>
</div>
<div className="chart-card portfolio__chart">
<ResponsiveContainer width="100%" height={280}>
<LineChart data={account.points.map((point) => ({ date: point.reportPeriodTo, value: point.totalValuation === null ? null : Number(point.totalValuation) }))}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" vertical={false} />
<XAxis dataKey="date" tickFormatter={(value: string) => formatDate(value)} fontSize={12} stroke="var(--color-text-secondary)" tickLine={false} axisLine={false} />
<YAxis tickFormatter={(value: number) => `${Math.round(value / 1000)}к`} fontSize={12} stroke="var(--color-text-secondary)" tickLine={false} axisLine={false} />
<Tooltip labelFormatter={(value) => formatDate(String(value))} formatter={(value) => value == null ? '—' : money.format(Number(value))} />
<Line type="monotone" dataKey="value" name="Стоимость" stroke="var(--color-primary)" strokeWidth={2} dot={{ r: 3 }} connectNulls />
</LineChart>
</ResponsiveContainer>
</div>
</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>
);
}

View File

@@ -341,6 +341,38 @@ button {
color: var(--color-text-muted);
}
.portfolio {
margin-bottom: 20px;
}
.portfolio__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.portfolio__title {
font-size: 18px;
font-weight: 850;
}
.portfolio__meta {
margin-top: 3px;
color: var(--color-text-secondary);
}
.portfolio__total {
font-size: 20px;
font-variant-numeric: tabular-nums;
}
.portfolio__chart {
min-height: 280px;
padding: 16px;
}
/* ================================================================
Forms, buttons, badges
================================================================ */
@@ -1462,6 +1494,11 @@ input[type="checkbox"] {
width: 100%;
}
.portfolio__header {
align-items: flex-start;
flex-direction: column;
}
.filters__row,
.analytics-panel__filters {
align-items: stretch;

View File

@@ -1,6 +1,6 @@
{
"name": "@family-budget/shared",
"version": "0.6.0",
"version": "0.9.0",
"private": true,
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -98,3 +98,53 @@ export interface ImportBrokerReportResponse {
cash: ImportStatementResponse;
portfolio: ImportPortfolioResponse;
}
export interface PortfolioOverviewPosition {
instrument: string;
isin: string | null;
quantity: string;
price: string | null;
valuation: string | null;
}
export interface PortfolioOverviewAccount {
accountId: number;
accountName: string;
reportPeriodTo: string;
totalValuation: string | null;
positions: PortfolioOverviewPosition[];
}
export interface PortfolioOverviewResponse {
accounts: PortfolioOverviewAccount[];
}
export interface PortfolioHistoryPoint {
reportPeriodTo: string;
totalValuation: string | null;
}
export interface PortfolioHistoryAccount {
accountId: number;
accountName: string;
points: PortfolioHistoryPoint[];
}
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[];
}

View File

@@ -44,6 +44,14 @@ export type {
PortfolioTrade,
ImportPortfolioResponse,
ImportBrokerReportResponse,
PortfolioOverviewPosition,
PortfolioOverviewAccount,
PortfolioOverviewResponse,
PortfolioHistoryPoint,
PortfolioHistoryAccount,
PortfolioHistoryResponse,
PortfolioPerformanceAccount,
PortfolioPerformanceResponse,
} from './import';
export type {