refactor: centralize domain values and enforce numeric lint

This commit is contained in:
2026-09-13 14:37:39 +03:00
parent 8ba005346d
commit 8637ad2f34
44 changed files with 543 additions and 174 deletions
@@ -1,3 +1,4 @@
import { MILLISECONDS_PER_DAY } from '../../../common/time';
import { BadRequestException } from '@nestjs/common';
import {
DateOnlyRange,
@@ -6,6 +7,12 @@ import {
utcDate,
} from '../investment-calendar';
const DAILY_BUCKET_MAX_DAYS = 45;
const WEEKLY_BUCKET_MAX_DAYS = 180;
const DAYS_PER_WEEK = 7;
const ISO_MONTH_LENGTH = 7;
const ISO_YEAR_LENGTH = 4;
export type IncomeBucketGranularity = 'day' | 'week' | 'month';
export function planIncomePeriod(
@@ -36,9 +43,9 @@ export function planIncomePeriod(
previous,
yearAgo: yearAgoRange,
granularity:
rangeDays(selected) <= 45
rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS
? 'day'
: rangeDays(selected) <= 180
: rangeDays(selected) <= WEEKLY_BUCKET_MAX_DAYS
? 'week'
: 'month',
};
@@ -53,11 +60,19 @@ export function rangeBuckets(
const buckets: DateOnlyRange[] = [];
let cursor =
granularity === 'week'
? addDays(range.from, -((utcDate(range.from).getUTCDay() + 6) % 7))
: `${range.from.slice(0, 7)}-01`;
? addDays(
range.from,
-(
(utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - 1) %
DAYS_PER_WEEK
),
)
: `${range.from.slice(0, ISO_MONTH_LENGTH)}-01`;
while (cursor <= range.to) {
const end =
granularity === 'week' ? addDays(cursor, 6) : endOfMonth(cursor);
granularity === 'week'
? addDays(cursor, DAYS_PER_WEEK - 1)
: endOfMonth(cursor);
buckets.push({
from: cursor < range.from ? range.from : cursor,
to: end > range.to ? range.to : end,
@@ -71,7 +86,7 @@ export function rangeDays(range: DateOnlyRange): number {
return (
Math.round(
(utcDate(range.to).valueOf() - utcDate(range.from).valueOf()) /
86_400_000,
MILLISECONDS_PER_DAY,
) + 1
);
}
@@ -100,7 +115,7 @@ function dates(range: DateOnlyRange): string[] {
}
function firstOfMonth(value: string): string {
return `${value.slice(0, 7)}-01`;
return `${value.slice(0, ISO_MONTH_LENGTH)}-01`;
}
function endOfMonth(value: string): string {
@@ -126,7 +141,7 @@ function shiftDays(range: DateOnlyRange, days: number): DateOnlyRange {
function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
const shift = (value: string) => {
const [year, month, day] = dateParts(value);
const result = `${String(year - 1).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const result = `${String(year - 1).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
return utcDate(result).toISOString().slice(0, 10) === result
? result
: null;
@@ -1,16 +1,21 @@
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import { ServiceUnavailableException } from '@nestjs/common';
import type {
TbankCandle,
TbankTimestamp,
} from '../../../../integrations/tbank/tbank.api';
const MOSCOW_OFFSET_MS = 10_800_000;
export function timestamp(value?: TbankTimestamp): string | null {
const seconds = Number(value?.seconds);
return Number.isFinite(seconds) && seconds > 0
? new Date(seconds * 1000).toISOString()
? new Date(seconds * MILLISECONDS_PER_SECOND).toISOString()
: null;
}
export function moscowDay(iso: string): string {
return new Date(Date.parse(iso) + 3 * 3_600_000).toISOString().slice(0, 10);
return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS)
.toISOString()
.slice(0, 10);
}
export function previousClose(
candles: TbankCandle[],
@@ -13,6 +13,9 @@ import {
} from '../../portfolio/portfolio.api';
import { StockPositionDto } from '../stocks/presentation/stock.dto';
const PRICE_DECIMAL_PLACES = 9;
const RETURN_PERCENT_DECIMAL_PLACES = 6;
export function instrumentPosition(
portfolio: PortfolioDto,
uid: string,
@@ -41,7 +44,11 @@ export function instrumentPosition(
? {
currency: totalCost.currency,
value: formatDecimal(
divide(decimalFromString(totalCost.value), quantity, 9),
divide(
decimalFromString(totalCost.value),
quantity,
PRICE_DECIMAL_PLACES,
),
),
}
: null,
@@ -103,7 +110,7 @@ function percent(
divide(
multiply(decimalFromString(result.value), decimalFromString('100')),
decimalFromString(base.value),
6,
RETURN_PERCENT_DECIMAL_PLACES,
),
);
}
@@ -19,6 +19,8 @@ import { BondDto, BondSearchResultDto } from '../presentation/bond.dto';
type IdentifiedBond = TbankBond & { uid: string };
import { money } from '../infrastructure/bond-money.mapper';
const SEARCH_RESULTS_LIMIT = 30;
@Injectable()
export class BondCatalogService {
private readonly cache = new LRUCache<string, Promise<object>>({
@@ -33,7 +35,7 @@ export class BondCatalogService {
return this.cached(userId, `search:${query}`, async (connection) => {
const { token } = connection;
const found = await this.tbank.findBonds(token, query);
const bonds = selectBonds(found, query).slice(0, 30);
const bonds = selectBonds(found, query).slice(0, SEARCH_RESULTS_LIMIT);
if (!bonds.length) return [];
// Batch prices and share metadata with detail sections. Resolve readable
// aliases only when an issue is opened, not for every search result.
@@ -27,6 +27,10 @@ import {
import { BondMarketService } from './bond-market.service';
import { money } from '../infrastructure/bond-money.mapper';
import { BondCatalogService } from './bond-catalog.service';
const ISO_YEAR_LENGTH = 4;
const INCOME_HISTORY_YEARS = 5;
const COUPON_YIELD_DECIMAL_PLACES = 4;
@Injectable()
export class BondIncomeService {
constructor(
@@ -42,8 +46,8 @@ export class BondIncomeService {
const instrument = await this.catalog.bond(userId, connection, uid);
const { ticker, isin, classCode } = instrument;
const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`;
const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`;
const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
const unavailable: string[] = [];
const [events, quote, effectiveYield, creditRating] = await Promise.all([
optional(
@@ -94,7 +98,7 @@ export class BondIncomeService {
decimalFromString(String(couponPeriodDays)),
decimalFromString(clean.value),
),
4,
COUPON_YIELD_DECIMAL_PLACES,
),
)
: null;
@@ -137,8 +141,8 @@ export class BondIncomeService {
return this.catalog.cached(userId, `coupons:${uid}`, async (connection) => {
await this.catalog.bond(userId, connection, uid);
const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`;
const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`;
const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
const unavailable: string[] = [];
const [events, position] = await Promise.all([
this.couponEvents(userId, connection, uid, from, to),
@@ -24,6 +24,12 @@ const dayMs = 86_400_000;
import { money } from '../infrastructure/bond-money.mapper';
import { BondCatalogService } from './bond-catalog.service';
const PREVIOUS_CLOSE_LOOKBACK_DAYS = 20;
const HALF_YEAR_MONTHS = 6;
const YEAR_MONTHS = 12;
const CANDLE_WINDOW_DAYS = 365;
const PRICE_DECIMAL_PLACES = 9;
@Injectable()
export class BondMarketService {
constructor(
@@ -66,7 +72,7 @@ export class BondMarketService {
decimalFromString(nominal.value),
),
decimalFromString('100'),
9,
PRICE_DECIMAL_PLACES,
),
),
}
@@ -90,7 +96,9 @@ export class BondMarketService {
await this.tbank.getCandles(
token,
uid,
new Date(Date.parse(priceAt) - 20 * dayMs),
new Date(
Date.parse(priceAt) - PREVIOUS_CLOSE_LOOKBACK_DAYS * dayMs,
),
new Date(priceAt),
),
priceAt,
@@ -134,7 +142,11 @@ export class BondMarketService {
const from = new Date(to);
from.setUTCMonth(
from.getUTCMonth() -
(period === 'month' ? 1 : period === 'halfYear' ? 6 : 12),
(period === 'month'
? 1
: period === 'halfYear'
? HALF_YEAR_MONTHS
: YEAR_MONTHS),
);
if (period === 'all') {
if (!historyFrom)
@@ -147,7 +159,10 @@ export class BondMarketService {
const collected = new Map<string, BondCandlesDto['candles'][number]>();
// Conservative one-year windows also work with older deployed RPC limits.
for (let start = from.valueOf(); start < to.valueOf();) {
const end = Math.min(start + 365 * dayMs, to.valueOf());
const end = Math.min(
start + CANDLE_WINDOW_DAYS * dayMs,
to.valueOf(),
);
for (const candle of await this.tbank.getCandles(
token,
uid,
@@ -15,6 +15,8 @@ import { decimal } from '../../../../../integrations/tbank/decimal';
import { optional, timestamp } from '../../application/instrument-market';
import { StockDto, StockSearchResultDto } from '../presentation/stock.dto';
const SEARCH_RESULTS_LIMIT = 30;
type IdentifiedShare = TbankShare & { uid: string };
@Injectable()
@@ -30,7 +32,7 @@ export class StockCatalogService {
search(userId: string, query: string): Promise<StockSearchResultDto[]> {
return this.cached(userId, `search:${query}`, async (token) => {
const found = await this.tbank.findShares(token, query);
const shares = selectShares(found, query).slice(0, 30);
const shares = selectShares(found, query).slice(0, SEARCH_RESULTS_LIMIT);
if (!shares.length) return [];
// One market-data request for the list; failed enrichment must not hide a catalog result.
const [details, prices] = await Promise.all([
@@ -20,6 +20,9 @@ import {
} from '../presentation/stock.dto';
import { StockCatalogService } from './stock-catalog.service';
const ISO_YEAR_LENGTH = 4;
const INCOME_HISTORY_YEARS = 5;
@Injectable()
export class StockIncomeService {
constructor(
@@ -36,8 +39,8 @@ export class StockIncomeService {
return this.catalog.cached(userId, `dividends:${uid}`, async (token) => {
await this.catalog.share(token, uid);
const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`;
const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`;
const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
const unavailable: string[] = [];
const [events, position] = await Promise.all([
this.tbank.getDividends(token, uid, from, to),
@@ -22,6 +22,12 @@ import {
const dayMs = 86_400_000;
import { StockCatalogService } from './stock-catalog.service';
const PREVIOUS_CLOSE_LOOKBACK_DAYS = 20;
const HALF_YEAR_MONTHS = 6;
const YEAR_MONTHS = 12;
const CANDLE_WINDOW_DAYS = 365;
const CHANGE_PERCENT_DECIMAL_PLACES = 6;
@Injectable()
export class StockMarketService {
constructor(
@@ -58,7 +64,9 @@ export class StockMarketService {
await this.tbank.getCandles(
token,
uid,
new Date(end.valueOf() - 20 * dayMs),
new Date(
end.valueOf() - PREVIOUS_CLOSE_LOOKBACK_DAYS * dayMs,
),
end,
),
priceAt,
@@ -97,7 +105,7 @@ export class StockMarketService {
divide(
multiply(difference, decimalFromString('100')),
decimalFromString(previousPrice.value),
6,
CHANGE_PERCENT_DECIMAL_PLACES,
),
)
: null,
@@ -121,7 +129,11 @@ export class StockMarketService {
const from = new Date(to);
from.setUTCMonth(
from.getUTCMonth() -
(period === 'month' ? 1 : period === 'halfYear' ? 6 : 12),
(period === 'month'
? 1
: period === 'halfYear'
? HALF_YEAR_MONTHS
: YEAR_MONTHS),
);
if (period === 'all') {
if (!historyFrom)
@@ -134,7 +146,10 @@ export class StockMarketService {
const collected = new Map<string, StockCandlesDto['candles'][number]>();
// Conservative one-year windows also work with older deployed RPC limits.
for (let start = from.valueOf(); start < to.valueOf();) {
const end = Math.min(start + 365 * dayMs, to.valueOf());
const end = Math.min(
start + CANDLE_WINDOW_DAYS * dayMs,
to.valueOf(),
);
for (const candle of await this.tbank.getCandles(
token,
uid,
@@ -11,25 +11,27 @@ import {
import { MoneyDto } from '../../../../../common/money.dto';
import { PortfolioPositionDto } from '../../../portfolio/portfolio.api';
const MAX_INSTRUMENT_QUERY_LENGTH = 80;
export class StockSearchQueryDto {
@ApiProperty({ minLength: 2, maxLength: 80 })
@ApiProperty({ minLength: 2, maxLength: MAX_INSTRUMENT_QUERY_LENGTH })
@Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value,
)
@IsString()
@MinLength(2)
@MaxLength(80)
@MaxLength(MAX_INSTRUMENT_QUERY_LENGTH)
declare query: string;
}
export class StockTickerParamsDto {
@ApiProperty({
minLength: 1,
maxLength: 80,
maxLength: MAX_INSTRUMENT_QUERY_LENGTH,
pattern: '^[A-Za-z0-9][A-Za-z0-9._@+-]*$',
})
@IsString()
@MinLength(1)
@MaxLength(80)
@MaxLength(MAX_INSTRUMENT_QUERY_LENGTH)
@Matches(/^[A-Za-z0-9][A-Za-z0-9._@+-]*$/)
declare ticker: string;
}
+21 -2
View File
@@ -1,3 +1,10 @@
const MOSCOW_UTC_OFFSET_HOURS = 3;
const MOSCOW_DAY_END_UTC = {
hours: 20,
minutes: 59,
seconds: 59,
milliseconds: 999,
};
export type DateOnlyRange = { from: string; to: string };
const moscow = 'Europe/Moscow';
export function moscowDate(value: Date): string {
@@ -14,9 +21,21 @@ export function moscowDate(value: Date): string {
export function dateOnlyBounds(range: DateOnlyRange): { from: Date; to: Date } {
const from = dateParts(range.from);
const end = dateParts(range.to);
const to = new Date(Date.UTC(end[0], end[1] - 1, end[2], 20, 59, 59, 999));
const to = new Date(
Date.UTC(
end[0],
end[1] - 1,
end[2],
MOSCOW_DAY_END_UTC.hours,
MOSCOW_DAY_END_UTC.minutes,
MOSCOW_DAY_END_UTC.seconds,
MOSCOW_DAY_END_UTC.milliseconds,
),
);
return {
from: new Date(Date.UTC(from[0], from[1] - 1, from[2], -3)),
from: new Date(
Date.UTC(from[0], from[1] - 1, from[2], -MOSCOW_UTC_OFFSET_HOURS),
),
to: to > new Date() ? new Date() : to,
};
}
@@ -1,3 +1,4 @@
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import {
BadRequestException,
Injectable,
@@ -15,6 +16,7 @@ import { AccountReferenceDto } from '../../account-reference.dto';
import { DateOnlyRange, dateOnlyBounds } from '../../investment-calendar';
import {
BrokerageOperationDto,
DEFAULT_OPERATIONS_PAGE_SIZE,
BrokerageOperationsPageDto,
BrokerageOperationsQueryDto,
} from '../presentation/brokerage-operation.dto';
@@ -23,6 +25,8 @@ import {
operationCategory,
} from '../infrastructure/brokerage-operation.mapper';
const CONTINUATION_TTL_MS = 300_000;
type OperationStream = {
account: AccountReferenceDto;
pending: BrokerageOperationDto[];
@@ -54,7 +58,7 @@ export class BrokerageOperationsService {
AllOperationsContinuation
>({
max: 100,
ttl: 5 * 60_000,
ttl: CONTINUATION_TTL_MS,
ttlAutopurge: true,
});
@@ -86,7 +90,10 @@ export class BrokerageOperationsService {
query.instrumentUid && !query.from
? {
...range,
from: new Date(Number(account.openedDate?.seconds ?? 0) * 1000),
from: new Date(
Number(account.openedDate?.seconds ?? 0) *
MILLISECONDS_PER_SECOND,
),
}
: range,
),
@@ -108,7 +115,7 @@ export class BrokerageOperationsService {
from: query.from ?? null,
to: query.to ?? null,
categories: [...new Set(query.categories ?? [])].sort(),
limit: query.limit ?? 100,
limit: query.limit ?? DEFAULT_OPERATIONS_PAGE_SIZE,
});
const continuation = query.cursor
? this.continuations.get(query.cursor)
@@ -178,7 +185,7 @@ export class BrokerageOperationsService {
): Promise<BrokerageOperationsPageDto> {
const items: BrokerageOperationDto[] = [];
// Each stream retains unconsumed rows. A page boundary never discards them.
while (items.length < (query.limit ?? 100)) {
while (items.length < (query.limit ?? DEFAULT_OPERATIONS_PAGE_SIZE)) {
for (const stream of streams) {
while (!stream.pending.length && !stream.finished) {
const page = await this.page(
@@ -324,7 +331,9 @@ export class BrokerageOperationsService {
accountId,
async ({ token, generation, account }) => {
const accountOpenedAt = account.openedDate?.seconds
? new Date(Number(account.openedDate.seconds) * 1000)
? new Date(
Number(account.openedDate.seconds) * MILLISECONDS_PER_SECOND,
)
: undefined;
const selected =
typeof ranges === 'function' ? ranges(accountOpenedAt) : ranges;
@@ -395,7 +404,7 @@ export class BrokerageOperationsService {
from: seconds(range.from),
to: seconds(range.to, /^\d{4}-\d{2}-\d{2}$/.test(query.to ?? '')),
cursor: query.cursor,
limit: query.limit ?? 100,
limit: query.limit ?? DEFAULT_OPERATIONS_PAGE_SIZE,
state: 'OPERATION_STATE_EXECUTED',
...(query.instrumentUid ? { instrumentId: query.instrumentUid } : {}),
});
@@ -480,7 +489,7 @@ function cacheKey(
categories,
instrumentUid: query.instrumentUid ?? null,
cursor: query.cursor ?? null,
limit: query.limit ?? 100,
limit: query.limit ?? DEFAULT_OPERATIONS_PAGE_SIZE,
});
}
@@ -493,7 +502,7 @@ function dateBoundary(value: string | undefined, end: boolean): Date | null {
function seconds(value: Date, endOfSecond = false) {
return {
seconds: Math.floor(value.valueOf() / 1000).toString(),
seconds: Math.floor(value.valueOf() / MILLISECONDS_PER_SECOND).toString(),
...(endOfSecond ? { nanos: 999_999_999 } : {}),
};
}
@@ -1,3 +1,4 @@
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import { formatDecimal } from '../../../../common/decimal';
import {
TbankMoney,
@@ -84,5 +85,7 @@ function money(value: TbankMoney | undefined) {
function timestamp(
value: { seconds?: string | number; nanos?: number } | undefined,
) {
return new Date(Number(value?.seconds ?? 0) * 1000).toISOString();
return new Date(
Number(value?.seconds ?? 0) * MILLISECONDS_PER_SECOND,
).toISOString();
}
@@ -16,6 +16,10 @@ import { MoneyDto } from '../../../../common/money.dto';
import { AccountReferenceDto } from '../../account-reference.dto';
const MAX_CURSOR_LENGTH = 4096;
const MAX_OPERATIONS_PAGE_SIZE = 1000;
export const DEFAULT_OPERATIONS_PAGE_SIZE = 100;
export const operationCategories = [
'trade',
'income',
@@ -58,15 +62,19 @@ export class BrokerageOperationsQueryDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(4096)
@MaxLength(MAX_CURSOR_LENGTH)
cursor?: string;
@ApiPropertyOptional({ minimum: 1, maximum: 1000, default: 100 })
@ApiPropertyOptional({
minimum: 1,
maximum: MAX_OPERATIONS_PAGE_SIZE,
default: DEFAULT_OPERATIONS_PAGE_SIZE,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(1000)
@Max(MAX_OPERATIONS_PAGE_SIZE)
limit?: number;
}
@@ -6,6 +6,9 @@ import {
import type { Money } from '../../../../common/money.types';
const SHARE_PERCENT_DECIMAL_PLACES = 6;
const PERCENT_SCALED_FACTOR = 100_000_000n;
const categoryTypes = ['STOCK', 'BOND', 'OTHER', 'CASH'] as const;
// Build from positions and cash, never from totals (which already include cash).
@@ -59,10 +62,10 @@ export function portfolioStructure(
value:
(value.value *
10n ** BigInt(total.scale - value.scale) *
100_000_000n +
PERCENT_SCALED_FACTOR +
total.value / 2n) /
total.value,
scale: 6,
scale: SHARE_PERCENT_DECIMAL_PLACES,
})
: null,
}));
@@ -4,6 +4,7 @@ import {
Delete,
Get,
HttpCode,
HttpStatus,
Put,
Req,
UseGuards,
@@ -47,7 +48,7 @@ export class TbankConnectionController {
}
@Delete()
@HttpCode(204)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiNoContentResponse()
delete(@Req() request: Request & { user: AccessPayload }): Promise<void> {
return this.connection.delete(request.user.sub);
@@ -1,11 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
const MAX_TOKEN_LENGTH = 4096;
export class PutTbankConnectionDto {
@ApiProperty({ writeOnly: true })
@IsString()
@IsNotEmpty()
@MaxLength(4096)
@MaxLength(MAX_TOKEN_LENGTH)
declare token: string;
}
@@ -1,3 +1,4 @@
import { MILLISECONDS_PER_SECOND } from '../../../common/time';
import { BadRequestException, Injectable } from '@nestjs/common';
import {
add,
@@ -25,12 +26,14 @@ import {
UpcomingEventsQueryDto,
} from './upcoming-events.dto';
const DEFAULT_EVENT_WINDOW_DAYS = 30;
type Position = NonNullable<TbankPortfolio['positions']>[number];
type CachedEntry<T> = { expiresAt: number; promise: Promise<T> };
const positionsBatchSize = 5;
const dividendCacheTtlMs = 15 * 60_000;
const instrumentCacheTtlMs = 24 * 60 * 60_000;
const dividendCacheTtlMs = 900_000;
const instrumentCacheTtlMs = 86_400_000;
@Injectable()
export class UpcomingEventsService {
@@ -242,7 +245,9 @@ export class UpcomingEventsService {
return {
...metadata,
type: 'dividend',
date: new Date(Number(event.recordDate?.seconds) * 1000)
date: new Date(
Number(event.recordDate?.seconds) * MILLISECONDS_PER_SECOND,
)
.toISOString()
.slice(0, 10),
dateKind: 'registry',
@@ -287,7 +292,7 @@ function supported(position: Position): boolean {
function dateWindow(query: UpcomingEventsQueryDto) {
const from = query.from ?? new Date().toISOString().slice(0, 10);
const end = new Date(`${from}T00:00:00.000Z`);
end.setUTCDate(end.getUTCDate() + 30);
end.setUTCDate(end.getUTCDate() + DEFAULT_EVENT_WINDOW_DAYS);
const to = query.to ?? end.toISOString().slice(0, 10);
if (from > to)
throw new BadRequestException({ code: 'INVALID_EVENTS_PERIOD' });