보조지표 소수점 추가, cci 수정

This commit is contained in:
Macbook
2026-05-30 00:13:22 +09:00
parent 6332d198b1
commit ad92dace28
11 changed files with 286 additions and 20 deletions
@@ -0,0 +1,45 @@
package com.goldenchart.service;
import org.ta4j.core.BarSeries;
import org.ta4j.core.Indicator;
import org.ta4j.core.indicators.CachedIndicator;
import org.ta4j.core.indicators.averages.SMAIndicator;
import org.ta4j.core.indicators.statistics.MeanDeviationIndicator;
import org.ta4j.core.num.Num;
/**
* TradingView/업비트 CCI — 임의 가격 소스에 대해
* (src - SMA) / (0.015 × MeanDeviation) 계산.
*/
public class CciOnSourceIndicator extends CachedIndicator<Num> {
private final Indicator<Num> price;
private final SMAIndicator sma;
private final MeanDeviationIndicator meanDeviation;
private final Num factor;
private final int barCount;
public CciOnSourceIndicator(Indicator<Num> price, int barCount) {
super(price);
this.price = price;
this.barCount = barCount;
this.factor = getBarSeries().numFactory().numOf(0.015);
this.sma = new SMAIndicator(price, barCount);
this.meanDeviation = new MeanDeviationIndicator(price, barCount);
}
@Override
protected Num calculate(int index) {
final Num meanDeviation = this.meanDeviation.getValue(index);
if (meanDeviation.isZero()) {
return getBarSeries().numFactory().zero();
}
return price.getValue(index).minus(sma.getValue(index))
.dividedBy(meanDeviation.multipliedBy(factor));
}
@Override
public int getCountOfUnstableBars() {
return barCount - 1;
}
}
@@ -396,10 +396,10 @@ public class IndicatorService {
*/
private Map<String, List<PlotPoint>> calcCCI(BarSeries s, Map<String, Object> p) {
int len = intP(p, "length", 13);
int maLen = intP(p, "maLength", 20);
int maLen = intP(p, "maLength", 10);
String maType = p.getOrDefault("maType", "SMA").toString();
CCIIndicator cci = new CCIIndicator(s, len);
CciOnSourceIndicator cci = new CciOnSourceIndicator(src(s, p), len);
Indicator<Num> maInd = switch (maType) {
case "EMA" -> new EMAIndicator(cci, maLen);
@@ -599,10 +599,11 @@ public class StrategyDslToTa4jAdapter {
// CCI — 기간 접미사(CCI_VALUE_13) 또는 기본 CCI_VALUE
if (field.startsWith("CCI_VALUE_")) {
int period = parseTrailingInt(field, "CCI_VALUE_", intP(p, "length", 13));
return new CCIIndicator(s, period);
return new CciOnSourceIndicator(resolvePriceSource(s, p), period);
}
if (field.equals("CCI_VALUE") || indType.equals("CCI"))
return new CCIIndicator(s, periodOverride > 0 ? periodOverride : intP(p, "length", 13));
return new CciOnSourceIndicator(resolvePriceSource(s, p),
periodOverride > 0 ? periodOverride : intP(p, "length", 13));
// RSI — 기간 접미사(RSI_VALUE_9) 또는 기본 RSI_VALUE
if (field.startsWith("RSI_VALUE_")) {
int period = parseTrailingInt(field, "RSI_VALUE_", intP(p, "length", 14));
@@ -865,6 +866,17 @@ public class StrategyDslToTa4jAdapter {
// ── TRIX 헬퍼 ─────────────────────────────────────────────────────────────
private Indicator<Num> resolvePriceSource(BarSeries s, Map<String, Object> p) {
return switch (p.getOrDefault("src", "close").toString()) {
case "open" -> new OpenPriceIndicator(s);
case "high" -> new HighPriceIndicator(s);
case "low" -> new LowPriceIndicator(s);
case "hl2" -> new MedianPriceIndicator(s);
case "hlc3" -> new TypicalPriceIndicator(s);
default -> new ClosePriceIndicator(s);
};
}
private EMAIndicator tripleEma(ClosePriceIndicator close, int len) {
return new EMAIndicator(new EMAIndicator(new EMAIndicator(close, len), len), len);
}
+2
View File
@@ -2357,6 +2357,8 @@ function App() {
onDuplicateIndicator={handleDuplicateIndicator}
focusedIndicatorId={focusedIndicatorId}
onRestoreIndicators={handleRestoreIndicators}
onToggleIndicatorHidden={handleToggleIndicatorHidden}
onOpenIndicatorSettings={id => setSettingsModalId(id)}
volumeVisible={chartVolumeVisible}
paneSeparatorOptions={chartPaneSeparator}
/>
+47 -1
View File
@@ -20,6 +20,10 @@ export interface ChartRightToolbarProps {
onRestore?: () => void;
focusedId?: string | null;
onDragStart?: (id: string, e: React.PointerEvent) => void;
/** 지표명 툴바 — 표시/숨기기 */
onToggleHidden?: (id: string) => void;
/** 지표명 툴바 — 설정 모달 */
onSettings?: (id: string) => void;
}
const BTN_SIZE = 32;
@@ -89,6 +93,25 @@ const IconMore = () => (
</svg>
);
const IconEye = ({ hidden }: { hidden?: boolean }) => hidden ? (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M1 7 Q4 2.5 7 2.5 Q10 2.5 13 7 Q10 11.5 7 11.5 Q4 11.5 1 7 Z" strokeOpacity="0.35"/>
<line x1="2" y1="2" x2="12" y2="12"/>
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M1 7 Q4 2.5 7 2.5 Q10 2.5 13 7 Q10 11.5 7 11.5 Q4 11.5 1 7 Z"/>
<circle cx="7" cy="7" r="2"/>
</svg>
);
const IconGear = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4">
<circle cx="7" cy="7" r="2.3"/>
<path d="M7 1 L7.7 2.8 L9.8 2 L9.8 4.2 L11.8 5 L11 7 L11.8 9 L9.8 9.8 L9.8 12 L7.7 11.2 L7 13 L6.3 11.2 L4.2 12 L4.2 9.8 L2.2 9 L3 7 L2.2 5 L4.2 4.2 L4.2 2 L6.3 2.8 Z"/>
</svg>
);
interface MenuItemDef {
key: string;
title: string;
@@ -109,6 +132,8 @@ function buildMenuItems(
onExpand?: (id: string) => void;
onRestore?: () => void;
onRemove?: (id: string) => void;
onToggleHidden?: (id: string) => void;
onSettings?: (id: string) => void;
onDone?: () => void;
},
): MenuItemDef[] {
@@ -119,9 +144,28 @@ function buildMenuItems(
const isFocused = opts.focusedId === item.id;
const showExpand = !!(opts.onExpand || (isFocused && opts.onRestore));
const done = opts.onDone ?? (() => {});
const cfg = indicators.find(ind => ind.id === item.id);
const isHidden = cfg?.hidden === true;
const items: MenuItemDef[] = [];
if (opts.onToggleHidden) {
items.push({
key: 'visibility',
title: isHidden ? '표시' : '숨기기',
icon: <IconEye hidden={isHidden} />,
active: isHidden,
onClick: () => { opts.onToggleHidden?.(item.id); done(); },
});
}
if (opts.onSettings) {
items.push({
key: 'settings',
title: '지표 설정',
icon: <IconGear />,
onClick: () => { opts.onSettings?.(item.id); done(); },
});
}
if (showSplit) {
items.push({
key: 'split',
@@ -168,7 +212,7 @@ const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
manager, indicators, chartHeight, paused = false,
onSplit, onRemove, onDuplicate,
onExpand, onRestore, focusedId,
onDragStart,
onDragStart, onToggleHidden, onSettings,
}) => {
const [paneLayouts, setPaneLayouts] = useState<Array<{ paneIndex: number; topY: number; height: number }>>([]);
const [paneItems, setPaneItems] = useState<ReturnType<typeof buildPaneItems>>([]);
@@ -263,6 +307,7 @@ const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
const clusterTop = paneBottom - 4 - clusterHeight - stackIdx * (clusterHeight + 4);
const menuItems = buildMenuItems(item, indicators, paneItems, {
focusedId, onSplit, onDuplicate, onExpand, onRestore, onRemove,
onToggleHidden, onSettings,
});
if (menuItems.length === 0 && !allowDrag) return null;
@@ -306,6 +351,7 @@ const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
{openItem && openMenuId && (() => {
const menuItems = buildMenuItems(openItem, indicators, paneItems, {
focusedId, onSplit, onDuplicate, onExpand, onRestore, onRemove,
onToggleHidden, onSettings,
onDone: closeFlyout,
});
if (menuItems.length === 0) return null;
+2
View File
@@ -949,6 +949,8 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
onDuplicateIndicator={handleDuplicateIndicator}
focusedIndicatorId={focusedIndicatorId}
onRestoreIndicators={handleRestoreIndicators}
onToggleIndicatorHidden={handleToggleHidden}
onOpenIndicatorSettings={id => setSettingsModalId(id)}
onFullView={onFullView ? () => onFullView(symbolRef.current) : undefined}
/>
+8
View File
@@ -120,6 +120,10 @@ interface TradingChartProps {
focusedIndicatorId?: string | null;
/** 전체화면 → 전체 보기 복원 */
onRestoreIndicators?: () => void;
/** 지표 표시/숨기기 */
onToggleIndicatorHidden?: (id: string) => void;
/** 지표 설정 모달 열기 */
onOpenIndicatorSettings?: (id: string) => void;
/** 우클릭 메뉴에서 매수·매도 선택 시 */
onTradeOrderRequest?: (req: { market: string; price: number; side: 'buy' | 'sell' }) => void;
/** 표시 시간대 (IANA) */
@@ -164,6 +168,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
onDuplicateIndicator,
focusedIndicatorId,
onRestoreIndicators,
onToggleIndicatorHidden,
onOpenIndicatorSettings,
onTradeOrderRequest,
displayTimezone = DEFAULT_DISPLAY_TIMEZONE,
volumeVisible = true,
@@ -1391,6 +1397,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
onRestore={onRestoreIndicators}
focusedId={focusedIndicatorId}
onDragStart={(id, e) => paneDragRef.current?.(id, e)}
onToggleHidden={onToggleIndicatorHidden}
onSettings={onOpenIndicatorSettings}
/>
)}
</div>
+22 -3
View File
@@ -24,7 +24,7 @@ import { IndicatorFillPrimitive } from './IndicatorFillPrimitive';
import { BollingerBandFillPrimitive } from './BollingerBandFillPrimitive';
import { resolveBbBandBackground } from './bollingerConfig';
import type { OHLCVBar, ChartType, Theme, IndicatorConfig, Timeframe } from '../types';
import { formatChartAxisPrice } from './dataGenerator';
import { formatChartAxisPrice, formatIndicatorAxisPrice } from './dataGenerator';
import {
DEFAULT_CHART_TIME_FORMAT,
formatUnixWithChartPattern,
@@ -42,6 +42,16 @@ const MAIN_PRICE_FORMAT = {
minMove: 1,
formatter: formatChartAxisPrice,
};
const INDICATOR_PRICE_FORMAT = {
type: 'custom' as const,
minMove: 0.01,
formatter: formatIndicatorAxisPrice,
};
function priceFormatForIndicatorPane(paneIndex: number) {
return paneIndex >= 2 ? INDICATOR_PRICE_FORMAT : undefined;
}
import {
getIchimokuPlotTitle,
isIchimokuCloudVisible,
@@ -655,8 +665,12 @@ export class ChartManager {
if (filtered.length === 0) continue;
let series: ISeriesApi<SeriesType>;
const indicatorPriceFormat = priceFormatForIndicatorPane(pane);
if (plotDef.type === 'histogram') {
series = this.chart.addSeries(HistogramSeries, { color: plotDef.color }, pane);
series = this.chart.addSeries(HistogramSeries, {
color: plotDef.color,
...(indicatorPriceFormat ? { priceFormat: indicatorPriceFormat } : {}),
}, pane);
series.setData(mapHistogramSeriesData(filtered, plotDef));
seriesMeta.push({ plotId: plotDef.id, isHistogram: true, color: plotDef.color });
} else {
@@ -671,6 +685,7 @@ export class ChartManager {
lastValueVisible: showPriceLabel,
title: showSeriesTitle ? this.seriesTitleForPlot(config, plotDef) : '',
visible: isPlotVisible,
...(indicatorPriceFormat ? { priceFormat: indicatorPriceFormat } : {}),
}, pane);
series.setData(filtered.map(p => ({ time: p.time as Time, value: p.value })));
seriesMeta.push({ plotId: plotDef.id, isHistogram: false, color: plotDef.color });
@@ -2263,8 +2278,13 @@ export class ChartManager {
if (!plot) return;
const indicatorVisible = config.hidden !== true;
const visible = indicatorVisible && config.plotVisibility?.[plot.id] !== false;
const paneIdx = entry.paneIndex ?? 0;
const indicatorPriceFormat = priceFormatForIndicatorPane(paneIdx);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const opts: Record<string, unknown> = { visible };
if (indicatorPriceFormat) {
opts['priceFormat'] = indicatorPriceFormat;
}
if (!entry.seriesMeta[i]?.isHistogram) {
opts['color'] = plot.color ?? '#aaa';
if (plot.lineWidth != null) opts['lineWidth'] = plot.lineWidth;
@@ -2274,7 +2294,6 @@ export class ChartManager {
plotAllows = entry.seriesMeta[i]?.plotId !== 'plot2';
}
const showPriceLabel = this.shouldShowSeriesPriceLabel(config, plotAllows);
const paneIdx = entry.paneIndex ?? 0;
opts['lastValueVisible'] = showPriceLabel;
opts['title'] = paneIdx < 2 && showPriceLabel
? this.seriesTitleForPlot(config, plot, plotAllows)
+113 -11
View File
@@ -15,6 +15,20 @@ function sma(values: number[], period: number): number[] {
});
}
function wma(values: number[], period: number): number[] {
return values.map((_, i) => {
if (i < period - 1) return NaN;
let wSum = 0;
let w = 0;
for (let j = 0; j < period; j++) {
const weight = j + 1;
wSum += values[i - period + 1 + j] * weight;
w += weight;
}
return wSum / w;
});
}
function ema(values: number[], period: number): number[] {
const out = new Array<number>(values.length).fill(NaN);
if (values.length < period) return out;
@@ -182,6 +196,104 @@ export function calcInvestPsychological(
return { plot0: toPlotData(bars, vals) };
}
/**
* RSI + 신호선 (업비트·TradingView Wilder RSI)
*/
export function calcRSI(
bars: OHLCVBar[],
params: Record<string, number | string | boolean>,
): Record<string, PlotData> {
const len = Math.max(1, Number(params.length ?? 14) || 14);
const maLen = Math.max(1, Number(params.maLength ?? 14) || 14);
const rawMa = String(params.maType ?? 'SMA');
const maType = rawMa === 'EMA' || rawMa === 'WMA' ? rawMa : (rawMa === 'None' ? 'None' : 'SMA');
const srcKey = String(params.src ?? 'close');
const prices = bars.map(b => sourceAt(b, srcKey));
const rsi = new Array<number>(prices.length).fill(NaN);
if (prices.length > len) {
let gain = 0;
let loss = 0;
for (let i = 1; i <= len; i++) {
const d = prices[i] - prices[i - 1];
if (d > 0) gain += d;
else loss -= d;
}
let avgG = gain / len;
let avgL = loss / len;
rsi[len] = avgL === 0 ? 100 : 100 - 100 / (1 + avgG / avgL);
for (let i = len + 1; i < prices.length; i++) {
const d = prices[i] - prices[i - 1];
const g = d > 0 ? d : 0;
const l = d < 0 ? -d : 0;
avgG = (avgG * (len - 1) + g) / len;
avgL = (avgL * (len - 1) + l) / len;
rsi[i] = avgL === 0 ? 100 : 100 - 100 / (1 + avgG / avgL);
}
}
let signal: number[];
if (maType === 'None') {
signal = rsi.map(() => NaN);
} else if (maType === 'EMA') {
signal = ema(rsi.map(v => (Number.isFinite(v) ? v : NaN)), maLen);
} else if (maType === 'WMA') {
signal = wma(rsi.map(v => (Number.isFinite(v) ? v : NaN)), maLen);
} else {
signal = sma(rsi, maLen);
}
return {
plot0: toPlotData(bars, rsi),
plot1: toPlotData(bars, signal),
};
}
/**
* CCI + 신호선 (업비트·TradingView)
* CCI = (src - SMA(src,N)) / (0.015 × MeanDeviation)
* 신호선 = CCI의 SMA/EMA/WMA (기본 SMA 10)
*/
export function calcCCI(
bars: OHLCVBar[],
params: Record<string, number | string | boolean>,
): Record<string, PlotData> {
const len = Math.max(1, Number(params.length ?? 13) || 13);
const maLen = Math.max(1, Number(params.maLength ?? 10) || 10);
const rawMa = String(params.maType ?? 'SMA');
const maType = rawMa === 'EMA' || rawMa === 'WMA' ? rawMa : (rawMa === 'None' ? 'None' : 'SMA');
const srcKey = String(params.src ?? 'close');
const prices = bars.map(b => sourceAt(b, srcKey));
const cci = prices.map((_, i) => {
if (i < len - 1) return NaN;
let sum = 0;
for (let j = i - len + 1; j <= i; j++) sum += prices[j];
const ma = sum / len;
let md = 0;
for (let j = i - len + 1; j <= i; j++) md += Math.abs(prices[j] - ma);
md /= len;
if (md === 0) return 0;
return (prices[i] - ma) / (0.015 * md);
});
let signal: number[];
if (maType === 'None') {
signal = cci.map(() => NaN);
} else if (maType === 'EMA') {
signal = ema(cci.map(v => (Number.isFinite(v) ? v : NaN)), maLen);
} else if (maType === 'WMA') {
signal = wma(cci.map(v => (Number.isFinite(v) ? v : NaN)), maLen);
} else {
signal = sma(cci, maLen);
}
return {
plot0: toPlotData(bars, cci),
plot1: toPlotData(bars, signal),
};
}
/**
* TRIX + TRMA(시그널) — 업비트·키움 HTS
* EMA3 = EMA(EMA(EMA(종가, N), N), N)
@@ -241,17 +353,7 @@ export function calcOBVWithMA(
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;
});
ma = wma(obv, maLen);
break;
default:
ma = sma(obv, maLen);
+13
View File
@@ -97,6 +97,19 @@ export function formatChartAxisPrice(price: number): string {
});
}
/** 보조지표 pane 우측 가격축·시리즈 라벨 (소수점 표시) */
export function formatIndicatorAxisPrice(price: number): string {
if (!Number.isFinite(price)) return String(price);
const abs = Math.abs(price);
if (abs >= 1_000) {
return price.toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
}
if (abs >= 1) {
return price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
return price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 4 });
}
export function formatTime(ts: number, timeframe: Timeframe): string {
return formatUnixForChart(ts, timeframe);
}
+18 -1
View File
@@ -295,7 +295,7 @@ export const INDICATOR_REGISTRY: IndicatorDef[] = [
{ 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 Slow', 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:STOCHASTIC_DEFAULT_HLINES, description:'Stochastic Slow %K/%D (Slowing 적용)' },
{ 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:STOCHASTIC_DEFAULT_HLINES, 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:'CCI', name:'Commodity Channel Index', koreanName:'상품채널지수', shortName:'CCI', category:'Oscillators', overlay:false, defaultParams:{length:13, src:'close', maType:'SMA', maLength:10}, plots:[{id:'plot0',title:'CCI',color:'#F23645',type:'line',lineWidth:2},{id:'plot1',title:'Signal',color:'#2962FF',type:'line',lineWidth:1}], hlines:[{price:100,color:'#EF5350'},{price:0,color:'#607D8B'},{price:-100,color:'#4CAF50'}], description:'상품 채널 지수 (업비트: 종가·신호 10)' },
{ 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:'챈드 모멘텀 오실레이터' },
@@ -454,6 +454,18 @@ export function normalizeObvParams(
return { ...params, maType, maLength };
}
/** 업비트 CCI: 기간 13·종가·신호 SMA 10 */
export function normalizeCciParams(
params: Record<string, number | string | boolean>,
): Record<string, number | string | boolean> {
const rawMa = String(params.maType ?? 'SMA');
const maType = rawMa === 'EMA' || rawMa === 'WMA' || rawMa === 'None' ? rawMa : 'SMA';
const src = String(params.src ?? 'close');
const length = Math.max(1, Number(params.length ?? 13) || 13);
const maLength = Math.max(1, Number(params.maLength ?? 10) || 10);
return { ...params, src, maType, length, maLength };
}
/** 차트·설정 모달용 — 파라미터·플롯·가시성을 registry 기본값과 병합 */
export function enrichIndicatorConfig(
cfg: import('../types').IndicatorConfig
@@ -492,6 +504,9 @@ export function enrichIndicatorConfig(
if (cfgNorm.type === 'OBV') {
params = normalizeObvParams(params);
}
if (cfgNorm.type === 'CCI') {
params = normalizeCciParams(params);
}
if (cfgNorm.type === 'Psychological' || cfgNorm.type === 'NewPsychological' || cfgNorm.type === 'InvestPsychological') {
params = normalizePsychologicalParams(cfgNorm.type, params);
}
@@ -592,6 +607,8 @@ async function loadCustomCalculators() {
CUSTOM_CALCULATORS.InvestPsychological = c.calcInvestPsychological;
CUSTOM_CALCULATORS.BollingerBands = c.calcBollingerBands;
CUSTOM_CALCULATORS.DMI = c.calcDMI;
CUSTOM_CALCULATORS.CCI = c.calcCCI;
CUSTOM_CALCULATORS.RSI = c.calcRSI;
}
/** 차트 심볼·타임프레임 (BB 다른 심볼용) */