Загрузка данных
import dayjs from 'dayjs';
import { clone } from 'lodash-es';
import { BehaviorSubject, firstValueFrom, Observable, Subject } from 'rxjs';
import { filter, take } from 'rxjs/operators';
import { Candle } from '@src/types';
import { Timeframes } from '@src/types/timeframes';
import { ensureDefined, getStartTime, normalizeSeriesData, parseTimeframe } from '@src/utils';
export interface SymbolSourceParams {
symbol: string;
getData: (timeframe: Timeframes, symbol: string, until?: Candle) => Promise<Candle[] | null>;
getTimeframe: () => Timeframes;
}
export class SymbolSource {
public readonly symbol: string;
private readonly getData: SymbolSourceParams['getData'];
private readonly getTimeframe: SymbolSourceParams['getTimeframe'];
private readonly currentDataSubject = new BehaviorSubject<Candle[]>([]);
private readonly realtimeSubject = new Subject<Candle>();
private readonly lastCandleSubject = new BehaviorSubject<Candle | null>(null);
private readonly isLoadingSubject = new BehaviorSubject<boolean>(false);
private readonly isInitializedSubject = new BehaviorSubject<boolean>(false);
private realtimeCache: Candle[] = [];
private realtimeBuffer: Candle[] = [];
private oldestCandle: Candle | null = null;
private newestCandle: Candle | null = null;
private loadSeq = 0;
private loadingPromise: Promise<void> | null = null;
private isEndOfData = false;
constructor({ symbol, getData, getTimeframe }: SymbolSourceParams) {
this.symbol = symbol;
this.getData = getData;
this.getTimeframe = getTimeframe;
}
public init(): void {
this.reload(this.getTimeframe());
}
public data$(): Observable<Candle[]> {
return this.currentDataSubject.asObservable();
}
public realtime$(): Observable<Candle> {
return this.realtimeSubject.asObservable();
}
public lastCandle$(): Observable<Candle | null> {
return this.lastCandleSubject.asObservable();
}
public isInitialized$(): Observable<boolean> {
return this.isInitializedSubject.asObservable();
}
public isLoadingValue(): boolean {
return this.isLoadingSubject.value;
}
public getOldestTime(): number | null {
return this.oldestCandle?.time ?? null;
}
public getLastValue(): Candle | null {
return this.lastCandleSubject.value;
}
public destroy(): void {
this.loadSeq += 1;
this.loadingPromise = null;
this.currentDataSubject.complete();
this.realtimeSubject.complete();
this.lastCandleSubject.complete();
this.isLoadingSubject.complete();
this.isInitializedSubject.complete();
this.realtimeCache = [];
this.realtimeBuffer = [];
this.oldestCandle = null;
this.newestCandle = null;
}
public pushRealtime(next: Candle): void {
const tf = this.getTimeframe();
const aligned = this.alignCandle(tf, next);
const res = SymbolSource.stackCandles(tf, aligned, this.newestCandle, this.realtimeCache);
this.newestCandle = res.newestCandle;
this.realtimeCache = res.realtimeCache;
this.lastCandleSubject.next(res.newestCandle);
if (!this.isInitializedSubject.value) {
this.realtimeBuffer.push(res.newestCandle);
return;
}
this.realtimeSubject.next(res.newestCandle);
}
public saveRealtimeCache(): void {
if (this.realtimeCache.length === 0) return;
const tf = this.getTimeframe();
this.realtimeCache = this.normalizeList(tf, this.realtimeCache);
const left = this.currentDataSubject.value.filter((v) => v.open !== undefined);
const right = this.realtimeCache;
if (left.length === 0 || right.length === 0) {
this.realtimeCache = [];
return;
}
const lastLeft = left[left.length - 1];
const firstRight = right[0];
const next =
lastLeft && firstRight && lastLeft.time === firstRight.time
? left
.slice(0, -1)
.concat(SymbolSource.combineCandles(lastLeft, firstRight, true))
.concat(right.slice(1))
: left.concat(right);
const normalizedNext = this.normalizeList(tf, next);
const extendedNormalizedNext = applyWhitespacesToFuture(normalizedNext);
this.currentDataSubject.next(extendedNormalizedNext);
this.newestCandle = normalizedNext[normalizedNext.length - 1] ?? null;
this.realtimeCache = [];
this.lastCandleSubject.next(this.newestCandle);
}
public async loadMoreHistory(): Promise<void> {
await this.ready();
if (this.isEndOfData) return;
if (this.loadingPromise) {
return this.loadingPromise;
}
this.loadSeq += 1;
if (!this.oldestCandle) return;
const tf = ensureDefined(this.getTimeframe());
const task = (async () => {
this.isLoadingSubject.next(true);
try {
if (!this.oldestCandle) return;
const olderData = await this.getData(tf, this.symbol, this.oldestCandle);
if (olderData === null) {
this.isEndOfData = true;
return;
}
const older = this.normalizeList(tf, olderData);
if (older.length === 0) return;
const current = this.currentDataSubject.value;
const combinedRaw = SymbolSource.mergeHistory(older, current);
const combined = this.normalizeList(tf, combinedRaw);
const nextOldest = combined[0] ?? null;
if (!nextOldest || !this.oldestCandle || nextOldest.time >= this.oldestCandle.time) {
return;
}
this.oldestCandle = nextOldest;
this.newestCandle = combined[combined.length - 1] ?? this.newestCandle;
this.currentDataSubject.next(combined);
this.lastCandleSubject.next(this.newestCandle);
this.saveRealtimeCache();
} catch (error) {
console.error('[DataSource] Ошибка при догрузке истории:', error);
} finally {
this.isLoadingSubject.next(false);
}
})();
this.loadingPromise = task;
task.finally(() => {
if (this.loadingPromise === task) {
this.loadingPromise = null;
}
});
await task;
}
public async loadAllHistory(): Promise<void> {
await this.ready();
if (this.isEndOfData) return;
if (this.loadingPromise) {
await this.loadingPromise;
}
const seq = ++this.loadSeq;
const tf = ensureDefined(this.getTimeframe());
const task = (async () => {
this.isLoadingSubject.next(true);
try {
let current = this.currentDataSubject.value;
let oldest = current[0] ?? null;
if (!oldest) return;
const seenOldestTimes = new Set<number>();
while (oldest) {
if (seq !== this.loadSeq) return;
if (seenOldestTimes.has(oldest.time)) {
break;
}
seenOldestTimes.add(oldest.time);
// eslint-disable-next-line no-await-in-loop
const olderData = await this.getData(tf, this.symbol, oldest);
if (seq !== this.loadSeq) return;
if (olderData === null) {
this.isEndOfData = true;
break;
}
const older = this.normalizeList(tf, olderData);
if (older.length === 0) break;
const combinedRaw = SymbolSource.mergeHistory(older, current);
const combined = this.normalizeList(tf, combinedRaw);
const nextOldest = combined[0] ?? null;
if (!nextOldest || nextOldest.time >= oldest.time) {
break;
}
oldest = nextOldest;
current = combined;
}
this.oldestCandle = current[0] ?? null;
this.newestCandle = current[current.length - 1] ?? null;
this.currentDataSubject.next(current);
this.saveRealtimeCache();
} catch (error) {
console.error('[DataSource] Ошибка при полной загрузке истории:', error);
} finally {
if (seq === this.loadSeq) {
this.isLoadingSubject.next(false);
}
}
})();
this.loadingPromise = task;
task.finally(() => {
if (this.loadingPromise === task) {
this.loadingPromise = null;
}
});
await task;
}
public async loadTill(time: number): Promise<void> {
await this.ready();
while (this.oldestCandle && this.oldestCandle.time >= time) {
const before = this.oldestCandle.time;
// eslint-disable-next-line no-await-in-loop
await this.loadMoreHistory();
if (!this.oldestCandle) break;
if (this.oldestCandle.time === before) break;
}
}
public async reload(tf: Timeframes): Promise<void> {
this.loadSeq += 1;
const seq = this.loadSeq;
this.isInitializedSubject.next(false);
this.isLoadingSubject.next(true);
this.currentDataSubject.next([]);
this.realtimeCache = [];
this.realtimeBuffer = [];
this.oldestCandle = null;
this.newestCandle = null;
this.isEndOfData = false;
this.lastCandleSubject.next(null);
const task = (async () => {
try {
const loaded = (await this.getData(tf, this.symbol)) ?? [];
if (seq !== this.loadSeq) return;
let normalized = this.normalizeList(tf, loaded);
this.newestCandle = normalized[normalized.length - 1] ?? null;
normalized = applyWhitespacesToFuture(normalized);
this.oldestCandle = normalized[0] ?? null;
this.currentDataSubject.next(normalized);
this.lastCandleSubject.next(this.newestCandle);
this.isInitializedSubject.next(true);
this.flushRealtimeBuffer();
} catch (error) {
console.error('[DataSource] Ошибка при загрузке данных:', error);
} finally {
if (seq === this.loadSeq) this.isLoadingSubject.next(false);
}
})();
this.loadingPromise = task;
task.finally(() => {
if (this.loadingPromise === task) this.loadingPromise = null;
});
await task;
}
private async ready(): Promise<void> {
if (this.isInitializedSubject.value) return;
await firstValueFrom(this.isInitializedSubject.pipe(filter(Boolean), take(1)));
}
private flushRealtimeBuffer(): void {
if (this.realtimeBuffer.length === 0) return;
const newestTime = this.newestCandle?.time ?? Number.NEGATIVE_INFINITY;
const filtered = this.realtimeBuffer.filter((c) => c.time >= newestTime);
const map = new Map<number, Candle>();
for (const c of filtered) {
map.set(c.time, c);
}
const unique = Array.from(map.values()).sort((a, b) => a.time - b.time);
for (const c of unique) {
this.realtimeSubject.next(c);
}
this.realtimeBuffer = [];
}
private alignCandle(tf: Timeframes, c: Candle): Candle {
const timeMS = c.time > 1e10 ? Math.floor(c.time) : Math.floor(c.time * 1000);
const startMS = getStartTime(tf, timeMS);
return { ...c, time: startMS };
}
private normalizeList(tf: Timeframes, list: Candle[]): Candle[] {
const aligned = list.map((c) => this.alignCandle(tf, c)).sort((a, b) => a.time - b.time);
return normalizeSeriesData(aligned);
}
private static stackCandles(
timeframe: Timeframes,
next: Candle,
newestCandle: Candle | null,
realtimeCache: Candle[],
): { realtimeCache: Candle[]; newestCandle: Candle } {
const { candleWidth, dayjsUnit } = parseTimeframe(timeframe);
const historicalUpdate = newestCandle
? next.time - newestCandle.time < dayjs.duration(candleWidth, dayjsUnit).as('s')
: false;
if (!historicalUpdate) {
const nextNewestCandle = { ...next, time: getStartTime(timeframe, next.time * 1000) };
realtimeCache.push(nextNewestCandle);
return { realtimeCache, newestCandle: nextNewestCandle };
}
const dataToSet = SymbolSource.combineCandles(ensureDefined(newestCandle), next);
if (realtimeCache.length === 0) {
realtimeCache.push(dataToSet);
return { realtimeCache, newestCandle: dataToSet };
}
realtimeCache[realtimeCache.length - 1] = dataToSet;
return { realtimeCache, newestCandle: dataToSet };
}
private static mergeHistory(older: Candle[], current: Candle[]): Candle[] {
if (current.length === 0) return older;
if (older.length === 0) return current;
const lastOlder = older[older.length - 1];
const firstCurrent = current[0];
if (lastOlder && firstCurrent && lastOlder.time === firstCurrent.time) {
const merged = SymbolSource.combineCandles(lastOlder, firstCurrent, true);
return older.slice(0, -1).concat(merged).concat(current.slice(1));
}
return older.concat(current);
}
private static combineCandles(left: Candle, right: Candle, volumeStacked?: boolean): Candle {
return {
time: left.time,
open: left.open,
high: Math.max(left.high, right.high),
low: Math.min(left.low, right.low),
close: right.close,
volume: volumeStacked ? (left.volume ?? 0) + (right.volume ?? 0) : (right.volume ?? 0),
};
}
}
export function applyWhitespacesToFuture(data: Candle[]): Candle[] {
if (data.length < 2) {
return [];
}
const lastTime = data[data.length - 1].time;
const timeDiff = data[data.length - 1].time - data[data.length - 2].time;
const whitespacesNeeded = 5000;
const res = clone(data);
let i = 0;
while (i < whitespacesNeeded) {
i++;
res.push({ time: lastTime + timeDiff * i } as Candle);
}
return res;
}