Загрузка данных
import dayjs from 'dayjs';
import { BehaviorSubject, Observable, Subject } from 'rxjs';
import { Candle } from '@src/types';
import { Timeframes } from '@src/types/timeframes';
import { normalizeSeriesData, parseTimeframe } from '@src/utils';
const FUTURE_WHITESPACES = 5000;
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 waitUntilReady(): Promise<void> {
if (this.isInitializedSubject.value) {
return Promise.resolve();
}
const { loadingPromise } = this;
if (!loadingPromise) {
return Promise.resolve();
}
return loadingPromise.then(() => {
if (this.isInitializedSubject.value) {
return;
}
return this.waitUntilReady();
});
}
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 candle = this.normalizeCandle(next);
this.realtimeCache = this.normalizeList(this.realtimeCache.concat(candle));
if (!this.newestCandle || candle.time >= this.newestCandle.time) {
this.newestCandle = candle;
this.lastCandleSubject.next(candle);
}
if (!this.isInitializedSubject.value) {
this.realtimeBuffer.push(candle);
return;
}
this.realtimeSubject.next(candle);
}
public saveRealtimeCache(): void {
if (this.realtimeCache.length === 0) return;
const timeframe = this.getTimeframe();
const current = this.currentDataSubject.value.filter((candle) => candle.open !== undefined);
const next = this.normalizeList(current.concat(this.realtimeCache));
this.newestCandle = next[next.length - 1] ?? null;
this.realtimeCache = [];
this.currentDataSubject.next(applyWhitespacesToFuture(next, timeframe));
this.lastCandleSubject.next(this.newestCandle);
}
public async loadMoreHistory(): Promise<void> {
await this.waitUntilReady();
if (this.isEndOfData) return;
if (this.loadingPromise) {
return this.loadingPromise;
}
if (!this.oldestCandle) return;
const seq = ++this.loadSeq;
const timeframe = this.getTimeframe();
const { oldestCandle } = this;
const task = (async () => {
this.isLoadingSubject.next(true);
try {
const olderData = await this.getData(timeframe, this.symbol, oldestCandle);
if (seq !== this.loadSeq) return;
if (olderData === null) {
this.isEndOfData = true;
return;
}
const older = this.normalizeList(olderData);
if (older.length === 0) return;
const current = this.currentDataSubject.value.filter((candle) => candle.open !== undefined);
const combined = this.normalizeList(older.concat(current));
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(applyWhitespacesToFuture(combined, timeframe));
this.lastCandleSubject.next(this.newestCandle);
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 loadAllHistory(): Promise<void> {
await this.waitUntilReady();
if (this.isEndOfData) return;
if (this.loadingPromise) {
await this.loadingPromise;
}
const seq = ++this.loadSeq;
const timeframe = this.getTimeframe();
const task = (async () => {
this.isLoadingSubject.next(true);
try {
let current = this.currentDataSubject.value.filter((candle) => candle.open !== undefined);
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(timeframe, this.symbol, oldest);
if (seq !== this.loadSeq) return;
if (olderData === null) {
this.isEndOfData = true;
break;
}
const older = this.normalizeList(olderData);
if (older.length === 0) break;
const combined = this.normalizeList(older.concat(current));
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(applyWhitespacesToFuture(current, timeframe));
this.lastCandleSubject.next(this.newestCandle);
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.waitUntilReady();
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(timeframe: 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(timeframe, this.symbol)) ?? [];
if (seq !== this.loadSeq) return;
const normalized = this.normalizeList(loaded);
this.oldestCandle = normalized[0] ?? null;
this.newestCandle = normalized[normalized.length - 1] ?? null;
this.currentDataSubject.next(applyWhitespacesToFuture(normalized, timeframe));
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 flushRealtimeBuffer(): void {
if (this.realtimeBuffer.length === 0) return;
const newestTime = this.newestCandle?.time ?? Number.NEGATIVE_INFINITY;
const bufferedCandles = this.normalizeList(this.realtimeBuffer.filter((candle) => candle.time >= newestTime));
for (const candle of bufferedCandles) {
this.realtimeSubject.next(candle);
}
this.realtimeBuffer = [];
}
private normalizeCandle(candle: Candle): Candle {
const time = candle.time > 1e10 ? Math.floor(candle.time / 1000) : Math.floor(candle.time);
if (time === candle.time) {
return candle;
}
return {
...candle,
time,
};
}
private normalizeList(candles: Candle[]): Candle[] {
return normalizeSeriesData(candles.map((candle) => this.normalizeCandle(candle)));
}
}
export function applyWhitespacesToFuture(data: Candle[], timeframe: Timeframes): Candle[] {
const lastCandle = data[data.length - 1];
if (!lastCandle) {
return data;
}
const { candleWidth, dayjsUnit } = parseTimeframe(timeframe);
const startTime = dayjs.unix(lastCandle.time).utc();
const result = data.slice();
for (let index = 1; index <= FUTURE_WHITESPACES; index += 1) {
result.push({
time: startTime.add(candleWidth * index, dayjsUnit).unix(),
} as Candle);
}
return result;
}
import { ChartIcon, SeasonalityIcon } from '@src/components/Icon';
import { ChartViewType } from '@src/types';
interface ChartViewTypeIconProps {
type?: ChartViewType;
}
export function ChartViewTypeIcon({ type }: ChartViewTypeIconProps) {
switch (type) {
case ChartViewType.Seasonality:
return <SeasonalityIcon />;
default:
return <ChartIcon />;
}
}
import { ChartIcon, SeasonalityIcon } from '@src/components/Icon';
import { ChartViewType } from '@src/types';
interface ChartViewTypeIconProps {
type?: ChartViewType;
}
export function ChartViewTypeIcon({ type }: ChartViewTypeIconProps) {
switch (type) {
case ChartViewType.Seasonality:
return <SeasonalityIcon />;
default:
return <ChartIcon />;
}
}
import { SeriesType, Time } from 'lightweight-charts';
import { DateFormat } from '@src/utils/formatter';
import { TimeFormat } from './timeScale';
/**
* Типы графиков, поддерживаемые библиотекой
*/
export type ChartSeriesType = SeriesType;
/**
* Универсальный формат данных для всех типов графиков
*/
export interface ChartDataPoint {
time?: Time;
value?: number;
open?: number;
high?: number;
low?: number;
close?: number;
volume?: number;
}
interface CandlestickChartOptions {
upColor?: string;
downColor?: string;
wickUpColor?: string;
wickDownColor?: string;
borderVisible?: boolean;
}
interface LineChartOptions {
lineStyle?: 'solid' | 'dotted' | 'dashed';
}
interface AreaChartOptions {
topColor?: string;
bottomColor?: string;
}
interface BaselineChartOptions {
baseValue?: number;
topLineColor?: string;
bottomLineColor?: string;
topFillColor1?: string;
topFillColor2?: string;
bottomFillColor1?: string;
bottomFillColor2?: string;
}
interface TimescaleOptions {
timeVisible?: boolean;
secondsVisible?: boolean;
timeFormat?: TimeFormat;
dateFormat?: DateFormat;
}
interface CrosshairOptions {
crosshairLineXColor?: string;
crosshairLineYColor?: string;
crosshairLabelXBgColor?: string;
crosshairLabelYBgColor?: string;
}
interface HistogramOptions {
scaleMargins?: {
top: number;
bottom: number;
};
}
/**
* @deprecated
* Опции для разных типов графиков
*/
export interface ChartTypeOptions // todo: неверный интерфейс
extends HistogramOptions,
CrosshairOptions,
TimescaleOptions,
BaselineChartOptions,
AreaChartOptions,
LineChartOptions,
CandlestickChartOptions {
// Общие опции
color?: string;
lineWidth?: number;
}
export interface LineCandle {
time: number;
value: number;
volume?: number;
}
export interface Candle {
time: number;
open: number;
high: number;
low: number;
close: number;
volume?: number; // todo: volume & future metaData to customFields
}
export type Destroyable = {
destroy(): void;
};
export enum Direction {
Top = 'top',
Bottom = 'bottom',
Left = 'left',
Right = 'right',
}
// Настройки графика, которые могут изменяться в рантайме
export interface ChartOptionsModel {
dateFormat: DateFormat;
timeFormat: TimeFormat;
showTime: boolean;
}