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();
}
}
@@ -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[];
}