feat!: persist T-Bank data and synchronize reads
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Store account membership, portfolio, operations, forecasts and market sources in SQLite. Coalesce scoped refreshes, fence connection revisions, preserve complete financial snapshots and stabilize operation period state. BREAKING CHANGE: investment reads return data/meta resource envelopes and locally persisted snapshots; consumers must use the matching OpenAPI contract and explicit synchronization endpoints.
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
import { planIncomePeriod } from './income/income.api';
|
||||
import { dateOnlyBounds, moscowDate } from './investment-calendar';
|
||||
import {
|
||||
operationIntervals,
|
||||
type OperationInterval,
|
||||
} from './operations/operations.api';
|
||||
|
||||
export type InvestmentScreen = {
|
||||
screen:
|
||||
| 'accounts'
|
||||
| 'account'
|
||||
| 'operations'
|
||||
| 'income'
|
||||
| 'events'
|
||||
| 'stocks'
|
||||
| 'bonds'
|
||||
| 'stock'
|
||||
| 'bond';
|
||||
accountId?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
instrumentUid?: string;
|
||||
query?: string;
|
||||
candlePeriod?: 'month' | 'halfYear' | 'year' | 'all';
|
||||
candleRange?: { from: Date; to: Date };
|
||||
};
|
||||
const defaultEventDays = 30;
|
||||
const incomeHistoryYears = 5;
|
||||
const candleMonths = { month: 1, halfYear: 6, year: 12 };
|
||||
|
||||
type Account = { accountId: string; openedOn: string | null };
|
||||
type Instrument = {
|
||||
uid: string;
|
||||
type: 'STOCK' | 'BOND';
|
||||
historyFrom: Date | null;
|
||||
};
|
||||
type Source = {
|
||||
kind:
|
||||
| 'membership'
|
||||
| 'portfolio'
|
||||
| 'operations'
|
||||
| 'metadata'
|
||||
| 'catalog'
|
||||
| 'quote'
|
||||
| 'company'
|
||||
| 'metrics'
|
||||
| 'calendar'
|
||||
| 'candles';
|
||||
accountId?: string;
|
||||
instrumentUid?: string;
|
||||
instrumentType?: 'STOCK' | 'BOND';
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
query?: string;
|
||||
priority: 'discovery' | 'screen' | 'history';
|
||||
};
|
||||
|
||||
// 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: { resourceKey: string; reason: 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,
|
||||
})),
|
||||
);
|
||||
const knownOpening =
|
||||
accounts.length && accounts.every((account) => account.openedOn)
|
||||
? new Date(
|
||||
`${accounts.map((account) => account.openedOn ?? '').sort()[0]}T00:00:00+03:00`,
|
||||
)
|
||||
: undefined;
|
||||
if (!context.membershipKnown) return { sources, unavailable, now };
|
||||
if (
|
||||
['accounts', 'account', 'events', 'stock', 'bond'].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,
|
||||
knownOpening,
|
||||
);
|
||||
for (const range of [
|
||||
period.selected,
|
||||
...(screen.screen === 'income'
|
||||
? [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])]
|
||||
: []),
|
||||
]) {
|
||||
const bounds = dateOnlyBounds(range);
|
||||
operations(
|
||||
bounds.from,
|
||||
new Date(Math.min(bounds.to.valueOf() + 1, now.valueOf())),
|
||||
);
|
||||
}
|
||||
if (screen.screen === 'income' && !period.yearAgo)
|
||||
unavailable.push({
|
||||
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
|
||||
reason: 'calendar_or_account_boundary',
|
||||
});
|
||||
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 (['stocks', 'bonds', 'stock', 'bond'].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')
|
||||
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', 'events', 'stock', 'bond'].includes(screen.screen)) {
|
||||
const day =
|
||||
screen.screen === 'stock' || screen.screen === 'bond'
|
||||
? moscowDate(now)
|
||||
: now.toISOString().slice(0, 10);
|
||||
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() + 1);
|
||||
}
|
||||
sources.push({
|
||||
...identity,
|
||||
kind: 'calendar',
|
||||
from,
|
||||
to,
|
||||
priority: 'screen',
|
||||
});
|
||||
}
|
||||
if (
|
||||
!force &&
|
||||
['account', 'operations', 'income', 'events'].includes(screen.screen)
|
||||
) {
|
||||
if (instrument.historyFrom)
|
||||
sources.push({
|
||||
...identity,
|
||||
kind: 'candles',
|
||||
from: instrument.historyFrom,
|
||||
to: now,
|
||||
priority: 'history',
|
||||
});
|
||||
else
|
||||
unavailable.push({
|
||||
resourceKey: `candles/${instrument.uid}/all`,
|
||||
reason: 'history_start_unavailable',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!force && !['stocks', 'bonds'].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)
|
||||
? 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(), 0, 1));
|
||||
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 ? 1 : 0), now.valueOf())),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user