goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
@@ -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();
}
}