chore: strengthen backend eslint profile
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
import { planInvestmentScreen } from '../../investment-sync-plan';
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||
|
||||
import { formatDecimal } from '../../../../common/decimal';
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import {
|
||||
TbankAccessService,
|
||||
@@ -16,35 +16,36 @@ import {
|
||||
type TbankSyncAttempt,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||
import { planInvestmentScreen } from '../../investment-sync-plan';
|
||||
import {
|
||||
candleGaps,
|
||||
type CandleRange,
|
||||
candleWindows,
|
||||
utcDay,
|
||||
type CandleRange,
|
||||
} from '../domain/candle-coverage';
|
||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||
import { timestamp } from './instrument-market';
|
||||
|
||||
type CandleInput = {
|
||||
uid: string;
|
||||
instrumentType: 'share' | 'bond';
|
||||
period: 'month' | 'halfYear' | 'year' | 'all';
|
||||
instrumentType: 'bond' | 'share';
|
||||
period: 'all' | 'halfYear' | 'month' | 'year';
|
||||
range?: CandleRange;
|
||||
uid: string;
|
||||
};
|
||||
type Producer = {
|
||||
input: CandleInput;
|
||||
attempt: TbankSyncAttempt;
|
||||
now: Date;
|
||||
force: boolean;
|
||||
priority: number;
|
||||
input: CandleInput;
|
||||
now: Date;
|
||||
observers: TbankSyncAttempt[];
|
||||
pass?: {
|
||||
now: Date;
|
||||
force: boolean;
|
||||
ranges: CandleRange[];
|
||||
nextWindow: number;
|
||||
now: Date;
|
||||
ranges: CandleRange[];
|
||||
};
|
||||
priority: number;
|
||||
};
|
||||
const months = { month: 1, halfYear: 6, year: 12 };
|
||||
const tailFreshForMs = 60_000;
|
||||
@@ -61,18 +62,20 @@ function rangeFor(
|
||||
const from = utcDay(now);
|
||||
const history = timestamp(instrument.first_1dayCandleDate);
|
||||
if (input.period === 'all') {
|
||||
if (!history)
|
||||
if (!history) {
|
||||
throw new ServiceUnavailableException({
|
||||
code:
|
||||
input.instrumentType === 'share'
|
||||
? 'STOCK_HISTORY_START_UNAVAILABLE'
|
||||
: 'BOND_HISTORY_START_UNAVAILABLE',
|
||||
input.instrumentType === 'share' ?
|
||||
'STOCK_HISTORY_START_UNAVAILABLE' :
|
||||
'BOND_HISTORY_START_UNAVAILABLE',
|
||||
});
|
||||
}
|
||||
from.setTime(utcDay(new Date(history)).valueOf());
|
||||
} else {
|
||||
from.setUTCMonth(from.getUTCMonth() - months[input.period]);
|
||||
if (history && Date.parse(history) > from.valueOf())
|
||||
if (history && Date.parse(history) > from.valueOf()) {
|
||||
from.setTime(utcDay(new Date(history)).valueOf());
|
||||
}
|
||||
}
|
||||
return { from, to: new Date(utcDay(now).valueOf() + MILLISECONDS_PER_DAY) };
|
||||
}
|
||||
@@ -102,16 +105,18 @@ export class CandleService {
|
||||
true,
|
||||
['detail'],
|
||||
);
|
||||
if (detail.meta.availability === 'complete' && !detail.instrument)
|
||||
if (detail.meta.availability === 'complete' && !detail.instrument) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const now = new Date();
|
||||
const range = detail.instrument
|
||||
? rangeFor(input, detail.instrument, now)
|
||||
: null;
|
||||
const range = detail.instrument ?
|
||||
rangeFor(input, detail.instrument, now) :
|
||||
null;
|
||||
const saved = range ? await this.repository.read(input.uid, range) : null;
|
||||
const gaps = range && saved ? candleGaps(range, saved.coverage, now) : [];
|
||||
if (!observe && (!saved || gaps.length))
|
||||
if (!observe && (!saved || gaps.length)) {
|
||||
await this.request(userId, revision, input, now);
|
||||
}
|
||||
const work = await this.repository.work(userId, revision, scope(input));
|
||||
const fetchedAt =
|
||||
saved?.coverage
|
||||
@@ -129,14 +134,14 @@ export class CandleService {
|
||||
});
|
||||
return {
|
||||
data:
|
||||
!saved || !covered
|
||||
? null
|
||||
: {
|
||||
!saved || !covered ?
|
||||
null :
|
||||
{
|
||||
candles: saved.data,
|
||||
currency:
|
||||
input.instrumentType === 'share'
|
||||
? detail.instrument?.currency?.toUpperCase() || null
|
||||
: null,
|
||||
input.instrumentType === 'share' ?
|
||||
detail.instrument?.currency?.toUpperCase() || null :
|
||||
null,
|
||||
historyFrom: timestamp(detail?.instrument?.first_1dayCandleDate),
|
||||
observedAt: fetchedAt?.toISOString() ?? now.toISOString(),
|
||||
},
|
||||
@@ -149,8 +154,8 @@ export class CandleService {
|
||||
work,
|
||||
coverage: {
|
||||
requested: range ? [publicRange(range)] : [],
|
||||
completeRanges: range
|
||||
? completeCoverage.flatMap((row) => {
|
||||
completeRanges: range ?
|
||||
completeCoverage.flatMap((row) => {
|
||||
const from = new Date(
|
||||
Math.max(row.from.valueOf(), range.from.valueOf()),
|
||||
);
|
||||
@@ -158,22 +163,22 @@ export class CandleService {
|
||||
Math.min(row.to.valueOf(), range.to.valueOf()),
|
||||
);
|
||||
return from < to ? [publicRange({ from, to })] : [];
|
||||
})
|
||||
: [],
|
||||
}) :
|
||||
[],
|
||||
missingRanges: missing.map(publicRange),
|
||||
asOf: fetchedAt?.toISOString() ?? null,
|
||||
historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate)
|
||||
? 'known'
|
||||
: 'unknown',
|
||||
historyState: complete
|
||||
? 'complete'
|
||||
: work?.state === 'failed'
|
||||
? 'failed'
|
||||
: work?.state === 'running'
|
||||
? 'loading'
|
||||
: covered
|
||||
? 'partial'
|
||||
: 'missing',
|
||||
historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate) ?
|
||||
'known' :
|
||||
'unknown',
|
||||
historyState: complete ?
|
||||
'complete' :
|
||||
work?.state === 'failed' ?
|
||||
'failed' :
|
||||
work?.state === 'running' ?
|
||||
'loading' :
|
||||
covered ?
|
||||
'partial' :
|
||||
'missing',
|
||||
},
|
||||
dependencies: [
|
||||
detail.meta,
|
||||
@@ -183,9 +188,9 @@ export class CandleService {
|
||||
dataRevision: String(row.dataRevision),
|
||||
availability: 'complete',
|
||||
fetchedAt: row.fetchedAt,
|
||||
freshForMs: row.isComplete
|
||||
? Number.POSITIVE_INFINITY
|
||||
: tailFreshForMs,
|
||||
freshForMs: row.isComplete ?
|
||||
Number.POSITIVE_INFINITY :
|
||||
tailFreshForMs,
|
||||
work: null,
|
||||
}),
|
||||
),
|
||||
@@ -197,13 +202,14 @@ export class CandleService {
|
||||
prefetch(
|
||||
userId: string,
|
||||
revision: string,
|
||||
positions: { instrumentUid?: string | null; type: string }[],
|
||||
positions: { instrumentUid?: null | string; type: string }[],
|
||||
) {
|
||||
const key = JSON.stringify([
|
||||
userId,
|
||||
revision,
|
||||
positions
|
||||
.map((position) => [position.instrumentUid, position.type])
|
||||
// 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.
|
||||
.sort(),
|
||||
]);
|
||||
const existing = this.prefetches.get(key);
|
||||
@@ -218,14 +224,14 @@ export class CandleService {
|
||||
private async prefetchPositions(
|
||||
userId: string,
|
||||
revision: string,
|
||||
positions: { instrumentUid?: string | null; type: string }[],
|
||||
positions: { instrumentUid?: null | string; type: string }[],
|
||||
) {
|
||||
const now = new Date();
|
||||
const unique = new Map(
|
||||
positions
|
||||
.filter(
|
||||
(position) =>
|
||||
position.instrumentUid && ['STOCK', 'BOND'].includes(position.type),
|
||||
position.instrumentUid && ['BOND', 'STOCK'].includes(position.type),
|
||||
)
|
||||
.map((position) => [position.instrumentUid ?? '', position]),
|
||||
);
|
||||
@@ -267,7 +273,7 @@ export class CandleService {
|
||||
instruments: [
|
||||
{
|
||||
uid,
|
||||
type: position.type as 'STOCK' | 'BOND',
|
||||
type: position.type as 'BOND' | 'STOCK',
|
||||
historyFrom: history ? new Date(history) : null,
|
||||
},
|
||||
],
|
||||
@@ -280,9 +286,9 @@ export class CandleService {
|
||||
) {
|
||||
const input = { uid, instrumentType, period: 'all' as const };
|
||||
const range =
|
||||
detail?.instrument && history
|
||||
? rangeFor(input, detail.instrument, now)
|
||||
: null;
|
||||
detail?.instrument && history ?
|
||||
rangeFor(input, detail.instrument, now) :
|
||||
null;
|
||||
if (
|
||||
!range ||
|
||||
candleGaps(
|
||||
@@ -290,8 +296,9 @@ export class CandleService {
|
||||
coverage.filter((row) => row.instrumentUid === uid),
|
||||
now,
|
||||
).length
|
||||
)
|
||||
) {
|
||||
await this.request(userId, revision, input, now, false, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -379,6 +386,7 @@ export class CandleService {
|
||||
}
|
||||
const { ranges, now, force } = pass;
|
||||
const index = pass.nextWindow;
|
||||
// eslint-disable-next-line security/detect-object-injection -- The index is the internal numeric window cursor.
|
||||
let window = ranges[index];
|
||||
if (window && !force) {
|
||||
const latest = await this.repository.read(input.uid, window);
|
||||
@@ -386,6 +394,7 @@ export class CandleService {
|
||||
.flatMap(candleWindows)
|
||||
.reverse();
|
||||
ranges.splice(index, 1, ...gaps);
|
||||
// eslint-disable-next-line security/detect-object-injection -- The index is the internal numeric window cursor.
|
||||
window = ranges[index];
|
||||
}
|
||||
if (window) {
|
||||
@@ -408,8 +417,9 @@ export class CandleService {
|
||||
},
|
||||
];
|
||||
});
|
||||
if (!(await this.repository.stage(attempt, window, rows, now)))
|
||||
if (!(await this.repository.stage(attempt, window, rows, now))) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!force &&
|
||||
!(await this.repository.publish(
|
||||
@@ -417,8 +427,9 @@ export class CandleService {
|
||||
{ uid: input.uid, ranges: [window] },
|
||||
false,
|
||||
))
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
pass.nextWindow = index + 1;
|
||||
if (pass.nextWindow < ranges.length) return null;
|
||||
}
|
||||
@@ -441,16 +452,18 @@ export class CandleService {
|
||||
}
|
||||
success = result;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
||||
await this.access.rejectMembershipToken(producer.attempt);
|
||||
}
|
||||
if (error instanceof ServiceUnavailableException) {
|
||||
const response = error.getResponse();
|
||||
if (
|
||||
typeof response === 'object' &&
|
||||
'code' in response &&
|
||||
typeof response.code === 'string'
|
||||
)
|
||||
) {
|
||||
code = response.code;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Seal publication with reservations so a late forced join cannot be
|
||||
@@ -458,8 +471,9 @@ export class CandleService {
|
||||
const finished = this.reservations.then(async () => {
|
||||
const pass = producer.pass;
|
||||
if (success && pass) {
|
||||
if (producer.force && (!pass.force || producer.now > pass.now))
|
||||
if (producer.force && (!pass.force || producer.now > pass.now)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
success = await this.repository.publish(producer.attempt, {
|
||||
uid: producer.input.uid,
|
||||
@@ -478,8 +492,9 @@ export class CandleService {
|
||||
observer,
|
||||
producer.attempt,
|
||||
))
|
||||
)
|
||||
) {
|
||||
await this.repository.fail(observer, code);
|
||||
}
|
||||
this.active.delete(observer.attemptId);
|
||||
}
|
||||
this.producers.delete(scope(producer.input));
|
||||
@@ -490,7 +505,7 @@ export class CandleService {
|
||||
this.queue.push(producer);
|
||||
continue;
|
||||
}
|
||||
if (success && !producer.force && producer.input.period !== 'all')
|
||||
if (success && !producer.force && producer.input.period !== 'all') {
|
||||
await this.request(
|
||||
producer.attempt.userId,
|
||||
producer.attempt.revision,
|
||||
@@ -499,6 +514,7 @@ export class CandleService {
|
||||
false,
|
||||
2,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
this.logger.event('error', 'candles.persistence-failed', {
|
||||
|
||||
Reference in New Issue
Block a user