refactor: replace ignored numeric literals with constants
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 2m17s
Conventional commits / commits (pull_request) Successful in 31s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 1m56s
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 2m17s
Conventional commits / commits (pull_request) Successful in 31s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 1m56s
This commit is contained in:
@@ -27,6 +27,11 @@ import { CandleRepository } from '../infrastructure/candle.repository';
|
||||
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||
import { timestamp } from './instrument-market';
|
||||
|
||||
const FIRST_BATCH_OFFSET = 0;
|
||||
const BACKGROUND_SYNC_PRIORITY = 2;
|
||||
const REPLACED_WINDOW_COUNT = 1;
|
||||
const NEXT_WINDOW_OFFSET = 1;
|
||||
|
||||
type CandleInput = {
|
||||
instrumentType: 'bond' | 'share';
|
||||
period: 'all' | 'halfYear' | 'month' | 'year';
|
||||
@@ -246,7 +251,7 @@ export class CandleService {
|
||||
)
|
||||
.map(([uid]) => uid);
|
||||
for (
|
||||
let offset = 0;
|
||||
let offset = FIRST_BATCH_OFFSET;
|
||||
offset < uids.length;
|
||||
offset += prefetchReadBatchSize
|
||||
) {
|
||||
@@ -297,7 +302,7 @@ export class CandleService {
|
||||
now,
|
||||
).length
|
||||
) {
|
||||
await this.request(userId, revision, input, now, false, 2);
|
||||
await this.request(userId, revision, input, now, false, BACKGROUND_SYNC_PRIORITY);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -393,7 +398,7 @@ export class CandleService {
|
||||
const gaps = candleGaps(window, latest.coverage, now)
|
||||
.flatMap(candleWindows)
|
||||
.reverse();
|
||||
ranges.splice(index, 1, ...gaps);
|
||||
ranges.splice(index, REPLACED_WINDOW_COUNT, ...gaps);
|
||||
// eslint-disable-next-line security/detect-object-injection -- The index is the internal numeric window cursor.
|
||||
window = ranges[index];
|
||||
}
|
||||
@@ -430,7 +435,7 @@ export class CandleService {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
pass.nextWindow = index + 1;
|
||||
pass.nextWindow = index + NEXT_WINDOW_OFFSET;
|
||||
if (pass.nextWindow < ranges.length) return null;
|
||||
}
|
||||
return true;
|
||||
@@ -512,7 +517,7 @@ export class CandleService {
|
||||
{ ...producer.input, period: 'all' },
|
||||
producer.now,
|
||||
false,
|
||||
2,
|
||||
BACKGROUND_SYNC_PRIORITY,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ import {
|
||||
selectCatalogInstruments,
|
||||
} from './catalog-selection';
|
||||
|
||||
const UNAMBIGUOUS_MATCH_COUNT = 1;
|
||||
const NO_MATCHES = 0;
|
||||
const FIRST_RESULT_OFFSET = 0;
|
||||
|
||||
const tickerAmbiguityLimit = 2;
|
||||
|
||||
type Producer = {
|
||||
@@ -160,7 +164,7 @@ export class CatalogDiscoveryService {
|
||||
{ instrumentType, query: instrument.ticker, exactTicker: true },
|
||||
observe,
|
||||
);
|
||||
if (alias.data?.length === 1 && alias.data[0].uid === uid) {
|
||||
if (alias.data?.length === UNAMBIGUOUS_MATCH_COUNT && alias.data[0].uid === uid) {
|
||||
slug = instrument.ticker.toUpperCase();
|
||||
}
|
||||
dependencies.push(alias.meta);
|
||||
@@ -200,8 +204,8 @@ export class CatalogDiscoveryService {
|
||||
{ instrumentType, query: ticker, exactTicker: true },
|
||||
observe,
|
||||
);
|
||||
if (lookup.data?.length === 0) throw new NotFoundException();
|
||||
if (lookup.data && lookup.data.length > 1) {
|
||||
if (lookup.data?.length === NO_MATCHES) throw new NotFoundException();
|
||||
if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) {
|
||||
throw new ConflictException({
|
||||
code:
|
||||
instrumentType === 'share' ?
|
||||
@@ -346,7 +350,7 @@ export class CatalogDiscoveryService {
|
||||
input.query,
|
||||
input.instrumentType,
|
||||
).slice(
|
||||
0,
|
||||
FIRST_RESULT_OFFSET,
|
||||
input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit,
|
||||
),
|
||||
new Date(),
|
||||
@@ -364,7 +368,7 @@ export class CatalogDiscoveryService {
|
||||
if (success) {
|
||||
const saved = await this.repository.read(input);
|
||||
for (const observer of [attempt, ...producer.observers]) {
|
||||
if (!input.exactTicker || saved.data?.length === 1) {
|
||||
if (!input.exactTicker || saved.data?.length === UNAMBIGUOUS_MATCH_COUNT) {
|
||||
await this.enrichment.requestMany(
|
||||
observer.userId,
|
||||
observer.revision,
|
||||
|
||||
@@ -39,6 +39,10 @@ import {
|
||||
import { catalogSearchLimit } from './catalog-selection';
|
||||
import { previousClose, timestamp } from './instrument-market';
|
||||
|
||||
const MISSING_DATA_REVISION = 0;
|
||||
const FIRST_QUEUE_INDEX = 0;
|
||||
const DEQUEUED_ITEM_COUNT = 1;
|
||||
|
||||
type Producer = {
|
||||
attempt: TbankSyncAttempt;
|
||||
input: CatalogSource;
|
||||
@@ -147,7 +151,7 @@ export class CatalogEnrichmentService {
|
||||
price: payload?.price ?? null,
|
||||
meta: investmentResourceMeta({
|
||||
resourceKey: catalogScope(input),
|
||||
dataRevision: String(row?.dataRevision ?? 0),
|
||||
dataRevision: String(row?.dataRevision ?? MISSING_DATA_REVISION),
|
||||
availability: row ? 'complete' : 'missing',
|
||||
fetchedAt: row?.fetchedAt ?? null,
|
||||
freshForMs: tbankSourceTtlMs[sourcePolicy(input.kind)],
|
||||
@@ -485,7 +489,7 @@ export class CatalogEnrichmentService {
|
||||
// One batch RPC for queued prices sharing the same authorized producer.
|
||||
if (producer.input.kind === 'last-price') {
|
||||
for (
|
||||
let i = 0;
|
||||
let i = FIRST_QUEUE_INDEX;
|
||||
i < this.queue.length && batch.length < catalogSearchLimit;
|
||||
) {
|
||||
// eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only.
|
||||
@@ -496,7 +500,7 @@ export class CatalogEnrichmentService {
|
||||
next.attempt.revision === producer.attempt.revision
|
||||
) {
|
||||
batch.push(next);
|
||||
this.queue.splice(i, 1);
|
||||
this.queue.splice(i, DEQUEUED_ITEM_COUNT);
|
||||
} else i++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,19 +3,21 @@ import type {
|
||||
TbankTimestamp,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
|
||||
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||
|
||||
const MIN_TIMESTAMP_SECONDS_EXCLUSIVE = 0;
|
||||
|
||||
const MOSCOW_OFFSET_MS = 10_800_000;
|
||||
export function timestamp(value?: TbankTimestamp): null | string {
|
||||
const seconds = Number(value?.seconds);
|
||||
return Number.isFinite(seconds) && seconds > 0 ?
|
||||
return Number.isFinite(seconds) && seconds > MIN_TIMESTAMP_SECONDS_EXCLUSIVE ?
|
||||
new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() :
|
||||
null;
|
||||
}
|
||||
export function moscowDay(iso: string): string {
|
||||
return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
.slice(ISO_DATE_START, ISO_DATE_LENGTH);
|
||||
}
|
||||
export function previousClose(
|
||||
candles: TbankCandle[],
|
||||
@@ -29,7 +31,7 @@ export function previousClose(
|
||||
candle.isComplete &&
|
||||
candle.close &&
|
||||
time &&
|
||||
time.slice(0, 10) < moscowDay(priceAt)
|
||||
time.slice(ISO_DATE_START, ISO_DATE_LENGTH) < moscowDay(priceAt)
|
||||
);
|
||||
})
|
||||
.sort((a, b) => Number(b.time?.seconds) - Number(a.time?.seconds))[0] ??
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
} from '../../portfolio/portfolio.api';
|
||||
import { StockPositionDto } from '../stocks/presentation/stock.dto';
|
||||
|
||||
const EMPTY_POSITIONS_COUNT = 0;
|
||||
const ZERO_QUANTITY = 0n;
|
||||
const ZERO_AMOUNT = 0n;
|
||||
|
||||
const PRICE_DECIMAL_PLACES = 9;
|
||||
const RETURN_PERCENT_DECIMAL_PLACES = 6;
|
||||
|
||||
@@ -32,8 +36,8 @@ export function instrumentPosition(
|
||||
const currentValue = sumComplete(positions.map((p) => p.currentValue));
|
||||
const absoluteReturn = sumComplete(positions.map((p) => p.absoluteReturn));
|
||||
const positive =
|
||||
positions.length > 0 &&
|
||||
positions.every((p) => decimalFromString(p.quantity).value > 0n);
|
||||
positions.length > EMPTY_POSITIONS_COUNT &&
|
||||
positions.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY);
|
||||
return {
|
||||
quantity: formatDecimal(quantity),
|
||||
currentValue,
|
||||
@@ -55,7 +59,7 @@ export function instrumentPosition(
|
||||
accounts: positions.map((p) => ({
|
||||
...p,
|
||||
returnPercent:
|
||||
decimalFromString(p.quantity).value > 0n ?
|
||||
decimalFromString(p.quantity).value > ZERO_QUANTITY ?
|
||||
percent(p.absoluteReturn, cost(p)) :
|
||||
null,
|
||||
})),
|
||||
@@ -103,7 +107,7 @@ function percent(
|
||||
!base ||
|
||||
!result ||
|
||||
base.currency !== result.currency ||
|
||||
decimalFromString(base.value).value <= 0n
|
||||
decimalFromString(base.value).value <= ZERO_AMOUNT
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -14,8 +14,11 @@ import {
|
||||
|
||||
type IdentifiedBond = TbankBond & { uid: string };
|
||||
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
||||
import { money } from '../infrastructure/bond-money.mapper';
|
||||
|
||||
const MIN_LOT_EXCLUSIVE = 0;
|
||||
|
||||
@Injectable()
|
||||
export class BondCatalogService {
|
||||
constructor(private readonly discovery: CatalogDiscoveryService) {}
|
||||
@@ -100,13 +103,13 @@ function toBond(bond: IdentifiedBond): BondDto {
|
||||
currency: bond.currency?.toUpperCase() || null,
|
||||
bondType: bond.bondType || null,
|
||||
sector: bond.sector || null,
|
||||
lot: bond.lot && bond.lot > 0 ? bond.lot : null,
|
||||
lot: bond.lot && bond.lot > MIN_LOT_EXCLUSIVE ? bond.lot : null,
|
||||
historyFrom: timestamp(bond.first_1dayCandleDate),
|
||||
nominal: money(bond.nominal),
|
||||
initialNominal: money(bond.initialNominal),
|
||||
aci: money(bond.aciValue),
|
||||
maturityDate: timestamp(bond.maturityDate)?.slice(0, 10) ?? null,
|
||||
callDate: timestamp(bond.callDate)?.slice(0, 10) ?? null,
|
||||
maturityDate: timestamp(bond.maturityDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
|
||||
callDate: timestamp(bond.callDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
|
||||
couponQuantityPerYear: bond.couponQuantityPerYear ?? null,
|
||||
floatingCoupon: bond.floatingCouponFlag ?? null,
|
||||
perpetual: bond.perpetualFlag ?? null,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
formatDecimal,
|
||||
multiply,
|
||||
} from '../../../../../common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
||||
import {
|
||||
TbankAccessService,
|
||||
type TbankBond,
|
||||
@@ -30,6 +31,12 @@ import {
|
||||
BondPositionDto,
|
||||
} from '../presentation/bond.dto';
|
||||
import { bondQuote } from './bond-market.service';
|
||||
|
||||
const INCOME_FORECAST_YEARS = 1;
|
||||
const ZERO_QUANTITY = 0n;
|
||||
const MIN_COUPON_PERIOD_EXCLUSIVE = 0;
|
||||
const ZERO_AMOUNT = 0n;
|
||||
|
||||
const ISO_YEAR_LENGTH = 4;
|
||||
const INCOME_HISTORY_YEARS = 5;
|
||||
const COUPON_YIELD_DECIMAL_PLACES = 4;
|
||||
@@ -77,8 +84,8 @@ export class BondIncomeService {
|
||||
): Promise<BondCouponsResourceDto> {
|
||||
const connection = await this.access.local(userId);
|
||||
const today = moscowDay(new Date().toISOString());
|
||||
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 from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||
const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||
const portfolio = await this.portfolios.read(userId, 'all', observe);
|
||||
const sources = await this.sources.readMany(
|
||||
userId,
|
||||
@@ -139,13 +146,13 @@ export function bondCoupons(
|
||||
const { from, to, today } = window;
|
||||
const quantity =
|
||||
position?.accounts.length &&
|
||||
position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) ?
|
||||
position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) ?
|
||||
decimalFromString(position.quantity) :
|
||||
null;
|
||||
const coupons = events
|
||||
.map((event) => {
|
||||
const paymentDate = timestamp(event.couponDate)?.slice(0, 10) ?? null;
|
||||
const recordDate = timestamp(event.fixDate)?.slice(0, 10) ?? null;
|
||||
const paymentDate = timestamp(event.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
|
||||
const recordDate = timestamp(event.fixDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
|
||||
const perBond = money(event.payOneBond);
|
||||
const timing = !paymentDate ?
|
||||
('undated' as const) :
|
||||
@@ -198,10 +205,10 @@ export function bondMetrics(
|
||||
const effectiveYield = values.effectiveYield ?? null;
|
||||
const creditRating = values.creditRating ?? null;
|
||||
const today = moscowDay(new Date().toISOString());
|
||||
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||
const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||
const next = events
|
||||
?.filter((event) => {
|
||||
const date = timestamp(event.couponDate)?.slice(0, 10);
|
||||
const date = timestamp(event.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH);
|
||||
return date && date >= today && date <= to;
|
||||
})
|
||||
.sort(
|
||||
@@ -210,15 +217,15 @@ export function bondMetrics(
|
||||
const coupon = money(next?.payOneBond);
|
||||
const period = next?.couponPeriod;
|
||||
const couponPeriodDays =
|
||||
period && Number.isInteger(period) && period > 0 ? period : null;
|
||||
period && Number.isInteger(period) && period > MIN_COUPON_PERIOD_EXCLUSIVE ? period : null;
|
||||
const clean = quote?.cleanPrice;
|
||||
const couponYieldPercent =
|
||||
coupon &&
|
||||
couponPeriodDays &&
|
||||
clean &&
|
||||
coupon.currency === clean.currency &&
|
||||
decimalFromString(coupon.value).value >= 0n &&
|
||||
decimalFromString(clean.value).value > 0n ?
|
||||
decimalFromString(coupon.value).value >= ZERO_AMOUNT &&
|
||||
decimalFromString(clean.value).value > ZERO_AMOUNT ?
|
||||
formatDecimal(
|
||||
divide(
|
||||
multiply(
|
||||
@@ -242,7 +249,7 @@ export function bondMetrics(
|
||||
return {
|
||||
creditRating,
|
||||
coupon,
|
||||
couponDate: timestamp(next?.couponDate)?.slice(0, 10) ?? null,
|
||||
couponDate: timestamp(next?.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
|
||||
couponType,
|
||||
couponPeriodDays,
|
||||
couponYieldPercent,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
multiply,
|
||||
subtract,
|
||||
} from '../../../../../common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||
import { CandleService } from '../../application/candle.service';
|
||||
import {
|
||||
@@ -24,6 +25,8 @@ import {
|
||||
BondQuoteResourceDto,
|
||||
} from '../presentation/bond.dto';
|
||||
|
||||
const ZERO_AMOUNT = 0n;
|
||||
|
||||
const PRICE_DECIMAL_PLACES = 9;
|
||||
@Injectable()
|
||||
export class BondMarketService {
|
||||
@@ -80,7 +83,7 @@ export function bondQuote(
|
||||
const cleanPrice =
|
||||
pricePercent !== null &&
|
||||
nominal &&
|
||||
decimalFromString(nominal.value).value > 0n ?
|
||||
decimalFromString(nominal.value).value > ZERO_AMOUNT ?
|
||||
{
|
||||
currency: nominal.currency,
|
||||
value: formatDecimal(
|
||||
@@ -118,7 +121,7 @@ export function bondQuote(
|
||||
last?.price && previous?.close ?
|
||||
formatDecimal(subtract(decimal(last.price), decimal(previous.close))) :
|
||||
null,
|
||||
previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null,
|
||||
previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
|
||||
unavailable,
|
||||
observedAt,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||
|
||||
const FIRST_RANGE_END_INDEX = 1;
|
||||
|
||||
export type CandleRange = { from: Date; to: Date };
|
||||
export type CandleCoverage = CandleRange & {
|
||||
fetchedAt: Date;
|
||||
@@ -80,7 +82,7 @@ export function confirmedCandleCoverage(
|
||||
}
|
||||
}
|
||||
const sorted = [...boundaries].sort((a, b) => a - b);
|
||||
return sorted.slice(1).map((to, index) => {
|
||||
return sorted.slice(FIRST_RANGE_END_INDEX).map((to, index) => {
|
||||
// eslint-disable-next-line security/detect-object-injection -- The index comes from the sorted numeric boundary array.
|
||||
const from = sorted[index];
|
||||
return {
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
type SavedCandle,
|
||||
} from '../domain/candle-coverage';
|
||||
|
||||
const NO_UPDATED_ROWS = 0;
|
||||
|
||||
const provider = 'tbank';
|
||||
const interval = 'day';
|
||||
type CandleIntent = {
|
||||
@@ -323,7 +325,7 @@ export class CandleRepository {
|
||||
},
|
||||
data: { state: 'complete', errorCode: null },
|
||||
});
|
||||
return result.count > 0;
|
||||
return result.count > NO_UPDATED_ROWS;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ import type {
|
||||
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
|
||||
const UNCHANGED_REVISION_INCREMENT = 0;
|
||||
const REVISION_INCREMENT = 1;
|
||||
|
||||
export type CatalogQuery = {
|
||||
exactTicker?: boolean;
|
||||
instrumentType: 'bond' | 'share';
|
||||
@@ -205,7 +208,7 @@ export class CatalogDiscoveryRepository {
|
||||
ticker: instrument.ticker ?? null,
|
||||
fetchedAt: now,
|
||||
dataRevision: {
|
||||
increment: isDeepStrictEqual(previous.metadata, metadata) ? 0 : 1,
|
||||
increment: isDeepStrictEqual(previous.metadata, metadata) ? UNCHANGED_REVISION_INCREMENT : REVISION_INCREMENT,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -230,8 +233,8 @@ export class CatalogDiscoveryRepository {
|
||||
previousAlias?.matchedUids,
|
||||
matchedUids,
|
||||
) ?
|
||||
0 :
|
||||
1,
|
||||
UNCHANGED_REVISION_INCREMENT :
|
||||
REVISION_INCREMENT,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -257,7 +260,7 @@ export class CatalogDiscoveryRepository {
|
||||
payload,
|
||||
fetchedAt: now,
|
||||
dataRevision: {
|
||||
increment: isDeepStrictEqual(previous?.payload, payload) ? 0 : 1,
|
||||
increment: isDeepStrictEqual(previous?.payload, payload) ? UNCHANGED_REVISION_INCREMENT : REVISION_INCREMENT,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -390,7 +393,7 @@ export class CatalogDiscoveryRepository {
|
||||
payload,
|
||||
fetchedAt: now,
|
||||
dataRevision: {
|
||||
increment: isDeepStrictEqual(previous?.payload, payload) ? 0 : 1,
|
||||
increment: isDeepStrictEqual(previous?.payload, payload) ? UNCHANGED_REVISION_INCREMENT : REVISION_INCREMENT,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -416,8 +419,8 @@ export class CatalogDiscoveryRepository {
|
||||
previous?.matchedUids,
|
||||
instrumentUids,
|
||||
) ?
|
||||
0 :
|
||||
1,
|
||||
UNCHANGED_REVISION_INCREMENT :
|
||||
REVISION_INCREMENT,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -442,8 +445,8 @@ export class CatalogDiscoveryRepository {
|
||||
previous?.instrumentUids,
|
||||
instrumentUids,
|
||||
) ?
|
||||
0 :
|
||||
1,
|
||||
UNCHANGED_REVISION_INCREMENT :
|
||||
REVISION_INCREMENT,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
StockSearchResourceDto,
|
||||
} from '../presentation/stock.dto';
|
||||
|
||||
const MIN_LOT_EXCLUSIVE = 0;
|
||||
|
||||
type IdentifiedShare = TbankShare & { uid: string };
|
||||
|
||||
@Injectable()
|
||||
@@ -102,7 +104,7 @@ function stock(share: IdentifiedShare): StockDto {
|
||||
currency: share.currency?.toUpperCase() || null,
|
||||
shareType: share.shareType || null,
|
||||
sector: share.sector || null,
|
||||
lot: share.lot && share.lot > 0 ? share.lot : null,
|
||||
lot: share.lot && share.lot > MIN_LOT_EXCLUSIVE ? share.lot : null,
|
||||
historyFrom: timestamp(share.first_1dayCandleDate),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
formatDecimal,
|
||||
multiply,
|
||||
} from '../../../../../common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||
import {
|
||||
TbankAccessService,
|
||||
@@ -23,6 +24,10 @@ import {
|
||||
StockDividendsResourceDto,
|
||||
StockPositionDto,
|
||||
} from '../presentation/stock.dto';
|
||||
|
||||
const INCOME_FORECAST_YEARS = 1;
|
||||
const ZERO_QUANTITY = 0n;
|
||||
|
||||
const ISO_YEAR_LENGTH = 4;
|
||||
const INCOME_HISTORY_YEARS = 5;
|
||||
@Injectable()
|
||||
@@ -49,8 +54,8 @@ export class StockIncomeService {
|
||||
): Promise<StockDividendsResourceDto> {
|
||||
const connection = await this.access.local(userId);
|
||||
const today = moscowDay(new Date().toISOString());
|
||||
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 from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||
const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||
const portfolio = await this.portfolios.read(userId, 'all', observe);
|
||||
const sources = await this.sources.readMany(
|
||||
userId,
|
||||
@@ -112,15 +117,15 @@ export function stockDividends(
|
||||
const quantity =
|
||||
position &&
|
||||
position.accounts.length &&
|
||||
position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) ?
|
||||
position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) ?
|
||||
decimalFromString(position.quantity) :
|
||||
null;
|
||||
const dividends: StockDividendDto[] = events
|
||||
.filter((event) => event.dividendType?.toLowerCase() !== 'cancelled')
|
||||
.map((event) => {
|
||||
const recordDate = timestamp(event.recordDate)?.slice(0, 10) ?? null;
|
||||
const paymentDate = timestamp(event.paymentDate)?.slice(0, 10) ?? null;
|
||||
const lastBuyDate = timestamp(event.lastBuyDate)?.slice(0, 10) ?? null;
|
||||
const recordDate = timestamp(event.recordDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
|
||||
const paymentDate = timestamp(event.paymentDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
|
||||
const lastBuyDate = timestamp(event.lastBuyDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
|
||||
const currency = event.dividendNet?.currency?.toUpperCase();
|
||||
const perShare =
|
||||
currency && event.dividendNet ?
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
multiply,
|
||||
subtract,
|
||||
} from '../../../../../common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||
import { CandleService } from '../../application/candle.service';
|
||||
import {
|
||||
@@ -20,6 +21,8 @@ import {
|
||||
StockQuoteResourceDto,
|
||||
} from '../presentation/stock.dto';
|
||||
|
||||
const ZERO_AMOUNT = 0n;
|
||||
|
||||
const CHANGE_PERCENT_DECIMAL_PLACES = 6;
|
||||
@Injectable()
|
||||
export class StockMarketService {
|
||||
@@ -90,7 +93,7 @@ export function stockQuote(
|
||||
price,
|
||||
priceAt,
|
||||
tradingStatus,
|
||||
previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null,
|
||||
previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
|
||||
previousClose: previousPrice,
|
||||
change:
|
||||
difference && currency ?
|
||||
@@ -99,7 +102,7 @@ export function stockQuote(
|
||||
changePercent:
|
||||
difference &&
|
||||
previousPrice &&
|
||||
decimalFromString(previousPrice.value).value > 0n ?
|
||||
decimalFromString(previousPrice.value).value > ZERO_AMOUNT ?
|
||||
formatDecimal(
|
||||
divide(
|
||||
multiply(difference, decimalFromString('100')),
|
||||
|
||||
@@ -10,32 +10,36 @@ import {
|
||||
} from 'class-validator';
|
||||
|
||||
import { MoneyDto } from '../../../../../common/money.dto';
|
||||
import {
|
||||
MAX_INSTRUMENT_QUERY_LENGTH,
|
||||
MIN_INSTRUMENT_QUERY_LENGTH,
|
||||
} from '../../../instrument-query';
|
||||
import {
|
||||
InvestmentReadQueryDto,
|
||||
InvestmentResourceMetaDto,
|
||||
} from '../../../investment-resource.dto';
|
||||
import { PortfolioPositionDto } from '../../../portfolio/portfolio.api';
|
||||
|
||||
const MAX_INSTRUMENT_QUERY_LENGTH = 80;
|
||||
const MIN_TICKER_LENGTH = 1;
|
||||
|
||||
export class StockSearchQueryDto extends InvestmentReadQueryDto {
|
||||
@ApiProperty({ minLength: 2, maxLength: MAX_INSTRUMENT_QUERY_LENGTH })
|
||||
@ApiProperty({ minLength: MIN_INSTRUMENT_QUERY_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH })
|
||||
@Transform(({ value }: { value: unknown }) =>
|
||||
typeof value === 'string' ? value.trim() : value,
|
||||
)
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MinLength(MIN_INSTRUMENT_QUERY_LENGTH)
|
||||
@MaxLength(MAX_INSTRUMENT_QUERY_LENGTH)
|
||||
declare query: string;
|
||||
}
|
||||
export class StockTickerParamsDto {
|
||||
@ApiProperty({
|
||||
minLength: 1,
|
||||
minLength: MIN_TICKER_LENGTH,
|
||||
maxLength: MAX_INSTRUMENT_QUERY_LENGTH,
|
||||
pattern: '^[A-Za-z0-9][A-Za-z0-9._@+-]*$',
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MinLength(MIN_TICKER_LENGTH)
|
||||
@MaxLength(MAX_INSTRUMENT_QUERY_LENGTH)
|
||||
@Matches(/^[A-Z0-9][\w.@+-]*$/i)
|
||||
declare ticker: string;
|
||||
|
||||
Reference in New Issue
Block a user