goldenChat base source add
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 볼린저밴드 상·하단 사이 영역 채움 (업비트 "백그라운드 그리기")
|
||||
*/
|
||||
import type {
|
||||
ISeriesPrimitive,
|
||||
IPrimitivePaneView,
|
||||
SeriesAttachedParameter,
|
||||
Time,
|
||||
SeriesType,
|
||||
ISeriesApi,
|
||||
} from 'lightweight-charts';
|
||||
import type { CanvasRenderingTarget2D, MediaCoordinatesRenderingScope } from 'fancy-canvas';
|
||||
|
||||
interface ScreenPt { x: number; y: number }
|
||||
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const h = hex.startsWith('#') ? hex.slice(1) : hex;
|
||||
const r = parseInt(h.slice(0, 2), 16);
|
||||
const g = parseInt(h.slice(2, 4), 16);
|
||||
const b = parseInt(h.slice(4, 6), 16);
|
||||
const a = h.length >= 8 ? parseInt(h.slice(6, 8), 16) / 255 : alpha;
|
||||
return `rgba(${r},${g},${b},${a})`;
|
||||
}
|
||||
|
||||
function resolveFillColor(color: string): string {
|
||||
if (color.length >= 9) {
|
||||
return hexToRgba(color.slice(0, 7), parseInt(color.slice(7, 9), 16) / 255);
|
||||
}
|
||||
return hexToRgba(color.slice(0, 7), 0.15);
|
||||
}
|
||||
|
||||
class BandFillRenderer {
|
||||
constructor(
|
||||
private readonly upper: ScreenPt[],
|
||||
private readonly lower: ScreenPt[],
|
||||
private readonly fillColor: string,
|
||||
) {}
|
||||
|
||||
draw(target: CanvasRenderingTarget2D): void {
|
||||
if (this.upper.length < 2 || this.lower.length < 2) return;
|
||||
target.useMediaCoordinateSpace((scope: MediaCoordinatesRenderingScope) => {
|
||||
const ctx = scope.context;
|
||||
const n = this.upper.length;
|
||||
ctx.save();
|
||||
ctx.fillStyle = this.fillColor;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(this.upper[0].x, this.upper[0].y);
|
||||
for (let i = 1; i < n; i++) ctx.lineTo(this.upper[i].x, this.upper[i].y);
|
||||
for (let i = n - 1; i >= 0; i--) ctx.lineTo(this.lower[i].x, this.lower[i].y);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class BandFillPaneView implements IPrimitivePaneView {
|
||||
constructor(
|
||||
private readonly upper: ScreenPt[],
|
||||
private readonly lower: ScreenPt[],
|
||||
private readonly fillColor: string,
|
||||
) {}
|
||||
|
||||
renderer() { return new BandFillRenderer(this.upper, this.lower, this.fillColor); }
|
||||
zOrder(): 'bottom' { return 'bottom'; }
|
||||
}
|
||||
|
||||
export interface BandBackgroundOptions {
|
||||
visible: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** basis 시리즈에 부착, upper/lower 데이터로 채움 */
|
||||
export class BollingerBandFillPrimitive implements ISeriesPrimitive<Time> {
|
||||
private _ap: SeriesAttachedParameter<Time> | null = null;
|
||||
private _upper: ISeriesApi<SeriesType, Time>;
|
||||
private _lower: ISeriesApi<SeriesType, Time>;
|
||||
private _bg: BandBackgroundOptions;
|
||||
private _upperPts: ScreenPt[] = [];
|
||||
private _lowerPts: ScreenPt[] = [];
|
||||
|
||||
constructor(
|
||||
upperSeries: ISeriesApi<SeriesType, Time>,
|
||||
lowerSeries: ISeriesApi<SeriesType, Time>,
|
||||
background: BandBackgroundOptions,
|
||||
) {
|
||||
this._upper = upperSeries;
|
||||
this._lower = lowerSeries;
|
||||
this._bg = background;
|
||||
}
|
||||
|
||||
attached(ap: SeriesAttachedParameter<Time>): void {
|
||||
this._ap = ap;
|
||||
this._recompute();
|
||||
}
|
||||
detached(): void { this._ap = null; }
|
||||
|
||||
updateAllViews(): void { this._recompute(); }
|
||||
paneViews(): readonly IPrimitivePaneView[] {
|
||||
if (this._bg.visible === false || this._upperPts.length < 2) return [];
|
||||
return [new BandFillPaneView(this._upperPts, this._lowerPts, resolveFillColor(this._bg.color))];
|
||||
}
|
||||
|
||||
updateBackground(bg: BandBackgroundOptions): void {
|
||||
this._bg = bg;
|
||||
this._ap?.requestUpdate();
|
||||
}
|
||||
|
||||
requestRefresh(): void {
|
||||
this._recompute();
|
||||
this._ap?.requestUpdate();
|
||||
}
|
||||
|
||||
private _recompute(): void {
|
||||
if (!this._ap || this._bg.visible === false) {
|
||||
this._upperPts = [];
|
||||
this._lowerPts = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const ts = this._ap.chart.timeScale();
|
||||
const upperRaw = this._upper.data() as readonly { time: Time; value?: number }[];
|
||||
const lowerRaw = this._lower.data() as readonly { time: Time; value?: number }[];
|
||||
const lowerByTime = new Map<Time, number>();
|
||||
for (const d of lowerRaw) {
|
||||
if (d.value !== undefined && d.value !== null && !Number.isNaN(d.value)) {
|
||||
lowerByTime.set(d.time, d.value);
|
||||
}
|
||||
}
|
||||
|
||||
const upperPts: ScreenPt[] = [];
|
||||
const lowerPts: ScreenPt[] = [];
|
||||
|
||||
for (const u of upperRaw) {
|
||||
const lv = lowerByTime.get(u.time);
|
||||
if (u.value === undefined || u.value === null || Number.isNaN(u.value)) continue;
|
||||
if (lv === undefined || Number.isNaN(lv)) continue;
|
||||
const x = ts.timeToCoordinate(u.time);
|
||||
const uy = this._upper.priceToCoordinate(u.value);
|
||||
const ly = this._lower.priceToCoordinate(lv);
|
||||
if (x === null || uy === null || ly === null) continue;
|
||||
upperPts.push({ x: x as number, y: uy as number });
|
||||
lowerPts.push({ x: x as number, y: ly as number });
|
||||
}
|
||||
|
||||
this._upperPts = upperPts;
|
||||
this._lowerPts = lowerPts;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Ichimoku Cloud Fill Plugin
|
||||
* LWC v5 ISeriesPrimitive를 이용해 선행스팬A/B 사이 구름 영역을 캔버스에 직접 렌더링합니다.
|
||||
*
|
||||
* 한국 관례:
|
||||
* SpanA > SpanB → 양운 (빨간 구름, 상승 신호)
|
||||
* SpanA <= SpanB → 음운 (청록 구름, 하락 신호)
|
||||
*/
|
||||
import type {
|
||||
ISeriesPrimitive,
|
||||
IPrimitivePaneView,
|
||||
IPrimitivePaneRenderer,
|
||||
SeriesAttachedParameter,
|
||||
ISeriesApi,
|
||||
SeriesType,
|
||||
Time,
|
||||
IChartApiBase,
|
||||
} from 'lightweight-charts';
|
||||
|
||||
export interface IchimokuCloudPoint {
|
||||
time: number; // Unix timestamp (seconds)
|
||||
spanA: number;
|
||||
spanB: number;
|
||||
}
|
||||
|
||||
function toCanvasFillColor(color: string): string {
|
||||
if (color.startsWith('rgba(') || color.startsWith('rgb(')) return color;
|
||||
const hex = color.startsWith('#') ? color.slice(1) : color;
|
||||
if (hex.length < 6) return color;
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
const a = hex.length >= 8
|
||||
? parseInt(hex.slice(6, 8), 16) / 255
|
||||
: 1;
|
||||
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
}
|
||||
|
||||
// ─── Renderer ──────────────────────────────────────────────────────────────
|
||||
class CloudRenderer implements IPrimitivePaneRenderer {
|
||||
private _chart: IChartApiBase<Time>;
|
||||
private _series: ISeriesApi<SeriesType>;
|
||||
private _data: IchimokuCloudPoint[];
|
||||
private _bullishColor: string;
|
||||
private _bearishColor: string;
|
||||
|
||||
constructor(
|
||||
chart: IChartApiBase<Time>,
|
||||
series: ISeriesApi<SeriesType>,
|
||||
data: IchimokuCloudPoint[],
|
||||
bullishColor: string,
|
||||
bearishColor: string,
|
||||
) {
|
||||
this._chart = chart;
|
||||
this._series = series;
|
||||
this._data = data;
|
||||
this._bullishColor = bullishColor;
|
||||
this._bearishColor = bearishColor;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
draw(target: any): void {
|
||||
if (this._data.length < 2) return;
|
||||
const timeScale = this._chart.timeScale();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
target.useBitmapCoordinateSpace(({ context: ctx, horizontalPixelRatio: hpr, verticalPixelRatio: vpr }: any) => {
|
||||
for (let i = 0; i < this._data.length - 1; i++) {
|
||||
const a = this._data[i];
|
||||
const b = this._data[i + 1];
|
||||
|
||||
const x1 = timeScale.timeToCoordinate(a.time as Time);
|
||||
const x2 = timeScale.timeToCoordinate(b.time as Time);
|
||||
if (x1 == null || x2 == null) continue;
|
||||
|
||||
const aAy = this._series.priceToCoordinate(a.spanA);
|
||||
const aBy = this._series.priceToCoordinate(a.spanB);
|
||||
const bAy = this._series.priceToCoordinate(b.spanA);
|
||||
const bBy = this._series.priceToCoordinate(b.spanB);
|
||||
if (aAy == null || aBy == null || bAy == null || bBy == null) continue;
|
||||
|
||||
// 한국 관례: SpanA > SpanB → 양운(빨강), SpanA <= SpanB → 음운(청록)
|
||||
const bullish = (a.spanA + b.spanA) > (a.spanB + b.spanB);
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1 * hpr, aAy * vpr);
|
||||
ctx.lineTo(x2 * hpr, bAy * vpr);
|
||||
ctx.lineTo(x2 * hpr, bBy * vpr);
|
||||
ctx.lineTo(x1 * hpr, aBy * vpr);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = bullish ? this._bullishColor : this._bearishColor;
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pane View ─────────────────────────────────────────────────────────────
|
||||
class CloudPaneView implements IPrimitivePaneView {
|
||||
private _plugin: IchimokuCloudPlugin;
|
||||
constructor(plugin: IchimokuCloudPlugin) { this._plugin = plugin; }
|
||||
|
||||
renderer(): IPrimitivePaneRenderer | null {
|
||||
const chart = this._plugin.getChart();
|
||||
const series = this._plugin.getSeries();
|
||||
if (!chart || !series) return null;
|
||||
return new CloudRenderer(
|
||||
chart,
|
||||
series,
|
||||
this._plugin.getCloudData(),
|
||||
this._plugin.getBullishColor(),
|
||||
this._plugin.getBearishColor(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Plugin ────────────────────────────────────────────────────────────────
|
||||
export class IchimokuCloudPlugin implements ISeriesPrimitive<Time> {
|
||||
private _chart: IChartApiBase<Time> | null = null;
|
||||
private _series: ISeriesApi<SeriesType> | null = null;
|
||||
private _data: IchimokuCloudPoint[] = [];
|
||||
private _bullishColor = 'rgba(239, 83, 80, 0.2)';
|
||||
private _bearishColor = 'rgba(38, 166, 154, 0.2)';
|
||||
private _updateFn: (() => void) | null = null;
|
||||
|
||||
attached({ chart, series, requestUpdate }: SeriesAttachedParameter<Time>): void {
|
||||
this._chart = chart;
|
||||
this._series = series;
|
||||
this._updateFn = requestUpdate;
|
||||
}
|
||||
|
||||
detached(): void {
|
||||
this._chart = null;
|
||||
this._series = null;
|
||||
}
|
||||
|
||||
setCloudData(data: IchimokuCloudPoint[]): void {
|
||||
this._data = data;
|
||||
this._updateFn?.();
|
||||
}
|
||||
|
||||
setColors(bullishColor: string, bearishColor: string): void {
|
||||
this._bullishColor = toCanvasFillColor(bullishColor);
|
||||
this._bearishColor = toCanvasFillColor(bearishColor);
|
||||
this._updateFn?.();
|
||||
}
|
||||
|
||||
getBullishColor() { return this._bullishColor; }
|
||||
getBearishColor() { return this._bearishColor; }
|
||||
|
||||
paneViews(): readonly IPrimitivePaneView[] {
|
||||
if (!this._chart || !this._series) return [];
|
||||
return [new CloudPaneView(this)];
|
||||
}
|
||||
|
||||
getChart() { return this._chart; }
|
||||
getSeries() { return this._series; }
|
||||
getCloudData() { return this._data; }
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* IndicatorFillPrimitive
|
||||
*
|
||||
* 보조지표 그래프에서 과매수(상한선 이상) / 과매도(하한선 이하) 구간에
|
||||
* 반투명 음영을 그려주는 lightweight-charts ISeriesPrimitive 구현.
|
||||
*
|
||||
* - 상한선(과매수) 색상의 25% 투명도로 상단 영역을 채운다.
|
||||
* - 하한선(과매도) 색상의 25% 투명도로 하단 영역을 채운다.
|
||||
* - 지표 선과 한계선 사이의 정확한 교차점을 보간하여 부드러운 채움을 표현한다.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ISeriesPrimitive,
|
||||
IPrimitivePaneView,
|
||||
SeriesAttachedParameter,
|
||||
Time,
|
||||
SeriesType,
|
||||
ISeriesApi,
|
||||
} from 'lightweight-charts';
|
||||
import type { CanvasRenderingTarget2D, MediaCoordinatesRenderingScope } from 'fancy-canvas';
|
||||
import { getHLineLabel } from './indicatorRegistry';
|
||||
import type { HLineDef } from './indicatorRegistry';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 내부 유틸
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** HEX(6~9자리) + alpha 값 → CSS rgba() 문자열 */
|
||||
function hexToRgba(hex: string, alpha: number): string {
|
||||
const h = hex.startsWith('#') ? hex.slice(1) : hex;
|
||||
const r = parseInt(h.slice(0, 2), 16);
|
||||
const g = parseInt(h.slice(2, 4), 16);
|
||||
const b = parseInt(h.slice(4, 6), 16);
|
||||
return `rgba(${r},${g},${b},${alpha})`;
|
||||
}
|
||||
|
||||
interface ScreenPt { x: number; y: number; }
|
||||
|
||||
interface FillZone {
|
||||
limitY: number;
|
||||
color: string;
|
||||
isAbove: boolean; // true=상한 초과, false=하한 미만
|
||||
isBand?: boolean; // true=배경 직사각형 밴드 (과열선~침체선 사이)
|
||||
bandLimitY?: number; // isBand=true 일 때 반대쪽 경계 Y
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Canvas 그리기 유틸 (교차점 보간 포함)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 하나의 연속 구간에 대해 채움 폴리곤을 그린다.
|
||||
* pts[0]·pts[last]는 반드시 limitY 위에 있어야 한다 (교차점 또는 첫/마지막 점).
|
||||
*/
|
||||
function drawSegment(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
pts: ScreenPt[],
|
||||
limitY: number,
|
||||
): void {
|
||||
if (pts.length < 2) return;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[0].x, limitY);
|
||||
for (const p of pts) ctx.lineTo(p.x, p.y);
|
||||
ctx.lineTo(pts[pts.length - 1].x, limitY);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
/**
|
||||
* isAbove=true: indicator > limitPrice(화면상 y < limitY) → 빨간 음영
|
||||
* isAbove=false: indicator < limitPrice(화면상 y > limitY) → 초록 음영
|
||||
*/
|
||||
function drawFillZone(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
pts: ScreenPt[],
|
||||
limitY: number,
|
||||
color: string,
|
||||
isAbove: boolean,
|
||||
): void {
|
||||
if (pts.length < 2) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = color;
|
||||
|
||||
const inZone = (y: number) => isAbove ? y < limitY : y > limitY;
|
||||
|
||||
let seg: ScreenPt[] = [];
|
||||
|
||||
const flushSeg = () => {
|
||||
if (seg.length >= 1) {
|
||||
drawSegment(ctx, seg, limitY);
|
||||
seg = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const cur = pts[i];
|
||||
const curIn = inZone(cur.y);
|
||||
|
||||
if (curIn) {
|
||||
if (seg.length === 0 && i > 0) {
|
||||
// 이전 점과의 교차점 보간
|
||||
const prev = pts[i - 1];
|
||||
const t = (limitY - prev.y) / (cur.y - prev.y);
|
||||
seg.push({ x: prev.x + t * (cur.x - prev.x), y: limitY });
|
||||
}
|
||||
seg.push({ x: cur.x, y: cur.y });
|
||||
} else if (seg.length > 0) {
|
||||
// 구간 종료 → 종료 교차점 보간
|
||||
const prev = pts[i - 1];
|
||||
const t = (limitY - prev.y) / (cur.y - prev.y);
|
||||
seg.push({ x: prev.x + t * (cur.x - prev.x), y: limitY });
|
||||
flushSeg();
|
||||
}
|
||||
}
|
||||
|
||||
flushSeg(); // 마지막 구간
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Renderer
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class FillRenderer {
|
||||
constructor(
|
||||
private readonly pts: ScreenPt[],
|
||||
private readonly zones: FillZone[],
|
||||
) {}
|
||||
|
||||
draw(target: CanvasRenderingTarget2D): void {
|
||||
target.useMediaCoordinateSpace((scope: MediaCoordinatesRenderingScope) => {
|
||||
const ctx = scope.context;
|
||||
for (const z of this.zones) {
|
||||
if (z.isBand && z.bandLimitY !== undefined && this.pts.length >= 2) {
|
||||
// 과열선~침체선 사이 배경 직사각형
|
||||
const minX = this.pts[0].x;
|
||||
const maxX = this.pts[this.pts.length - 1].x;
|
||||
const topY = Math.min(z.limitY, z.bandLimitY);
|
||||
const bottomY = Math.max(z.limitY, z.bandLimitY);
|
||||
ctx.save();
|
||||
ctx.fillStyle = z.color;
|
||||
ctx.fillRect(minX, topY, maxX - minX, bottomY - topY);
|
||||
ctx.restore();
|
||||
} else {
|
||||
drawFillZone(ctx, this.pts, z.limitY, z.color, z.isAbove);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PaneView
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class FillPaneView implements IPrimitivePaneView {
|
||||
constructor(
|
||||
private readonly pts: ScreenPt[],
|
||||
private readonly zones: FillZone[],
|
||||
) {}
|
||||
|
||||
renderer() { return new FillRenderer(this.pts, this.zones); }
|
||||
zOrder(): 'bottom' { return 'bottom'; }
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Public Primitive
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface HlinesBackgroundOptions {
|
||||
visible: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export class IndicatorFillPrimitive implements ISeriesPrimitive<Time> {
|
||||
private _ap: SeriesAttachedParameter<Time> | null = null;
|
||||
private _hlines: HLineDef[] = [];
|
||||
private _background: HlinesBackgroundOptions | undefined;
|
||||
private _pts: ScreenPt[] = [];
|
||||
private _zones: FillZone[] = [];
|
||||
|
||||
constructor(hlines: HLineDef[], background?: HlinesBackgroundOptions) {
|
||||
this._hlines = hlines;
|
||||
this._background = background;
|
||||
}
|
||||
|
||||
// ISeriesPrimitive 콜백
|
||||
attached(ap: SeriesAttachedParameter<Time>): void { this._ap = ap; }
|
||||
detached(): void { this._ap = null; }
|
||||
|
||||
updateAllViews(): void { this._recompute(); }
|
||||
paneViews(): readonly IPrimitivePaneView[] {
|
||||
return [new FillPaneView(this._pts, this._zones)];
|
||||
}
|
||||
|
||||
/** 외부에서 hlines 가 변경되면 호출 */
|
||||
updateHLines(hlines: HLineDef[], background?: HlinesBackgroundOptions): void {
|
||||
this._hlines = hlines;
|
||||
this._background = background;
|
||||
this._ap?.requestUpdate();
|
||||
}
|
||||
|
||||
// ─── 좌표 재계산 ───────────────────────────────────────────────────────────
|
||||
private _recompute(): void {
|
||||
if (!this._ap) { this._pts = []; this._zones = []; return; }
|
||||
|
||||
const series = this._ap.series as ISeriesApi<SeriesType, Time>;
|
||||
const ts = this._ap.chart.timeScale();
|
||||
|
||||
// 시리즈 데이터 → 화면 좌표
|
||||
const raw = series.data() as readonly { time: Time; value?: number }[];
|
||||
this._pts = raw
|
||||
.map(d => {
|
||||
if (d.value === undefined || d.value === null || isNaN(d.value)) return null;
|
||||
const x = ts.timeToCoordinate(d.time);
|
||||
const y = series.priceToCoordinate(d.value);
|
||||
if (x === null || y === null) return null;
|
||||
return { x: x as number, y: y as number };
|
||||
})
|
||||
.filter((p): p is ScreenPt => p !== null);
|
||||
|
||||
// 음영 구간 결정 (과매수/과매도 레이블이 있는 hline 만 사용)
|
||||
const visible = this._hlines.filter(h => h.visible !== false);
|
||||
const prices = visible.map(h => h.price);
|
||||
|
||||
this._zones = [];
|
||||
|
||||
// ── Hlines Background: 과열선~침체선 사이 배경 밴드 ────────────────────
|
||||
const bg = this._background;
|
||||
if (bg?.visible !== false) {
|
||||
const upperHl = visible.find(h => (h.label ?? getHLineLabel(h.price, prices)) === '과열선');
|
||||
const lowerHl = visible.find(h => (h.label ?? getHLineLabel(h.price, prices)) === '침체선');
|
||||
if (upperHl && lowerHl) {
|
||||
const upperY = series.priceToCoordinate(upperHl.price);
|
||||
const lowerY = series.priceToCoordinate(lowerHl.price);
|
||||
if (upperY !== null && lowerY !== null) {
|
||||
// 배경을 채우기 위해 항상 존재하는 "모든 구간" 영역으로 처리
|
||||
// 과열선 아래(침체선까지)를 채우는 방식: 두 개의 존 대신 캔버스 rect 로 직접 그림
|
||||
const bgColor = bg?.color
|
||||
? hexToRgba(bg.color.slice(0, 7), parseInt(bg.color.slice(7, 9) || 'ff', 16) / 255 * 0.4)
|
||||
: 'rgba(120,123,134,0.08)';
|
||||
// 배경 존은 limitY=upperY, isAbove=false 로 표현하면 침체선 아래만 채움.
|
||||
// 대신 "배경 전용 존"은 전체 visible 구간을 채우도록 두 경계를 모두 넣는다.
|
||||
this._zones.push({
|
||||
limitY: upperY as number,
|
||||
color: bgColor,
|
||||
isAbove: false,
|
||||
isBand: true,
|
||||
bandLimitY: lowerY as number,
|
||||
} as FillZone & { isBand: true; bandLimitY: number });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 과매수/과매도 초과 구간 음영 ────────────────────────────────────────
|
||||
for (const hl of visible) {
|
||||
const label = hl.label ?? getHLineLabel(hl.price, prices);
|
||||
if (label !== '과열선' && label !== '침체선') continue;
|
||||
|
||||
const limitY = series.priceToCoordinate(hl.price);
|
||||
if (limitY === null) continue;
|
||||
|
||||
this._zones.push({
|
||||
limitY: limitY as number,
|
||||
color: hexToRgba(hl.color.slice(0, 7), 0.25),
|
||||
isAbove: label === '과열선',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** 관리자 설정 화면 잠금 해제 (세션, 30분) */
|
||||
const KEY = 'gc_admin_unlock_until';
|
||||
const TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
export function isAdminUnlocked(): boolean {
|
||||
const until = Number(sessionStorage.getItem(KEY) ?? 0);
|
||||
return Number.isFinite(until) && until > Date.now();
|
||||
}
|
||||
|
||||
export function setAdminUnlocked(): void {
|
||||
sessionStorage.setItem(KEY, String(Date.now() + TTL_MS));
|
||||
}
|
||||
|
||||
export function clearAdminUnlock(): void {
|
||||
sessionStorage.removeItem(KEY);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/** 앱 진입(스플래시 통과) — 탭 세션 단위 */
|
||||
const ENTERED_KEY = 'gc_app_entered';
|
||||
|
||||
export function isAppEntered(): boolean {
|
||||
return sessionStorage.getItem(ENTERED_KEY) === '1';
|
||||
}
|
||||
|
||||
export function setAppEntered(): void {
|
||||
sessionStorage.setItem(ENTERED_KEY, '1');
|
||||
}
|
||||
|
||||
export function clearAppEntered(): void {
|
||||
sessionStorage.removeItem(ENTERED_KEY);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 로그인 세션 (localStorage).
|
||||
* - 미로그인: X-Device-Id 만 전송 → 기존과 동일 (기기별 설정)
|
||||
* - 로그인: X-User-Id 전송 → 사용자별 DB 설정 공유
|
||||
*/
|
||||
|
||||
import type { UserRole } from './permissions';
|
||||
import { normalizeRole } from './permissions';
|
||||
|
||||
export interface AuthSession {
|
||||
userId: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
const USER_ID_KEY = 'gc_user_id';
|
||||
const USERNAME_KEY = 'gc_username';
|
||||
const DISPLAY_NAME_KEY = 'gc_display_name';
|
||||
const ROLE_KEY = 'gc_user_role';
|
||||
|
||||
export function getAuthSession(): AuthSession | null {
|
||||
const id = localStorage.getItem(USER_ID_KEY);
|
||||
if (!id) return null;
|
||||
const userId = Number(id);
|
||||
if (!Number.isFinite(userId) || userId <= 0) return null;
|
||||
const username = localStorage.getItem(USERNAME_KEY) ?? '';
|
||||
const displayName = localStorage.getItem(DISPLAY_NAME_KEY) ?? username;
|
||||
const storedRole = localStorage.getItem(ROLE_KEY);
|
||||
const role = normalizeRole(storedRole ?? 'USER');
|
||||
return { userId, username, displayName, role };
|
||||
}
|
||||
|
||||
export function setAuthSession(session: AuthSession): void {
|
||||
localStorage.setItem(USER_ID_KEY, String(session.userId));
|
||||
localStorage.setItem(USERNAME_KEY, session.username);
|
||||
localStorage.setItem(DISPLAY_NAME_KEY, session.displayName);
|
||||
localStorage.setItem(ROLE_KEY, session.role);
|
||||
}
|
||||
|
||||
export function clearAuthSession(): void {
|
||||
localStorage.removeItem(USER_ID_KEY);
|
||||
localStorage.removeItem(USERNAME_KEY);
|
||||
localStorage.removeItem(DISPLAY_NAME_KEY);
|
||||
localStorage.removeItem(ROLE_KEY);
|
||||
}
|
||||
|
||||
export function isLoggedIn(): boolean {
|
||||
return getAuthSession() !== null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 볼린저밴드 (업비트 BB 설정)
|
||||
* - 길이(length) 기본 20, 곱(mult) 기본 2, 오프셋(offset) 기본 0
|
||||
* - 기준선: 종가 SMA, 표준편차는 모집단(N) 기준
|
||||
*/
|
||||
import type { OHLCVBar, Timeframe } from '../types';
|
||||
import type { PlotData } from './indicatorRegistry';
|
||||
import { fetchUpbitCandles, isUpbitMarket } from './upbitApi';
|
||||
|
||||
export const BB_DEFAULT_LENGTH = 20;
|
||||
export const BB_DEFAULT_MULT = 2;
|
||||
export const BB_DEFAULT_OFFSET = 0;
|
||||
|
||||
/** 업비트 BB 백그라운드 그리기 기본 (반투명 파랑) */
|
||||
export const DEFAULT_BB_BAND_BACKGROUND = {
|
||||
visible: true,
|
||||
color: '#42A5F528',
|
||||
} as const;
|
||||
|
||||
const BB_PLOT_TITLES = ['중앙값', '어퍼', '로우어'] as const;
|
||||
const BB_PLOT_IDS = ['plot0', 'plot1', 'plot2'] as const;
|
||||
const BB_DEFAULT_COLORS = ['#FF9800', '#42A5F5', '#42A5F5'] as const;
|
||||
|
||||
export type BbSymbolMode = 'main' | 'other';
|
||||
|
||||
export type BbBandBackground = { visible: boolean; color: string };
|
||||
|
||||
export function resolveBbBandBackground(
|
||||
bg?: Partial<BbBandBackground> | null,
|
||||
): BbBandBackground {
|
||||
return {
|
||||
visible: bg?.visible !== false,
|
||||
color: bg?.color ?? DEFAULT_BB_BAND_BACKGROUND.color,
|
||||
};
|
||||
}
|
||||
|
||||
/** 업비트 플롯 정의 병합 (중앙값·어퍼·로우어, 기본 색) */
|
||||
export function mergeBbPlots(
|
||||
saved: import('./indicatorRegistry').PlotDef[] | undefined,
|
||||
defaults: import('./indicatorRegistry').PlotDef[],
|
||||
): import('./indicatorRegistry').PlotDef[] {
|
||||
const byId = new Map((saved ?? []).map(p => [p.id, p]));
|
||||
return BB_PLOT_IDS.map((id, i) => {
|
||||
const def = defaults.find(p => p.id === id) ?? defaults[i];
|
||||
const prev = byId.get(id);
|
||||
return {
|
||||
...def,
|
||||
...prev,
|
||||
id,
|
||||
title: BB_PLOT_TITLES[i],
|
||||
color: prev?.color ?? BB_DEFAULT_COLORS[i],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 차트 레전드: BB 20 2 0 SMA */
|
||||
export function formatBbLegendLabel(
|
||||
params: Record<string, number | string | boolean>,
|
||||
): string {
|
||||
const p = normalizeBollingerParams(params);
|
||||
const ma = String(p.maType ?? 'SMA').replace(/\s*\(.*\)\s*/g, '').trim();
|
||||
return `BB ${p.length} ${p.mult} ${p.offset} ${ma}`;
|
||||
}
|
||||
|
||||
export function normalizeBollingerParams(
|
||||
params: Record<string, number | string | boolean>,
|
||||
): Record<string, number | string | boolean> {
|
||||
const length = Math.max(1, Math.round(Number(params.length ?? BB_DEFAULT_LENGTH) || BB_DEFAULT_LENGTH));
|
||||
const mult = Math.max(0.001, Number(params.mult ?? BB_DEFAULT_MULT) || BB_DEFAULT_MULT);
|
||||
const offset = Math.round(Number(params.offset ?? BB_DEFAULT_OFFSET) || 0);
|
||||
const rawMode = String(params.symbolMode ?? 'main');
|
||||
const symbolMode: BbSymbolMode = rawMode === 'other' ? 'other' : 'main';
|
||||
const refSymbol = String(params.refSymbol ?? '').trim().toUpperCase();
|
||||
return {
|
||||
...params,
|
||||
length,
|
||||
mult,
|
||||
offset,
|
||||
symbolMode,
|
||||
refSymbol,
|
||||
src: 'close',
|
||||
maType: 'SMA',
|
||||
};
|
||||
}
|
||||
|
||||
/** TradingView/업비트 오프셋: 양수면 플롯을 오른쪽(미래)으로 이동 */
|
||||
export function applyPlotOffset(data: PlotData, offset: number): PlotData {
|
||||
if (!offset) return data;
|
||||
const n = data.length;
|
||||
return data.map((point, i) => {
|
||||
const srcIdx = i - offset;
|
||||
if (srcIdx < 0 || srcIdx >= n) {
|
||||
return { time: point.time, value: NaN };
|
||||
}
|
||||
return { time: point.time, value: data[srcIdx].value };
|
||||
});
|
||||
}
|
||||
|
||||
/** 다른 심볼 캔들을 메인 차트 시간축에 맞춤 (같은 time 키) */
|
||||
export function alignBarsToMainTimeline(mainBars: OHLCVBar[], refBars: OHLCVBar[]): OHLCVBar[] {
|
||||
const byTime = new Map(refBars.map(b => [b.time, b]));
|
||||
return mainBars.map(mb => {
|
||||
const ref = byTime.get(mb.time);
|
||||
if (!ref) {
|
||||
return { ...mb, open: NaN, high: NaN, low: NaN, close: NaN, volume: 0 };
|
||||
}
|
||||
return ref;
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveBollingerBars(
|
||||
mainBars: OHLCVBar[],
|
||||
params: Record<string, number | string | boolean>,
|
||||
timeframe: Timeframe,
|
||||
): Promise<OHLCVBar[]> {
|
||||
const p = normalizeBollingerParams(params);
|
||||
if (p.symbolMode !== 'other') return mainBars;
|
||||
const ref = String(p.refSymbol ?? '');
|
||||
if (!ref || !isUpbitMarket(ref)) return mainBars;
|
||||
try {
|
||||
const refBars = await fetchUpbitCandles(ref, timeframe, mainBars.length);
|
||||
return alignBarsToMainTimeline(mainBars, refBars);
|
||||
} catch (e) {
|
||||
console.warn('[BB] ref symbol fetch failed:', ref, e);
|
||||
return mainBars;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { OHLCVBar } from '../types';
|
||||
|
||||
export interface PriceStats {
|
||||
current: number;
|
||||
change: number;
|
||||
changePct: number;
|
||||
high52w: number;
|
||||
low52w: number;
|
||||
avgVol: number;
|
||||
avgRange: number;
|
||||
volatility: number; // annualized std dev of returns (%)
|
||||
rrsi14: number; // rough RSI(14) approximation
|
||||
pivotH: number; // classic pivot points
|
||||
pivotL: number;
|
||||
pivotP: number;
|
||||
support1: number;
|
||||
support2: number;
|
||||
resistance1: number;
|
||||
resistance2: number;
|
||||
trend: 'up' | 'down' | 'sideways';
|
||||
}
|
||||
|
||||
export function calcStats(bars: OHLCVBar[]): PriceStats {
|
||||
if (bars.length < 2) {
|
||||
return {
|
||||
current: 0, change: 0, changePct: 0,
|
||||
high52w: 0, low52w: 0, avgVol: 0, avgRange: 0,
|
||||
volatility: 0, rrsi14: 50,
|
||||
pivotH: 0, pivotL: 0, pivotP: 0,
|
||||
support1: 0, support2: 0, resistance1: 0, resistance2: 0,
|
||||
trend: 'sideways',
|
||||
};
|
||||
}
|
||||
|
||||
const last = bars[bars.length - 1];
|
||||
const prev = bars[bars.length - 2];
|
||||
const change = last.close - prev.close;
|
||||
const changePct = change / prev.close * 100;
|
||||
|
||||
const lookback = Math.min(bars.length, 252);
|
||||
const slice = bars.slice(-lookback);
|
||||
|
||||
const high52w = Math.max(...slice.map(b => b.high));
|
||||
const low52w = Math.min(...slice.map(b => b.low));
|
||||
const avgVol = slice.reduce((s, b) => s + b.volume, 0) / slice.length;
|
||||
const avgRange= slice.reduce((s, b) => s + (b.high - b.low), 0) / slice.length;
|
||||
|
||||
// Annualized volatility
|
||||
const returns = slice.slice(1).map((b, i) => Math.log(b.close / slice[i].close));
|
||||
const mean = returns.reduce((s, r) => s + r, 0) / returns.length;
|
||||
const variance = returns.reduce((s, r) => s + (r - mean) ** 2, 0) / returns.length;
|
||||
const volatility = Math.sqrt(variance * 252) * 100;
|
||||
|
||||
// Rough RSI(14)
|
||||
const rsiSlice = bars.slice(-15);
|
||||
let gains = 0, losses = 0;
|
||||
for (let i = 1; i < rsiSlice.length; i++) {
|
||||
const d = rsiSlice[i].close - rsiSlice[i - 1].close;
|
||||
if (d > 0) gains += d; else losses -= d;
|
||||
}
|
||||
const avgGain = gains / 14;
|
||||
const avgLoss = losses / 14;
|
||||
const rrsi14 = avgLoss === 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss);
|
||||
|
||||
// Classic Pivot Points (from last bar)
|
||||
const pivotH = last.high;
|
||||
const pivotL = last.low;
|
||||
const pivotP = (last.high + last.low + last.close) / 3;
|
||||
const resistance1 = 2 * pivotP - last.low;
|
||||
const support1 = 2 * pivotP - last.high;
|
||||
const resistance2 = pivotP + (last.high - last.low);
|
||||
const support2 = pivotP - (last.high - last.low);
|
||||
|
||||
// Trend (20-bar EMA vs 50-bar EMA)
|
||||
const ema20 = calcEMA(bars.map(b => b.close), 20);
|
||||
const ema50 = calcEMA(bars.map(b => b.close), 50);
|
||||
const e20 = ema20[ema20.length - 1];
|
||||
const e50 = ema50[ema50.length - 1];
|
||||
const trend = e20 > e50 * 1.002 ? 'up' : e20 < e50 * 0.998 ? 'down' : 'sideways';
|
||||
|
||||
return {
|
||||
current: last.close, change, changePct,
|
||||
high52w, low52w, avgVol, avgRange, volatility, rrsi14,
|
||||
pivotH, pivotL, pivotP,
|
||||
support1, support2, resistance1, resistance2,
|
||||
trend,
|
||||
};
|
||||
}
|
||||
|
||||
function calcEMA(prices: number[], period: number): number[] {
|
||||
const k = 2 / (period + 1);
|
||||
const result: number[] = [];
|
||||
let ema = prices[0];
|
||||
for (const p of prices) {
|
||||
ema = p * k + ema * (1 - k);
|
||||
result.push(ema);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Auto-calculate Fibonacci retracement levels from visible data */
|
||||
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,12 @@
|
||||
import type { Timeframe } from '../types';
|
||||
|
||||
/** 차트 Timeframe → 백엔드 candleType */
|
||||
export function timeframeToCandleType(tf: Timeframe): string {
|
||||
switch (tf) {
|
||||
case '1m': case '3m': case '5m': case '15m': case '30m': case '1h': case '4h':
|
||||
return tf;
|
||||
case '1D': return '1d';
|
||||
case '1W': case '1M': return '1d';
|
||||
default: return '1m';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/** HEX · RGB · HSV 변환 (색상 팔레트 UI용) */
|
||||
|
||||
export interface Rgb {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
export interface Hsv {
|
||||
/** 0–360 */
|
||||
h: number;
|
||||
/** 0–1 */
|
||||
s: number;
|
||||
/** 0–1 */
|
||||
v: number;
|
||||
}
|
||||
|
||||
function clamp(n: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, n));
|
||||
}
|
||||
|
||||
export function normalizeHex6(hex: string): string {
|
||||
const h = hex.trim();
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(h)) return h.toUpperCase();
|
||||
if (/^#[0-9A-Fa-f]{3}$/.test(h)) {
|
||||
const c = h.slice(1);
|
||||
return ('#' + c[0] + c[0] + c[1] + c[1] + c[2] + c[2]).toUpperCase();
|
||||
}
|
||||
return '#2962FF';
|
||||
}
|
||||
|
||||
export function hexToRgb(hex: string): Rgb {
|
||||
const h = normalizeHex6(hex).slice(1);
|
||||
return {
|
||||
r: parseInt(h.slice(0, 2), 16),
|
||||
g: parseInt(h.slice(2, 4), 16),
|
||||
b: parseInt(h.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
export function rgbToHex(r: number, g: number, b: number): string {
|
||||
const to = (n: number) => clamp(Math.round(n), 0, 255).toString(16).padStart(2, '0');
|
||||
return `#${to(r)}${to(g)}${to(b)}`.toUpperCase();
|
||||
}
|
||||
|
||||
export function rgbToHsv({ r, g, b }: Rgb): Hsv {
|
||||
const rn = r / 255;
|
||||
const gn = g / 255;
|
||||
const bn = b / 255;
|
||||
const max = Math.max(rn, gn, bn);
|
||||
const min = Math.min(rn, gn, bn);
|
||||
const d = max - min;
|
||||
let h = 0;
|
||||
if (d !== 0) {
|
||||
if (max === rn) h = ((gn - bn) / d) % 6;
|
||||
else if (max === gn) h = (bn - rn) / d + 2;
|
||||
else h = (rn - gn) / d + 4;
|
||||
h *= 60;
|
||||
if (h < 0) h += 360;
|
||||
}
|
||||
const s = max === 0 ? 0 : d / max;
|
||||
return { h, s, v: max };
|
||||
}
|
||||
|
||||
export function hsvToRgb({ h, s, v }: Hsv): Rgb {
|
||||
const c = v * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = v - c;
|
||||
let rp = 0;
|
||||
let gp = 0;
|
||||
let bp = 0;
|
||||
if (h < 60) { rp = c; gp = x; }
|
||||
else if (h < 120) { rp = x; gp = c; }
|
||||
else if (h < 180) { gp = c; bp = x; }
|
||||
else if (h < 240) { gp = x; bp = c; }
|
||||
else if (h < 300) { rp = x; bp = c; }
|
||||
else { rp = c; bp = x; }
|
||||
return {
|
||||
r: Math.round((rp + m) * 255),
|
||||
g: Math.round((gp + m) * 255),
|
||||
b: Math.round((bp + m) * 255),
|
||||
};
|
||||
}
|
||||
|
||||
export function hexToHsv(hex: string): Hsv {
|
||||
return rgbToHsv(hexToRgb(hex));
|
||||
}
|
||||
|
||||
export function hsvToHex(hsv: Hsv): string {
|
||||
const { r, g, b } = hsvToRgb(hsv);
|
||||
return rgbToHex(r, g, b);
|
||||
}
|
||||
|
||||
/** 채도·명도 영역 배경 (hue 고정) */
|
||||
export function svPanelBackground(hue: number): string {
|
||||
const pure = hsvToHex({ h: hue, s: 1, v: 1 });
|
||||
return `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, ${pure})`;
|
||||
}
|
||||
|
||||
/** 색상(Hue) 슬라이더 배경 */
|
||||
export const HUE_SLIDER_BG =
|
||||
'linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)';
|
||||
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* lightweight-charts-indicators 미등록 / 확장 지표 계산
|
||||
* (업비트 차트 기준 공식)
|
||||
*/
|
||||
import type { OHLCVBar } from '../types';
|
||||
import type { PlotData } from './indicatorRegistry';
|
||||
import { applyPlotOffset, normalizeBollingerParams } from './bollingerConfig';
|
||||
|
||||
function sma(values: number[], period: number): number[] {
|
||||
return values.map((_, i) => {
|
||||
if (i < period - 1) return NaN;
|
||||
let sum = 0;
|
||||
for (let j = i - period + 1; j <= i; j++) sum += values[j];
|
||||
return sum / period;
|
||||
});
|
||||
}
|
||||
|
||||
function ema(values: number[], period: number): number[] {
|
||||
const out = new Array<number>(values.length).fill(NaN);
|
||||
if (values.length < period) return out;
|
||||
|
||||
// 선행 NaN(1차 EMA 워밍업 등) 이후 첫 유효 구간에서 SMA 시드
|
||||
let start = -1;
|
||||
for (let i = period - 1; i < values.length; i++) {
|
||||
let ok = true;
|
||||
for (let j = i - period + 1; j <= i; j++) {
|
||||
if (!Number.isFinite(values[j])) { ok = false; break; }
|
||||
}
|
||||
if (ok) { start = i; break; }
|
||||
}
|
||||
if (start < 0) return out;
|
||||
|
||||
let sum = 0;
|
||||
for (let j = start - period + 1; j <= start; j++) sum += values[j];
|
||||
let prev = sum / period;
|
||||
out[start] = prev;
|
||||
const k = 2 / (period + 1);
|
||||
for (let i = start + 1; i < values.length; i++) {
|
||||
const v = values[i];
|
||||
if (!Number.isFinite(v)) { out[i] = NaN; continue; }
|
||||
prev = v * k + prev * (1 - k);
|
||||
out[i] = prev;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sourceAt(bar: OHLCVBar, src: string): number {
|
||||
switch (src) {
|
||||
case 'open': return bar.open;
|
||||
case 'high': return bar.high;
|
||||
case 'low': return bar.low;
|
||||
case 'hl2': return (bar.high + bar.low) / 2;
|
||||
case 'hlc3': return (bar.high + bar.low + bar.close) / 3;
|
||||
case 'ohlc4': return (bar.open + bar.high + bar.low + bar.close) / 4;
|
||||
default: return bar.close;
|
||||
}
|
||||
}
|
||||
|
||||
function toPlotData(bars: OHLCVBar[], values: number[]): PlotData {
|
||||
return bars.map((b, i) => ({
|
||||
time: b.time,
|
||||
value: Number.isFinite(values[i]) ? values[i] : NaN,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 업비트 이격도: 종가 ÷ 이동평균 × 100 */
|
||||
export function calcDisparityUpbit(
|
||||
bars: OHLCVBar[],
|
||||
params: Record<string, number | string | boolean>,
|
||||
): Record<string, PlotData> {
|
||||
const src = String(params.src ?? 'close');
|
||||
const closes = bars.map(b => sourceAt(b, src));
|
||||
const ultra = Number(params.ultraLength ?? 5);
|
||||
const short = Number(params.shortLength ?? 10);
|
||||
const mid = Number(params.midLength ?? 20);
|
||||
const long = Number(params.longLength ?? 60);
|
||||
|
||||
const plot = (period: number, id: string) => {
|
||||
const ma = sma(closes, period);
|
||||
const vals = closes.map((c, i) =>
|
||||
Number.isFinite(ma[i]) && ma[i] !== 0 ? (c / ma[i]) * 100 : NaN,
|
||||
);
|
||||
return { [id]: toPlotData(bars, vals) };
|
||||
};
|
||||
|
||||
return {
|
||||
...plot(ultra, 'plot0'),
|
||||
...plot(short, 'plot1'),
|
||||
...plot(mid, 'plot2'),
|
||||
...plot(long, 'plot3'),
|
||||
};
|
||||
}
|
||||
|
||||
/** VR: 상승일 거래량 합 / 하락일 거래량 합 × 100 */
|
||||
export function calcVR(
|
||||
bars: OHLCVBar[],
|
||||
params: Record<string, number | string | boolean>,
|
||||
): Record<string, PlotData> {
|
||||
const period = Number(params.length ?? 10);
|
||||
const closes = bars.map(b => b.close);
|
||||
const volumes = bars.map(b => b.volume);
|
||||
const vals = closes.map((_, i) => {
|
||||
if (i < period) return NaN;
|
||||
let upVol = 0;
|
||||
let downVol = 0;
|
||||
for (let j = i - period + 1; j <= i; j++) {
|
||||
if (closes[j] > closes[j - 1]) upVol += volumes[j];
|
||||
else if (closes[j] < closes[j - 1]) downVol += volumes[j];
|
||||
}
|
||||
if (downVol === 0) return NaN;
|
||||
return (upVol / downVol) * 100;
|
||||
});
|
||||
return { plot0: toPlotData(bars, vals) };
|
||||
}
|
||||
|
||||
/** 심리도(업비트): N일 중 전일 대비 상승일 비율 × 100 */
|
||||
export function calcPsychological(
|
||||
bars: OHLCVBar[],
|
||||
params: Record<string, number | string | boolean>,
|
||||
): Record<string, PlotData> {
|
||||
const period = Number(params.length ?? 12);
|
||||
const closes = bars.map(b => b.close);
|
||||
const vals = closes.map((_, i) => {
|
||||
if (i < period) return NaN;
|
||||
let up = 0;
|
||||
for (let j = i - period + 1; j <= i; j++) {
|
||||
if (closes[j] > closes[j - 1]) up++;
|
||||
}
|
||||
return (up / period) * 100;
|
||||
});
|
||||
return { plot0: toPlotData(bars, vals) };
|
||||
}
|
||||
|
||||
/** @deprecated Psychological 사용 */
|
||||
export const calcNewPsychological = calcPsychological;
|
||||
|
||||
/** 투자심리도(업비트): N일 중 상승일 거래량 비중 × 100 */
|
||||
export function calcInvestPsychological(
|
||||
bars: OHLCVBar[],
|
||||
params: Record<string, number | string | boolean>,
|
||||
): Record<string, PlotData> {
|
||||
const period = Number(params.length ?? 10);
|
||||
const closes = bars.map(b => b.close);
|
||||
const volumes = bars.map(b => b.volume);
|
||||
const vals = closes.map((_, i) => {
|
||||
if (i < period) return NaN;
|
||||
let upVol = 0;
|
||||
let total = 0;
|
||||
for (let j = i - period + 1; j <= i; j++) {
|
||||
total += volumes[j];
|
||||
if (closes[j] > closes[j - 1]) upVol += volumes[j];
|
||||
}
|
||||
if (total === 0) return NaN;
|
||||
return (upVol / total) * 100;
|
||||
});
|
||||
return { plot0: toPlotData(bars, vals) };
|
||||
}
|
||||
|
||||
/**
|
||||
* TRIX + 신호선 (업비트·TradingView 동일)
|
||||
* 1) ln(종가) → 2) EMA × 3 → 3) 1봉 차분 × 10000 → 4) 신호선 = TRIX의 EMA(signalLength)
|
||||
*/
|
||||
export function calcTRIXWithSignal(
|
||||
bars: OHLCVBar[],
|
||||
params: Record<string, number | string | boolean>,
|
||||
): Record<string, PlotData> {
|
||||
const len = Number(params.length ?? 12);
|
||||
const sigLen = Number(params.signalLength ?? 9);
|
||||
const logClose = bars.map(b => (b.close > 0 ? Math.log(b.close) : NaN));
|
||||
|
||||
const e1 = ema(logClose, len);
|
||||
const e2 = ema(e1.map(v => (Number.isFinite(v) ? v : NaN)), len);
|
||||
const e3 = ema(e2.map(v => (Number.isFinite(v) ? v : NaN)), len);
|
||||
|
||||
const trix = e3.map((v, i) => {
|
||||
if (i === 0 || !Number.isFinite(v) || !Number.isFinite(e3[i - 1])) return NaN;
|
||||
return (v - e3[i - 1]) * 10_000;
|
||||
});
|
||||
|
||||
const signal = ema(trix.map(v => (Number.isFinite(v) ? v : NaN)), sigLen);
|
||||
|
||||
return {
|
||||
plot0: toPlotData(bars, trix),
|
||||
plot1: toPlotData(bars, signal),
|
||||
};
|
||||
}
|
||||
|
||||
/** OBV + 신호선 MA (업비트: 단순·지수·가중, 기본 단순 9) */
|
||||
export function calcOBVWithMA(
|
||||
bars: OHLCVBar[],
|
||||
params: Record<string, number | string | boolean>,
|
||||
): Record<string, PlotData> {
|
||||
const raw = String(params.maType ?? 'SMA');
|
||||
const maType = raw === 'EMA' || raw === 'WMA' ? raw : 'SMA';
|
||||
const maLen = Math.max(1, Number(params.maLength ?? 9) || 9);
|
||||
|
||||
const obv: number[] = [];
|
||||
let acc = 0;
|
||||
for (let i = 0; i < bars.length; i++) {
|
||||
if (i === 0) {
|
||||
obv.push(0);
|
||||
continue;
|
||||
}
|
||||
const vol = bars[i].volume;
|
||||
if (bars[i].close > bars[i - 1].close) acc += vol;
|
||||
else if (bars[i].close < bars[i - 1].close) acc -= vol;
|
||||
obv.push(acc);
|
||||
}
|
||||
|
||||
let ma: number[];
|
||||
switch (maType) {
|
||||
case 'EMA':
|
||||
ma = ema(obv, maLen);
|
||||
break;
|
||||
case 'WMA':
|
||||
ma = obv.map((_, i) => {
|
||||
if (i < maLen - 1) return NaN;
|
||||
let wSum = 0;
|
||||
let w = 0;
|
||||
for (let j = 0; j < maLen; j++) {
|
||||
const weight = j + 1;
|
||||
wSum += obv[i - maLen + 1 + j] * weight;
|
||||
w += weight;
|
||||
}
|
||||
return wSum / w;
|
||||
});
|
||||
break;
|
||||
default:
|
||||
ma = sma(obv, maLen);
|
||||
}
|
||||
|
||||
return {
|
||||
plot0: toPlotData(bars, obv),
|
||||
plot1: toPlotData(bars, ma),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 표준편차 (TradingView ta.stdev / 업비트 BB)
|
||||
* - Pine v5 ta.stdev: 모집단 분산 (N으로 나눔)
|
||||
*/
|
||||
function stdevBb(values: number[], period: number): number[] {
|
||||
const avg = sma(values, period);
|
||||
return values.map((_, i) => {
|
||||
if (i < period - 1) return NaN;
|
||||
let sumSq = 0;
|
||||
for (let j = i - period + 1; j <= i; j++) {
|
||||
const d = values[j] - avg[i];
|
||||
sumSq += d * d;
|
||||
}
|
||||
return Math.sqrt(sumSq / period);
|
||||
});
|
||||
}
|
||||
|
||||
/** 볼린저밴드 (업비트): SMA(종가) ± mult × σ, 오프셋 적용 */
|
||||
export function calcBollingerBands(
|
||||
bars: OHLCVBar[],
|
||||
params: Record<string, number | string | boolean>,
|
||||
): Record<string, PlotData> {
|
||||
const p = normalizeBollingerParams(params);
|
||||
const period = p.length as number;
|
||||
const mult = p.mult as number;
|
||||
const offset = p.offset as number;
|
||||
const src = String(p.src ?? 'close');
|
||||
const values = bars.map(b => sourceAt(b, src));
|
||||
|
||||
const basis = sma(values, period);
|
||||
const dev = stdevBb(values, period).map(d => (Number.isFinite(d) ? d * mult : NaN));
|
||||
const upper = basis.map((m, i) =>
|
||||
Number.isFinite(m) && Number.isFinite(dev[i]) ? m + dev[i] : NaN,
|
||||
);
|
||||
const lower = basis.map((m, i) =>
|
||||
Number.isFinite(m) && Number.isFinite(dev[i]) ? m - dev[i] : NaN,
|
||||
);
|
||||
|
||||
let plot0 = toPlotData(bars, basis);
|
||||
let plot1 = toPlotData(bars, upper);
|
||||
let plot2 = toPlotData(bars, lower);
|
||||
if (offset) {
|
||||
plot0 = applyPlotOffset(plot0, offset);
|
||||
plot1 = applyPlotOffset(plot1, offset);
|
||||
plot2 = applyPlotOffset(plot2, offset);
|
||||
}
|
||||
return { plot0, plot1, plot2 };
|
||||
}
|
||||
|
||||
/** Wilder RMA — TradingView ta.rma / oakscriptjs 와 동일 시드·유지 */
|
||||
function rmaWilder(values: Array<number | null>, period: number): number[] {
|
||||
const len = Math.max(1, Math.floor(period));
|
||||
const alpha = 1 / len;
|
||||
const out = new Array<number>(values.length).fill(NaN);
|
||||
let initSum = 0;
|
||||
let validCount = 0;
|
||||
let firstIdx = -1;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const v = values[i];
|
||||
if (v != null && Number.isFinite(v)) {
|
||||
initSum += v;
|
||||
validCount++;
|
||||
if (validCount === len) {
|
||||
firstIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let rmaVal = validCount > 0 ? initSum / validCount : NaN;
|
||||
const initialized = firstIdx >= 0;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
if (!initialized || i < firstIdx) {
|
||||
out[i] = NaN;
|
||||
} else if (i === firstIdx) {
|
||||
out[i] = rmaVal;
|
||||
} else {
|
||||
const v = values[i];
|
||||
if (v != null && Number.isFinite(v)) {
|
||||
rmaVal = alpha * v + (1 - alpha) * rmaVal;
|
||||
}
|
||||
out[i] = rmaVal;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** ADXR = (ADX + ADX[n]) / 2 (Wilder·업비트) */
|
||||
function calcAdxr(adx: number[], lag: number): number[] {
|
||||
return adx.map((cur, i) => {
|
||||
if (!Number.isFinite(cur) || i < lag) return NaN;
|
||||
const prev = adx[i - lag];
|
||||
return Number.isFinite(prev) ? (cur + prev) / 2 : NaN;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DMI (업비트) — +DI, -DI, DX, ADX, ADXR
|
||||
* 파라미터: diLength(DI 길이), adxSmoothing(ADX 스무딩)
|
||||
*/
|
||||
export function calcDMI(
|
||||
bars: OHLCVBar[],
|
||||
params: Record<string, number | string | boolean>,
|
||||
): Record<string, PlotData> {
|
||||
const diLength = Math.max(1, Number(params.diLength ?? params.length ?? 14));
|
||||
const adxSmoothing = Math.max(1, Number(params.adxSmoothing ?? 14));
|
||||
const len = bars.length;
|
||||
|
||||
const plusDM: Array<number | null> = [];
|
||||
const minusDM: Array<number | null> = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (i === 0) {
|
||||
plusDM.push(null);
|
||||
minusDM.push(null);
|
||||
} else {
|
||||
const upMove = bars[i].high - bars[i - 1].high;
|
||||
const downMove = bars[i - 1].low - bars[i].low;
|
||||
plusDM.push(upMove > downMove && upMove > 0 ? upMove : 0);
|
||||
minusDM.push(downMove > upMove && downMove > 0 ? downMove : 0);
|
||||
}
|
||||
}
|
||||
|
||||
const tr: Array<number | null> = bars.map((b, i) => {
|
||||
if (i === 0) return null;
|
||||
const hl = b.high - b.low;
|
||||
const hc = Math.abs(b.high - bars[i - 1].close);
|
||||
const lc = Math.abs(b.low - bars[i - 1].close);
|
||||
return Math.max(hl, hc, lc);
|
||||
});
|
||||
|
||||
const smPlusDM = rmaWilder(plusDM, diLength);
|
||||
const smMinusDM = rmaWilder(minusDM, diLength);
|
||||
const smTR = rmaWilder(tr, diLength);
|
||||
|
||||
let lastPlus = 0;
|
||||
let lastMinus = 0;
|
||||
const plusDI: number[] = [];
|
||||
const minusDI: number[] = [];
|
||||
for (let i = 0; i < len; i++) {
|
||||
const t = smTR[i];
|
||||
const pDm = smPlusDM[i];
|
||||
const mDm = smMinusDM[i];
|
||||
if (!Number.isFinite(t) || t === 0 || !Number.isFinite(pDm)) {
|
||||
plusDI.push(lastPlus);
|
||||
} else {
|
||||
const val = (100 * pDm) / t;
|
||||
plusDI.push(val);
|
||||
lastPlus = val;
|
||||
}
|
||||
if (!Number.isFinite(t) || t === 0 || !Number.isFinite(mDm)) {
|
||||
minusDI.push(lastMinus);
|
||||
} else {
|
||||
const val = (100 * mDm) / t;
|
||||
minusDI.push(val);
|
||||
lastMinus = val;
|
||||
}
|
||||
}
|
||||
|
||||
const dx: number[] = plusDI.map((p, i) => {
|
||||
const m = minusDI[i];
|
||||
const sum = p + m;
|
||||
return sum === 0 ? 0 : (100 * Math.abs(p - m)) / sum;
|
||||
});
|
||||
|
||||
const adx = rmaWilder(dx, adxSmoothing);
|
||||
const adxr = calcAdxr(adx, adxSmoothing);
|
||||
|
||||
return {
|
||||
plot0: toPlotData(bars, plusDI),
|
||||
plot1: toPlotData(bars, minusDI),
|
||||
plot2: toPlotData(bars, dx),
|
||||
plot3: toPlotData(bars, adx),
|
||||
plot4: toPlotData(bars, adxr),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { OHLCVBar, Timeframe } from '../types';
|
||||
import { formatUnixForChart } from './timezone';
|
||||
|
||||
export const SYMBOLS = [
|
||||
'BTCUSD','ETHUSD','BNBUSD','SOLUSD','ADAUSD',
|
||||
'AAPL','MSFT','TSLA','NVDA','AMZN','GOOGL','META',
|
||||
'SPX500','NASDAQ','DJI',
|
||||
'EURUSD','GBPUSD','USDJPY','AUDUSD',
|
||||
'XAUUSD','XAGUSD','USOIL',
|
||||
];
|
||||
|
||||
const TF_SECONDS: Record<Timeframe, number> = {
|
||||
'1m':60,'3m':180,'5m':300,'15m':900,'30m':1800,'1h':3600,'4h':14400,'1D':86400,'1W':604800,'1M':2592000,
|
||||
};
|
||||
|
||||
const TF_BARS: Record<Timeframe, number> = {
|
||||
'1m':300,'3m':300,'5m':300,'15m':300,'30m':300,'1h':500,'4h':500,'1D':500,'1W':300,'1M':120,
|
||||
};
|
||||
|
||||
const SEED_MAP: Record<string, number> = {
|
||||
BTCUSD:1, ETHUSD:2, BNBUSD:3, SOLUSD:4, ADAUSD:5,
|
||||
AAPL:6, MSFT:7, TSLA:8, NVDA:9, AMZN:10, GOOGL:11, META:12,
|
||||
SPX500:13, NASDAQ:14, DJI:15,
|
||||
EURUSD:16, GBPUSD:17, USDJPY:18, AUDUSD:19,
|
||||
XAUUSD:20, XAGUSD:21, USOIL:22,
|
||||
};
|
||||
|
||||
const BASE_PRICE: Record<string, number> = {
|
||||
BTCUSD:45000, ETHUSD:2800, BNBUSD:380, SOLUSD:105, ADAUSD:0.52,
|
||||
AAPL:192, MSFT:420, TSLA:245, NVDA:870, AMZN:195, GOOGL:172, META:510,
|
||||
SPX500:5200, NASDAQ:18500, DJI:39000,
|
||||
EURUSD:1.088, GBPUSD:1.268, USDJPY:148, AUDUSD:0.655,
|
||||
XAUUSD:2050, XAGUSD:23.5, USOIL:78,
|
||||
};
|
||||
|
||||
const VOLATILITY: Record<string, number> = {
|
||||
BTCUSD:0.025, ETHUSD:0.03, BNBUSD:0.025, SOLUSD:0.04, ADAUSD:0.035,
|
||||
AAPL:0.012, MSFT:0.011, TSLA:0.03, NVDA:0.025, AMZN:0.013, GOOGL:0.012, META:0.018,
|
||||
SPX500:0.008, NASDAQ:0.01, DJI:0.007,
|
||||
EURUSD:0.004, GBPUSD:0.005, USDJPY:0.004, AUDUSD:0.005,
|
||||
XAUUSD:0.008, XAGUSD:0.012, USOIL:0.015,
|
||||
};
|
||||
|
||||
function lcg(seed: number) {
|
||||
let s = seed;
|
||||
return () => { s = (s * 1664525 + 1013904223) & 0xffffffff; return (s >>> 0) / 0xffffffff; };
|
||||
}
|
||||
|
||||
export function generateBars(symbol: string, timeframe: Timeframe): OHLCVBar[] {
|
||||
const seed = (SEED_MAP[symbol] ?? 1) * 97 + (TF_SECONDS[timeframe] ?? 86400);
|
||||
const rand = lcg(seed);
|
||||
const count = TF_BARS[timeframe] ?? 300;
|
||||
const tfSec = TF_SECONDS[timeframe] ?? 86400;
|
||||
const baseP = BASE_PRICE[symbol] ?? 100;
|
||||
const vol = VOLATILITY[symbol] ?? 0.015;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const startTime = now - count * tfSec;
|
||||
|
||||
const bars: OHLCVBar[] = [];
|
||||
let price = baseP;
|
||||
const digits = baseP < 5 ? 4 : baseP < 100 ? 2 : 0;
|
||||
const roundP = (p: number) => parseFloat(p.toFixed(digits));
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = startTime + i * tfSec;
|
||||
// Trend bias - creates realistic trending periods
|
||||
const trendBias = Math.sin(i / 40) * vol * 0.4 + (rand() - 0.495) * vol;
|
||||
const open = price;
|
||||
const close = roundP(open * (1 + trendBias));
|
||||
const spread = Math.abs(close - open);
|
||||
const highExtra = rand() * spread * (1.5 + rand());
|
||||
const lowExtra = rand() * spread * (1.5 + rand());
|
||||
const high = roundP(Math.max(open, close) + highExtra);
|
||||
const low = roundP(Math.min(open, close) - lowExtra);
|
||||
const volume = Math.round((0.5 + rand() * 1.5) * baseP * 1000 * (1 + Math.abs(trendBias) / vol));
|
||||
|
||||
bars.push({ time: t, open, high, low, close, volume });
|
||||
price = close;
|
||||
}
|
||||
return bars;
|
||||
}
|
||||
|
||||
export function formatPrice(price: number): string {
|
||||
if (price >= 10000) return price.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||
if (price >= 100) return price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
if (price >= 1) return price.toLocaleString('en-US', { minimumFractionDigits: 3, maximumFractionDigits: 4 });
|
||||
return price.toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 6 });
|
||||
}
|
||||
|
||||
export function formatTime(ts: number, timeframe: Timeframe): string {
|
||||
return formatUnixForChart(ts, timeframe);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* FCM 웹 푸시 — frontend_golden / goldenAnalysis 패턴 기반
|
||||
* VITE_FIREBASE_* 환경 변수 필요
|
||||
*/
|
||||
import { registerFcmToken } from './backendApi';
|
||||
|
||||
const FIREBASE_CDN = 'https://www.gstatic.com/firebasejs/10.7.1';
|
||||
|
||||
type FirebaseNs = {
|
||||
initializeApp: (cfg: Record<string, string>) => { name: string };
|
||||
messaging: () => {
|
||||
getToken: (opts: { vapidKey: string; serviceWorkerRegistration?: ServiceWorkerRegistration }) => Promise<string>;
|
||||
onMessage: (cb: (p: { notification?: { title?: string; body?: string } }) => void) => void;
|
||||
};
|
||||
};
|
||||
|
||||
function loadScript(src: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (document.querySelector(`script[src="${src}"]`)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const s = document.createElement('script');
|
||||
s.src = src;
|
||||
s.onload = () => resolve();
|
||||
s.onerror = () => reject(new Error(`load failed: ${src}`));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
function firebaseConfig() {
|
||||
const apiKey = import.meta.env.VITE_FIREBASE_API_KEY as string | undefined;
|
||||
if (!apiKey || apiKey === 'YOUR_API_KEY') return null;
|
||||
return {
|
||||
apiKey,
|
||||
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN ?? '',
|
||||
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID ?? '',
|
||||
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET ?? '',
|
||||
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID ?? '',
|
||||
appId: import.meta.env.VITE_FIREBASE_APP_ID ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export async function initFcmPush(onForeground?: (title: string, body: string) => void): Promise<boolean> {
|
||||
const cfg = firebaseConfig();
|
||||
const vapidKey = import.meta.env.VITE_FIREBASE_VAPID_KEY as string | undefined;
|
||||
if (!cfg || !vapidKey) {
|
||||
console.info('[FCM] VITE_FIREBASE_* 미설정 — 푸시 비활성');
|
||||
return false;
|
||||
}
|
||||
if (!('serviceWorker' in navigator) || !('Notification' in window)) return false;
|
||||
|
||||
try {
|
||||
await loadScript(`${FIREBASE_CDN}/firebase-app-compat.js`);
|
||||
await loadScript(`${FIREBASE_CDN}/firebase-messaging-compat.js`);
|
||||
const fb = (window as unknown as { firebase: FirebaseNs }).firebase;
|
||||
fb.initializeApp(cfg);
|
||||
const reg = await navigator.serviceWorker.register('/firebase-messaging-sw.js');
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') return false;
|
||||
|
||||
const token = await fb.messaging().getToken({ vapidKey, serviceWorkerRegistration: reg });
|
||||
if (token) await registerFcmToken(token);
|
||||
|
||||
fb.messaging().onMessage(payload => {
|
||||
const title = payload.notification?.title ?? 'GoldenChart';
|
||||
const body = payload.notification?.body ?? '';
|
||||
onForeground?.(title, body);
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('[FCM] init failed', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* 일목균형표(IchimokuCloud) 설정
|
||||
*/
|
||||
import type { IndicatorConfig } from '../types';
|
||||
import type { PlotDef } from './indicatorRegistry';
|
||||
|
||||
/** 설정 화면 한 줄 = 플롯 1개 + (선택) 기간 파라미터 */
|
||||
export interface IchimokuLineRowDef {
|
||||
plotIndex: number;
|
||||
plotId: string;
|
||||
/** 플롯 행 기간 입력에 쓰는 params 키 */
|
||||
paramKey?: string;
|
||||
}
|
||||
|
||||
/** registry plot ID → 차트·설정 화면 한글 라벨 */
|
||||
export const ICHIMOKU_PLOT_TITLES: Record<string, string> = {
|
||||
plot0: '전환선',
|
||||
plot1: '기준선',
|
||||
plot2: '후행스팬',
|
||||
plot3: '선행스팬1',
|
||||
plot4: '선행스팬2',
|
||||
};
|
||||
|
||||
export function getIchimokuPlotTitle(plotId: string): string {
|
||||
return ICHIMOKU_PLOT_TITLES[plotId] ?? plotId;
|
||||
}
|
||||
|
||||
export const ICHIMOKU_LINE_ROWS: IchimokuLineRowDef[] = [
|
||||
{ plotIndex: 0, plotId: 'plot0', paramKey: 'conversionPeriods' },
|
||||
{ plotIndex: 1, plotId: 'plot1', paramKey: 'basePeriods' },
|
||||
{ plotIndex: 2, plotId: 'plot2', paramKey: 'displacement' },
|
||||
{ plotIndex: 3, plotId: 'plot3', paramKey: 'laggingSpan2Periods' },
|
||||
{ plotIndex: 4, plotId: 'plot4', paramKey: 'displacement' },
|
||||
];
|
||||
|
||||
export const ICHIMOKU_DEFAULT_PARAMS: Record<string, number> = {
|
||||
conversionPeriods: 9,
|
||||
basePeriods: 26,
|
||||
laggingSpan2Periods: 52,
|
||||
displacement: 26,
|
||||
};
|
||||
|
||||
export function createDefaultIchimokuParams(): Record<string, number> {
|
||||
return { ...ICHIMOKU_DEFAULT_PARAMS };
|
||||
}
|
||||
|
||||
export function createDefaultIchimokuPlotVisibility(): Record<string, boolean> {
|
||||
const vis: Record<string, boolean> = {};
|
||||
for (const row of ICHIMOKU_LINE_ROWS) vis[row.plotId] = true;
|
||||
return vis;
|
||||
}
|
||||
|
||||
export interface IchimokuCloudColors {
|
||||
/** 선행스팬1 > 선행스팬2 (상승 구름) */
|
||||
bullishColor: string;
|
||||
/** 선행스팬1 < 선행스팬2 (하락 구름) */
|
||||
bearishColor: string;
|
||||
}
|
||||
|
||||
/** #RRGGBBAA — 상승 구름 빨강 20%, 하락 구름 청록 20% */
|
||||
export const DEFAULT_ICHIMOKU_CLOUD_COLORS: IchimokuCloudColors = {
|
||||
bullishColor: '#EF535033',
|
||||
bearishColor: '#26A69A33',
|
||||
};
|
||||
|
||||
/** registry plot ID → lightweight-charts-indicators plot ID */
|
||||
export const ICHIMOKU_REGISTRY_TO_LIB: Record<string, string> = {
|
||||
plot0: 'plot0',
|
||||
plot1: 'plot1',
|
||||
plot2: 'plot3',
|
||||
plot3: 'plot4',
|
||||
plot4: 'plot2',
|
||||
};
|
||||
|
||||
export const ICHIMOKU_LIB_TO_REGISTRY: Record<string, string> = {
|
||||
plot0: 'plot0',
|
||||
plot1: 'plot1',
|
||||
plot2: 'plot4',
|
||||
plot3: 'plot2',
|
||||
plot4: 'plot3',
|
||||
};
|
||||
|
||||
export function isIchimokuCloudVisible(config: IndicatorConfig): boolean {
|
||||
return (
|
||||
config.plotVisibility?.['plot2'] !== false &&
|
||||
config.plotVisibility?.['plot3'] !== false
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveIchimokuCloudColors(
|
||||
colors?: Partial<IchimokuCloudColors>,
|
||||
): IchimokuCloudColors {
|
||||
return {
|
||||
bullishColor: colors?.bullishColor ?? DEFAULT_ICHIMOKU_CLOUD_COLORS.bullishColor,
|
||||
bearishColor: colors?.bearishColor ?? DEFAULT_ICHIMOKU_CLOUD_COLORS.bearishColor,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeIchimokuConfig(config: IndicatorConfig): IndicatorConfig {
|
||||
if (config.type !== 'IchimokuCloud') return config;
|
||||
|
||||
const params: Record<string, number | string | boolean> = {
|
||||
...createDefaultIchimokuParams(),
|
||||
...config.params,
|
||||
};
|
||||
for (const key of Object.keys(ICHIMOKU_DEFAULT_PARAMS)) {
|
||||
const v = params[key];
|
||||
if (typeof v !== 'number' || v < 1) {
|
||||
params[key] = ICHIMOKU_DEFAULT_PARAMS[key];
|
||||
}
|
||||
}
|
||||
|
||||
const plotVisibility: Record<string, boolean> = {
|
||||
...createDefaultIchimokuPlotVisibility(),
|
||||
...config.plotVisibility,
|
||||
};
|
||||
|
||||
return {
|
||||
...config,
|
||||
params,
|
||||
plotVisibility,
|
||||
cloudColors: resolveIchimokuCloudColors(config.cloudColors),
|
||||
};
|
||||
}
|
||||
|
||||
/** registry 기본 플롯과 병합된 일목 플롯 목록 */
|
||||
export function mergeIchimokuPlots(
|
||||
saved: PlotDef[] | undefined,
|
||||
defaults: PlotDef[],
|
||||
): PlotDef[] {
|
||||
const savedMap = new Map((saved ?? []).map(p => [p.id, p]));
|
||||
return defaults.map(def => {
|
||||
const s = savedMap.get(def.id);
|
||||
const title = getIchimokuPlotTitle(def.id);
|
||||
return s
|
||||
? { ...def, ...s, id: def.id, title, type: 'line' as const }
|
||||
: { ...def, title };
|
||||
});
|
||||
}
|
||||
|
||||
/** #RRGGBB 또는 #RRGGBBAA → rgba(r,g,b,a) 표시 문자열 */
|
||||
export function colorToRgbaString(color: string): string {
|
||||
if (color.startsWith('rgba(')) return color;
|
||||
const hex = color.startsWith('#') ? color.slice(1) : color;
|
||||
if (hex.length < 6) return color;
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
const a = hex.length >= 8
|
||||
? Math.round(parseInt(hex.slice(6, 8), 16) / 255 * 100) / 100
|
||||
: 1;
|
||||
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
}
|
||||
|
||||
export function cloudColorOpacityPercent(color: string): number {
|
||||
if (color.startsWith('rgba(')) {
|
||||
const m = color.match(/,\s*([\d.]+)\s*\)$/);
|
||||
return m ? Math.round(parseFloat(m[1]) * 100) : 100;
|
||||
}
|
||||
if (color.length >= 9) {
|
||||
return Math.round(parseInt(color.slice(7, 9), 16) / 255 * 100);
|
||||
}
|
||||
return 100;
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* 지표 설정 화면 라벨 — 한글명 (영문명) 형식
|
||||
*/
|
||||
|
||||
type LabelPair = { ko: string; en: string };
|
||||
|
||||
function bilingual({ ko, en }: LabelPair): string {
|
||||
return `${ko} (${en})`;
|
||||
}
|
||||
|
||||
/** 입력값(파라미터) 키 라벨 */
|
||||
const PARAM_LABELS: Record<string, LabelPair> = {
|
||||
len: { ko: '기간', en: 'Length' },
|
||||
length: { ko: '기간', en: 'Length' },
|
||||
src: { ko: '소스', en: 'Source' },
|
||||
fastLength: { ko: '단기 기간', en: 'Fast Length' },
|
||||
slowLength: { ko: '장기 기간', en: 'Slow Length' },
|
||||
signalLength: { ko: '신호선 기간', en: 'Signal Length' },
|
||||
mult: { ko: '승수', en: 'Multiplier' },
|
||||
multiplier: { ko: '승수', en: 'Multiplier' },
|
||||
offset: { ko: '오프셋', en: 'Offset' },
|
||||
sigma: { ko: '시그마', en: 'Sigma' },
|
||||
increment: { ko: '증가값', en: 'Increment' },
|
||||
maximum: { ko: '최대값', en: 'Maximum' },
|
||||
start: { ko: '시작값', en: 'Start' },
|
||||
factor: { ko: '계수', en: 'Factor' },
|
||||
atrPeriod: { ko: 'ATR 기간', en: 'ATR Period' },
|
||||
displacement: { ko: '이동 기간', en: 'Displacement' },
|
||||
conversionPeriods: { ko: '전환선 기간', en: 'Conversion Periods' },
|
||||
basePeriods: { ko: '기준선 기간', en: 'Base Periods' },
|
||||
laggingSpan2Periods: { ko: '선행스팬2 기간', en: 'Lagging Span 2 Periods' },
|
||||
kLength: { ko: '%K 기간', en: '%K Length' },
|
||||
dSmoothing: { ko: '%D 스무딩', en: '%D Smoothing' },
|
||||
smooth: { ko: '스무딩', en: 'Smooth' },
|
||||
lengthRSI: { ko: 'RSI 기간', en: 'RSI Length' },
|
||||
lengthStoch: { ko: '스토캐 기간', en: 'Stoch Length' },
|
||||
smoothK: { ko: '%K 스무딩', en: '%K Smooth' },
|
||||
smoothD: { ko: '%D 스무딩', en: '%D Smooth' },
|
||||
length1: { ko: '기간 1', en: 'Length 1' },
|
||||
length2: { ko: '기간 2', en: 'Length 2' },
|
||||
length3: { ko: '기간 3', en: 'Length 3' },
|
||||
rsiLength: { ko: 'RSI 기간', en: 'RSI Length' },
|
||||
upDownLength: { ko: '상하 기간', en: 'Up/Down Length' },
|
||||
rocLength: { ko: 'ROC 기간', en: 'ROC Length' },
|
||||
longLength: { ko: '장기', en: 'Long Length' },
|
||||
shortLength: { ko: '단기', en: 'Short Length' },
|
||||
adxSmoothing: { ko: 'ADX 스무딩', en: 'ADX Smoothing' },
|
||||
diLength: { ko: 'DI 길이', en: 'DI Length' },
|
||||
percent: { ko: '비율', en: 'Percent' },
|
||||
deviation: { ko: '편차', en: 'Deviation' },
|
||||
depth: { ko: '깊이', en: 'Depth' },
|
||||
backstep: { ko: '되돌림', en: 'Backstep' },
|
||||
p: { ko: 'P', en: 'P' },
|
||||
q: { ko: 'Q', en: 'Q' },
|
||||
x: { ko: 'X', en: 'X' },
|
||||
annualBars: { ko: '연간 봉 수', en: 'Annual Bars' },
|
||||
smoothing: { ko: '스무딩', en: 'Smoothing' },
|
||||
maType: { ko: '신호선', en: 'Signal Type' },
|
||||
maLength: { ko: '신호선 기간', en: 'Signal Length' },
|
||||
ultraLength: { ko: '초단기 기간', en: 'Ultra Length' },
|
||||
midLength: { ko: '중기 기간', en: 'Mid Length' },
|
||||
sessionType: { ko: '세션 유형', en: 'Session Type' },
|
||||
ccilength: { ko: 'CCI 기간', en: 'CCI Length' },
|
||||
turbolen: { ko: '터보 기간', en: 'Turbo Length' },
|
||||
stdevLength: { ko: '표준편차 기간', en: 'Stdev Length' },
|
||||
period1: { ko: 'MA1 기간', en: 'MA1 Period' },
|
||||
period2: { ko: 'MA2 기간', en: 'MA2 Period' },
|
||||
period3: { ko: 'MA3 기간', en: 'MA3 Period' },
|
||||
period4: { ko: 'MA4 기간', en: 'MA4 Period' },
|
||||
period5: { ko: 'MA5 기간', en: 'MA5 Period' },
|
||||
period6: { ko: 'MA6 기간', en: 'MA6 Period' },
|
||||
period7: { ko: 'MA7 기간', en: 'MA7 Period' },
|
||||
period8: { ko: 'MA8 기간', en: 'MA8 Period' },
|
||||
period9: { ko: 'MA9 기간', en: 'MA9 Period' },
|
||||
period10: { ko: 'MA10 기간', en: 'MA10 Period' },
|
||||
period11: { ko: 'MA11 기간', en: 'MA11 Period' },
|
||||
};
|
||||
|
||||
/** 플롯(선) 타이틀 라벨 */
|
||||
const PLOT_LABELS: Record<string, LabelPair> = {
|
||||
SMA: { ko: '단순이동평균', en: 'SMA' },
|
||||
EMA: { ko: '지수이동평균', en: 'EMA' },
|
||||
WMA: { ko: '가중이동평균', en: 'WMA' },
|
||||
HMA: { ko: '헐이동평균', en: 'HMA' },
|
||||
VWMA: { ko: '거래량가중이동평균', en: 'VWMA' },
|
||||
DEMA: { ko: '이중지수이동평균', en: 'DEMA' },
|
||||
TEMA: { ko: '삼중지수이동평균', en: 'TEMA' },
|
||||
LSMA: { ko: '최소제곱이동평균', en: 'LSMA' },
|
||||
ALMA: { ko: '아르노-레구이동평균', en: 'ALMA' },
|
||||
RMA: { ko: '윌더스무딩이동평균', en: 'RMA' },
|
||||
SMMA: { ko: '스무딩이동평균', en: 'SMMA' },
|
||||
MGD: { ko: '맥긴리다이나믹', en: 'MGD' },
|
||||
Median: { ko: '중간값', en: 'Median' },
|
||||
TWAP: { ko: '시간가중평균가격', en: 'TWAP' },
|
||||
Fast: { ko: '단기', en: 'Fast' },
|
||||
Slow: { ko: '장기', en: 'Slow' },
|
||||
Upper: { ko: '상단', en: 'Upper' },
|
||||
Lower: { ko: '하단', en: 'Lower' },
|
||||
Middle: { ko: '중간', en: 'Middle' },
|
||||
Basis: { ko: '기준', en: 'Basis' },
|
||||
중앙값: { ko: '중앙값', en: 'Basis' },
|
||||
어퍼: { ko: '어퍼', en: 'Upper' },
|
||||
로우어: { ko: '로우어', en: 'Lower' },
|
||||
Tenkan: { ko: '전환선', en: 'Tenkan' },
|
||||
Kijun: { ko: '기준선', en: 'Kijun' },
|
||||
SpanA: { ko: '선행스팬1', en: 'SpanA' },
|
||||
SpanB: { ko: '선행스팬2', en: 'SpanB' },
|
||||
Lagging: { ko: '후행스팬', en: 'Lagging' },
|
||||
RSI: { ko: '상대강도지수', en: 'RSI' },
|
||||
'%K': { ko: '스토캐스틱 %K', en: '%K' },
|
||||
'%D': { ko: '스토캐스틱 %D', en: '%D' },
|
||||
'%R': { ko: '윌리엄스 %R', en: '%R' },
|
||||
'%B': { ko: '볼린저 %B', en: '%B' },
|
||||
CCI: { ko: '상품채널지수', en: 'CCI' },
|
||||
'CCI MA': { ko: 'CCI 이동평균', en: 'CCI MA' },
|
||||
AO: { ko: '어썸오실레이터', en: 'AO' },
|
||||
CMO: { ko: '챈드모멘텀', en: 'CMO' },
|
||||
DPO: { ko: '비트렌드가격', en: 'DPO' },
|
||||
RVI: { ko: '상대활력지수', en: 'RVI' },
|
||||
Fisher: { ko: '피셔변환', en: 'Fisher' },
|
||||
Signal: { ko: '신호선', en: 'Signal' },
|
||||
UO: { ko: '울티메이트', en: 'UO' },
|
||||
CRSI: { ko: '코너스RSI', en: 'CRSI' },
|
||||
SMI: { ko: 'SMI', en: 'SMI' },
|
||||
SMIO: { ko: 'SMI 오실레이터', en: 'SMIO' },
|
||||
Turbo: { ko: '터보', en: 'Turbo' },
|
||||
MACD: { ko: 'MACD', en: 'MACD' },
|
||||
Histogram: { ko: '히스토그램', en: 'Histogram' },
|
||||
MOM: { ko: '모멘텀', en: 'MOM' },
|
||||
ROC: { ko: '가격변화율', en: 'ROC' },
|
||||
TSI: { ko: '참강도지수', en: 'TSI' },
|
||||
TRIX: { ko: 'TRIX', en: 'TRIX' },
|
||||
KST: { ko: 'KST', en: 'KST' },
|
||||
Coppock: { ko: '콥독커브', en: 'Coppock' },
|
||||
BBP: { ko: '매수매도세력', en: 'BBP' },
|
||||
EFI: { ko: '엘더강도지수', en: 'EFI' },
|
||||
PPO: { ko: '가격오실레이터', en: 'PPO' },
|
||||
EOM: { ko: '이동용이성', en: 'EOM' },
|
||||
ADX: { ko: '평균방향성지수', en: 'ADX' },
|
||||
'+DI': { ko: '+방향지표', en: '+DI' },
|
||||
'-DI': { ko: '-방향지표', en: '-DI' },
|
||||
DX: { ko: '방향지수', en: 'DX' },
|
||||
ADXR: { ko: 'ADX 등급', en: 'ADXR' },
|
||||
Up: { ko: '상승', en: 'Up' },
|
||||
Down: { ko: '하락', en: 'Down' },
|
||||
ST: { ko: '슈퍼트렌드', en: 'ST' },
|
||||
SAR: { ko: '파라볼릭SAR', en: 'SAR' },
|
||||
CHOP: { ko: '불규칙성지수', en: 'CHOP' },
|
||||
MI: { ko: '매스인덱스', en: 'MI' },
|
||||
'VI+': { ko: '보텍스+', en: 'VI+' },
|
||||
'VI-': { ko: '보텍스-', en: 'VI-' },
|
||||
Jaw: { ko: '턱', en: 'Jaw' },
|
||||
Teeth: { ko: '이', en: 'Teeth' },
|
||||
Lips: { ko: '입술', en: 'Lips' },
|
||||
BBTrend: { ko: '볼린저추세', en: 'BBTrend' },
|
||||
'Stop+': { ko: '스탑+', en: 'Stop+' },
|
||||
'Stop-': { ko: '스탑-', en: 'Stop-' },
|
||||
ZZ: { ko: '지그재그', en: 'ZZ' },
|
||||
RCI9: { ko: 'RCI 9', en: 'RCI9' },
|
||||
RCI26: { ko: 'RCI 26', en: 'RCI26' },
|
||||
ATR: { ko: '평균실제범위', en: 'ATR' },
|
||||
ADR: { ko: '평균일일범위', en: 'ADR' },
|
||||
StdDev: { ko: '표준편차', en: 'StdDev' },
|
||||
HV: { ko: '역사적변동성', en: 'HV' },
|
||||
OBV: { ko: '잔량균형', en: 'OBV' },
|
||||
심리도: { ko: '심리도', en: 'Psychological' },
|
||||
투자심리도: { ko: '투자심리도', en: 'Invest Psychological' },
|
||||
MFI: { ko: '자금흐름지수', en: 'MFI' },
|
||||
CMF: { ko: '차이킨자금흐름', en: 'CMF' },
|
||||
CO: { ko: '차이킨오실레이터', en: 'CO' },
|
||||
PVT: { ko: '가격거래량추세', en: 'PVT' },
|
||||
NV: { ko: '순거래량', en: 'NV' },
|
||||
VD: { ko: '거래량델타', en: 'VD' },
|
||||
CVD: { ko: '누적거래량델타', en: 'CVD' },
|
||||
VO: { ko: '거래량오실레이터', en: 'VO' },
|
||||
KVO: { ko: '클링거오실레이터', en: 'KVO' },
|
||||
RVAT: { ko: '시간대별상대거래량', en: 'RVAT' },
|
||||
BBW: { ko: '볼린저밴드폭', en: 'BBW' },
|
||||
MA1: { ko: 'MA1', en: 'MA1' },
|
||||
MA2: { ko: 'MA2', en: 'MA2' },
|
||||
MA3: { ko: 'MA3', en: 'MA3' },
|
||||
MA4: { ko: 'MA4', en: 'MA4' },
|
||||
MA5: { ko: 'MA5', en: 'MA5' },
|
||||
MA6: { ko: 'MA6', en: 'MA6' },
|
||||
MA7: { ko: 'MA7', en: 'MA7' },
|
||||
MA8: { ko: 'MA8', en: 'MA8' },
|
||||
MA9: { ko: 'MA9', en: 'MA9' },
|
||||
MA10: { ko: 'MA10', en: 'MA10' },
|
||||
MA11: { ko: 'MA11', en: 'MA11' },
|
||||
};
|
||||
|
||||
/** 소스(src) 선택 옵션 라벨 */
|
||||
const SRC_LABELS: Record<string, LabelPair> = {
|
||||
close: { ko: '종가', en: 'close' },
|
||||
open: { ko: '시가', en: 'open' },
|
||||
high: { ko: '고가', en: 'high' },
|
||||
low: { ko: '저가', en: 'low' },
|
||||
hl2: { ko: '고저평균', en: 'hl2' },
|
||||
hlc3: { ko: '고저종평균', en: 'hlc3' },
|
||||
ohlc4: { ko: 'OHLC평균', en: 'ohlc4' },
|
||||
Regular:{ ko: '정규장', en: 'Regular' },
|
||||
RMA: { ko: 'RMA', en: 'RMA' },
|
||||
};
|
||||
|
||||
/** camelCase → 공백 구분 영문 (미매핑 키 폴백) */
|
||||
function humanizeKey(key: string): string {
|
||||
return key
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/^./, s => s.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function getParamLabel(key: string, indicatorType?: string): string {
|
||||
if (indicatorType === 'BollingerBands') {
|
||||
if (key === 'length') return bilingual({ ko: '길이', en: 'Length' });
|
||||
if (key === 'mult') return bilingual({ ko: '곱', en: 'Multiplier' });
|
||||
if (key === 'offset') return bilingual({ ko: '오프셋', en: 'Offset' });
|
||||
}
|
||||
if (key === 'maType' && indicatorType === 'OBV') {
|
||||
return bilingual({ ko: '잔량균형(OBV)', en: 'OBV Smoothing' });
|
||||
}
|
||||
if (key === 'maLength' && indicatorType === 'OBV') {
|
||||
return bilingual({ ko: '신호선 기간', en: 'Signal Length' });
|
||||
}
|
||||
const pair = PARAM_LABELS[key];
|
||||
if (pair) return bilingual(pair);
|
||||
return bilingual({ ko: humanizeKey(key), en: key });
|
||||
}
|
||||
|
||||
export function getPlotLabel(title: string): string {
|
||||
const pair = PLOT_LABELS[title];
|
||||
if (pair) return bilingual(pair);
|
||||
return bilingual({ ko: title, en: title });
|
||||
}
|
||||
|
||||
export function getSrcOptionLabel(value: string): string {
|
||||
const pair = SRC_LABELS[value];
|
||||
if (pair) return bilingual(pair);
|
||||
return bilingual({ ko: value, en: value });
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* 지표 추가 팝업 Main 탭 — 설정 초기화·차트 인스턴스 병합
|
||||
*/
|
||||
import type { IndicatorConfig } from '../types';
|
||||
import type { PlotDef, HLineDef } from './indicatorRegistry';
|
||||
import { getIndicatorDef, enrichIndicatorConfig } from './indicatorRegistry';
|
||||
import { MAIN_INDICATOR_TYPES } from './indicatorMainTab';
|
||||
import { getSettingsIndicatorTypes } from './indicatorSettingsList';
|
||||
import { initializeIndicatorConfigForEditor } from './indicatorSettingsEditor';
|
||||
import {
|
||||
normalizeSmaConfig,
|
||||
createDefaultSmaPlotVisibility,
|
||||
} from './smaConfig';
|
||||
import { normalizeIchimokuConfig } from './ichimokuConfig';
|
||||
import type { IchimokuCloudColors } from './ichimokuConfig';
|
||||
import { isMainIndicatorType } from './indicatorMainTab';
|
||||
|
||||
export type MainIndicatorDraftMap = Record<string, IndicatorConfig>;
|
||||
|
||||
/** 지표 추가 팝업에서 차트 미적용 지표 설정용 modal id 접두사 */
|
||||
export const INDICATOR_SETTINGS_TEMPLATE_PREFIX = 'template:';
|
||||
|
||||
export function indicatorSettingsTemplateId(type: string): string {
|
||||
return `${INDICATOR_SETTINGS_TEMPLATE_PREFIX}${type}`;
|
||||
}
|
||||
|
||||
export function isIndicatorSettingsTemplateId(id: string): boolean {
|
||||
return id.startsWith(INDICATOR_SETTINGS_TEMPLATE_PREFIX);
|
||||
}
|
||||
|
||||
export function indicatorTypeFromSettingsId(id: string): string | null {
|
||||
if (!isIndicatorSettingsTemplateId(id)) return null;
|
||||
return id.slice(INDICATOR_SETTINGS_TEMPLATE_PREFIX.length);
|
||||
}
|
||||
|
||||
export function buildMainIndicatorConfig(
|
||||
type: string,
|
||||
activeIndicators: IndicatorConfig[],
|
||||
getParams: (type: string, defaults: Record<string, number | string | boolean>) => Record<string, number | string | boolean>,
|
||||
getVisualConfig: (
|
||||
type: string,
|
||||
defaultPlots?: PlotDef[],
|
||||
defaultHlines?: HLineDef[],
|
||||
) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors },
|
||||
): IndicatorConfig | null {
|
||||
const def = getIndicatorDef(type);
|
||||
if (!def) return null;
|
||||
const active = activeIndicators.find(i => i.type === type);
|
||||
const base: IndicatorConfig = active ?? {
|
||||
id: `template_${type}`,
|
||||
type,
|
||||
params: {},
|
||||
plots: [],
|
||||
hlines: [],
|
||||
};
|
||||
return initializeIndicatorConfigForEditor(base, getParams, getVisualConfig);
|
||||
}
|
||||
|
||||
/** Main 탭 16종 전체 설정 맵 (차트에 없어도 DB 기본값 편집용) */
|
||||
export function buildMainIndicatorDraftMap(
|
||||
activeIndicators: IndicatorConfig[],
|
||||
getParams: (type: string, defaults: Record<string, number | string | boolean>) => Record<string, number | string | boolean>,
|
||||
getVisualConfig: (
|
||||
type: string,
|
||||
defaultPlots?: PlotDef[],
|
||||
defaultHlines?: HLineDef[],
|
||||
) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors },
|
||||
): MainIndicatorDraftMap {
|
||||
const map: MainIndicatorDraftMap = {};
|
||||
for (const type of MAIN_INDICATOR_TYPES) {
|
||||
const cfg = buildMainIndicatorConfig(type, activeIndicators, getParams, getVisualConfig);
|
||||
if (cfg) map[type] = cfg;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function newMainIndicatorId(): string {
|
||||
return `ind_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function finalizeMainIndicatorConfig(cfg: IndicatorConfig, isNew: boolean): IndicatorConfig {
|
||||
let out = enrichIndicatorConfig(cfg);
|
||||
if (out.type === 'SMA') {
|
||||
out = normalizeSmaConfig({
|
||||
...out,
|
||||
plotVisibility: isNew
|
||||
? (out.plotVisibility ?? createDefaultSmaPlotVisibility())
|
||||
: out.plotVisibility,
|
||||
});
|
||||
} else if (out.type === 'IchimokuCloud') {
|
||||
out = normalizeIchimokuConfig(out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Main 탭 설정 → 현재 차트에 올라간 동일 type 인스턴스에 반영 (id·hidden 유지) */
|
||||
export function mergeMainDraftOntoChart(
|
||||
activeIndicators: IndicatorConfig[],
|
||||
draftByType: MainIndicatorDraftMap,
|
||||
): IndicatorConfig[] {
|
||||
return activeIndicators.map(ind => {
|
||||
const tpl = draftByType[ind.type];
|
||||
if (!tpl) return enrichIndicatorConfig(ind);
|
||||
return finalizeMainIndicatorConfig(
|
||||
{ ...tpl, id: ind.id, hidden: ind.hidden },
|
||||
false,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Main 탭 일괄/설정 적용 — enabledTypes 에 포함된 지표만 차트에 남기고,
|
||||
* 새로 켠 type 은 draft 설정으로 추가, 꺼진 type 은 제거.
|
||||
* Main 탭이 아닌 지표는 그대로 유지.
|
||||
*/
|
||||
export function applyMainDraftToChart(
|
||||
activeIndicators: IndicatorConfig[],
|
||||
draftByType: MainIndicatorDraftMap,
|
||||
enabledTypes: ReadonlySet<string>,
|
||||
): IndicatorConfig[] {
|
||||
const result: IndicatorConfig[] = activeIndicators
|
||||
.filter(ind => !isMainIndicatorType(ind.type))
|
||||
.map(ind => enrichIndicatorConfig(ind));
|
||||
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const ind of activeIndicators) {
|
||||
if (!isMainIndicatorType(ind.type)) continue;
|
||||
if (!enabledTypes.has(ind.type)) continue;
|
||||
seen.add(ind.type);
|
||||
const tpl = draftByType[ind.type];
|
||||
if (!tpl) {
|
||||
result.push(enrichIndicatorConfig(ind));
|
||||
continue;
|
||||
}
|
||||
result.push(finalizeMainIndicatorConfig(
|
||||
{ ...tpl, id: ind.id, hidden: ind.hidden },
|
||||
false,
|
||||
));
|
||||
}
|
||||
|
||||
for (const type of MAIN_INDICATOR_TYPES) {
|
||||
if (!enabledTypes.has(type) || seen.has(type)) continue;
|
||||
const tpl = draftByType[type];
|
||||
if (!tpl) continue;
|
||||
result.push(finalizeMainIndicatorConfig(
|
||||
{ ...tpl, id: newMainIndicatorId() },
|
||||
true,
|
||||
));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 차트에 올라간 Main 탭 type 집합 */
|
||||
export function mainTypesOnChart(indicators: IndicatorConfig[]): Set<string> {
|
||||
return new Set(
|
||||
indicators.filter(i => isMainIndicatorType(i.type)).map(i => i.type),
|
||||
);
|
||||
}
|
||||
|
||||
export function mainDraftListFromMap(map: MainIndicatorDraftMap): IndicatorConfig[] {
|
||||
return MAIN_INDICATOR_TYPES
|
||||
.map(type => map[type])
|
||||
.filter((c): c is IndicatorConfig => c != null);
|
||||
}
|
||||
|
||||
/** 설정 UI 전체 지표 draft 맵 */
|
||||
export function buildAllIndicatorDraftMap(
|
||||
activeIndicators: IndicatorConfig[],
|
||||
getParams: (type: string, defaults: Record<string, number | string | boolean>) => Record<string, number | string | boolean>,
|
||||
getVisualConfig: (
|
||||
type: string,
|
||||
defaultPlots?: PlotDef[],
|
||||
defaultHlines?: HLineDef[],
|
||||
) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors },
|
||||
): MainIndicatorDraftMap {
|
||||
const map: MainIndicatorDraftMap = {};
|
||||
for (const type of getSettingsIndicatorTypes()) {
|
||||
const cfg = buildMainIndicatorConfig(type, activeIndicators, getParams, getVisualConfig);
|
||||
if (cfg) map[type] = cfg;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** draft 맵 → DB 저장용 config 배열 (순서 유지) */
|
||||
export function allDraftListFromMap(map: MainIndicatorDraftMap): IndicatorConfig[] {
|
||||
return getSettingsIndicatorTypes()
|
||||
.map(type => map[type])
|
||||
.filter((c): c is IndicatorConfig => c != null);
|
||||
}
|
||||
|
||||
/** 차트에 올라간 보조지표 type 집합 (Main 외 포함) */
|
||||
export function indicatorTypesOnChart(indicators: IndicatorConfig[]): Set<string> {
|
||||
return new Set(indicators.map(i => i.type));
|
||||
}
|
||||
|
||||
const MANAGED_TYPES = getSettingsIndicatorTypes();
|
||||
const MANAGED_SET = new Set(MANAGED_TYPES);
|
||||
|
||||
/**
|
||||
* 설정 UI에서 관리하는 모든 type — enabledTypes 기준으로 차트 병합.
|
||||
* 관리 대상이 아닌 type 은 변경 없이 유지.
|
||||
*/
|
||||
export function applyAllDraftToChart(
|
||||
activeIndicators: IndicatorConfig[],
|
||||
draftByType: MainIndicatorDraftMap,
|
||||
enabledTypes: ReadonlySet<string>,
|
||||
): IndicatorConfig[] {
|
||||
const result: IndicatorConfig[] = activeIndicators
|
||||
.filter(ind => !MANAGED_SET.has(ind.type))
|
||||
.map(ind => enrichIndicatorConfig(ind));
|
||||
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const ind of activeIndicators) {
|
||||
if (!MANAGED_SET.has(ind.type)) continue;
|
||||
if (!enabledTypes.has(ind.type)) continue;
|
||||
seen.add(ind.type);
|
||||
const tpl = draftByType[ind.type];
|
||||
if (!tpl) {
|
||||
result.push(enrichIndicatorConfig(ind));
|
||||
continue;
|
||||
}
|
||||
result.push(finalizeMainIndicatorConfig(
|
||||
{ ...tpl, id: ind.id, hidden: ind.hidden },
|
||||
false,
|
||||
));
|
||||
}
|
||||
|
||||
for (const type of MANAGED_TYPES) {
|
||||
if (!enabledTypes.has(type) || seen.has(type)) continue;
|
||||
const tpl = draftByType[type];
|
||||
if (!tpl) continue;
|
||||
result.push(finalizeMainIndicatorConfig(
|
||||
{ ...tpl, id: newMainIndicatorId() },
|
||||
true,
|
||||
));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { IndicatorCategory } from './indicatorRegistry';
|
||||
|
||||
/** 지표 선택 팝업 탭 키 */
|
||||
export type IndicatorTabKey = IndicatorCategory | 'All' | 'Main';
|
||||
|
||||
/** Main 탭 고정 지표 (표시 순서) */
|
||||
export const MAIN_INDICATOR_TYPES: readonly string[] = [
|
||||
'RSI',
|
||||
'Stochastic',
|
||||
'BollingerBands',
|
||||
'IchimokuCloud',
|
||||
'ADX',
|
||||
'DMI',
|
||||
'CCI',
|
||||
'MACD',
|
||||
'WilliamsPercentRange',
|
||||
'TRIX',
|
||||
'OBV',
|
||||
'VolumeOscillator',
|
||||
'VR',
|
||||
'Disparity',
|
||||
'Psychological',
|
||||
'InvestPsychological',
|
||||
'SMA',
|
||||
] as const;
|
||||
|
||||
/** Main 탭 전용 표시 라벨 (업비트 주요 지표 명칭) */
|
||||
export const MAIN_INDICATOR_LABELS: Record<string, { ko: string; en: string }> = {
|
||||
RSI: { ko: 'RSI (상대강도지수)', en: 'Relative Strength Index' },
|
||||
Stochastic: { ko: 'Stochastic Oscillator', en: 'Stochastic Oscillator' },
|
||||
BollingerBands: { ko: '볼린저밴드', en: 'Bollinger Bands' },
|
||||
IchimokuCloud: { ko: '일목균형표', en: 'Ichimoku Cloud' },
|
||||
ADX: { ko: 'ADX (평균방향성지수)', en: 'Average Directional Index' },
|
||||
DMI: { ko: 'DMI (방향성이동지수)', en: 'Directional Movement Index' },
|
||||
CCI: { ko: 'CCI (상품채널지수)', en: 'Commodity Channel Index' },
|
||||
MACD: { ko: 'MACD (이동평균수렴발산)', en: 'Moving Average Convergence Divergence' },
|
||||
WilliamsPercentRange: { ko: 'Williams %R', en: 'Williams %R' },
|
||||
TRIX: { ko: 'TRIX', en: 'TRIX' },
|
||||
OBV: { ko: 'OBV (잔량균형지수)', en: 'On Balance Volume' },
|
||||
VolumeOscillator: { ko: 'Volume Oscillator', en: 'Volume Oscillator' },
|
||||
VR: { ko: 'VR (거래량비율)', en: 'Volume Ratio' },
|
||||
Disparity: { ko: '이격도', en: 'Disparity' },
|
||||
Psychological: { ko: '심리도', en: 'Psychological Line' },
|
||||
InvestPsychological: { ko: '투자심리도', en: 'Invest Psychological Line' },
|
||||
SMA: { ko: '단순 이동평균 (MA1~MA11)', en: 'Simple Moving Average' },
|
||||
};
|
||||
|
||||
export function isMainIndicatorType(type: string): boolean {
|
||||
return (MAIN_INDICATOR_TYPES as readonly string[]).includes(type);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { IndicatorConfig } from '../types';
|
||||
import { enrichIndicatorConfig, getIndicatorDef } from './indicatorRegistry';
|
||||
|
||||
/** 보조지표 인스턴스 복제 (새 id, 별도 pane) */
|
||||
export function cloneIndicatorConfig(src: IndicatorConfig, newId: string): IndicatorConfig {
|
||||
return enrichIndicatorConfig({
|
||||
...src,
|
||||
id: newId,
|
||||
mergedWith: undefined,
|
||||
params: { ...src.params },
|
||||
plots: src.plots?.map(p => ({ ...p })),
|
||||
hlines: src.hlines?.map(h => ({ ...h })),
|
||||
plotVisibility: src.plotVisibility ? { ...src.plotVisibility } : undefined,
|
||||
timeframeVisibility: src.timeframeVisibility
|
||||
? { ...src.timeframeVisibility }
|
||||
: undefined,
|
||||
cloudColors: src.cloudColors ? { ...src.cloudColors } : undefined,
|
||||
hlinesBackground: src.hlinesBackground
|
||||
? { ...src.hlinesBackground }
|
||||
: undefined,
|
||||
bandBackground: src.bandBackground
|
||||
? { ...src.bandBackground }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** sourceId 바로 아래에 동일 설정의 보조지표 복사본 추가 */
|
||||
export function duplicateIndicatorInList(
|
||||
sourceId: string,
|
||||
prev: IndicatorConfig[],
|
||||
newId: () => string,
|
||||
): IndicatorConfig[] | null {
|
||||
const idx = prev.findIndex(i => i.id === sourceId);
|
||||
if (idx === -1) return null;
|
||||
const src = prev[idx];
|
||||
const def = getIndicatorDef(src.type);
|
||||
if (!def || def.overlay || def.returnsMarkers) return null;
|
||||
|
||||
const clone = cloneIndicatorConfig(src, newId());
|
||||
const next = [...prev];
|
||||
next.splice(idx + 1, 0, clone);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** mergedWith 체인의 루트(공유 pane 소유) 지표 id */
|
||||
export function getPaneHostId(id: string, indicators: IndicatorConfig[]): string {
|
||||
const seen = new Set<string>();
|
||||
let cur = id;
|
||||
for (;;) {
|
||||
if (seen.has(cur)) return id;
|
||||
seen.add(cur);
|
||||
const ind = indicators.find(i => i.id === cur);
|
||||
if (!ind?.mergedWith) return cur;
|
||||
cur = ind.mergedWith;
|
||||
}
|
||||
}
|
||||
|
||||
/** 같은 pane 에 묶인 지표 id 목록 (배열 순서 유지) */
|
||||
export function getMergedGroupIds(hostId: string, indicators: IndicatorConfig[]): string[] {
|
||||
const host = getPaneHostId(hostId, indicators);
|
||||
return indicators
|
||||
.filter(i => getPaneHostId(i.id, indicators) === host)
|
||||
.map(i => i.id);
|
||||
}
|
||||
|
||||
/** 드롭 대상 → 실제 pane 호스트 id */
|
||||
export function resolveMergeTargetId(targetId: string, indicators: IndicatorConfig[]): string {
|
||||
return getPaneHostId(targetId, indicators);
|
||||
}
|
||||
|
||||
/** reload 시 호스트 지표를 먼저 addIndicator 하도록 정렬 */
|
||||
export function sortIndicatorsForPaneLoad(inds: IndicatorConfig[]): IndicatorConfig[] {
|
||||
const byId = new Map(inds.map(i => [i.id, i]));
|
||||
const out: IndicatorConfig[] = [];
|
||||
const added = new Set<string>();
|
||||
|
||||
const addOne = (ind: IndicatorConfig) => {
|
||||
if (added.has(ind.id)) return;
|
||||
if (ind.mergedWith) {
|
||||
const host = byId.get(ind.mergedWith);
|
||||
if (host) addOne(host);
|
||||
}
|
||||
out.push(ind);
|
||||
added.add(ind.id);
|
||||
};
|
||||
|
||||
for (const ind of inds) addOne(ind);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 보조지표 그룹(병합 pane) 통째로 순서 변경 */
|
||||
export function reorderIndicatorGroup(
|
||||
fromId: string,
|
||||
insertBeforeId: string | null,
|
||||
prev: IndicatorConfig[],
|
||||
): IndicatorConfig[] {
|
||||
const hostId = getPaneHostId(fromId, prev);
|
||||
const groupSet = new Set(getMergedGroupIds(hostId, prev));
|
||||
const groupItems = prev.filter(i => groupSet.has(i.id));
|
||||
const without = prev.filter(i => !groupSet.has(i.id));
|
||||
|
||||
if (insertBeforeId === null) {
|
||||
return [...without, ...groupItems];
|
||||
}
|
||||
|
||||
const beforeHost = getPaneHostId(insertBeforeId, prev);
|
||||
const toIdx = without.findIndex(i => i.id === beforeHost);
|
||||
without.splice(toIdx === -1 ? without.length : toIdx, 0, ...groupItems);
|
||||
return without;
|
||||
}
|
||||
|
||||
/** fromId 를 intoId pane 에 병합 */
|
||||
export function mergeIndicators(
|
||||
fromId: string,
|
||||
intoId: string,
|
||||
prev: IndicatorConfig[],
|
||||
): IndicatorConfig[] {
|
||||
const host = resolveMergeTargetId(intoId, prev);
|
||||
if (fromId === host) return prev;
|
||||
if (getPaneHostId(fromId, prev) === host) return prev;
|
||||
return prev.map(i => (i.id === fromId ? { ...i, mergedWith: host } : i));
|
||||
}
|
||||
|
||||
/** 병합 pane 분리 — 호스트 그룹의 mergedWith 제거 */
|
||||
export function splitIndicatorPane(
|
||||
hostOrMemberId: string,
|
||||
prev: IndicatorConfig[],
|
||||
): IndicatorConfig[] {
|
||||
const host = getPaneHostId(hostOrMemberId, prev);
|
||||
return prev.map(i => {
|
||||
if (getPaneHostId(i.id, prev) !== host || !i.mergedWith) return i;
|
||||
const { mergedWith: _m, ...rest } = i;
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
|
||||
export function layoutKey(inds: IndicatorConfig[]): string {
|
||||
return inds.map(i => `${i.id}>${i.mergedWith ?? ''}`).join(';');
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* 보조지표 설정 — 숫자 파라미터 허용 범위·드롭다운 목록
|
||||
*/
|
||||
|
||||
export interface NumericParamSpec {
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
allowNegative: boolean;
|
||||
decimal: boolean;
|
||||
/** 드롭다운에 표시할 허용 값 목록 */
|
||||
options: number[];
|
||||
}
|
||||
|
||||
const COMMON_PERIODS = [
|
||||
1, 2, 3, 5, 6, 7, 8, 9, 10, 12, 13, 14, 15, 20, 21, 24, 26, 28, 34,
|
||||
50, 52, 60, 72, 90, 100, 120, 200,
|
||||
];
|
||||
|
||||
const DECIMAL_MULT = [0.1, 0.2, 0.25, 0.5, 0.75, 1, 1.5, 2, 2.5, 3, 4, 5];
|
||||
|
||||
function roundTo(v: number, decimals: number): number {
|
||||
const f = 10 ** decimals;
|
||||
return Math.round(v * f) / f;
|
||||
}
|
||||
|
||||
function mergeOptions(
|
||||
min: number,
|
||||
max: number,
|
||||
step: number,
|
||||
decimal: boolean,
|
||||
presets: number[],
|
||||
maxCount = 56,
|
||||
include?: number,
|
||||
): number[] {
|
||||
const set = new Set<number>();
|
||||
const dec = decimal ? (step < 1 ? 2 : 1) : 0;
|
||||
for (const p of presets) {
|
||||
if (p >= min && p <= max) set.add(roundTo(p, dec));
|
||||
}
|
||||
if (include != null && !isNaN(include) && include >= min && include <= max) {
|
||||
set.add(roundTo(include, dec));
|
||||
}
|
||||
for (let v = min; v <= max + step * 0.001 && set.size < maxCount; v += step) {
|
||||
set.add(roundTo(v, dec));
|
||||
}
|
||||
return Array.from(set).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function isPeriodKey(key: string): boolean {
|
||||
return /^(length|len|period\d*|kLength|dSmoothing|smooth|adxSmoothing|diLength|ccilength|turbolen|signalLength|fastLength|slowLength|longLength|shortLength|maLength|ultraLength|midLength|longLength|displacement|conversionPeriods|basePeriods|laggingSpan2Periods|length1|length2|length3|lengthRSI|lengthStoch|rsiLength|upDownLength|rocLength|stdevLength|atrLength|p|q|x)$/i.test(key);
|
||||
}
|
||||
|
||||
function isMultKey(key: string): boolean {
|
||||
return /^(mult|multiplier|percent|bbMult)$/i.test(key);
|
||||
}
|
||||
|
||||
function isOffsetKey(key: string): boolean {
|
||||
return /^offset$/i.test(key);
|
||||
}
|
||||
|
||||
function isSigmaKey(key: string): boolean {
|
||||
return /^sigma$/i.test(key);
|
||||
}
|
||||
|
||||
/** 지표 파라미터 키별 숫자 스펙 */
|
||||
export function getNumericParamSpec(
|
||||
indicatorType: string,
|
||||
paramKey: string,
|
||||
currentValue?: number,
|
||||
): NumericParamSpec {
|
||||
if (isPeriodKey(paramKey)) {
|
||||
const max = paramKey === 'rocLength' ? 200 : 500;
|
||||
return {
|
||||
min: 1,
|
||||
max,
|
||||
step: 1,
|
||||
allowNegative: false,
|
||||
decimal: false,
|
||||
options: mergeOptions(1, max, 1, false, COMMON_PERIODS, 56, currentValue),
|
||||
};
|
||||
}
|
||||
|
||||
if (isMultKey(paramKey)) {
|
||||
const isPercent = paramKey === 'percent';
|
||||
return {
|
||||
min: isPercent ? 0.01 : 0.1,
|
||||
max: isPercent ? 1 : 10,
|
||||
step: isPercent ? 0.01 : 0.1,
|
||||
allowNegative: false,
|
||||
decimal: true,
|
||||
options: mergeOptions(
|
||||
isPercent ? 0.01 : 0.1,
|
||||
isPercent ? 1 : 10,
|
||||
isPercent ? 0.01 : 0.1,
|
||||
true,
|
||||
isPercent
|
||||
? [0.05, 0.1, 0.15, 0.2, 0.25, 0.5, 0.75, 1]
|
||||
: DECIMAL_MULT,
|
||||
40,
|
||||
currentValue,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (isOffsetKey(paramKey)) {
|
||||
if (indicatorType === 'ALMA') {
|
||||
return {
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
allowNegative: false,
|
||||
decimal: true,
|
||||
options: mergeOptions(0, 1, 0.05, true, [0, 0.5, 0.85, 1], 24, currentValue),
|
||||
};
|
||||
}
|
||||
return {
|
||||
min: -500,
|
||||
max: 500,
|
||||
step: 1,
|
||||
allowNegative: true,
|
||||
decimal: false,
|
||||
options: mergeOptions(-500, 500, 1, false, [-26, -9, 0, 9, 26], 48, currentValue),
|
||||
};
|
||||
}
|
||||
|
||||
if (isSigmaKey(paramKey)) {
|
||||
return {
|
||||
min: 1,
|
||||
max: 30,
|
||||
step: 1,
|
||||
allowNegative: false,
|
||||
decimal: false,
|
||||
options: mergeOptions(1, 30, 1, false, [4, 6, 8, 10, 14, 20], 30, currentValue),
|
||||
};
|
||||
}
|
||||
|
||||
// SMI 등 소수 오실레이터 임계
|
||||
if (indicatorType === 'SMIErgodic' || indicatorType === 'SMIErgodicOscillator') {
|
||||
return {
|
||||
min: 1,
|
||||
max: 100,
|
||||
step: 1,
|
||||
allowNegative: false,
|
||||
decimal: false,
|
||||
options: mergeOptions(1, 100, 1, false, [5, 10, 20, 40], 40, currentValue),
|
||||
};
|
||||
}
|
||||
|
||||
// 기본: 정수 1~100
|
||||
return {
|
||||
min: 1,
|
||||
max: 500,
|
||||
step: 1,
|
||||
allowNegative: false,
|
||||
decimal: false,
|
||||
options: mergeOptions(1, 500, 1, false, COMMON_PERIODS, 56, currentValue),
|
||||
};
|
||||
}
|
||||
|
||||
/** 수평선(과열/침체) 기준값 */
|
||||
export function getHlinePriceSpec(indicatorType: string, current: number): NumericParamSpec {
|
||||
const byType: Record<string, number[]> = {
|
||||
RSI: [20, 30, 50, 70, 80],
|
||||
Stochastic: [20, 50, 80],
|
||||
StochRSI: [20, 50, 80],
|
||||
CCI: [-200, -100, 0, 100, 200],
|
||||
WilliamsPercentRange: [-80, -50, -20],
|
||||
MACD: [-50, 0, 50],
|
||||
ADX: [20, 25, 40],
|
||||
DMI: [10, 20, 30],
|
||||
TRIX: [-0.05, -0.01, 0, 0.01, 0.05],
|
||||
MOM: [-50, 0, 50],
|
||||
ROC: [-20, 0, 20],
|
||||
CMO: [-50, 0, 50],
|
||||
DPO: [-100, 0, 100],
|
||||
FisherTransform: [-2.5, 0, 2.5],
|
||||
SMIErgodic: [-0.4, 0, 0.4],
|
||||
SMIErgodicOscillator: [-0.4, 0, 0.4],
|
||||
Disparity: [90, 95, 100, 105, 110],
|
||||
VR: [50, 100, 150, 200, 250],
|
||||
Psychological: [25, 50, 75],
|
||||
NewPsychological: [25, 50, 75],
|
||||
InvestPsychological: [25, 50, 75],
|
||||
};
|
||||
|
||||
const presets = byType[indicatorType] ?? [-200, -100, 0, 100, 200];
|
||||
const allowNegative = presets.some(p => p < 0) || indicatorType !== 'RSI';
|
||||
const min = Math.min(-500, ...presets, current) - 50;
|
||||
const max = Math.max(500, ...presets, current) + 50;
|
||||
|
||||
return {
|
||||
min,
|
||||
max,
|
||||
step: 1,
|
||||
allowNegative: true,
|
||||
decimal: true,
|
||||
options: mergeOptions(min, max, allowNegative ? 10 : 5, true, presets, 48, current),
|
||||
};
|
||||
}
|
||||
|
||||
export function getLineWidthSpec(min = 1, max = 5): NumericParamSpec {
|
||||
const options = Array.from({ length: max - min + 1 }, (_, i) => min + i);
|
||||
return {
|
||||
min,
|
||||
max,
|
||||
step: 1,
|
||||
allowNegative: false,
|
||||
decimal: false,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
export function getOpacityPercentSpec(current?: number): NumericParamSpec {
|
||||
const options = Array.from({ length: 21 }, (_, i) => i * 5);
|
||||
return {
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
allowNegative: false,
|
||||
decimal: false,
|
||||
options: mergeOptions(0, 100, 5, false, options, 21, current),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
import type { OHLCVBar } from '../types';
|
||||
import type { Bar } from 'oakscriptjs';
|
||||
import {
|
||||
createDefaultSmaParams,
|
||||
createDefaultSmaPlots,
|
||||
calculateSmaMulti,
|
||||
normalizeSmaConfig,
|
||||
} from './smaConfig';
|
||||
import { normalizeIchimokuConfig, mergeIchimokuPlots } from './ichimokuConfig';
|
||||
import {
|
||||
normalizePsychologicalParams,
|
||||
resolvePsychologicalIndicatorType,
|
||||
} from './psychologicalConfig';
|
||||
import {
|
||||
normalizeBollingerParams,
|
||||
resolveBollingerBars,
|
||||
resolveBbBandBackground,
|
||||
mergeBbPlots,
|
||||
} from './bollingerConfig';
|
||||
import type { Timeframe } from '../types';
|
||||
|
||||
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;
|
||||
/** 라인 시리즈 선 유형 (기본 solid) */
|
||||
lineStyle?: HLineStyle;
|
||||
}
|
||||
|
||||
export type HLineStyle = 'solid' | 'dashed' | 'dotted';
|
||||
|
||||
export interface HLineDef {
|
||||
price: number;
|
||||
color: string;
|
||||
/** 표시 이름: '과열선' | '중앙선' | '침체선' | '기준선' 등 */
|
||||
label?: string;
|
||||
visible?: boolean; // false 이면 차트에서 숨김 (기본 true)
|
||||
lineStyle?: HLineStyle; // 선 유형 (기본 'dashed')
|
||||
lineWidth?: number; // 선 굵기 1~4 (기본 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* price 값과 같은 hlines 배열 내 다른 값들을 기반으로
|
||||
* 기본 레이블을 자동 결정합니다.
|
||||
*/
|
||||
/** 가격 값으로 hline 역할 레이블을 결정 — 용어 통일: 과열선/중앙선/침체선 */
|
||||
export function getHLineLabel(price: number, allPrices: number[]): string {
|
||||
if (price === 0) return '중앙선';
|
||||
const uniq = [...new Set(allPrices)].sort((a, b) => b - a);
|
||||
if (uniq.length === 1) return '중앙선';
|
||||
if (price === uniq[0]) return '과열선';
|
||||
if (price === uniq[uniq.length - 1]) return '침체선';
|
||||
return '중앙선';
|
||||
}
|
||||
|
||||
function migrateHLineLabel(label: string | undefined): string | undefined {
|
||||
if (!label) return undefined;
|
||||
if (label === '과매수' || label === '상한선') return '과열선';
|
||||
if (label === '과매도' || label === '하한선') return '침체선';
|
||||
if (label === '0선' || label === '중립선' || label === '중간선' || label === '기준선') return '중앙선';
|
||||
return label;
|
||||
}
|
||||
|
||||
/** 지표별 누락 수평선 합성 기본값 */
|
||||
const HL_SYNTH_DEFAULTS: Record<string, { over: number; mid: number; under: number }> = {
|
||||
ADX: { over: 40, mid: 25, under: 20 },
|
||||
DMI: { over: 30, mid: 20, under: 10 },
|
||||
};
|
||||
|
||||
/** ADX 기본 수평선 — 강한 추세(과열) / 기준(중앙) / 약한 추세(침체) */
|
||||
export const ADX_DEFAULT_HLINES: HLineDef[] = [
|
||||
{ price: 40, color: '#EF5350', label: '과열선', lineStyle: 'dashed', lineWidth: 1 },
|
||||
{ price: 25, color: '#607D8B', label: '중앙선', lineStyle: 'dashed', lineWidth: 1 },
|
||||
{ price: 20, color: '#4CAF50', label: '침체선', lineStyle: 'dashed', lineWidth: 1 },
|
||||
];
|
||||
|
||||
/** DMI 기본 수평선 (업비트: 과열 30 / 중간 20 / 침체 10) */
|
||||
export const DMI_DEFAULT_HLINES: HLineDef[] = [
|
||||
{ price: 30, color: '#EF5350', label: '과열선', lineStyle: 'dashed', lineWidth: 1 },
|
||||
{ price: 20, color: '#607D8B', label: '중앙선', lineStyle: 'dashed', lineWidth: 1 },
|
||||
{ price: 10, color: '#4CAF50', label: '침체선', lineStyle: 'dashed', lineWidth: 1 },
|
||||
];
|
||||
|
||||
export function normalizeDmiParams(
|
||||
params: Record<string, number | string | boolean>,
|
||||
): Record<string, number | string | boolean> {
|
||||
const diLength = Math.max(1, Number(params.diLength ?? params.length ?? 14));
|
||||
const adxSmoothing = Math.max(1, Number(params.adxSmoothing ?? 14));
|
||||
return { ...params, diLength, adxSmoothing };
|
||||
}
|
||||
|
||||
/** 저장된 hlines를 registry 기본값과 역할(라벨) 기준으로 병합 */
|
||||
export function mergeHlines(
|
||||
saved: HLineDef[] | undefined,
|
||||
defaults: HLineDef[] | undefined,
|
||||
indicatorType?: string,
|
||||
): HLineDef[] {
|
||||
const defs = (defaults ?? []).map(h => ({ ...h }));
|
||||
if (!defs.length) return (saved ?? []).map(h => ({ ...h }));
|
||||
|
||||
const savedList = (saved ?? []).map(h => {
|
||||
const label = migrateHLineLabel(h.label);
|
||||
return label ? { ...h, label } : { ...h };
|
||||
});
|
||||
|
||||
const prices = savedList.map(h => h.price);
|
||||
const labelOf = (h: HLineDef) => h.label ?? getHLineLabel(h.price, prices);
|
||||
|
||||
const legacyMid =
|
||||
(indicatorType === 'ADX' || indicatorType === 'DMI') && savedList.length === 1
|
||||
? savedList[0]
|
||||
: undefined;
|
||||
|
||||
return defs
|
||||
.map(def => {
|
||||
const wantLabel = def.label ?? getHLineLabel(def.price, defs.map(d => d.price));
|
||||
const match =
|
||||
savedList.find(h => labelOf(h) === wantLabel)
|
||||
?? (legacyMid && wantLabel === '중앙선' ? legacyMid : undefined);
|
||||
if (match) {
|
||||
return { ...def, ...match, label: wantLabel, price: match.price };
|
||||
}
|
||||
return { ...def, label: wantLabel };
|
||||
})
|
||||
.sort((a, b) => b.price - a.price);
|
||||
}
|
||||
|
||||
/** 수평선 배열을 과열·중앙·침체 3종으로 정규화 (설정 모달·차트 공통) */
|
||||
export function normalizeHLines(
|
||||
hlines: HLineDef[],
|
||||
indicatorType?: string,
|
||||
defaults?: HLineDef[],
|
||||
): HLineDef[] {
|
||||
let list = defaults?.length ? mergeHlines(hlines, defaults, indicatorType) : [...hlines];
|
||||
if (list.length === 0) return list;
|
||||
|
||||
list = list.map(h => {
|
||||
const migrated = migrateHLineLabel(h.label);
|
||||
return migrated ? { ...h, label: migrated } : h;
|
||||
});
|
||||
|
||||
const initPrices = list.map(h => h.price);
|
||||
const labeled = list.map(h => ({
|
||||
...h,
|
||||
label: h.label ?? getHLineLabel(h.price, initPrices),
|
||||
}));
|
||||
const withFixedLabels = labeled.map(h =>
|
||||
h.label === '기준선' ? { ...h, label: '중앙선' } : h,
|
||||
);
|
||||
|
||||
const prices = withFixedLabels.map(h => h.price);
|
||||
const getLabel = (h: HLineDef) => h.label ?? getHLineLabel(h.price, prices);
|
||||
|
||||
const hasOver = withFixedLabels.some(h => getLabel(h) === '과열선');
|
||||
const hasCenter = withFixedLabels.some(h => getLabel(h) === '중앙선');
|
||||
const hasUnder = withFixedLabels.some(h => getLabel(h) === '침체선');
|
||||
|
||||
if (hasOver && hasCenter && hasUnder) {
|
||||
return withFixedLabels.sort((a, b) => b.price - a.price);
|
||||
}
|
||||
|
||||
const result = [...withFixedLabels];
|
||||
const spec = indicatorType ? HL_SYNTH_DEFAULTS[indicatorType] : undefined;
|
||||
const maxAbsPrice = prices.reduce((m, p) => Math.max(m, Math.abs(p)), 0);
|
||||
const synthUpper = maxAbsPrice > 0 ? parseFloat((maxAbsPrice * 1.5).toPrecision(3)) : 100;
|
||||
const synthLower = maxAbsPrice > 0 ? -parseFloat((maxAbsPrice * 1.5).toPrecision(3)) : -100;
|
||||
|
||||
if (!hasOver) {
|
||||
result.push({
|
||||
price: spec?.over ?? synthUpper,
|
||||
color: '#EF5350',
|
||||
label: '과열선',
|
||||
visible: false,
|
||||
lineStyle: 'dashed',
|
||||
lineWidth: 1,
|
||||
});
|
||||
}
|
||||
if (!hasCenter) {
|
||||
result.push({
|
||||
price: spec?.mid ?? 0,
|
||||
color: '#607D8B',
|
||||
label: '중앙선',
|
||||
visible: false,
|
||||
lineStyle: 'dashed',
|
||||
lineWidth: 1,
|
||||
});
|
||||
}
|
||||
if (!hasUnder) {
|
||||
result.push({
|
||||
price: spec?.under ?? synthLower,
|
||||
color: '#4CAF50',
|
||||
label: '침체선',
|
||||
visible: false,
|
||||
lineStyle: 'dashed',
|
||||
lineWidth: 1,
|
||||
});
|
||||
}
|
||||
|
||||
return result.sort((a, b) => b.price - a.price);
|
||||
}
|
||||
|
||||
export interface IndicatorDef {
|
||||
type: string;
|
||||
name: string;
|
||||
koreanName: string;
|
||||
shortName: string;
|
||||
category: IndicatorCategory;
|
||||
overlay: boolean;
|
||||
defaultParams: Record<string, number | string | boolean>;
|
||||
plots: PlotDef[];
|
||||
hlines?: HLineDef[];
|
||||
returnsMarkers?: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Full Indicator Registry (82 standard + 44 candlestick patterns)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export const INDICATOR_REGISTRY: IndicatorDef[] = [
|
||||
|
||||
// ── Moving Averages ──────────────────────────────────────────────────────
|
||||
{ type:'SMA', name:'Simple Moving Average', koreanName:'단순이동평균', shortName:'SMA', category:'Moving Averages', overlay:true, defaultParams:createDefaultSmaParams(), plots:createDefaultSmaPlots(), description:'단순 이동평균 (MA1~MA11)' },
|
||||
{ type:'EMA', name:'Exponential Moving Average', koreanName:'지수이동평균', 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', koreanName:'가중이동평균', 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:'HMA', name:'Hull Moving Average', koreanName:'헐이동평균', shortName:'HMA', category:'Moving Averages', overlay:true, defaultParams:{length:16, src:'close'}, plots:[{id:'plot0',title:'HMA', color:'#4CAF50',type:'line',lineWidth:2}], description:'헐 이동평균 (래그 최소)' },
|
||||
{ type:'VWMA', name:'Volume Weighted Moving Average', koreanName:'거래량가중이동평균', shortName:'VWMA', category:'Moving Averages', overlay:true, defaultParams:{length:20, src:'close'}, plots:[{id:'plot0',title:'VWMA', color:'#9C27B0',type:'line',lineWidth:2}], description:'거래량 가중 이동평균' },
|
||||
{ type:'DEMA', name:'Double Exponential MA', koreanName:'이중지수이동평균', shortName:'DEMA', category:'Moving Averages', overlay:true, defaultParams:{length:21, src:'close'}, plots:[{id:'plot0',title:'DEMA', color:'#F44336',type:'line',lineWidth:2}], description:'이중 지수 이동평균' },
|
||||
{ type:'TEMA', name:'Triple Exponential MA', koreanName:'삼중지수이동평균', shortName:'TEMA', category:'Moving Averages', overlay:true, defaultParams:{length:21, src:'close'}, plots:[{id:'plot0',title:'TEMA', color:'#FF9800',type:'line',lineWidth:2}], description:'삼중 지수 이동평균' },
|
||||
{ type:'LSMA', name:'Least Squares MA', koreanName:'최소제곱이동평균', shortName:'LSMA', category:'Moving Averages', overlay:true, defaultParams:{length:25, src:'close', offset:0}, plots:[{id:'plot0',title:'LSMA', color:'#00E5FF',type:'line',lineWidth:2}], description:'최소제곱 이동평균' },
|
||||
{ type:'ALMA', name:'Arnaud Legoux MA', koreanName:'아르노-레구이동평균', shortName:'ALMA', category:'Moving Averages', overlay:true, defaultParams:{length:21, offset:0.85, sigma:6}, plots:[{id:'plot0',title:'ALMA', color:'#E040FB',type:'line',lineWidth:2}], description:'아르노 레구 이동평균' },
|
||||
{ type:'RMA', name:'Smoothed MA (SMMA/RMA)', koreanName:'윌더스무딩이동평균', shortName:'RMA', category:'Moving Averages', overlay:true, defaultParams:{length:14, src:'close'}, plots:[{id:'plot0',title:'RMA', color:'#8BC34A',type:'line',lineWidth:2}], description:'와일더 스무딩 이동평균' },
|
||||
{ type:'SMMA', name:'Smoothed Moving Average', koreanName:'스무딩이동평균', shortName:'SMMA', category:'Moving Averages', overlay:true, defaultParams:{length:14, src:'close'}, plots:[{id:'plot0',title:'SMMA', color:'#CDDC39',type:'line',lineWidth:2}], description:'스무딩 이동평균' },
|
||||
{ type:'McGinleyDynamic', name:'McGinley Dynamic', koreanName:'맥긴리다이나믹', shortName:'MGD', category:'Moving Averages', overlay:true, defaultParams:{length:14}, plots:[{id:'plot0',title:'MGD', color:'#FF5722',type:'line',lineWidth:2}], description:'맥긴리 다이나믹 MA' },
|
||||
{ type:'Median', name:'Median Price', koreanName:'중간값이동평균', shortName:'Med', category:'Moving Averages', overlay:true, defaultParams:{length:14}, plots:[{id:'plot0',title:'Median',color:'#607D8B',type:'line',lineWidth:1}], description:'중간값 (Median) 이동평균' },
|
||||
{ type:'TWAP', name:'Time Weighted Average Price', koreanName:'시간가중평균가격', shortName:'TWAP', category:'Moving Averages', overlay:true, defaultParams:{}, plots:[{id:'plot0',title:'TWAP', color:'#795548',type:'line',lineWidth:2}], description:'시간 가중 평균 가격' },
|
||||
{ type:'MACross',name:'MA Cross', koreanName:'이동평균교차', shortName:'MACross',category:'Moving Averages', overlay:true, defaultParams:{fastLength:9, slowLength:21, src:'close'},plots:[{id:'plot0',title:'Fast', color:'#2962FF',type:'line',lineWidth:2},{id:'plot1',title:'Slow',color:'#FF6D00',type:'line',lineWidth:2}], description:'이동평균 교차 (골든크로스/데드크로스)' },
|
||||
|
||||
// ── Channels & Bands ──────────────────────────────────────────────────────
|
||||
{ type:'BollingerBands', name:'Bollinger Bands', koreanName:'볼린저밴드', shortName:'BB', category:'Channels & Bands', overlay:true, defaultParams:{symbolMode:'main', refSymbol:'', length:20, mult:2, offset:0, src:'close', maType:'SMA'}, plots:[{id:'plot0',title:'중앙값',color:'#FF9800',type:'line',lineWidth:1},{id:'plot1',title:'어퍼',color:'#42A5F5',type:'line',lineWidth:1},{id:'plot2',title:'로우어',color:'#42A5F5',type:'line',lineWidth:1}], description:'볼린저 밴드 (SMA ± σ×곱)' },
|
||||
{ type:'KeltnerChannels',name:'Keltner Channels', koreanName:'켈트너채널', shortName:'KC', category:'Channels & Bands', overlay:true, defaultParams:{length:20, multiplier:2}, plots:[{id:'plot0',title:'Upper', color:'#7E57C2',type:'line',lineWidth:1},{id:'plot1',title:'Middle',color:'#EF5350',type:'line',lineWidth:1},{id:'plot2',title:'Lower',color:'#7E57C2',type:'line',lineWidth:1}], description:'켈트너 채널' },
|
||||
{ type:'DonchianChannels',name:'Donchian Channels', koreanName:'돈치안채널', shortName:'DC', category:'Channels & Bands', overlay:true, defaultParams:{length:20}, plots:[{id:'plot0',title:'Upper', color:'#4CAF50',type:'line',lineWidth:1},{id:'plot1',title:'Basis',color:'#FFC107',type:'line',lineWidth:1},{id:'plot2',title:'Lower',color:'#4CAF50',type:'line',lineWidth:1}], description:'돈치안 채널' },
|
||||
{ type:'Envelope', name:'Envelope', koreanName:'엔벨로프', shortName:'Env', category:'Channels & Bands', overlay:true, defaultParams:{length:20, percent:0.1, src:'close'}, plots:[{id:'plot0',title:'Upper', color:'#009688',type:'line',lineWidth:1},{id:'plot1',title:'Basis',color:'#607D8B',type:'line',lineWidth:1},{id:'plot2',title:'Lower',color:'#009688',type:'line',lineWidth:1}], description:'엔벨로프 채널' },
|
||||
{ type:'BBPercentB', name:'BB %B', koreanName:'볼린저밴드 %B', shortName:'BB%B', category:'Channels & Bands', overlay:false,defaultParams:{length:20, mult:2, src:'close'}, plots:[{id:'plot0',title:'%B', color:'#2196F3',type:'line',lineWidth:2}], hlines:[{price:1,color:'#EF5350'},{price:0.5,color:'#607D8B'},{price:0,color:'#4CAF50'}], description:'볼린저밴드 내 가격 위치 (%B)' },
|
||||
{ type:'BBBandWidth', name:'BB BandWidth', koreanName:'볼린저밴드폭', shortName:'BBW', category:'Channels & Bands', overlay:false,defaultParams:{length:20, mult:2, src:'close'}, plots:[{id:'plot0',title:'BBW', color:'#9C27B0',type:'line',lineWidth:2}], description:'볼린저밴드 폭 (변동성 측정)' },
|
||||
|
||||
// ── Oscillators ───────────────────────────────────────────────────────────
|
||||
{ type:'RSI', name:'Relative Strength Index', koreanName:'상대강도지수', shortName:'RSI', category:'Oscillators', overlay:false, defaultParams:{length:14, src:'close', maType:'SMA', maLength:14}, plots:[{id:'plot0',title:'RSI',color:'#7E57C2',type:'line',lineWidth:2},{id:'plot1',title:'Signal',color:'#F23645',type:'line',lineWidth:1}], hlines:[{price:70,color:'#EF5350'},{price:50,color:'#607D8B'},{price:30,color:'#4CAF50'}], description:'상대 강도 지수 (0~100)' },
|
||||
{ type:'Stochastic', name:'Stochastic', koreanName:'스토캐스틱', 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:'스토캐스틱 %K/%D' },
|
||||
{ type:'StochRSI', name:'Stochastic RSI', koreanName:'스토캐스틱RSI', shortName:'StochRSI',category:'Oscillators',overlay:false, defaultParams:{lengthRSI:14, lengthStoch:14, smoothK:3, smoothD:3, src:'close'}, 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:'스토캐스틱 RSI' },
|
||||
{ type:'CCI', name:'Commodity Channel Index', koreanName:'상품채널지수', shortName:'CCI', category:'Oscillators', overlay:false, defaultParams:{length:13, src:'hlc3', maType:'SMA', maLength:20}, plots:[{id:'plot0',title:'CCI',color:'#2962FF',type:'line',lineWidth:2},{id:'plot1',title:'Signal',color:'#F23645',type:'line',lineWidth:1}], hlines:[{price:100,color:'#EF5350'},{price:0,color:'#607D8B'},{price:-100,color:'#4CAF50'}], description:'상품 채널 지수' },
|
||||
{ type:'WilliamsPercentRange',name:'Williams %R', koreanName:'윌리엄스 %R', shortName:'W%R', category:'Oscillators', overlay:false, defaultParams:{length:14, src:'close'}, 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:'AwesomeOscillator', name:'Awesome Oscillator', koreanName:'어썸오실레이터', shortName:'AO', category:'Oscillators', overlay:false, defaultParams:{}, plots:[{id:'plot0',title:'AO',color:'#26A69A',type:'histogram'}], hlines:[{price:0,color:'#607D8B'}], description:'AO 오실레이터' },
|
||||
{ type:'ChandeMO', name:'Chande Momentum Oscillator', koreanName:'챈드모멘텀오실레이터', shortName:'CMO', category:'Oscillators', overlay:false, defaultParams:{length:9, src:'close'}, plots:[{id:'plot0',title:'CMO',color:'#4DB6AC',type:'line',lineWidth:2}], hlines:[{price:50,color:'#EF5350'},{price:0,color:'#607D8B'},{price:-50,color:'#4CAF50'}], description:'챈드 모멘텀 오실레이터' },
|
||||
{ type:'DPO', name:'Detrended Price Oscillator', koreanName:'비트렌드가격오실레이터',shortName:'DPO', category:'Oscillators', overlay:false, defaultParams:{length:21, src:'close'}, plots:[{id:'plot0',title:'DPO',color:'#FF7043',type:'line',lineWidth:2}], hlines:[{price:0,color:'#607D8B'}], description:'비트렌드 가격 오실레이터' },
|
||||
{ type:'RVI', name:'Relative Vigor Index', koreanName:'상대활력지수', shortName:'RVI', category:'Oscillators', overlay:false, defaultParams:{length:10}, plots:[{id:'plot0',title:'RVI', color:'#26A69A',type:'line',lineWidth:2},{id:'plot1',title:'Signal',color:'#EF5350',type:'line',lineWidth:1}], hlines:[{price:0,color:'#607D8B'}], description:'상대 활력 지수' },
|
||||
{ type:'FisherTransform', name:'Fisher Transform', koreanName:'피셔변환', shortName:'Fish', category:'Oscillators', overlay:false, defaultParams:{length:9}, plots:[{id:'plot0',title:'Fisher', color:'#2962FF',type:'line',lineWidth:2},{id:'plot1',title:'Signal',color:'#FF6D00',type:'line',lineWidth:1}], hlines:[{price:2.5,color:'#EF5350'},{price:0,color:'#607D8B'},{price:-2.5,color:'#4CAF50'}], description:'피셔 변환 오실레이터' },
|
||||
{ type:'UltimateOscillator', name:'Ultimate Oscillator', koreanName:'울티메이트오실레이터', shortName:'UO', category:'Oscillators', overlay:false, defaultParams:{length1:7, length2:14, length3:28}, plots:[{id:'plot0',title:'UO', color:'#7E57C2',type:'line',lineWidth:2}], hlines:[{price:70,color:'#EF5350'},{price:50,color:'#607D8B'},{price:30,color:'#4CAF50'}], description:'울티메이트 오실레이터' },
|
||||
{ type:'ConnorsRSI', name:'Connors RSI', koreanName:'코너스RSI', shortName:'CRSI', category:'Oscillators', overlay:false, defaultParams:{rsiLength:3, upDownLength:2, rocLength:100}, plots:[{id:'plot0',title:'CRSI',color:'#AB47BC',type:'line',lineWidth:2}], hlines:[{price:90,color:'#EF5350'},{price:10,color:'#4CAF50'}], description:'코너스 RSI' },
|
||||
{ type:'SMIErgodic', name:'SMI Ergodic', koreanName:'SMI에르고딕', shortName:'SMI', category:'Oscillators', overlay:false, defaultParams:{longLength:20, shortLength:5, signalLength:5}, plots:[{id:'plot0',title:'SMI',color:'#26A69A',type:'line',lineWidth:2},{id:'plot1',title:'Signal',color:'#EF5350',type:'line',lineWidth:1}], hlines:[{price:0.4,color:'#EF535080'},{price:0,color:'#607D8B'},{price:-0.4,color:'#4CAF5080'}], description:'SMI 에르고딕' },
|
||||
{ type:'SMIErgodicOscillator',name:'SMI Ergodic Oscillator', koreanName:'SMI에르고딕오실레이터', shortName:'SMIO', category:'Oscillators', overlay:false, defaultParams:{longLength:20, shortLength:5, signalLength:5}, plots:[{id:'plot0',title:'SMIO',color:'#00BCD4',type:'histogram'}], hlines:[{price:0.4,color:'#EF535080'},{price:0,color:'#607D8B'},{price:-0.4,color:'#4CAF5080'}], description:'SMI 에르고딕 오실레이터' },
|
||||
{ type:'WoodiesCCI', name:'Woodies CCI', koreanName:'우디CCI', shortName:'WCCI', category:'Oscillators', overlay:false, defaultParams:{ccilength:14, turbolen:6}, plots:[{id:'plot0',title:'Turbo',color:'#FF6D00',type:'histogram'},{id:'plot1',title:'CCI',color:'#2962FF',type:'line',lineWidth:2}], hlines:[{price:100,color:'#EF535080'},{price:0,color:'#607D8B'},{price:-100,color:'#4CAF5080'}], description:'우디 CCI' },
|
||||
{ type:'RelativeVolatilityIndex',name:'Relative Volatility Index',koreanName:'상대변동성지수', shortName:'RVI2', category:'Oscillators', overlay:false, defaultParams:{length:14, stdevLength:10}, plots:[{id:'plot0',title:'RVI',color:'#FF7043',type:'line',lineWidth:2}], hlines:[{price:60,color:'#EF5350'},{price:40,color:'#4CAF50'}], description:'상대 변동성 지수' },
|
||||
{ type:'ChopZone', name:'Chop Zone', koreanName:'변동성구분지표', shortName:'CZ', category:'Oscillators', overlay:false, defaultParams:{length:30, atrLength:1}, plots:[{id:'plot0',title:'CZ', color:'#FF9800',type:'histogram'}], hlines:[], description:'변동성 vs 추세 구분 지표' },
|
||||
|
||||
// ── Momentum ──────────────────────────────────────────────────────────────
|
||||
{ type:'MACD', name:'MACD', koreanName:'이동평균수렴발산', 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:'Momentum', name:'Momentum', koreanName:'모멘텀', shortName:'MOM', category:'Momentum', overlay:false, defaultParams:{length:10, src:'close'}, plots:[{id:'plot0',title:'MOM', color:'#26A69A',type:'line',lineWidth:2}], hlines:[{price:0,color:'#607D8B'}], description:'모멘텀 지표' },
|
||||
{ type:'ROC', name:'Rate of Change', koreanName:'가격변화율', shortName:'ROC', category:'Momentum', overlay:false, defaultParams:{length:9, src:'close'}, plots:[{id:'plot0',title:'ROC', color:'#00BCD4',type:'line',lineWidth:2}], hlines:[{price:10,color:'#EF535080'},{price:0,color:'#607D8B'},{price:-10,color:'#4CAF5080'}], description:'가격 변화율 (%)' },
|
||||
{ type:'TSI', name:'True Strength Index', koreanName:'참강도지수', shortName:'TSI', category:'Momentum', overlay:false, defaultParams:{longLength:25, shortLength:13, signalLength:13, src:'close'}, plots:[{id:'plot0',title:'TSI', color:'#2196F3',type:'line',lineWidth:2},{id:'plot1',title:'Signal',color:'#FF6D00',type:'line',lineWidth:1}], hlines:[{price:25,color:'#EF5350'},{price:0,color:'#607D8B'},{price:-25,color:'#4CAF50'}], description:'참 강도 지수' },
|
||||
{ type:'TRIX', name:'TRIX', koreanName:'삼중지수변화율', shortName:'TRIX', category:'Momentum', overlay:false, defaultParams:{length:12, signalLength:9}, plots:[{id:'plot0',title:'TRIX', color:'#26A69A',type:'line',lineWidth:2},{id:'plot1',title:'Signal',color:'#FF6D00',type:'line',lineWidth:1}], hlines:[{price:0,color:'#607D8B', label:'중앙선'}], description:'삼중 지수 변화율 (ln→3×EMA→Δ×10000)' },
|
||||
{ type:'KnowSureThing', name:'Know Sure Thing (KST)', koreanName:'KST모멘텀', shortName:'KST', category:'Momentum', overlay:false, defaultParams:{}, plots:[{id:'plot0',title:'KST', color:'#9C27B0',type:'line',lineWidth:2},{id:'plot1',title:'Signal',color:'#FF9800',type:'line',lineWidth:1}], hlines:[{price:0,color:'#607D8B'}], description:'KST 모멘텀 지표' },
|
||||
{ type:'CoppockCurve', name:'Coppock Curve', koreanName:'콥독커브', shortName:'Copp', category:'Momentum', overlay:false, defaultParams:{}, plots:[{id:'plot0',title:'Coppock',color:'#E91E63',type:'line',lineWidth:2}], hlines:[{price:0,color:'#607D8B'}], description:'콥독 커브 장기 모멘텀' },
|
||||
{ type:'BullBearPower', name:'Bull Bear Power', koreanName:'매수매도세력', shortName:'BBP', category:'Momentum', overlay:false, defaultParams:{length:13, src:'close'}, plots:[{id:'plot0',title:'BBP', color:'#26A69A',type:'histogram'}], hlines:[{price:0,color:'#607D8B'}], description:'매수/매도 세력 비교' },
|
||||
{ type:'ElderForceIndex', name:'Elder Force Index', koreanName:'엘더강도지수', shortName:'EFI', category:'Momentum', overlay:false, defaultParams:{length:13}, plots:[{id:'plot0',title:'EFI', color:'#00BCD4',type:'histogram'}], hlines:[{price:0,color:'#607D8B'}], description:'엘더 강도 지수' },
|
||||
{ type:'PriceOscillator', name:'Price Oscillator (PPO)', koreanName:'가격오실레이터', shortName:'PPO', category:'Momentum', overlay:false, defaultParams:{fastLength:12, slowLength:26, src:'close'}, plots:[{id:'plot0',title:'PPO', color:'#FF7043',type:'line',lineWidth:2}], hlines:[{price:2,color:'#EF535080'},{price:0,color:'#607D8B'},{price:-2,color:'#4CAF5080'}], description:'가격 오실레이터 (MACD의 %)' },
|
||||
{ type:'EaseOfMovement', name:'Ease of Movement', koreanName:'이동용이성', shortName:'EOM', category:'Momentum', overlay:false, defaultParams:{length:14}, plots:[{id:'plot0',title:'EOM', color:'#009688',type:'line',lineWidth:2}], hlines:[{price:0,color:'#607D8B'}], description:'이동 용이성 지표' },
|
||||
|
||||
// ── Trend ──────────────────────────────────────────────────────────────
|
||||
{ type:'ADX', name:'Average Directional Index', koreanName:'평균방향성지수', 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:ADX_DEFAULT_HLINES, description:'평균 방향성 지수 (추세 강도)' },
|
||||
{ type:'DMI', name:'Directional Movement Index', koreanName:'방향성이동지수', shortName:'DMI', category:'Trend', overlay:false, defaultParams:{diLength:14, adxSmoothing:14}, plots:[{id:'plot0',title:'+DI',color:'#2962FF',type:'line',lineWidth:1},{id:'plot1',title:'-DI',color:'#FF6D00',type:'line',lineWidth:1},{id:'plot2',title:'DX',color:'#FF9800',type:'line',lineWidth:1},{id:'plot3',title:'ADX',color:'#E91E63',type:'line',lineWidth:1},{id:'plot4',title:'ADXR',color:'#9C27B0',type:'line',lineWidth:1}], hlines:DMI_DEFAULT_HLINES, description:'방향성 이동 지수 (+DI·-DI·DX·ADX·ADXR)' },
|
||||
{ type:'Aroon', name:'Aroon', koreanName:'아룬지표', shortName:'Aroon', category:'Trend', overlay:false, defaultParams:{length:14}, plots:[{id:'plot0',title:'Up', color:'#4CAF50',type:'line',lineWidth:1},{id:'plot1',title:'Down',color:'#EF5350',type:'line',lineWidth:1}], hlines:[{price:70,color:'#4CAF5060'},{price:30,color:'#EF535060'}], description:'아룬 추세 지표' },
|
||||
{ type:'IchimokuCloud', name:'Ichimoku Cloud', koreanName:'일목균형표', shortName:'Ichi', category:'Trend', overlay:true, defaultParams:{conversionPeriods:9, basePeriods:26, laggingSpan2Periods:52, displacement:26}, plots:[{id:'plot0',title:'전환선',color:'#2196F3',type:'line',lineWidth:1},{id:'plot1',title:'기준선',color:'#EF5350',type:'line',lineWidth:1},{id:'plot2',title:'후행스팬',color:'#4CAF50',type:'line',lineWidth:1},{id:'plot3',title:'선행스팬1',color:'#EF5350',type:'line',lineWidth:1},{id:'plot4',title:'선행스팬2',color:'#9C27B0',type:'line',lineWidth:1}], description:'일목균형표' },
|
||||
{ type:'Supertrend', name:'Supertrend', koreanName:'슈퍼트렌드', shortName:'ST', category:'Trend', overlay:true, defaultParams:{atrPeriod:10, factor:3}, plots:[{id:'plot0',title:'ST', color:'#4CAF50',type:'line',lineWidth:2}], description:'슈퍼트렌드 (ATR 기반)' },
|
||||
{ type:'ParabolicSAR', name:'Parabolic SAR', koreanName:'파라볼릭SAR', shortName:'SAR', category:'Trend', overlay:true, defaultParams:{start:0.02, increment:0.02, maximum:0.2}, plots:[{id:'plot0',title:'SAR', color:'#FF6D00',type:'scatter'}], description:'파라볼릭 SAR' },
|
||||
{ type:'Choppiness', name:'Choppiness Index', koreanName:'불규칙성지수', shortName:'CHOP', category:'Trend', overlay:false, defaultParams:{length:14}, plots:[{id:'plot0',title:'CHOP',color:'#FFC107',type:'line',lineWidth:2}], hlines:[{price:61.8,color:'#EF535080'},{price:38.2,color:'#4CAF5080'}], description:'변동성 vs 추세 구분 (61.8이하=추세)' },
|
||||
{ type:'MassIndex', name:'Mass Index', koreanName:'매스인덱스', shortName:'MI', category:'Trend', overlay:false, defaultParams:{length:25, length2:9}, plots:[{id:'plot0',title:'MI', color:'#E91E63',type:'line',lineWidth:2}], hlines:[{price:27,color:'#EF535080'},{price:26.5,color:'#4CAF5080'}], description:'매스 인덱스 (반전 탐지)' },
|
||||
{ type:'VortexIndicator', name:'Vortex Indicator', koreanName:'보텍스지표', shortName:'VI', category:'Trend', overlay:false, defaultParams:{length:14}, plots:[{id:'plot0',title:'VI+', color:'#4CAF50',type:'line',lineWidth:1},{id:'plot1',title:'VI-',color:'#EF5350',type:'line',lineWidth:1}], hlines:[{price:1,color:'#607D8B'}], description:'보텍스 지표 (추세 전환 확인)' },
|
||||
{ type:'WilliamsAlligator',name:'Williams Alligator', koreanName:'윌리엄스악어', shortName:'Alli', category:'Trend', overlay:true, defaultParams:{}, plots:[{id:'plot0',title:'Jaw', color:'#2196F3',type:'line',lineWidth:1},{id:'plot1',title:'Teeth',color:'#4CAF50',type:'line',lineWidth:1},{id:'plot2',title:'Lips',color:'#EF5350',type:'line',lineWidth:1}], description:'윌리엄스 악어 (3개 SMMA)' },
|
||||
{ type:'TrendStrengthIndex',name:'Trend Strength Index', koreanName:'추세강도지수', shortName:'TSI2', category:'Trend', overlay:false, defaultParams:{}, plots:[{id:'plot0',title:'TSI', color:'#9C27B0',type:'line',lineWidth:2}], description:'추세 강도 지수' },
|
||||
{ type:'BBTrend', name:'BB Trend', koreanName:'볼린저밴드추세', shortName:'BBT', category:'Trend', overlay:false, defaultParams:{length:20, mult:2}, plots:[{id:'plot0',title:'BBTrend',color:'#FF7043',type:'histogram'}], hlines:[{price:0,color:'#607D8B'}], description:'볼린저밴드 기반 추세 방향' },
|
||||
{ type:'ChandeKrollStop', name:'Chande Kroll Stop', koreanName:'챈드크롤스탑', shortName:'CKS', category:'Trend', overlay:true, defaultParams:{p:10, q:9, x:3}, plots:[{id:'plot0',title:'Stop+',color:'#4CAF50',type:'line',lineWidth:2},{id:'plot1',title:'Stop-',color:'#EF5350',type:'line',lineWidth:2}], description:'챈드 크롤 스탑' },
|
||||
{ type:'ZigZag', name:'ZigZag', koreanName:'지그재그', shortName:'ZZ', category:'Trend', overlay:true, defaultParams:{deviation:5, depth:10, backstep:3}, plots:[{id:'plot0',title:'ZZ', color:'#FF5722',type:'line',lineWidth:2}], description:'지그재그 (스윙 고점·저점 연결)' },
|
||||
{ type:'WilliamsFractals',name:'Williams Fractals', koreanName:'윌리엄스프랙탈', shortName:'Frac', category:'Trend', overlay:true, defaultParams:{}, plots:[], returnsMarkers:true, description:'윌리엄스 프랙탈 (고점·저점 마커)' },
|
||||
{ type:'RankCorrelationIndex',name:'Rank Correlation Index (RCI)',koreanName:'순위상관지수', shortName:'RCI', category:'Oscillators', overlay:false, defaultParams:{length1:9, length2:26, length3:52}, plots:[{id:'plot0',title:'RCI9',color:'#2196F3',type:'line',lineWidth:2},{id:'plot1',title:'RCI26',color:'#FF9800',type:'line',lineWidth:1}], hlines:[{price:80,color:'#EF535080'},{price:-80,color:'#4CAF5080'}], description:'순위 상관 지수 (추세 강도)' },
|
||||
|
||||
// ── Volatility ────────────────────────────────────────────────────────────
|
||||
{ type:'ATR', name:'Average True Range', koreanName:'평균실제범위', shortName:'ATR', category:'Volatility', overlay:false, defaultParams:{length:14, smoothing:'RMA'}, plots:[{id:'plot0',title:'ATR', color:'#FF9800',type:'line',lineWidth:2}], description:'평균 진범위 (변동성)' },
|
||||
{ type:'ADR', name:'Average Day Range', koreanName:'평균일일범위', shortName:'ADR', category:'Volatility', overlay:false, defaultParams:{length:14}, plots:[{id:'plot0',title:'ADR', color:'#FF5722',type:'line',lineWidth:2}], description:'평균 일일 범위' },
|
||||
{ type:'StandardDeviation', name:'Standard Deviation', koreanName:'표준편차', shortName:'StdDev', category:'Volatility', overlay:false, defaultParams:{length:20, src:'close'}, plots:[{id:'plot0',title:'StdDev',color:'#9C27B0',type:'line',lineWidth:2}], description:'표준 편차 (가격 변동성)' },
|
||||
{ type:'HistoricalVolatility',name:'Historical Volatility', koreanName:'역사적변동성', shortName:'HV', category:'Volatility', overlay:false, defaultParams:{length:20, annualBars:252}, plots:[{id:'plot0',title:'HV', color:'#E91E63',type:'line',lineWidth:2}], description:'역사적 변동성 (연화)' },
|
||||
|
||||
// ── Volume ────────────────────────────────────────────────────────────────
|
||||
{ type:'OBV', name:'On Balance Volume', koreanName:'잔량균형지수', shortName:'OBV', category:'Volume', overlay:false, defaultParams:{maType:'SMA', maLength:9}, plots:[{id:'plot0',title:'OBV', color:'#2196F3',type:'line',lineWidth:2},{id:'plot1',title:'Signal',color:'#FF9800',type:'line',lineWidth:1}], hlines:[], description:'잔량 균형 지수 (OBV + 이동평균 신호선)' },
|
||||
{ type:'VR', name:'Volume Ratio', koreanName:'거래량비율', shortName:'VR', category:'Volume', overlay:false, defaultParams:{length:10}, plots:[{id:'plot0',title:'VR', color:'#AB47BC',type:'line',lineWidth:2}], hlines:[{price:200,color:'#EF5350'},{price:100,color:'#607D8B'},{price:50,color:'#4CAF50'}], description:'거래량 비율 (VR)' },
|
||||
{ type:'Disparity', name:'Disparity Index', koreanName:'이격도', shortName:'Disp', category:'Oscillators', overlay:false, defaultParams:{ultraLength:5, shortLength:10, midLength:20, longLength:60, src:'close'}, plots:[{id:'plot0',title:'초단기',color:'#E91E63',type:'line',lineWidth:1},{id:'plot1',title:'단기',color:'#FF9800',type:'line',lineWidth:1},{id:'plot2',title:'중기',color:'#4CAF50',type:'line',lineWidth:1},{id:'plot3',title:'장기',color:'#2196F3',type:'line',lineWidth:1}], hlines:[{price:100,color:'#607D8B'}], description:'이격도 (종가÷이평×100)' },
|
||||
{ type:'Psychological', name:'Psychological Line', koreanName:'심리도', shortName:'Psy', category:'Oscillators', overlay:false, defaultParams:{length:12}, plots:[{id:'plot0',title:'심리도',color:'#00BCD4',type:'line',lineWidth:2}], hlines:[{price:75,color:'#EF5350',label:'과열선'},{price:50,color:'#607D8B',label:'중앙선'},{price:25,color:'#4CAF50',label:'침체선'}], description:'심리도 (상승일 비율)' },
|
||||
{ type:'InvestPsychological', name:'Invest Psychological Line', koreanName:'투자심리도', shortName:'IPsych',category:'Oscillators', overlay:false, defaultParams:{length:10}, plots:[{id:'plot0',title:'투자심리도',color:'#7E57C2',type:'line',lineWidth:2}], hlines:[{price:75,color:'#EF5350',label:'과열선'},{price:50,color:'#607D8B',label:'중앙선'},{price:25,color:'#4CAF50',label:'침체선'}], description:'투자심리도 (상승일 거래량 비중)' },
|
||||
{ type:'MFI', name:'Money Flow Index', koreanName:'자금흐름지수', shortName:'MFI', category:'Volume', overlay:false, defaultParams:{length:14}, plots:[{id:'plot0',title:'MFI', color:'#00BCD4',type:'line',lineWidth:2}], hlines:[{price:80,color:'#EF5350'},{price:20,color:'#4CAF50'}], description:'자금 흐름 지수' },
|
||||
{ type:'ChaikinMF', name:'Chaikin Money Flow', koreanName:'차이킨자금흐름', shortName:'CMF', category:'Volume', overlay:false, defaultParams:{length:20}, plots:[{id:'plot0',title:'CMF', color:'#4CAF50',type:'histogram'}], hlines:[{price:0.25,color:'#EF5350'},{price:0,color:'#607D8B'},{price:-0.25,color:'#4CAF50'}], description:'차이킨 자금 흐름' },
|
||||
{ type:'ChaikinOscillator', name:'Chaikin Oscillator', koreanName:'차이킨오실레이터', shortName:'CO', category:'Volume', overlay:false, defaultParams:{fastLength:3, slowLength:10}, plots:[{id:'plot0',title:'CO', color:'#FF9800',type:'histogram'}], hlines:[{price:0,color:'#607D8B'}], description:'차이킨 오실레이터' },
|
||||
{ type:'PVT', name:'Price Volume Trend', koreanName:'가격거래량추세', shortName:'PVT', category:'Volume', overlay:false, defaultParams:{}, plots:[{id:'plot0',title:'PVT', color:'#7E57C2',type:'line',lineWidth:2}], description:'가격 거래량 추세' },
|
||||
{ type:'NetVolume', name:'Net Volume', koreanName:'순거래량', shortName:'NV', category:'Volume', overlay:false, defaultParams:{}, plots:[{id:'plot0',title:'NV', color:'#26A69A',type:'histogram'}], hlines:[{price:0,color:'#607D8B'}], description:'순 거래량 (매수-매도)' },
|
||||
{ type:'VolumeDelta', name:'Volume Delta', koreanName:'거래량델타', shortName:'VD', category:'Volume', overlay:false, defaultParams:{}, plots:[{id:'plot0',title:'VD', color:'#2196F3',type:'histogram'}], hlines:[{price:0,color:'#607D8B'}], description:'볼륨 델타 (매수/매도 차이)' },
|
||||
{ type:'CumulativeVolumeDelta',name:'Cumulative Volume Delta', koreanName:'누적거래량델타', shortName:'CVD', category:'Volume', overlay:false, defaultParams:{}, plots:[{id:'plot0',title:'CVD', color:'#E91E63',type:'line',lineWidth:2}], hlines:[{price:0,color:'#607D8B'}], description:'누적 볼륨 델타' },
|
||||
{ type:'VolumeOscillator', name:'Volume Oscillator', koreanName:'거래량오실레이터', shortName:'VO', category:'Volume', overlay:false, defaultParams:{shortLength:5, longLength:10}, plots:[{id:'plot0',title:'VO', color:'#FF7043',type:'histogram'}], hlines:[{price:10,color:'#EF535080'},{price:0,color:'#607D8B'},{price:-10,color:'#4CAF5080'}], description:'볼륨 오실레이터' },
|
||||
{ type:'KlingerOscillator', name:'Klinger Oscillator', koreanName:'클링거오실레이터', shortName:'KVO', category:'Volume', overlay:false, defaultParams:{fastLength:34, slowLength:55, signalLength:13}, plots:[{id:'plot0',title:'KVO',color:'#2962FF',type:'line',lineWidth:2},{id:'plot1',title:'Signal',color:'#FF6D00',type:'line',lineWidth:1}], hlines:[{price:0,color:'#607D8B'}], description:'클링거 오실레이터' },
|
||||
{ type:'RelativeVolumeAtTime',name:'Relative Volume at Time', koreanName:'시간대별상대거래량', shortName:'RVAT', category:'Volume', overlay:false, defaultParams:{length:24, sessionType:'Regular'}, plots:[{id:'plot0',title:'RVAT', color:'#9C27B0',type:'histogram'}], hlines:[{price:1,color:'#607D8B'}], description:'시간대별 상대 거래량' },
|
||||
|
||||
// ── Candlestick Patterns ───────────────────────────────────────────────────
|
||||
{ type:'Hammer', name:'Hammer', koreanName:'망치형', shortName:'H', category:'Candlestick Patterns', overlay:true, defaultParams:{}, plots:[], returnsMarkers:true, description:'망치형 (상승 반전)' },
|
||||
{ type:'InvertedHammer', name:'Inverted Hammer', koreanName:'역망치형', shortName:'IH', category:'Candlestick Patterns', overlay:true, defaultParams:{}, plots:[], returnsMarkers:true, description:'역망치형 (상승 반전)' },
|
||||
{ type:'HangingMan', name:'Hanging Man', koreanName:'교수형', shortName:'HM', category:'Candlestick Patterns', overlay:true, defaultParams:{}, plots:[], returnsMarkers:true, description:'교수형 (하락 반전)' },
|
||||
{ type:'ShootingStar', name:'Shooting Star', koreanName:'슈팅스타', shortName:'SS', category:'Candlestick Patterns', overlay:true, defaultParams:{}, plots:[], returnsMarkers:true, description:'슈팅스타 (하락 반전)' },
|
||||
{ type:'Doji', name:'Doji', koreanName:'도지형', shortName:'Doji', category:'Candlestick Patterns', overlay:true, defaultParams:{}, plots:[], returnsMarkers:true, description:'도지형' },
|
||||
{ type:'DragonflyDoji', name:'Dragonfly Doji', koreanName:'잠자리도지', shortName:'DDoji', category:'Candlestick Patterns', overlay:true, defaultParams:{}, plots:[], returnsMarkers:true, description:'잠자리 도지' },
|
||||
{ type:'GravestoneDoji', name:'Gravestone Doji', koreanName:'비석도지', shortName:'GDoji', category:'Candlestick Patterns', overlay:true, defaultParams:{}, plots:[], returnsMarkers:true, description:'비석 도지' },
|
||||
{ type:'EngulfingBullish', name:'Bullish Engulfing', koreanName:'강세포용형', shortName:'BE', category:'Candlestick Patterns', overlay:true, defaultParams:{}, plots:[], returnsMarkers:true, description:'강세 포용형' },
|
||||
{ type:'EngulfingBearish', name:'Bearish Engulfing', koreanName:'약세포용형', shortName:'BrE', category:'Candlestick Patterns', overlay:true, defaultParams:{}, plots:[], returnsMarkers:true, description:'약세 포용형' },
|
||||
{ type:'HaramiBullish', name:'Bullish Harami', koreanName:'강세하라미', shortName:'BH', category:'Candlestick Patterns', overlay:true, defaultParams:{}, plots:[], returnsMarkers:true, description:'강세 하라미' },
|
||||
{ type:'HaramiBearish', name:'Bearish Harami', koreanName:'약세하라미', shortName:'BrH', category:'Candlestick Patterns', overlay:true, defaultParams:{}, plots:[], returnsMarkers:true, description:'약세 하라미' },
|
||||
{ type:'MorningStar', name:'Morning Star', koreanName:'샛별형', shortName:'MS', category:'Candlestick Patterns', overlay:true, defaultParams:{}, plots:[], returnsMarkers:true, description:'샛별형 (강세 반전)' },
|
||||
{ type:'EveningStar', name:'Evening Star', koreanName:'저녁별형', shortName:'ES', category:'Candlestick Patterns', overlay:true, defaultParams:{}, plots:[], returnsMarkers:true, description:'저녁별형 (약세 반전)' },
|
||||
{ type:'MorningDojiStar', name:'Morning Doji Star', koreanName:'도지샛별형', shortName:'MDS', category:'Candlestick Patterns', overlay:true, defaultParams:{}, plots:[], returnsMarkers:true, description:'도지 샛별형' },
|
||||
{ type:'EveningDojiStar', name:'Evening Doji Star', koreanName:'도지저녁별형', shortName:'EDS', category:'Candlestick Patterns', overlay:true, defaultParams:{}, plots:[], returnsMarkers:true, description:'도지 저녁별형' },
|
||||
{ type:'ThreeWhiteSoldiers', name:'Three White Soldiers', koreanName:'삼백병사형', shortName:'3WS', category:'Candlestick Patterns', overlay:true, defaultParams:{}, plots:[], returnsMarkers:true, description:'삼병 (강세 패턴)' },
|
||||
{ type:'ThreeBlackCrows', name:'Three Black Crows', koreanName:'흑삼병형', shortName:'3BC', category:'Candlestick Patterns', overlay:true, defaultParams:{}, plots:[], returnsMarkers:true, description:'흑삼병 (약세 패턴)' },
|
||||
{ type:'Piercing', name:'Piercing Line', koreanName:'관통형', shortName:'PL', category:'Candlestick Patterns', overlay:true, defaultParams:{}, plots:[], returnsMarkers:true, description:'관통형 (상승 반전)' },
|
||||
{ type:'DarkCloudCover', name:'Dark Cloud Cover', koreanName:'먹구름형', shortName:'DCC', category:'Candlestick Patterns', overlay:true, defaultParams:{}, plots:[], returnsMarkers:true, description:'먹구름형 (하락 반전)' },
|
||||
{ type:'TweezerBottom', name:'Tweezer Bottom', koreanName:'집게바닥형', shortName:'TwB', category:'Candlestick Patterns', overlay:true, defaultParams:{}, plots:[], returnsMarkers:true, description:'집게바닥형 (상승 반전)' },
|
||||
{ type:'TweezerTop', name:'Tweezer Top', koreanName:'집게천장형', shortName:'TwT', category:'Candlestick Patterns', overlay:true, defaultParams:{}, plots:[], returnsMarkers:true, description:'집게천장형 (하락 반전)' },
|
||||
];
|
||||
|
||||
export function getIndicatorDef(type: string): IndicatorDef | undefined {
|
||||
return INDICATOR_REGISTRY.find(d => d.type === type);
|
||||
}
|
||||
|
||||
/** 실시간 차트 pane·범례 타이틀 (심리도·투자심리도·이격도 등 한글명 우선) */
|
||||
export function getIndicatorChartTitle(type: string): string {
|
||||
const resolved = type === 'NewPsychological' ? 'Psychological' : type;
|
||||
const def = getIndicatorDef(resolved);
|
||||
if (!def) return type;
|
||||
if (
|
||||
resolved === 'Psychological'
|
||||
|| resolved === 'InvestPsychological'
|
||||
|| resolved === 'Disparity'
|
||||
) {
|
||||
return def.koreanName;
|
||||
}
|
||||
return def.shortName;
|
||||
}
|
||||
|
||||
/**
|
||||
* DB/슬롯에 일부 플롯만 저장된 경우 registry 기본 플롯과 병합.
|
||||
* (예: CCI plot0만 있으면 신호선 plot1 이 설정·차트에서 누락됨)
|
||||
*/
|
||||
export function mergePlotDefs(saved: PlotDef[] | undefined, defaults: PlotDef[]): PlotDef[] {
|
||||
if (!defaults.length) return (saved ?? []).map(p => ({ ...p }));
|
||||
const savedMap = new Map((saved ?? []).map(p => [p.id, p]));
|
||||
return defaults.map(def => {
|
||||
const s = savedMap.get(def.id);
|
||||
return s ? { ...def, ...s, id: def.id, title: def.title } : { ...def };
|
||||
});
|
||||
}
|
||||
|
||||
/** 업비트 OBV 신호선 MA 종류 (단순·지수·가중만) */
|
||||
export const OBV_MA_TYPE_OPTIONS = ['SMA', 'EMA', 'WMA'] as const;
|
||||
export type ObvMaType = (typeof OBV_MA_TYPE_OPTIONS)[number];
|
||||
|
||||
export function normalizeObvParams(
|
||||
params: Record<string, number | string | boolean>,
|
||||
): Record<string, number | string | boolean> {
|
||||
const raw = String(params.maType ?? 'SMA');
|
||||
const maType: ObvMaType = raw === 'EMA' || raw === 'WMA' ? raw : 'SMA';
|
||||
const maLength = Math.max(1, Number(params.maLength ?? 9) || 9);
|
||||
return { ...params, maType, maLength };
|
||||
}
|
||||
|
||||
/** 차트·설정 모달용 — 파라미터·플롯·가시성을 registry 기본값과 병합 */
|
||||
export function enrichIndicatorConfig(
|
||||
cfg: import('../types').IndicatorConfig
|
||||
): import('../types').IndicatorConfig {
|
||||
const resolvedType = resolvePsychologicalIndicatorType(cfg.type);
|
||||
const cfgNorm = resolvedType !== cfg.type ? { ...cfg, type: resolvedType } : cfg;
|
||||
const def = getIndicatorDef(cfgNorm.type);
|
||||
if (!def) return cfgNorm;
|
||||
|
||||
if (cfgNorm.type === 'SMA') {
|
||||
return normalizeSmaConfig({
|
||||
...cfgNorm,
|
||||
params: {
|
||||
...(def.defaultParams as Record<string, number | string | boolean>),
|
||||
...cfg.params,
|
||||
},
|
||||
plots: mergePlotDefs(cfg.plots, def.plots),
|
||||
});
|
||||
}
|
||||
|
||||
if (cfgNorm.type === 'IchimokuCloud') {
|
||||
return normalizeIchimokuConfig({
|
||||
...cfgNorm,
|
||||
params: {
|
||||
...(def.defaultParams as Record<string, number | string | boolean>),
|
||||
...cfgNorm.params,
|
||||
},
|
||||
plots: mergeIchimokuPlots(cfgNorm.plots, def.plots),
|
||||
});
|
||||
}
|
||||
|
||||
let params = {
|
||||
...(def.defaultParams as Record<string, number | string | boolean>),
|
||||
...cfgNorm.params,
|
||||
};
|
||||
if (cfgNorm.type === 'OBV') {
|
||||
params = normalizeObvParams(params);
|
||||
}
|
||||
if (cfgNorm.type === 'Psychological' || cfgNorm.type === 'InvestPsychological') {
|
||||
params = normalizePsychologicalParams(cfgNorm.type, params);
|
||||
}
|
||||
if (cfgNorm.type === 'BollingerBands') {
|
||||
params = normalizeBollingerParams(params);
|
||||
}
|
||||
if (cfgNorm.type === 'DMI') {
|
||||
params = normalizeDmiParams(params);
|
||||
}
|
||||
const plots = cfgNorm.type === 'BollingerBands'
|
||||
? mergeBbPlots(cfgNorm.plots, def.plots)
|
||||
: mergePlotDefs(cfgNorm.plots, def.plots);
|
||||
const plotVisibility: Record<string, boolean> = { ...cfgNorm.plotVisibility };
|
||||
for (const p of plots) {
|
||||
if (plotVisibility[p.id] === undefined) plotVisibility[p.id] = true;
|
||||
}
|
||||
if (cfgNorm.type === 'OBV') {
|
||||
plotVisibility.plot1 = plotVisibility.plot1 !== false;
|
||||
} else if ((cfgNorm.type === 'CCI' || cfgNorm.type === 'RSI') && params.maType === 'None') {
|
||||
plotVisibility.plot1 = false;
|
||||
}
|
||||
|
||||
const out: import('../types').IndicatorConfig = {
|
||||
...cfgNorm,
|
||||
params,
|
||||
plots,
|
||||
plotVisibility,
|
||||
hlines: normalizeHLines(cfgNorm.hlines ?? [], cfgNorm.type, def.hlines),
|
||||
};
|
||||
if (cfgNorm.type === 'BollingerBands') {
|
||||
out.bandBackground = resolveBbBandBackground(cfgNorm.bandBackground);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로그램 내부 파라미터 키 → lightweight-charts-indicators 라이브러리 파라미터 키 변환.
|
||||
*
|
||||
* 라이브러리가 기대하는 파라미터 이름이 내부 registry/DB 키와 다른 경우만 등록.
|
||||
* indicatorRegistry.ts defaultParams 와 라이브러리 defaultInputs 를 대조하여 작성.
|
||||
*/
|
||||
const PARAM_KEY_MAP: Record<string, Record<string, string>> = {
|
||||
// Stochastic: 내부 {kLength, smooth, dSmoothing} → 라이브러리 {periodK, smoothK, periodD}
|
||||
Stochastic: {
|
||||
kLength: 'periodK',
|
||||
smooth: 'smoothK',
|
||||
dSmoothing: 'periodD',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 지표 파라미터를 라이브러리가 인식하는 키 이름으로 변환한다.
|
||||
* 매핑이 없는 지표는 원본을 그대로 반환.
|
||||
*/
|
||||
function translateParams(
|
||||
type: string,
|
||||
params: Record<string, number | string | boolean>
|
||||
): Record<string, number | string | boolean> {
|
||||
const keyMap = PARAM_KEY_MAP[type];
|
||||
if (!keyMap) return params;
|
||||
|
||||
const translated: Record<string, number | string | boolean> = {};
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
translated[keyMap[k] ?? k] = v;
|
||||
}
|
||||
return translated;
|
||||
}
|
||||
|
||||
const CUSTOM_CALCULATORS: Record<
|
||||
string,
|
||||
(bars: OHLCVBar[], params: Record<string, number | string | boolean>) => Record<string, PlotData>
|
||||
> = {};
|
||||
|
||||
async function loadCustomCalculators() {
|
||||
if (Object.keys(CUSTOM_CALCULATORS).length > 0) return;
|
||||
const c = await import('./customIndicators');
|
||||
CUSTOM_CALCULATORS.TRIX = c.calcTRIXWithSignal;
|
||||
CUSTOM_CALCULATORS.OBV = c.calcOBVWithMA;
|
||||
CUSTOM_CALCULATORS.VR = c.calcVR;
|
||||
CUSTOM_CALCULATORS.Disparity = c.calcDisparityUpbit;
|
||||
CUSTOM_CALCULATORS.Psychological = c.calcPsychological;
|
||||
CUSTOM_CALCULATORS.NewPsychological = c.calcPsychological;
|
||||
CUSTOM_CALCULATORS.InvestPsychological = c.calcInvestPsychological;
|
||||
CUSTOM_CALCULATORS.BollingerBands = c.calcBollingerBands;
|
||||
CUSTOM_CALCULATORS.DMI = c.calcDMI;
|
||||
}
|
||||
|
||||
/** 차트 심볼·타임프레임 (BB 다른 심볼용) */
|
||||
let chartContext: { market: string; timeframe: Timeframe } = { market: '', timeframe: '1D' };
|
||||
|
||||
export function setIndicatorChartContext(market: string, timeframe: Timeframe): void {
|
||||
chartContext = { market, timeframe };
|
||||
}
|
||||
|
||||
export async function calculateIndicator(
|
||||
type: string,
|
||||
bars: OHLCVBar[],
|
||||
params: Record<string, number | string | boolean>
|
||||
): Promise<{ plots: IndicatorResult; markers?: MarkerData[] }> {
|
||||
if (type === 'SMA') {
|
||||
return { plots: calculateSmaMulti(bars, params) };
|
||||
}
|
||||
|
||||
let calcBars = bars;
|
||||
let calcParams = params;
|
||||
if (type === 'BollingerBands') {
|
||||
calcParams = normalizeBollingerParams(params);
|
||||
calcBars = await resolveBollingerBars(bars, calcParams, chartContext.timeframe);
|
||||
}
|
||||
|
||||
await loadCustomCalculators();
|
||||
const custom = CUSTOM_CALCULATORS[type];
|
||||
if (custom) {
|
||||
return { plots: custom(calcBars, calcParams) as IndicatorResult };
|
||||
}
|
||||
|
||||
const b = toBars(bars);
|
||||
const lib = await import('lightweight-charts-indicators');
|
||||
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, translateParams(type, params));
|
||||
return {
|
||||
plots: (result.plots ?? {}) as IndicatorResult,
|
||||
markers: result.markers as MarkerData[] | undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 보조지표 설정 편집 — 개별 모달·일괄 설정 팝업 공통 직렬화/초기화
|
||||
*/
|
||||
import type { IndicatorConfig, HlinesBackground, Timeframe } from '../types';
|
||||
import type { PlotDef, HLineDef } from './indicatorRegistry';
|
||||
import {
|
||||
getIndicatorDef,
|
||||
mergePlotDefs,
|
||||
normalizeHLines,
|
||||
normalizeObvParams,
|
||||
enrichIndicatorConfig,
|
||||
} from './indicatorRegistry';
|
||||
import {
|
||||
normalizeBollingerParams,
|
||||
DEFAULT_BB_BAND_BACKGROUND,
|
||||
resolveBbBandBackground,
|
||||
} from './bollingerConfig';
|
||||
import { normalizeDmiParams } from './indicatorRegistry';
|
||||
import {
|
||||
normalizeSmaConfig,
|
||||
createDefaultSmaParams,
|
||||
createDefaultSmaPlots,
|
||||
createDefaultSmaPlotVisibility,
|
||||
} from './smaConfig';
|
||||
import {
|
||||
normalizeIchimokuConfig,
|
||||
createDefaultIchimokuParams,
|
||||
createDefaultIchimokuPlotVisibility,
|
||||
mergeIchimokuPlots,
|
||||
DEFAULT_ICHIMOKU_CLOUD_COLORS,
|
||||
resolveIchimokuCloudColors,
|
||||
type IchimokuCloudColors,
|
||||
} from './ichimokuConfig';
|
||||
|
||||
export const DEFAULT_HLINES_BG: HlinesBackground = { visible: true, color: '#787B8640' };
|
||||
|
||||
export interface IndicatorSettingsEditorSnapshot {
|
||||
params: Record<string, number | string | boolean>;
|
||||
plots: PlotDef[];
|
||||
plotVis: Record<string, boolean>;
|
||||
hlines: HLineDef[];
|
||||
hlinesBg: HlinesBackground;
|
||||
lastValueVisible: boolean;
|
||||
timeframeVis: Partial<Record<Timeframe, boolean>>;
|
||||
cloudColors: IchimokuCloudColors;
|
||||
bandBg: HlinesBackground;
|
||||
}
|
||||
|
||||
/** 차트·DB 값으로 편집기 초기 config (개별 모달·일괄 팝업 동일) */
|
||||
export function initializeIndicatorConfigForEditor(
|
||||
raw: IndicatorConfig,
|
||||
getParams?: (type: string, defaults: Record<string, number | string | boolean>) => Record<string, number | string | boolean>,
|
||||
getVisualConfig?: (
|
||||
type: string,
|
||||
defaultPlots: PlotDef[],
|
||||
defaultHlines?: HLineDef[],
|
||||
) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors },
|
||||
): IndicatorConfig {
|
||||
const def = getIndicatorDef(raw.type);
|
||||
if (!def) return enrichIndicatorConfig(raw);
|
||||
|
||||
const params = getParams
|
||||
? { ...getParams(raw.type, def.defaultParams), ...raw.params }
|
||||
: { ...raw.params };
|
||||
const visual = getVisualConfig?.(raw.type, def.plots, def.hlines);
|
||||
|
||||
return enrichIndicatorConfig({
|
||||
...raw,
|
||||
params,
|
||||
plots: raw.plots?.length ? raw.plots : visual?.plots,
|
||||
hlines: raw.hlines?.length ? raw.hlines : visual?.hlines,
|
||||
cloudColors: raw.cloudColors ?? visual?.cloudColors,
|
||||
});
|
||||
}
|
||||
|
||||
export function createEditorSnapshot(config: IndicatorConfig): IndicatorSettingsEditorSnapshot {
|
||||
const normalized = config.type === 'SMA'
|
||||
? normalizeSmaConfig(config)
|
||||
: config.type === 'IchimokuCloud'
|
||||
? normalizeIchimokuConfig(config)
|
||||
: config;
|
||||
const def = getIndicatorDef(config.type);
|
||||
|
||||
const baseParams = config.type === 'SMA' || config.type === 'IchimokuCloud'
|
||||
? { ...normalized.params }
|
||||
: { ...(def?.defaultParams ?? {}), ...normalized.params };
|
||||
|
||||
let params = baseParams;
|
||||
if (config.type === 'OBV') params = normalizeObvParams(baseParams);
|
||||
if (config.type === 'BollingerBands') params = normalizeBollingerParams(baseParams);
|
||||
if (config.type === 'DMI') params = normalizeDmiParams(baseParams);
|
||||
|
||||
const basePlots: PlotDef[] = config.type === 'IchimokuCloud'
|
||||
? mergeIchimokuPlots(normalized.plots, def?.plots ?? [])
|
||||
: mergePlotDefs(normalized.plots, def?.plots ?? []);
|
||||
|
||||
const plotVis: Record<string, boolean> = { ...normalized.plotVisibility };
|
||||
const mergedParams = { ...(def?.defaultParams ?? {}), ...normalized.params };
|
||||
for (const p of basePlots) {
|
||||
if (plotVis[p.id] === undefined) {
|
||||
plotVis[p.id] = normalized.plotVisibility?.[p.id] !== false;
|
||||
}
|
||||
}
|
||||
if ((config.type === 'CCI' || config.type === 'RSI') && mergedParams.maType === 'None') {
|
||||
plotVis.plot1 = false;
|
||||
}
|
||||
|
||||
const baseHLines: HLineDef[] = config.hlines ?? def?.hlines ?? [];
|
||||
|
||||
return {
|
||||
params,
|
||||
plots: basePlots.map(p => ({ ...p })),
|
||||
plotVis,
|
||||
hlines: normalizeHLines(
|
||||
baseHLines.map(h => ({ ...h, visible: h.visible !== false })),
|
||||
config.type,
|
||||
def?.hlines,
|
||||
),
|
||||
hlinesBg: normalized.hlinesBackground ?? { ...DEFAULT_HLINES_BG },
|
||||
lastValueVisible: normalized.lastValueVisible !== false,
|
||||
timeframeVis: { ...normalized.timeframeVisibility },
|
||||
cloudColors: resolveIchimokuCloudColors(normalized.cloudColors),
|
||||
bandBg: resolveBbBandBackground(normalized.bandBackground),
|
||||
};
|
||||
}
|
||||
|
||||
/** 편집 스냅샷 → IndicatorConfig (저장·일괄 적용·실시간 onChange 공통) */
|
||||
export function configFromEditorSnapshot(
|
||||
base: IndicatorConfig,
|
||||
snap: IndicatorSettingsEditorSnapshot,
|
||||
): IndicatorConfig {
|
||||
const def = getIndicatorDef(base.type);
|
||||
const hasHLines = (def?.hlines ?? []).length > 0 || snap.hlines.length > 0;
|
||||
const tfVis = Object.values(snap.timeframeVis).some(v => v === false)
|
||||
? snap.timeframeVis
|
||||
: undefined;
|
||||
|
||||
const draft: IndicatorConfig = {
|
||||
...base,
|
||||
params: snap.params,
|
||||
plots: snap.plots,
|
||||
plotVisibility: snap.plotVis,
|
||||
hlines: snap.hlines,
|
||||
hlinesBackground: hasHLines ? snap.hlinesBg : undefined,
|
||||
lastValueVisible: snap.lastValueVisible,
|
||||
timeframeVisibility: tfVis,
|
||||
...(base.type === 'IchimokuCloud' ? { cloudColors: snap.cloudColors } : {}),
|
||||
};
|
||||
|
||||
if (base.type === 'SMA') return normalizeSmaConfig(draft);
|
||||
if (base.type === 'IchimokuCloud') return normalizeIchimokuConfig(draft);
|
||||
if (base.type === 'BollingerBands') {
|
||||
return enrichIndicatorConfig({
|
||||
...draft,
|
||||
params: normalizeBollingerParams(draft.params),
|
||||
bandBackground: snap.bandBg,
|
||||
});
|
||||
}
|
||||
return enrichIndicatorConfig(draft);
|
||||
}
|
||||
|
||||
/** 지표별 기본값 (id·hidden 유지) */
|
||||
export function resetConfigToDefaults(config: IndicatorConfig): IndicatorConfig {
|
||||
const def = getIndicatorDef(config.type);
|
||||
if (!def) return config;
|
||||
|
||||
if (config.type === 'SMA') {
|
||||
return normalizeSmaConfig({
|
||||
...config,
|
||||
params: createDefaultSmaParams(),
|
||||
plots: createDefaultSmaPlots(),
|
||||
plotVisibility: createDefaultSmaPlotVisibility(),
|
||||
hlines: [],
|
||||
hlinesBackground: undefined,
|
||||
lastValueVisible: true,
|
||||
timeframeVisibility: undefined,
|
||||
});
|
||||
}
|
||||
if (config.type === 'IchimokuCloud') {
|
||||
return normalizeIchimokuConfig({
|
||||
...config,
|
||||
params: createDefaultIchimokuParams(),
|
||||
plots: def.plots.map(p => ({ ...p })),
|
||||
plotVisibility: createDefaultIchimokuPlotVisibility(),
|
||||
cloudColors: DEFAULT_ICHIMOKU_CLOUD_COLORS,
|
||||
hlines: [],
|
||||
hlinesBackground: undefined,
|
||||
lastValueVisible: true,
|
||||
timeframeVisibility: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const baseParams = { ...(def.defaultParams as Record<string, number | string | boolean>) };
|
||||
const plots = def.plots.map(p => ({ ...p }));
|
||||
const plotVis: Record<string, boolean> = {};
|
||||
for (const p of def.plots) plotVis[p.id] = true;
|
||||
|
||||
let params = config.type === 'BollingerBands'
|
||||
? normalizeBollingerParams(baseParams)
|
||||
: baseParams;
|
||||
|
||||
const draft: IndicatorConfig = {
|
||||
...config,
|
||||
params,
|
||||
plots,
|
||||
plotVisibility: plotVis,
|
||||
hlines: normalizeHLines(
|
||||
(def.hlines ?? []).map(h => ({ ...h, visible: h.visible !== false })),
|
||||
config.type,
|
||||
def.hlines,
|
||||
),
|
||||
hlinesBackground: { ...DEFAULT_HLINES_BG },
|
||||
lastValueVisible: true,
|
||||
timeframeVisibility: undefined,
|
||||
cloudColors: undefined,
|
||||
bandBackground: config.type === 'BollingerBands' ? { ...DEFAULT_BB_BAND_BACKGROUND } : undefined,
|
||||
};
|
||||
return enrichIndicatorConfig(draft);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 보조지표 설정 화면 — 플롯 행·전역 파라미터 배치 (일목/SMA 단일 화면 컨셉)
|
||||
*/
|
||||
import type { PlotDef } from './indicatorRegistry';
|
||||
|
||||
/** 플롯 행에 붙지 않고 상단에만 표시할 키 (우선 순서) */
|
||||
const GLOBAL_FIRST_KEYS: string[] = [
|
||||
'src', 'maType', 'mult', 'multiplier', 'percent', 'bbMult', 'offset', 'sigma',
|
||||
];
|
||||
|
||||
const GLOBAL_FIRST_SET = new Set(GLOBAL_FIRST_KEYS);
|
||||
|
||||
/** 지표별 플롯 ID → 파라미터 키 (빈 배열 = 자동/공유) */
|
||||
export const INDICATOR_PLOT_PARAMS: Record<string, Record<string, string[]>> = {
|
||||
MACross: { plot0: ['fastLength'], plot1: ['slowLength'] },
|
||||
Stochastic: { plot0: ['kLength', 'smooth'], plot1: ['dSmoothing'] },
|
||||
StochRSI: { plot0: ['lengthRSI', 'lengthStoch', 'smoothK'], plot1: ['smoothD'] },
|
||||
CCI: { plot0: ['length'], plot1: ['maLength'] },
|
||||
RSI: { plot0: ['length'], plot1: ['maLength'] },
|
||||
/** 업비트: OBV 행=이동평균 종류, 신호선 행=기간 (색상 컨트롤 좌측) */
|
||||
OBV: { plot0: ['maType'], plot1: ['maLength'] },
|
||||
/** 업비트: 길이·곱·오프셋은 상단 전역 행, 플롯 행은 스타일만 */
|
||||
BollingerBands: { plot0: [], plot1: [], plot2: [] },
|
||||
/** 업비트: DI 길이·ADX 스무딩은 상단 전역, 플롯 행은 스타일만 */
|
||||
DMI: { plot0: [], plot1: [], plot2: [], plot3: [], plot4: [] },
|
||||
KeltnerChannels: { plot0: ['length', 'multiplier'], plot1: [], plot2: [] },
|
||||
DonchianChannels: { plot0: ['length'], plot1: [], plot2: [] },
|
||||
Envelope: { plot0: ['length', 'percent'], plot1: [], plot2: [] },
|
||||
RVI: { plot0: ['length'], plot1: [] },
|
||||
FisherTransform: { plot0: ['length'], plot1: [] },
|
||||
MACD: { plot0: ['fastLength', 'slowLength', 'signalLength'], plot1: [], plot2: [] },
|
||||
TSI: { plot0: ['longLength', 'shortLength'], plot1: ['signalLength'] },
|
||||
TRIX: { plot0: ['length'], plot1: ['signalLength'] },
|
||||
SMIErgodic: { plot0: ['shortLength', 'longLength'], plot1: ['signalLength'] },
|
||||
SMIErgodicOscillator: { plot0: ['shortLength', 'longLength'], plot1: ['signalLength'] },
|
||||
UltimateOscillator: { plot0: ['length1', 'length2', 'length3'] },
|
||||
ConnorsRSI: { plot0: ['rsiLength', 'upDownLength', 'rocLength'] },
|
||||
Psychological: { plot0: ['length'] },
|
||||
InvestPsychological: { plot0: ['length'] },
|
||||
Disparity: {
|
||||
plot0: ['ultraLength'],
|
||||
plot1: ['shortLength'],
|
||||
plot2: ['midLength'],
|
||||
plot3: ['longLength'],
|
||||
},
|
||||
IchimokuCloud: {
|
||||
plot0: ['conversionPeriods'],
|
||||
plot1: ['basePeriods'],
|
||||
/** 선행스팬1 — 전환·기준 평균선의 미래 이동(shift) 기간 */
|
||||
plot2: ['displacement'],
|
||||
plot3: ['laggingSpan2Periods'],
|
||||
/** 후행스팬 — chikou 이동(선행스팬1과 동일 displacement) */
|
||||
plot4: ['displacement'],
|
||||
},
|
||||
};
|
||||
|
||||
const PLOT_INDEX_FALLBACK: Record<number, string[]> = {
|
||||
0: [
|
||||
'length', 'len', 'kLength', 'fastLength', 'length1', 'lengthRSI', 'rsiLength',
|
||||
'shortLength', 'ultraLength', 'conversionPeriods', 'turbolen',
|
||||
],
|
||||
1: ['dSmoothing', 'slowLength', 'length2', 'smoothD', 'maLength', 'signalLength', 'midLength', 'basePeriods'],
|
||||
2: ['smooth', 'length3', 'longLength', 'laggingSpan2Periods'],
|
||||
3: ['longLength', 'displacement'],
|
||||
4: ['displacement'],
|
||||
};
|
||||
|
||||
function keysInParams(keys: string[], params: Record<string, unknown>): string[] {
|
||||
return keys.filter(k => k in params);
|
||||
}
|
||||
|
||||
/** 플롯 한 줄에 표시할 숫자·선택 파라미터 키 */
|
||||
export function getPlotParamKeys(
|
||||
indicatorType: string,
|
||||
plotId: string,
|
||||
plotIndex: number,
|
||||
plots: PlotDef[],
|
||||
params: Record<string, number | string | boolean>,
|
||||
): string[] {
|
||||
const mapped = INDICATOR_PLOT_PARAMS[indicatorType]?.[plotId];
|
||||
if (mapped !== undefined) {
|
||||
return keysInParams(mapped, params);
|
||||
}
|
||||
|
||||
if (plots.length === 1) {
|
||||
return Object.keys(params).filter(
|
||||
k => !GLOBAL_FIRST_SET.has(k) && typeof params[k] === 'number',
|
||||
);
|
||||
}
|
||||
|
||||
const fallback = keysInParams(PLOT_INDEX_FALLBACK[plotIndex] ?? [], params);
|
||||
if (fallback.length) return fallback;
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 업비트 BB 인풋 탭 순서 */
|
||||
const BOLLINGER_GLOBAL_KEYS = ['length', 'mult', 'offset'] as const;
|
||||
|
||||
/** 플롯에 할당되지 않은 전역 파라미터 (상단 행) */
|
||||
export function getGlobalParamKeys(
|
||||
indicatorType: string,
|
||||
params: Record<string, number | string | boolean>,
|
||||
plots: PlotDef[],
|
||||
): string[] {
|
||||
if (indicatorType === 'BollingerBands') {
|
||||
return BOLLINGER_GLOBAL_KEYS.filter(k => k in params);
|
||||
}
|
||||
|
||||
const assigned = new Set<string>();
|
||||
for (let i = 0; i < plots.length; i++) {
|
||||
for (const k of getPlotParamKeys(indicatorType, plots[i].id, i, plots, params)) {
|
||||
assigned.add(k);
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = Object.keys(params).filter(
|
||||
k => !assigned.has(k) && k !== 'symbolMode' && k !== 'refSymbol' && k !== 'src' && k !== 'maType',
|
||||
);
|
||||
const ordered = GLOBAL_FIRST_KEYS.filter(k => remaining.includes(k));
|
||||
const rest = remaining.filter(k => !ordered.includes(k));
|
||||
return [...ordered, ...rest];
|
||||
}
|
||||
|
||||
/** 플롯 행 입력란에 표시할 내용 없음 */
|
||||
export function plotRowHasNoInputs(
|
||||
indicatorType: string,
|
||||
plotId: string,
|
||||
plotIndex: number,
|
||||
plots: PlotDef[],
|
||||
params: Record<string, number | string | boolean>,
|
||||
): boolean {
|
||||
return getPlotParamKeys(indicatorType, plotId, plotIndex, plots, params).length === 0;
|
||||
}
|
||||
|
||||
/** 파라미터가 상단 전역 행에만 있음 (OBV: maType·maLength) — 플롯 행 '자동' 미표시 */
|
||||
export function plotParamsOnGlobalRowOnly(indicatorType: string, plotId: string): boolean {
|
||||
const mapped = INDICATOR_PLOT_PARAMS[indicatorType]?.[plotId];
|
||||
return mapped !== undefined && mapped.length === 0;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 보조지표 설정 목록 — Main 탭 지표 우선 + 전체 registry, 검색 필터
|
||||
*/
|
||||
import { INDICATOR_REGISTRY } from './indicatorRegistry';
|
||||
import { getIndicatorDef } from './indicatorRegistry';
|
||||
import { MAIN_INDICATOR_TYPES, MAIN_INDICATOR_LABELS, isMainIndicatorType } from './indicatorMainTab';
|
||||
|
||||
const MAIN_SET = new Set<string>(MAIN_INDICATOR_TYPES);
|
||||
|
||||
/** 설정 UI에서 제외 (Psychological 과 동일) */
|
||||
const EXCLUDED_TYPES = new Set(['NewPsychological']);
|
||||
|
||||
/** Main 탭 지표 + 그 외 모든 보조지표 type (순서 고정, Main 항목은 하단 registry 에서 제외) */
|
||||
export function getSettingsIndicatorTypes(): string[] {
|
||||
const rest = INDICATOR_REGISTRY
|
||||
.map(d => d.type)
|
||||
.filter(t => !MAIN_SET.has(t) && !EXCLUDED_TYPES.has(t));
|
||||
return [...MAIN_INDICATOR_TYPES, ...rest];
|
||||
}
|
||||
|
||||
export function isSettingsMainSection(type: string): boolean {
|
||||
return isMainIndicatorType(type);
|
||||
}
|
||||
|
||||
export function getIndicatorListLabels(type: string): { ko: string; en: string } {
|
||||
const def = getIndicatorDef(type);
|
||||
const main = MAIN_INDICATOR_LABELS[type];
|
||||
return {
|
||||
ko: main?.ko ?? def?.description ?? def?.koreanName ?? type,
|
||||
en: main?.en ?? def?.name ?? type,
|
||||
};
|
||||
}
|
||||
|
||||
/** 지표 추가 팝업과 동일 — 이름·약어·한글명·설명 검색 */
|
||||
export function matchIndicatorSearch(type: string, query: string): boolean {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
const def = getIndicatorDef(type);
|
||||
if (!def) return type.toLowerCase().includes(q);
|
||||
const main = MAIN_INDICATOR_LABELS[type];
|
||||
const hay = [
|
||||
type,
|
||||
def.name,
|
||||
def.koreanName,
|
||||
def.shortName,
|
||||
def.description ?? '',
|
||||
main?.ko ?? '',
|
||||
main?.en ?? '',
|
||||
].join(' ').toLowerCase();
|
||||
return hay.includes(q);
|
||||
}
|
||||
|
||||
export function filterSettingsIndicatorTypes(types: readonly string[], query: string): string[] {
|
||||
return types.filter(t => matchIndicatorSearch(t, query));
|
||||
}
|
||||
|
||||
export function partitionFilteredTypes(filtered: string[]): { main: string[]; other: string[] } {
|
||||
const main: string[] = [];
|
||||
const other: string[] = [];
|
||||
for (const t of filtered) {
|
||||
if (isSettingsMainSection(t)) main.push(t);
|
||||
else other.push(t);
|
||||
}
|
||||
return { main, other };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// 레이아웃 관련 타입 및 상수 정의
|
||||
|
||||
export interface LayoutDef {
|
||||
id: string;
|
||||
count: number; // 차트 슬롯 수
|
||||
cols: string; // grid-template-columns
|
||||
rows: string; // grid-template-rows
|
||||
areas: string; // grid-template-areas (슬롯 'a'~'h' 사용)
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const LAYOUTS: LayoutDef[] = [
|
||||
// ── Row 1: 1 chart ──────────────────────────────────────────────────────
|
||||
{ id:'1', count:1, cols:'1fr', rows:'1fr', areas:'"a"', label:'Single' },
|
||||
// ── Row 2: 2 charts ─────────────────────────────────────────────────────
|
||||
{ id:'2v', count:2, cols:'1fr 1fr', rows:'1fr', areas:'"a b"', label:'2 Vertical' },
|
||||
{ id:'2h', count:2, cols:'1fr', rows:'1fr 1fr', areas:'"a" "b"', label:'2 Horizontal' },
|
||||
// ── Row 3: 3 charts ─────────────────────────────────────────────────────
|
||||
{ id:'3v', count:3, cols:'1fr 1fr 1fr', rows:'1fr', areas:'"a b c"', label:'3 Vertical' },
|
||||
{ id:'3h', count:3, cols:'1fr', rows:'1fr 1fr 1fr', areas:'"a" "b" "c"', label:'3 Horizontal' },
|
||||
{ id:'3-1t2b', count:3, cols:'1fr 1fr', rows:'1fr 1fr', areas:'"a a" "b c"', label:'1 Top 2 Bottom' },
|
||||
{ id:'3-2t1b', count:3, cols:'1fr 1fr', rows:'1fr 1fr', areas:'"a b" "c c"', label:'2 Top 1 Bottom' },
|
||||
{ id:'3-1l2r', count:3, cols:'1fr 1fr', rows:'1fr 1fr', areas:'"a b" "a c"', label:'1 Left 2 Right' },
|
||||
{ id:'3-2l1r', count:3, cols:'1fr 1fr', rows:'1fr 1fr', areas:'"a c" "b c"', label:'2 Left 1 Right' },
|
||||
// ── Row 4: 4 charts ─────────────────────────────────────────────────────
|
||||
{ id:'4', count:4, cols:'1fr 1fr', rows:'1fr 1fr', areas:'"a b" "c d"', label:'4 Grid' },
|
||||
{ id:'4v', count:4, cols:'1fr 1fr 1fr 1fr', rows:'1fr', areas:'"a b c d"', label:'4 Vertical' },
|
||||
{ id:'4h', count:4, cols:'1fr', rows:'1fr 1fr 1fr 1fr', areas:'"a" "b" "c" "d"', label:'4 Horizontal' },
|
||||
{ id:'4-1l3r', count:4, cols:'1fr 1fr 1fr', rows:'1fr 1fr', areas:'"a b c" "a d d"', label:'1L 3R' },
|
||||
{ id:'4-big', count:4, cols:'2fr 1fr', rows:'1fr 1fr', areas:'"a b" "a c"', label:'1 Big 2 Right' },
|
||||
// ── Row 5: 5 charts ─────────────────────────────────────────────────────
|
||||
{ id:'5v', count:5, cols:'1fr 1fr 1fr 1fr 1fr',rows:'1fr', areas:'"a b c d e"', label:'5 Vertical' },
|
||||
{ id:'5-2t3b', count:5, cols:'1fr 1fr 1fr', rows:'1fr 1fr', areas:'"a a b" "c d e"', label:'2 Top 3 Bottom' },
|
||||
{ id:'5-3t2b', count:5, cols:'1fr 1fr 1fr', rows:'1fr 1fr', areas:'"a b c" "d d e"', label:'3 Top 2 Bottom' },
|
||||
// ── Row 6: 6 charts ─────────────────────────────────────────────────────
|
||||
{ id:'6-2x3', count:6, cols:'1fr 1fr 1fr', rows:'1fr 1fr', areas:'"a b c" "d e f"', label:'2x3 Grid' },
|
||||
{ id:'6-3x2', count:6, cols:'1fr 1fr', rows:'1fr 1fr 1fr', areas:'"a b" "c d" "e f"', label:'3x2 Grid' },
|
||||
// ── Row 8: 8 charts ─────────────────────────────────────────────────────
|
||||
{ id:'8-2x4', count:8, cols:'1fr 1fr 1fr 1fr', rows:'1fr 1fr', areas:'"a b c d" "e f g h"', label:'2x4 Grid' },
|
||||
{ id:'8-4x2', count:8, cols:'1fr 1fr', rows:'1fr 1fr 1fr 1fr', areas:'"a b" "c d" "e f" "g h"', label:'4x2 Grid' },
|
||||
];
|
||||
|
||||
export interface SyncOptions {
|
||||
symbol: boolean;
|
||||
interval: boolean;
|
||||
crosshair: boolean;
|
||||
time: boolean;
|
||||
dateRange: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_SYNC: SyncOptions = {
|
||||
symbol: false,
|
||||
interval: false,
|
||||
crosshair: true,
|
||||
time: false,
|
||||
dateRange: false,
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 업비트 마켓 코드 ↔ 한글명 캐시
|
||||
* MarketSearchPanel 에서 market/all API 결과를 저장하고
|
||||
* Toolbar 등에서 getKoreanName() 으로 조회합니다.
|
||||
*/
|
||||
|
||||
const CACHE_KEY = 'upbit_market_names_v1';
|
||||
|
||||
// 앱 시작 시 localStorage에서 복원
|
||||
let _cache: Record<string, string> = {};
|
||||
try {
|
||||
_cache = JSON.parse(localStorage.getItem(CACHE_KEY) ?? '{}');
|
||||
} catch { /* ignore */ }
|
||||
|
||||
/** market/all 로드 후 한꺼번에 저장 */
|
||||
export function setMarketNames(map: Record<string, string>): void {
|
||||
_cache = { ..._cache, ...map };
|
||||
try { localStorage.setItem(CACHE_KEY, JSON.stringify(_cache)); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* 마켓 코드 → 한글명
|
||||
* 캐시에 없으면 "BTC" 형식 fallback
|
||||
*/
|
||||
export function getKoreanName(market: string): string {
|
||||
return _cache[market] ?? market.replace(/^KRW-/, '');
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 관심종목(즐겨찾기) / 보유종목 관리
|
||||
* - DB(gc_watchlist)가 기준 저장소
|
||||
* - localStorage(gc_favorites)는 오프라인 미러·UI 동기화용
|
||||
*/
|
||||
|
||||
import { addWatchlistItem, removeWatchlistItem } from './backendApi';
|
||||
|
||||
const FAV_KEY = 'gc_favorites';
|
||||
const LEGACY_FAV_KEY = 'upbit_market_favorites';
|
||||
const HOLD_KEY = 'gc_holdings';
|
||||
|
||||
/** 관심종목 변경 시 MarketSearchPanel·App 등이 동기화하도록 발행 */
|
||||
export const FAVORITES_CHANGED_EVENT = 'gc-favorites-changed';
|
||||
|
||||
// ── 즐겨찾기 (관심종목) ─────────────────────────────────────────────────────
|
||||
|
||||
/** 구버전 MarketSearchPanel 키 → gc_favorites 로 1회 마이그레이션 */
|
||||
function migrateLegacyFavorites(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(FAV_KEY);
|
||||
if (raw) {
|
||||
const current = JSON.parse(raw) as string[];
|
||||
if (Array.isArray(current) && current.length > 0) return;
|
||||
}
|
||||
const legacy = localStorage.getItem(LEGACY_FAV_KEY);
|
||||
if (!legacy) return;
|
||||
const parsed = JSON.parse(legacy) as string[];
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
saveFavorites(parsed, false);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function getFavorites(): string[] {
|
||||
migrateLegacyFavorites();
|
||||
try { return JSON.parse(localStorage.getItem(FAV_KEY) ?? '[]'); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
export function saveFavorites(markets: string[], notify = true): void {
|
||||
try {
|
||||
localStorage.setItem(FAV_KEY, JSON.stringify(markets));
|
||||
if (notify) {
|
||||
window.dispatchEvent(new CustomEvent(FAVORITES_CHANGED_EVENT, { detail: markets }));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* 관심종목 토글 — DB 저장 + 실시간 전략 체크 대상 자동 연동(백엔드).
|
||||
* @returns 변경 후 관심종목 코드 목록
|
||||
*/
|
||||
export async function toggleFavorite(
|
||||
market: string,
|
||||
meta?: { koreanName?: string; englishName?: string },
|
||||
): Promise<string[]> {
|
||||
const list = getFavorites();
|
||||
let next: string[];
|
||||
if (list.includes(market)) {
|
||||
await removeWatchlistItem(market);
|
||||
next = list.filter(m => m !== market);
|
||||
} else {
|
||||
await addWatchlistItem({
|
||||
symbol: market,
|
||||
koreanName: meta?.koreanName,
|
||||
englishName: meta?.englishName,
|
||||
});
|
||||
next = [...list, market];
|
||||
}
|
||||
saveFavorites(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function isFavorite(market: string): boolean {
|
||||
return getFavorites().includes(market);
|
||||
}
|
||||
|
||||
// ── 보유종목 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function getHoldings(): string[] {
|
||||
try { return JSON.parse(localStorage.getItem(HOLD_KEY) ?? '[]'); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
export function saveHoldings(markets: string[]): void {
|
||||
try { localStorage.setItem(HOLD_KEY, JSON.stringify(markets)); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** 추가 → 변경 후 전체 목록 반환 (중복 무시) */
|
||||
export function addHolding(market: string): string[] {
|
||||
const list = getHoldings();
|
||||
if (list.includes(market)) return list;
|
||||
const next = [...list, market];
|
||||
saveHoldings(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 제거 → 변경 후 전체 목록 반환 */
|
||||
export function removeHolding(market: string): string[] {
|
||||
const next = getHoldings().filter(m => m !== market);
|
||||
saveHoldings(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function isHolding(market: string): boolean {
|
||||
return getHoldings().includes(market);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 역할·메뉴 접근 권한
|
||||
*/
|
||||
import type { MenuPage } from '../components/TopMenuBar';
|
||||
|
||||
export type UserRole = 'ADMIN' | 'USER' | 'GUEST';
|
||||
|
||||
export type TopMenuId = MenuPage;
|
||||
|
||||
export type SettingsCategoryId =
|
||||
| 'general' | 'chart' | 'indicators' | 'backtest' | 'strategy'
|
||||
| 'paper' | 'alert' | 'network' | 'admin';
|
||||
|
||||
export const TOP_MENU_IDS: TopMenuId[] = [
|
||||
'dashboard', 'chart', 'paper', 'strategy', 'backtest', 'settings',
|
||||
];
|
||||
|
||||
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
|
||||
'general', 'chart', 'indicators', 'backtest', 'strategy', 'paper', 'alert', 'network', 'admin',
|
||||
];
|
||||
|
||||
export const ALL_MENU_IDS = [
|
||||
'dashboard', 'chart', 'paper', 'strategy', 'backtest', 'notifications', 'settings',
|
||||
...SETTINGS_MENU_IDS.map(id => `settings_${id}` as const),
|
||||
] as const;
|
||||
|
||||
export type MenuPermissionId = typeof ALL_MENU_IDS[number];
|
||||
|
||||
export const MENU_LABELS: Record<string, string> = {
|
||||
dashboard: '대시보드',
|
||||
chart: '실시간차트',
|
||||
paper: '모의투자',
|
||||
strategy: '투자전략',
|
||||
backtest: '백테스팅',
|
||||
notifications: '알림',
|
||||
settings: '설정',
|
||||
settings_general: '설정 · 일반',
|
||||
settings_chart: '설정 · 차트',
|
||||
settings_indicators: '설정 · 보조지표',
|
||||
settings_backtest: '설정 · 백테스팅',
|
||||
settings_strategy: '설정 · 전략',
|
||||
settings_paper: '설정 · 모의투자',
|
||||
settings_alert: '설정 · 알림',
|
||||
settings_network: '설정 · 네트워크',
|
||||
settings_admin: '설정 · 관리자',
|
||||
};
|
||||
|
||||
export const ROLE_LABELS: Record<UserRole, string> = {
|
||||
ADMIN: '관리자',
|
||||
USER: '사용자',
|
||||
GUEST: '게스트',
|
||||
};
|
||||
|
||||
export interface MenuPermissionsDto {
|
||||
role: UserRole;
|
||||
permissions: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export function settingsCategoryToMenuId(cat: SettingsCategoryId): string {
|
||||
return `settings_${cat}`;
|
||||
}
|
||||
|
||||
export function canAccessMenu(
|
||||
permissions: Record<string, boolean> | null | undefined,
|
||||
menuId: string,
|
||||
): boolean {
|
||||
if (!permissions) return menuId === 'chart';
|
||||
return permissions[menuId] === true;
|
||||
}
|
||||
|
||||
export function firstAllowedTopMenu(
|
||||
permissions: Record<string, boolean> | null | undefined,
|
||||
): TopMenuId {
|
||||
for (const id of TOP_MENU_IDS) {
|
||||
if (canAccessMenu(permissions, id)) return id;
|
||||
}
|
||||
return 'chart';
|
||||
}
|
||||
|
||||
export function normalizeRole(role?: string | null): UserRole {
|
||||
const r = (role ?? 'GUEST').toUpperCase();
|
||||
if (r === 'ADMIN' || r === 'USER' || r === 'GUEST') return r;
|
||||
return 'USER';
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { HLineStyle } from './indicatorRegistry';
|
||||
|
||||
/** 업비트/트레이딩뷰 스타일 색상 팔레트 (10×7) */
|
||||
export const COLOR_PALETTE: string[] = [
|
||||
'#FFFFFF', '#F5F5F5', '#E0E3EB', '#B2B5BE', '#9598A1', '#787B86', '#5D606B', '#434651', '#2A2E39', '#131722',
|
||||
'#FCE4EC', '#FFCDD2', '#EF9A9A', '#E57373', '#EF5350', '#F44336', '#E53935', '#D32F2F', '#C62828', '#B71C1C',
|
||||
'#FFF3E0', '#FFE0B2', '#FFCC80', '#FFB74D', '#FFA726', '#FF9800', '#FB8C00', '#F57C00', '#EF6C00', '#E65100',
|
||||
'#FFFDE7', '#FFF9C4', '#FFF59D', '#FFF176', '#FFEE58', '#FFEB3B', '#FDD835', '#FBC02D', '#F9A825', '#F57F17',
|
||||
'#E8F5E9', '#C8E6C9', '#A5D6A7', '#81C784', '#66BB6A', '#4CAF50', '#43A047', '#388E3C', '#2E7D32', '#1B5E20',
|
||||
'#E0F7FA', '#B2EBF2', '#80DEEA', '#4DD0E1', '#26C6DA', '#00BCD4', '#00ACC1', '#0097A7', '#00838F', '#006064',
|
||||
'#E3F2FD', '#BBDEFB', '#90CAF9', '#64B5F6', '#42A5F5', '#2196F3', '#1E88E5', '#1976D2', '#1565C0', '#0D47A1',
|
||||
];
|
||||
|
||||
export function parsePlotColor(value: string): { hex6: string; alpha: number } {
|
||||
const v = value.trim();
|
||||
if (v.length >= 9 && v.startsWith('#')) {
|
||||
const hex6 = v.slice(0, 7);
|
||||
const alpha = Math.round(parseInt(v.slice(7, 9), 16) / 255 * 100);
|
||||
return { hex6, alpha: Math.max(0, Math.min(100, alpha)) };
|
||||
}
|
||||
if (v.length >= 7 && v.startsWith('#')) {
|
||||
return { hex6: v.slice(0, 7), alpha: 100 };
|
||||
}
|
||||
return { hex6: '#2962FF', alpha: 100 };
|
||||
}
|
||||
|
||||
export function formatPlotColor(hex6: string, alphaPercent: number): string {
|
||||
const h = hex6.length >= 7 ? hex6.slice(0, 7) : '#2962FF';
|
||||
const a = Math.max(0, Math.min(100, alphaPercent));
|
||||
const alphaHex = Math.round(a / 100 * 255).toString(16).padStart(2, '0');
|
||||
return h + alphaHex;
|
||||
}
|
||||
|
||||
export function plotColorCss(value: string): string {
|
||||
const { hex6, alpha } = parsePlotColor(value);
|
||||
if (alpha >= 100) return hex6;
|
||||
const r = parseInt(hex6.slice(1, 3), 16);
|
||||
const g = parseInt(hex6.slice(3, 5), 16);
|
||||
const b = parseInt(hex6.slice(5, 7), 16);
|
||||
return `rgba(${r},${g},${b},${(alpha / 100).toFixed(2)})`;
|
||||
}
|
||||
|
||||
export const LINE_WIDTH_OPTIONS = [1, 2, 3, 4] as const;
|
||||
export const LINE_STYLE_OPTIONS: HLineStyle[] = ['solid', 'dashed', 'dotted'];
|
||||
|
||||
export const LINE_STYLE_LABELS: Record<HLineStyle, string> = {
|
||||
solid: '실선',
|
||||
dashed: '점선',
|
||||
dotted: '도트',
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 마우스·터치·펜 입력을 통합한 드래그/포인터 좌표 유틸
|
||||
*/
|
||||
|
||||
export interface ClientXY {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
type ReactPointerLike =
|
||||
| React.MouseEvent
|
||||
| React.TouchEvent
|
||||
| React.PointerEvent;
|
||||
|
||||
/** React 합성 이벤트에서 client 좌표 추출 */
|
||||
export function clientXYFromReact(e: ReactPointerLike): ClientXY {
|
||||
if ('touches' in e && e.touches.length > 0) {
|
||||
return { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||
}
|
||||
if ('changedTouches' in e && e.changedTouches.length > 0) {
|
||||
return { x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY };
|
||||
}
|
||||
const pe = e as React.MouseEvent | React.PointerEvent;
|
||||
return { x: pe.clientX, y: pe.clientY };
|
||||
}
|
||||
|
||||
/** window/document 네이티브 이벤트에서 client 좌표 추출 */
|
||||
export function clientXYFromNative(e: Event): ClientXY | null {
|
||||
if (e instanceof TouchEvent) {
|
||||
const t = e.touches[0] ?? e.changedTouches[0];
|
||||
if (!t) return null;
|
||||
return { x: t.clientX, y: t.clientY };
|
||||
}
|
||||
if (e instanceof MouseEvent || e instanceof PointerEvent) {
|
||||
return { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface BindWindowDragOptions {
|
||||
/** 터치 드래그 중 페이지 스크롤 방지 (기본 true) */
|
||||
preventTouchScroll?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 드래그 시작 후 window 에 move/end 리스너 등록.
|
||||
* pointer + mouse + touch 모두 수신 (구형 브라우저·iOS 대응).
|
||||
*/
|
||||
export function bindWindowDrag(
|
||||
onMove: (xy: ClientXY, ev: Event) => void,
|
||||
onEnd: () => void,
|
||||
options: BindWindowDragOptions = {},
|
||||
): () => void {
|
||||
const { preventTouchScroll = true } = options;
|
||||
|
||||
const move = (ev: Event) => {
|
||||
const xy = clientXYFromNative(ev);
|
||||
if (!xy) return;
|
||||
if (preventTouchScroll && ev.cancelable && (ev instanceof TouchEvent || ev.type === 'touchmove')) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
onMove(xy, ev);
|
||||
};
|
||||
|
||||
const end = () => onEnd();
|
||||
|
||||
window.addEventListener('pointermove', move);
|
||||
window.addEventListener('pointerup', end);
|
||||
window.addEventListener('pointercancel', end);
|
||||
window.addEventListener('mousemove', move);
|
||||
window.addEventListener('mouseup', end);
|
||||
window.addEventListener('touchmove', move, { passive: !preventTouchScroll });
|
||||
window.addEventListener('touchend', end);
|
||||
window.addEventListener('touchcancel', end);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', move);
|
||||
window.removeEventListener('pointerup', end);
|
||||
window.removeEventListener('pointercancel', end);
|
||||
window.removeEventListener('mousemove', move);
|
||||
window.removeEventListener('mouseup', end);
|
||||
window.removeEventListener('touchmove', move);
|
||||
window.removeEventListener('touchend', end);
|
||||
window.removeEventListener('touchcancel', end);
|
||||
};
|
||||
}
|
||||
|
||||
/** 드래그 핸들(타이틀바 등)에 붙일 공통 스타일 */
|
||||
export const DRAG_HANDLE_TOUCH_STYLE: React.CSSProperties = {
|
||||
touchAction: 'none',
|
||||
};
|
||||
|
||||
/** primary 버튼(마우스 왼쪽 / 터치)만 허용 */
|
||||
export function isPrimaryPointerButton(e: { button: number; pointerType: string }): boolean {
|
||||
return e.button === 0;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 심리도 / 투자심리도 (업비트)
|
||||
* - 심리도: N일 중 종가 상승일 비율 × 100
|
||||
* - 투자심리도: N일 중 상승일 거래량 비중 × 100
|
||||
*/
|
||||
|
||||
export const PSYCHOLOGICAL_DEFAULT_LENGTH = 12;
|
||||
export const INVEST_PSYCHOLOGICAL_DEFAULT_LENGTH = 10;
|
||||
|
||||
/** 레거시 type → 업비트 명칭 type */
|
||||
export function resolvePsychologicalIndicatorType(type: string): string {
|
||||
if (type === 'NewPsychological') return 'Psychological';
|
||||
return type;
|
||||
}
|
||||
|
||||
export function normalizePsychologicalParams(
|
||||
type: string,
|
||||
params: Record<string, number | string | boolean>,
|
||||
): Record<string, number | string | boolean> {
|
||||
const defaultLen =
|
||||
type === 'InvestPsychological'
|
||||
? INVEST_PSYCHOLOGICAL_DEFAULT_LENGTH
|
||||
: PSYCHOLOGICAL_DEFAULT_LENGTH;
|
||||
const length = Math.max(1, Number(params.length ?? defaultLen) || defaultLen);
|
||||
return { ...params, length };
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Upbit 캔들 REST 요청 중복 제거(deduplication) + 단기 캐시
|
||||
*
|
||||
* 멀티차트 모드에서 동일한 종목/타임프레임을 여러 슬롯이 동시에 요청하면
|
||||
* 단 1개의 HTTP 요청만 실행하고 나머지는 같은 Promise를 공유한다.
|
||||
* 데이터는 CACHE_TTL(30초) 동안 메모리에 보관해 재요청을 방지한다.
|
||||
*
|
||||
* ┌ 슬롯0 ─── fetchUpbitCandlesCached('KRW-BTC', '1D', 300) ───┐
|
||||
* ├ 슬롯1 ─── fetchUpbitCandlesCached('KRW-BTC', '1D', 300) ───┼─▶ fetchUpbitCandles 1회만 실행
|
||||
* └ 슬롯2 ─── fetchUpbitCandlesCached('KRW-BTC', '1D', 300) ───┘
|
||||
*/
|
||||
import type { OHLCVBar, Timeframe } from '../types';
|
||||
import { fetchUpbitCandles } from './upbitApi';
|
||||
|
||||
// ─── 과거(이전 시점) 데이터 전용 캐시 ─────────────────────────────────────────
|
||||
// 역사 캔들은 변하지 않으므로 일반 30초보다 긴 TTL 을 사용한다.
|
||||
const HISTORY_CACHE_TTL = 5 * 60_000; // 5분
|
||||
|
||||
const historyCache = new Map<string, { data: OHLCVBar[]; fetchedAt: number }>();
|
||||
const historyPending = new Map<string, Promise<OHLCVBar[]>>();
|
||||
|
||||
function historyKey(market: string, timeframe: Timeframe, count: number, before: string) {
|
||||
return `${market.toUpperCase()}:${timeframe}:${count}:${before}`;
|
||||
}
|
||||
|
||||
/** 캐시 유효 기간 (ms) */
|
||||
const CACHE_TTL = 30_000;
|
||||
|
||||
interface CacheEntry {
|
||||
data: OHLCVBar[];
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
// 완료된 요청 캐시 (market:timeframe:count → entry)
|
||||
const dataCache = new Map<string, CacheEntry>();
|
||||
|
||||
// 진행 중인 요청 (중복 방지용, market:timeframe:count → Promise)
|
||||
const pendingMap = new Map<string, Promise<OHLCVBar[]>>();
|
||||
|
||||
function cacheKey(market: string, timeframe: Timeframe, count: number): string {
|
||||
return `${market.toUpperCase()}:${timeframe}:${count}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시·in-flight 중복 제거를 적용한 캔들 요청.
|
||||
* - 동일 인자로 동시 호출 시: 첫 번째 요청 Promise 를 공유한다.
|
||||
* - CACHE_TTL 이내 재호출 시: 캐시된 데이터를 즉시 반환한다.
|
||||
* - invalidate=true 로 호출 시: 캐시를 무효화하고 새 요청을 실행한다.
|
||||
*/
|
||||
export async function fetchUpbitCandlesCached(
|
||||
market: string,
|
||||
timeframe: Timeframe,
|
||||
count: number,
|
||||
invalidate = false,
|
||||
): Promise<OHLCVBar[]> {
|
||||
const key = cacheKey(market, timeframe, count);
|
||||
|
||||
// 캐시 무효화 요청이면 기존 항목 삭제
|
||||
if (invalidate) {
|
||||
dataCache.delete(key);
|
||||
pendingMap.delete(key);
|
||||
}
|
||||
|
||||
// 유효한 캐시가 있으면 즉시 반환
|
||||
const cached = dataCache.get(key);
|
||||
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// 이미 진행 중인 요청이 있으면 공유
|
||||
const pending = pendingMap.get(key);
|
||||
if (pending) return pending;
|
||||
|
||||
// 새 요청 실행
|
||||
const promise = fetchUpbitCandles(market, timeframe, count)
|
||||
.then(data => {
|
||||
dataCache.set(key, { data, fetchedAt: Date.now() });
|
||||
pendingMap.delete(key);
|
||||
return data;
|
||||
})
|
||||
.catch(err => {
|
||||
// 실패 시 pending 제거 → 다음 호출이 재시도할 수 있도록
|
||||
pendingMap.delete(key);
|
||||
throw err;
|
||||
});
|
||||
|
||||
pendingMap.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** 특정 종목의 캐시 항목을 모두 삭제 (종목 변경 시 호출) */
|
||||
export function invalidateMarketCache(market: string): void {
|
||||
const prefix = market.toUpperCase() + ':';
|
||||
for (const key of [...dataCache.keys(), ...pendingMap.keys()]) {
|
||||
if (key.startsWith(prefix)) {
|
||||
dataCache.delete(key);
|
||||
pendingMap.delete(key);
|
||||
}
|
||||
}
|
||||
// 역사 캐시도 함께 삭제
|
||||
for (const key of [...historyCache.keys(), ...historyPending.keys()]) {
|
||||
if (key.startsWith(prefix)) {
|
||||
historyCache.delete(key);
|
||||
historyPending.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 시각 이전 캔들 페치 (캐시·in-flight 중복 제거 적용).
|
||||
*
|
||||
* @param before - ISO 8601 UTC exclusive (예: '2024-01-01T00:00:00')
|
||||
* candleEndpoint 에서 'Z' 를 붙여 요청하므로 'Z' 없이 전달한다.
|
||||
*/
|
||||
export async function fetchUpbitCandlesBeforeCached(
|
||||
market: string,
|
||||
timeframe: Timeframe,
|
||||
count: number,
|
||||
before: string,
|
||||
): Promise<OHLCVBar[]> {
|
||||
const key = historyKey(market, timeframe, count, before);
|
||||
|
||||
const cached = historyCache.get(key);
|
||||
if (cached && Date.now() - cached.fetchedAt < HISTORY_CACHE_TTL) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const pending = historyPending.get(key);
|
||||
if (pending) return pending;
|
||||
|
||||
const promise = fetchUpbitCandles(market, timeframe, count, before)
|
||||
.then(data => {
|
||||
historyCache.set(key, { data, fetchedAt: Date.now() });
|
||||
historyPending.delete(key);
|
||||
return data;
|
||||
})
|
||||
.catch(err => {
|
||||
historyPending.delete(key);
|
||||
throw err;
|
||||
});
|
||||
|
||||
historyPending.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 단순이동평균(SMA) — MA1~MA11 다중선 설정 및 계산
|
||||
*/
|
||||
import type { OHLCVBar } from '../types';
|
||||
import type { IndicatorConfig } from '../types';
|
||||
import type { PlotDef } from './indicatorRegistry';
|
||||
import type { IndicatorResult } from './indicatorRegistry';
|
||||
|
||||
export const SMA_MA_COUNT = 11;
|
||||
|
||||
/** 소스 기본값 — 종가 */
|
||||
export const SMA_DEFAULT_SRC = 'close' as const;
|
||||
|
||||
export const SMA_SRC_OPTIONS = [
|
||||
'close', 'open', 'high', 'low', 'hl2', 'hlc3', 'ohlc4',
|
||||
] as const;
|
||||
|
||||
export type SmaSource = (typeof SMA_SRC_OPTIONS)[number];
|
||||
|
||||
/** MA1~MA11 기본 기간 */
|
||||
export const SMA_DEFAULT_PERIODS = [5, 12, 16, 20, 26, 34, 50, 60, 72, 90, 120] as const;
|
||||
|
||||
/** MA1~MA11 기본 색상 */
|
||||
export const SMA_DEFAULT_COLORS = [
|
||||
'#2962FF', '#FF6D00', '#4CAF50', '#E91E63',
|
||||
'#9C27B0', '#00BCD4', '#FFC107', '#795548',
|
||||
'#607D8B', '#3F51B5', '#8BC34A',
|
||||
] as const;
|
||||
|
||||
/** MA1~MA5 기본 ON, MA6~11 OFF */
|
||||
export const SMA_DEFAULT_ENABLED = [
|
||||
true, true, true, true, true, false, false, false, false, false, false,
|
||||
] as const;
|
||||
|
||||
export function smaPlotId(index: number): string {
|
||||
return `plot${index}`;
|
||||
}
|
||||
|
||||
export function smaPeriodKey(maIndex: number): string {
|
||||
return `period${maIndex}`;
|
||||
}
|
||||
|
||||
export function createDefaultSmaPlots(): PlotDef[] {
|
||||
return SMA_DEFAULT_PERIODS.map((_, i) => ({
|
||||
id: smaPlotId(i),
|
||||
title: `MA${i + 1}`,
|
||||
color: SMA_DEFAULT_COLORS[i],
|
||||
type: 'line',
|
||||
lineWidth: 2,
|
||||
lineStyle: 'solid',
|
||||
}));
|
||||
}
|
||||
|
||||
/** 저장·레거시 파라미터에서 유효한 SMA 소스 키 반환 (기본: 종가) */
|
||||
export function resolveSmaSource(
|
||||
params: Record<string, number | string | boolean> | undefined,
|
||||
): SmaSource {
|
||||
const raw = params?.src ?? params?.source;
|
||||
const s = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
|
||||
if ((SMA_SRC_OPTIONS as readonly string[]).includes(s)) {
|
||||
return s as SmaSource;
|
||||
}
|
||||
return SMA_DEFAULT_SRC;
|
||||
}
|
||||
|
||||
export function createDefaultSmaParams(): Record<string, number | string | boolean> {
|
||||
const params: Record<string, number | string | boolean> = { src: SMA_DEFAULT_SRC };
|
||||
for (let i = 1; i <= SMA_MA_COUNT; i++) {
|
||||
params[smaPeriodKey(i)] = SMA_DEFAULT_PERIODS[i - 1];
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
export function createDefaultSmaPlotVisibility(): Record<string, boolean> {
|
||||
const vis: Record<string, boolean> = {};
|
||||
for (let i = 0; i < SMA_MA_COUNT; i++) {
|
||||
vis[smaPlotId(i)] = SMA_DEFAULT_ENABLED[i];
|
||||
}
|
||||
return vis;
|
||||
}
|
||||
|
||||
/**
|
||||
* 레거시 단일 len 파라미터·plot0 단일 플롯 → MA1~11 구성으로 정규화
|
||||
*/
|
||||
export function normalizeSmaConfig(config: IndicatorConfig): IndicatorConfig {
|
||||
if (config.type !== 'SMA') return config;
|
||||
|
||||
const params: Record<string, number | string | boolean> = {
|
||||
...createDefaultSmaParams(),
|
||||
...config.params,
|
||||
};
|
||||
|
||||
// 구버전 len → period1
|
||||
const legacyLen = params.len;
|
||||
if (legacyLen != null && typeof legacyLen === 'number') {
|
||||
if (params.period1 == null || params.period1 === SMA_DEFAULT_PERIODS[0]) {
|
||||
params.period1 = legacyLen;
|
||||
}
|
||||
}
|
||||
delete params.len;
|
||||
delete params.source;
|
||||
params.src = resolveSmaSource(params);
|
||||
|
||||
for (let i = 1; i <= SMA_MA_COUNT; i++) {
|
||||
const key = smaPeriodKey(i);
|
||||
const v = params[key];
|
||||
if (typeof v !== 'number' || v < 1) {
|
||||
params[key] = SMA_DEFAULT_PERIODS[i - 1];
|
||||
}
|
||||
}
|
||||
|
||||
const defaultPlots = createDefaultSmaPlots();
|
||||
const savedById = new Map((config.plots ?? []).map(p => [p.id, p]));
|
||||
const plots: PlotDef[] = defaultPlots.map((def, i) => {
|
||||
const saved = savedById.get(def.id) ?? config.plots?.[i];
|
||||
return saved
|
||||
? { ...def, ...saved, id: def.id, title: `MA${i + 1}`, type: 'line' as const }
|
||||
: def;
|
||||
});
|
||||
|
||||
const plotVisibility: Record<string, boolean> = {
|
||||
...createDefaultSmaPlotVisibility(),
|
||||
...config.plotVisibility,
|
||||
};
|
||||
for (let i = 0; i < SMA_MA_COUNT; i++) {
|
||||
const id = smaPlotId(i);
|
||||
if (plotVisibility[id] === undefined) {
|
||||
plotVisibility[id] = SMA_DEFAULT_ENABLED[i];
|
||||
}
|
||||
}
|
||||
|
||||
return { ...config, params, plots, plotVisibility };
|
||||
}
|
||||
|
||||
function sourceValues(bars: OHLCVBar[], src: string): number[] {
|
||||
switch (src) {
|
||||
case 'open': return bars.map(b => b.open);
|
||||
case 'high': return bars.map(b => b.high);
|
||||
case 'low': return bars.map(b => b.low);
|
||||
case 'hl2': return bars.map(b => (b.high + b.low) / 2);
|
||||
case 'hlc3': return bars.map(b => (b.high + b.low + b.close) / 3);
|
||||
case 'ohlc4': return bars.map(b => (b.open + b.high + b.low + b.close) / 4);
|
||||
default: return bars.map(b => b.close);
|
||||
}
|
||||
}
|
||||
|
||||
function computeSma(values: number[], period: number): (number | null)[] {
|
||||
const p = Math.max(1, Math.floor(period));
|
||||
const out: (number | null)[] = [];
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
if (i < p - 1) {
|
||||
out.push(null);
|
||||
continue;
|
||||
}
|
||||
let sum = 0;
|
||||
for (let j = i - p + 1; j <= i; j++) sum += values[j];
|
||||
out.push(sum / p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** MA1~MA11 각각 plot0~plot10 데이터 계산 */
|
||||
export function calculateSmaMulti(
|
||||
bars: OHLCVBar[],
|
||||
params: Record<string, number | string | boolean>
|
||||
): IndicatorResult {
|
||||
const src = resolveSmaSource(params);
|
||||
const values = sourceValues(bars, src);
|
||||
const plots: IndicatorResult = {};
|
||||
|
||||
for (let i = 0; i < SMA_MA_COUNT; i++) {
|
||||
const periodKey = smaPeriodKey(i + 1);
|
||||
const period = Number(params[periodKey] ?? SMA_DEFAULT_PERIODS[i]);
|
||||
const sma = computeSma(values, period);
|
||||
const plotId = smaPlotId(i);
|
||||
plots[plotId] = [];
|
||||
for (let j = 0; j < bars.length; j++) {
|
||||
const v = sma[j];
|
||||
if (v == null || Number.isNaN(v)) continue;
|
||||
plots[plotId].push({ time: bars[j].time, value: v });
|
||||
}
|
||||
}
|
||||
|
||||
return plots;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 차트·실시간 전략용 STOMP 단일 연결 (SockJS 1회).
|
||||
* 훅마다 StompClient 를 만들면 "closed before connection is established" 가 반복된다.
|
||||
*/
|
||||
import { Client as StompClient, IMessage, StompSubscription } from '@stomp/stompjs';
|
||||
import SockJS from 'sockjs-client';
|
||||
import { getStompSockJsUrl } from './backendApi';
|
||||
|
||||
type MessageHandler = (msg: IMessage) => void;
|
||||
|
||||
interface TopicEntry {
|
||||
handlers: Set<MessageHandler>;
|
||||
stompSub?: StompSubscription;
|
||||
}
|
||||
|
||||
let client: StompClient | null = null;
|
||||
let connected = false;
|
||||
const topics = new Map<string, TopicEntry>();
|
||||
let disconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function handlerCount(): number {
|
||||
let n = 0;
|
||||
for (const entry of topics.values()) n += entry.handlers.size;
|
||||
return n;
|
||||
}
|
||||
|
||||
function cancelPendingDisconnect(): void {
|
||||
if (disconnectTimer) {
|
||||
clearTimeout(disconnectTimer);
|
||||
disconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function resubscribeAll(): void {
|
||||
if (!client?.connected) return;
|
||||
for (const [topic, entry] of topics) {
|
||||
if (entry.handlers.size === 0) continue;
|
||||
entry.stompSub?.unsubscribe();
|
||||
entry.stompSub = client.subscribe(topic, msg => {
|
||||
entry.handlers.forEach(h => h(msg));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getOrCreateClient(): StompClient {
|
||||
if (client) return client;
|
||||
|
||||
client = new StompClient({
|
||||
webSocketFactory: () => new SockJS(getStompSockJsUrl()),
|
||||
reconnectDelay: 5000,
|
||||
heartbeatIncoming: 10_000,
|
||||
heartbeatOutgoing: 10_000,
|
||||
onConnect: () => {
|
||||
connected = true;
|
||||
cancelPendingDisconnect();
|
||||
resubscribeAll();
|
||||
},
|
||||
onDisconnect: () => {
|
||||
connected = false;
|
||||
for (const entry of topics.values()) {
|
||||
entry.stompSub = undefined;
|
||||
}
|
||||
},
|
||||
onStompError: frame => {
|
||||
console.warn('[stompChartBroker] STOMP error', frame.headers?.message ?? frame);
|
||||
},
|
||||
onWebSocketError: ev => {
|
||||
console.warn('[stompChartBroker] WebSocket error', getStompSockJsUrl(), ev);
|
||||
},
|
||||
onWebSocketClose: () => {
|
||||
connected = false;
|
||||
for (const entry of topics.values()) {
|
||||
entry.stompSub = undefined;
|
||||
}
|
||||
},
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
function ensureActive(): void {
|
||||
cancelPendingDisconnect();
|
||||
const c = getOrCreateClient();
|
||||
if (!c.active) c.activate();
|
||||
}
|
||||
|
||||
function scheduleDisconnect(): void {
|
||||
cancelPendingDisconnect();
|
||||
disconnectTimer = setTimeout(() => {
|
||||
disconnectTimer = null;
|
||||
if (handlerCount() > 0) return;
|
||||
if (!client?.active) return;
|
||||
// 연결 수립 중 deactivate 하면 "closed before connection is established" 발생
|
||||
if (!connected) {
|
||||
scheduleDisconnect();
|
||||
return;
|
||||
}
|
||||
client.deactivate();
|
||||
connected = false;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* STOMP 토픽 구독 (참조 카운트). 반환 함수로 해제.
|
||||
*/
|
||||
export function subscribeStompTopic(topic: string, handler: MessageHandler): () => void {
|
||||
let entry = topics.get(topic);
|
||||
if (!entry) {
|
||||
entry = { handlers: new Set() };
|
||||
topics.set(topic, entry);
|
||||
}
|
||||
entry.handlers.add(handler);
|
||||
|
||||
if (connected && client && entry.handlers.size === 1) {
|
||||
entry.stompSub = client.subscribe(topic, msg => {
|
||||
entry!.handlers.forEach(h => h(msg));
|
||||
});
|
||||
}
|
||||
|
||||
ensureActive();
|
||||
|
||||
return () => {
|
||||
const e = topics.get(topic);
|
||||
if (!e) return;
|
||||
e.handlers.delete(handler);
|
||||
if (e.handlers.size === 0) {
|
||||
e.stompSub?.unsubscribe();
|
||||
topics.delete(topic);
|
||||
scheduleDisconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function isStompChartConnected(): boolean {
|
||||
return connected && Boolean(client?.connected);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { IMessage } from '@stomp/stompjs';
|
||||
|
||||
/** STOMP MESSAGE body — 텍스트·바이너리(UTF-8 JSON) 모두 처리 */
|
||||
export function stompMessageBody(msg: IMessage): string {
|
||||
if (msg.body && msg.body.length > 0) return msg.body;
|
||||
const bin = msg.binaryBody;
|
||||
if (bin && bin.length > 0) {
|
||||
return new TextDecoder('utf-8').decode(bin);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function parseStompJson<T>(msg: IMessage): T | null {
|
||||
const raw = stompMessageBody(msg);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { IndicatorConfig, Theme, ChartType, Timeframe, MainChartStyle } from '../types';
|
||||
|
||||
interface ChartState {
|
||||
version: number;
|
||||
symbol: string;
|
||||
timeframe: Timeframe;
|
||||
chartType: ChartType;
|
||||
theme: Theme;
|
||||
indicators: IndicatorConfig[];
|
||||
watchlist: string[];
|
||||
alertPrices: number[];
|
||||
mainChartStyle?: MainChartStyle;
|
||||
}
|
||||
|
||||
const KEY = 'trading_chart_state';
|
||||
const CUR_VERSION = 8; // v8: 기본 지표 제거 — 초기 로딩 시 캔들차트만 표시
|
||||
|
||||
/** 기본 보조지표 없음 — 처음 로딩 시 캔들차트만 표시 */
|
||||
const DEFAULT_INDICATORS: IndicatorConfig[] = [];
|
||||
|
||||
const DEFAULT_WATCHLIST = [
|
||||
'KRW-BTC', 'KRW-ETH', 'KRW-XRP', 'KRW-SOL', 'KRW-DOGE',
|
||||
'KRW-ADA', 'KRW-AVAX', 'KRW-DOT',
|
||||
];
|
||||
|
||||
/**
|
||||
* 절대적 최후 fallback 기본값.
|
||||
* 우선순위: DB(gc_app_settings) > localStorage > 이 값.
|
||||
* 타임프레임을 '1h' 에서 '1D' 로 통일 (ChartSlot 기본값과 일치).
|
||||
*/
|
||||
const DEFAULT_STATE: ChartState = {
|
||||
version: CUR_VERSION,
|
||||
symbol: 'KRW-BTC',
|
||||
timeframe: '1D',
|
||||
chartType: 'candlestick',
|
||||
theme: 'dark',
|
||||
indicators: DEFAULT_INDICATORS,
|
||||
watchlist: DEFAULT_WATCHLIST,
|
||||
alertPrices: [],
|
||||
};
|
||||
|
||||
export function saveState(state: Partial<ChartState>): void {
|
||||
try {
|
||||
const existing = loadState();
|
||||
localStorage.setItem(KEY, JSON.stringify({ ...existing, ...state, version: CUR_VERSION }));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function loadState(): ChartState {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as ChartState;
|
||||
// 버전이 다르면 기본값으로 재설정 (마이그레이션)
|
||||
if (parsed.version !== CUR_VERSION) {
|
||||
localStorage.removeItem(KEY);
|
||||
return DEFAULT_STATE;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return DEFAULT_STATE;
|
||||
}
|
||||
|
||||
export function clearState(): void {
|
||||
localStorage.removeItem(KEY);
|
||||
}
|
||||
|
||||
export const DEFAULT_MAIN_CHART_STYLE: MainChartStyle = {
|
||||
upColor: '#ff6b6b',
|
||||
downColor: '#4dabf7',
|
||||
borderUpColor: '#ff6b6b',
|
||||
borderDownColor: '#4dabf7',
|
||||
wickUpColor: '#ff6b6b',
|
||||
wickDownColor: '#4dabf7',
|
||||
};
|
||||
|
||||
export { DEFAULT_INDICATORS };
|
||||
@@ -0,0 +1,279 @@
|
||||
// ── DSL 트리 타입 ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type LogicNodeType = 'AND' | 'OR' | 'NOT' | 'CONDITION';
|
||||
|
||||
export interface ConditionDSL {
|
||||
indicatorType: string;
|
||||
conditionType: string;
|
||||
targetValue?: number;
|
||||
period?: number;
|
||||
leftField?: string;
|
||||
rightField?: string;
|
||||
compareValue?: number;
|
||||
slopePeriod?: number;
|
||||
holdDays?: number;
|
||||
candleRange?: number;
|
||||
}
|
||||
|
||||
export interface LogicNode {
|
||||
id: string;
|
||||
type: LogicNodeType;
|
||||
children?: LogicNode[];
|
||||
condition?: ConditionDSL;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface StrategyDSLDto {
|
||||
id?: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
buyCondition?: LogicNode | null;
|
||||
sellCondition?: LogicNode | null;
|
||||
enabled?: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
// ── 지표별 조건 옵션 ──────────────────────────────────────────────────────────
|
||||
|
||||
export const CONDITION_LABEL: Record<string, string> = {
|
||||
GT: '초과(>)', LT: '미만(<)', GTE: '이상(≥)', LTE: '이하(≤)',
|
||||
EQ: '같음(=)', NEQ: '다름(≠)',
|
||||
CROSS_UP: '상향 돌파', CROSS_DOWN: '하향 돌파',
|
||||
SLOPE_UP: '상승 중', SLOPE_DOWN: '하락 중',
|
||||
DIFF_GT: '차이>값', DIFF_LT: '차이<값',
|
||||
HOLD_N_DAYS: 'N일 유지',
|
||||
ABOVE_CLOUD: '구름 위', BELOW_CLOUD: '구름 아래', IN_CLOUD: '구름 안',
|
||||
CLOUD_BREAK_UP: '구름 상향 돌파', CLOUD_BREAK_DOWN: '구름 하향 돌파',
|
||||
SPAN1_GT_SPAN2: '선행1>선행2', SPAN1_LT_SPAN2: '선행1<선행2',
|
||||
SPAN1_CROSS_UP_SPAN2: '선행1 상향교차', SPAN1_CROSS_DOWN_SPAN2: '선행1 하향교차',
|
||||
LAGGING_GT_PRICE: '후행>종가', LAGGING_LT_PRICE: '후행<종가',
|
||||
};
|
||||
|
||||
const COMMON_CONDITIONS = ['GT', 'LT', 'GTE', 'LTE', 'EQ', 'NEQ', 'CROSS_UP', 'CROSS_DOWN', 'SLOPE_UP', 'SLOPE_DOWN', 'DIFF_GT', 'DIFF_LT', 'HOLD_N_DAYS'];
|
||||
|
||||
export const INDICATOR_CONDITIONS: Record<string, string[]> = {
|
||||
RSI: COMMON_CONDITIONS, MACD: COMMON_CONDITIONS, CCI: COMMON_CONDITIONS,
|
||||
ADX: COMMON_CONDITIONS, BWI: COMMON_CONDITIONS, DMI: COMMON_CONDITIONS,
|
||||
OBV: COMMON_CONDITIONS, STOCHASTIC: COMMON_CONDITIONS, WILLIAMS_R: COMMON_CONDITIONS,
|
||||
TRIX: COMMON_CONDITIONS, VOLUME_OSC: COMMON_CONDITIONS, VR: COMMON_CONDITIONS,
|
||||
DISPARITY: COMMON_CONDITIONS, PSYCHOLOGICAL: COMMON_CONDITIONS,
|
||||
NEW_PSYCHOLOGICAL: COMMON_CONDITIONS, INVEST_PSYCHOLOGICAL: COMMON_CONDITIONS,
|
||||
VOLUME: COMMON_CONDITIONS,
|
||||
MA: COMMON_CONDITIONS, EMA: COMMON_CONDITIONS,
|
||||
BOLLINGER: COMMON_CONDITIONS,
|
||||
ICHIMOKU: [
|
||||
...COMMON_CONDITIONS,
|
||||
'ABOVE_CLOUD', 'BELOW_CLOUD', 'IN_CLOUD', 'CLOUD_BREAK_UP', 'CLOUD_BREAK_DOWN',
|
||||
'SPAN1_GT_SPAN2', 'SPAN1_LT_SPAN2', 'SPAN1_CROSS_UP_SPAN2', 'SPAN1_CROSS_DOWN_SPAN2',
|
||||
'LAGGING_GT_PRICE', 'LAGGING_LT_PRICE',
|
||||
],
|
||||
};
|
||||
|
||||
// ── 지표별 leftField 옵션 ───────────────────────────────────────────────────
|
||||
|
||||
export type FieldOption = { value: string; label: string };
|
||||
|
||||
export const LEFT_FIELD_OPTIONS: Record<string, FieldOption[]> = {
|
||||
RSI: [{ value: 'CURRENT', label: '현재 캔들' }],
|
||||
CCI: [{ value: 'CURRENT', label: '현재 캔들' }],
|
||||
ADX: [{ value: 'CURRENT', label: '현재 캔들' }],
|
||||
BWI: [{ value: 'CURRENT', label: '현재 캔들' }],
|
||||
DMI: [{ value: 'CURRENT', label: '현재 캔들' }, { value: 'PLUS_DI', label: '+DI' }, { value: 'MINUS_DI', label: '-DI' }],
|
||||
OBV: [{ value: 'CURRENT', label: 'OBV' }],
|
||||
STOCHASTIC: [{ value: 'K', label: '%K' }, { value: 'D', label: '%D' }],
|
||||
MACD: [{ value: 'MACD', label: 'MACD선' }, { value: 'SIGNAL', label: '시그널선' }, { value: 'HISTOGRAM', label: '히스토그램' }],
|
||||
WILLIAMS_R: [{ value: 'CURRENT', label: '현재 캔들' }],
|
||||
TRIX: [{ value: 'TRIX', label: 'TRIX' }, { value: 'SIGNAL', label: '시그널' }],
|
||||
VOLUME_OSC: [{ value: 'CURRENT', label: 'VolumeOSC' }],
|
||||
VR: [{ value: 'CURRENT', label: 'VR' }],
|
||||
DISPARITY: [{ value: 'CURRENT', label: '이격도' }],
|
||||
PSYCHOLOGICAL: [{ value: 'CURRENT', label: '심리도' }],
|
||||
NEW_PSYCHOLOGICAL: [{ value: 'CURRENT', label: '심리도' }],
|
||||
INVEST_PSYCHOLOGICAL: [{ value: 'CURRENT', label: '투자심리도' }],
|
||||
VOLUME: [{ value: 'CURRENT', label: '거래량' }],
|
||||
MA: [
|
||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||
{ value: 'MA5', label: 'MA(5일)' }, { value: 'MA10', label: 'MA(10일)' },
|
||||
{ value: 'MA20', label: 'MA(20일)' }, { value: 'MA60', label: 'MA(60일)' },
|
||||
{ value: 'MA120',label: 'MA(120일)'},
|
||||
],
|
||||
EMA: [
|
||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||
{ value: 'EMA5', label: 'EMA(5일)' }, { value: 'EMA10', label: 'EMA(10일)' },
|
||||
{ value: 'EMA20', label: 'EMA(20일)' }, { value: 'EMA60', label: 'EMA(60일)' },
|
||||
],
|
||||
BOLLINGER: [
|
||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||
{ value: 'UPPER', label: '상단밴드' }, { value: 'MIDDLE', label: '중단밴드' },
|
||||
{ value: 'LOWER', label: '하단밴드' },
|
||||
],
|
||||
ICHIMOKU: [
|
||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||
{ value: 'CONVERSION_LINE', label: '전환선' }, { value: 'BASE_LINE', label: '기준선' },
|
||||
{ value: 'LEADING_SPAN1', label: '선행1' }, { value: 'LEADING_SPAN2', label: '선행2' },
|
||||
{ value: 'LAGGING_SPAN', label: '후행스팬' },
|
||||
],
|
||||
};
|
||||
|
||||
export const RIGHT_FIELD_OPTIONS: Record<string, FieldOption[]> = {
|
||||
RSI: [
|
||||
{ value: 'OVERBOUGHT_70', label: '과매수(70)' }, { value: 'OVERBOUGHT_75', label: '과매수(75)' },
|
||||
{ value: 'NEUTRAL_50', label: '중립(50)' }, { value: 'OVERSOLD_30', label: '과매도(30)' },
|
||||
{ value: 'OVERSOLD_25', label: '과매도(25)' }, { value: 'CUSTOM', label: '직접 입력' },
|
||||
],
|
||||
CCI: [
|
||||
{ value: 'OVERBOUGHT_100', label: '과매수(100)' }, { value: 'NEUTRAL_0', label: '중립(0)' },
|
||||
{ value: 'OVERSOLD_NEG100',label: '과매도(-100)'}, { value: 'CUSTOM', label: '직접 입력' },
|
||||
],
|
||||
STOCHASTIC: [
|
||||
{ value: 'OVERBOUGHT_80', label: '과매수(80)' }, { value: 'NEUTRAL_50', label: '중립(50)' },
|
||||
{ value: 'OVERSOLD_20', label: '과매도(20)' }, { value: 'K', label: '%K' },
|
||||
{ value: 'D', label: '%D' }, { value: 'CUSTOM', label: '직접 입력' },
|
||||
],
|
||||
MACD: [
|
||||
{ value: 'SIGNAL', label: '시그널선' }, { value: 'ZERO_LINE', label: '0선' },
|
||||
{ value: 'CUSTOM', label: '직접 입력' },
|
||||
],
|
||||
WILLIAMS_R: [
|
||||
{ value: 'OVERBOUGHT_NEG20', label: '과매수(-20)' }, { value: 'NEUTRAL_NEG50', label: '중립(-50)' },
|
||||
{ value: 'OVERSOLD_NEG80', label: '과매도(-80)' }, { value: 'CUSTOM', label: '직접 입력' },
|
||||
],
|
||||
ADX: [
|
||||
{ value: 'ADX_25', label: '강한추세(25)' }, { value: 'ADX_20', label: '약한추세(20)' },
|
||||
{ value: 'CUSTOM', label: '직접 입력' },
|
||||
],
|
||||
PSYCHOLOGICAL: [
|
||||
{ value: 'OVERBOUGHT_75', label: '과매수(75)' }, { value: 'NEUTRAL_50', label: '중립(50)' },
|
||||
{ value: 'OVERSOLD_25', label: '과매도(25)' }, { value: 'CUSTOM', label: '직접 입력' },
|
||||
],
|
||||
NEW_PSYCHOLOGICAL: [
|
||||
{ value: 'OVERBOUGHT_75', label: '과매수(75)' }, { value: 'NEUTRAL_50', label: '중립(50)' },
|
||||
{ value: 'OVERSOLD_25', label: '과매도(25)' }, { value: 'CUSTOM', label: '직접 입력' },
|
||||
],
|
||||
INVEST_PSYCHOLOGICAL: [
|
||||
{ value: 'OVERBOUGHT_75', label: '과매수(75)' }, { value: 'NEUTRAL_50', label: '중립(50)' },
|
||||
{ value: 'OVERSOLD_25', label: '과매도(25)' }, { value: 'CUSTOM', label: '직접 입력' },
|
||||
],
|
||||
TRIX: [
|
||||
{ value: 'SIGNAL', label: '시그널' }, { value: 'ZERO_LINE', label: '0선' }, { value: 'CUSTOM', label: '직접 입력' },
|
||||
],
|
||||
VR: [
|
||||
{ value: 'OVERBOUGHT_200', label: '과매수(200)' }, { value: 'NEUTRAL_100', label: '기준(100)' },
|
||||
{ value: 'OVERSOLD_70', label: '과매도(70)' }, { value: 'CUSTOM', label: '직접 입력' },
|
||||
],
|
||||
MA: [
|
||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||
{ value: 'MA5', label: 'MA(5일)' }, { value: 'MA10', label: 'MA(10일)' },
|
||||
{ value: 'MA20', label: 'MA(20일)' }, { value: 'MA60', label: 'MA(60일)' },
|
||||
{ value: 'MA120',label: 'MA(120일)'}, { value: 'CUSTOM', label: '직접 입력' },
|
||||
],
|
||||
EMA: [
|
||||
{ value: 'CLOSE_PRICE', label: '종가' },
|
||||
{ value: 'EMA5', label: 'EMA(5일)' }, { value: 'EMA10', label: 'EMA(10일)' },
|
||||
{ value: 'EMA20', label: 'EMA(20일)' }, { value: 'EMA60', label: 'EMA(60일)' },
|
||||
{ value: 'CUSTOM', label: '직접 입력' },
|
||||
],
|
||||
BOLLINGER: [
|
||||
{ value: 'UPPER', label: '상단밴드' }, { value: 'MIDDLE', label: '중단밴드' },
|
||||
{ value: 'LOWER', label: '하단밴드' }, { value: 'CUSTOM', label: '직접 입력' },
|
||||
],
|
||||
ICHIMOKU: [
|
||||
{ value: 'CONVERSION_LINE', label: '전환선' }, { value: 'BASE_LINE', label: '기준선' },
|
||||
{ value: 'LEADING_SPAN1', label: '선행1' }, { value: 'LEADING_SPAN2', label: '선행2' },
|
||||
{ value: 'LAGGING_SPAN', label: '후행스팬' }, { value: 'CLOSE_PRICE', label: '종가' },
|
||||
],
|
||||
VOLUME: [{ value: 'MA5', label: 'MA(5일)' }, { value: 'MA20', label: 'MA(20일)' }, { value: 'CUSTOM', label: '직접 입력' }],
|
||||
DISPARITY: [{ value: 'NEUTRAL_100', label: '기준(100)' }, { value: 'OVERBOUGHT_105', label: '과매수(105)' }, { value: 'OVERSOLD_95', label: '과매도(95)' }, { value: 'CUSTOM', label: '직접 입력' }],
|
||||
};
|
||||
|
||||
// ── 지표 목록 (팔레트용) ───────────────────────────────────────────────────
|
||||
|
||||
export interface PaletteItem {
|
||||
id: string;
|
||||
type: 'operator' | 'indicator';
|
||||
label: string;
|
||||
value: string;
|
||||
color: 'green' | 'blue' | 'orange' | 'primary' | 'secondary';
|
||||
}
|
||||
|
||||
export const OPERATOR_ITEMS: PaletteItem[] = [
|
||||
{ id: 'op-and', type: 'operator', label: 'AND (그리고)', value: 'AND', color: 'green' },
|
||||
{ id: 'op-or', type: 'operator', label: 'OR (또는)', value: 'OR', color: 'blue' },
|
||||
{ id: 'op-not', type: 'operator', label: 'NOT (반대)', value: 'NOT', color: 'orange' },
|
||||
];
|
||||
|
||||
export const MA_BAND_ITEMS: PaletteItem[] = [
|
||||
{ id: 'ind-ma', type: 'indicator', label: 'MA (이동평균)', value: 'MA', color: 'primary' },
|
||||
{ id: 'ind-ema', type: 'indicator', label: 'EMA (지수이동평균)', value: 'EMA', color: 'primary' },
|
||||
{ id: 'ind-bollinger',type: 'indicator', label: '볼린저밴드', value: 'BOLLINGER', color: 'primary' },
|
||||
{ id: 'ind-ichimoku', type: 'indicator', label: '일목균형표', value: 'ICHIMOKU', color: 'primary' },
|
||||
];
|
||||
|
||||
export const INDICATOR_ITEMS: PaletteItem[] = [
|
||||
{ id: 'ind-psy', type: 'indicator', label: '심리도', value: 'PSYCHOLOGICAL', color: 'secondary' },
|
||||
{ id: 'ind-ipsy', type: 'indicator', label: '투자심리도', value: 'INVEST_PSYCHOLOGICAL', color: 'secondary' },
|
||||
{ id: 'ind-macd', type: 'indicator', label: 'MACD', value: 'MACD', color: 'secondary' },
|
||||
{ id: 'ind-cci', type: 'indicator', label: 'CCI', value: 'CCI', color: 'secondary' },
|
||||
{ id: 'ind-adx', type: 'indicator', label: 'ADX', value: 'ADX', color: 'secondary' },
|
||||
{ id: 'ind-bwi', type: 'indicator', label: 'BWI', value: 'BWI', color: 'secondary' },
|
||||
{ id: 'ind-dmi', type: 'indicator', label: 'DMI', value: 'DMI', color: 'secondary' },
|
||||
{ id: 'ind-obv', type: 'indicator', label: 'OBV', value: 'OBV', color: 'secondary' },
|
||||
{ id: 'ind-rsi', type: 'indicator', label: 'RSI', value: 'RSI', color: 'secondary' },
|
||||
{ id: 'ind-stoch', type: 'indicator', label: 'Stochastic', value: 'STOCHASTIC', color: 'secondary' },
|
||||
{ id: 'ind-wr', type: 'indicator', label: 'Williams %R', value: 'WILLIAMS_R', color: 'secondary' },
|
||||
{ id: 'ind-trix', type: 'indicator', label: 'TRIX', value: 'TRIX', color: 'secondary' },
|
||||
{ id: 'ind-vosc', type: 'indicator', label: 'VolumeOSC', value: 'VOLUME_OSC', color: 'secondary' },
|
||||
{ id: 'ind-vr', type: 'indicator', label: 'VR', value: 'VR', color: 'secondary' },
|
||||
{ id: 'ind-disp', type: 'indicator', label: '이격도', value: 'DISPARITY', color: 'secondary' },
|
||||
{ id: 'ind-vol', type: 'indicator', label: '거래량', value: 'VOLUME', color: 'secondary' },
|
||||
];
|
||||
|
||||
// ── 유틸 함수 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
let _nodeCounter = 0;
|
||||
export function generateNodeId(): string {
|
||||
return `node_${++_nodeCounter}_${Date.now()}`;
|
||||
}
|
||||
|
||||
/** 자연어 설명 변환 */
|
||||
export function nodeToNaturalLanguage(node: LogicNode): string {
|
||||
if (!node) return '';
|
||||
if (node.type === 'CONDITION' && node.condition) {
|
||||
const c = node.condition;
|
||||
const lOpts = LEFT_FIELD_OPTIONS[c.indicatorType] ?? [];
|
||||
const rOpts = RIGHT_FIELD_OPTIONS[c.indicatorType] ?? [];
|
||||
const leftLabel = lOpts.find(o => o.value === c.leftField)?.label ?? c.leftField ?? c.indicatorType;
|
||||
const rightLabel = rOpts.find(o => o.value === c.rightField)?.label ?? c.rightField ?? '';
|
||||
const condLabel = CONDITION_LABEL[c.conditionType] ?? c.conditionType;
|
||||
if (rightLabel && rightLabel !== '직접 입력') return `${leftLabel} ${condLabel} ${rightLabel}`;
|
||||
if (c.targetValue !== undefined) return `${leftLabel} ${condLabel} ${c.targetValue}`;
|
||||
return `${leftLabel} ${condLabel}`;
|
||||
}
|
||||
if (node.type === 'AND') {
|
||||
const parts = (node.children ?? []).map(c => nodeToNaturalLanguage(c));
|
||||
return parts.length === 0 ? '(AND 그룹)' : parts.join(' 그리고 ');
|
||||
}
|
||||
if (node.type === 'OR') {
|
||||
const parts = (node.children ?? []).map(c => nodeToNaturalLanguage(c));
|
||||
return parts.length === 0 ? '(OR 그룹)' : parts.join(' 또는 ');
|
||||
}
|
||||
if (node.type === 'NOT') {
|
||||
const child = node.children?.[0];
|
||||
return child ? `[아닌 경우] ${nodeToNaturalLanguage(child)}` : '(NOT 그룹)';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'gc_strategies_v1';
|
||||
|
||||
export function loadStrategiesFromStorage(): StrategyDSLDto[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export function saveStrategiesToStorage(strategies: StrategyDSLDto[]): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(strategies));
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 앱 전역 표시 시간대 (IANA).
|
||||
* DB app-settings · 설정 화면 · 하단 바에서 동일 값 사용.
|
||||
*/
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { TickMarkType, type Time } from 'lightweight-charts';
|
||||
import type { Timeframe } from '../types';
|
||||
|
||||
export const DEFAULT_DISPLAY_TIMEZONE = 'Asia/Seoul';
|
||||
|
||||
export interface TimezoneOption {
|
||||
id: string;
|
||||
label: string;
|
||||
abbr: string;
|
||||
}
|
||||
|
||||
export const TIMEZONE_OPTIONS: TimezoneOption[] = [
|
||||
{ id: 'Asia/Seoul', label: 'Asia/Seoul (KST, UTC+9)', abbr: 'KST' },
|
||||
{ id: 'UTC', label: 'UTC', abbr: 'UTC' },
|
||||
{ id: 'Asia/Tokyo', label: 'Asia/Tokyo (JST, UTC+9)', abbr: 'JST' },
|
||||
{ id: 'Asia/Shanghai', label: 'Asia/Shanghai (CST, UTC+8)', abbr: 'CST' },
|
||||
{ id: 'Asia/Hong_Kong', label: 'Asia/Hong_Kong (HKT, UTC+8)', abbr: 'HKT' },
|
||||
{ id: 'Asia/Singapore', label: 'Asia/Singapore (SGT, UTC+8)', abbr: 'SGT' },
|
||||
{ id: 'Europe/London', label: 'Europe/London (GMT/BST)', abbr: 'GMT' },
|
||||
{ id: 'Europe/Berlin', label: 'Europe/Berlin (CET)', abbr: 'CET' },
|
||||
{ id: 'America/New_York', label: 'America/New_York (ET)', abbr: 'ET' },
|
||||
{ id: 'America/Chicago', label: 'America/Chicago (CT)', abbr: 'CT' },
|
||||
{ id: 'America/Los_Angeles',label: 'America/Los_Angeles (PT)', abbr: 'PT' },
|
||||
];
|
||||
|
||||
let _tz = DEFAULT_DISPLAY_TIMEZONE;
|
||||
const _listeners = new Set<() => void>();
|
||||
|
||||
export function getDisplayTimezone(): string {
|
||||
return _tz;
|
||||
}
|
||||
|
||||
export function setDisplayTimezone(tz: string): void {
|
||||
const next = normalizeTimezone(tz);
|
||||
if (_tz === next) return;
|
||||
_tz = next;
|
||||
_listeners.forEach(l => l());
|
||||
}
|
||||
|
||||
export function subscribeDisplayTimezone(cb: () => void): () => void {
|
||||
_listeners.add(cb);
|
||||
return () => _listeners.delete(cb);
|
||||
}
|
||||
|
||||
export function useDisplayTimezone(): string {
|
||||
return useSyncExternalStore(subscribeDisplayTimezone, getDisplayTimezone, () => DEFAULT_DISPLAY_TIMEZONE);
|
||||
}
|
||||
|
||||
export function normalizeTimezone(tz: string): string {
|
||||
const t = (tz || '').trim();
|
||||
if (!t) return DEFAULT_DISPLAY_TIMEZONE;
|
||||
if (TIMEZONE_OPTIONS.some(o => o.id === t)) return t;
|
||||
try {
|
||||
Intl.DateTimeFormat(undefined, { timeZone: t });
|
||||
return t;
|
||||
} catch {
|
||||
return DEFAULT_DISPLAY_TIMEZONE;
|
||||
}
|
||||
}
|
||||
|
||||
export function getTimezoneAbbr(tz: string): string {
|
||||
const id = normalizeTimezone(tz);
|
||||
return TIMEZONE_OPTIONS.find(o => o.id === id)?.abbr ?? id.split('/').pop() ?? 'TZ';
|
||||
}
|
||||
|
||||
function isDailyTimeframe(tf?: string): boolean {
|
||||
return tf === '1D' || tf === '1W' || tf === '1M';
|
||||
}
|
||||
|
||||
/** 하단 바·시계 등 HH:mm:ss */
|
||||
export function formatUnixClock(unixSec: number, tz?: string, withSeconds = true): string {
|
||||
if (!unixSec) return '–';
|
||||
const zone = normalizeTimezone(tz ?? _tz);
|
||||
const d = new Date(unixSec * 1000);
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
timeZone: zone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
};
|
||||
if (withSeconds) opts.second = '2-digit';
|
||||
return new Intl.DateTimeFormat('en-GB', opts).format(d);
|
||||
}
|
||||
|
||||
/** 차트 축·범례용 */
|
||||
export function formatUnixForChart(unixSec: number, timeframe?: Timeframe, tz?: string): string {
|
||||
if (!unixSec) return '';
|
||||
const zone = normalizeTimezone(tz ?? _tz);
|
||||
const d = new Date(unixSec * 1000);
|
||||
if (isDailyTimeframe(timeframe)) {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone,
|
||||
year: '2-digit',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(d);
|
||||
}
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone,
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
/** LWC Time → unix 초 (없으면 null) */
|
||||
export function lwcTimeToUnix(time: Time): number | null {
|
||||
if (typeof time === 'number') return time;
|
||||
if (time && typeof time === 'object' && 'year' in time) {
|
||||
const t = time as { year: number; month: number; day: number };
|
||||
return Math.floor(Date.UTC(t.year, t.month - 1, t.day) / 1000);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** LWC Time → 문자열 (크로스헤어 등) */
|
||||
export function formatLwcTime(
|
||||
time: number | { year: number; month: number; day: number },
|
||||
timeframe?: Timeframe,
|
||||
tz?: string,
|
||||
): string {
|
||||
if (typeof time === 'number') {
|
||||
return formatUnixForChart(time, timeframe, tz);
|
||||
}
|
||||
const zone = normalizeTimezone(tz ?? _tz);
|
||||
const utc = Date.UTC(time.year, time.month - 1, time.day);
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone,
|
||||
year: '2-digit',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date(utc));
|
||||
}
|
||||
|
||||
/** LWC X축 눈금 — 선택한 표시 시간대 기준 (기본 포맷터는 브라우저 로컬 TZ 사용) */
|
||||
export function formatLwcTickMark(
|
||||
time: Time,
|
||||
tickMarkType: TickMarkType,
|
||||
timeframe?: Timeframe,
|
||||
tz?: string,
|
||||
): string {
|
||||
const zone = normalizeTimezone(tz ?? _tz);
|
||||
const unixSec = lwcTimeToUnix(time);
|
||||
if (unixSec == null) return '';
|
||||
const d = new Date(unixSec * 1000);
|
||||
|
||||
switch (tickMarkType) {
|
||||
case TickMarkType.Year:
|
||||
return new Intl.DateTimeFormat('en-GB', { timeZone: zone, year: 'numeric' }).format(d);
|
||||
case TickMarkType.Month:
|
||||
return new Intl.DateTimeFormat('en-GB', { timeZone: zone, month: 'short' }).format(d);
|
||||
case TickMarkType.DayOfMonth:
|
||||
if (isDailyTimeframe(timeframe)) {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone,
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(d);
|
||||
}
|
||||
return new Intl.DateTimeFormat('en-GB', { timeZone: zone, day: 'numeric' }).format(d);
|
||||
case TickMarkType.Time:
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(d);
|
||||
case TickMarkType.TimeWithSeconds:
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).format(d);
|
||||
default:
|
||||
return formatUnixForChart(unixSec, timeframe, zone);
|
||||
}
|
||||
}
|
||||
|
||||
/** 하단 바 표시: `06:36:00 KST` */
|
||||
export function formatBottomBarTime(unixSec: number, tz?: string): string {
|
||||
if (!unixSec) return `– ${getTimezoneAbbr(tz ?? _tz)}`;
|
||||
const zone = normalizeTimezone(tz ?? _tz);
|
||||
return `${formatUnixClock(unixSec, zone, true)} ${getTimezoneAbbr(zone)}`;
|
||||
}
|
||||
|
||||
/** 알림·드로잉 등 날짜+시간 */
|
||||
export function formatUnixDateTime(unixSec: number, tz?: string): string {
|
||||
if (!unixSec) return '—';
|
||||
const zone = normalizeTimezone(tz ?? _tz);
|
||||
const d = new Date(unixSec * 1000);
|
||||
const date = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: zone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(d);
|
||||
return `${date} ${formatUnixClock(unixSec, zone, true)}`;
|
||||
}
|
||||
|
||||
/** 현재 시각 (호가창 시계 등) */
|
||||
export function formatNowClock(tz?: string, withSeconds = true): string {
|
||||
const zone = normalizeTimezone(tz ?? _tz);
|
||||
return formatUnixClock(Math.floor(Date.now() / 1000), zone, withSeconds);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/** 매매 알림 팝업 표시 위치 */
|
||||
export type TradeAlertPopupPosition = 'right' | 'left' | 'bottom';
|
||||
|
||||
/** 매매 알림 팝업 배치 방식 */
|
||||
export type TradeAlertPopupLayout = 'stack' | 'grid' | 'strip' | 'single';
|
||||
|
||||
export const TRADE_ALERT_POPUP_POSITIONS: { id: TradeAlertPopupPosition; label: string; desc: string }[] = [
|
||||
{ id: 'right', label: '화면 우측', desc: '우측 상단부터 아래로 쌓입니다 (기본)' },
|
||||
{ id: 'left', label: '화면 좌측', desc: '좌측 상단부터 아래로 쌓입니다' },
|
||||
{ id: 'bottom', label: '화면 하단', desc: '화면 하단 중앙에 표시됩니다' },
|
||||
];
|
||||
|
||||
export const TRADE_ALERT_POPUP_LAYOUTS: { id: TradeAlertPopupLayout; label: string; desc: string }[] = [
|
||||
{ id: 'stack', label: '세로 스택', desc: '알림을 위·아래로 순서대로 쌓아 표시' },
|
||||
{ id: 'grid', label: '그리드 (N×N)', desc: '알림을 격자 형태로 배치' },
|
||||
{ id: 'strip', label: '가로 나열', desc: '알림을 가로로 나열 (스크롤)' },
|
||||
{ id: 'single', label: '최신 1건', desc: '가장 최근 알림 1건만 표시' },
|
||||
];
|
||||
|
||||
export const TRADE_ALERT_GRID_COL_OPTIONS = [2, 3, 4] as const;
|
||||
|
||||
export function normalizeTradeAlertPopupPosition(raw?: string | null): TradeAlertPopupPosition {
|
||||
if (raw === 'left' || raw === 'bottom') return raw;
|
||||
return 'right';
|
||||
}
|
||||
|
||||
export function normalizeTradeAlertPopupLayout(raw?: string | null): TradeAlertPopupLayout {
|
||||
if (raw === 'grid' || raw === 'strip' || raw === 'single') return raw;
|
||||
return 'stack';
|
||||
}
|
||||
|
||||
export function normalizeTradeAlertGridCols(raw?: number | null): number {
|
||||
const n = raw ?? 2;
|
||||
return Math.min(4, Math.max(2, Math.round(n)));
|
||||
}
|
||||
|
||||
/** CSS 클래스 조합 */
|
||||
export function tradeAlertPopupClassNames(
|
||||
position: TradeAlertPopupPosition,
|
||||
layout: TradeAlertPopupLayout,
|
||||
): { stack: string; cards: string } {
|
||||
return {
|
||||
stack: `tsn-stack tsn-stack--${position} tsn-layout--${layout}`,
|
||||
cards: `tsn-cards tsn-cards--${layout}`,
|
||||
};
|
||||
}
|
||||
|
||||
const POSITION_SHORT: Record<TradeAlertPopupPosition, string> = {
|
||||
right: '우측',
|
||||
left: '좌측',
|
||||
bottom: '하단',
|
||||
};
|
||||
|
||||
const LAYOUT_SHORT: Record<TradeAlertPopupLayout, string> = {
|
||||
stack: '스택',
|
||||
grid: '그리드',
|
||||
strip: '가로',
|
||||
single: '1건',
|
||||
};
|
||||
|
||||
export function tradeAlertPopupModeKey(
|
||||
position: TradeAlertPopupPosition,
|
||||
layout: TradeAlertPopupLayout,
|
||||
): string {
|
||||
return `${position}:${layout}`;
|
||||
}
|
||||
|
||||
export function parseTradeAlertPopupModeKey(key: string): {
|
||||
position: TradeAlertPopupPosition;
|
||||
layout: TradeAlertPopupLayout;
|
||||
} {
|
||||
const [pos, lay] = key.split(':');
|
||||
return {
|
||||
position: normalizeTradeAlertPopupPosition(pos),
|
||||
layout: normalizeTradeAlertPopupLayout(lay),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatTradeAlertPopupMode(
|
||||
position: TradeAlertPopupPosition,
|
||||
layout: TradeAlertPopupLayout,
|
||||
): string {
|
||||
return `${POSITION_SHORT[position]} · ${LAYOUT_SHORT[layout]}`;
|
||||
}
|
||||
|
||||
/** 메뉴바 드롭다운용 — 위치별 배치 옵션 */
|
||||
export function buildTradeAlertPopupModeOptions(): {
|
||||
position: TradeAlertPopupPosition;
|
||||
layout: TradeAlertPopupLayout;
|
||||
key: string;
|
||||
label: string;
|
||||
}[] {
|
||||
const options: {
|
||||
position: TradeAlertPopupPosition;
|
||||
layout: TradeAlertPopupLayout;
|
||||
key: string;
|
||||
label: string;
|
||||
}[] = [];
|
||||
for (const pos of TRADE_ALERT_POPUP_POSITIONS) {
|
||||
for (const lay of TRADE_ALERT_POPUP_LAYOUTS) {
|
||||
options.push({
|
||||
position: pos.id,
|
||||
layout: lay.id,
|
||||
key: tradeAlertPopupModeKey(pos.id, lay.id),
|
||||
label: `${pos.label} · ${lay.label}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/** @deprecated useToastPageCapacity 훅 사용 */
|
||||
export function getToastPageSize(
|
||||
layout: TradeAlertPopupLayout,
|
||||
gridCols: number,
|
||||
): number {
|
||||
switch (layout) {
|
||||
case 'single': return 1;
|
||||
case 'strip': return 4;
|
||||
case 'grid': return normalizeTradeAlertGridCols(gridCols) * 4;
|
||||
case 'stack':
|
||||
default: return 5;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 매매 시그널 알림음
|
||||
*/
|
||||
|
||||
export type TradeAlertSoundId =
|
||||
| 'silent'
|
||||
| 'bell'
|
||||
| 'chime'
|
||||
| 'alert'
|
||||
| 'success'
|
||||
| 'ding'
|
||||
| 'message'
|
||||
| 'notification'
|
||||
| 'ring'
|
||||
| 'positive'
|
||||
| 'coin'
|
||||
| 'pop'
|
||||
| 'tone'
|
||||
| 'complete';
|
||||
|
||||
export const TRADE_ALERT_SOUND_OPTIONS: { id: TradeAlertSoundId; label: string }[] = [
|
||||
{ id: 'bell', label: '벨 (기본)' },
|
||||
{ id: 'chime', label: '차임' },
|
||||
{ id: 'alert', label: '경고' },
|
||||
{ id: 'success', label: '성공' },
|
||||
{ id: 'ding', label: '딩동' },
|
||||
{ id: 'message', label: '메시지' },
|
||||
{ id: 'notification', label: '알림 톤' },
|
||||
{ id: 'ring', label: '전화 벨' },
|
||||
{ id: 'positive', label: '긍정 알림' },
|
||||
{ id: 'coin', label: '코인' },
|
||||
{ id: 'pop', label: '팝' },
|
||||
{ id: 'tone', label: '클린 톤' },
|
||||
{ id: 'complete', label: '완료' },
|
||||
{ id: 'silent', label: '무음' },
|
||||
];
|
||||
|
||||
const SOUND_URLS: Record<Exclude<TradeAlertSoundId, 'silent'>, string> = {
|
||||
bell: 'https://assets.mixkit.co/active_storage/sfx/2869/2869-preview.mp3',
|
||||
chime: 'https://assets.mixkit.co/active_storage/sfx/2870/2870-preview.mp3',
|
||||
alert: 'https://assets.mixkit.co/active_storage/sfx/2868/2868-preview.mp3',
|
||||
success: 'https://assets.mixkit.co/active_storage/sfx/2018/2018-preview.mp3',
|
||||
ding: 'https://assets.mixkit.co/active_storage/sfx/2354/2354-preview.mp3',
|
||||
message: 'https://assets.mixkit.co/active_storage/sfx/2357/2357-preview.mp3',
|
||||
notification: 'https://assets.mixkit.co/active_storage/sfx/2359/2359-preview.mp3',
|
||||
ring: 'https://assets.mixkit.co/active_storage/sfx/2356/2356-preview.mp3',
|
||||
positive: 'https://assets.mixkit.co/active_storage/sfx/2000/2000-preview.mp3',
|
||||
coin: 'https://assets.mixkit.co/active_storage/sfx/1998/1998-preview.mp3',
|
||||
pop: 'https://assets.mixkit.co/active_storage/sfx/2355/2355-preview.mp3',
|
||||
tone: 'https://assets.mixkit.co/active_storage/sfx/2867/2867-preview.mp3',
|
||||
complete: 'https://assets.mixkit.co/active_storage/sfx/2001/2001-preview.mp3',
|
||||
};
|
||||
|
||||
export function normalizeTradeAlertSoundId(id?: string | null): TradeAlertSoundId {
|
||||
if (!id) return 'bell';
|
||||
const found = TRADE_ALERT_SOUND_OPTIONS.some(o => o.id === id);
|
||||
return found ? (id as TradeAlertSoundId) : 'bell';
|
||||
}
|
||||
|
||||
export function getTradeAlertSoundUrl(soundId: TradeAlertSoundId): string {
|
||||
if (soundId === 'silent') return '';
|
||||
return SOUND_URLS[soundId] ?? SOUND_URLS.bell;
|
||||
}
|
||||
|
||||
let lastPlayAt = 0;
|
||||
|
||||
/** 새 매매 시그널 알림 시 사운드 재생 */
|
||||
export function playTradeAlertSound(soundId: TradeAlertSoundId, volume = 0.55): void {
|
||||
if (soundId === 'silent') return;
|
||||
const url = getTradeAlertSoundUrl(soundId);
|
||||
if (!url) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastPlayAt < 400) return;
|
||||
lastPlayAt = now;
|
||||
|
||||
try {
|
||||
const audio = new Audio(url);
|
||||
audio.volume = Math.min(1, Math.max(0, volume));
|
||||
void audio.play().catch(() => { /* autoplay 정책 등 */ });
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function previewTradeAlertSound(soundId: TradeAlertSoundId): void {
|
||||
lastPlayAt = 0;
|
||||
playTradeAlertSound(soundId);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 매매 시그널 알림 표시용 포맷·라벨
|
||||
*/
|
||||
import { getKoreanName } from './marketNameCache';
|
||||
import { formatUnixDateTime, formatUnixClock, getDisplayTimezone } from './timezone';
|
||||
|
||||
export interface TradeSignalDisplaySource {
|
||||
market: string;
|
||||
signalType: 'BUY' | 'SELL';
|
||||
price: number;
|
||||
candleTime: number;
|
||||
strategyName?: string | null;
|
||||
strategyId?: number | null;
|
||||
executionType?: string;
|
||||
candleType?: string;
|
||||
receivedAt?: number;
|
||||
}
|
||||
|
||||
export function parseMarket(market: string) {
|
||||
const parts = market.split('-');
|
||||
const fiat = parts[0] ?? 'KRW';
|
||||
const coin = parts.length > 1 ? parts.slice(1).join('-') : market;
|
||||
return { fiat, coin, code: market };
|
||||
}
|
||||
|
||||
export function formatSignalPrice(price: number): string {
|
||||
if (!Number.isFinite(price) || price <= 0) return '—';
|
||||
return `₩${price.toLocaleString('ko-KR', { maximumFractionDigits: 0 })}`;
|
||||
}
|
||||
|
||||
export function formatSignalTime(unixSec: number, withDate = true): string {
|
||||
if (!unixSec) return '—';
|
||||
const tz = getDisplayTimezone();
|
||||
if (!withDate) return formatUnixClock(unixSec, tz, true);
|
||||
return formatUnixDateTime(unixSec, tz);
|
||||
}
|
||||
|
||||
export function getSignalTypeKo(type: 'BUY' | 'SELL'): string {
|
||||
return type === 'BUY' ? '매수' : '매도';
|
||||
}
|
||||
|
||||
export function getSignalHeadline(item: TradeSignalDisplaySource): string {
|
||||
const korean = getKoreanName(item.market);
|
||||
const { coin } = parseMarket(item.market);
|
||||
const name = korean || coin;
|
||||
return `${name} ${getSignalTypeKo(item.signalType)} 알림`;
|
||||
}
|
||||
|
||||
export function getExecutionLabel(executionType?: string, candleType?: string): string {
|
||||
const candle = candleType ?? '1m';
|
||||
const candleKo = candle === '1m' ? '1분봉' : candle;
|
||||
if (executionType === 'REALTIME_TICK') return `실시간 틱 · ${candleKo}`;
|
||||
if (executionType === 'CANDLE_CLOSE') return `봉 마감 · ${candleKo}`;
|
||||
return candleKo;
|
||||
}
|
||||
|
||||
export function getMarketDisplayLine(market: string): { primary: string; secondary: string } {
|
||||
const korean = getKoreanName(market);
|
||||
const { code } = parseMarket(market);
|
||||
return {
|
||||
primary: korean || code,
|
||||
secondary: code,
|
||||
};
|
||||
}
|
||||
|
||||
/** 상세 행 목록 (스낵바·목록 공통) */
|
||||
export function buildSignalDetailRows(item: TradeSignalDisplaySource): Array<{ label: string; value: string; highlight?: boolean }> {
|
||||
const { primary, secondary } = getMarketDisplayLine(item.market);
|
||||
const rows: Array<{ label: string; value: string; highlight?: boolean }> = [
|
||||
{ label: '종목', value: primary !== secondary ? `${secondary} · ${primary}` : secondary },
|
||||
{ label: '매매 구분', value: `${getSignalTypeKo(item.signalType)} (${item.signalType})`, highlight: true },
|
||||
{ label: '기준 가격', value: formatSignalPrice(item.price), highlight: true },
|
||||
{ label: '캔들 시각', value: `${formatSignalTime(item.candleTime)} · ${item.candleType ?? '1m'}` },
|
||||
];
|
||||
|
||||
if (item.receivedAt && Math.abs(item.receivedAt - item.candleTime * 1000) > 2000) {
|
||||
rows.push({ label: '수신 시각', value: formatSignalTime(Math.floor(item.receivedAt / 1000)) });
|
||||
}
|
||||
|
||||
rows.push(
|
||||
{ label: '전략', value: item.strategyName?.trim() || (item.strategyId != null ? `전략 #${item.strategyId}` : '실시간 전략') },
|
||||
{ label: '실행 방식', value: getExecutionLabel(item.executionType, item.candleType) },
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import type { OHLCVBar, Timeframe } from '../types';
|
||||
|
||||
// ─── Upbit REST API ────────────────────────────────────────────────────────
|
||||
// 개발: Vite proxy (/upbit-api) → https://api.upbit.com
|
||||
// 프로덕션: nginx proxy (/upbit-api) → https://api.upbit.com
|
||||
// 항상 동일한 상대 경로를 사용하므로 CORS 없이 동일 오리진으로 요청됨
|
||||
const UPBIT_API = '/upbit-api/v1';
|
||||
|
||||
export const UPBIT_MARKETS = [
|
||||
'KRW-BTC', 'KRW-ETH', 'KRW-XRP', 'KRW-SOL', 'KRW-DOGE',
|
||||
'KRW-ADA', 'KRW-DOT', 'KRW-AVAX', 'KRW-LINK', 'KRW-MATIC',
|
||||
'KRW-SHIB', 'KRW-UNI', 'KRW-ATOM', 'KRW-LTC', 'KRW-ETC',
|
||||
];
|
||||
|
||||
export function isUpbitMarket(symbol: string): boolean {
|
||||
return /^(KRW|BTC|USDT)-\w+$/.test(symbol.toUpperCase());
|
||||
}
|
||||
|
||||
// ─── Upbit candle → OHLCVBar ───────────────────────────────────────────────
|
||||
export interface UpbitCandle {
|
||||
market: string;
|
||||
candle_date_time_utc: string;
|
||||
opening_price: number;
|
||||
high_price: number;
|
||||
low_price: number;
|
||||
trade_price: number;
|
||||
timestamp: number;
|
||||
candle_acc_trade_price: number;
|
||||
candle_acc_trade_volume: number;
|
||||
unit?: number;
|
||||
}
|
||||
|
||||
export function candleToBar(c: UpbitCandle): OHLCVBar {
|
||||
const time = Math.floor(new Date(c.candle_date_time_utc + 'Z').getTime() / 1000);
|
||||
return {
|
||||
time,
|
||||
open: c.opening_price,
|
||||
high: c.high_price,
|
||||
low: c.low_price,
|
||||
close: c.trade_price,
|
||||
volume: c.candle_acc_trade_volume,
|
||||
};
|
||||
}
|
||||
|
||||
/** Upbit 캔들 API 1회 요청 최대 개수 */
|
||||
const UPBIT_MAX_PER_REQ = 200;
|
||||
|
||||
/**
|
||||
* 지표 워밍업 바 수 분석
|
||||
* ─────────────────────────────────────────────────────
|
||||
* 지표 | 최대 계산 기간 | 워밍업 바
|
||||
* BB(20) | 20 | 20
|
||||
* EMA(20/50) | 50 | 50
|
||||
* RSI(14) | 14 | 14
|
||||
* MACD(12,26,9) | 26 + 9 | 35
|
||||
* DMI/ADX(14) | 14 × 2 | 28
|
||||
* 일목균형표 Span B | 52 | 52 ← 최대
|
||||
* ─────────────────────────────────────────────────────
|
||||
* → 최소 52바, 안전 마진 포함 100바 워밍업 필요
|
||||
* → 표시 200 + 워밍업 100 = 300바 = 요청 2회 (200 + 100)
|
||||
*/
|
||||
export const INDICATOR_WARMUP = 100; // 안전 마진 포함 워밍업 바 수
|
||||
|
||||
/** OBV 누적 정확도용 초기 캔들 수 (표시 200 + 워밍업 600) */
|
||||
export const OBV_DEEP_HISTORY = 800;
|
||||
|
||||
function candleEndpoint(
|
||||
timeframe: Timeframe,
|
||||
market: string,
|
||||
count: number,
|
||||
to?: string, // ISO 8601 (exclusive) - 이 시각 이전 캔들을 반환
|
||||
): string {
|
||||
const base = `${UPBIT_API}/candles`;
|
||||
const toParam = to ? `&to=${encodeURIComponent(to + 'Z')}` : '';
|
||||
switch (timeframe) {
|
||||
case '1m': return `${base}/minutes/1?market=${market}&count=${count}${toParam}`;
|
||||
case '3m': return `${base}/minutes/3?market=${market}&count=${count}${toParam}`;
|
||||
case '5m': return `${base}/minutes/5?market=${market}&count=${count}${toParam}`;
|
||||
case '15m': return `${base}/minutes/15?market=${market}&count=${count}${toParam}`;
|
||||
case '30m': return `${base}/minutes/30?market=${market}&count=${count}${toParam}`;
|
||||
case '1h': return `${base}/minutes/60?market=${market}&count=${count}${toParam}`;
|
||||
case '4h': return `${base}/minutes/240?market=${market}&count=${count}${toParam}`;
|
||||
case '1D': return `${base}/days?market=${market}&count=${count}${toParam}`;
|
||||
case '1W': return `${base}/weeks?market=${market}&count=${count}${toParam}`;
|
||||
case '1M': return `${base}/months?market=${market}&count=${count}${toParam}`;
|
||||
default: return `${base}/minutes/1?market=${market}&count=${count}${toParam}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 업비트 캔들 페치 (count > 200 이면 자동 분할 요청)
|
||||
*
|
||||
* Upbit API 는 1회 최대 200개 반환.
|
||||
* count=300 이면:
|
||||
* - 1차: 최신 200개
|
||||
* - 2차: 그 이전 100개 (to = 1차 결과 중 가장 오래된 시각)
|
||||
* 결과는 시간 오름차순(오래된→최신)으로 반환.
|
||||
*
|
||||
* @param before - ISO 8601 UTC (exclusive) — 이 시각 **이전** 캔들을 반환.
|
||||
* 과거 데이터 추가 로드 시 현재 가장 오래된 캔들의 시각을 전달한다.
|
||||
*/
|
||||
export async function fetchUpbitCandles(
|
||||
market: string,
|
||||
timeframe: Timeframe,
|
||||
count = 200,
|
||||
before?: string, // ISO 8601 UTC exclusive (예: '2024-01-01T00:00:00')
|
||||
): Promise<OHLCVBar[]> {
|
||||
const mkt = market.toUpperCase();
|
||||
let remaining = count;
|
||||
let to: string | undefined = before; // before 가 있으면 그 시각 이전부터 시작
|
||||
const batches: UpbitCandle[][] = [];
|
||||
|
||||
while (remaining > 0) {
|
||||
const batchCount = Math.min(remaining, UPBIT_MAX_PER_REQ);
|
||||
const url = candleEndpoint(timeframe, mkt, batchCount, to);
|
||||
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error(`Upbit API 오류: ${res.status} ${res.statusText}`);
|
||||
const data: UpbitCandle[] = await res.json();
|
||||
if (data.length === 0) break;
|
||||
|
||||
batches.push(data); // 각 배치: Upbit 반환순 (최신→오래된)
|
||||
remaining -= data.length;
|
||||
|
||||
if (data.length < batchCount) break; // 서버에 데이터 더 없음
|
||||
|
||||
// 다음 배치 기준: 현재 배치에서 가장 오래된 캔들 시각 (exclusive)
|
||||
to = data[data.length - 1].candle_date_time_utc;
|
||||
|
||||
// requestCache 의 deduplication 으로 동시 요청이 1건으로 줄어들었으므로
|
||||
// 배치 간 딜레이는 100ms 로 유지 (2번째 배치가 동시에 날아가지 않도록)
|
||||
if (remaining > 0) await new Promise(r => setTimeout(r, 100));
|
||||
}
|
||||
|
||||
// 배치 역순 합산 후 시간 오름차순 정렬 → 중복 제거
|
||||
const allCandles = batches.reverse().flat();
|
||||
const seen = new Set<string>();
|
||||
const unique = allCandles.filter(c => {
|
||||
if (seen.has(c.candle_date_time_utc)) return false;
|
||||
seen.add(c.candle_date_time_utc);
|
||||
return true;
|
||||
});
|
||||
unique.sort((a, b) => a.candle_date_time_utc.localeCompare(b.candle_date_time_utc));
|
||||
return unique.map(candleToBar);
|
||||
}
|
||||
|
||||
// ─── Upbit WebSocket ───────────────────────────────────────────────────────
|
||||
export interface UpbitTrade {
|
||||
type: 'trade';
|
||||
code: string;
|
||||
trade_price: number;
|
||||
trade_volume: number;
|
||||
ask_bid: 'ASK' | 'BID';
|
||||
trade_timestamp: number;
|
||||
trade_date: string;
|
||||
trade_time: string;
|
||||
sequential_id: number;
|
||||
stream_type: 'REALTIME' | 'SNAPSHOT';
|
||||
}
|
||||
export interface UpbitTickerSimple {
|
||||
type: 'ticker';
|
||||
code: string;
|
||||
trade_price: number;
|
||||
trade_volume: number;
|
||||
opening_price: number;
|
||||
high_price: number;
|
||||
low_price: number;
|
||||
acc_trade_volume: number;
|
||||
trade_timestamp: number;
|
||||
stream_type: string;
|
||||
}
|
||||
export type UpbitMessage = UpbitTrade | UpbitTickerSimple;
|
||||
|
||||
export interface UpbitWsOptions {
|
||||
market: string;
|
||||
onMessage: (msg: UpbitMessage) => void;
|
||||
onConnect: () => void;
|
||||
onDisconnect: () => void;
|
||||
onError: (err: string) => void;
|
||||
}
|
||||
|
||||
// 개발: Vite proxy (/upbit-ws) → wss://api.upbit.com/websocket/v1
|
||||
// 프로덕션: nginx proxy (/upbit-ws) → wss://api.upbit.com/websocket/v1
|
||||
// 항상 동일 오리진 상대 경로를 사용 → CORS / Origin 차단 모두 우회
|
||||
function getWsUrl(): string {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
return `${proto}://${window.location.host}/upbit-ws`;
|
||||
}
|
||||
|
||||
const INITIAL_DELAY = 80; // React StrictMode 이중마운트 방지 (cleanup < 80ms면 연결 안 함)
|
||||
const RECONNECT_BASE = 2000; // 최초 재연결 대기
|
||||
const RECONNECT_MAX = 30000; // 최대 재연결 대기
|
||||
const PING_INTERVAL = 20000; // 20초마다 PING (서버가 60초 내 응답 없으면 끊음)
|
||||
|
||||
export class UpbitWebSocketClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private opts: UpbitWsOptions;
|
||||
private destroyed = false;
|
||||
private retryCount = 0;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(opts: UpbitWsOptions) {
|
||||
this.opts = opts;
|
||||
// 80ms 지연으로 React StrictMode 이중 마운트 방지
|
||||
// (cleanup 이 80ms 안에 호출되면 connect 가 취소됨)
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.connect();
|
||||
}, INITIAL_DELAY);
|
||||
}
|
||||
|
||||
private connect() {
|
||||
if (this.destroyed) return;
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(getWsUrl());
|
||||
this.ws = ws;
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => {
|
||||
this.retryCount = 0;
|
||||
|
||||
// ── 구독 메시지 ──────────────────────────────────────────────
|
||||
// format 생략 시 DEFAULT (긴 키명), SIMPLE이면 단축 키명
|
||||
ws.send(JSON.stringify([
|
||||
{ ticket: `react_chart_${Date.now()}` },
|
||||
{ type: 'trade', codes: [this.opts.market.toUpperCase()] },
|
||||
{ format: 'DEFAULT' },
|
||||
]));
|
||||
|
||||
// ── PING keepalive ─────────────────────────────────────────
|
||||
this.clearPing();
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send('PING');
|
||||
}
|
||||
}, PING_INTERVAL);
|
||||
|
||||
this.opts.onConnect();
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// ① 텍스트 메시지: 서버 PING → PONG 응답, 상태 메시지 → 무시
|
||||
if (typeof event.data === 'string') {
|
||||
if (event.data === 'PING') ws.send('PONG');
|
||||
return;
|
||||
}
|
||||
|
||||
// ② 바이너리 메시지: 체결/시세 데이터
|
||||
let msg: UpbitMessage | null = null;
|
||||
try {
|
||||
const text = new TextDecoder('utf-8').decode(event.data as ArrayBuffer);
|
||||
if (text === 'PING') { ws.send('PONG'); return; }
|
||||
const parsed = JSON.parse(text) as UpbitMessage;
|
||||
if (!('type' in parsed)) return;
|
||||
msg = parsed;
|
||||
} catch {
|
||||
// JSON 파싱 실패 → 무시
|
||||
return;
|
||||
}
|
||||
// JSON 파싱과 onMessage 를 분리: 차트 업데이트 오류가 WS 메시지 처리를 중단시키지 않도록
|
||||
try {
|
||||
this.opts.onMessage(msg);
|
||||
} catch (err) {
|
||||
console.error('[UpbitWS] onMessage 처리 중 오류:', err);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (ev) => {
|
||||
this.clearPing();
|
||||
if (!this.destroyed) {
|
||||
// 정상 종료(1000/1001)가 아닐 때만 오류 표시
|
||||
if (ev.code !== 1000 && ev.code !== 1001) {
|
||||
this.opts.onError(`연결 끊김 (code ${ev.code})`);
|
||||
}
|
||||
this.opts.onDisconnect();
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// onerror 직후 onclose가 반드시 발생하므로 여기서는 로그만
|
||||
// (onclose에서 재연결 처리)
|
||||
console.warn('[UpbitWS] onerror 발생 → onclose 대기');
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
this.opts.onError(`WebSocket 초기화 실패: ${String(err)}`);
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private clearPing() {
|
||||
if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; }
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.destroyed) return;
|
||||
const delay = Math.min(RECONNECT_BASE * 2 ** this.retryCount, RECONNECT_MAX);
|
||||
this.retryCount++;
|
||||
console.info(`[UpbitWS] ${delay}ms 후 재연결 시도 (${this.retryCount}회차)`);
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), delay);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
this.clearPing();
|
||||
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
|
||||
if (this.ws) {
|
||||
this.ws.onclose = null;
|
||||
this.ws.onerror = null;
|
||||
this.ws.close(1000, 'destroy');
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 타임프레임 초 단위 ───────────────────────────────────────────────────
|
||||
export const TF_SECONDS: Record<Timeframe, number> = {
|
||||
'1m': 60, '3m': 180, '5m': 300, '15m': 900, '30m': 1800,
|
||||
'1h': 3600, '4h': 14400, '1D': 86400, '1W': 604800, '1M': 2592000,
|
||||
};
|
||||
|
||||
export function getCandleStart(tradeTimestampMs: number, timeframe: Timeframe): number {
|
||||
const sec = Math.floor(tradeTimestampMs / 1000);
|
||||
const tfSec = TF_SECONDS[timeframe] ?? 60;
|
||||
return Math.floor(sec / tfSec) * tfSec;
|
||||
}
|
||||
Reference in New Issue
Block a user