- income analytics returns only the selected period; previous/yearAgo comparison payloads, dependency roles and sync planning removed - income entries now carry saved instrument logos for payment cards - tbank grpc diagnostics distinguish queue wait, channel state and response headers without logging secrets
308 lines
8.8 KiB
TypeScript
308 lines
8.8 KiB
TypeScript
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
|
|
import { planIncomePeriod } from './income/income.api';
|
|
import { dateOnlyBounds, moscowDate } from './investment-calendar';
|
|
import {
|
|
type OperationInterval,
|
|
operationIntervals,
|
|
} from './operations/domain/operations-sync-plan';
|
|
|
|
const FORECAST_YEARS = 1;
|
|
const JANUARY_INDEX = 0;
|
|
const FIRST_DAY_OF_MONTH = 1;
|
|
const EXCLUSIVE_END_OFFSET_MS = 1;
|
|
const NO_END_OFFSET_MS = 0;
|
|
|
|
export type InvestmentScreen = {
|
|
accountId?: string;
|
|
candlePeriod?: 'all' | 'halfYear' | 'month' | 'year';
|
|
candleRange?: { from: Date; to: Date };
|
|
from?: string;
|
|
instrumentUid?: string;
|
|
query?: string;
|
|
screen:
|
|
| 'account' |
|
|
'accounts' |
|
|
'bond' |
|
|
'bonds' |
|
|
'events' |
|
|
'income' |
|
|
'operations' |
|
|
'stock' |
|
|
'stocks';
|
|
to?: string;
|
|
};
|
|
const defaultEventDays = 30;
|
|
const incomeHistoryYears = 5;
|
|
const candleMonths = { month: 1, halfYear: 6, year: 12 };
|
|
|
|
type Account = { accountId: string; openedOn: null | string };
|
|
type Instrument = {
|
|
historyFrom: Date | null;
|
|
type: 'BOND' | 'STOCK';
|
|
uid: string;
|
|
};
|
|
type Source = {
|
|
accountId?: string;
|
|
from?: Date;
|
|
instrumentType?: 'BOND' | 'STOCK';
|
|
instrumentUid?: string;
|
|
kind:
|
|
| 'calendar' |
|
|
'candles' |
|
|
'catalog' |
|
|
'company' |
|
|
'membership' |
|
|
'metadata' |
|
|
'metrics' |
|
|
'operations' |
|
|
'portfolio' |
|
|
'quote';
|
|
priority: 'discovery' | 'history' | 'screen';
|
|
query?: string;
|
|
to?: Date;
|
|
};
|
|
|
|
// Expands screen scope, never credentials or arbitrary caller-supplied jobs.
|
|
// Owners execute their own sources and expand instruments after discovery.
|
|
export function planInvestmentScreen(
|
|
screen: InvestmentScreen,
|
|
context: {
|
|
accounts: Account[];
|
|
instruments: Instrument[];
|
|
membershipKnown: boolean;
|
|
},
|
|
now: Date,
|
|
force = false,
|
|
) {
|
|
const sources: Source[] = [{ kind: 'membership', priority: 'discovery' }];
|
|
const accounts = context.accounts.filter(
|
|
(account) =>
|
|
!screen.accountId ||
|
|
screen.accountId === 'all' ||
|
|
account.accountId === screen.accountId,
|
|
);
|
|
const unavailable: { reason: string; resourceKey: string }[] = [];
|
|
const operations = (from: Date, to: Date, instrumentUid?: string) => {
|
|
const targets = accounts.flatMap((account) =>
|
|
from < to
|
|
? [
|
|
{
|
|
accountId: account.accountId,
|
|
from,
|
|
to,
|
|
...(instrumentUid ? { instrumentUid } : {}),
|
|
},
|
|
]
|
|
: [],
|
|
);
|
|
sources.push(
|
|
...operationIntervals(targets).map((range) => ({
|
|
...range,
|
|
kind: 'operations' as const,
|
|
priority: 'screen' as const,
|
|
})),
|
|
);
|
|
};
|
|
const portfolio = () =>
|
|
sources.push(
|
|
...accounts.map(({ accountId }) => ({
|
|
kind: 'portfolio' as const,
|
|
accountId,
|
|
priority: 'screen' as const,
|
|
})),
|
|
);
|
|
if (!context.membershipKnown) return { sources, unavailable, now };
|
|
if (
|
|
['account', 'accounts', 'bond', 'events', 'stock'].includes(screen.screen)
|
|
) {
|
|
portfolio();
|
|
}
|
|
if (
|
|
screen.screen === 'operations' ||
|
|
screen.screen === 'stock' ||
|
|
screen.screen === 'bond'
|
|
) {
|
|
const range = ledgerRange(
|
|
screen.from,
|
|
screen.to,
|
|
screen.instrumentUid,
|
|
accounts,
|
|
now,
|
|
);
|
|
operations(range.from, range.to, force ? screen.instrumentUid : undefined);
|
|
}
|
|
if (screen.screen === 'account' || screen.screen === 'income') {
|
|
const period = planIncomePeriod(
|
|
screen.screen === 'income' ? screen.from : undefined,
|
|
screen.screen === 'income' ? screen.to : undefined,
|
|
);
|
|
const bounds = dateOnlyBounds(period.selected, now);
|
|
operations(bounds.from, bounds.to);
|
|
if (screen.screen === 'account') {
|
|
const range = ledgerRange(
|
|
screen.from,
|
|
screen.to,
|
|
undefined,
|
|
accounts,
|
|
now,
|
|
);
|
|
operations(range.from, range.to);
|
|
}
|
|
}
|
|
if (screen.screen === 'stocks' || screen.screen === 'bonds') {
|
|
sources.push({
|
|
kind: 'catalog',
|
|
instrumentType: screen.screen === 'stocks' ? 'STOCK' : 'BOND',
|
|
query: screen.query?.trim().toUpperCase() ?? '',
|
|
priority: 'screen',
|
|
});
|
|
}
|
|
const instruments = context.instruments.filter(
|
|
(instrument) =>
|
|
!screen.instrumentUid || instrument.uid === screen.instrumentUid,
|
|
);
|
|
if (
|
|
screen.instrumentUid &&
|
|
!instruments.length &&
|
|
(screen.screen === 'stock' || screen.screen === 'bond')
|
|
) {
|
|
instruments.push({
|
|
uid: screen.instrumentUid,
|
|
type: screen.screen === 'stock' ? 'STOCK' : 'BOND',
|
|
historyFrom: null,
|
|
});
|
|
}
|
|
for (const instrument of instruments) {
|
|
const identity = {
|
|
instrumentUid: instrument.uid,
|
|
instrumentType: instrument.type,
|
|
};
|
|
sources.push({ ...identity, kind: 'metadata', priority: 'screen' });
|
|
if (['bond', 'bonds', 'stock', 'stocks'].includes(screen.screen)) {
|
|
sources.push({ ...identity, kind: 'quote', priority: 'screen' });
|
|
}
|
|
if (screen.screen === 'stock' || screen.screen === 'bond') {
|
|
sources.push({
|
|
...identity,
|
|
kind: screen.screen === 'stock' ? 'company' : 'metrics',
|
|
priority: 'screen',
|
|
});
|
|
const from = new Date(now);
|
|
const period = screen.candlePeriod ?? 'month';
|
|
if (period !== 'all') {
|
|
// eslint-disable-next-line security/detect-object-injection -- The period belongs to the validated candle-period union.
|
|
from.setUTCMonth(from.getUTCMonth() - candleMonths[period]);
|
|
}
|
|
const range =
|
|
screen.candleRange ??
|
|
(period === 'all'
|
|
? instrument.historyFrom
|
|
? { from: instrument.historyFrom, to: now }
|
|
: null
|
|
: { from, to: now });
|
|
if (range) {
|
|
sources.push({
|
|
...identity,
|
|
kind: 'candles',
|
|
...range,
|
|
priority: 'screen',
|
|
});
|
|
} else {
|
|
unavailable.push({
|
|
resourceKey: `candles/${instrument.uid}/all`,
|
|
reason: 'history_start_unavailable',
|
|
});
|
|
}
|
|
}
|
|
if (['account', 'bond', 'events', 'stock'].includes(screen.screen)) {
|
|
const day =
|
|
screen.screen === 'stock' || screen.screen === 'bond'
|
|
? moscowDate(now)
|
|
: now.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
|
|
const from =
|
|
screen.screen === 'events' && screen.from
|
|
? new Date(`${screen.from}T00:00:00Z`)
|
|
: new Date(`${day}T00:00:00Z`);
|
|
const to =
|
|
screen.screen === 'events' && screen.to
|
|
? new Date(`${screen.to}T00:00:00Z`)
|
|
: new Date(from);
|
|
if (
|
|
!(screen.screen === 'events' && screen.to) &&
|
|
screen.screen !== 'stock' &&
|
|
screen.screen !== 'bond'
|
|
) {
|
|
to.setUTCDate(to.getUTCDate() + defaultEventDays);
|
|
}
|
|
if (screen.screen === 'stock' || screen.screen === 'bond') {
|
|
from.setUTCFullYear(from.getUTCFullYear() - incomeHistoryYears);
|
|
to.setUTCFullYear(to.getUTCFullYear() + FORECAST_YEARS);
|
|
}
|
|
sources.push({
|
|
...identity,
|
|
kind: 'calendar',
|
|
from,
|
|
to,
|
|
priority: 'screen',
|
|
});
|
|
}
|
|
}
|
|
if (!force && !['bonds', 'stocks'].includes(screen.screen)) {
|
|
sources.push(
|
|
...accountHistory(accounts, now).map((range) => ({
|
|
...range,
|
|
kind: 'operations' as const,
|
|
priority: 'history' as const,
|
|
})),
|
|
);
|
|
}
|
|
const priorities = { discovery: 0, screen: 1, history: 2 };
|
|
sources.sort(
|
|
(left, right) => priorities[left.priority] - priorities[right.priority],
|
|
);
|
|
return { sources, unavailable, now };
|
|
}
|
|
|
|
export function accountHistory(
|
|
accounts: Account[],
|
|
now: Date,
|
|
): OperationInterval[] {
|
|
return accounts.flatMap((account) => {
|
|
const from = new Date(
|
|
account.openedOn
|
|
? `${account.openedOn}T00:00:00Z`
|
|
: '1970-01-01T00:00:00Z',
|
|
);
|
|
return from < now ? [{ accountId: account.accountId, from, to: now }] : [];
|
|
});
|
|
}
|
|
|
|
export function ledgerRange(
|
|
fromValue: string | undefined,
|
|
toValue: string | undefined,
|
|
instrumentUid: string | undefined,
|
|
accounts: Account[],
|
|
now: Date,
|
|
) {
|
|
const opening = accounts.every((account) => account.openedOn)
|
|
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
|
? accounts.map((account) => account.openedOn ?? '').sort()[0]
|
|
: undefined;
|
|
const from = fromValue
|
|
? new Date(fromValue)
|
|
: instrumentUid
|
|
? new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z')
|
|
: new Date(Date.UTC(now.getUTCFullYear(), JANUARY_INDEX, FIRST_DAY_OF_MONTH));
|
|
const end = toValue
|
|
? new Date(
|
|
/^\d{4}-\d{2}-\d{2}$/.test(toValue)
|
|
? `${toValue}T23:59:59.999Z`
|
|
: toValue,
|
|
)
|
|
: now;
|
|
return {
|
|
from,
|
|
to: new Date(Math.min(end.valueOf() + (toValue ? EXCLUSIVE_END_OFFSET_MS : NO_END_OFFSET_MS), now.valueOf())),
|
|
};
|
|
}
|