diff --git a/CHANGELOG.md b/CHANGELOG.md index 258c039..b97d587 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/backend/package.json b/backend/package.json index c0405d5..ec3c670 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/services/moex.test.ts b/backend/src/services/moex.test.ts new file mode 100644 index 0000000..4ac0d26 --- /dev/null +++ b/backend/src/services/moex.test.ts @@ -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 { + 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(); diff --git a/backend/src/services/moex.ts b/backend/src/services/moex.ts new file mode 100644 index 0000000..e96b647 --- /dev/null +++ b/backend/src/services/moex.ts @@ -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[] { + 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> { + 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; +} + +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 { + 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; + } +} diff --git a/backend/src/services/portfolio.ts b/backend/src/services/portfolio.ts index 5538f12..83b481b 100644 --- a/backend/src/services/portfolio.ts +++ b/backend/src/services/portfolio.ts @@ -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 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>>(); + 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 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) { diff --git a/backend/src/services/portfolioOverview.test.ts b/backend/src/services/portfolioOverview.test.ts index 64fce33..abf116b 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 { 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'); diff --git a/frontend/package.json b/frontend/package.json index d62d532..f6a7684 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@family-budget/frontend", - "version": "0.15.2", + "version": "0.16.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/pages/PortfolioPage.tsx b/frontend/src/pages/PortfolioPage.tsx index 06f0b1e..f5b27f7 100644 --- a/frontend/src/pages/PortfolioPage.tsx +++ b/frontend/src/pages/PortfolioPage.tsx @@ -34,9 +34,9 @@ export function PortfolioPage() {

{account.accountName}

-

Состояние на {formatDate(account.reportPeriodTo)}

+

{account.valuationSource === 'mixed' ? 'Часть позиций по последнему отчёту' : account.valuationSource === 'report' ? `Стоимость по отчёту на ${formatDate(account.reportPeriodTo)}` : `Котировки на ${formatDate(account.valuationAsOf ?? account.reportPeriodTo)}`}

- {account.totalValuation !== null && {money.format(Number(account.totalValuation))}} + {(account.currentValuation ?? account.totalValuation) !== null && {money.format(Number(account.currentValuation ?? account.totalValuation))}}
{account.positions.length === 0 ?
В последнем отчёте нет открытых позиций.
: (
@@ -46,8 +46,8 @@ export function PortfolioPage() { {account.positions.map((position, index) =>
{position.instrument}
{position.isin &&
{position.isin}
} {number.format(Number(position.quantity))} - {position.price === null ? '—' : money.format(Number(position.price))} - {position.valuation === null ? '—' : money.format(Number(position.valuation))} + {(position.currentPrice ?? position.price) === null ? '—' : money.format(Number(position.currentPrice ?? position.price))} + {(position.currentValuation ?? position.valuation) === null ? '—' : money.format(Number(position.currentValuation ?? position.valuation))} )} diff --git a/package-lock.json b/package-lock.json index 4b83152..16e1506 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } diff --git a/shared/package.json b/shared/package.json index f0abe09..f593019 100644 --- a/shared/package.json +++ b/shared/package.json @@ -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", diff --git a/shared/src/types/import.ts b/shared/src/types/import.ts index 7e3e6d4..892536a 100644 --- a/shared/src/types/import.ts +++ b/shared/src/types/import.ts @@ -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[]; }