goldenChat base source add
This commit is contained in:
@@ -0,0 +1,545 @@
|
||||
import {
|
||||
createChart,
|
||||
CandlestickSeries,
|
||||
BarSeries,
|
||||
LineSeries,
|
||||
AreaSeries,
|
||||
BaselineSeries,
|
||||
HistogramSeries,
|
||||
createSeriesMarkers,
|
||||
type IChartApi,
|
||||
type ISeriesApi,
|
||||
type SeriesType,
|
||||
type MouseEventParams,
|
||||
type Time,
|
||||
type IPriceLine,
|
||||
type ISeriesMarkersPluginApi,
|
||||
ColorType,
|
||||
LineStyle,
|
||||
} from 'lightweight-charts-v5';
|
||||
import type { OHLCVBar, ChartType, Theme, IndicatorConfig } from './types';
|
||||
import { calculateIndicator, getIndicatorDef, type PlotData, type MarkerData } from './indicatorRegistry';
|
||||
import { calcAutoFib } from './calculations';
|
||||
|
||||
interface ThemeTokens {
|
||||
bgColor: string;
|
||||
textColor: string;
|
||||
gridColor: string;
|
||||
borderColor: string;
|
||||
crosshairColor: string;
|
||||
upColor: string;
|
||||
downColor: string;
|
||||
}
|
||||
|
||||
const DARK: ThemeTokens = {
|
||||
bgColor: '#131722',
|
||||
textColor: '#D1D4DC',
|
||||
gridColor: '#1e2330',
|
||||
borderColor: '#2A2E39',
|
||||
crosshairColor: '#9598A1',
|
||||
upColor: '#26a69a',
|
||||
downColor: '#ef5350',
|
||||
};
|
||||
const LIGHT: ThemeTokens = {
|
||||
bgColor: '#FFFFFF',
|
||||
textColor: '#131722',
|
||||
gridColor: '#F0F3FA',
|
||||
borderColor: '#E0E3EB',
|
||||
crosshairColor: '#758696',
|
||||
upColor: '#26a69a',
|
||||
downColor: '#ef5350',
|
||||
};
|
||||
|
||||
interface IndicatorEntry {
|
||||
id: string;
|
||||
type: string;
|
||||
seriesList: ISeriesApi<SeriesType>[];
|
||||
paneIndex?: number;
|
||||
alertLines: IPriceLine[];
|
||||
config: IndicatorConfig;
|
||||
}
|
||||
|
||||
interface AlertEntry { price: number; label: string; line: IPriceLine; }
|
||||
interface FibLevel { price: number; label: string; line: IPriceLine; }
|
||||
|
||||
function getTheme(theme: Theme): ThemeTokens {
|
||||
return theme === 'dark' ? DARK : LIGHT;
|
||||
}
|
||||
|
||||
export class ChartManager {
|
||||
private chart: IChartApi;
|
||||
private container: HTMLElement;
|
||||
private mainSeries: ISeriesApi<SeriesType> | null = null;
|
||||
private volumeSeries: ISeriesApi<'Histogram'> | null = null;
|
||||
private indicators = new Map<string, IndicatorEntry>();
|
||||
private alertEntries: AlertEntry[] = [];
|
||||
private fibLines: FibLevel[] = [];
|
||||
private drawingTool = 'cursor';
|
||||
private drawingPoints: { time: Time; price: number }[] = [];
|
||||
private drawingLines: IPriceLine[] = [];
|
||||
private _clickHandler: ((p: MouseEventParams<Time>) => void) | null = null;
|
||||
private rawBars: OHLCVBar[] = [];
|
||||
private currentTheme: Theme = 'dark';
|
||||
private mainMarkersPlugin: ISeriesMarkersPluginApi<Time> | null = null;
|
||||
private patternMarkers: Array<{ id: string; markers: MarkerData[] }> = [];
|
||||
private compareSeriesMap = new Map<string, ISeriesApi<SeriesType>>();
|
||||
|
||||
constructor(container: HTMLElement, theme: Theme) {
|
||||
this.container = container;
|
||||
const t = getTheme(theme);
|
||||
this.chart = createChart(container, {
|
||||
layout: {
|
||||
background: { type: ColorType.Solid, color: t.bgColor },
|
||||
textColor: t.textColor,
|
||||
fontSize: 12,
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: t.gridColor },
|
||||
horzLines: { color: t.gridColor },
|
||||
},
|
||||
crosshair: {
|
||||
vertLine: { color: t.crosshairColor, labelBackgroundColor: '#9598A1' },
|
||||
horzLine: { color: t.crosshairColor, labelBackgroundColor: '#9598A1' },
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: t.borderColor,
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
rightOffset: 8,
|
||||
},
|
||||
rightPriceScale: { borderColor: t.borderColor },
|
||||
handleScroll: { mouseWheel: true, pressedMouseMove: true, horzTouchDrag: true, vertTouchDrag: false },
|
||||
handleScale: { axisPressedMouseMove: true, axisDoubleClickReset: true, mouseWheel: true, pinch: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** 내부 IChartApi 직접 접근 (커스텀 시리즈 추가 등) */
|
||||
getChart(): IChartApi { return this.chart; }
|
||||
|
||||
// ─── Data ───────────────────────────────────────────────────────────────
|
||||
setData(bars: OHLCVBar[], chartType: ChartType, theme: Theme): void {
|
||||
this.rawBars = bars;
|
||||
this.currentTheme = theme;
|
||||
const t = getTheme(theme);
|
||||
|
||||
if (this.mainSeries) { try { this.chart.removeSeries(this.mainSeries); } catch { /* ok */ } this.mainSeries = null; }
|
||||
if (this.volumeSeries) { try { this.chart.removeSeries(this.volumeSeries); } catch { /* ok */ } this.volumeSeries = null; }
|
||||
this.mainMarkersPlugin = null;
|
||||
|
||||
this.mainSeries = this._createMainSeries(chartType, t);
|
||||
const mainData = bars.map(b => {
|
||||
if (chartType === 'line' || chartType === 'area' || chartType === 'baseline')
|
||||
return { time: b.time as Time, value: b.close };
|
||||
return { time: b.time as Time, open: b.open, high: b.high, low: b.low, close: b.close };
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(this.mainSeries as ISeriesApi<any>).setData(mainData);
|
||||
|
||||
// Volume sub-pane (pane index 1)
|
||||
this.volumeSeries = this.chart.addSeries(HistogramSeries, {
|
||||
priceFormat: { type: 'volume' },
|
||||
priceScaleId: 'volume',
|
||||
}, 1);
|
||||
|
||||
const panes = this.chart.panes();
|
||||
if (panes[1]) panes[1].setHeight(60);
|
||||
|
||||
this.volumeSeries.setData(bars.map(b => ({
|
||||
time: b.time as Time,
|
||||
value: b.volume,
|
||||
color: b.close >= b.open ? `${t.upColor}88` : `${t.downColor}88`,
|
||||
})));
|
||||
|
||||
this._reapplyAllPatternMarkers();
|
||||
this.chart.timeScale().fitContent();
|
||||
}
|
||||
|
||||
private _createMainSeries(chartType: ChartType, t: ThemeTokens): ISeriesApi<SeriesType> {
|
||||
switch (chartType) {
|
||||
case 'candlestick':
|
||||
return this.chart.addSeries(CandlestickSeries, {
|
||||
upColor: t.upColor, downColor: t.downColor,
|
||||
borderUpColor: t.upColor, borderDownColor: t.downColor,
|
||||
wickUpColor: t.upColor, wickDownColor: t.downColor,
|
||||
});
|
||||
case 'bar':
|
||||
return this.chart.addSeries(BarSeries, { upColor: t.upColor, downColor: t.downColor });
|
||||
case 'line':
|
||||
return this.chart.addSeries(LineSeries, { color: '#2962FF', lineWidth: 2, crosshairMarkerVisible: true });
|
||||
case 'area':
|
||||
return this.chart.addSeries(AreaSeries, {
|
||||
lineColor: '#2962FF', topColor: '#2962FF44', bottomColor: '#2962FF00', lineWidth: 2,
|
||||
});
|
||||
case 'baseline':
|
||||
return this.chart.addSeries(BaselineSeries, {
|
||||
baseValue: { type: 'price', price: 0 },
|
||||
topLineColor: '#26a69a', topFillColor1: '#26a69a44', topFillColor2: '#26a69a00',
|
||||
bottomLineColor: '#ef5350', bottomFillColor1: '#ef535000', bottomFillColor2: '#ef535044',
|
||||
});
|
||||
default:
|
||||
return this.chart.addSeries(CandlestickSeries, {
|
||||
upColor: t.upColor, downColor: t.downColor,
|
||||
borderUpColor: t.upColor, borderDownColor: t.downColor,
|
||||
wickUpColor: t.upColor, wickDownColor: t.downColor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Theme ──────────────────────────────────────────────────────────────
|
||||
setTheme(theme: Theme): void {
|
||||
const t = getTheme(theme);
|
||||
this.chart.applyOptions({
|
||||
layout: { background: { type: ColorType.Solid, color: t.bgColor }, textColor: t.textColor },
|
||||
grid: { vertLines: { color: t.gridColor }, horzLines: { color: t.gridColor } },
|
||||
crosshair: { vertLine: { color: t.crosshairColor, labelBackgroundColor: '#9598A1' }, horzLine: { color: t.crosshairColor, labelBackgroundColor: '#9598A1' } },
|
||||
timeScale: { borderColor: t.borderColor },
|
||||
rightPriceScale: { borderColor: t.borderColor },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Indicators ─────────────────────────────────────────────────────────
|
||||
async addIndicator(config: IndicatorConfig): Promise<void> {
|
||||
if (this.indicators.has(config.id) || this.rawBars.length === 0) return;
|
||||
const def = getIndicatorDef(config.type);
|
||||
const seriesList: ISeriesApi<SeriesType>[] = [];
|
||||
|
||||
try {
|
||||
const result = await calculateIndicator(config.type, this.rawBars, config.params);
|
||||
|
||||
if (def?.returnsMarkers && result.markers && result.markers.length > 0) {
|
||||
this.patternMarkers.push({ id: config.id, markers: result.markers });
|
||||
this._reapplyAllPatternMarkers();
|
||||
this.indicators.set(config.id, { id: config.id, type: config.type, seriesList: [], alertLines: [], config });
|
||||
return;
|
||||
}
|
||||
|
||||
const pane = def?.overlay ? 0 : this._getNextSubPane();
|
||||
const plotDefs = config.plots ?? def?.plots ?? [];
|
||||
|
||||
for (const plotDef of plotDefs) {
|
||||
const plotData = result.plots[plotDef.id] as PlotData | undefined;
|
||||
if (!plotData || plotData.length === 0) continue;
|
||||
const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value));
|
||||
if (filtered.length === 0) continue;
|
||||
|
||||
let series: ISeriesApi<SeriesType>;
|
||||
if (plotDef.type === 'histogram') {
|
||||
series = this.chart.addSeries(HistogramSeries, { color: plotDef.color }, pane);
|
||||
const colorData = filtered.map((p, i, arr) => ({
|
||||
time: p.time as Time,
|
||||
value: p.value,
|
||||
color: p.color ?? (i > 0 && p.value < arr[i - 1].value
|
||||
? '#ef535088'
|
||||
: p.value < 0 ? '#ef535088' : `${plotDef.color}88`),
|
||||
}));
|
||||
series.setData(colorData);
|
||||
} else {
|
||||
series = this.chart.addSeries(LineSeries, {
|
||||
color: plotDef.color,
|
||||
lineWidth: (plotDef.lineWidth ?? 1) as 1 | 2 | 3 | 4,
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: true,
|
||||
}, pane);
|
||||
series.setData(filtered.map(p => ({ time: p.time as Time, value: p.value })));
|
||||
}
|
||||
|
||||
const hlines = config.hlines ?? def?.hlines ?? [];
|
||||
for (const hl of hlines) {
|
||||
series.createPriceLine({ price: hl.price, color: hl.color, lineWidth: 1, lineStyle: LineStyle.Dashed, axisLabelVisible: false });
|
||||
}
|
||||
seriesList.push(series);
|
||||
}
|
||||
|
||||
this.indicators.set(config.id, { id: config.id, type: config.type, seriesList, paneIndex: pane, alertLines: [], config });
|
||||
} catch (e) {
|
||||
console.error(`[ChartV5 Indicator] ${config.type} error:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
removeIndicator(id: string): void {
|
||||
const entry = this.indicators.get(id);
|
||||
if (!entry) return;
|
||||
for (const s of entry.seriesList) { try { this.chart.removeSeries(s); } catch { /* ok */ } }
|
||||
this.patternMarkers = this.patternMarkers.filter(p => p.id !== id);
|
||||
this._reapplyAllPatternMarkers();
|
||||
this.indicators.delete(id);
|
||||
}
|
||||
|
||||
private _reapplyAllPatternMarkers(): void {
|
||||
if (!this.mainSeries) return;
|
||||
const all = this.patternMarkers.flatMap(({ markers }) =>
|
||||
markers.map(m => ({
|
||||
time: m.time as Time,
|
||||
position: m.position,
|
||||
shape: m.shape,
|
||||
color: m.color,
|
||||
text: m.text,
|
||||
}))
|
||||
);
|
||||
if (!this.mainMarkersPlugin) {
|
||||
this.mainMarkersPlugin = createSeriesMarkers(this.mainSeries, all);
|
||||
} else {
|
||||
this.mainMarkersPlugin.setMarkers(all);
|
||||
}
|
||||
}
|
||||
|
||||
private _getNextSubPane(): number {
|
||||
const used = new Set(Array.from(this.indicators.values()).map(e => e.paneIndex).filter(Boolean));
|
||||
let pane = 2;
|
||||
while (used.has(pane)) pane++;
|
||||
return pane;
|
||||
}
|
||||
|
||||
// ─── 실시간 바 업데이트 ────────────────────────────────────────────────
|
||||
updateBar(bar: OHLCVBar): void {
|
||||
if (!this.mainSeries) return;
|
||||
const t = getTheme(this.currentTheme);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(this.mainSeries as ISeriesApi<any>).update({
|
||||
time: bar.time as Time,
|
||||
open: bar.open, high: bar.high, low: bar.low, close: bar.close,
|
||||
});
|
||||
|
||||
this.volumeSeries?.update({
|
||||
time: bar.time as Time,
|
||||
value: bar.volume,
|
||||
color: bar.close >= bar.open ? `${t.upColor}88` : `${t.downColor}88`,
|
||||
});
|
||||
|
||||
if (this.rawBars.length > 0) {
|
||||
const last = this.rawBars[this.rawBars.length - 1];
|
||||
if (last.time === bar.time) {
|
||||
this.rawBars[this.rawBars.length - 1] = bar;
|
||||
} else if (bar.time > last.time) {
|
||||
this.rawBars.push(bar);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async appendBar(bar: OHLCVBar): Promise<void> {
|
||||
if (!this.mainSeries) return;
|
||||
const t = getTheme(this.currentTheme);
|
||||
|
||||
const last = this.rawBars[this.rawBars.length - 1];
|
||||
if (last && last.time === bar.time) {
|
||||
this.rawBars[this.rawBars.length - 1] = bar;
|
||||
} else {
|
||||
this.rawBars.push(bar);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(this.mainSeries as ISeriesApi<any>).update({
|
||||
time: bar.time as Time,
|
||||
open: bar.open, high: bar.high, low: bar.low, close: bar.close,
|
||||
});
|
||||
|
||||
this.volumeSeries?.update({
|
||||
time: bar.time as Time,
|
||||
value: bar.volume,
|
||||
color: bar.close >= bar.open ? `${t.upColor}88` : `${t.downColor}88`,
|
||||
});
|
||||
|
||||
for (const [, entry] of Array.from(this.indicators.entries())) {
|
||||
if (!entry.config || entry.seriesList.length === 0) continue;
|
||||
if (entry.config.type) {
|
||||
try {
|
||||
const { plots } = await calculateIndicator(
|
||||
entry.config.type, this.rawBars, entry.config.params ?? {}
|
||||
);
|
||||
const plotValues = Object.values(plots);
|
||||
for (let i = 0; i < entry.seriesList.length && i < plotValues.length; i++) {
|
||||
const plotData = plotValues[i];
|
||||
if (!plotData?.length) continue;
|
||||
const lastPoint = plotData[plotData.length - 1];
|
||||
if (lastPoint) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(entry.seriesList[i] as ISeriesApi<any>).update(lastPoint as any);
|
||||
}
|
||||
}
|
||||
} catch { /* 인디케이터 계산 실패 무시 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async recalculateIndicators(newBars: OHLCVBar[]): Promise<void> {
|
||||
this.rawBars = newBars;
|
||||
|
||||
const configs = Array.from(this.indicators.values()).map(e => e.config);
|
||||
|
||||
for (const entry of Array.from(this.indicators.values())) {
|
||||
for (const s of entry.seriesList) { try { this.chart.removeSeries(s); } catch { /* ok */ } }
|
||||
}
|
||||
this.indicators.clear();
|
||||
this.patternMarkers = [];
|
||||
this._reapplyAllPatternMarkers();
|
||||
|
||||
for (const cfg of configs) {
|
||||
await this.addIndicator(cfg);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 커스텀 시리즈 (pre-calculated data) ────────────────────────────────
|
||||
addCustomSeriesGroup(
|
||||
id: string,
|
||||
seriesSpecs: Array<{
|
||||
data: Array<{ time: number; value: number; color?: string }>;
|
||||
seriesType: 'line' | 'histogram';
|
||||
color: string;
|
||||
lineWidth?: number;
|
||||
}>,
|
||||
hlines?: Array<{ price: number; color: string }>,
|
||||
pane?: number
|
||||
): void {
|
||||
if (this.indicators.has(id)) return;
|
||||
const targetPane = pane ?? this._getNextSubPane();
|
||||
const seriesList: ISeriesApi<SeriesType>[] = [];
|
||||
|
||||
for (const spec of seriesSpecs) {
|
||||
if (!spec.data || spec.data.length === 0) continue;
|
||||
const filtered = spec.data.filter(p => p.value !== null && !isNaN(p.value));
|
||||
if (filtered.length === 0) continue;
|
||||
|
||||
let series: ISeriesApi<SeriesType>;
|
||||
if (spec.seriesType === 'histogram') {
|
||||
series = this.chart.addSeries(HistogramSeries, { color: spec.color }, targetPane);
|
||||
series.setData(filtered.map(p => ({
|
||||
time: p.time as Time,
|
||||
value: p.value,
|
||||
color: p.color ?? (p.value < 0 ? '#ef535088' : `${spec.color}88`),
|
||||
})));
|
||||
} else {
|
||||
series = this.chart.addSeries(LineSeries, {
|
||||
color: spec.color,
|
||||
lineWidth: (spec.lineWidth ?? 1) as 1 | 2 | 3 | 4,
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: true,
|
||||
}, targetPane);
|
||||
series.setData(filtered.map(p => ({ time: p.time as Time, value: p.value })));
|
||||
}
|
||||
|
||||
if (hlines) {
|
||||
for (const hl of hlines) {
|
||||
series.createPriceLine({ price: hl.price, color: hl.color, lineWidth: 1, lineStyle: LineStyle.Dashed, axisLabelVisible: false });
|
||||
}
|
||||
}
|
||||
seriesList.push(series);
|
||||
}
|
||||
|
||||
this.indicators.set(id, {
|
||||
id, type: '__custom__', seriesList, paneIndex: targetPane, alertLines: [],
|
||||
config: { id, type: '__custom__', params: {} },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Alerts ──────────────────────────────────────────────────────────────
|
||||
addAlert(price: number, label = ''): void {
|
||||
if (!this.mainSeries) return;
|
||||
const line = this.mainSeries.createPriceLine({
|
||||
price, color: '#FFB300', lineWidth: 1, lineStyle: LineStyle.Dashed,
|
||||
axisLabelVisible: true, title: label || `Alert ${price.toFixed(2)}`,
|
||||
});
|
||||
this.alertEntries.push({ price, label, line });
|
||||
}
|
||||
|
||||
removeAlert(price: number): void {
|
||||
const idx = this.alertEntries.findIndex(a => Math.abs(a.price - price) < 0.0001);
|
||||
if (idx < 0) return;
|
||||
try { this.mainSeries?.removePriceLine(this.alertEntries[idx].line); } catch { /* ok */ }
|
||||
this.alertEntries.splice(idx, 1);
|
||||
}
|
||||
|
||||
getAlerts(): Array<{ price: number; label: string }> {
|
||||
return this.alertEntries.map(({ price, label }) => ({ price, label }));
|
||||
}
|
||||
|
||||
// ─── Auto Fibonacci ──────────────────────────────────────────────────────
|
||||
drawAutoFib(lookback = 50): void {
|
||||
if (!this.mainSeries || this.rawBars.length === 0) return;
|
||||
this._clearFibLines();
|
||||
const { levels } = calcAutoFib(this.rawBars, lookback);
|
||||
const COLORS: Record<string, string> = {
|
||||
'0%': '#EF5350', '23.6%': '#FF9800', '38.2%': '#FFC107',
|
||||
'50%': '#FFFFFF', '61.8%': '#4CAF50', '78.6%': '#2196F3',
|
||||
'100%': '#9C27B0', '127.2%': '#607D8B', '161.8%': '#607D8B',
|
||||
};
|
||||
for (const l of levels) {
|
||||
const line = this.mainSeries.createPriceLine({
|
||||
price: l.price, color: COLORS[l.label] ?? '#607D8B',
|
||||
lineWidth: l.label === '0%' || l.label === '100%' ? 2 : 1,
|
||||
lineStyle: l.label === '50%' ? LineStyle.Dashed : LineStyle.Dotted,
|
||||
axisLabelVisible: true, title: `Fib ${l.label}`,
|
||||
});
|
||||
this.fibLines.push({ price: l.price, label: l.label, line });
|
||||
}
|
||||
}
|
||||
|
||||
clearFib(): void { this._clearFibLines(); }
|
||||
private _clearFibLines(): void {
|
||||
for (const f of this.fibLines) { try { this.mainSeries?.removePriceLine(f.line); } catch { /* ok */ } }
|
||||
this.fibLines = [];
|
||||
}
|
||||
|
||||
// ─── Crosshair ────────────────────────────────────────────────────────────
|
||||
subscribeCrosshair(cb: (p: MouseEventParams<Time>) => void): () => void {
|
||||
this.chart.subscribeCrosshairMove(cb);
|
||||
return () => this.chart.unsubscribeCrosshairMove(cb);
|
||||
}
|
||||
|
||||
// ─── Layout ───────────────────────────────────────────────────────────────
|
||||
fitContent(): void { this.chart.timeScale().fitContent(); }
|
||||
setLogScale(enabled: boolean): void {
|
||||
this.mainSeries?.applyOptions({ priceScale: { mode: enabled ? 1 : 0 } } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
|
||||
}
|
||||
resize(w: number, h: number): void { this.chart.resize(w, h); }
|
||||
|
||||
// ─── Compare overlay ──────────────────────────────────────────────────────
|
||||
addCompareSeries(symbol: string, bars: OHLCVBar[], color: string): void {
|
||||
if (this.compareSeriesMap.has(symbol)) return;
|
||||
const s = this.chart.addSeries(LineSeries, { color, lineWidth: 2, priceLineVisible: false });
|
||||
const base = bars[0]?.close ?? 1;
|
||||
s.setData(bars.map(b => ({ time: b.time as Time, value: (b.close / base - 1) * 100 })));
|
||||
this.compareSeriesMap.set(symbol, s);
|
||||
}
|
||||
|
||||
removeCompareSeries(symbol: string): void {
|
||||
const s = this.compareSeriesMap.get(symbol);
|
||||
if (s) { try { this.chart.removeSeries(s); } catch { /* ok */ } this.compareSeriesMap.delete(symbol); }
|
||||
}
|
||||
|
||||
// ─── Coordinate conversion ───────────────────────────────────────────────
|
||||
timeToX(time: number): number | null {
|
||||
const coord = this.chart.timeScale().timeToCoordinate(time as Time);
|
||||
return coord === null ? null : coord;
|
||||
}
|
||||
|
||||
priceToY(price: number): number | null {
|
||||
const coord = this.mainSeries?.priceToCoordinate(price);
|
||||
return coord === null || coord === undefined ? null : coord;
|
||||
}
|
||||
|
||||
xToTime(x: number): number | null {
|
||||
const t = this.chart.timeScale().coordinateToTime(x);
|
||||
return t === null ? null : (t as number);
|
||||
}
|
||||
|
||||
yToPrice(y: number): number | null {
|
||||
const p = this.mainSeries?.coordinateToPrice(y);
|
||||
return p === null || p === undefined ? null : p;
|
||||
}
|
||||
|
||||
subscribeViewport(cb: () => void): () => void {
|
||||
this.chart.timeScale().subscribeVisibleTimeRangeChange(cb);
|
||||
this.chart.timeScale().subscribeVisibleLogicalRangeChange(cb);
|
||||
return () => {
|
||||
try { this.chart.timeScale().unsubscribeVisibleTimeRangeChange(cb); } catch { /* ok */ }
|
||||
try { this.chart.timeScale().unsubscribeVisibleLogicalRangeChange(cb); } catch { /* ok */ }
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Destroy ─────────────────────────────────────────────────────────────
|
||||
destroy(): void {
|
||||
if (this._clickHandler) this.chart.unsubscribeClick(this._clickHandler);
|
||||
this.chart.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { OHLCVBar } from './types';
|
||||
|
||||
export function calcAutoFib(bars: OHLCVBar[], lookback = 50): {
|
||||
high: number; low: number; highTime: number; lowTime: number;
|
||||
levels: Array<{ ratio: number; price: number; label: string }>;
|
||||
} {
|
||||
const slice = bars.slice(-lookback);
|
||||
let high = -Infinity, low = Infinity, highTime = 0, lowTime = 0;
|
||||
for (const b of slice) {
|
||||
if (b.high > high) { high = b.high; highTime = b.time; }
|
||||
if (b.low < low) { low = b.low; lowTime = b.time; }
|
||||
}
|
||||
const range = high - low;
|
||||
const LEVELS = [
|
||||
{ ratio: 0, label: '0%' },
|
||||
{ ratio: 0.236, label: '23.6%' },
|
||||
{ ratio: 0.382, label: '38.2%' },
|
||||
{ ratio: 0.5, label: '50%' },
|
||||
{ ratio: 0.618, label: '61.8%' },
|
||||
{ ratio: 0.786, label: '78.6%' },
|
||||
{ ratio: 1, label: '100%' },
|
||||
{ ratio: 1.272, label: '127.2%' },
|
||||
{ ratio: 1.618, label: '161.8%' },
|
||||
];
|
||||
return {
|
||||
high, low, highTime, lowTime,
|
||||
levels: LEVELS.map(l => ({ ...l, price: high - range * l.ratio })),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { OHLCVBar } from './types';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
import type { Bar } from 'oakscriptjs';
|
||||
|
||||
export type IndicatorCategory =
|
||||
| 'Moving Averages'
|
||||
| 'Oscillators'
|
||||
| 'Momentum'
|
||||
| 'Trend'
|
||||
| 'Volatility'
|
||||
| 'Channels & Bands'
|
||||
| 'Volume'
|
||||
| 'Candlestick Patterns';
|
||||
|
||||
export interface PlotDef {
|
||||
id: string;
|
||||
title: string;
|
||||
color: string;
|
||||
type: 'line' | 'histogram' | 'area' | 'scatter';
|
||||
lineWidth?: number;
|
||||
}
|
||||
|
||||
export interface HLineDef { price: number; color: string; }
|
||||
|
||||
export interface IndicatorDef {
|
||||
type: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
category: IndicatorCategory;
|
||||
overlay: boolean;
|
||||
defaultParams: Record<string, number | string | boolean>;
|
||||
plots: PlotDef[];
|
||||
hlines?: HLineDef[];
|
||||
returnsMarkers?: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const INDICATOR_REGISTRY: IndicatorDef[] = [
|
||||
{ type:'SMA', name:'Simple Moving Average', shortName:'SMA', category:'Moving Averages', overlay:true, defaultParams:{len:20, src:'close'}, plots:[{id:'plot0',title:'SMA', color:'#2962FF',type:'line',lineWidth:2}], description:'단순 이동평균' },
|
||||
{ type:'EMA', name:'Exponential Moving Average', shortName:'EMA', category:'Moving Averages', overlay:true, defaultParams:{length:21, src:'close'}, plots:[{id:'plot0',title:'EMA', color:'#FF6D00',type:'line',lineWidth:2}], description:'지수 이동평균' },
|
||||
{ type:'WMA', name:'Weighted Moving Average', shortName:'WMA', category:'Moving Averages', overlay:true, defaultParams:{length:20, src:'close'}, plots:[{id:'plot0',title:'WMA', color:'#00BCD4',type:'line',lineWidth:2}], description:'가중 이동평균' },
|
||||
{ type:'BollingerBands', name:'Bollinger Bands', shortName:'BB', category:'Channels & Bands', overlay:true, defaultParams:{length:20, mult:2, src:'close'}, plots:[{id:'plot0',title:'Upper', color:'#2196F320',type:'line',lineWidth:1},{id:'plot1',title:'Basis',color:'#E91E63',type:'line',lineWidth:1},{id:'plot2',title:'Lower',color:'#2196F320',type:'line',lineWidth:1}], description:'볼린저 밴드' },
|
||||
{ type:'RSI', name:'Relative Strength Index', shortName:'RSI', category:'Oscillators', overlay:false, defaultParams:{length:14, src:'close'}, plots:[{id:'plot0',title:'RSI',color:'#7E57C2',type:'line',lineWidth:2}], hlines:[{price:70,color:'#EF5350'},{price:50,color:'#607D8B'},{price:30,color:'#4CAF50'}], description:'상대 강도 지수' },
|
||||
{ type:'MACD', name:'MACD', shortName:'MACD', category:'Momentum', overlay:false, defaultParams:{fastLength:12, slowLength:26, signalLength:9, src:'close'}, plots:[{id:'plot0',title:'Histogram',color:'#26A69A',type:'histogram'},{id:'plot1',title:'MACD',color:'#2962FF',type:'line',lineWidth:1},{id:'plot2',title:'Signal',color:'#FF6D00',type:'line',lineWidth:1}], hlines:[{price:0,color:'#607D8B'}], description:'이동평균 수렴·발산' },
|
||||
{ type:'Stochastic', name:'Stochastic', shortName:'Stoch', category:'Oscillators', overlay:false, defaultParams:{kLength:14, dSmoothing:3, smooth:3}, plots:[{id:'plot0',title:'%K',color:'#2196F3',type:'line',lineWidth:1},{id:'plot1',title:'%D',color:'#FF6D00',type:'line',lineWidth:1}], hlines:[{price:80,color:'#EF5350'},{price:20,color:'#4CAF50'}], description:'스토캐스틱' },
|
||||
{ type:'CCI', name:'Commodity Channel Index', shortName:'CCI', category:'Oscillators', overlay:false, defaultParams:{length:20, src:'hlc3'}, plots:[{id:'plot0',title:'CCI',color:'#00BCD4',type:'line',lineWidth:2}], hlines:[{price:100,color:'#EF5350'},{price:0,color:'#607D8B'},{price:-100,color:'#4CAF50'}], description:'상품 채널 지수' },
|
||||
{ type:'ADX', name:'Average Directional Index', shortName:'ADX', category:'Trend', overlay:false, defaultParams:{adxSmoothing:14, diLength:14}, plots:[{id:'plot0',title:'ADX',color:'#FF6D00',type:'line',lineWidth:2},{id:'plot1',title:'+DI',color:'#4CAF50',type:'line',lineWidth:1},{id:'plot2',title:'-DI',color:'#EF5350',type:'line',lineWidth:1}], hlines:[{price:25,color:'#607D8B'}], description:'평균 방향성 지수' },
|
||||
{ type:'OBV', name:'On Balance Volume', shortName:'OBV', category:'Volume', overlay:false, defaultParams:{}, plots:[{id:'plot0',title:'OBV',color:'#2196F3',type:'line',lineWidth:2}], description:'잔량 균형 지수' },
|
||||
{ type:'DMI', name:'Directional Movement Index', shortName:'DMI', category:'Trend', overlay:false, defaultParams:{length:14}, plots:[{id:'plot0',title:'+DI',color:'#4CAF50',type:'line',lineWidth:2},{id:'plot1',title:'-DI',color:'#EF5350',type:'line',lineWidth:2},{id:'plot2',title:'DX',color:'#FF9800',type:'line',lineWidth:1}], hlines:[{price:25,color:'#607D8B'}], description:'방향성 이동 지수' },
|
||||
{ type:'WilliamsPercentRange', name:'Williams %R', shortName:'W%R', category:'Oscillators', overlay:false, defaultParams:{length:14}, plots:[{id:'plot0',title:'%R',color:'#9C27B0',type:'line',lineWidth:2}], hlines:[{price:-20,color:'#EF5350'},{price:-50,color:'#607D8B'},{price:-80,color:'#4CAF50'}], description:'윌리엄스 %R' },
|
||||
{ type:'TRIX', name:'TRIX', shortName:'TRIX', category:'Momentum', overlay:false, defaultParams:{length:18, signalLength:9, src:'close'}, plots:[{id:'plot0',title:'TRIX',color:'#4CAF50',type:'line',lineWidth:2},{id:'plot1',title:'Signal',color:'#FF6D00',type:'line',lineWidth:1}], hlines:[{price:0,color:'#607D8B'}], description:'삼중 지수 변화율' },
|
||||
{ type:'VolumeOscillator', name:'Volume Oscillator', shortName:'VO', category:'Volume', overlay:false, defaultParams:{shortLength:5, longLength:10}, plots:[{id:'plot0',title:'VO',color:'#FF7043',type:'histogram'}], hlines:[{price:0,color:'#607D8B'}], description:'볼륨 오실레이터' },
|
||||
{ type:'IchimokuCloud', name:'Ichimoku Cloud', shortName:'Ichi', category:'Trend', overlay:true, defaultParams:{conversionPeriods:9, basePeriods:26, laggingSpan2Periods:52, displacement:26}, plots:[{id:'plot0',title:'Tenkan',color:'#2196F3',type:'line',lineWidth:1},{id:'plot1',title:'Kijun',color:'#EF5350',type:'line',lineWidth:1},{id:'plot2',title:'SpanA',color:'#4CAF50',type:'line',lineWidth:1},{id:'plot3',title:'SpanB',color:'#EF5350',type:'line',lineWidth:1},{id:'plot4',title:'Lagging',color:'#9C27B0',type:'line',lineWidth:1}], description:'일목균형표' },
|
||||
];
|
||||
|
||||
export function getIndicatorDef(type: string): IndicatorDef | undefined {
|
||||
return INDICATOR_REGISTRY.find(d => d.type === type);
|
||||
}
|
||||
|
||||
export function toBars(ohlcv: OHLCVBar[]): Bar[] {
|
||||
return ohlcv as unknown as Bar[];
|
||||
}
|
||||
|
||||
export type PlotData = Array<{ time: number; value: number; color?: string }>;
|
||||
export type IndicatorResult = Record<string, PlotData>;
|
||||
|
||||
export interface MarkerData {
|
||||
time: number;
|
||||
position: 'aboveBar' | 'belowBar';
|
||||
shape: 'arrowUp' | 'arrowDown' | 'circle' | 'square';
|
||||
color: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export async function calculateIndicator(
|
||||
type: string,
|
||||
bars: OHLCVBar[],
|
||||
params: Record<string, number | string | boolean>
|
||||
): Promise<{ plots: IndicatorResult; markers?: MarkerData[] }> {
|
||||
const b = toBars(bars);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const lib = await import('lightweight-charts-indicators-v5') as any;
|
||||
const ind = (lib as unknown as Record<string, {
|
||||
calculate: (b: Bar[], p: unknown) => { plots?: Record<string, PlotData>; markers?: MarkerData[] }
|
||||
}>)[type];
|
||||
if (!ind?.calculate) throw new Error(`Unknown indicator: ${type}`);
|
||||
const result = ind.calculate(b, params);
|
||||
return {
|
||||
plots: (result.plots ?? {}) as IndicatorResult,
|
||||
markers: result.markers as MarkerData[] | undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* LWC 4.x → 5.x 호환 shim
|
||||
*
|
||||
* LWC 4.x API를 내부적으로 LWC 5.x로 실행하도록 래핑합니다.
|
||||
* CandlestickChart / IndicatorChart 에서 import 경로만 교체해서 사용합니다.
|
||||
*
|
||||
* 주요 변환:
|
||||
* - chart.addCandlestickSeries / addLineSeries / addHistogramSeries / addAreaSeries / addBarSeries
|
||||
* → chart.addSeries(SeriesClass, options)
|
||||
* - series.setMarkers(markers)
|
||||
* → createSeriesMarkers(series, markers) / markersPlugin.setMarkers(markers)
|
||||
*/
|
||||
|
||||
import {
|
||||
createChart as _createChart,
|
||||
CandlestickSeries,
|
||||
LineSeries,
|
||||
HistogramSeries,
|
||||
AreaSeries,
|
||||
BarSeries,
|
||||
BaselineSeries,
|
||||
createSeriesMarkers,
|
||||
ColorType,
|
||||
CrosshairMode,
|
||||
LineStyle,
|
||||
LineType,
|
||||
PriceScaleMode,
|
||||
LastPriceAnimationMode,
|
||||
MismatchDirection,
|
||||
PriceLineSource,
|
||||
TickMarkType,
|
||||
TrackingModeExitMode,
|
||||
isBusinessDay,
|
||||
isUTCTimestamp,
|
||||
version,
|
||||
} from 'lightweight-charts-v5';
|
||||
|
||||
import type {
|
||||
IChartApi as _IChartApi,
|
||||
ISeriesApi as _ISeriesApi,
|
||||
IRange,
|
||||
SeriesType,
|
||||
Time,
|
||||
DeepPartial,
|
||||
ChartOptions,
|
||||
CandlestickSeriesOptions,
|
||||
LineSeriesOptions,
|
||||
HistogramSeriesOptions,
|
||||
AreaSeriesOptions,
|
||||
BarSeriesOptions,
|
||||
BaselineSeriesOptions,
|
||||
} from 'lightweight-charts-v5';
|
||||
|
||||
// ─── 타입 재내보내기 ─────────────────────────────────────────────────────────
|
||||
|
||||
// LWC 5.x에서 제거된 LineWidth 타입 — 타입으로만 유지
|
||||
export type LineWidth = 1 | 2 | 3 | 4;
|
||||
|
||||
export type {
|
||||
Time,
|
||||
BusinessDay,
|
||||
UTCTimestamp,
|
||||
IRange,
|
||||
IRange as Range,
|
||||
LogicalRange,
|
||||
SeriesType,
|
||||
SeriesOptionsMap,
|
||||
SeriesDataItemTypeMap,
|
||||
MouseEventParams,
|
||||
MouseEventHandler,
|
||||
DeepPartial,
|
||||
ChartOptions,
|
||||
CandlestickSeriesOptions,
|
||||
LineSeriesOptions,
|
||||
HistogramSeriesOptions,
|
||||
AreaSeriesOptions,
|
||||
BarSeriesOptions,
|
||||
BaselineSeriesOptions,
|
||||
BaselineSeriesOptions as BaselineOptions,
|
||||
IPriceLine,
|
||||
PriceLineOptions,
|
||||
SeriesMarker,
|
||||
SeriesMarkerPosition,
|
||||
SeriesMarkerShape,
|
||||
CrosshairOptions,
|
||||
LayoutOptions,
|
||||
GridOptions,
|
||||
PriceScaleOptions,
|
||||
TimeScaleOptions,
|
||||
AutoscaleInfo,
|
||||
WhitespaceData,
|
||||
OhlcData,
|
||||
LineData,
|
||||
HistogramData,
|
||||
BarData,
|
||||
AreaData,
|
||||
BaselineData,
|
||||
CandlestickData,
|
||||
IPriceFormatter,
|
||||
} from 'lightweight-charts-v5';
|
||||
|
||||
// LWC 4.x 타입 호환 (v5에서 제거/변경된 것들)
|
||||
export type TimeRange<T = Time> = IRange<T>;
|
||||
|
||||
// ─── 확장된 ISeriesApi (setMarkers 포함) ────────────────────────────────────
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export interface ISeriesApi<T extends SeriesType = SeriesType> extends _ISeriesApi<T> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setMarkers(markers: any[]): void;
|
||||
}
|
||||
|
||||
// ─── 확장된 IChartApi (addXxxSeries 포함) ────────────────────────────────────
|
||||
export interface IChartApi extends _IChartApi {
|
||||
addCandlestickSeries(options?: DeepPartial<CandlestickSeriesOptions>): ISeriesApi<'Candlestick'>;
|
||||
addLineSeries(options?: DeepPartial<LineSeriesOptions>): ISeriesApi<'Line'>;
|
||||
addHistogramSeries(options?: DeepPartial<HistogramSeriesOptions>): ISeriesApi<'Histogram'>;
|
||||
addAreaSeries(options?: DeepPartial<AreaSeriesOptions>): ISeriesApi<'Area'>;
|
||||
addBarSeries(options?: DeepPartial<BarSeriesOptions>): ISeriesApi<'Bar'>;
|
||||
addBaselineSeries(options?: DeepPartial<BaselineSeriesOptions>): ISeriesApi<'Baseline'>;
|
||||
}
|
||||
|
||||
// ─── 값 재내보내기 ───────────────────────────────────────────────────────────
|
||||
export {
|
||||
ColorType,
|
||||
CrosshairMode,
|
||||
LineStyle,
|
||||
LineType,
|
||||
PriceScaleMode,
|
||||
LastPriceAnimationMode,
|
||||
MismatchDirection,
|
||||
PriceLineSource,
|
||||
TickMarkType,
|
||||
TrackingModeExitMode,
|
||||
CandlestickSeries,
|
||||
LineSeries,
|
||||
HistogramSeries,
|
||||
AreaSeries,
|
||||
BarSeries,
|
||||
BaselineSeries,
|
||||
createSeriesMarkers,
|
||||
isBusinessDay,
|
||||
isUTCTimestamp,
|
||||
version,
|
||||
};
|
||||
|
||||
// ─── series.setMarkers 지원 ─────────────────────────────────────────────────
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const markersPluginMap = new WeakMap<object, any>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function wrapSeriesMarkers<T extends SeriesType>(series: _ISeriesApi<T>): ISeriesApi<T> {
|
||||
const s = series as ISeriesApi<T>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(s as any).setMarkers = (markers: any[]) => {
|
||||
if (markersPluginMap.has(series)) {
|
||||
markersPluginMap.get(series).setMarkers(markers);
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const plugin = (createSeriesMarkers as any)(series, markers);
|
||||
markersPluginMap.set(series, plugin);
|
||||
}
|
||||
};
|
||||
return s;
|
||||
}
|
||||
|
||||
// ─── createChart 래퍼 ───────────────────────────────────────────────────────
|
||||
export function createChart(
|
||||
container: HTMLElement | string,
|
||||
options?: DeepPartial<ChartOptions>,
|
||||
): IChartApi {
|
||||
const chart = _createChart(container, options) as IChartApi;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const wrapAdd = (SeriesClass: any) => (opts?: any) =>
|
||||
wrapSeriesMarkers(chart.addSeries(SeriesClass, opts ?? {}));
|
||||
|
||||
(chart as IChartApi).addCandlestickSeries = wrapAdd(CandlestickSeries) as IChartApi['addCandlestickSeries'];
|
||||
(chart as IChartApi).addLineSeries = wrapAdd(LineSeries) as IChartApi['addLineSeries'];
|
||||
(chart as IChartApi).addHistogramSeries = wrapAdd(HistogramSeries) as IChartApi['addHistogramSeries'];
|
||||
(chart as IChartApi).addAreaSeries = wrapAdd(AreaSeries) as IChartApi['addAreaSeries'];
|
||||
(chart as IChartApi).addBarSeries = wrapAdd(BarSeries) as IChartApi['addBarSeries'];
|
||||
(chart as IChartApi).addBaselineSeries = wrapAdd(BaselineSeries) as IChartApi['addBaselineSeries'];
|
||||
|
||||
return chart;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export type ChartType = 'candlestick' | 'bar' | 'line' | 'area' | 'baseline';
|
||||
export type Theme = 'dark' | 'light';
|
||||
|
||||
export interface OHLCVBar {
|
||||
time: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export interface IndicatorParam {
|
||||
name: string;
|
||||
type: 'number' | 'string' | 'boolean';
|
||||
default: number | string | boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export interface IndicatorConfig {
|
||||
id: string;
|
||||
type: string;
|
||||
params: Record<string, number | string | boolean>;
|
||||
plots?: import('./indicatorRegistry').PlotDef[];
|
||||
hlines?: import('./indicatorRegistry').HLineDef[];
|
||||
}
|
||||
Reference in New Issue
Block a user