feat: refresh portfolio valuation from market quotes

This commit is contained in:
2026-08-28 08:23:56 +03:00
parent 71a18e8656
commit c2e515ba8d
11 changed files with 167 additions and 27 deletions

View File

@@ -1,5 +1,12 @@
# Changelog
## [Frontend 0.16.0 / Backend 0.15.0 / Shared 0.10.0] - 2026-08-28
### Added
- Revalued open portfolio positions from current MOEX quotes with a fallback to the latest broker report.
- Broadened broker withdrawal detection in investment performance.
## [Frontend 0.15.2] - 2026-08-27
### Fixed

View File

@@ -1,6 +1,6 @@
{
"name": "@family-budget/backend",
"version": "0.14.1",
"version": "0.15.0",
"private": true,
"scripts": {
"dev": "tsx watch src/app.ts",
@@ -13,6 +13,7 @@
"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:moex": "tsx src/services/moex.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

@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import { getMoexQuote } from './moex';
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: string | URL | Request) => {
const url = String(input);
if (url.includes('/securities/RU0000000001.json')) return new Response(JSON.stringify({ boards: { columns: ['engine', 'market', 'boardid', 'secid'], data: [['stock', 'shares', 'TQBR', 'TEST']] } }), { status: 200 });
return new Response(JSON.stringify({ marketdata: { columns: ['LAST', 'LCURRENTPRICE', 'WAPRICE', 'UPDATETIME'], data: [[123.45, null, null, '2026-08-28 12:00:00']] } }), { status: 200 });
}) as typeof fetch;
async function run(): Promise<void> {
const quote = await getMoexQuote('RU0000000001');
assert.deepEqual(quote, { price: 123.45, at: '2026-08-28 12:00:00' });
assert.equal(await getMoexQuote('bad-isin'), null);
globalThis.fetch = originalFetch;
console.log('MOEX quote: OK');
}
run();

View File

@@ -0,0 +1,42 @@
type IssBlock = { columns?: string[]; data?: unknown[][] };
export type MarketQuote = { price: number; at: string | null };
const BASE_URL = 'https://iss.moex.com/iss';
const timeoutMs = 5000;
function rows(block: IssBlock | undefined): Record<string, unknown>[] {
if (!block?.columns || !block.data) return [];
return block.data.map((row) => Object.fromEntries(block.columns!.map((column, index) => [column.toLowerCase(), row[index]])));
}
async function getJson(url: string): Promise<Record<string, IssBlock>> {
const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), headers: { accept: 'application/json' } });
if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`);
return await response.json() as Record<string, IssBlock>;
}
function numberValue(value: unknown): number | null {
const number = typeof value === 'number' ? value : Number(value);
return Number.isFinite(number) && number > 0 ? number : null;
}
export async function getMoexQuote(isin: string): Promise<MarketQuote | null> {
if (!/^RU[A-Z0-9]{10}$/.test(isin)) return null;
try {
const info = await getJson(`${BASE_URL}/securities/${encodeURIComponent(isin)}.json?iss.meta=off&iss.only=boards&boards.columns=engine,market,boardid,secid`);
const boards = rows(info.boards).filter((item) => typeof item.engine === 'string' && typeof item.market === 'string' && typeof item.boardid === 'string' && typeof item.secid === 'string');
for (const board of boards) {
try {
const quote = await getJson(`${BASE_URL}/engines/${board.engine}/markets/${board.market}/boards/${board.boardid}/securities/${encodeURIComponent(String(board.secid))}.json?iss.meta=off&iss.only=marketdata&marketdata.columns=LAST,LCURRENTPRICE,WAPRICE,UPDATETIME`);
const market = rows(quote.marketdata)[0];
if (!market) continue;
const price = numberValue(market.last) ?? numberValue(market.lcurrentprice) ?? numberValue(market.waprice);
if (price !== null) return { price, at: typeof market.updatetime === 'string' ? market.updatetime : null };
} catch { /* try the next trading board */ }
}
return null;
} catch {
return null;
}
}

View File

@@ -2,6 +2,7 @@ import crypto from 'crypto';
import type { PoolClient } from 'pg';
import { pool } from '../db/pool';
import { maskAccountNumber } from '../utils';
import { getMoexQuote } from './moex';
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;
@@ -33,6 +34,16 @@ type PerformanceCashRow = { account_id: number | string; alias: string | null; b
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 };
export type PortfolioCashKind = 'income' | 'contribution' | 'withdrawal' | 'other';
export function classifyPortfolioCash(amount: number, description: string): PortfolioCashKind {
const text = description.toLowerCase().replace(/ё/g, 'е');
if (amount > 0 && /дивиденд|купон|процент/.test(text)) return 'income';
if (amount > 0 && /зачисление|пополнение денежных средств/.test(text)) return 'contribution';
if (amount < 0 && /вывод|перевод денежных средств|перечисление денежных средств|возврат денежных средств/.test(text) && !text.includes('под нерассчитанные сделки')) return 'withdrawal';
return 'other';
}
function decimalValue(value: unknown, field: string, required = false): string | null {
if (value == null || value === '') {
if (required) throw new Error(`${field} is required`);
@@ -179,7 +190,40 @@ export async function getPortfolioOverview(): Promise<PortfolioOverviewResponse>
WHERE a.account_type IN ('brokerage', 'iis')
ORDER BY a.id, p.valuation DESC NULLS LAST, p.id`,
);
return toPortfolioOverview(rows);
const overview = toPortfolioOverview(rows);
const quoteCache = new Map<string, Awaited<ReturnType<typeof getMoexQuote>>>();
for (const account of overview.accounts) {
let currentValuation = 0;
let hasValue = false;
let hasMarket = false;
let hasFallback = false;
let valuationAsOf: string | null = null;
for (const position of account.positions) {
if (!position.isin) { hasFallback = true; if (position.valuation !== null) currentValuation += Number(position.valuation); hasValue = position.valuation !== null; continue; }
if (!quoteCache.has(position.isin)) quoteCache.set(position.isin, await getMoexQuote(position.isin));
const quote = quoteCache.get(position.isin);
if (!quote) { hasFallback = true; if (position.valuation !== null) currentValuation += Number(position.valuation); hasValue = position.valuation !== null; continue; }
const reportPrice = position.price === null ? null : Number(position.price);
const reportValuation = position.valuation === null ? null : Number(position.valuation);
const currentValuationForPosition = reportPrice && reportValuation !== null
? reportValuation * quote.price / reportPrice
: Number(position.quantity) * quote.price;
if (!Number.isFinite(currentValuationForPosition)) continue;
position.currentPrice = String(quote.price);
position.currentValuation = String(currentValuationForPosition);
position.quoteAt = quote.at;
hasMarket = true;
currentValuation += currentValuationForPosition;
hasValue = true;
if (quote.at && (!valuationAsOf || quote.at > valuationAsOf)) valuationAsOf = quote.at;
}
if (hasValue) {
account.currentValuation = String(currentValuation);
account.valuationAsOf = valuationAsOf;
account.valuationSource = hasMarket ? (hasFallback ? 'mixed' : 'market') : 'report';
}
}
return overview;
}
export function toPortfolioHistory(rows: PortfolioHistoryRow[]): PortfolioHistoryResponse {
@@ -291,10 +335,10 @@ export async function getPortfolioPerformance(): Promise<PortfolioPerformanceRes
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;
const kind = classifyPortfolioCash(amount, row.description);
if (kind === 'income') account.income += amount;
else if (kind === 'contribution') account.contributions += amount;
else if (kind === 'withdrawal') account.withdrawals += -amount;
accounts.set(accountId, account);
}
for (const [accountId, values] of tradeMap) {

View File

@@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
import { calculatePortfolioTradeResults, toPortfolioHistory, toPortfolioOverview } from './portfolio';
import { calculatePortfolioTradeResults, classifyPortfolioCash, 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' },
@@ -26,3 +26,7 @@ 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');
assert.equal(classifyPortfolioCash(-12500, 'Перечисление денежных средств со счёта'), 'withdrawal');
assert.equal(classifyPortfolioCash(-12500, 'Перевод денежных средств под нерассчитанные сделки'), 'other');
console.log('portfolio cash classification: OK');

View File

@@ -1,6 +1,6 @@
{
"name": "@family-budget/frontend",
"version": "0.15.2",
"version": "0.16.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -34,9 +34,9 @@ export function PortfolioPage() {
<div className="portfolio__header">
<div>
<h2 id={`portfolio-${account.accountId}`} className="portfolio__title">{account.accountName}</h2>
<p className="portfolio__meta">Состояние на {formatDate(account.reportPeriodTo)}</p>
<p className="portfolio__meta">{account.valuationSource === 'mixed' ? 'Часть позиций по последнему отчёту' : account.valuationSource === 'report' ? `Стоимость по отчёту на ${formatDate(account.reportPeriodTo)}` : `Котировки на ${formatDate(account.valuationAsOf ?? account.reportPeriodTo)}`}</p>
</div>
{account.totalValuation !== null && <strong className="portfolio__total">{money.format(Number(account.totalValuation))}</strong>}
{(account.currentValuation ?? account.totalValuation) !== null && <strong className="portfolio__total">{money.format(Number(account.currentValuation ?? account.totalValuation))}</strong>}
</div>
{account.positions.length === 0 ? <div className="state state--empty">В последнем отчёте нет открытых позиций.</div> : (
<div className="table-shell">
@@ -46,8 +46,8 @@ export function PortfolioPage() {
{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>
<td className="data-table__cell data-table__cell--nowrap">{(position.currentPrice ?? position.price) === null ? '—' : money.format(Number(position.currentPrice ?? position.price))}</td>
<td className="data-table__cell data-table__cell--nowrap money-amount">{(position.currentValuation ?? position.valuation) === null ? '—' : money.format(Number(position.currentValuation ?? position.valuation))}</td>
</tr>)}
</tbody>
</table>

45
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "family-budget",
"version": "0.1.0",
"version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "family-budget",
"version": "0.1.0",
"version": "0.1.1",
"workspaces": [
"shared",
"backend",
@@ -15,7 +15,7 @@
},
"backend": {
"name": "@family-budget/backend",
"version": "0.1.0",
"version": "0.15.0",
"dependencies": {
"@family-budget/shared": "*",
"cookie-parser": "^1.4.7",
@@ -64,7 +64,7 @@
},
"frontend": {
"name": "@family-budget/frontend",
"version": "0.9.0",
"version": "0.16.0",
"dependencies": {
"@family-budget/shared": "*",
"react": "^19.0.0",
@@ -111,7 +111,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1395,7 +1394,6 @@
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
@@ -1474,7 +1472,6 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1614,7 +1611,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2738,7 +2734,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.11.0",
"pg-pool": "^3.12.0",
@@ -2836,7 +2831,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -2986,7 +2980,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -2996,7 +2989,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -3500,6 +3492,7 @@
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3517,6 +3510,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3534,6 +3528,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3551,6 +3546,7 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3568,6 +3564,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3585,6 +3582,7 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3602,6 +3600,7 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3619,6 +3618,7 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3636,6 +3636,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3653,6 +3654,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3670,6 +3672,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3687,6 +3690,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3704,6 +3708,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3721,6 +3726,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3738,6 +3744,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3755,6 +3762,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3772,6 +3780,7 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3789,6 +3798,7 @@
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3806,6 +3816,7 @@
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3823,6 +3834,7 @@
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3840,6 +3852,7 @@
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3857,6 +3870,7 @@
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3874,6 +3888,7 @@
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3891,6 +3906,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3908,6 +3924,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -3925,6 +3942,7 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -4108,7 +4126,6 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -4202,7 +4219,7 @@
},
"shared": {
"name": "@family-budget/shared",
"version": "0.1.0",
"version": "0.10.0",
"devDependencies": {
"typescript": "^5.7.0"
}

View File

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

View File

@@ -105,6 +105,9 @@ export interface PortfolioOverviewPosition {
quantity: string;
price: string | null;
valuation: string | null;
currentPrice?: string | null;
currentValuation?: string | null;
quoteAt?: string | null;
}
export interface PortfolioOverviewAccount {
@@ -112,6 +115,9 @@ export interface PortfolioOverviewAccount {
accountName: string;
reportPeriodTo: string;
totalValuation: string | null;
currentValuation?: string | null;
valuationAsOf?: string | null;
valuationSource?: 'market' | 'mixed' | 'report';
positions: PortfolioOverviewPosition[];
}