Merge pull request 'Исправить классификацию денежных движений брокера' (#51) from fix/broker-cash-flow-classification into main
Reviewed-on: #51
This commit was merged in pull request #51.
This commit is contained in:
commit
8202d292cd
5 files changed
+35
-9
No files matched your search
@@ -1,5 +1,17 @@
|
||||
# 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/backend",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/app.ts",
|
||||
|
||||
@@ -30,17 +30,17 @@ type PortfolioHistoryRow = {
|
||||
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 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): PortfolioCashKind {
|
||||
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 && /зачисление|пополнение денежных средств/.test(text)) return 'contribution';
|
||||
if (amount < 0 && /вывод|перевод денежных средств|перечисление денежных средств|возврат денежных средств/.test(text) && !text.includes('под нерассчитанные сделки')) return 'withdrawal';
|
||||
if (amount > 0 && (matchedExternalTransfer || /пополнение/.test(text)) && !text.includes('под нерассчитанные сделки')) return 'contribution';
|
||||
if (amount < 0 && /вывод|перевод денежных средств|перечисление денежных средств|возврат денежных средств/.test(text) && (!text.includes('под нерассчитанные сделки') || matchedExternalTransfer)) return 'withdrawal';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
@@ -329,17 +329,27 @@ export function calculatePortfolioTradeResults(trades: PerformanceTradeRow[], po
|
||||
|
||||
export async function getPortfolioPerformance(): Promise<PortfolioPerformanceResponse> {
|
||||
const [cashResult, tradeResult, positionResult] = await Promise.all([
|
||||
pool.query<PerformanceCashRow>(`SELECT a.id AS account_id, a.alias, a.bank, a.account_number, t.amount_signed, t.description FROM accounts a JOIN transactions t ON t.account_id = a.id WHERE a.account_type IN ('brokerage', 'iis') ORDER BY a.id, t.operation_at, t.id`),
|
||||
pool.query<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]>();
|
||||
for (const row of cashResult.rows) {
|
||||
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);
|
||||
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;
|
||||
|
||||
@@ -29,5 +29,9 @@ 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');
|
||||
Generated
+1
-1
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"backend": {
|
||||
"name": "@family-budget/backend",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.2",
|
||||
"dependencies": {
|
||||
"@family-budget/shared": "*",
|
||||
"cookie-parser": "^1.4.7",
|
||||
|
||||
Reference in new issue
Block a user