import crypto from 'crypto'; import type { PoolClient } from 'pg'; import { pool } from '../db/pool'; 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`); return null; } const result = String(value).replace(/\s/g, '').replace(',', '.'); if (!/^-?(?:\d+\.?\d*|\.\d+)$/.test(result)) throw new Error(`${field} must be numeric`); return result; } export function deriveTradeSourceId(trade: PortfolioTrade): string { if (trade.sourceId?.trim()) return trade.sourceId.trim(); return [trade.isin || trade.instrument, trade.concludedAt, trade.side, decimalValue(trade.quantity, 'quantity', true), decimalValue(trade.settlementAmount, 'settlementAmount') || ''].join('|'); } export function validatePortfolio(body: unknown): asserts body is PortfolioFile { if (!body || typeof body !== 'object') throw new Error('Portfolio file must be an object'); const data = body as PortfolioFile; if (data.schemaVersion !== 'broker-portfolio-1.0' || typeof data.bank !== 'string' || !data.bank.trim() || typeof data.accountNumber !== 'string' || !data.accountNumber.trim()) throw new Error('Invalid portfolio file'); if (!data.reportPeriod?.from || !data.reportPeriod?.to || Number.isNaN(Date.parse(data.reportPeriod.from)) || Number.isNaN(Date.parse(data.reportPeriod.to)) || data.reportPeriod.from > data.reportPeriod.to) throw new Error('Invalid report period'); if (data.reportedAt !== null && (typeof data.reportedAt !== 'string' || Number.isNaN(Date.parse(data.reportedAt)))) throw new Error('Invalid reportedAt'); if (!Array.isArray(data.positions) || !Array.isArray(data.trades)) throw new Error('positions and trades must be arrays'); const sourceIds = new Set(); for (const trade of data.trades) { if (!trade || typeof trade.instrument !== 'string' || !trade.instrument.trim() || typeof trade.concludedAt !== 'string' || Number.isNaN(Date.parse(trade.concludedAt)) || typeof trade.side !== 'string' || !trade.side.trim()) throw new Error('Invalid trade'); if (trade.sourceId !== undefined && typeof trade.sourceId !== 'string') throw new Error('sourceId must be a string'); if (trade.operationId !== undefined && typeof trade.operationId !== 'string') throw new Error('operationId must be a string'); const sourceId = deriveTradeSourceId(trade); if (sourceIds.has(sourceId)) throw new Error(`Duplicate sourceId: ${sourceId}`); sourceIds.add(sourceId); if (trade.operationId && !UUID_RE.test(trade.operationId)) throw new Error(`Invalid operationId: ${trade.sourceId}`); decimalValue(trade.quantity, 'quantity', true); } for (const position of data.positions) { if (!position || typeof position.instrument !== 'string' || !position.instrument.trim()) throw new Error('Invalid position'); decimalValue(position.quantity, 'position.quantity', true); decimalValue(position.price, 'position.price'); decimalValue(position.valuation, 'position.valuation'); } } export async function importPortfolio(body: unknown, db: Pick = pool, transactionClient?: PoolClient): Promise { validatePortfolio(body); const data = body; const sourceHash = crypto.createHash('sha256').update(JSON.stringify(data)).digest('hex'); const client = transactionClient ?? await db.connect(); const ownsTransaction = transactionClient == null; try { if (ownsTransaction) await client.query('BEGIN'); const accountResult = await client.query( `INSERT INTO accounts (bank, account_number, currency, account_type) VALUES ($1, $2, 'RUB', 'brokerage') ON CONFLICT (bank, account_number) DO UPDATE SET account_type = COALESCE(accounts.account_type, 'brokerage') RETURNING id`, [data.bank, data.accountNumber], ); const accountId = Number(accountResult.rows[0].id); const reportResult = await client.query( `INSERT INTO portfolio_reports (account_id, source_hash, report_period_from, report_period_to, reported_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (account_id, source_hash) DO NOTHING RETURNING id`, [accountId, sourceHash, data.reportPeriod.from, data.reportPeriod.to, data.reportedAt], ); if (reportResult.rows.length === 0) { const existing = await client.query('SELECT id FROM portfolio_reports WHERE account_id = $1 AND source_hash = $2', [accountId, sourceHash]); if (ownsTransaction) await client.query('COMMIT'); return { accountId, reportId: Number(existing.rows[0].id), importedTrades: 0, duplicateTrades: data.trades.length, positions: 0 }; } const reportId = Number(reportResult.rows[0].id); for (const position of data.positions) { await client.query( `INSERT INTO portfolio_positions (report_id, instrument, isin, quantity, price, valuation) VALUES ($1, $2, $3, $4, $5, $6)`, [reportId, position.instrument, position.isin ?? null, decimalValue(position.quantity, 'position.quantity', true), decimalValue(position.price, 'position.price'), decimalValue(position.valuation, 'position.valuation')], ); } let importedTrades = 0; for (const trade of data.trades) { const sourceId = deriveTradeSourceId(trade); const result = await client.query( `INSERT INTO portfolio_trades (account_id, source_id, operation_id, instrument, isin, concluded_at, side, quantity, price_currency, price, settlement_currency, settlement_amount, nkd, settlement_commission, trade_commission, order_id, trade_id, venue, comment) VALUES ($1, $2, COALESCE($3::uuid, gen_random_uuid()), $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) ON CONFLICT (account_id, source_id) DO NOTHING`, [accountId, sourceId, trade.operationId ?? null, trade.instrument, trade.isin ?? null, trade.concludedAt, trade.side, decimalValue(trade.quantity, 'quantity', true), trade.priceCurrency ?? null, decimalValue(trade.price, 'price'), trade.settlementCurrency ?? null, decimalValue(trade.settlementAmount, 'settlementAmount'), decimalValue(trade.nkd, 'nkd'), decimalValue(trade.settlementCommission, 'settlementCommission'), decimalValue(trade.tradeCommission, 'tradeCommission'), trade.orderId ?? null, trade.tradeId ?? null, trade.venue ?? null, trade.comment ?? null], ); importedTrades += result.rowCount ?? 0; } if (ownsTransaction) await client.query('COMMIT'); return { accountId, reportId, importedTrades, duplicateTrades: data.trades.length - importedTrades, positions: data.positions.length }; } catch (error) { if (ownsTransaction) await client.query('ROLLBACK'); throw error; } finally { if (ownsTransaction) client.release(); } } export function toPortfolioOverview(rows: PortfolioOverviewRow[]): PortfolioOverviewResponse { const accounts = new Map(); 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 { const { rows } = await pool.query( `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(); 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 { const { rows } = await pool.query( `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 { const result = new Map(); const lots = new Map(); for (const trade of trades) { const accountId = Number(trade.account_id); const current = result.get(accountId) ?? { fees: 0, realized: 0, unrealized: null }; const quantity = Number(trade.quantity); const amount = Number(trade.settlement_amount ?? 0); const fees = Number(trade.settlement_commission ?? 0) + Number(trade.trade_commission ?? 0); current.fees += fees; const key = `${accountId}:${trade.isin ?? trade.instrument}`; const queue = lots.get(key) ?? []; if (/продаж/i.test(trade.side)) { let remaining = quantity; let matchedCost = 0; while (remaining > 0 && queue.length > 0) { const lot = queue[0]; const used = Math.min(remaining, lot.quantity); const unitCost = lot.cost / lot.quantity; matchedCost += used * unitCost; lot.cost -= used * unitCost; lot.quantity -= used; remaining -= used; if (lot.quantity <= 0) queue.shift(); } const matched = quantity - remaining; current.realized += matched > 0 ? (amount - fees) * (matched / quantity) - matchedCost : 0; } else if (/покуп/i.test(trade.side) && quantity > 0) { queue.push({ quantity, cost: amount + fees }); } lots.set(key, queue); result.set(accountId, current); } const positionCost = new Map(); for (const [key, queue] of lots) positionCost.set(key, queue.reduce((sum, lot) => sum + lot.cost, 0)); for (const position of positions) { const accountId = Number(position.account_id); const current = result.get(accountId) ?? { fees: 0, realized: 0, unrealized: 0 }; if (position.valuation !== null) { const key = `${accountId}:${position.isin ?? position.instrument}`; current.unrealized = (current.unrealized ?? 0) + Number(position.valuation) - (positionCost.get(key) ?? 0); } result.set(accountId, current); } return result; } export async function getPortfolioPerformance(): Promise { const [cashResult, tradeResult, positionResult] = await Promise.all([ pool.query(`SELECT a.id AS account_id, a.alias, a.bank, a.account_number, t.amount_signed, t.description FROM accounts a JOIN transactions t ON t.account_id = a.id WHERE a.account_type IN ('brokerage', 'iis') ORDER BY a.id, t.operation_at, t.id`), pool.query(`SELECT account_id, side, quantity, settlement_amount, settlement_commission, trade_commission, isin, instrument FROM portfolio_trades ORDER BY account_id, concluded_at, id`), pool.query(`SELECT r.account_id, p.quantity, p.valuation, p.isin, p.instrument FROM portfolio_reports r JOIN portfolio_positions p ON p.report_id = r.id JOIN LATERAL (SELECT id FROM portfolio_reports WHERE account_id = r.account_id ORDER BY report_period_to DESC, imported_at DESC, id DESC LIMIT 1) latest ON latest.id = r.id`), ]); const tradeMap = calculatePortfolioTradeResults(tradeResult.rows, positionResult.rows); const accounts = new Map(); for (const row of cashResult.rows) { const accountId = Number(row.account_id); const account = accounts.get(accountId) ?? { accountId, accountName: row.alias || `${row.bank} · ${maskAccountNumber(row.account_number)}`, contributions: 0, withdrawals: 0, income: 0, fees: 0, realizedResult: 0, unrealizedResult: null }; const amount = Number(row.amount_signed); const description = row.description.toLowerCase(); if (amount > 0 && /дивиденд|купон|процент/.test(description)) account.income += amount; else if (amount > 0 && description.includes('зачисление денежных средств')) account.contributions += amount; else if (amount < 0 && /вывод денежных средств|вывод дс/.test(description) && !description.includes('под нерассчитанные сделки')) account.withdrawals += -amount; accounts.set(accountId, account); } for (const [accountId, values] of tradeMap) { const account = accounts.get(accountId) ?? { accountId, accountName: `Счёт ${accountId}`, contributions: 0, withdrawals: 0, income: 0, fees: 0, realizedResult: 0, unrealizedResult: null }; account.fees = Math.round(values.fees * 100); account.realizedResult = Math.round(values.realized * 100); account.unrealizedResult = values.unrealized === null ? null : Math.round(values.unrealized * 100); accounts.set(accountId, account); } return { accounts: [...accounts.values()] }; }