refactor: centralize domain values and enforce numeric lint
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user