goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
@@ -0,0 +1,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
+161
View File
@@ -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 === '과열선',
});
}
}
}
+16
View File
@@ -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);
}
+14
View File
@@ -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);
}
+50
View File
@@ -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
+127
View File
@@ -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;
}
}
+128
View File
@@ -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 })),
};
}
+12
View File
@@ -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';
}
}
+102
View File
@@ -0,0 +1,102 @@
/** HEX · RGB · HSV 변환 (색상 팔레트 UI용) */
export interface Rgb {
r: number;
g: number;
b: number;
}
export interface Hsv {
/** 0360 */
h: number;
/** 01 */
s: number;
/** 01 */
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%)';
+411
View File
@@ -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),
};
}
+93
View File
@@ -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);
}
+75
View File
@@ -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;
}
}
+164
View File
@@ -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;
}
+240
View File
@@ -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 });
}
+242
View File
@@ -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;
}
+50
View File
@@ -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);
}
+139
View File
@@ -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(';');
}
+224
View File
@@ -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),
};
}
+581
View File
@@ -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 };
}
+57
View File
@@ -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,
};
+27
View File
@@ -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-/, '');
}
+108
View File
@@ -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);
}
+84
View File
@@ -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';
}
+50
View File
@@ -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: '도트',
};
+96
View File
@@ -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;
}
+26
View File
@@ -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 };
}
+144
View File
@@ -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;
}
+185
View File
@@ -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;
}
+135
View File
@@ -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);
}
+21
View File
@@ -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;
}
}
+78
View File
@@ -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 };
+279
View File
@@ -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));
}
+213
View File
@@ -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);
}
+124
View File
@@ -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;
}
}
+87
View File
@@ -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);
}
+86
View File
@@ -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;
}
+328
View File
@@ -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;
}