Compare commits
1 Commits
fix/broker
...
feature/po
| Author | SHA1 | Date | |
|---|---|---|---|
| 4911f65b7b |
49
CHANGELOG.md
49
CHANGELOG.md
@@ -1,54 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [Backend 0.15.2] - 2026-08-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- Count broker deposits and withdrawals only when matched to a transfer on another family account, while retaining real explicit top-ups and withdrawals.
|
||||
|
||||
## [Backend 0.15.1] - 2026-08-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- Excluded internal transfers for pending securities purchases from broker contributions.
|
||||
|
||||
## [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
|
||||
|
||||
- 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/backend",
|
||||
"version": "0.15.2",
|
||||
"version": "0.13.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/app.ts",
|
||||
@@ -12,8 +12,6 @@
|
||||
"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: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",
|
||||
|
||||
@@ -22,7 +22,6 @@ 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);
|
||||
@@ -50,7 +49,6 @@ 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(
|
||||
(
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
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;
|
||||
@@ -1,23 +0,0 @@
|
||||
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/RU0000000002.json')) return new Response('{}', { status: 404 });
|
||||
if (url.includes('/securities.json') && url.includes('RU0000000002')) return new Response(JSON.stringify({ securities: { columns: ['secid', 'isin'], data: [['TEST2', 'RU0000000002']] } }), { status: 200 });
|
||||
if (url.includes('/securities/TEST2.json') && url.includes('iss.only=boards')) return new Response(JSON.stringify({ boards: { columns: ['engine', 'market', 'boardid', 'secid'], data: [['stock', 'shares', 'TQBR', 'TEST2']] } }), { status: 200 });
|
||||
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.deepEqual(await getMoexQuote('RU0000000002'), { 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();
|
||||
@@ -1,54 +0,0 @@
|
||||
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 {
|
||||
let info: Record<string, IssBlock> = {};
|
||||
try {
|
||||
info = await getJson(`${BASE_URL}/securities/${encodeURIComponent(isin)}.json?iss.meta=off&iss.only=boards&boards.columns=engine,market,boardid,secid`);
|
||||
} catch { /* search by ISIN below */ }
|
||||
let boards = rows(info.boards);
|
||||
if (boards.length === 0) {
|
||||
const search = await getJson(`${BASE_URL}/securities.json?q=${encodeURIComponent(isin)}&iss.meta=off&iss.only=securities&securities.columns=secid,isin`);
|
||||
const match = rows(search.securities).find((item) => String(item.isin ?? '').toUpperCase() === isin);
|
||||
if (typeof match?.secid === 'string') {
|
||||
info = await getJson(`${BASE_URL}/securities/${encodeURIComponent(match.secid)}.json?iss.meta=off&iss.only=boards&boards.columns=engine,market,boardid,secid`);
|
||||
boards = rows(info.boards);
|
||||
}
|
||||
}
|
||||
boards = 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;
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,6 @@ 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 {
|
||||
@@ -23,12 +17,6 @@ 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')");
|
||||
|
||||
@@ -2,8 +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';
|
||||
import type { ImportPortfolioResponse, PortfolioFile, PortfolioHistoryResponse, PortfolioOverviewResponse, 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;
|
||||
|
||||
@@ -30,25 +29,6 @@ type PortfolioHistoryRow = {
|
||||
total_valuation: string | number | null;
|
||||
};
|
||||
|
||||
type PerformanceCashRow = { id: number | string; account_id: number | string; alias: string | null; bank: string; account_number: string; account_type: string | null; operation_at: 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 };
|
||||
|
||||
export type PortfolioCashKind = 'income' | 'contribution' | 'withdrawal' | 'other';
|
||||
|
||||
export function classifyPortfolioCash(amount: number, description: string, matchedExternalTransfer = false): PortfolioCashKind {
|
||||
const text = description.toLowerCase().replace(/ё/g, 'е');
|
||||
if (amount > 0 && /дивиденд|купон|процент/.test(text)) return 'income';
|
||||
if (amount > 0 && (matchedExternalTransfer || /пополнение/.test(text)) && !text.includes('под нерассчитанные сделки')) return 'contribution';
|
||||
if (amount < 0 && /вывод|перевод денежных средств|перечисление денежных средств|возврат денежных средств/.test(text) && (!text.includes('под нерассчитанные сделки') || matchedExternalTransfer)) return 'withdrawal';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export function sumKnownPortfolioValuations(values: Array<number | null>): number | null {
|
||||
const known = values.filter((value): value is number => value !== null && Number.isFinite(value));
|
||||
return known.length > 0 ? known.reduce((sum, value) => sum + value, 0) : null;
|
||||
}
|
||||
|
||||
function decimalValue(value: unknown, field: string, required = false): string | null {
|
||||
if (value == null || value === '') {
|
||||
if (required) throw new Error(`${field} is required`);
|
||||
@@ -195,39 +175,7 @@ export async function getPortfolioOverview(): Promise<PortfolioOverviewResponse>
|
||||
WHERE a.account_type IN ('brokerage', 'iis')
|
||||
ORDER BY a.id, p.valuation DESC NULLS LAST, p.id`,
|
||||
);
|
||||
const overview = toPortfolioOverview(rows);
|
||||
const quoteCache = new Map<string, Awaited<ReturnType<typeof getMoexQuote>>>();
|
||||
for (const account of overview.accounts) {
|
||||
const currentValuations: Array<number | null> = [];
|
||||
let hasMarket = false;
|
||||
let hasFallback = false;
|
||||
let valuationAsOf: string | null = null;
|
||||
for (const position of account.positions) {
|
||||
if (!position.isin) { hasFallback = true; currentValuations.push(position.valuation === null ? null : Number(position.valuation)); continue; }
|
||||
if (!quoteCache.has(position.isin)) quoteCache.set(position.isin, await getMoexQuote(position.isin));
|
||||
const quote = quoteCache.get(position.isin);
|
||||
if (!quote) { hasFallback = true; currentValuations.push(position.valuation === null ? null : Number(position.valuation)); 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;
|
||||
currentValuations.push(currentValuationForPosition);
|
||||
if (quote.at && (!valuationAsOf || quote.at > valuationAsOf)) valuationAsOf = quote.at;
|
||||
}
|
||||
const currentValuation = sumKnownPortfolioValuations(currentValuations);
|
||||
if (currentValuation !== null) {
|
||||
account.currentValuation = String(currentValuation);
|
||||
account.valuationAsOf = valuationAsOf;
|
||||
account.valuationSource = hasMarket ? (hasFallback ? 'mixed' : 'market') : 'report';
|
||||
}
|
||||
}
|
||||
return overview;
|
||||
return toPortfolioOverview(rows);
|
||||
}
|
||||
|
||||
export function toPortfolioHistory(rows: PortfolioHistoryRow[]): PortfolioHistoryResponse {
|
||||
@@ -264,103 +212,3 @@ export async function getPortfolioHistory(): Promise<PortfolioHistoryResponse> {
|
||||
);
|
||||
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 t.id, a.id AS account_id, a.alias, a.bank, a.account_number, a.account_type, t.operation_at, t.amount_signed, t.description FROM accounts a JOIN transactions t ON t.account_id = a.id 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]>();
|
||||
const matchedExternalTransfers = new Set<number | string>();
|
||||
const externalCash = cashResult.rows.filter((row) => !['brokerage', 'iis'].includes(row.account_type ?? ''));
|
||||
const hasExternalTransfer = (row: PerformanceCashRow): boolean => {
|
||||
const target = -Number(row.amount_signed);
|
||||
const at = Date.parse(row.operation_at);
|
||||
const match = externalCash.find((candidate) => !matchedExternalTransfers.has(candidate.id) && Number(candidate.amount_signed) === target && Math.abs(Date.parse(candidate.operation_at) - at) <= 3 * 24 * 60 * 60 * 1000 && /перевод|перечисление|счет|сч[её]т/.test(candidate.description.toLowerCase()));
|
||||
if (!match) return false;
|
||||
matchedExternalTransfers.add(match.id);
|
||||
return true;
|
||||
};
|
||||
for (const row of cashResult.rows.filter((item) => ['brokerage', 'iis'].includes(item.account_type ?? ''))) {
|
||||
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 kind = classifyPortfolioCash(amount, row.description, hasExternalTransfer(row));
|
||||
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) {
|
||||
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 { calculatePortfolioTradeResults, classifyPortfolioCash, sumKnownPortfolioValuations, toPortfolioHistory, toPortfolioOverview } from './portfolio';
|
||||
import { 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,21 +17,3 @@ 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');
|
||||
|
||||
assert.equal(classifyPortfolioCash(-12500, 'Перечисление денежных средств со счёта'), 'withdrawal');
|
||||
assert.equal(classifyPortfolioCash(-12500, 'Перевод денежных средств под нерассчитанные сделки'), 'other');
|
||||
assert.equal(classifyPortfolioCash(52000, 'Зачисление денежных средств для приобретения ценных бумаг'), 'other');
|
||||
assert.equal(classifyPortfolioCash(52000, 'Зачисление денежных средств для приобретения ценных бумаг', true), 'contribution');
|
||||
assert.equal(classifyPortfolioCash(52000, 'Пополнение брокерского счёта'), 'contribution');
|
||||
assert.equal(classifyPortfolioCash(-129000, 'Вывод ДС под нерассчитанные сделки', true), 'withdrawal');
|
||||
assert.equal(sumKnownPortfolioValuations([100, null, 25]), 125);
|
||||
console.log('portfolio cash classification: OK');
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
{
|
||||
"name": "@family-budget/frontend",
|
||||
"version": "0.16.0",
|
||||
"version": "0.14.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"test:portfolio": "tsx src/api/portfolio.test.ts",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ImportBrokerReportResponse, ImportPortfolioResponse, PortfolioFile, PortfolioHistoryResponse, PortfolioOverviewResponse, PortfolioPerformanceResponse } from '@family-budget/shared';
|
||||
import type { ImportBrokerReportResponse, ImportPortfolioResponse, PortfolioFile, PortfolioHistoryResponse, PortfolioOverviewResponse } from '@family-budget/shared';
|
||||
import { api } from './client';
|
||||
|
||||
export function importPortfolio(data: PortfolioFile): Promise<ImportPortfolioResponse> {
|
||||
@@ -18,7 +18,3 @@ export function getPortfolioOverview(): Promise<PortfolioOverviewResponse> {
|
||||
export function getPortfolioHistory(): Promise<PortfolioHistoryResponse> {
|
||||
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 { 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 type { PortfolioHistoryResponse, PortfolioOverviewResponse } from '@family-budget/shared';
|
||||
import { getPortfolioHistory, getPortfolioOverview } from '../api/portfolio';
|
||||
import { formatDate } from '../utils/format';
|
||||
|
||||
const money = new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB', minimumFractionDigits: 2 });
|
||||
@@ -10,12 +10,10 @@ 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 (
|
||||
@@ -34,9 +32,9 @@ export function PortfolioPage() {
|
||||
<div className="portfolio__header">
|
||||
<div>
|
||||
<h2 id={`portfolio-${account.accountId}`} className="portfolio__title">{account.accountName}</h2>
|
||||
<p className="portfolio__meta">{account.valuationSource === 'mixed' ? 'Часть позиций по последнему отчёту' : account.valuationSource === 'report' ? `Стоимость по отчёту на ${formatDate(account.reportPeriodTo)}` : `Котировки на ${formatDate(account.valuationAsOf ?? account.reportPeriodTo)}`}</p>
|
||||
<p className="portfolio__meta">Состояние на {formatDate(account.reportPeriodTo)}</p>
|
||||
</div>
|
||||
{(account.currentValuation ?? account.totalValuation) !== null && <strong className="portfolio__total">{money.format(Number(account.currentValuation ?? account.totalValuation))}</strong>}
|
||||
{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">
|
||||
@@ -46,8 +44,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.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>
|
||||
<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>
|
||||
@@ -77,20 +75,6 @@ export function PortfolioPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
45
package-lock.json
generated
45
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "family-budget",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "family-budget",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.0",
|
||||
"workspaces": [
|
||||
"shared",
|
||||
"backend",
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"backend": {
|
||||
"name": "@family-budget/backend",
|
||||
"version": "0.15.2",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@family-budget/shared": "*",
|
||||
"cookie-parser": "^1.4.7",
|
||||
@@ -64,7 +64,7 @@
|
||||
},
|
||||
"frontend": {
|
||||
"name": "@family-budget/frontend",
|
||||
"version": "0.16.0",
|
||||
"version": "0.9.0",
|
||||
"dependencies": {
|
||||
"@family-budget/shared": "*",
|
||||
"react": "^19.0.0",
|
||||
@@ -111,6 +111,7 @@
|
||||
"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",
|
||||
@@ -1394,6 +1395,7 @@
|
||||
"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",
|
||||
@@ -1472,6 +1474,7 @@
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1611,6 +1614,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -2734,6 +2738,7 @@
|
||||
"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",
|
||||
@@ -2831,6 +2836,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -2980,6 +2986,7 @@
|
||||
"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"
|
||||
}
|
||||
@@ -2989,6 +2996,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -3492,7 +3500,6 @@
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3510,7 +3517,6 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3528,7 +3534,6 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3546,7 +3551,6 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3564,7 +3568,6 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3582,7 +3585,6 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3600,7 +3602,6 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3618,7 +3619,6 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3636,7 +3636,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3654,7 +3653,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3672,7 +3670,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3690,7 +3687,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3708,7 +3704,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3726,7 +3721,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3744,7 +3738,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3762,7 +3755,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3780,7 +3772,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3798,7 +3789,6 @@
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3816,7 +3806,6 @@
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3834,7 +3823,6 @@
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3852,7 +3840,6 @@
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3870,7 +3857,6 @@
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3888,7 +3874,6 @@
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3906,7 +3891,6 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3924,7 +3908,6 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -3942,7 +3925,6 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -4126,6 +4108,7 @@
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -4219,7 +4202,7 @@
|
||||
},
|
||||
"shared": {
|
||||
"name": "@family-budget/shared",
|
||||
"version": "0.10.0",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/shared",
|
||||
"version": "0.10.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -105,9 +105,6 @@ export interface PortfolioOverviewPosition {
|
||||
quantity: string;
|
||||
price: string | null;
|
||||
valuation: string | null;
|
||||
currentPrice?: string | null;
|
||||
currentValuation?: string | null;
|
||||
quoteAt?: string | null;
|
||||
}
|
||||
|
||||
export interface PortfolioOverviewAccount {
|
||||
@@ -115,9 +112,6 @@ export interface PortfolioOverviewAccount {
|
||||
accountName: string;
|
||||
reportPeriodTo: string;
|
||||
totalValuation: string | null;
|
||||
currentValuation?: string | null;
|
||||
valuationAsOf?: string | null;
|
||||
valuationSource?: 'market' | 'mixed' | 'report';
|
||||
positions: PortfolioOverviewPosition[];
|
||||
}
|
||||
|
||||
@@ -139,18 +133,3 @@ 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[];
|
||||
}
|
||||
|
||||
@@ -50,8 +50,6 @@ export type {
|
||||
PortfolioHistoryPoint,
|
||||
PortfolioHistoryAccount,
|
||||
PortfolioHistoryResponse,
|
||||
PortfolioPerformanceAccount,
|
||||
PortfolioPerformanceResponse,
|
||||
} from './import';
|
||||
|
||||
export type {
|
||||
|
||||
Reference in New Issue
Block a user