goldenChat base source add
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
import type { AlertConditionDto } from '../../services/alertApi';
|
||||
import type { ConditionGroupDto, StrategyRuleDto } from '../../services/strategyRuleApi';
|
||||
import type { CandleUpdate } from '../../contexts/MultiChartMarketDataContext';
|
||||
import { mapWsIndicatorsToChartFormat } from '../investmentChart/wsIndicatorMap';
|
||||
|
||||
/** 형광 비교용 — RealtimeChartValuesPopup과 동일 원칙 */
|
||||
export function flashCompareTimeKeyMs(ts: number | undefined): string {
|
||||
if (ts == null || !Number.isFinite(ts)) return '—';
|
||||
const sec = ts > 1e12 ? Math.floor(ts / 1000) : Math.floor(ts);
|
||||
return `u:${sec}`;
|
||||
}
|
||||
|
||||
export function flashCompareNumKey(v: unknown): string {
|
||||
if (v === null || v === undefined || v === '') return '—';
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return '—';
|
||||
return n.toLocaleString('en-US', {
|
||||
useGrouping: false,
|
||||
maximumFractionDigits: 12,
|
||||
minimumFractionDigits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function formatDisplay(v: unknown): string {
|
||||
if (v === null || v === undefined || v === '') return '—';
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n)) return '—';
|
||||
const abs = Math.abs(n);
|
||||
if (abs >= 1000) return n.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
||||
if (abs >= 1) return n.toLocaleString(undefined, { maximumFractionDigits: 4 });
|
||||
if (abs >= 0.0001) return n.toLocaleString(undefined, { maximumFractionDigits: 6 });
|
||||
return n.toExponential(3);
|
||||
}
|
||||
|
||||
export interface StrategyMetricColumn {
|
||||
id: string;
|
||||
label: string;
|
||||
getCompareValue: (raw: Record<string, number>, candle: CandleUpdate, mapped: Record<string, unknown>) => string;
|
||||
getDisplayValue: (raw: Record<string, number>, candle: CandleUpdate, mapped: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
function numFromRawOrMapped(
|
||||
raw: Record<string, number>,
|
||||
mapped: Record<string, unknown>,
|
||||
rawKeys: string[],
|
||||
mappedKeys: string[],
|
||||
): number | null {
|
||||
for (const k of rawKeys) {
|
||||
const v = raw[k];
|
||||
if (v != null && Number.isFinite(Number(v))) return Number(v);
|
||||
}
|
||||
for (const k of mappedKeys) {
|
||||
const v = mapped[k];
|
||||
if (v != null && Number.isFinite(Number(v))) return Number(v);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function colFromNumber(
|
||||
id: string,
|
||||
label: string,
|
||||
rawKeys: string[],
|
||||
mappedKeys: string[],
|
||||
): StrategyMetricColumn {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
getCompareValue: (raw, _c, mapped) => {
|
||||
const n = numFromRawOrMapped(raw, mapped, rawKeys, mappedKeys);
|
||||
return n == null ? '—' : flashCompareNumKey(n);
|
||||
},
|
||||
getDisplayValue: (raw, _c, mapped) => {
|
||||
const n = numFromRawOrMapped(raw, mapped, rawKeys, mappedKeys);
|
||||
return n == null ? '—' : formatDisplay(n);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function walkConditionGroups(groups: ConditionGroupDto[] | undefined, acc: Set<string>) {
|
||||
if (!groups?.length) return;
|
||||
for (const g of groups) {
|
||||
for (const c of g.conditions || []) {
|
||||
if (c.enabled === false) continue;
|
||||
if (c.indicatorType) acc.add(c.indicatorType);
|
||||
}
|
||||
walkConditionGroups(g.childGroups, acc);
|
||||
}
|
||||
}
|
||||
|
||||
export function collectIndicatorTypesFromStrategyRule(rule: StrategyRuleDto): Set<string> {
|
||||
const s = new Set<string>();
|
||||
walkConditionGroups(rule.buyConditionGroups, s);
|
||||
walkConditionGroups(rule.sellConditionGroups, s);
|
||||
return s;
|
||||
}
|
||||
|
||||
export function collectIndicatorTypesFromAlertsAndGlobal(
|
||||
alerts: { enabled?: boolean; conditions?: AlertConditionDto[] }[],
|
||||
globalConds: AlertConditionDto[],
|
||||
): Set<string> {
|
||||
const s = new Set<string>();
|
||||
for (const c of globalConds) {
|
||||
if (c.enabled === false) continue;
|
||||
if (c.indicatorType) s.add(c.indicatorType);
|
||||
}
|
||||
for (const a of alerts) {
|
||||
if (a.enabled === false) continue;
|
||||
for (const c of a.conditions || []) {
|
||||
if (c.enabled === false) continue;
|
||||
if (c.indicatorType) s.add(c.indicatorType);
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function mapIndicatorTypeToColumns(t: string): StrategyMetricColumn[] {
|
||||
switch (t) {
|
||||
case 'RSI':
|
||||
return [colFromNumber('rsi', 'RSI', ['rsi_14'], ['rsi'])];
|
||||
case 'MACD':
|
||||
return [
|
||||
colFromNumber('macd', 'MACD', ['macd'], ['macdLine']),
|
||||
colFromNumber('macd_sig', 'MACD Sig', ['macd_signal'], ['signalLine', 'macdSignal']),
|
||||
colFromNumber('macd_hist', 'Hist', ['macd_histogram'], ['macdHistogram', 'histogram']),
|
||||
];
|
||||
case 'STOCHASTIC':
|
||||
case 'STOCHASTIC_K':
|
||||
return [colFromNumber('stoch_k', '%K', ['stoch_k'], ['stochasticK'])];
|
||||
case 'STOCHASTIC_D':
|
||||
return [colFromNumber('stoch_d', '%D', ['stoch_d'], ['stochasticD'])];
|
||||
case 'ADX':
|
||||
return [colFromNumber('adx', 'ADX', ['adx'], ['adx'])];
|
||||
case 'DMI':
|
||||
return [
|
||||
colFromNumber('plus_di', '+DI', ['plus_di'], ['plusDI']),
|
||||
colFromNumber('minus_di', '-DI', ['minus_di'], ['minusDI']),
|
||||
];
|
||||
case 'CCI':
|
||||
return [colFromNumber('cci', 'CCI', ['cci'], ['cci', 'cci13'])];
|
||||
case 'TRIX':
|
||||
return [
|
||||
colFromNumber('trix', 'TRIX', ['trix'], ['trix']),
|
||||
colFromNumber('trix_sig', 'TRIX Sig', ['trix_signal'], ['trixSignal']),
|
||||
];
|
||||
case 'DISPARITY':
|
||||
return [colFromNumber('disparity_mid', '이격도', ['disparity_mid'], ['disparityMid'])];
|
||||
case 'NEW_PSYCHOLOGICAL':
|
||||
return [colFromNumber('new_psy', '신심리', ['new_psy'], ['newPsy', 'newPsychological'])];
|
||||
case 'INVEST_PSYCHOLOGICAL':
|
||||
return [colFromNumber('invest_psy', '투심', ['invest_psy'], ['investPsy', 'investPsychological'])];
|
||||
case 'WILLIAMS_R':
|
||||
return [colFromNumber('williams_r', 'W%R', ['williams_r'], ['williamsR'])];
|
||||
case 'BWI':
|
||||
return [colFromNumber('bwi', 'BWI', ['bwi'], ['bwi'])];
|
||||
case 'OBV':
|
||||
return [
|
||||
colFromNumber('obv', 'OBV', ['obv'], ['obv']),
|
||||
colFromNumber('obv_sig', 'OBV Sig', ['obv_signal'], ['obvSignal']),
|
||||
];
|
||||
case 'VOLUME_OSC':
|
||||
return [colFromNumber('vol_osc', 'VolOSC', ['volume_osc'], ['volumeOsc'])];
|
||||
case 'VR':
|
||||
return [
|
||||
colFromNumber('vr', 'VR', ['vr'], ['vr']),
|
||||
colFromNumber('vr2', 'VR2', ['vr2'], ['vr2']),
|
||||
];
|
||||
case 'VOLUME':
|
||||
return [
|
||||
{
|
||||
id: 'vol_candle',
|
||||
label: '거래량(봉)',
|
||||
getCompareValue: (_r, c) => flashCompareNumKey(c.volume),
|
||||
getDisplayValue: (_r, c) => formatDisplay(c.volume),
|
||||
},
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_EXTRA: StrategyMetricColumn[] = [
|
||||
colFromNumber('rsi', 'RSI', ['rsi_14'], ['rsi']),
|
||||
colFromNumber('macd', 'MACD', ['macd'], ['macdLine']),
|
||||
];
|
||||
|
||||
export function buildStrategyMetricColumns(types: Set<string>): StrategyMetricColumn[] {
|
||||
const out: StrategyMetricColumn[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (cols: StrategyMetricColumn[]) => {
|
||||
for (const c of cols) {
|
||||
if (seen.has(c.id)) continue;
|
||||
seen.add(c.id);
|
||||
out.push(c);
|
||||
}
|
||||
};
|
||||
const sorted = Array.from(types).sort();
|
||||
for (const t of sorted) {
|
||||
add(mapIndicatorTypeToColumns(t));
|
||||
}
|
||||
if (out.length === 0) add(DEFAULT_EXTRA);
|
||||
return out;
|
||||
}
|
||||
|
||||
export const BASE_PRICE_COLUMNS: StrategyMetricColumn[] = [
|
||||
{
|
||||
id: 'c_ts',
|
||||
label: '봉시각(UTC초)',
|
||||
getCompareValue: (_r, c) => flashCompareTimeKeyMs(c.timestamp),
|
||||
getDisplayValue: (_r, c) => {
|
||||
const ms = c.timestamp > 1e12 ? c.timestamp : c.timestamp * 1000;
|
||||
return Number.isFinite(ms) ? new Date(ms).toISOString().slice(0, 19) : '—';
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c_o',
|
||||
label: '시가',
|
||||
getCompareValue: (_r, c) => flashCompareNumKey(c.open),
|
||||
getDisplayValue: (_r, c) => formatDisplay(c.open),
|
||||
},
|
||||
{
|
||||
id: 'c_h',
|
||||
label: '고가',
|
||||
getCompareValue: (_r, c) => flashCompareNumKey(c.high),
|
||||
getDisplayValue: (_r, c) => formatDisplay(c.high),
|
||||
},
|
||||
{
|
||||
id: 'c_l',
|
||||
label: '저가',
|
||||
getCompareValue: (_r, c) => flashCompareNumKey(c.low),
|
||||
getDisplayValue: (_r, c) => formatDisplay(c.low),
|
||||
},
|
||||
{
|
||||
id: 'c_c',
|
||||
label: '종가',
|
||||
getCompareValue: (_r, c) => flashCompareNumKey(c.close),
|
||||
getDisplayValue: (_r, c) => formatDisplay(c.close),
|
||||
},
|
||||
{
|
||||
id: 'c_v',
|
||||
label: '거래량',
|
||||
getCompareValue: (_r, c) => flashCompareNumKey(c.volume),
|
||||
getDisplayValue: (_r, c) => formatDisplay(c.volume),
|
||||
},
|
||||
];
|
||||
|
||||
export function buildFlatCompareMap(
|
||||
columns: StrategyMetricColumn[],
|
||||
raw: Record<string, number>,
|
||||
candle: CandleUpdate,
|
||||
): Record<string, string> {
|
||||
const mapped = mapWsIndicatorsToChartFormat(raw as Record<string, unknown>);
|
||||
const flat: Record<string, string> = {};
|
||||
for (const col of columns) {
|
||||
flat[col.id] = col.getCompareValue(raw, candle, mapped);
|
||||
}
|
||||
return flat;
|
||||
}
|
||||
|
||||
export function buildFlatDisplayMap(
|
||||
columns: StrategyMetricColumn[],
|
||||
raw: Record<string, number>,
|
||||
candle: CandleUpdate,
|
||||
): Record<string, string> {
|
||||
const mapped = mapWsIndicatorsToChartFormat(raw as Record<string, unknown>);
|
||||
const flat: Record<string, string> = {};
|
||||
for (const col of columns) {
|
||||
flat[col.id] = col.getDisplayValue(raw, candle, mapped);
|
||||
}
|
||||
return flat;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 통합 알림 메시지 파싱
|
||||
* 형식: "🎯 알림 조건 충족 (N개)\n종목명 × 전략명\n..."
|
||||
* 한 알림에 여러 종목이 포함될 수 있음
|
||||
*/
|
||||
|
||||
/**
|
||||
* 메시지에서 조건 충족한 투자전략명 목록 추출
|
||||
* "종목명 × 전략명" 형식의 각 라인에서 전략명 부분만 추출
|
||||
*/
|
||||
export function extractStrategyNames(message: string): string[] | null {
|
||||
if (!message || !message.includes('알림 조건 충족') || !message.includes('개)')) return null;
|
||||
const lines = message.split('\n').map((s) => s.trim()).filter(Boolean);
|
||||
const strategyNames: string[] = [];
|
||||
const SEPARATORS = [' × ', ' X '];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
let idx = -1;
|
||||
for (const sep of SEPARATORS) {
|
||||
idx = lines[i].indexOf(sep);
|
||||
if (idx >= 0) break;
|
||||
}
|
||||
if (idx >= 0) {
|
||||
const name = lines[i].substring(idx + 3).trim();
|
||||
if (name) strategyNames.push(name);
|
||||
}
|
||||
}
|
||||
return strategyNames.length > 0 ? Array.from(new Set(strategyNames)) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메시지에서 조건 충족한 종목명(한글) 목록 추출
|
||||
* "종목명 × 전략명" 형식의 각 라인에서 종목명 부분만 추출
|
||||
*/
|
||||
export function extractAssetNamesFromMessage(message: string): string[] | null {
|
||||
if (!message || !message.includes('알림 조건 충족') || !message.includes('개)')) return null;
|
||||
const lines = message.split('\n').map((s) => s.trim()).filter(Boolean);
|
||||
const names: string[] = [];
|
||||
const SEPARATORS = [' × ', ' X ']; // ×(유니코드), X(라틴) 모두 지원
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
let idx = -1;
|
||||
for (const sep of SEPARATORS) {
|
||||
idx = lines[i].indexOf(sep);
|
||||
if (idx >= 0) break;
|
||||
}
|
||||
if (idx >= 0) {
|
||||
const koreanName = lines[i].substring(0, idx).trim();
|
||||
if (koreanName) names.push(koreanName);
|
||||
}
|
||||
}
|
||||
return names.length > 0 ? Array.from(new Set(names)) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메시지에서 "종목명 × 전략명" 라인별 항목 추출 (종목+전략 쌍)
|
||||
*/
|
||||
export function extractStockStrategyPairs(message: string): Array<{ koreanName: string; strategyName: string }> | null {
|
||||
if (!message || !message.includes('알림 조건 충족') || !message.includes('개)')) return null;
|
||||
const lines = message.split('\n').map((s) => s.trim()).filter(Boolean);
|
||||
const pairs: Array<{ koreanName: string; strategyName: string }> = [];
|
||||
const SEPARATORS = [' × ', ' X '];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
let idx = -1;
|
||||
for (const sep of SEPARATORS) {
|
||||
idx = lines[i].indexOf(sep);
|
||||
if (idx >= 0) break;
|
||||
}
|
||||
if (idx >= 0) {
|
||||
const koreanName = lines[i].substring(0, idx).trim();
|
||||
const strategyName = lines[i].substring(idx + 3).trim();
|
||||
if (koreanName) pairs.push({ koreanName, strategyName: strategyName || '' });
|
||||
}
|
||||
}
|
||||
return pairs.length > 0 ? pairs : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 목록에서 symbol → 전략명[] 매핑 추출
|
||||
* 최신순 정렬된 알림에서, 각 종목별로 매칭된 전략명 수집 (중복 제거)
|
||||
*/
|
||||
export function extractSymbolToStrategyNames(
|
||||
notifications: Array<{ message?: string; symbol?: string; koreanName?: string; triggeredAt?: string }>,
|
||||
koreanToSymbol: Map<string, string>
|
||||
): Record<string, string[]> {
|
||||
const symbolToStrategies = new Map<string, Set<string>>();
|
||||
const sorted = [...notifications].sort((a, b) => {
|
||||
const at = a.triggeredAt ? new Date(a.triggeredAt).getTime() : 0;
|
||||
const bt = b.triggeredAt ? new Date(b.triggeredAt).getTime() : 0;
|
||||
return bt - at;
|
||||
});
|
||||
|
||||
for (const n of sorted) {
|
||||
const pairs = extractStockStrategyPairs(n.message || '');
|
||||
if (pairs && pairs.length > 0) {
|
||||
for (const { koreanName, strategyName } of pairs) {
|
||||
if (!strategyName?.trim()) continue;
|
||||
const row = resolveStockStrategyPairRow({ koreanName, strategyName }, koreanToSymbol);
|
||||
const s = row.navSymbol;
|
||||
if (s) {
|
||||
if (!symbolToStrategies.has(s)) symbolToStrategies.set(s, new Set());
|
||||
symbolToStrategies.get(s)!.add(strategyName.trim());
|
||||
}
|
||||
}
|
||||
} else if (n.symbol && n.koreanName) {
|
||||
// 단일 알림: 전략명은 메시지 첫 줄에서 추출
|
||||
const firstLine = (n.message || '').split('\n')[0]?.trim() || '';
|
||||
const strategyName = firstLine.replace(/^[\s🧪🎯✅]*/, '').trim();
|
||||
if (strategyName) {
|
||||
if (!symbolToStrategies.has(n.symbol)) symbolToStrategies.set(n.symbol, new Set());
|
||||
symbolToStrategies.get(n.symbol)!.add(strategyName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result: Record<string, string[]> = {};
|
||||
symbolToStrategies.forEach((strategies, symbol) => {
|
||||
result[symbol] = Array.from(strategies);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 통합 알림 한 줄의 왼쪽이 마켓코드(KRW-BTC)인 경우 — cryptoMap 키는 한글명이라 resolveSymbols만으로는 매칭 안 됨 */
|
||||
const UPBIT_MARKET_CODE_RE = /^(KRW|USDT|BTC)-[A-Z0-9]+$/i;
|
||||
|
||||
function koreanNameForMarketSymbol(symbol: string, cryptoMap: Map<string, string>, fallback: string): string {
|
||||
const s = symbol.trim().toUpperCase();
|
||||
const entries = Array.from(cryptoMap.entries());
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const [koreanName, sym] = entries[i];
|
||||
if (sym.toUpperCase() === s) return koreanName;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 통합 알림 종목 행 표시용: 한글명·심볼·업비트 URL용 navSymbol 정리
|
||||
* - 메시지가 "KRW-BTC × CCI 1"처럼 마켓코드만 있으면 navSymbol을 채우고 한글 종목명으로 바꿉니다.
|
||||
*/
|
||||
export function resolveStockStrategyPairRow(
|
||||
pair: { koreanName: string; strategyName: string; symbol?: string },
|
||||
cryptoMap: Map<string, string>
|
||||
): { koreanName: string; strategyName: string; symbol?: string; displaySymbol: string; navSymbol?: string } {
|
||||
if (pair.symbol?.trim()) {
|
||||
const navSymbol = pair.symbol.trim().toUpperCase();
|
||||
const left = pair.koreanName?.trim() || '';
|
||||
const koreanName =
|
||||
left && !UPBIT_MARKET_CODE_RE.test(left) ? pair.koreanName : koreanNameForMarketSymbol(navSymbol, cryptoMap, left || navSymbol);
|
||||
return { ...pair, koreanName, displaySymbol: navSymbol, navSymbol };
|
||||
}
|
||||
const raw = pair.koreanName?.trim() || '';
|
||||
if (UPBIT_MARKET_CODE_RE.test(raw)) {
|
||||
const navSymbol = raw.toUpperCase();
|
||||
const koreanName = koreanNameForMarketSymbol(navSymbol, cryptoMap, raw);
|
||||
return { ...pair, koreanName, displaySymbol: navSymbol, navSymbol };
|
||||
}
|
||||
const resolved = resolveSymbols([pair.koreanName], cryptoMap);
|
||||
return {
|
||||
...pair,
|
||||
displaySymbol: resolved[0]?.symbol ?? pair.strategyName,
|
||||
navSymbol: resolved[0]?.symbol,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* koreanName → symbol 매핑 (getActiveCryptos 결과 활용)
|
||||
* 공백 정규화 및 부분 일치로 한글명 표기 차이 보정
|
||||
*/
|
||||
export function resolveSymbols(
|
||||
koreanNames: string[],
|
||||
cryptoMap: Map<string, string>
|
||||
): Array<{ symbol: string; koreanName: string }> {
|
||||
const normalize = (s: string) => s.replace(/\s+/g, ' ').trim();
|
||||
const entries = Array.from(cryptoMap.entries());
|
||||
|
||||
const result: Array<{ symbol: string; koreanName: string }> = [];
|
||||
for (const kn of koreanNames) {
|
||||
const n = normalize(kn);
|
||||
let matched = entries.find(([k]) => normalize(k) === n || k === kn);
|
||||
if (!matched) {
|
||||
matched = entries.find(([k]) => normalize(k).includes(n) || n.includes(normalize(k)));
|
||||
}
|
||||
if (matched) result.push({ symbol: matched[1], koreanName: matched[0] });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { AlertNotificationDto } from '../services/alertApi';
|
||||
import { extractStockStrategyPairs } from './alertMessageParser';
|
||||
|
||||
/** Jackson 등에서 LocalDateTime이 배열로 올 때 ISO 문자열로 변환 */
|
||||
export function normalizeAlertDateTime(raw: unknown): string | null {
|
||||
if (raw == null) return null;
|
||||
if (typeof raw === 'string' && raw.trim()) return raw.trim();
|
||||
if (Array.isArray(raw) && raw.length >= 3) {
|
||||
const y = Number(raw[0]);
|
||||
const mo = Number(raw[1]);
|
||||
const d = Number(raw[2]);
|
||||
const h = raw.length > 3 ? Number(raw[3]) : 0;
|
||||
const mi = raw.length > 4 ? Number(raw[4]) : 0;
|
||||
const se = raw.length > 5 ? Number(raw[5]) : 0;
|
||||
if (![y, mo, d, h, mi, se].every((n) => Number.isFinite(n))) return null;
|
||||
const dt = new Date(y, mo - 1, d, h, mi, se);
|
||||
return Number.isFinite(dt.getTime()) ? dt.toISOString() : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 알림 발생 캔들 시각(가능하면 candleTime, 없으면 triggeredAt) */
|
||||
export function alertMarkerTimeIsoFromDto(n: Pick<AlertNotificationDto, 'triggeredAt' | 'candleTime'>): string | null {
|
||||
const fromCandle = normalizeAlertDateTime(n.candleTime as unknown);
|
||||
if (fromCandle) return fromCandle;
|
||||
const fromTrig = normalizeAlertDateTime(n.triggeredAt);
|
||||
if (fromTrig) return fromTrig;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 전략명 문자열만 보고 매수/매도 쪽 알림인지 추정 */
|
||||
export function inferAlertSignalFromStrategyName(strategyName: string): 'BUY' | 'SELL' | null {
|
||||
const s = (strategyName || '').trim();
|
||||
if (!s) return null;
|
||||
const hint = s.replace(/\s/g, '');
|
||||
if (/매도$/.test(hint) && !/과매도$/.test(hint)) return 'SELL';
|
||||
if (/매수$/.test(hint) && !/과매수$/.test(hint)) return 'BUY';
|
||||
if (s.includes('데드크로스')) return 'SELL';
|
||||
if (s.includes('골든크로스')) return 'BUY';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메시지/조건 설명·전략명에서 매수 알림 vs 매도 알림 추정.
|
||||
* 통합 알림이면 `koreanName`에 해당하는 줄의 전략명만 사용하는 것이 가장 정확함.
|
||||
*/
|
||||
export function inferAlertSignalFromNotification(
|
||||
n: Pick<AlertNotificationDto, 'message' | 'conditionDescription'>,
|
||||
koreanNameForRow?: string
|
||||
): 'BUY' | 'SELL' | null {
|
||||
if (koreanNameForRow?.trim()) {
|
||||
const pairs = extractStockStrategyPairs(n.message || '');
|
||||
const pair = pairs?.find((p) => p.koreanName.trim() === koreanNameForRow.trim());
|
||||
if (pair?.strategyName) {
|
||||
const fromPair = inferAlertSignalFromStrategyName(pair.strategyName);
|
||||
if (fromPair) return fromPair;
|
||||
}
|
||||
}
|
||||
|
||||
const raw = `${n.message || ''}\n${n.conditionDescription || ''}`;
|
||||
const pairsAll = extractStockStrategyPairs(n.message || '');
|
||||
if (pairsAll && pairsAll.length === 1) {
|
||||
const s = inferAlertSignalFromStrategyName(pairsAll[0].strategyName);
|
||||
if (s) return s;
|
||||
}
|
||||
|
||||
const hint = raw.replace(/\s/g, '');
|
||||
if (/매도$/.test(hint) && !/과매도$/.test(hint)) return 'SELL';
|
||||
if (/매수$/.test(hint) && !/과매수$/.test(hint)) return 'BUY';
|
||||
if (raw.includes('데드크로스')) return 'SELL';
|
||||
if (raw.includes('골든크로스')) return 'BUY';
|
||||
|
||||
const masked = raw.replace(/과매도/g, '').replace(/과매수/g, '');
|
||||
if (/매도조건|매도신호/.test(masked) && !/매수조건|매수신호/.test(masked)) return 'SELL';
|
||||
if (/매수조건|매수신호/.test(masked) && !/매도조건|매도신호/.test(masked)) return 'BUY';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function candleTimeToMs(c: { time: string | number }): number {
|
||||
if (typeof c.time === 'number') {
|
||||
return c.time >= 1e12 ? c.time : c.time * 1000;
|
||||
}
|
||||
return new Date(c.time).getTime();
|
||||
}
|
||||
|
||||
/** 알림 시각이 속한 봉(또는 직전 봉)에 맞춰 lightweight-charts용 Unix 초 */
|
||||
export function pickMarkerUnixSecondsForAlert<C extends { time: string | number }>(
|
||||
chartData: C[],
|
||||
alertTimeIso: string
|
||||
): number | null {
|
||||
if (!chartData.length) return null;
|
||||
const alertMs = new Date(alertTimeIso).getTime();
|
||||
if (!Number.isFinite(alertMs)) return null;
|
||||
|
||||
let best: C | null = null;
|
||||
let bestMs = -Infinity;
|
||||
for (const c of chartData) {
|
||||
const t = candleTimeToMs(c);
|
||||
if (!Number.isFinite(t)) continue;
|
||||
if (t <= alertMs && t > bestMs) {
|
||||
bestMs = t;
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
const pick = best ?? chartData[0];
|
||||
const sec = Math.floor(candleTimeToMs(pick) / 1000);
|
||||
return Number.isFinite(sec) ? sec : null;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { AlertConditionDto, AlertNotificationDetailDto, IndicatorDataDto } from '../services/alertApi';
|
||||
import type {
|
||||
BacktestResult,
|
||||
ChartIndicators,
|
||||
ConditionMatch,
|
||||
IndicatorData,
|
||||
TradeRecord,
|
||||
} from '../services/backtestApi';
|
||||
import { normalizeAlertDateTime } from './alertSignalInference';
|
||||
|
||||
function indicatorDtoToChartIndicators(d: IndicatorDataDto): ChartIndicators {
|
||||
const anyD = d as Record<string, number | undefined>;
|
||||
return {
|
||||
ma10: d.ma10,
|
||||
ma20: d.ma20,
|
||||
ma60: d.ma60,
|
||||
macdLine: d.macdLine,
|
||||
macdSignal: d.signalLine,
|
||||
macdHistogram: d.histogram,
|
||||
stochK: d.stochK,
|
||||
stochD: d.stochD,
|
||||
rsi: d.rsi,
|
||||
adx: d.adx,
|
||||
plusDi: d.plusDI ?? anyD.plusDi,
|
||||
minusDi: d.minusDI ?? anyD.minusDi,
|
||||
cci: d.cci,
|
||||
trix: d.trix,
|
||||
trixSignal: d.trixSignal,
|
||||
ichimokuTenkan: anyD.ichimokuTenkan,
|
||||
ichimokuKijun: anyD.ichimokuKijun,
|
||||
};
|
||||
}
|
||||
|
||||
/** 알림 상세 API 응답 → 투자분석 매수·매도 신호 상세 팝업용 TradeRecord */
|
||||
export function buildTradeRecordFromAlertDetail(
|
||||
detail: AlertNotificationDetailDto,
|
||||
tradeType: 'BUY' | 'SELL',
|
||||
strategyNameHint?: string
|
||||
): TradeRecord {
|
||||
const candleRaw = (detail as { candleTime?: unknown }).candleTime;
|
||||
const indicatorTime = detail.indicatorData?.time;
|
||||
const ts =
|
||||
normalizeAlertDateTime(indicatorTime) ??
|
||||
normalizeAlertDateTime(candleRaw) ??
|
||||
normalizeAlertDateTime(detail.triggeredAt) ??
|
||||
(detail.triggeredAt as string) ??
|
||||
'';
|
||||
|
||||
const o = detail.snapshotOpen ?? detail.currentPrice ?? 0;
|
||||
const h = detail.snapshotHigh ?? detail.currentPrice ?? 0;
|
||||
const l = detail.snapshotLow ?? detail.currentPrice ?? 0;
|
||||
const c = detail.snapshotClose ?? detail.currentPrice ?? 0;
|
||||
const v = detail.snapshotVolume ?? detail.volume ?? 0;
|
||||
|
||||
const strategyName =
|
||||
strategyNameHint?.trim() ||
|
||||
(detail.conditionDescription || '').replace(/^전략:\s*/, '').trim() ||
|
||||
undefined;
|
||||
|
||||
const indicators = detail.indicatorData ? indicatorDtoToChartIndicators(detail.indicatorData) : undefined;
|
||||
const matchedConditions: ConditionMatch[] | undefined = (detail.satisfiedConditions ?? []).map((cond) =>
|
||||
toConditionMatch(cond)
|
||||
);
|
||||
|
||||
return {
|
||||
timestamp: ts,
|
||||
tradeType,
|
||||
price: c,
|
||||
quantity: 0,
|
||||
totalAmount: 0,
|
||||
commission: 0,
|
||||
balance: 0,
|
||||
holdings: 0,
|
||||
strategyName,
|
||||
open: o,
|
||||
high: h,
|
||||
low: l,
|
||||
close: c,
|
||||
volume: v,
|
||||
indicators,
|
||||
matchedConditions,
|
||||
};
|
||||
}
|
||||
|
||||
function toConditionMatch(cond: AlertConditionDto): ConditionMatch {
|
||||
const indicator = (cond.indicatorType || '').toUpperCase();
|
||||
const condition = (cond.conditionType || '').toUpperCase();
|
||||
return {
|
||||
conditionType: `${indicator}_${condition}`,
|
||||
indicatorName: cond.indicatorType,
|
||||
description: cond.description || `${cond.indicatorType} ${cond.conditionType}`,
|
||||
targetValue: cond.targetValue,
|
||||
secondaryValue: cond.secondaryValue,
|
||||
matched: true,
|
||||
};
|
||||
}
|
||||
|
||||
function toIndicatorData(row: IndicatorDataDto): IndicatorData {
|
||||
return {
|
||||
time: row.time ?? '',
|
||||
timestamp: row.time ?? '',
|
||||
macdLine: row.macdLine,
|
||||
signalLine: row.signalLine,
|
||||
macdSignal: row.signalLine,
|
||||
histogram: row.histogram,
|
||||
macdHistogram: row.histogram,
|
||||
stochK: row.stochK,
|
||||
stochD: row.stochD,
|
||||
adx: row.adx,
|
||||
plusDI: row.plusDI,
|
||||
minusDI: row.minusDI,
|
||||
cci: row.cci,
|
||||
rsi: row.rsi,
|
||||
trix: row.trix,
|
||||
trixSignal: row.trixSignal,
|
||||
ma10: row.ma10,
|
||||
ma20: row.ma20,
|
||||
ma60: row.ma60,
|
||||
ma120: row.ma120,
|
||||
ichimokuTenkan: row.ichimokuTenkan,
|
||||
ichimokuKijun: row.ichimokuKijun,
|
||||
ichimokuSenkouA: row.ichimokuSenkouA,
|
||||
ichimokuSenkouB: row.ichimokuSenkouB,
|
||||
ichimokuChikou: row.ichimokuChikou,
|
||||
open: row.open,
|
||||
high: row.high,
|
||||
low: row.low,
|
||||
close: row.price,
|
||||
volume: row.volume,
|
||||
};
|
||||
}
|
||||
|
||||
/** 알림 상세 API 응답 → 투자분석 신호상세 차트용 최소 BacktestResult */
|
||||
export function buildBacktestResultFromAlertDetail(
|
||||
detail: AlertNotificationDetailDto,
|
||||
trade: TradeRecord
|
||||
): BacktestResult | null {
|
||||
const indicatorData = (detail.indicatorDataSeries ?? [])
|
||||
.map(toIndicatorData)
|
||||
.filter((x) => !!x.time);
|
||||
|
||||
if (indicatorData.length === 0) return null;
|
||||
|
||||
const symbol = detail.symbol || 'UNKNOWN';
|
||||
const strategyName =
|
||||
trade.strategyName || (detail.conditionDescription || '').replace(/^전략:\s*/, '').trim() || 'Alert Strategy';
|
||||
|
||||
return {
|
||||
symbol,
|
||||
strategyId: -1,
|
||||
strategyName,
|
||||
strategyType: 'ALERT_CONTEXT',
|
||||
initialCapital: 0,
|
||||
finalCapital: 0,
|
||||
netProfit: 0,
|
||||
returnRate: 0,
|
||||
totalTrades: 1,
|
||||
buyCount: trade.tradeType === 'BUY' ? 1 : 0,
|
||||
sellCount: trade.tradeType === 'SELL' ? 1 : 0,
|
||||
winningTrades: 0,
|
||||
losingTrades: 0,
|
||||
winRate: 0,
|
||||
maxProfit: 0,
|
||||
maxLoss: 0,
|
||||
avgProfit: 0,
|
||||
avgLoss: 0,
|
||||
maxConsecutiveWins: 0,
|
||||
maxConsecutiveLosses: 0,
|
||||
maxCapital: 0,
|
||||
maxDrawdown: 0,
|
||||
sharpeRatio: 0,
|
||||
totalCommission: 0,
|
||||
tradeRecords: [trade],
|
||||
performanceHistory: [],
|
||||
candleData: [],
|
||||
indicatorData,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { CandleData } from '../services/backtestApi';
|
||||
|
||||
/**
|
||||
* 차트 캔들 time 필드 → Unix ms.
|
||||
* REST/내부에서 toISOString() 후 'Z'를 뺀 문자열은 UTC로 해석해야 Upbit WS·alignSec와 일치함.
|
||||
*/
|
||||
export function parseChartCandleTimeToUnixMs(c: CandleData | undefined): number {
|
||||
if (!c || c.time == null || (typeof c.time === 'string' && c.time.trim() === '')) return 0;
|
||||
const t = c.time as string | number;
|
||||
if (typeof t === 'number') {
|
||||
return t > 1e12 ? t : t * 1000;
|
||||
}
|
||||
const s = String(t).trim();
|
||||
if (!s) return 0;
|
||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(s) && !/[zZ]|[+-]\d{2}:?\d{2}$/.test(s)) {
|
||||
const ms = Date.parse(`${s}Z`);
|
||||
if (Number.isFinite(ms)) return ms;
|
||||
}
|
||||
const ms = Date.parse(s);
|
||||
return Number.isFinite(ms) ? ms : 0;
|
||||
}
|
||||
|
||||
/** 단일 time 값 → Unix 초 (메인 buildCandleDataRaw / updateLastCandle / 보조지표 toUnix 공통) */
|
||||
export function chartTimeValueToUnixSeconds(v: string | number | undefined | null): number | null {
|
||||
if (v == null || v === '') return null;
|
||||
if (typeof v === 'number') {
|
||||
if (!isFinite(v)) return null;
|
||||
return Math.floor(v >= 1e12 ? v / 1000 : v);
|
||||
}
|
||||
const ms = parseChartCandleTimeToUnixMs({ time: v } as CandleData);
|
||||
if (!ms) return null;
|
||||
return Math.floor(ms / 1000);
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
import {
|
||||
createChart,
|
||||
CandlestickSeries,
|
||||
BarSeries,
|
||||
LineSeries,
|
||||
AreaSeries,
|
||||
BaselineSeries,
|
||||
HistogramSeries,
|
||||
createSeriesMarkers,
|
||||
type IChartApi,
|
||||
type ISeriesApi,
|
||||
type SeriesType,
|
||||
type MouseEventParams,
|
||||
type Time,
|
||||
type IPriceLine,
|
||||
type ISeriesMarkersPluginApi,
|
||||
ColorType,
|
||||
LineStyle,
|
||||
} from 'lightweight-charts-v5';
|
||||
import type { OHLCVBar, ChartType, Theme, IndicatorConfig } from './types';
|
||||
import { calculateIndicator, getIndicatorDef, type PlotData, type MarkerData } from './indicatorRegistry';
|
||||
import { calcAutoFib } from './calculations';
|
||||
|
||||
interface ThemeTokens {
|
||||
bgColor: string;
|
||||
textColor: string;
|
||||
gridColor: string;
|
||||
borderColor: string;
|
||||
crosshairColor: string;
|
||||
upColor: string;
|
||||
downColor: string;
|
||||
}
|
||||
|
||||
const DARK: ThemeTokens = {
|
||||
bgColor: '#131722',
|
||||
textColor: '#D1D4DC',
|
||||
gridColor: '#1e2330',
|
||||
borderColor: '#2A2E39',
|
||||
crosshairColor: '#9598A1',
|
||||
upColor: '#26a69a',
|
||||
downColor: '#ef5350',
|
||||
};
|
||||
const LIGHT: ThemeTokens = {
|
||||
bgColor: '#FFFFFF',
|
||||
textColor: '#131722',
|
||||
gridColor: '#F0F3FA',
|
||||
borderColor: '#E0E3EB',
|
||||
crosshairColor: '#758696',
|
||||
upColor: '#26a69a',
|
||||
downColor: '#ef5350',
|
||||
};
|
||||
|
||||
interface IndicatorEntry {
|
||||
id: string;
|
||||
type: string;
|
||||
seriesList: ISeriesApi<SeriesType>[];
|
||||
paneIndex?: number;
|
||||
alertLines: IPriceLine[];
|
||||
config: IndicatorConfig;
|
||||
}
|
||||
|
||||
interface AlertEntry { price: number; label: string; line: IPriceLine; }
|
||||
interface FibLevel { price: number; label: string; line: IPriceLine; }
|
||||
|
||||
function getTheme(theme: Theme): ThemeTokens {
|
||||
return theme === 'dark' ? DARK : LIGHT;
|
||||
}
|
||||
|
||||
export class ChartManager {
|
||||
private chart: IChartApi;
|
||||
private container: HTMLElement;
|
||||
private mainSeries: ISeriesApi<SeriesType> | null = null;
|
||||
private volumeSeries: ISeriesApi<'Histogram'> | null = null;
|
||||
private indicators = new Map<string, IndicatorEntry>();
|
||||
private alertEntries: AlertEntry[] = [];
|
||||
private fibLines: FibLevel[] = [];
|
||||
private drawingTool = 'cursor';
|
||||
private drawingPoints: { time: Time; price: number }[] = [];
|
||||
private drawingLines: IPriceLine[] = [];
|
||||
private _clickHandler: ((p: MouseEventParams<Time>) => void) | null = null;
|
||||
private rawBars: OHLCVBar[] = [];
|
||||
private currentTheme: Theme = 'dark';
|
||||
private mainMarkersPlugin: ISeriesMarkersPluginApi<Time> | null = null;
|
||||
private patternMarkers: Array<{ id: string; markers: MarkerData[] }> = [];
|
||||
private compareSeriesMap = new Map<string, ISeriesApi<SeriesType>>();
|
||||
|
||||
constructor(container: HTMLElement, theme: Theme) {
|
||||
this.container = container;
|
||||
const t = getTheme(theme);
|
||||
this.chart = createChart(container, {
|
||||
layout: {
|
||||
background: { type: ColorType.Solid, color: t.bgColor },
|
||||
textColor: t.textColor,
|
||||
fontSize: 12,
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: t.gridColor },
|
||||
horzLines: { color: t.gridColor },
|
||||
},
|
||||
crosshair: {
|
||||
vertLine: { color: t.crosshairColor, labelBackgroundColor: '#9598A1' },
|
||||
horzLine: { color: t.crosshairColor, labelBackgroundColor: '#9598A1' },
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: t.borderColor,
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
rightOffset: 8,
|
||||
},
|
||||
rightPriceScale: { borderColor: t.borderColor },
|
||||
handleScroll: { mouseWheel: true, pressedMouseMove: true, horzTouchDrag: true, vertTouchDrag: false },
|
||||
handleScale: { axisPressedMouseMove: true, axisDoubleClickReset: true, mouseWheel: true, pinch: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** 내부 IChartApi 직접 접근 (커스텀 시리즈 추가 등) */
|
||||
getChart(): IChartApi { return this.chart; }
|
||||
|
||||
// ─── Data ───────────────────────────────────────────────────────────────
|
||||
setData(bars: OHLCVBar[], chartType: ChartType, theme: Theme): void {
|
||||
this.rawBars = bars;
|
||||
this.currentTheme = theme;
|
||||
const t = getTheme(theme);
|
||||
|
||||
if (this.mainSeries) { try { this.chart.removeSeries(this.mainSeries); } catch { /* ok */ } this.mainSeries = null; }
|
||||
if (this.volumeSeries) { try { this.chart.removeSeries(this.volumeSeries); } catch { /* ok */ } this.volumeSeries = null; }
|
||||
this.mainMarkersPlugin = null;
|
||||
|
||||
this.mainSeries = this._createMainSeries(chartType, t);
|
||||
const mainData = bars.map(b => {
|
||||
if (chartType === 'line' || chartType === 'area' || chartType === 'baseline')
|
||||
return { time: b.time as Time, value: b.close };
|
||||
return { time: b.time as Time, open: b.open, high: b.high, low: b.low, close: b.close };
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(this.mainSeries as ISeriesApi<any>).setData(mainData);
|
||||
|
||||
// Volume sub-pane (pane index 1)
|
||||
this.volumeSeries = this.chart.addSeries(HistogramSeries, {
|
||||
priceFormat: { type: 'volume' },
|
||||
priceScaleId: 'volume',
|
||||
}, 1);
|
||||
|
||||
const panes = this.chart.panes();
|
||||
if (panes[1]) panes[1].setHeight(60);
|
||||
|
||||
this.volumeSeries.setData(bars.map(b => ({
|
||||
time: b.time as Time,
|
||||
value: b.volume,
|
||||
color: b.close >= b.open ? `${t.upColor}88` : `${t.downColor}88`,
|
||||
})));
|
||||
|
||||
this._reapplyAllPatternMarkers();
|
||||
this.chart.timeScale().fitContent();
|
||||
}
|
||||
|
||||
private _createMainSeries(chartType: ChartType, t: ThemeTokens): ISeriesApi<SeriesType> {
|
||||
switch (chartType) {
|
||||
case 'candlestick':
|
||||
return this.chart.addSeries(CandlestickSeries, {
|
||||
upColor: t.upColor, downColor: t.downColor,
|
||||
borderUpColor: t.upColor, borderDownColor: t.downColor,
|
||||
wickUpColor: t.upColor, wickDownColor: t.downColor,
|
||||
});
|
||||
case 'bar':
|
||||
return this.chart.addSeries(BarSeries, { upColor: t.upColor, downColor: t.downColor });
|
||||
case 'line':
|
||||
return this.chart.addSeries(LineSeries, { color: '#2962FF', lineWidth: 2, crosshairMarkerVisible: true });
|
||||
case 'area':
|
||||
return this.chart.addSeries(AreaSeries, {
|
||||
lineColor: '#2962FF', topColor: '#2962FF44', bottomColor: '#2962FF00', lineWidth: 2,
|
||||
});
|
||||
case 'baseline':
|
||||
return this.chart.addSeries(BaselineSeries, {
|
||||
baseValue: { type: 'price', price: 0 },
|
||||
topLineColor: '#26a69a', topFillColor1: '#26a69a44', topFillColor2: '#26a69a00',
|
||||
bottomLineColor: '#ef5350', bottomFillColor1: '#ef535000', bottomFillColor2: '#ef535044',
|
||||
});
|
||||
default:
|
||||
return this.chart.addSeries(CandlestickSeries, {
|
||||
upColor: t.upColor, downColor: t.downColor,
|
||||
borderUpColor: t.upColor, borderDownColor: t.downColor,
|
||||
wickUpColor: t.upColor, wickDownColor: t.downColor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Theme ──────────────────────────────────────────────────────────────
|
||||
setTheme(theme: Theme): void {
|
||||
const t = getTheme(theme);
|
||||
this.chart.applyOptions({
|
||||
layout: { background: { type: ColorType.Solid, color: t.bgColor }, textColor: t.textColor },
|
||||
grid: { vertLines: { color: t.gridColor }, horzLines: { color: t.gridColor } },
|
||||
crosshair: { vertLine: { color: t.crosshairColor, labelBackgroundColor: '#9598A1' }, horzLine: { color: t.crosshairColor, labelBackgroundColor: '#9598A1' } },
|
||||
timeScale: { borderColor: t.borderColor },
|
||||
rightPriceScale: { borderColor: t.borderColor },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Indicators ─────────────────────────────────────────────────────────
|
||||
async addIndicator(config: IndicatorConfig): Promise<void> {
|
||||
if (this.indicators.has(config.id) || this.rawBars.length === 0) return;
|
||||
const def = getIndicatorDef(config.type);
|
||||
const seriesList: ISeriesApi<SeriesType>[] = [];
|
||||
|
||||
try {
|
||||
const result = await calculateIndicator(config.type, this.rawBars, config.params);
|
||||
|
||||
if (def?.returnsMarkers && result.markers && result.markers.length > 0) {
|
||||
this.patternMarkers.push({ id: config.id, markers: result.markers });
|
||||
this._reapplyAllPatternMarkers();
|
||||
this.indicators.set(config.id, { id: config.id, type: config.type, seriesList: [], alertLines: [], config });
|
||||
return;
|
||||
}
|
||||
|
||||
const pane = def?.overlay ? 0 : this._getNextSubPane();
|
||||
const plotDefs = config.plots ?? def?.plots ?? [];
|
||||
|
||||
for (const plotDef of plotDefs) {
|
||||
const plotData = result.plots[plotDef.id] as PlotData | undefined;
|
||||
if (!plotData || plotData.length === 0) continue;
|
||||
const filtered = plotData.filter(p => p.value !== null && !isNaN(p.value));
|
||||
if (filtered.length === 0) continue;
|
||||
|
||||
let series: ISeriesApi<SeriesType>;
|
||||
if (plotDef.type === 'histogram') {
|
||||
series = this.chart.addSeries(HistogramSeries, { color: plotDef.color }, pane);
|
||||
const colorData = filtered.map((p, i, arr) => ({
|
||||
time: p.time as Time,
|
||||
value: p.value,
|
||||
color: p.color ?? (i > 0 && p.value < arr[i - 1].value
|
||||
? '#ef535088'
|
||||
: p.value < 0 ? '#ef535088' : `${plotDef.color}88`),
|
||||
}));
|
||||
series.setData(colorData);
|
||||
} else {
|
||||
series = this.chart.addSeries(LineSeries, {
|
||||
color: plotDef.color,
|
||||
lineWidth: (plotDef.lineWidth ?? 1) as 1 | 2 | 3 | 4,
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: true,
|
||||
}, pane);
|
||||
series.setData(filtered.map(p => ({ time: p.time as Time, value: p.value })));
|
||||
}
|
||||
|
||||
const hlines = config.hlines ?? def?.hlines ?? [];
|
||||
for (const hl of hlines) {
|
||||
series.createPriceLine({ price: hl.price, color: hl.color, lineWidth: 1, lineStyle: LineStyle.Dashed, axisLabelVisible: false });
|
||||
}
|
||||
seriesList.push(series);
|
||||
}
|
||||
|
||||
this.indicators.set(config.id, { id: config.id, type: config.type, seriesList, paneIndex: pane, alertLines: [], config });
|
||||
} catch (e) {
|
||||
console.error(`[ChartV5 Indicator] ${config.type} error:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
removeIndicator(id: string): void {
|
||||
const entry = this.indicators.get(id);
|
||||
if (!entry) return;
|
||||
for (const s of entry.seriesList) { try { this.chart.removeSeries(s); } catch { /* ok */ } }
|
||||
this.patternMarkers = this.patternMarkers.filter(p => p.id !== id);
|
||||
this._reapplyAllPatternMarkers();
|
||||
this.indicators.delete(id);
|
||||
}
|
||||
|
||||
private _reapplyAllPatternMarkers(): void {
|
||||
if (!this.mainSeries) return;
|
||||
const all = this.patternMarkers.flatMap(({ markers }) =>
|
||||
markers.map(m => ({
|
||||
time: m.time as Time,
|
||||
position: m.position,
|
||||
shape: m.shape,
|
||||
color: m.color,
|
||||
text: m.text,
|
||||
}))
|
||||
);
|
||||
if (!this.mainMarkersPlugin) {
|
||||
this.mainMarkersPlugin = createSeriesMarkers(this.mainSeries, all);
|
||||
} else {
|
||||
this.mainMarkersPlugin.setMarkers(all);
|
||||
}
|
||||
}
|
||||
|
||||
private _getNextSubPane(): number {
|
||||
const used = new Set(Array.from(this.indicators.values()).map(e => e.paneIndex).filter(Boolean));
|
||||
let pane = 2;
|
||||
while (used.has(pane)) pane++;
|
||||
return pane;
|
||||
}
|
||||
|
||||
// ─── 실시간 바 업데이트 ────────────────────────────────────────────────
|
||||
updateBar(bar: OHLCVBar): void {
|
||||
if (!this.mainSeries) return;
|
||||
const t = getTheme(this.currentTheme);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(this.mainSeries as ISeriesApi<any>).update({
|
||||
time: bar.time as Time,
|
||||
open: bar.open, high: bar.high, low: bar.low, close: bar.close,
|
||||
});
|
||||
|
||||
this.volumeSeries?.update({
|
||||
time: bar.time as Time,
|
||||
value: bar.volume,
|
||||
color: bar.close >= bar.open ? `${t.upColor}88` : `${t.downColor}88`,
|
||||
});
|
||||
|
||||
if (this.rawBars.length > 0) {
|
||||
const last = this.rawBars[this.rawBars.length - 1];
|
||||
if (last.time === bar.time) {
|
||||
this.rawBars[this.rawBars.length - 1] = bar;
|
||||
} else if (bar.time > last.time) {
|
||||
this.rawBars.push(bar);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async appendBar(bar: OHLCVBar): Promise<void> {
|
||||
if (!this.mainSeries) return;
|
||||
const t = getTheme(this.currentTheme);
|
||||
|
||||
const last = this.rawBars[this.rawBars.length - 1];
|
||||
if (last && last.time === bar.time) {
|
||||
this.rawBars[this.rawBars.length - 1] = bar;
|
||||
} else {
|
||||
this.rawBars.push(bar);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(this.mainSeries as ISeriesApi<any>).update({
|
||||
time: bar.time as Time,
|
||||
open: bar.open, high: bar.high, low: bar.low, close: bar.close,
|
||||
});
|
||||
|
||||
this.volumeSeries?.update({
|
||||
time: bar.time as Time,
|
||||
value: bar.volume,
|
||||
color: bar.close >= bar.open ? `${t.upColor}88` : `${t.downColor}88`,
|
||||
});
|
||||
|
||||
for (const [, entry] of Array.from(this.indicators.entries())) {
|
||||
if (!entry.config || entry.seriesList.length === 0) continue;
|
||||
if (entry.config.type) {
|
||||
try {
|
||||
const { plots } = await calculateIndicator(
|
||||
entry.config.type, this.rawBars, entry.config.params ?? {}
|
||||
);
|
||||
const plotValues = Object.values(plots);
|
||||
for (let i = 0; i < entry.seriesList.length && i < plotValues.length; i++) {
|
||||
const plotData = plotValues[i];
|
||||
if (!plotData?.length) continue;
|
||||
const lastPoint = plotData[plotData.length - 1];
|
||||
if (lastPoint) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(entry.seriesList[i] as ISeriesApi<any>).update(lastPoint as any);
|
||||
}
|
||||
}
|
||||
} catch { /* 인디케이터 계산 실패 무시 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async recalculateIndicators(newBars: OHLCVBar[]): Promise<void> {
|
||||
this.rawBars = newBars;
|
||||
|
||||
const configs = Array.from(this.indicators.values()).map(e => e.config);
|
||||
|
||||
for (const entry of Array.from(this.indicators.values())) {
|
||||
for (const s of entry.seriesList) { try { this.chart.removeSeries(s); } catch { /* ok */ } }
|
||||
}
|
||||
this.indicators.clear();
|
||||
this.patternMarkers = [];
|
||||
this._reapplyAllPatternMarkers();
|
||||
|
||||
for (const cfg of configs) {
|
||||
await this.addIndicator(cfg);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 커스텀 시리즈 (pre-calculated data) ────────────────────────────────
|
||||
addCustomSeriesGroup(
|
||||
id: string,
|
||||
seriesSpecs: Array<{
|
||||
data: Array<{ time: number; value: number; color?: string }>;
|
||||
seriesType: 'line' | 'histogram';
|
||||
color: string;
|
||||
lineWidth?: number;
|
||||
}>,
|
||||
hlines?: Array<{ price: number; color: string }>,
|
||||
pane?: number
|
||||
): void {
|
||||
if (this.indicators.has(id)) return;
|
||||
const targetPane = pane ?? this._getNextSubPane();
|
||||
const seriesList: ISeriesApi<SeriesType>[] = [];
|
||||
|
||||
for (const spec of seriesSpecs) {
|
||||
if (!spec.data || spec.data.length === 0) continue;
|
||||
const filtered = spec.data.filter(p => p.value !== null && !isNaN(p.value));
|
||||
if (filtered.length === 0) continue;
|
||||
|
||||
let series: ISeriesApi<SeriesType>;
|
||||
if (spec.seriesType === 'histogram') {
|
||||
series = this.chart.addSeries(HistogramSeries, { color: spec.color }, targetPane);
|
||||
series.setData(filtered.map(p => ({
|
||||
time: p.time as Time,
|
||||
value: p.value,
|
||||
color: p.color ?? (p.value < 0 ? '#ef535088' : `${spec.color}88`),
|
||||
})));
|
||||
} else {
|
||||
series = this.chart.addSeries(LineSeries, {
|
||||
color: spec.color,
|
||||
lineWidth: (spec.lineWidth ?? 1) as 1 | 2 | 3 | 4,
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: true,
|
||||
}, targetPane);
|
||||
series.setData(filtered.map(p => ({ time: p.time as Time, value: p.value })));
|
||||
}
|
||||
|
||||
if (hlines) {
|
||||
for (const hl of hlines) {
|
||||
series.createPriceLine({ price: hl.price, color: hl.color, lineWidth: 1, lineStyle: LineStyle.Dashed, axisLabelVisible: false });
|
||||
}
|
||||
}
|
||||
seriesList.push(series);
|
||||
}
|
||||
|
||||
this.indicators.set(id, {
|
||||
id, type: '__custom__', seriesList, paneIndex: targetPane, alertLines: [],
|
||||
config: { id, type: '__custom__', params: {} },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Alerts ──────────────────────────────────────────────────────────────
|
||||
addAlert(price: number, label = ''): void {
|
||||
if (!this.mainSeries) return;
|
||||
const line = this.mainSeries.createPriceLine({
|
||||
price, color: '#FFB300', lineWidth: 1, lineStyle: LineStyle.Dashed,
|
||||
axisLabelVisible: true, title: label || `Alert ${price.toFixed(2)}`,
|
||||
});
|
||||
this.alertEntries.push({ price, label, line });
|
||||
}
|
||||
|
||||
removeAlert(price: number): void {
|
||||
const idx = this.alertEntries.findIndex(a => Math.abs(a.price - price) < 0.0001);
|
||||
if (idx < 0) return;
|
||||
try { this.mainSeries?.removePriceLine(this.alertEntries[idx].line); } catch { /* ok */ }
|
||||
this.alertEntries.splice(idx, 1);
|
||||
}
|
||||
|
||||
getAlerts(): Array<{ price: number; label: string }> {
|
||||
return this.alertEntries.map(({ price, label }) => ({ price, label }));
|
||||
}
|
||||
|
||||
// ─── Auto Fibonacci ──────────────────────────────────────────────────────
|
||||
drawAutoFib(lookback = 50): void {
|
||||
if (!this.mainSeries || this.rawBars.length === 0) return;
|
||||
this._clearFibLines();
|
||||
const { levels } = calcAutoFib(this.rawBars, lookback);
|
||||
const COLORS: Record<string, string> = {
|
||||
'0%': '#EF5350', '23.6%': '#FF9800', '38.2%': '#FFC107',
|
||||
'50%': '#FFFFFF', '61.8%': '#4CAF50', '78.6%': '#2196F3',
|
||||
'100%': '#9C27B0', '127.2%': '#607D8B', '161.8%': '#607D8B',
|
||||
};
|
||||
for (const l of levels) {
|
||||
const line = this.mainSeries.createPriceLine({
|
||||
price: l.price, color: COLORS[l.label] ?? '#607D8B',
|
||||
lineWidth: l.label === '0%' || l.label === '100%' ? 2 : 1,
|
||||
lineStyle: l.label === '50%' ? LineStyle.Dashed : LineStyle.Dotted,
|
||||
axisLabelVisible: true, title: `Fib ${l.label}`,
|
||||
});
|
||||
this.fibLines.push({ price: l.price, label: l.label, line });
|
||||
}
|
||||
}
|
||||
|
||||
clearFib(): void { this._clearFibLines(); }
|
||||
private _clearFibLines(): void {
|
||||
for (const f of this.fibLines) { try { this.mainSeries?.removePriceLine(f.line); } catch { /* ok */ } }
|
||||
this.fibLines = [];
|
||||
}
|
||||
|
||||
// ─── Crosshair ────────────────────────────────────────────────────────────
|
||||
subscribeCrosshair(cb: (p: MouseEventParams<Time>) => void): () => void {
|
||||
this.chart.subscribeCrosshairMove(cb);
|
||||
return () => this.chart.unsubscribeCrosshairMove(cb);
|
||||
}
|
||||
|
||||
// ─── Layout ───────────────────────────────────────────────────────────────
|
||||
fitContent(): void { this.chart.timeScale().fitContent(); }
|
||||
setLogScale(enabled: boolean): void {
|
||||
this.mainSeries?.applyOptions({ priceScale: { mode: enabled ? 1 : 0 } } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
|
||||
}
|
||||
resize(w: number, h: number): void { this.chart.resize(w, h); }
|
||||
|
||||
// ─── Compare overlay ──────────────────────────────────────────────────────
|
||||
addCompareSeries(symbol: string, bars: OHLCVBar[], color: string): void {
|
||||
if (this.compareSeriesMap.has(symbol)) return;
|
||||
const s = this.chart.addSeries(LineSeries, { color, lineWidth: 2, priceLineVisible: false });
|
||||
const base = bars[0]?.close ?? 1;
|
||||
s.setData(bars.map(b => ({ time: b.time as Time, value: (b.close / base - 1) * 100 })));
|
||||
this.compareSeriesMap.set(symbol, s);
|
||||
}
|
||||
|
||||
removeCompareSeries(symbol: string): void {
|
||||
const s = this.compareSeriesMap.get(symbol);
|
||||
if (s) { try { this.chart.removeSeries(s); } catch { /* ok */ } this.compareSeriesMap.delete(symbol); }
|
||||
}
|
||||
|
||||
// ─── Coordinate conversion ───────────────────────────────────────────────
|
||||
timeToX(time: number): number | null {
|
||||
const coord = this.chart.timeScale().timeToCoordinate(time as Time);
|
||||
return coord === null ? null : coord;
|
||||
}
|
||||
|
||||
priceToY(price: number): number | null {
|
||||
const coord = this.mainSeries?.priceToCoordinate(price);
|
||||
return coord === null || coord === undefined ? null : coord;
|
||||
}
|
||||
|
||||
xToTime(x: number): number | null {
|
||||
const t = this.chart.timeScale().coordinateToTime(x);
|
||||
return t === null ? null : (t as number);
|
||||
}
|
||||
|
||||
yToPrice(y: number): number | null {
|
||||
const p = this.mainSeries?.coordinateToPrice(y);
|
||||
return p === null || p === undefined ? null : p;
|
||||
}
|
||||
|
||||
subscribeViewport(cb: () => void): () => void {
|
||||
this.chart.timeScale().subscribeVisibleTimeRangeChange(cb);
|
||||
this.chart.timeScale().subscribeVisibleLogicalRangeChange(cb);
|
||||
return () => {
|
||||
try { this.chart.timeScale().unsubscribeVisibleTimeRangeChange(cb); } catch { /* ok */ }
|
||||
try { this.chart.timeScale().unsubscribeVisibleLogicalRangeChange(cb); } catch { /* ok */ }
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Destroy ─────────────────────────────────────────────────────────────
|
||||
destroy(): void {
|
||||
if (this._clickHandler) this.chart.unsubscribeClick(this._clickHandler);
|
||||
this.chart.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { OHLCVBar } from './types';
|
||||
|
||||
export function calcAutoFib(bars: OHLCVBar[], lookback = 50): {
|
||||
high: number; low: number; highTime: number; lowTime: number;
|
||||
levels: Array<{ ratio: number; price: number; label: string }>;
|
||||
} {
|
||||
const slice = bars.slice(-lookback);
|
||||
let high = -Infinity, low = Infinity, highTime = 0, lowTime = 0;
|
||||
for (const b of slice) {
|
||||
if (b.high > high) { high = b.high; highTime = b.time; }
|
||||
if (b.low < low) { low = b.low; lowTime = b.time; }
|
||||
}
|
||||
const range = high - low;
|
||||
const LEVELS = [
|
||||
{ ratio: 0, label: '0%' },
|
||||
{ ratio: 0.236, label: '23.6%' },
|
||||
{ ratio: 0.382, label: '38.2%' },
|
||||
{ ratio: 0.5, label: '50%' },
|
||||
{ ratio: 0.618, label: '61.8%' },
|
||||
{ ratio: 0.786, label: '78.6%' },
|
||||
{ ratio: 1, label: '100%' },
|
||||
{ ratio: 1.272, label: '127.2%' },
|
||||
{ ratio: 1.618, label: '161.8%' },
|
||||
];
|
||||
return {
|
||||
high, low, highTime, lowTime,
|
||||
levels: LEVELS.map(l => ({ ...l, price: high - range * l.ratio })),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { OHLCVBar } from './types';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
import type { Bar } from 'oakscriptjs';
|
||||
|
||||
export type IndicatorCategory =
|
||||
| 'Moving Averages'
|
||||
| 'Oscillators'
|
||||
| 'Momentum'
|
||||
| 'Trend'
|
||||
| 'Volatility'
|
||||
| 'Channels & Bands'
|
||||
| 'Volume'
|
||||
| 'Candlestick Patterns';
|
||||
|
||||
export interface PlotDef {
|
||||
id: string;
|
||||
title: string;
|
||||
color: string;
|
||||
type: 'line' | 'histogram' | 'area' | 'scatter';
|
||||
lineWidth?: number;
|
||||
}
|
||||
|
||||
export interface HLineDef { price: number; color: string; }
|
||||
|
||||
export interface IndicatorDef {
|
||||
type: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
category: IndicatorCategory;
|
||||
overlay: boolean;
|
||||
defaultParams: Record<string, number | string | boolean>;
|
||||
plots: PlotDef[];
|
||||
hlines?: HLineDef[];
|
||||
returnsMarkers?: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const INDICATOR_REGISTRY: IndicatorDef[] = [
|
||||
{ type:'SMA', name:'Simple Moving Average', shortName:'SMA', category:'Moving Averages', overlay:true, defaultParams:{len:20, src:'close'}, plots:[{id:'plot0',title:'SMA', color:'#2962FF',type:'line',lineWidth:2}], description:'단순 이동평균' },
|
||||
{ type:'EMA', name:'Exponential Moving Average', shortName:'EMA', category:'Moving Averages', overlay:true, defaultParams:{length:21, src:'close'}, plots:[{id:'plot0',title:'EMA', color:'#FF6D00',type:'line',lineWidth:2}], description:'지수 이동평균' },
|
||||
{ type:'WMA', name:'Weighted Moving Average', shortName:'WMA', category:'Moving Averages', overlay:true, defaultParams:{length:20, src:'close'}, plots:[{id:'plot0',title:'WMA', color:'#00BCD4',type:'line',lineWidth:2}], description:'가중 이동평균' },
|
||||
{ type:'BollingerBands', name:'Bollinger Bands', shortName:'BB', category:'Channels & Bands', overlay:true, defaultParams:{length:20, mult:2, src:'close'}, plots:[{id:'plot0',title:'Upper', color:'#2196F320',type:'line',lineWidth:1},{id:'plot1',title:'Basis',color:'#E91E63',type:'line',lineWidth:1},{id:'plot2',title:'Lower',color:'#2196F320',type:'line',lineWidth:1}], description:'볼린저 밴드' },
|
||||
{ type:'RSI', name:'Relative Strength Index', shortName:'RSI', category:'Oscillators', overlay:false, defaultParams:{length:14, src:'close'}, plots:[{id:'plot0',title:'RSI',color:'#7E57C2',type:'line',lineWidth:2}], hlines:[{price:70,color:'#EF5350'},{price:50,color:'#607D8B'},{price:30,color:'#4CAF50'}], description:'상대 강도 지수' },
|
||||
{ type:'MACD', name:'MACD', shortName:'MACD', category:'Momentum', overlay:false, defaultParams:{fastLength:12, slowLength:26, signalLength:9, src:'close'}, plots:[{id:'plot0',title:'Histogram',color:'#26A69A',type:'histogram'},{id:'plot1',title:'MACD',color:'#2962FF',type:'line',lineWidth:1},{id:'plot2',title:'Signal',color:'#FF6D00',type:'line',lineWidth:1}], hlines:[{price:0,color:'#607D8B'}], description:'이동평균 수렴·발산' },
|
||||
{ type:'Stochastic', name:'Stochastic', shortName:'Stoch', category:'Oscillators', overlay:false, defaultParams:{kLength:14, dSmoothing:3, smooth:3}, plots:[{id:'plot0',title:'%K',color:'#2196F3',type:'line',lineWidth:1},{id:'plot1',title:'%D',color:'#FF6D00',type:'line',lineWidth:1}], hlines:[{price:80,color:'#EF5350'},{price:20,color:'#4CAF50'}], description:'스토캐스틱' },
|
||||
{ type:'CCI', name:'Commodity Channel Index', shortName:'CCI', category:'Oscillators', overlay:false, defaultParams:{length:20, src:'hlc3'}, plots:[{id:'plot0',title:'CCI',color:'#00BCD4',type:'line',lineWidth:2}], hlines:[{price:100,color:'#EF5350'},{price:0,color:'#607D8B'},{price:-100,color:'#4CAF50'}], description:'상품 채널 지수' },
|
||||
{ type:'ADX', name:'Average Directional Index', shortName:'ADX', category:'Trend', overlay:false, defaultParams:{adxSmoothing:14, diLength:14}, plots:[{id:'plot0',title:'ADX',color:'#FF6D00',type:'line',lineWidth:2},{id:'plot1',title:'+DI',color:'#4CAF50',type:'line',lineWidth:1},{id:'plot2',title:'-DI',color:'#EF5350',type:'line',lineWidth:1}], hlines:[{price:25,color:'#607D8B'}], description:'평균 방향성 지수' },
|
||||
{ type:'OBV', name:'On Balance Volume', shortName:'OBV', category:'Volume', overlay:false, defaultParams:{}, plots:[{id:'plot0',title:'OBV',color:'#2196F3',type:'line',lineWidth:2}], description:'잔량 균형 지수' },
|
||||
{ type:'DMI', name:'Directional Movement Index', shortName:'DMI', category:'Trend', overlay:false, defaultParams:{length:14}, plots:[{id:'plot0',title:'+DI',color:'#4CAF50',type:'line',lineWidth:2},{id:'plot1',title:'-DI',color:'#EF5350',type:'line',lineWidth:2},{id:'plot2',title:'DX',color:'#FF9800',type:'line',lineWidth:1}], hlines:[{price:25,color:'#607D8B'}], description:'방향성 이동 지수' },
|
||||
{ type:'WilliamsPercentRange', name:'Williams %R', shortName:'W%R', category:'Oscillators', overlay:false, defaultParams:{length:14}, plots:[{id:'plot0',title:'%R',color:'#9C27B0',type:'line',lineWidth:2}], hlines:[{price:-20,color:'#EF5350'},{price:-50,color:'#607D8B'},{price:-80,color:'#4CAF50'}], description:'윌리엄스 %R' },
|
||||
{ type:'TRIX', name:'TRIX', shortName:'TRIX', category:'Momentum', overlay:false, defaultParams:{length:18, signalLength:9, src:'close'}, plots:[{id:'plot0',title:'TRIX',color:'#4CAF50',type:'line',lineWidth:2},{id:'plot1',title:'Signal',color:'#FF6D00',type:'line',lineWidth:1}], hlines:[{price:0,color:'#607D8B'}], description:'삼중 지수 변화율' },
|
||||
{ type:'VolumeOscillator', name:'Volume Oscillator', shortName:'VO', category:'Volume', overlay:false, defaultParams:{shortLength:5, longLength:10}, plots:[{id:'plot0',title:'VO',color:'#FF7043',type:'histogram'}], hlines:[{price:0,color:'#607D8B'}], description:'볼륨 오실레이터' },
|
||||
{ type:'IchimokuCloud', name:'Ichimoku Cloud', shortName:'Ichi', category:'Trend', overlay:true, defaultParams:{conversionPeriods:9, basePeriods:26, laggingSpan2Periods:52, displacement:26}, plots:[{id:'plot0',title:'Tenkan',color:'#2196F3',type:'line',lineWidth:1},{id:'plot1',title:'Kijun',color:'#EF5350',type:'line',lineWidth:1},{id:'plot2',title:'SpanA',color:'#4CAF50',type:'line',lineWidth:1},{id:'plot3',title:'SpanB',color:'#EF5350',type:'line',lineWidth:1},{id:'plot4',title:'Lagging',color:'#9C27B0',type:'line',lineWidth:1}], description:'일목균형표' },
|
||||
];
|
||||
|
||||
export function getIndicatorDef(type: string): IndicatorDef | undefined {
|
||||
return INDICATOR_REGISTRY.find(d => d.type === type);
|
||||
}
|
||||
|
||||
export function toBars(ohlcv: OHLCVBar[]): Bar[] {
|
||||
return ohlcv as unknown as Bar[];
|
||||
}
|
||||
|
||||
export type PlotData = Array<{ time: number; value: number; color?: string }>;
|
||||
export type IndicatorResult = Record<string, PlotData>;
|
||||
|
||||
export interface MarkerData {
|
||||
time: number;
|
||||
position: 'aboveBar' | 'belowBar';
|
||||
shape: 'arrowUp' | 'arrowDown' | 'circle' | 'square';
|
||||
color: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export async function calculateIndicator(
|
||||
type: string,
|
||||
bars: OHLCVBar[],
|
||||
params: Record<string, number | string | boolean>
|
||||
): Promise<{ plots: IndicatorResult; markers?: MarkerData[] }> {
|
||||
const b = toBars(bars);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const lib = await import('lightweight-charts-indicators-v5') as any;
|
||||
const ind = (lib as unknown as Record<string, {
|
||||
calculate: (b: Bar[], p: unknown) => { plots?: Record<string, PlotData>; markers?: MarkerData[] }
|
||||
}>)[type];
|
||||
if (!ind?.calculate) throw new Error(`Unknown indicator: ${type}`);
|
||||
const result = ind.calculate(b, params);
|
||||
return {
|
||||
plots: (result.plots ?? {}) as IndicatorResult,
|
||||
markers: result.markers as MarkerData[] | undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* LWC 4.x → 5.x 호환 shim
|
||||
*
|
||||
* LWC 4.x API를 내부적으로 LWC 5.x로 실행하도록 래핑합니다.
|
||||
* CandlestickChart / IndicatorChart 에서 import 경로만 교체해서 사용합니다.
|
||||
*
|
||||
* 주요 변환:
|
||||
* - chart.addCandlestickSeries / addLineSeries / addHistogramSeries / addAreaSeries / addBarSeries
|
||||
* → chart.addSeries(SeriesClass, options)
|
||||
* - series.setMarkers(markers)
|
||||
* → createSeriesMarkers(series, markers) / markersPlugin.setMarkers(markers)
|
||||
*/
|
||||
|
||||
import {
|
||||
createChart as _createChart,
|
||||
CandlestickSeries,
|
||||
LineSeries,
|
||||
HistogramSeries,
|
||||
AreaSeries,
|
||||
BarSeries,
|
||||
BaselineSeries,
|
||||
createSeriesMarkers,
|
||||
ColorType,
|
||||
CrosshairMode,
|
||||
LineStyle,
|
||||
LineType,
|
||||
PriceScaleMode,
|
||||
LastPriceAnimationMode,
|
||||
MismatchDirection,
|
||||
PriceLineSource,
|
||||
TickMarkType,
|
||||
TrackingModeExitMode,
|
||||
isBusinessDay,
|
||||
isUTCTimestamp,
|
||||
version,
|
||||
} from 'lightweight-charts-v5';
|
||||
|
||||
import type {
|
||||
IChartApi as _IChartApi,
|
||||
ISeriesApi as _ISeriesApi,
|
||||
IRange,
|
||||
SeriesType,
|
||||
Time,
|
||||
DeepPartial,
|
||||
ChartOptions,
|
||||
CandlestickSeriesOptions,
|
||||
LineSeriesOptions,
|
||||
HistogramSeriesOptions,
|
||||
AreaSeriesOptions,
|
||||
BarSeriesOptions,
|
||||
BaselineSeriesOptions,
|
||||
} from 'lightweight-charts-v5';
|
||||
|
||||
// ─── 타입 재내보내기 ─────────────────────────────────────────────────────────
|
||||
|
||||
// LWC 5.x에서 제거된 LineWidth 타입 — 타입으로만 유지
|
||||
export type LineWidth = 1 | 2 | 3 | 4;
|
||||
|
||||
export type {
|
||||
Time,
|
||||
BusinessDay,
|
||||
UTCTimestamp,
|
||||
IRange,
|
||||
IRange as Range,
|
||||
LogicalRange,
|
||||
SeriesType,
|
||||
SeriesOptionsMap,
|
||||
SeriesDataItemTypeMap,
|
||||
MouseEventParams,
|
||||
MouseEventHandler,
|
||||
DeepPartial,
|
||||
ChartOptions,
|
||||
CandlestickSeriesOptions,
|
||||
LineSeriesOptions,
|
||||
HistogramSeriesOptions,
|
||||
AreaSeriesOptions,
|
||||
BarSeriesOptions,
|
||||
BaselineSeriesOptions,
|
||||
BaselineSeriesOptions as BaselineOptions,
|
||||
IPriceLine,
|
||||
PriceLineOptions,
|
||||
SeriesMarker,
|
||||
SeriesMarkerPosition,
|
||||
SeriesMarkerShape,
|
||||
CrosshairOptions,
|
||||
LayoutOptions,
|
||||
GridOptions,
|
||||
PriceScaleOptions,
|
||||
TimeScaleOptions,
|
||||
AutoscaleInfo,
|
||||
WhitespaceData,
|
||||
OhlcData,
|
||||
LineData,
|
||||
HistogramData,
|
||||
BarData,
|
||||
AreaData,
|
||||
BaselineData,
|
||||
CandlestickData,
|
||||
IPriceFormatter,
|
||||
} from 'lightweight-charts-v5';
|
||||
|
||||
// LWC 4.x 타입 호환 (v5에서 제거/변경된 것들)
|
||||
export type TimeRange<T = Time> = IRange<T>;
|
||||
|
||||
// ─── 확장된 ISeriesApi (setMarkers 포함) ────────────────────────────────────
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export interface ISeriesApi<T extends SeriesType = SeriesType> extends _ISeriesApi<T> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setMarkers(markers: any[]): void;
|
||||
}
|
||||
|
||||
// ─── 확장된 IChartApi (addXxxSeries 포함) ────────────────────────────────────
|
||||
export interface IChartApi extends _IChartApi {
|
||||
addCandlestickSeries(options?: DeepPartial<CandlestickSeriesOptions>): ISeriesApi<'Candlestick'>;
|
||||
addLineSeries(options?: DeepPartial<LineSeriesOptions>): ISeriesApi<'Line'>;
|
||||
addHistogramSeries(options?: DeepPartial<HistogramSeriesOptions>): ISeriesApi<'Histogram'>;
|
||||
addAreaSeries(options?: DeepPartial<AreaSeriesOptions>): ISeriesApi<'Area'>;
|
||||
addBarSeries(options?: DeepPartial<BarSeriesOptions>): ISeriesApi<'Bar'>;
|
||||
addBaselineSeries(options?: DeepPartial<BaselineSeriesOptions>): ISeriesApi<'Baseline'>;
|
||||
}
|
||||
|
||||
// ─── 값 재내보내기 ───────────────────────────────────────────────────────────
|
||||
export {
|
||||
ColorType,
|
||||
CrosshairMode,
|
||||
LineStyle,
|
||||
LineType,
|
||||
PriceScaleMode,
|
||||
LastPriceAnimationMode,
|
||||
MismatchDirection,
|
||||
PriceLineSource,
|
||||
TickMarkType,
|
||||
TrackingModeExitMode,
|
||||
CandlestickSeries,
|
||||
LineSeries,
|
||||
HistogramSeries,
|
||||
AreaSeries,
|
||||
BarSeries,
|
||||
BaselineSeries,
|
||||
createSeriesMarkers,
|
||||
isBusinessDay,
|
||||
isUTCTimestamp,
|
||||
version,
|
||||
};
|
||||
|
||||
// ─── series.setMarkers 지원 ─────────────────────────────────────────────────
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const markersPluginMap = new WeakMap<object, any>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function wrapSeriesMarkers<T extends SeriesType>(series: _ISeriesApi<T>): ISeriesApi<T> {
|
||||
const s = series as ISeriesApi<T>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(s as any).setMarkers = (markers: any[]) => {
|
||||
if (markersPluginMap.has(series)) {
|
||||
markersPluginMap.get(series).setMarkers(markers);
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const plugin = (createSeriesMarkers as any)(series, markers);
|
||||
markersPluginMap.set(series, plugin);
|
||||
}
|
||||
};
|
||||
return s;
|
||||
}
|
||||
|
||||
// ─── createChart 래퍼 ───────────────────────────────────────────────────────
|
||||
export function createChart(
|
||||
container: HTMLElement | string,
|
||||
options?: DeepPartial<ChartOptions>,
|
||||
): IChartApi {
|
||||
const chart = _createChart(container, options) as IChartApi;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const wrapAdd = (SeriesClass: any) => (opts?: any) =>
|
||||
wrapSeriesMarkers(chart.addSeries(SeriesClass, opts ?? {}));
|
||||
|
||||
(chart as IChartApi).addCandlestickSeries = wrapAdd(CandlestickSeries) as IChartApi['addCandlestickSeries'];
|
||||
(chart as IChartApi).addLineSeries = wrapAdd(LineSeries) as IChartApi['addLineSeries'];
|
||||
(chart as IChartApi).addHistogramSeries = wrapAdd(HistogramSeries) as IChartApi['addHistogramSeries'];
|
||||
(chart as IChartApi).addAreaSeries = wrapAdd(AreaSeries) as IChartApi['addAreaSeries'];
|
||||
(chart as IChartApi).addBarSeries = wrapAdd(BarSeries) as IChartApi['addBarSeries'];
|
||||
(chart as IChartApi).addBaselineSeries = wrapAdd(BaselineSeries) as IChartApi['addBaselineSeries'];
|
||||
|
||||
return chart;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export type ChartType = 'candlestick' | 'bar' | 'line' | 'area' | 'baseline';
|
||||
export type Theme = 'dark' | 'light';
|
||||
|
||||
export interface OHLCVBar {
|
||||
time: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export interface IndicatorParam {
|
||||
name: string;
|
||||
type: 'number' | 'string' | 'boolean';
|
||||
default: number | string | boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export interface IndicatorConfig {
|
||||
id: string;
|
||||
type: string;
|
||||
params: Record<string, number | string | boolean>;
|
||||
plots?: import('./indicatorRegistry').PlotDef[];
|
||||
hlines?: import('./indicatorRegistry').HLineDef[];
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { CandleData, IndicatorData } from '../../services/backtestApi';
|
||||
import type { IndicatorSettings } from '../../contexts/IndicatorSettingsContext';
|
||||
|
||||
/**
|
||||
* 투자분석 `loadIndicatorDataSync`의 `/upbit/analyze` 요청 본문과 동일한 구조.
|
||||
* 멀티차트·멀티차트2 ChartItemInvestment에서 사용.
|
||||
*/
|
||||
export function buildUpbitAnalyzePayload(params: {
|
||||
symbol: string;
|
||||
interval: string;
|
||||
candleData: CandleData[];
|
||||
maPeriods: number[];
|
||||
bollingerPeriod: number;
|
||||
bollingerStdDev: number;
|
||||
settings: IndicatorSettings;
|
||||
vrPeriod: number;
|
||||
vr2Enabled: boolean;
|
||||
vr2Period: number;
|
||||
}): Record<string, unknown> {
|
||||
const s = params.settings;
|
||||
return {
|
||||
symbol: params.symbol,
|
||||
interval: params.interval,
|
||||
candleData: params.candleData,
|
||||
maPeriods: params.maPeriods,
|
||||
cciPeriod: s.cciPeriod || 13,
|
||||
cciSignalPeriod: s.cciSignalPeriod || 10,
|
||||
adxPeriod: s.adxPeriod || 14,
|
||||
dmiPeriod: s.dmiPeriod || 14,
|
||||
rsiPeriod: s.rsiPeriod || 9,
|
||||
macdFastPeriod: s.macdFastPeriod || 12,
|
||||
macdSlowPeriod: s.macdSlowPeriod || 26,
|
||||
macdSignalPeriod: s.macdSignalPeriod || 9,
|
||||
indicatorSettings: {
|
||||
bollingerPeriod: params.bollingerPeriod,
|
||||
bollingerStdDev: params.bollingerStdDev,
|
||||
maPeriods: params.maPeriods,
|
||||
cciPeriod: s.cciPeriod || 13,
|
||||
cciSignalPeriod: s.cciSignalPeriod || 10,
|
||||
rsiPeriod: s.rsiPeriod || 9,
|
||||
rsiSignalPeriod: s.rsiSignalPeriod || 9,
|
||||
macdFastPeriod: s.macdFastPeriod || 12,
|
||||
macdSlowPeriod: s.macdSlowPeriod || 26,
|
||||
macdSignalPeriod: s.macdSignalPeriod || 9,
|
||||
stochasticKPeriod: s.stochasticKPeriod || 12,
|
||||
stochasticDPeriod: s.stochasticDPeriod || 5,
|
||||
stochasticSlowing: s.stochasticSlowing || 5,
|
||||
adxPeriod: s.adxPeriod || 14,
|
||||
dmiPeriod: s.dmiPeriod || 14,
|
||||
obvPeriod: s.obvPeriod || 10,
|
||||
obvSignalPeriod: s.obvSignalPeriod || 10,
|
||||
williamsRPeriod: s.williamsRPeriod || 14,
|
||||
trixPeriod: s.trixPeriod || 12,
|
||||
trixSignalPeriod: s.trixSignalPeriod || 9,
|
||||
volumeOscShortPeriod: s.volumeOscShortPeriod || 5,
|
||||
volumeOscLongPeriod: s.volumeOscLongPeriod || 10,
|
||||
disparityUltraShortPeriod: s.disparityUltraShortPeriod || 5,
|
||||
disparityShortPeriod: s.disparityShortPeriod || 20,
|
||||
disparityMidPeriod: s.disparityMidPeriod || 60,
|
||||
disparityLongPeriod: s.disparityLongPeriod || 120,
|
||||
newPsychologicalPeriod: s.newPsychologicalPeriod || 12,
|
||||
investPsychologicalPeriod: s.investPsychologicalPeriod || 24,
|
||||
bwiPeriod: s.bwiPeriod || 20,
|
||||
},
|
||||
vrPeriod: params.vrPeriod,
|
||||
vr2Enabled: params.vr2Enabled,
|
||||
vr2Period: params.vr2Period,
|
||||
};
|
||||
}
|
||||
|
||||
/** API 행 → CandlestickChart용 IndicatorData 정규화 */
|
||||
export function normalizeAnalyzeIndicatorRow(d: any): Record<string, unknown> {
|
||||
const ts = d.timestamp ?? d.time ?? d.date;
|
||||
const timeStr =
|
||||
typeof ts === 'number'
|
||||
? new Date(ts >= 1e12 ? ts : ts * 1000).toISOString().slice(0, 19).replace('Z', '')
|
||||
: (d.time ?? (ts ? new Date(String(ts)).toISOString().slice(0, 19).replace('Z', '') : ''));
|
||||
return {
|
||||
...d,
|
||||
time: timeStr,
|
||||
timestamp: String(ts ?? ''),
|
||||
stochasticK: d.stochasticK ?? d.stochK,
|
||||
stochasticD: d.stochasticD ?? d.stochD,
|
||||
macdHistogram: d.macdHistogram ?? d.histogram,
|
||||
cci: d.cci ?? d.cci13,
|
||||
cci13: d.cci13 ?? d.cci,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* /upbit/analyze 행은 요청 candleData 와 인덱스 1:1이다.
|
||||
* 백엔드 timestamp 문자열과 프론트 parseCandles 의 time(UTC ISO) 형식이 다르면
|
||||
* 서브패널 mapIndicatorData 가 메인 캔들과 다른 unix time 을 만들어 CCI/ADX 등이 직선·깨짐으로 그려질 수 있다.
|
||||
* 투자분석과 동일하게 각 행의 time 을 같은 인덱스 캔들 time 으로 고정한다.
|
||||
*/
|
||||
export function alignIndicatorRowsToCandleTimes(
|
||||
rows: IndicatorData[],
|
||||
candles: CandleData[]
|
||||
): IndicatorData[] {
|
||||
const n = Math.min(rows.length, candles.length);
|
||||
const out: IndicatorData[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const ct = candles[i]?.time;
|
||||
const row = rows[i] as IndicatorData;
|
||||
out.push({
|
||||
...row,
|
||||
time: ct != null && ct !== '' ? ct : row.time,
|
||||
timestamp: ct != null && ct !== '' ? String(ct) : row.timestamp,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* Frontend Indicator Calculator
|
||||
*
|
||||
* lightweight-charts-indicators-main 라이브러리를 사용하여
|
||||
* 백엔드 없이 프론트엔드에서 직접 보조지표를 계산합니다.
|
||||
*/
|
||||
|
||||
import type { Bar } from 'oakscriptjs';
|
||||
import {
|
||||
calculateRSI as calcRSI,
|
||||
calculateMACD as calcMACD,
|
||||
calculateStochastic as calcStoch,
|
||||
calculateDMI as calcDMI,
|
||||
calculateCCI as calcCCI,
|
||||
calculateOBV as calcOBV,
|
||||
calculateWilliamsR as calcWilliamsR,
|
||||
calculateTRIX as calcTRIX,
|
||||
calculateBB as calcBB,
|
||||
calculateVolumeOsc as calcVolumeOsc,
|
||||
calculateIchimoku as calcIchimoku,
|
||||
calculateMomentum as calcMomentum,
|
||||
calculateSMA as calcSMA,
|
||||
} from 'lightweight-charts-indicators';
|
||||
|
||||
import type { CandleData, IndicatorData } from '../../services/backtestApi';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface FrontendIndicatorSettings {
|
||||
maPeriods?: number[];
|
||||
bollingerPeriod?: number;
|
||||
bollingerStdDev?: number;
|
||||
rsiPeriod?: number;
|
||||
macdFastPeriod?: number;
|
||||
macdSlowPeriod?: number;
|
||||
macdSignalPeriod?: number;
|
||||
stochasticKPeriod?: number;
|
||||
stochasticDPeriod?: number;
|
||||
stochasticSlowing?: number;
|
||||
adxPeriod?: number;
|
||||
dmiPeriod?: number;
|
||||
cciPeriod?: number;
|
||||
cciSignalPeriod?: number;
|
||||
obvPeriod?: number;
|
||||
obvSignalPeriod?: number;
|
||||
williamsRPeriod?: number;
|
||||
trixPeriod?: number;
|
||||
trixSignalPeriod?: number;
|
||||
volumeOscShortPeriod?: number;
|
||||
volumeOscLongPeriod?: number;
|
||||
disparityUltraShortPeriod?: number;
|
||||
disparityShortPeriod?: number;
|
||||
disparityMidPeriod?: number;
|
||||
disparityLongPeriod?: number;
|
||||
newPsychologicalPeriod?: number;
|
||||
investPsychologicalPeriod?: number;
|
||||
vrPeriod?: number;
|
||||
vr2Enabled?: boolean;
|
||||
vr2Period?: number;
|
||||
ichimokuSettings?: {
|
||||
conversionPeriod?: number;
|
||||
basePeriod?: number;
|
||||
laggingSpanPeriod?: number;
|
||||
displacement?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Conversion: CandleData → Bar (oakscriptjs format)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function toBar(c: CandleData): Bar {
|
||||
return {
|
||||
time: c.time as any,
|
||||
open: c.open,
|
||||
high: c.high,
|
||||
low: c.low,
|
||||
close: c.close,
|
||||
volume: c.volume,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Custom indicator helpers (not provided by the library)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 단순 SMA 계산 (number[] 입력) */
|
||||
function smaArr(src: number[], period: number): number[] {
|
||||
return src.map((_, i) => {
|
||||
if (i < period - 1) return NaN;
|
||||
let sum = 0;
|
||||
for (let j = i - period + 1; j <= i; j++) sum += src[j];
|
||||
return sum / period;
|
||||
});
|
||||
}
|
||||
|
||||
/** 이격도 (Disparity Index): (close - SMA(n)) / SMA(n) * 100 */
|
||||
function calcDisparity(closes: number[], period: number): number[] {
|
||||
const ma = smaArr(closes, period);
|
||||
return closes.map((c, i) => (isNaN(ma[i]) ? NaN : ((c - ma[i]) / ma[i]) * 100));
|
||||
}
|
||||
|
||||
/** 신심리도 (New Psychological): 최근 N봉 중 종가가 이전 봉 종가보다 높은 비율 * 100 */
|
||||
function calcNewPsychological(closes: number[], period: number): number[] {
|
||||
return closes.map((_, i) => {
|
||||
if (i < period) return NaN;
|
||||
let upCount = 0;
|
||||
for (let j = i - period + 1; j <= i; j++) {
|
||||
if (closes[j] > closes[j - 1]) upCount++;
|
||||
}
|
||||
return (upCount / period) * 100;
|
||||
});
|
||||
}
|
||||
|
||||
/** 투자심리도 (Invest Psychological): 거래량 가중 신심리도 */
|
||||
function calcInvestPsychological(closes: number[], volumes: number[], period: number): number[] {
|
||||
return closes.map((_, i) => {
|
||||
if (i < period) return NaN;
|
||||
let upVol = 0;
|
||||
let totalVol = 0;
|
||||
for (let j = i - period + 1; j <= i; j++) {
|
||||
totalVol += volumes[j];
|
||||
if (closes[j] > closes[j - 1]) upVol += volumes[j];
|
||||
}
|
||||
return totalVol === 0 ? NaN : (upVol / totalVol) * 100;
|
||||
});
|
||||
}
|
||||
|
||||
/** BWI (Bollinger Width Index): (upper - lower) / middle * 100 */
|
||||
function calcBWI(
|
||||
upper: number[],
|
||||
middle: number[],
|
||||
lower: number[]
|
||||
): number[] {
|
||||
return upper.map((u, i) => {
|
||||
const m = middle[i];
|
||||
const l = lower[i];
|
||||
if (isNaN(u) || isNaN(m) || isNaN(l) || m === 0) return NaN;
|
||||
return ((u - l) / m) * 100;
|
||||
});
|
||||
}
|
||||
|
||||
/** VR (Volume Ratio): up-volume / down-volume 비율 */
|
||||
function calcVR(closes: number[], volumes: number[], period: number): number[] {
|
||||
return 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;
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Main: Calculate all indicators from candle data
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function calculateAllIndicators(
|
||||
candles: CandleData[],
|
||||
settings: FrontendIndicatorSettings = {}
|
||||
): IndicatorData[] {
|
||||
if (!candles.length) return [];
|
||||
|
||||
const bars = candles.map(toBar);
|
||||
|
||||
const {
|
||||
maPeriods = [5, 10, 20, 60, 120],
|
||||
bollingerPeriod = 20,
|
||||
bollingerStdDev = 2,
|
||||
rsiPeriod = 9,
|
||||
macdFastPeriod = 12,
|
||||
macdSlowPeriod = 26,
|
||||
macdSignalPeriod = 9,
|
||||
stochasticKPeriod = 12,
|
||||
stochasticDPeriod = 5,
|
||||
stochasticSlowing = 5,
|
||||
adxPeriod = 14,
|
||||
dmiPeriod = 14,
|
||||
cciPeriod = 13,
|
||||
cciSignalPeriod = 10,
|
||||
obvSignalPeriod = 10,
|
||||
williamsRPeriod = 14,
|
||||
trixPeriod = 12,
|
||||
trixSignalPeriod = 9,
|
||||
volumeOscShortPeriod = 5,
|
||||
volumeOscLongPeriod = 10,
|
||||
disparityUltraShortPeriod = 5,
|
||||
disparityShortPeriod = 20,
|
||||
disparityMidPeriod = 60,
|
||||
disparityLongPeriod = 120,
|
||||
newPsychologicalPeriod = 12,
|
||||
investPsychologicalPeriod = 24,
|
||||
vrPeriod = 10,
|
||||
vr2Enabled = false,
|
||||
vr2Period = 10,
|
||||
ichimokuSettings = {},
|
||||
} = settings;
|
||||
|
||||
const closes = candles.map((c) => c.close);
|
||||
const volumes = candles.map((c) => c.volume);
|
||||
|
||||
// ── 라이브러리 지표 계산 ──────────────────────────────────────────────────
|
||||
|
||||
const rsiResult = calcRSI(bars, { length: rsiPeriod });
|
||||
const rsiValues = rsiResult.plots['plot0'];
|
||||
|
||||
const macdResult = calcMACD(bars, {
|
||||
fastLength: macdFastPeriod,
|
||||
slowLength: macdSlowPeriod,
|
||||
signalLength: macdSignalPeriod,
|
||||
});
|
||||
const macdHistValues = macdResult.plots['plot0']; // histogram
|
||||
const macdLineValues = macdResult.plots['plot1']; // MACD line
|
||||
const macdSignalValues = macdResult.plots['plot2']; // Signal line
|
||||
|
||||
const stochResult = calcStoch(bars, {
|
||||
periodK: stochasticKPeriod,
|
||||
smoothK: stochasticSlowing,
|
||||
periodD: stochasticDPeriod,
|
||||
});
|
||||
const stochKValues = stochResult.plots['plot0'];
|
||||
const stochDValues = stochResult.plots['plot1'];
|
||||
|
||||
// DMI returns ADX(plot0), +DI(plot1), -DI(plot2)
|
||||
const dmiResult = calcDMI(bars, {
|
||||
adxSmoothing: adxPeriod,
|
||||
diLength: dmiPeriod,
|
||||
});
|
||||
const adxValues = dmiResult.plots['plot0'];
|
||||
const plusDIValues = dmiResult.plots['plot1'];
|
||||
const minusDIValues = dmiResult.plots['plot2'];
|
||||
|
||||
const cciResult = calcCCI(bars, { length: cciPeriod });
|
||||
const cciValues = cciResult.plots['plot0'];
|
||||
const cciRawArr = cciValues.map((d) => (d ? (isNaN(d.value) ? NaN : d.value) : NaN));
|
||||
const cciSignalArr = smaArr(cciRawArr, cciSignalPeriod);
|
||||
|
||||
const obvResult = calcOBV(bars);
|
||||
const obvValues = obvResult.plots['plot0'];
|
||||
const obvRawArr = obvValues.map((d) => (d ? (isNaN(d.value) ? NaN : d.value) : NaN));
|
||||
const obvSignalArr = smaArr(obvRawArr, obvSignalPeriod);
|
||||
|
||||
const wrResult = calcWilliamsR(bars, { length: williamsRPeriod });
|
||||
const wrValues = wrResult.plots['plot0'];
|
||||
|
||||
const trixResult = calcTRIX(bars, { length: trixPeriod });
|
||||
const trixValues = trixResult.plots['plot0'];
|
||||
/** 라이브러리가 ROC에 1e4를 곱해 반환 — 차트·게이지는 이 스케일과 동일 */
|
||||
const trixRawArr = trixValues.map((d) => (d ? (isNaN(d.value) ? NaN : d.value) : NaN));
|
||||
const trixSignalArr = smaArr(trixRawArr, trixSignalPeriod);
|
||||
|
||||
const bbResult = calcBB(bars, {
|
||||
length: bollingerPeriod,
|
||||
mult: bollingerStdDev,
|
||||
maType: 'SMA',
|
||||
});
|
||||
const bbBasis = bbResult.plots['plot0']; // middle
|
||||
const bbUpper = bbResult.plots['plot1']; // upper
|
||||
const bbLower = bbResult.plots['plot2']; // lower
|
||||
|
||||
const volOscResult = calcVolumeOsc(bars, {
|
||||
shortLength: volumeOscShortPeriod,
|
||||
longLength: volumeOscLongPeriod,
|
||||
});
|
||||
const volOscValues = volOscResult.plots['plot0'];
|
||||
|
||||
const momentumResult = calcMomentum(bars, { length: 10 });
|
||||
const momentumValues = momentumResult.plots['plot0'];
|
||||
|
||||
const ichimokuResult = calcIchimoku(bars, {
|
||||
conversionPeriods: ichimokuSettings.conversionPeriod ?? 9,
|
||||
basePeriods: ichimokuSettings.basePeriod ?? 26,
|
||||
laggingSpan2Periods: ichimokuSettings.laggingSpanPeriod ?? 52,
|
||||
displacement: ichimokuSettings.displacement ?? 26,
|
||||
});
|
||||
const tenkanValues = ichimokuResult.plots['plot0']; // conversion
|
||||
const kijunValues = ichimokuResult.plots['plot1']; // base
|
||||
const chikouValues = ichimokuResult.plots['plot2']; // lagging
|
||||
const senkouAValues = ichimokuResult.plots['plot3']; // leading A
|
||||
const senkouBValues = ichimokuResult.plots['plot4']; // leading B
|
||||
|
||||
// ── MA 계산 ──────────────────────────────────────────────────────────────
|
||||
// maPeriods별 SMA 계산
|
||||
const maResultsMap: Record<string, number[]> = {};
|
||||
for (const period of maPeriods) {
|
||||
const smaResult = calcSMA(bars, { len: period });
|
||||
maResultsMap[`ma${period}`] = smaResult.plots['plot0'].map((d) =>
|
||||
d ? (isNaN(d.value) ? NaN : d.value) : NaN
|
||||
);
|
||||
}
|
||||
|
||||
// ── 커스텀 지표 계산 ──────────────────────────────────────────────────────
|
||||
|
||||
const bbUpperArr = bbUpper.map((d) => (d ? d.value : NaN));
|
||||
const bbMiddleArr = bbBasis.map((d) => (d ? d.value : NaN));
|
||||
const bbLowerArr = bbLower.map((d) => (d ? d.value : NaN));
|
||||
const bwiArr = calcBWI(bbUpperArr, bbMiddleArr, bbLowerArr);
|
||||
|
||||
const vrArr = calcVR(closes, volumes, vrPeriod);
|
||||
const vr2Arr = vr2Enabled ? calcVR(closes, volumes, vr2Period) : [];
|
||||
|
||||
const disparityUSArr = calcDisparity(closes, disparityUltraShortPeriod);
|
||||
const disparityShortArr = calcDisparity(closes, disparityShortPeriod);
|
||||
const disparityMidArr = calcDisparity(closes, disparityMidPeriod);
|
||||
const disparityLongArr = calcDisparity(closes, disparityLongPeriod);
|
||||
|
||||
const newPsyArr = calcNewPsychological(closes, newPsychologicalPeriod);
|
||||
const investPsyArr = calcInvestPsychological(closes, volumes, investPsychologicalPeriod);
|
||||
|
||||
// ── 결과 조합 ─────────────────────────────────────────────────────────────
|
||||
|
||||
return candles.map((candle, i) => {
|
||||
// MA 동적 값 맵 생성
|
||||
const maValues: Record<string, number> = {};
|
||||
for (const [key, arr] of Object.entries(maResultsMap)) {
|
||||
const v = arr[i];
|
||||
if (!isNaN(v)) maValues[key] = v;
|
||||
}
|
||||
|
||||
const get = (arr: { value: number }[] | undefined, idx: number): number | undefined => {
|
||||
if (!arr) return undefined;
|
||||
const v = arr[idx]?.value;
|
||||
return v !== undefined && !isNaN(v) ? v : undefined;
|
||||
};
|
||||
|
||||
const getNum = (arr: number[], idx: number): number | undefined => {
|
||||
const v = arr[idx];
|
||||
return !isNaN(v) ? v : undefined;
|
||||
};
|
||||
|
||||
const row: IndicatorData = {
|
||||
time: candle.time,
|
||||
timestamp: candle.time,
|
||||
open: candle.open,
|
||||
high: candle.high,
|
||||
low: candle.low,
|
||||
close: candle.close,
|
||||
volume: candle.volume,
|
||||
|
||||
// RSI
|
||||
rsi: get(rsiValues, i),
|
||||
|
||||
// MACD
|
||||
macdLine: get(macdLineValues, i),
|
||||
signalLine: get(macdSignalValues, i),
|
||||
macdHistogram: get(macdHistValues, i),
|
||||
histogram: get(macdHistValues, i),
|
||||
macd: get(macdHistValues, i),
|
||||
signal: get(macdSignalValues, i),
|
||||
|
||||
// Stochastic
|
||||
stochK: get(stochKValues, i),
|
||||
stochD: get(stochDValues, i),
|
||||
|
||||
// ADX / DMI
|
||||
adx: get(adxValues, i),
|
||||
plusDI: get(plusDIValues, i),
|
||||
minusDI: get(minusDIValues, i),
|
||||
|
||||
// CCI
|
||||
cci: get(cciValues, i),
|
||||
|
||||
// OBV
|
||||
obv: getNum(obvRawArr, i),
|
||||
obvSignal: getNum(obvSignalArr, i),
|
||||
|
||||
// Williams %R
|
||||
williamsR: get(wrValues, i),
|
||||
|
||||
// TRIX
|
||||
trix: getNum(trixRawArr, i),
|
||||
trixSignal: getNum(trixSignalArr, i),
|
||||
|
||||
// Bollinger Bands
|
||||
bollingerUpper: getNum(bbUpperArr, i),
|
||||
bollingerMiddle: getNum(bbMiddleArr, i),
|
||||
bollingerLower: getNum(bbLowerArr, i),
|
||||
|
||||
// Volume Oscillator
|
||||
volumeOsc: get(volOscValues, i),
|
||||
|
||||
// Momentum
|
||||
momentum: get(momentumValues, i),
|
||||
|
||||
// Ichimoku
|
||||
ichimokuTenkan: get(tenkanValues, i),
|
||||
ichimokuKijun: get(kijunValues, i),
|
||||
ichimokuChikou: get(chikouValues, i),
|
||||
ichimokuSenkouA: get(senkouAValues, i),
|
||||
ichimokuSenkouB: get(senkouBValues, i),
|
||||
|
||||
// BWI
|
||||
bwi: getNum(bwiArr, i),
|
||||
|
||||
// VR
|
||||
vr: getNum(vrArr, i),
|
||||
vr2: vr2Enabled ? getNum(vr2Arr, i) : undefined,
|
||||
|
||||
// 이격도
|
||||
disparity: getNum(disparityUSArr, i),
|
||||
|
||||
// 심리도
|
||||
newPsychological: getNum(newPsyArr, i),
|
||||
newPsy: getNum(newPsyArr, i),
|
||||
investPsychological: getNum(investPsyArr, i),
|
||||
investPsy: getNum(investPsyArr, i),
|
||||
|
||||
// MA 동적 값
|
||||
maValues,
|
||||
};
|
||||
|
||||
// CCI signal alias
|
||||
row.cciSignal = getNum(cciSignalArr, i);
|
||||
|
||||
// 개별 이격도
|
||||
row.disparityUltraShort = getNum(disparityUSArr, i);
|
||||
row.disparityShort = getNum(disparityShortArr, i);
|
||||
row.disparityMid = getNum(disparityMidArr, i);
|
||||
row.disparityLong = getNum(disparityLongArr, i);
|
||||
|
||||
// SMA 별칭 (호환성)
|
||||
for (const period of maPeriods) {
|
||||
const key = `ma${period}`;
|
||||
const val = maValues[key];
|
||||
if (val !== undefined) {
|
||||
row[`sma${period}`] = val;
|
||||
row[key] = val;
|
||||
}
|
||||
}
|
||||
|
||||
return row;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { CandleData, IndicatorData } from '../../services/backtestApi';
|
||||
|
||||
/** 알림 평가·멀티차트 WS와 동일한 `1m`~`1h` 문자열로 정규화 */
|
||||
export function chartIntervalToWsTimeframe(interval: string): string {
|
||||
const wsFormats = ['1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h', '1d', '1w', '1M'];
|
||||
const v = (interval || '1m').trim();
|
||||
if (wsFormats.includes(v)) return v;
|
||||
const mapping: Record<string, string> = {
|
||||
'1': '1m',
|
||||
'3': '3m',
|
||||
'5': '5m',
|
||||
'10': '10m',
|
||||
'15': '15m',
|
||||
'30': '30m',
|
||||
'60': '1h',
|
||||
'240': '4h',
|
||||
D: '1d',
|
||||
W: '1w',
|
||||
M: '1M',
|
||||
};
|
||||
return mapping[v] || '1m';
|
||||
}
|
||||
|
||||
/** '3m','1h','1d' → 밀리초 (useMarketDataWebSocket·동일봉 판별과 동일 규칙) */
|
||||
export function timeframeToIntervalMs(tf: string): number {
|
||||
const t = (tf || '1m').trim();
|
||||
const match = /^(\d+)(m|h|d|w|M)$/.exec(t);
|
||||
if (!match) return 60_000;
|
||||
const n = parseInt(match[1], 10);
|
||||
const u = match[2];
|
||||
if (u === 'm') return n * 60_000;
|
||||
if (u === 'h') return n * 60 * 60_000;
|
||||
if (u === 'd') return n * 24 * 60 * 60_000;
|
||||
if (u === 'w') return n * 7 * 24 * 60 * 60_000;
|
||||
if (u === 'M') return n * 30 * 24 * 60 * 60_000;
|
||||
return 60_000;
|
||||
}
|
||||
|
||||
/** 차트 캔들 open 시각 → epoch ms (백엔드 WS toEpochMs와 동일한 스케일) */
|
||||
export function chartCandleOpenToEpochMs(time: string | number | undefined): number {
|
||||
if (time == null || time === '') return NaN;
|
||||
if (typeof time === 'number') {
|
||||
return time > 1e12 ? time : time * 1000;
|
||||
}
|
||||
const ms = new Date(time as string).getTime();
|
||||
return Number.isFinite(ms) ? ms : NaN;
|
||||
}
|
||||
|
||||
/** 캔들 time → Unix 초 (WS·차트 동일 봉 판별용) */
|
||||
export function candleTimeToUnixSeconds(time: string | number | undefined): number {
|
||||
if (time == null || time === '') return NaN;
|
||||
if (typeof time === 'number') {
|
||||
return time > 1e12 ? Math.floor(time / 1000) : Math.floor(time);
|
||||
}
|
||||
const ms = new Date(time as string).getTime();
|
||||
return Number.isFinite(ms) ? Math.floor(ms / 1000) : NaN;
|
||||
}
|
||||
|
||||
/**
|
||||
* 마지막 캔들과 동일한 time을 가진 indicator 행 인덱스.
|
||||
*/
|
||||
export function findIndicatorRowIndexForCandle(
|
||||
indicators: IndicatorData[],
|
||||
candleLastIdx: number,
|
||||
candleTime: string | number | undefined
|
||||
): number {
|
||||
if (!indicators.length) return -1;
|
||||
const ct = candleTime != null && candleTime !== '' ? String(candleTime) : '';
|
||||
if (ct) {
|
||||
for (let i = indicators.length - 1; i >= 0; i--) {
|
||||
const ind = indicators[i] as any;
|
||||
const t = ind?.time ?? ind?.timestamp ?? ind?.date;
|
||||
if (t !== undefined && t !== null && String(t) === ct) return i;
|
||||
}
|
||||
}
|
||||
return Math.min(Math.max(0, candleLastIdx), indicators.length - 1);
|
||||
}
|
||||
|
||||
/** 마지막 두 봉 간격(초). 비정상이면 최소 60초 가정 */
|
||||
export function inferBarIntervalSecondsFromCandles(candles: CandleData[], fallbackSec = 60): number {
|
||||
if (candles.length < 2) return fallbackSec;
|
||||
const lastIdx = candles.length - 1;
|
||||
const b = candleTimeToUnixSeconds(candles[lastIdx]?.time);
|
||||
if (!Number.isFinite(b)) return fallbackSec;
|
||||
for (let i = lastIdx - 1; i >= 0; i--) {
|
||||
const a = candleTimeToUnixSeconds(candles[i]?.time);
|
||||
if (!Number.isFinite(a) || a === b) continue;
|
||||
return Math.max(60, Math.abs(b - a));
|
||||
}
|
||||
return fallbackSec;
|
||||
}
|
||||
|
||||
/** WS 틱이 차트 마지막 봉보다 이전 봉이면 무시 */
|
||||
export function isWsCandleStaleForChart(
|
||||
wsTimeSec: number,
|
||||
chartCandles: CandleData[],
|
||||
timeframe: string
|
||||
): boolean {
|
||||
if (!chartCandles.length || !Number.isFinite(wsTimeSec)) return true;
|
||||
const lastOpenMs = chartCandleOpenToEpochMs(chartCandles[chartCandles.length - 1]?.time);
|
||||
const wsMs = wsTimeSec > 1e12 ? wsTimeSec : wsTimeSec * 1000;
|
||||
if (!Number.isFinite(lastOpenMs) || !Number.isFinite(wsMs)) return false;
|
||||
const intervalMs = timeframeToIntervalMs(timeframe);
|
||||
if (intervalMs <= 0) return false;
|
||||
return Math.floor(wsMs / intervalMs) < Math.floor(lastOpenMs / intervalMs);
|
||||
}
|
||||
|
||||
/** WS 캔들 틱 시각 → epoch ms (백엔드 Realtime과 동일 스케일) */
|
||||
export function wsTickEpochMs(ws: {
|
||||
timestamp?: number;
|
||||
time?: number | string | undefined;
|
||||
}): number {
|
||||
const raw = ws.timestamp ?? ws.time;
|
||||
if (raw == null || raw === '') return NaN;
|
||||
if (typeof raw === 'number') {
|
||||
return raw > 1e12 ? raw : raw * 1000;
|
||||
}
|
||||
const ms = new Date(String(raw)).getTime();
|
||||
return Number.isFinite(ms) ? ms : NaN;
|
||||
}
|
||||
|
||||
/** 차트 마지막 봉보다 버킷이 큰 WS 캔들만, 버킷당 최신 틱 하나, 시간순 */
|
||||
export function wsCandlesAheadOfChartTail<
|
||||
T extends { timestamp?: number; time?: number | string; open: number; high: number; low: number; close: number; volume?: number }
|
||||
>(wsCandles: T[], chartLastTime: string | number | undefined, timeframe: string): T[] {
|
||||
const intervalMs = timeframeToIntervalMs(timeframe);
|
||||
const chartMs = chartCandleOpenToEpochMs(chartLastTime);
|
||||
if (!Number.isFinite(chartMs) || chartMs <= 0 || !wsCandles?.length || intervalMs <= 0) return [];
|
||||
const chartBucket = Math.floor(chartMs / intervalMs);
|
||||
const byBucket = new Map<number, T>();
|
||||
for (const c of wsCandles) {
|
||||
const ms = wsTickEpochMs(c);
|
||||
if (!Number.isFinite(ms)) continue;
|
||||
const b = Math.floor(ms / intervalMs);
|
||||
if (b > chartBucket) {
|
||||
byBucket.set(b, c);
|
||||
}
|
||||
}
|
||||
return Array.from(byBucket.entries())
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([, c]) => c);
|
||||
}
|
||||
|
||||
/** REST/차트와 동일한 로컬 ISO 문자열 (time 필드 비교·표시용) */
|
||||
export function formatCandleTimeLocalFromMs(openMs: number): string {
|
||||
const d = new Date(openMs);
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
/** 차트 마지막 time 타입에 맞춘 봉 시각 (문자열 REST vs 숫자 초) */
|
||||
export function candleOpenTimeForChartRow(openMs: number, lastChartTime: string | number | undefined): string | number {
|
||||
if (typeof lastChartTime === 'number') {
|
||||
if (lastChartTime > 1e12) return Math.floor(openMs);
|
||||
return Math.floor(openMs / 1000);
|
||||
}
|
||||
return formatCandleTimeLocalFromMs(openMs);
|
||||
}
|
||||
|
||||
/** WS 시각이 차트 마지막 봉과 같은 봉 버킷인지 (REST 중복 봉·추정 간격 오류 방지 — timeframe 필수) */
|
||||
export function isWsCandleSameBarAsChartLast(
|
||||
wsTimeSec: number,
|
||||
chartCandles: CandleData[],
|
||||
timeframe: string
|
||||
): boolean {
|
||||
if (!chartCandles.length || !Number.isFinite(wsTimeSec)) return false;
|
||||
const lastOpenMs = chartCandleOpenToEpochMs(chartCandles[chartCandles.length - 1]?.time);
|
||||
const wsMs = wsTimeSec > 1e12 ? wsTimeSec : wsTimeSec * 1000;
|
||||
if (!Number.isFinite(lastOpenMs) || !Number.isFinite(wsMs)) return false;
|
||||
const intervalMs = timeframeToIntervalMs(timeframe);
|
||||
if (intervalMs <= 0) return false;
|
||||
return Math.floor(wsMs / intervalMs) === Math.floor(lastOpenMs / intervalMs);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* WebSocket 보조지표를 차트 형식으로 변환
|
||||
* (투자분석 CandlestickTestPage와 동일)
|
||||
*/
|
||||
export function mapWsIndicatorsToChartFormat(ws: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
const w = ws as Record<string, unknown>;
|
||||
|
||||
if (ws.macd != null) out.macdLine = ws.macd;
|
||||
if (w.macdLine != null) out.macdLine = w.macdLine;
|
||||
if (w.macd_line != null) out.macdLine = w.macd_line;
|
||||
if (ws.macd_signal != null) {
|
||||
out.signalLine = ws.macd_signal;
|
||||
out.macdSignal = ws.macd_signal;
|
||||
}
|
||||
if (w.macdSignal != null) {
|
||||
out.signalLine = w.macdSignal;
|
||||
out.macdSignal = w.macdSignal;
|
||||
}
|
||||
if (ws.macd_histogram != null) out.macdHistogram = ws.macd_histogram;
|
||||
if (w.macdHistogram != null) out.macdHistogram = w.macdHistogram;
|
||||
if (w.histogram != null) out.macdHistogram = w.histogram;
|
||||
if (ws.rsi_14 != null) out.rsi = ws.rsi_14;
|
||||
if (w.rsi != null) out.rsi = w.rsi;
|
||||
if (ws.stoch_k != null) {
|
||||
out.stochasticK = ws.stoch_k;
|
||||
out.stochK = ws.stoch_k;
|
||||
}
|
||||
if (ws.stoch_d != null) out.stochasticD = ws.stoch_d;
|
||||
if (w.stochasticK != null) out.stochasticK = w.stochasticK;
|
||||
if (w.stochasticD != null) out.stochasticD = w.stochasticD;
|
||||
|
||||
if (ws.stoch_slow_k != null) out.stochasticSlowK = ws.stoch_slow_k;
|
||||
if (ws.stoch_slow_d != null) out.stochasticSlowD = ws.stoch_slow_d;
|
||||
if (w.stochasticSlowK != null) out.stochasticSlowK = w.stochasticSlowK;
|
||||
if (w.stochasticSlowD != null) out.stochasticSlowD = w.stochasticSlowD;
|
||||
|
||||
if (ws.bb_upper != null) out.bbUpper = ws.bb_upper;
|
||||
if (ws.bb_middle != null) out.bbMiddle = ws.bb_middle;
|
||||
if (ws.bb_lower != null) out.bbLower = ws.bb_lower;
|
||||
if (ws.ema_9 != null) out.ema9 = ws.ema_9;
|
||||
if (ws.ema_21 != null) out.ema21 = ws.ema_21;
|
||||
if (ws.adx != null) out.adx = ws.adx;
|
||||
if (ws.plus_di != null) out.plusDI = ws.plus_di;
|
||||
if (ws.minus_di != null) out.minusDI = ws.minus_di;
|
||||
if (w.plusDi != null) out.plusDI = w.plusDi;
|
||||
if (w.minusDi != null) out.minusDI = w.minusDi;
|
||||
|
||||
if (ws.ma_5 != null) out.ma5 = ws.ma_5;
|
||||
if (ws.ma_10 != null) out.ma10 = ws.ma_10;
|
||||
if (ws.ma_20 != null) out.ma20 = ws.ma_20;
|
||||
if (ws.ma_60 != null) out.ma60 = ws.ma_60;
|
||||
if (ws.ma_120 != null) out.ma120 = ws.ma_120;
|
||||
|
||||
if (ws.ichimoku_tenkan != null) out.ichimokuTenkan = ws.ichimoku_tenkan;
|
||||
if (ws.ichimoku_kijun != null) out.ichimokuKijun = ws.ichimoku_kijun;
|
||||
if (ws.ichimoku_senkou_a != null) out.ichimokuSenkouA = ws.ichimoku_senkou_a;
|
||||
if (ws.ichimoku_senkou_b != null) out.ichimokuSenkouB = ws.ichimoku_senkou_b;
|
||||
if (ws.ichimoku_chikou != null) out.ichimokuChikou = ws.ichimoku_chikou;
|
||||
|
||||
if (ws.cci != null) {
|
||||
out.cci = ws.cci;
|
||||
out.cci13 = ws.cci;
|
||||
}
|
||||
if (w.cci13 != null) {
|
||||
out.cci13 = w.cci13;
|
||||
if (out.cci == null) out.cci = w.cci13;
|
||||
}
|
||||
if (ws.cci_signal != null) out.cciSignal = ws.cci_signal;
|
||||
if (w.cciSignal != null) out.cciSignal = w.cciSignal;
|
||||
if (ws.obv != null) out.obv = ws.obv;
|
||||
if (ws.obv_signal != null) out.obvSignal = ws.obv_signal;
|
||||
if (w.obvSignal != null) out.obvSignal = w.obvSignal;
|
||||
if (ws.williams_r != null) out.williamsR = ws.williams_r;
|
||||
if (ws.trix != null) out.trix = ws.trix;
|
||||
if (ws.trix_signal != null) out.trixSignal = ws.trix_signal;
|
||||
if (ws.bwi != null) out.bwi = ws.bwi;
|
||||
if (ws.volume_osc != null) out.volumeOsc = ws.volume_osc;
|
||||
if (ws.vr != null) out.vr = ws.vr;
|
||||
if (ws.vr2 != null) out.vr2 = ws.vr2;
|
||||
if (ws.new_psy != null) {
|
||||
out.newPsy = ws.new_psy;
|
||||
out.newPsychological = ws.new_psy;
|
||||
}
|
||||
if (ws.invest_psy != null) {
|
||||
out.investPsy = ws.invest_psy;
|
||||
out.investPsychological = ws.invest_psy;
|
||||
}
|
||||
if (w.newPsychological != null) out.newPsychological = w.newPsychological;
|
||||
if (w.investPsychological != null) out.investPsychological = w.investPsychological;
|
||||
if (ws.disparity_ultra_short != null) out.disparityUltraShort = ws.disparity_ultra_short;
|
||||
if (ws.disparity_short != null) out.disparityShort = ws.disparity_short;
|
||||
if (ws.disparity_mid != null) out.disparityMid = ws.disparity_mid;
|
||||
if (out.stochasticK != null && out.stochK == null) out.stochK = out.stochasticK;
|
||||
if (out.newPsy != null && out.newPsychological == null) out.newPsychological = out.newPsy;
|
||||
if (out.newPsychological != null && out.newPsy == null) out.newPsy = out.newPsychological;
|
||||
if (out.investPsy != null && out.investPsychological == null) out.investPsychological = out.investPsy;
|
||||
if (out.investPsychological != null && out.investPsy == null) out.investPsy = out.investPsychological;
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { ChartSlot, StockInfo } from '../stores/useDashboardStore';
|
||||
|
||||
/** 멀티차트 / 알림목록 우측 그리드에 둘 수 있는 종목(차트) 상한 */
|
||||
export const MAX_MULTI_CHART_COUNT = 10;
|
||||
|
||||
/** 행×열 (예: 2×3 = 2행 3열) — 드롭다운·persist에 사용 */
|
||||
export const MULTI_CHART_GRID_PRESETS = [
|
||||
'1x1',
|
||||
'1x2',
|
||||
'1x3',
|
||||
'2x1',
|
||||
'2x2',
|
||||
'2x3',
|
||||
'3x1',
|
||||
'3x2',
|
||||
'3x3',
|
||||
] as const;
|
||||
|
||||
export type MultiChartGridPreset = (typeof MULTI_CHART_GRID_PRESETS)[number];
|
||||
|
||||
/** 하위 호환 */
|
||||
export type GridViewMode = MultiChartGridPreset;
|
||||
export type ColumnCount = MultiChartGridPreset;
|
||||
|
||||
const PRESET_ORDER = new Set<string>(MULTI_CHART_GRID_PRESETS);
|
||||
|
||||
export function isMultiChartGridPreset(s: string): s is MultiChartGridPreset {
|
||||
return PRESET_ORDER.has(s);
|
||||
}
|
||||
|
||||
export function presetSlotCount(p: MultiChartGridPreset): number {
|
||||
const [r, c] = p.split('x').map(Number);
|
||||
return r * c;
|
||||
}
|
||||
|
||||
export function gridRowsForPreset(p: MultiChartGridPreset): number {
|
||||
const [r] = p.split('x').map(Number);
|
||||
return r;
|
||||
}
|
||||
|
||||
export function gridColsForPreset(p: MultiChartGridPreset): number {
|
||||
const [, c] = p.split('x').map(Number);
|
||||
return c;
|
||||
}
|
||||
|
||||
/** @deprecated `gridColsForPreset` 사용 */
|
||||
export const gridColsForMode = gridColsForPreset;
|
||||
|
||||
/** 예전 숫자 columnCount(1|2|4|6|9) 또는 열 수 → 프리셋 */
|
||||
export function migrateLegacyColumnCount(raw: unknown): MultiChartGridPreset {
|
||||
const n = Number(raw);
|
||||
// 예전 1열(세로 다종목)은 1x1이면 종목 대부분 유실 → 3x3으로 최대 9칸까지 보존
|
||||
if (n === 1) return '3x3';
|
||||
if (n === 2) return '1x2';
|
||||
if (n === 4) return '2x2';
|
||||
if (n === 6) return '2x3';
|
||||
if (n === 9) return '3x3';
|
||||
if (n === 3) return '3x3';
|
||||
return '1x2';
|
||||
}
|
||||
|
||||
export function normalizeMultiChartGridPreset(raw: unknown): MultiChartGridPreset {
|
||||
if (typeof raw === 'string' && isMultiChartGridPreset(raw)) return raw;
|
||||
if (typeof raw === 'number') return migrateLegacyColumnCount(raw);
|
||||
return '1x2';
|
||||
}
|
||||
|
||||
/** @deprecated `normalizeMultiChartGridPreset` 사용 */
|
||||
export function normalizeGridViewMode(n: number): MultiChartGridPreset {
|
||||
return migrateLegacyColumnCount(n);
|
||||
}
|
||||
|
||||
export function sortSlotsByGridIndex(slots: ChartSlot[]): ChartSlot[] {
|
||||
return [...slots].sort((a, b) => a.gridIndex - b.gridIndex);
|
||||
}
|
||||
|
||||
export function flatStocksRowMajor(slots: ChartSlot[]): (StockInfo | null)[] {
|
||||
return sortSlotsByGridIndex(slots).map((s) => s.stock);
|
||||
}
|
||||
|
||||
export function stocksInGridOrder(slots: ChartSlot[]): StockInfo[] {
|
||||
const r: StockInfo[] = [];
|
||||
for (const s of sortSlotsByGridIndex(slots)) {
|
||||
if (s.stock) r.push(s.stock);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* 고정 그리드: `preset`의 칸 수만큼 슬롯을 채우고 부족분은 null.
|
||||
*/
|
||||
export function packFlatForGridView(stocks: StockInfo[], preset: MultiChartGridPreset): (StockInfo | null)[] {
|
||||
const n = presetSlotCount(preset);
|
||||
const out: (StockInfo | null)[] = stocks.slice(0, n);
|
||||
while (out.length < n) out.push(null);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 2·3열(레거시): 마지막 행을 cols로 맞추고, 마지막 행이 모두 종목이면 빈 행 한 줄 추가.
|
||||
* 1열: 종목만 나열. 단, 맨 끝 null 한 칸은 "차트 추가" 플레이스홀더로 유지.
|
||||
*/
|
||||
export function packFlatForColumns(flat: (StockInfo | null)[], cols: number): (StockInfo | null)[] {
|
||||
if (cols === 1) {
|
||||
const stocks = flat.filter((x): x is StockInfo => x != null);
|
||||
const trailingPlaceholder = flat.length > 0 && flat[flat.length - 1] == null;
|
||||
if (trailingPlaceholder) return [...stocks, null];
|
||||
return stocks;
|
||||
}
|
||||
let out = [...flat];
|
||||
while (out.length > 0 && out[out.length - 1] == null) out.pop();
|
||||
while (out.length % cols !== 0) out.push(null);
|
||||
if (out.length === 0) out = Array(cols).fill(null);
|
||||
const last = out.slice(-cols);
|
||||
if (last.length === cols && last.every((x) => x != null)) {
|
||||
out = [...out, ...Array(cols).fill(null)];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function countStocksInSlots(slots: ChartSlot[]): number {
|
||||
return flatStocksRowMajor(slots).filter((s): s is StockInfo => s != null).length;
|
||||
}
|
||||
|
||||
/** 저장/가져오기 등: 종목 수를 상한으로 자르고 패킹만 다시 계산 */
|
||||
export function clampSlotStocksToMax(
|
||||
slots: ChartSlot[],
|
||||
preset: MultiChartGridPreset,
|
||||
maxCount: number = MAX_MULTI_CHART_COUNT
|
||||
): (StockInfo | null)[] {
|
||||
const sorted = sortSlotsByGridIndex(slots);
|
||||
const stocks = sorted.map((s) => s.stock).filter((x): x is StockInfo => x != null).slice(0, maxCount);
|
||||
return packFlatForGridView(stocks, preset);
|
||||
}
|
||||
|
||||
/** 레거시: 숫자 열 수만 알 때(가져오기 등) */
|
||||
export function repackFlatForColumnChange(
|
||||
flat: (StockInfo | null)[],
|
||||
oldCols: number,
|
||||
newCols: number
|
||||
): (StockInfo | null)[] {
|
||||
const rows = Math.max(1, Math.ceil(flat.length / oldCols));
|
||||
const seq: (StockInfo | null)[] = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < oldCols; c++) {
|
||||
const idx = r * oldCols + c;
|
||||
if (idx < flat.length) seq.push(flat[idx]);
|
||||
}
|
||||
}
|
||||
while (seq.length && seq[seq.length - 1] == null) seq.pop();
|
||||
if (newCols === 1) {
|
||||
const stocks = seq.filter((x): x is StockInfo => x != null);
|
||||
return stocks;
|
||||
}
|
||||
return packFlatForColumns(seq, newCols);
|
||||
}
|
||||
|
||||
/** 프리셋 전환 시 flat(행 우선, null 포함)을 새 그리드에 맞게 재배치 */
|
||||
export function repackFlatForGridModeChange(
|
||||
flat: (StockInfo | null)[],
|
||||
oldPreset: MultiChartGridPreset,
|
||||
newPreset: MultiChartGridPreset
|
||||
): (StockInfo | null)[] {
|
||||
const seq: StockInfo[] = [];
|
||||
const oldCols = gridColsForPreset(oldPreset);
|
||||
const oldLen = Math.max(flat.length, presetSlotCount(oldPreset));
|
||||
const rows = Math.max(1, Math.ceil(oldLen / oldCols));
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < oldCols; c++) {
|
||||
const idx = r * oldCols + c;
|
||||
if (idx < flat.length && flat[idx]) seq.push(flat[idx]!);
|
||||
}
|
||||
}
|
||||
return packFlatForGridView(seq.slice(0, MAX_MULTI_CHART_COUNT), newPreset);
|
||||
}
|
||||
|
||||
export function flatToSlots(flat: (StockInfo | null)[], prevSlots: ChartSlot[], nextId: () => string): ChartSlot[] {
|
||||
const sortedPrev = sortSlotsByGridIndex(prevSlots);
|
||||
return flat.map((stock, i) => {
|
||||
const prev = sortedPrev.find((p) => p.gridIndex === i);
|
||||
if (prev) return { ...prev, gridIndex: i, stock };
|
||||
return { id: nextId(), gridIndex: i, stock };
|
||||
});
|
||||
}
|
||||
|
||||
export function slotsAfterStockChange(slots: ChartSlot[], preset: MultiChartGridPreset, nextId: () => string): ChartSlot[] {
|
||||
const sorted = sortSlotsByGridIndex(slots);
|
||||
const stocks = stocksInGridOrder(sorted);
|
||||
const packed = packFlatForGridView(stocks, preset);
|
||||
return flatToSlots(packed, sorted, nextId);
|
||||
}
|
||||
|
||||
export function removeSlotAtGridIndexOneColumn(slots: ChartSlot[], gridIndex: number, nextId: () => string): ChartSlot[] {
|
||||
const sorted = sortSlotsByGridIndex(slots);
|
||||
const idx = sorted.findIndex((s) => s.gridIndex === gridIndex);
|
||||
if (idx < 0) return sorted;
|
||||
const remaining = sorted.filter((_, i) => i !== idx);
|
||||
const flat = remaining.map((s) => s.stock);
|
||||
const packed = packFlatForColumns(flat, 1);
|
||||
return flatToSlots(packed, remaining, nextId);
|
||||
}
|
||||
|
||||
export function trimTrailingEmptyRows(slots: ChartSlot[], preset: MultiChartGridPreset, nextId: () => string): ChartSlot[] {
|
||||
const stocks = stocksInGridOrder(slots);
|
||||
const flat = packFlatForGridView(stocks, preset);
|
||||
return flatToSlots(flat, sortSlotsByGridIndex(slots), nextId);
|
||||
}
|
||||
|
||||
/** 1열: 맨 아래에 빈 슬롯(추가용) 한 칸 부착 */
|
||||
export function appendTrailingPlaceholderOneColumn(slots: ChartSlot[], nextId: () => string): ChartSlot[] {
|
||||
const sorted = sortSlotsByGridIndex(slots);
|
||||
const flat = sorted.map((s) => s.stock);
|
||||
const packed = packFlatForColumns(flat, 1);
|
||||
if (packed.length > 0 && packed[packed.length - 1] == null) {
|
||||
return flatToSlots(packed, sorted, nextId);
|
||||
}
|
||||
const withNull = [...packed, null];
|
||||
return flatToSlots(withNull, sorted, nextId);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import type { ChartColorSettings } from '../contexts/ChartColorContext';
|
||||
import {
|
||||
defaultMultiChartBollinger,
|
||||
defaultMultiChartIchimoku,
|
||||
type MultiChartBollingerState,
|
||||
type MultiChartIchimokuState,
|
||||
} from '../stores/useDashboardStore';
|
||||
|
||||
/** IchimokuSettingsDialog 로컬 상태와 동일 키 */
|
||||
export type IchimokuDialogLocalSettings = {
|
||||
ichimokuTenkan: boolean;
|
||||
ichimokuKijun: boolean;
|
||||
ichimokuChikou: boolean;
|
||||
ichimokuSenkouA: boolean;
|
||||
ichimokuSenkouB: boolean;
|
||||
ichimokuTenkanPeriod: number;
|
||||
ichimokuKijunPeriod: number;
|
||||
ichimokuChikouPeriod: number;
|
||||
ichimokuSenkouAPeriod: number;
|
||||
ichimokuSenkouBPeriod: number;
|
||||
};
|
||||
|
||||
export type IchimokuDialogLocalColors = {
|
||||
ichimokuTenkan: string;
|
||||
ichimokuKijun: string;
|
||||
ichimokuChikou: string;
|
||||
ichimokuSenkouA: string;
|
||||
ichimokuSenkouB: string;
|
||||
ichimokuTenkanWidth: number;
|
||||
ichimokuKijunWidth: number;
|
||||
ichimokuChikouWidth: number;
|
||||
ichimokuSenkouAWidth: number;
|
||||
ichimokuSenkouBWidth: number;
|
||||
ichimokuCloudBullish: string;
|
||||
ichimokuCloudBearish: string;
|
||||
};
|
||||
|
||||
export function ichimokuStoreToDialogSettings(ich: MultiChartIchimokuState): IchimokuDialogLocalSettings {
|
||||
return {
|
||||
ichimokuTenkan: ich.tenkan,
|
||||
ichimokuKijun: ich.kijun,
|
||||
ichimokuChikou: ich.chikou,
|
||||
ichimokuSenkouA: ich.senkouA,
|
||||
ichimokuSenkouB: ich.senkouB,
|
||||
ichimokuTenkanPeriod: ich.tenkanPeriod,
|
||||
ichimokuKijunPeriod: ich.kijunPeriod,
|
||||
ichimokuChikouPeriod: ich.chikouPeriod,
|
||||
ichimokuSenkouAPeriod: ich.senkouAPeriod,
|
||||
ichimokuSenkouBPeriod: ich.senkouBPeriod,
|
||||
};
|
||||
}
|
||||
|
||||
export function ichimokuStoreToDialogColors(ich: MultiChartIchimokuState): IchimokuDialogLocalColors {
|
||||
return {
|
||||
ichimokuTenkan: ich.tenkanColor,
|
||||
ichimokuKijun: ich.kijunColor,
|
||||
ichimokuChikou: ich.chikouColor,
|
||||
ichimokuSenkouA: ich.senkouAColor,
|
||||
ichimokuSenkouB: ich.senkouBColor,
|
||||
ichimokuTenkanWidth: ich.tenkanWidth,
|
||||
ichimokuKijunWidth: ich.kijunWidth,
|
||||
ichimokuChikouWidth: ich.chikouWidth,
|
||||
ichimokuSenkouAWidth: ich.senkouAWidth,
|
||||
ichimokuSenkouBWidth: ich.senkouBWidth,
|
||||
ichimokuCloudBullish: ich.cloudBullish,
|
||||
ichimokuCloudBearish: ich.cloudBearish,
|
||||
};
|
||||
}
|
||||
|
||||
export function ichimokuDialogToStore(
|
||||
localSettings: IchimokuDialogLocalSettings,
|
||||
localColors: IchimokuDialogLocalColors
|
||||
): MultiChartIchimokuState {
|
||||
return {
|
||||
tenkan: localSettings.ichimokuTenkan,
|
||||
kijun: localSettings.ichimokuKijun,
|
||||
chikou: localSettings.ichimokuChikou,
|
||||
senkouA: localSettings.ichimokuSenkouA,
|
||||
senkouB: localSettings.ichimokuSenkouB,
|
||||
tenkanPeriod: localSettings.ichimokuTenkanPeriod,
|
||||
kijunPeriod: localSettings.ichimokuKijunPeriod,
|
||||
chikouPeriod: localSettings.ichimokuChikouPeriod,
|
||||
senkouAPeriod: localSettings.ichimokuSenkouAPeriod,
|
||||
senkouBPeriod: localSettings.ichimokuSenkouBPeriod,
|
||||
tenkanColor: localColors.ichimokuTenkan,
|
||||
kijunColor: localColors.ichimokuKijun,
|
||||
chikouColor: localColors.ichimokuChikou,
|
||||
senkouAColor: localColors.ichimokuSenkouA,
|
||||
senkouBColor: localColors.ichimokuSenkouB,
|
||||
tenkanWidth: localColors.ichimokuTenkanWidth,
|
||||
kijunWidth: localColors.ichimokuKijunWidth,
|
||||
chikouWidth: localColors.ichimokuChikouWidth,
|
||||
senkouAWidth: localColors.ichimokuSenkouAWidth,
|
||||
senkouBWidth: localColors.ichimokuSenkouBWidth,
|
||||
cloudBullish: localColors.ichimokuCloudBullish,
|
||||
cloudBearish: localColors.ichimokuCloudBearish,
|
||||
};
|
||||
}
|
||||
|
||||
export function ichimokuDialogDefaultsFromStore(): {
|
||||
localSettings: IchimokuDialogLocalSettings;
|
||||
localColors: IchimokuDialogLocalColors;
|
||||
} {
|
||||
const d = defaultMultiChartIchimoku();
|
||||
return {
|
||||
localSettings: ichimokuStoreToDialogSettings(d),
|
||||
localColors: ichimokuStoreToDialogColors(d),
|
||||
};
|
||||
}
|
||||
|
||||
export type BollingerDialogLocalSettings = {
|
||||
bollingerUpper: boolean;
|
||||
bollingerMiddle: boolean;
|
||||
bollingerLower: boolean;
|
||||
bollingerPeriod: number;
|
||||
bollingerStdDev: number;
|
||||
};
|
||||
|
||||
export type BollingerDialogLocalColors = {
|
||||
bollingerUpperColor: string;
|
||||
bollingerMiddleColor: string;
|
||||
bollingerLowerColor: string;
|
||||
bollingerUpperWidth: number;
|
||||
bollingerMiddleWidth: number;
|
||||
bollingerLowerWidth: number;
|
||||
bollingerUpperLineStyle: 'solid' | 'dashed' | 'dotted';
|
||||
bollingerMiddleLineStyle: 'solid' | 'dashed' | 'dotted';
|
||||
bollingerLowerLineStyle: 'solid' | 'dashed' | 'dotted';
|
||||
bollingerBandBackgroundColor: string;
|
||||
};
|
||||
|
||||
export function bollingerStoreToDialogSettings(b: MultiChartBollingerState): BollingerDialogLocalSettings {
|
||||
return {
|
||||
bollingerUpper: b.upper,
|
||||
bollingerMiddle: b.middle,
|
||||
bollingerLower: b.lower,
|
||||
bollingerPeriod: b.period,
|
||||
bollingerStdDev: b.stdDev,
|
||||
};
|
||||
}
|
||||
|
||||
export function bollingerStoreToDialogColors(b: MultiChartBollingerState): BollingerDialogLocalColors {
|
||||
return {
|
||||
bollingerUpperColor: b.upperColor,
|
||||
bollingerMiddleColor: b.middleColor,
|
||||
bollingerLowerColor: b.lowerColor,
|
||||
bollingerUpperWidth: b.upperWidth,
|
||||
bollingerMiddleWidth: b.middleWidth,
|
||||
bollingerLowerWidth: b.lowerWidth,
|
||||
bollingerUpperLineStyle: b.upperLineStyle,
|
||||
bollingerMiddleLineStyle: b.middleLineStyle,
|
||||
bollingerLowerLineStyle: b.lowerLineStyle,
|
||||
bollingerBandBackgroundColor: b.bandBackgroundColor,
|
||||
};
|
||||
}
|
||||
|
||||
export function bollingerDialogToStore(
|
||||
localSettings: BollingerDialogLocalSettings,
|
||||
localColors: BollingerDialogLocalColors
|
||||
): MultiChartBollingerState {
|
||||
return {
|
||||
upper: localSettings.bollingerUpper,
|
||||
middle: localSettings.bollingerMiddle,
|
||||
lower: localSettings.bollingerLower,
|
||||
period: localSettings.bollingerPeriod,
|
||||
stdDev: localSettings.bollingerStdDev,
|
||||
upperColor: localColors.bollingerUpperColor,
|
||||
middleColor: localColors.bollingerMiddleColor,
|
||||
lowerColor: localColors.bollingerLowerColor,
|
||||
upperWidth: localColors.bollingerUpperWidth,
|
||||
middleWidth: localColors.bollingerMiddleWidth,
|
||||
lowerWidth: localColors.bollingerLowerWidth,
|
||||
upperLineStyle: localColors.bollingerUpperLineStyle,
|
||||
middleLineStyle: localColors.bollingerMiddleLineStyle,
|
||||
lowerLineStyle: localColors.bollingerLowerLineStyle,
|
||||
bandBackgroundColor: localColors.bollingerBandBackgroundColor,
|
||||
};
|
||||
}
|
||||
|
||||
export function bollingerDialogDefaultsFromStore(): {
|
||||
localSettings: BollingerDialogLocalSettings;
|
||||
localColors: BollingerDialogLocalColors;
|
||||
} {
|
||||
const d = defaultMultiChartBollinger();
|
||||
return {
|
||||
localSettings: bollingerStoreToDialogSettings(d),
|
||||
localColors: bollingerStoreToDialogColors(d),
|
||||
};
|
||||
}
|
||||
|
||||
/** CandlestickChart / ChartItemInvestment ichimokuSettings 형태 */
|
||||
export interface IchimokuChartSettings {
|
||||
tenkan: boolean;
|
||||
kijun: boolean;
|
||||
chikou: boolean;
|
||||
senkouA: boolean;
|
||||
senkouB: boolean;
|
||||
tenkanPeriod: number;
|
||||
kijunPeriod: number;
|
||||
senkouBPeriod: number;
|
||||
}
|
||||
|
||||
export function ichimokuStoreToChartSettings(ich: MultiChartIchimokuState): IchimokuChartSettings | undefined {
|
||||
if (!ich.tenkan && !ich.kijun && !ich.chikou && !ich.senkouA && !ich.senkouB) return undefined;
|
||||
return {
|
||||
tenkan: ich.tenkan,
|
||||
kijun: ich.kijun,
|
||||
chikou: ich.chikou,
|
||||
senkouA: ich.senkouA,
|
||||
senkouB: ich.senkouB,
|
||||
tenkanPeriod: ich.tenkanPeriod,
|
||||
kijunPeriod: ich.kijunPeriod,
|
||||
senkouBPeriod: ich.senkouBPeriod,
|
||||
};
|
||||
}
|
||||
|
||||
/** 일목 색·굵기·구름 → CandlestickChart colorSettings 일부만 덮어씀 */
|
||||
export function ichimokuStoreToColorOverrides(ich: MultiChartIchimokuState): Partial<ChartColorSettings> {
|
||||
return {
|
||||
ichimokuTenkan: ich.tenkanColor,
|
||||
ichimokuKijun: ich.kijunColor,
|
||||
ichimokuChikou: ich.chikouColor,
|
||||
ichimokuSenkouA: ich.senkouAColor,
|
||||
ichimokuSenkouB: ich.senkouBColor,
|
||||
ichimokuTenkanWidth: ich.tenkanWidth,
|
||||
ichimokuKijunWidth: ich.kijunWidth,
|
||||
ichimokuChikouWidth: ich.chikouWidth,
|
||||
ichimokuSenkouAWidth: ich.senkouAWidth,
|
||||
ichimokuSenkouBWidth: ich.senkouBWidth,
|
||||
ichimokuCloudBullish: ich.cloudBullish,
|
||||
ichimokuCloudBearish: ich.cloudBearish,
|
||||
};
|
||||
}
|
||||
|
||||
export function bollingerStoreToColorOverrides(b: MultiChartBollingerState): Partial<ChartColorSettings> {
|
||||
return {
|
||||
bollingerUpperColor: b.upperColor,
|
||||
bollingerMiddleColor: b.middleColor,
|
||||
bollingerLowerColor: b.lowerColor,
|
||||
bollingerUpperWidth: b.upperWidth,
|
||||
bollingerMiddleWidth: b.middleWidth,
|
||||
bollingerLowerWidth: b.lowerWidth,
|
||||
bollingerUpperLineStyle: b.upperLineStyle,
|
||||
bollingerMiddleLineStyle: b.middleLineStyle,
|
||||
bollingerLowerLineStyle: b.lowerLineStyle,
|
||||
bollingerBandBackgroundColor: b.bandBackgroundColor,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* 성능 모니터링 유틸리티
|
||||
* - 컴포넌트 렌더링 시간 측정
|
||||
* - API 호출 시간 측정
|
||||
* - 메모리 사용량 모니터링
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface PerformanceMetric {
|
||||
name: string;
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
duration?: number;
|
||||
type: 'render' | 'api' | 'compute' | 'other';
|
||||
}
|
||||
|
||||
class PerformanceMonitor {
|
||||
private metrics: Map<string, PerformanceMetric> = new Map();
|
||||
private enabled: boolean = process.env.NODE_ENV === 'development';
|
||||
|
||||
/**
|
||||
* 측정 시작
|
||||
*/
|
||||
start(name: string, type: PerformanceMetric['type'] = 'other'): void {
|
||||
if (!this.enabled) return;
|
||||
|
||||
this.metrics.set(name, {
|
||||
name,
|
||||
startTime: performance.now(),
|
||||
type,
|
||||
});
|
||||
|
||||
console.log(`⏱️ [Performance] ${name} 시작`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 측정 종료
|
||||
*/
|
||||
end(name: string): number | null {
|
||||
if (!this.enabled) return null;
|
||||
|
||||
const metric = this.metrics.get(name);
|
||||
if (!metric) {
|
||||
console.warn(`⚠️ [Performance] "${name}" 측정이 시작되지 않았습니다`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - metric.startTime;
|
||||
|
||||
metric.endTime = endTime;
|
||||
metric.duration = duration;
|
||||
|
||||
const emoji = duration < 100 ? '✅' : duration < 500 ? '⚠️' : '❌';
|
||||
console.log(`${emoji} [Performance] ${name} 완료: ${duration.toFixed(2)}ms`);
|
||||
|
||||
return duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* 측정 및 실행 (함수 래퍼)
|
||||
*/
|
||||
async measure<T>(
|
||||
name: string,
|
||||
fn: () => T | Promise<T>,
|
||||
type: PerformanceMetric['type'] = 'other'
|
||||
): Promise<T> {
|
||||
this.start(name, type);
|
||||
try {
|
||||
const result = await fn();
|
||||
this.end(name);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.end(name);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* React 컴포넌트 렌더링 측정 HOC
|
||||
*/
|
||||
measureRender<P extends object>(
|
||||
Component: React.ComponentType<P>,
|
||||
componentName: string
|
||||
): React.ComponentType<P> {
|
||||
if (!this.enabled) return Component;
|
||||
|
||||
return (props: P) => {
|
||||
const renderStart = performance.now();
|
||||
|
||||
React.useEffect(() => {
|
||||
const renderEnd = performance.now();
|
||||
const duration = renderEnd - renderStart;
|
||||
|
||||
const emoji = duration < 16 ? '✅' : duration < 50 ? '⚠️' : '❌';
|
||||
console.log(`${emoji} [Render] ${componentName}: ${duration.toFixed(2)}ms`);
|
||||
});
|
||||
|
||||
return React.createElement(Component, props);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 메트릭 조회
|
||||
*/
|
||||
getMetrics(): PerformanceMetric[] {
|
||||
return Array.from(this.metrics.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 메트릭 요약
|
||||
*/
|
||||
getSummary(): Record<string, any> {
|
||||
const metrics = this.getMetrics();
|
||||
const summary: Record<string, any> = {
|
||||
total: metrics.length,
|
||||
byType: {} as Record<string, number>,
|
||||
avgDuration: {} as Record<string, number>,
|
||||
};
|
||||
|
||||
metrics.forEach((metric) => {
|
||||
if (!metric.duration) return;
|
||||
|
||||
// 타입별 카운트
|
||||
summary.byType[metric.type] = (summary.byType[metric.type] || 0) + 1;
|
||||
|
||||
// 타입별 평균 시간
|
||||
if (!summary.avgDuration[metric.type]) {
|
||||
summary.avgDuration[metric.type] = [];
|
||||
}
|
||||
summary.avgDuration[metric.type].push(metric.duration);
|
||||
});
|
||||
|
||||
// 평균 계산
|
||||
Object.keys(summary.avgDuration).forEach((type) => {
|
||||
const durations = summary.avgDuration[type];
|
||||
summary.avgDuration[type] = durations.reduce((a: number, b: number) => a + b, 0) / durations.length;
|
||||
});
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메모리 사용량 측정
|
||||
*/
|
||||
getMemoryUsage(): Record<string, number> | null {
|
||||
if (!this.enabled) return null;
|
||||
|
||||
// @ts-ignore - performance.memory는 Chrome에서만 사용 가능
|
||||
if (typeof performance.memory !== 'undefined') {
|
||||
// @ts-ignore
|
||||
const { usedJSHeapSize, totalJSHeapSize, jsHeapSizeLimit } = performance.memory;
|
||||
|
||||
return {
|
||||
used: Math.round(usedJSHeapSize / 1024 / 1024), // MB
|
||||
total: Math.round(totalJSHeapSize / 1024 / 1024), // MB
|
||||
limit: Math.round(jsHeapSizeLimit / 1024 / 1024), // MB
|
||||
percentage: Math.round((usedJSHeapSize / jsHeapSizeLimit) * 100),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메모리 사용량 로깅
|
||||
*/
|
||||
logMemoryUsage(): void {
|
||||
const memory = this.getMemoryUsage();
|
||||
if (memory) {
|
||||
console.log(`💾 [Memory] ${memory.used}MB / ${memory.limit}MB (${memory.percentage}%)`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메트릭 초기화
|
||||
*/
|
||||
clear(): void {
|
||||
this.metrics.clear();
|
||||
console.log('🧹 [Performance] 메트릭 초기화');
|
||||
}
|
||||
|
||||
/**
|
||||
* 성능 모니터링 활성화/비활성화
|
||||
*/
|
||||
setEnabled(enabled: boolean): void {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* React DevTools Profiler 연동
|
||||
*/
|
||||
onRenderCallback(
|
||||
id: string,
|
||||
phase: 'mount' | 'update',
|
||||
actualDuration: number,
|
||||
baseDuration: number,
|
||||
startTime: number,
|
||||
commitTime: number
|
||||
): void {
|
||||
if (!this.enabled) return;
|
||||
|
||||
const emoji = actualDuration < 16 ? '✅' : actualDuration < 50 ? '⚠️' : '❌';
|
||||
console.log(
|
||||
`${emoji} [Profiler] ${id} (${phase}): ${actualDuration.toFixed(2)}ms (base: ${baseDuration.toFixed(2)}ms)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스
|
||||
export const performanceMonitor = new PerformanceMonitor();
|
||||
|
||||
// React 훅
|
||||
export function usePerformanceMonitor(componentName: string) {
|
||||
React.useEffect(() => {
|
||||
performanceMonitor.start(`${componentName}_mount`, 'render');
|
||||
return () => {
|
||||
performanceMonitor.end(`${componentName}_mount`);
|
||||
};
|
||||
}, [componentName]);
|
||||
}
|
||||
|
||||
// 전역으로 노출 (디버깅용)
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).__performanceMonitor = performanceMonitor;
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Web Push 알림 유틸리티
|
||||
* Service Worker 등록 및 Push 구독 관리
|
||||
*
|
||||
* ⚠️ 주의: Service Worker와 Web Push는 HTTPS 또는 localhost에서만 작동합니다!
|
||||
* HTTP 환경에서는 브라우저 알림만 사용하세요.
|
||||
*/
|
||||
|
||||
// VAPID 공개 키 (백엔드에서 생성)
|
||||
// 주의: 이 키는 백엔드 application.yml의 push.vapid.public-key와 동일해야 합니다!
|
||||
const VAPID_PUBLIC_KEY = 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE4xZ0NOe_lJmuwRmNvEy8sokT3IqDxQiKBOx8YRJfw3fbURO36jPl0WC-SD9ZKbgldcnfUIYJHHOdCmanShM90g';
|
||||
|
||||
/**
|
||||
* HTTPS 환경 확인
|
||||
* Service Worker는 HTTPS 또는 localhost에서만 작동
|
||||
*/
|
||||
export const isSecureContext = (): boolean => {
|
||||
// HTTPS 또는 localhost 확인
|
||||
return window.isSecureContext ||
|
||||
window.location.protocol === 'https:' ||
|
||||
window.location.hostname === 'localhost' ||
|
||||
window.location.hostname === '127.0.0.1';
|
||||
};
|
||||
|
||||
/**
|
||||
* PWA Web Push 지원 확인
|
||||
*/
|
||||
export const isPWAPushSupported = (): boolean => {
|
||||
return isSecureContext() && 'serviceWorker' in navigator && 'PushManager' in window;
|
||||
};
|
||||
|
||||
/**
|
||||
* Service Worker 등록
|
||||
* ⚠️ HTTPS 또는 localhost에서만 작동
|
||||
*/
|
||||
export const registerServiceWorker = async (): Promise<ServiceWorkerRegistration | null> => {
|
||||
if (!isSecureContext()) {
|
||||
console.warn('[Push] ⚠️ HTTP 환경에서는 Service Worker가 작동하지 않습니다.');
|
||||
console.warn('[Push] HTTPS를 사용하거나 localhost에서 실행하세요.');
|
||||
console.warn('[Push] 현재는 브라우저 알림만 사용됩니다.');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
console.warn('[Push] Service Worker not supported in this browser');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register('/service-worker.js', {
|
||||
scope: '/',
|
||||
});
|
||||
|
||||
console.log('[Push] ✅ Service Worker registered:', registration);
|
||||
|
||||
// Wait for the service worker to be ready
|
||||
await navigator.serviceWorker.ready;
|
||||
console.log('[Push] ✅ Service Worker ready');
|
||||
|
||||
return registration;
|
||||
} catch (error) {
|
||||
console.error('[Push] ❌ Service Worker registration failed:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 알림 권한 요청
|
||||
*/
|
||||
export const requestNotificationPermission = async (): Promise<NotificationPermission> => {
|
||||
if (!('Notification' in window)) {
|
||||
console.warn('Notifications not supported in this browser');
|
||||
return 'denied';
|
||||
}
|
||||
|
||||
if (Notification.permission === 'granted') {
|
||||
return 'granted';
|
||||
}
|
||||
|
||||
if (Notification.permission === 'denied') {
|
||||
return 'denied';
|
||||
}
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
console.log('[Push] Notification permission:', permission);
|
||||
return permission;
|
||||
};
|
||||
|
||||
/**
|
||||
* Base64를 Uint8Array로 변환 (VAPID 키 변환용)
|
||||
*/
|
||||
const urlBase64ToUint8Array = (base64String: string): Uint8Array => {
|
||||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
};
|
||||
|
||||
/**
|
||||
* Push 구독 생성
|
||||
*/
|
||||
export const subscribeToPush = async (): Promise<PushSubscription | null> => {
|
||||
try {
|
||||
// Service Worker 등록 확인
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
|
||||
// 기존 구독 확인
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (subscription) {
|
||||
console.log('[Push] Already subscribed:', subscription);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
// 알림 권한 요청
|
||||
const permission = await requestNotificationPermission();
|
||||
if (permission !== 'granted') {
|
||||
console.warn('[Push] Notification permission not granted');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 새 구독 생성
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
|
||||
});
|
||||
|
||||
console.log('[Push] New subscription created:', subscription);
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
console.error('[Push] Failed to subscribe:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Push 구독 해제
|
||||
*/
|
||||
export const unsubscribeFromPush = async (): Promise<boolean> => {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (!subscription) {
|
||||
console.log('[Push] No subscription to unsubscribe');
|
||||
return true;
|
||||
}
|
||||
|
||||
const result = await subscription.unsubscribe();
|
||||
console.log('[Push] Unsubscribed:', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[Push] Failed to unsubscribe:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 현재 구독 상태 확인
|
||||
*/
|
||||
export const getSubscription = async (): Promise<PushSubscription | null> => {
|
||||
try {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
console.error('[Push] Failed to get subscription:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 구독 정보를 서버로 전송
|
||||
*/
|
||||
export const sendSubscriptionToServer = async (
|
||||
subscription: PushSubscription,
|
||||
endpoint: string = '/api/user/settings/push-subscription'
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (!token) {
|
||||
console.warn('[Push] No access token found');
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
subscription: subscription.toJSON(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to send subscription: ${response.statusText}`);
|
||||
}
|
||||
|
||||
console.log('[Push] Subscription sent to server');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Push] Failed to send subscription to server:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 구독 삭제를 서버에 알림
|
||||
*/
|
||||
export const deleteSubscriptionFromServer = async (
|
||||
endpoint: string = '/api/user/settings/push-subscription'
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (!token) {
|
||||
console.warn('[Push] No access token found');
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete subscription: ${response.statusText}`);
|
||||
}
|
||||
|
||||
console.log('[Push] Subscription deleted from server');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Push] Failed to delete subscription from server:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* PWA 초기화 (Service Worker 등록 + Push 구독)
|
||||
*/
|
||||
export const initializePWA = async (): Promise<void> => {
|
||||
console.log('[PWA] Initializing...');
|
||||
|
||||
// Service Worker 등록
|
||||
const registration = await registerServiceWorker();
|
||||
if (!registration) {
|
||||
console.warn('[PWA] Service Worker registration failed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Push 구독은 사용자가 설정에서 수동으로 활성화
|
||||
console.log('[PWA] Initialized. Push subscription available in settings.');
|
||||
};
|
||||
|
||||
/**
|
||||
* 테스트 알림 표시
|
||||
*/
|
||||
export const showTestNotification = async (): Promise<void> => {
|
||||
const permission = await requestNotificationPermission();
|
||||
|
||||
if (permission !== 'granted') {
|
||||
alert('알림 권한이 거부되었습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
|
||||
await registration.showNotification('테스트 알림', {
|
||||
body: '푸시 알림이 정상적으로 작동합니다! 🎉',
|
||||
icon: '/favicon.ico',
|
||||
badge: '/favicon.ico',
|
||||
vibrate: [200, 100, 200],
|
||||
tag: 'test-notification',
|
||||
requireInteraction: false,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 전략 규칙(StrategyRule)의 보조지표 조건 → 차트 보조지표 설정(indicatorSettings) 매핑
|
||||
* ChartItem INDICATOR_MENU_ITEMS 키와 대응
|
||||
*/
|
||||
import type { StrategyRuleDto, StrategyConditionDto, ConditionGroupDto } from '../services/strategyRuleApi';
|
||||
|
||||
/** 전략 규칙 IndicatorType → 차트 indicatorSettings 키 */
|
||||
const INDICATOR_TYPE_TO_CHART_KEY: Record<string, string> = {
|
||||
VOLUME: 'volume',
|
||||
MACD: 'macd',
|
||||
MACD_SIGNAL: 'macd',
|
||||
MACD_HISTOGRAM: 'macd',
|
||||
RSI: 'rsi',
|
||||
STOCHASTIC: 'stochastic',
|
||||
STOCHASTIC_K: 'stochastic',
|
||||
STOCHASTIC_D: 'stochastic',
|
||||
ADX: 'adx',
|
||||
CCI: 'cci',
|
||||
OBV: 'obv',
|
||||
NEW_PSYCHOLOGICAL: 'newPsychological',
|
||||
INVEST_PSYCHOLOGICAL: 'investPsychological',
|
||||
BWI: 'bwi',
|
||||
WILLIAMS_R: 'williamsR',
|
||||
TRIX: 'trix',
|
||||
TRIX_SIGNAL: 'trix',
|
||||
VOLUME_OSC: 'volumeOsc',
|
||||
VR: 'vr',
|
||||
DISPARITY: 'disparity',
|
||||
DMI: 'dmi',
|
||||
PLUS_DI: 'dmi',
|
||||
MINUS_DI: 'dmi',
|
||||
};
|
||||
|
||||
/**
|
||||
* 전략 규칙에서 보조지표 관련 조건의 indicatorType 추출
|
||||
*/
|
||||
function collectIndicatorTypesFromConditions(conditions: StrategyConditionDto[]): Set<string> {
|
||||
const types = new Set<string>();
|
||||
for (const c of conditions) {
|
||||
if (c.indicatorType && c.enabled !== false) {
|
||||
const chartKey = INDICATOR_TYPE_TO_CHART_KEY[c.indicatorType];
|
||||
if (chartKey) types.add(chartKey);
|
||||
}
|
||||
}
|
||||
return types;
|
||||
}
|
||||
|
||||
function collectFromGroup(group: ConditionGroupDto | undefined): Set<string> {
|
||||
const types = new Set<string>();
|
||||
if (!group) return types;
|
||||
const fromConditions = collectIndicatorTypesFromConditions(group.conditions || []);
|
||||
fromConditions.forEach((t) => types.add(t));
|
||||
(group.childGroups || []).forEach((cg) => {
|
||||
collectFromGroup(cg).forEach((t) => types.add(t));
|
||||
});
|
||||
return types;
|
||||
}
|
||||
|
||||
/**
|
||||
* 전략 규칙 DTO에서 사용된 보조지표 키 목록 추출
|
||||
*/
|
||||
export function extractIndicatorKeysFromStrategyRule(rule: StrategyRuleDto): string[] {
|
||||
const keys = new Set<string>();
|
||||
(rule.buyConditionGroups || []).forEach((g) => collectFromGroup(g).forEach((k) => keys.add(k)));
|
||||
(rule.sellConditionGroups || []).forEach((g) => collectFromGroup(g).forEach((k) => keys.add(k)));
|
||||
return Array.from(keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* indicatorKeys → ChartItem indicatorSettings 형식 (Record<string, boolean>)
|
||||
*/
|
||||
export function indicatorKeysToSettings(keys: string[]): Record<string, boolean> {
|
||||
const settings: Record<string, boolean> = {};
|
||||
for (const k of keys) {
|
||||
settings[k] = true;
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
/**
|
||||
* 정배열/역배열 + Stochastic Slow 기반 매매 전략
|
||||
*
|
||||
* [정배열/역배열 조건]
|
||||
* - 정배열: 추세 강도 > 0
|
||||
* - 역배열: 추세 강도 < 0
|
||||
*
|
||||
* [매매 조건]
|
||||
* 1) 정배열인 경우:
|
||||
* - Stochastic Slow 20 상향 돌파 → 매수
|
||||
* - 50 상향 돌파 → 매수
|
||||
* - 80 상향 돌파 → 매수
|
||||
* - 20 하향 돌파 → 매도
|
||||
* - 50 하향 돌파 → 매도
|
||||
* - 80 하향 돌파 → 매도
|
||||
*
|
||||
* 2) 역배열인 경우:
|
||||
* - 80 상향 돌파 → 매수
|
||||
* - 80 하향 돌파 → 매도
|
||||
*/
|
||||
|
||||
// OHLCV + 지표 데이터 인터페이스
|
||||
export interface CandleWithIndicators {
|
||||
date: string;
|
||||
timestamp?: string;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
stochasticK: number | null; // Stochastic Slow %K
|
||||
stochasticD: number | null; // Stochastic Slow %D
|
||||
macdLine?: number | null;
|
||||
signalLine?: number | null;
|
||||
macdHistogram?: number | null;
|
||||
trendStrength?: number; // 추세 강도 (-100 ~ +100)
|
||||
}
|
||||
|
||||
// 매매 신호 타입
|
||||
export type SignalType = 'BUY' | 'SELL' | 'HOLD';
|
||||
|
||||
// 매매 신호 결과
|
||||
export interface TradingSignal {
|
||||
date: string;
|
||||
signal: SignalType;
|
||||
reason: string;
|
||||
stochasticK: number;
|
||||
stochasticD: number;
|
||||
trendStrength: number;
|
||||
isBullish: boolean; // 정배열 여부
|
||||
crossLevel?: number; // 돌파 레벨 (20, 50, 80)
|
||||
crossDirection?: 'UP' | 'DOWN'; // 돌파 방향
|
||||
}
|
||||
|
||||
// 돌파 레벨 정의
|
||||
const OVERSOLD_LEVEL = 20;
|
||||
const NEUTRAL_LEVEL = 50;
|
||||
const OVERBOUGHT_LEVEL = 80;
|
||||
|
||||
/**
|
||||
* 상향 돌파 여부 확인
|
||||
* @param prevValue 이전 값
|
||||
* @param currValue 현재 값
|
||||
* @param level 돌파 레벨
|
||||
* @returns 상향 돌파 여부
|
||||
*/
|
||||
export function isCrossUp(prevValue: number, currValue: number, level: number): boolean {
|
||||
return prevValue < level && currValue >= level;
|
||||
}
|
||||
|
||||
/**
|
||||
* 하향 돌파 여부 확인
|
||||
* @param prevValue 이전 값
|
||||
* @param currValue 현재 값
|
||||
* @param level 돌파 레벨
|
||||
* @returns 하향 돌파 여부
|
||||
*/
|
||||
export function isCrossDown(prevValue: number, currValue: number, level: number): boolean {
|
||||
return prevValue > level && currValue <= level;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정배열 여부 확인
|
||||
* @param trendStrength 추세 강도
|
||||
* @returns 정배열 여부 (추세 강도 > 0)
|
||||
*/
|
||||
export function isBullishTrend(trendStrength: number): boolean {
|
||||
return trendStrength > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 역배열 여부 확인
|
||||
* @param trendStrength 추세 강도
|
||||
* @returns 역배열 여부 (추세 강도 < 0)
|
||||
*/
|
||||
export function isBearishTrend(trendStrength: number): boolean {
|
||||
return trendStrength < 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 캔들에 대한 매매 신호 생성
|
||||
* @param current 현재 캔들 데이터
|
||||
* @param previous 이전 캔들 데이터
|
||||
* @param trendStrength 추세 강도
|
||||
* @returns 매매 신호 또는 null
|
||||
*/
|
||||
export function generateSignalForCandle(
|
||||
current: CandleWithIndicators,
|
||||
previous: CandleWithIndicators,
|
||||
trendStrength: number
|
||||
): TradingSignal | null {
|
||||
// Stochastic 값이 없으면 신호 없음
|
||||
if (current.stochasticK === null || previous.stochasticK === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currK = current.stochasticK;
|
||||
const prevK = previous.stochasticK;
|
||||
const isBullish = isBullishTrend(trendStrength);
|
||||
|
||||
// 정배열인 경우
|
||||
if (isBullish) {
|
||||
// 20 상향 돌파 → 매수
|
||||
if (isCrossUp(prevK, currK, OVERSOLD_LEVEL)) {
|
||||
return {
|
||||
date: current.date,
|
||||
signal: 'BUY',
|
||||
reason: '정배열 + Stochastic 20 상향 돌파 (과매도 탈출)',
|
||||
stochasticK: currK,
|
||||
stochasticD: current.stochasticD ?? 0,
|
||||
trendStrength,
|
||||
isBullish: true,
|
||||
crossLevel: OVERSOLD_LEVEL,
|
||||
crossDirection: 'UP',
|
||||
};
|
||||
}
|
||||
|
||||
// 50 상향 돌파 → 매수
|
||||
if (isCrossUp(prevK, currK, NEUTRAL_LEVEL)) {
|
||||
return {
|
||||
date: current.date,
|
||||
signal: 'BUY',
|
||||
reason: '정배열 + Stochastic 50 상향 돌파 (중립선 상향)',
|
||||
stochasticK: currK,
|
||||
stochasticD: current.stochasticD ?? 0,
|
||||
trendStrength,
|
||||
isBullish: true,
|
||||
crossLevel: NEUTRAL_LEVEL,
|
||||
crossDirection: 'UP',
|
||||
};
|
||||
}
|
||||
|
||||
// 80 상향 돌파 → 매수
|
||||
if (isCrossUp(prevK, currK, OVERBOUGHT_LEVEL)) {
|
||||
return {
|
||||
date: current.date,
|
||||
signal: 'BUY',
|
||||
reason: '정배열 + Stochastic 80 상향 돌파 (강한 상승 모멘텀)',
|
||||
stochasticK: currK,
|
||||
stochasticD: current.stochasticD ?? 0,
|
||||
trendStrength,
|
||||
isBullish: true,
|
||||
crossLevel: OVERBOUGHT_LEVEL,
|
||||
crossDirection: 'UP',
|
||||
};
|
||||
}
|
||||
|
||||
// 20 하향 돌파 → 매도
|
||||
if (isCrossDown(prevK, currK, OVERSOLD_LEVEL)) {
|
||||
return {
|
||||
date: current.date,
|
||||
signal: 'SELL',
|
||||
reason: '정배열 + Stochastic 20 하향 돌파 (과매도 진입)',
|
||||
stochasticK: currK,
|
||||
stochasticD: current.stochasticD ?? 0,
|
||||
trendStrength,
|
||||
isBullish: true,
|
||||
crossLevel: OVERSOLD_LEVEL,
|
||||
crossDirection: 'DOWN',
|
||||
};
|
||||
}
|
||||
|
||||
// 50 하향 돌파 → 매도
|
||||
if (isCrossDown(prevK, currK, NEUTRAL_LEVEL)) {
|
||||
return {
|
||||
date: current.date,
|
||||
signal: 'SELL',
|
||||
reason: '정배열 + Stochastic 50 하향 돌파 (중립선 하향)',
|
||||
stochasticK: currK,
|
||||
stochasticD: current.stochasticD ?? 0,
|
||||
trendStrength,
|
||||
isBullish: true,
|
||||
crossLevel: NEUTRAL_LEVEL,
|
||||
crossDirection: 'DOWN',
|
||||
};
|
||||
}
|
||||
|
||||
// 80 하향 돌파 → 매도
|
||||
if (isCrossDown(prevK, currK, OVERBOUGHT_LEVEL)) {
|
||||
return {
|
||||
date: current.date,
|
||||
signal: 'SELL',
|
||||
reason: '정배열 + Stochastic 80 하향 돌파 (과매수 이탈)',
|
||||
stochasticK: currK,
|
||||
stochasticD: current.stochasticD ?? 0,
|
||||
trendStrength,
|
||||
isBullish: true,
|
||||
crossLevel: OVERBOUGHT_LEVEL,
|
||||
crossDirection: 'DOWN',
|
||||
};
|
||||
}
|
||||
}
|
||||
// 역배열인 경우
|
||||
else if (isBearishTrend(trendStrength)) {
|
||||
// 80 상향 돌파 → 매수
|
||||
if (isCrossUp(prevK, currK, OVERBOUGHT_LEVEL)) {
|
||||
return {
|
||||
date: current.date,
|
||||
signal: 'BUY',
|
||||
reason: '역배열 + Stochastic 80 상향 돌파 (반등 시그널)',
|
||||
stochasticK: currK,
|
||||
stochasticD: current.stochasticD ?? 0,
|
||||
trendStrength,
|
||||
isBullish: false,
|
||||
crossLevel: OVERBOUGHT_LEVEL,
|
||||
crossDirection: 'UP',
|
||||
};
|
||||
}
|
||||
|
||||
// 80 하향 돌파 → 매도
|
||||
if (isCrossDown(prevK, currK, OVERBOUGHT_LEVEL)) {
|
||||
return {
|
||||
date: current.date,
|
||||
signal: 'SELL',
|
||||
reason: '역배열 + Stochastic 80 하향 돌파 (하락 지속)',
|
||||
stochasticK: currK,
|
||||
stochasticD: current.stochasticD ?? 0,
|
||||
trendStrength,
|
||||
isBullish: false,
|
||||
crossLevel: OVERBOUGHT_LEVEL,
|
||||
crossDirection: 'DOWN',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 신호 없음
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 캔들 데이터에 대한 매매 신호 생성
|
||||
* @param candles 캔들 데이터 배열 (시간순 정렬)
|
||||
* @param trendStrength 추세 강도 (현재 종목의 추세)
|
||||
* @returns 매매 신호 배열
|
||||
*/
|
||||
export function generateSignals(
|
||||
candles: CandleWithIndicators[],
|
||||
trendStrength: number
|
||||
): TradingSignal[] {
|
||||
const signals: TradingSignal[] = [];
|
||||
|
||||
if (candles.length < 2) {
|
||||
return signals;
|
||||
}
|
||||
|
||||
for (let i = 1; i < candles.length; i++) {
|
||||
const signal = generateSignalForCandle(candles[i], candles[i - 1], trendStrength);
|
||||
if (signal) {
|
||||
signals.push(signal);
|
||||
}
|
||||
}
|
||||
|
||||
return signals;
|
||||
}
|
||||
|
||||
/**
|
||||
* 가장 최근 매매 신호 조회
|
||||
* @param candles 캔들 데이터 배열 (시간순 정렬)
|
||||
* @param trendStrength 추세 강도
|
||||
* @param lookbackPeriod 조회할 기간 (캔들 수)
|
||||
* @returns 가장 최근 매매 신호 또는 null
|
||||
*/
|
||||
export function getLatestSignal(
|
||||
candles: CandleWithIndicators[],
|
||||
trendStrength: number,
|
||||
lookbackPeriod: number = 10
|
||||
): TradingSignal | null {
|
||||
if (candles.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startIdx = Math.max(1, candles.length - lookbackPeriod);
|
||||
|
||||
for (let i = candles.length - 1; i >= startIdx; i--) {
|
||||
const signal = generateSignalForCandle(candles[i], candles[i - 1], trendStrength);
|
||||
if (signal) {
|
||||
return signal;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 신호 요약 정보 생성
|
||||
* @param signals 매매 신호 배열
|
||||
* @returns 요약 정보
|
||||
*/
|
||||
export function getSignalSummary(signals: TradingSignal[]): {
|
||||
totalSignals: number;
|
||||
buySignals: number;
|
||||
sellSignals: number;
|
||||
lastSignal: TradingSignal | null;
|
||||
bullishSignals: number;
|
||||
bearishSignals: number;
|
||||
} {
|
||||
const buySignals = signals.filter(s => s.signal === 'BUY');
|
||||
const sellSignals = signals.filter(s => s.signal === 'SELL');
|
||||
const bullishSignals = signals.filter(s => s.isBullish);
|
||||
const bearishSignals = signals.filter(s => !s.isBullish);
|
||||
|
||||
return {
|
||||
totalSignals: signals.length,
|
||||
buySignals: buySignals.length,
|
||||
sellSignals: sellSignals.length,
|
||||
lastSignal: signals.length > 0 ? signals[signals.length - 1] : null,
|
||||
bullishSignals: bullishSignals.length,
|
||||
bearishSignals: bearishSignals.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 전략 설정 인터페이스
|
||||
*/
|
||||
export interface TrendStochasticStrategyConfig {
|
||||
oversoldLevel: number; // 과매도 레벨 (기본: 20)
|
||||
neutralLevel: number; // 중립 레벨 (기본: 50)
|
||||
overboughtLevel: number; // 과매수 레벨 (기본: 80)
|
||||
enableBullishAllLevels: boolean; // 정배열 시 모든 레벨 활성화 (기본: true)
|
||||
enableBearishOnlyOverbought: boolean; // 역배열 시 80 레벨만 활성화 (기본: true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 전략 설정
|
||||
*/
|
||||
export const DEFAULT_STRATEGY_CONFIG: TrendStochasticStrategyConfig = {
|
||||
oversoldLevel: 20,
|
||||
neutralLevel: 50,
|
||||
overboughtLevel: 80,
|
||||
enableBullishAllLevels: true,
|
||||
enableBearishOnlyOverbought: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* 설정 가능한 전략 신호 생성
|
||||
* @param current 현재 캔들 데이터
|
||||
* @param previous 이전 캔들 데이터
|
||||
* @param trendStrength 추세 강도
|
||||
* @param config 전략 설정
|
||||
* @returns 매매 신호 또는 null
|
||||
*/
|
||||
export function generateSignalWithConfig(
|
||||
current: CandleWithIndicators,
|
||||
previous: CandleWithIndicators,
|
||||
trendStrength: number,
|
||||
config: TrendStochasticStrategyConfig = DEFAULT_STRATEGY_CONFIG
|
||||
): TradingSignal | null {
|
||||
if (current.stochasticK === null || previous.stochasticK === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currK = current.stochasticK;
|
||||
const prevK = previous.stochasticK;
|
||||
const isBullish = isBullishTrend(trendStrength);
|
||||
|
||||
// 정배열인 경우
|
||||
if (isBullish && config.enableBullishAllLevels) {
|
||||
// 과매도 레벨 상향 돌파 → 매수
|
||||
if (isCrossUp(prevK, currK, config.oversoldLevel)) {
|
||||
return {
|
||||
date: current.date,
|
||||
signal: 'BUY',
|
||||
reason: `정배열 + Stochastic ${config.oversoldLevel} 상향 돌파`,
|
||||
stochasticK: currK,
|
||||
stochasticD: current.stochasticD ?? 0,
|
||||
trendStrength,
|
||||
isBullish: true,
|
||||
crossLevel: config.oversoldLevel,
|
||||
crossDirection: 'UP',
|
||||
};
|
||||
}
|
||||
|
||||
// 중립 레벨 상향 돌파 → 매수
|
||||
if (isCrossUp(prevK, currK, config.neutralLevel)) {
|
||||
return {
|
||||
date: current.date,
|
||||
signal: 'BUY',
|
||||
reason: `정배열 + Stochastic ${config.neutralLevel} 상향 돌파`,
|
||||
stochasticK: currK,
|
||||
stochasticD: current.stochasticD ?? 0,
|
||||
trendStrength,
|
||||
isBullish: true,
|
||||
crossLevel: config.neutralLevel,
|
||||
crossDirection: 'UP',
|
||||
};
|
||||
}
|
||||
|
||||
// 과매수 레벨 상향 돌파 → 매수
|
||||
if (isCrossUp(prevK, currK, config.overboughtLevel)) {
|
||||
return {
|
||||
date: current.date,
|
||||
signal: 'BUY',
|
||||
reason: `정배열 + Stochastic ${config.overboughtLevel} 상향 돌파`,
|
||||
stochasticK: currK,
|
||||
stochasticD: current.stochasticD ?? 0,
|
||||
trendStrength,
|
||||
isBullish: true,
|
||||
crossLevel: config.overboughtLevel,
|
||||
crossDirection: 'UP',
|
||||
};
|
||||
}
|
||||
|
||||
// 과매도 레벨 하향 돌파 → 매도
|
||||
if (isCrossDown(prevK, currK, config.oversoldLevel)) {
|
||||
return {
|
||||
date: current.date,
|
||||
signal: 'SELL',
|
||||
reason: `정배열 + Stochastic ${config.oversoldLevel} 하향 돌파`,
|
||||
stochasticK: currK,
|
||||
stochasticD: current.stochasticD ?? 0,
|
||||
trendStrength,
|
||||
isBullish: true,
|
||||
crossLevel: config.oversoldLevel,
|
||||
crossDirection: 'DOWN',
|
||||
};
|
||||
}
|
||||
|
||||
// 중립 레벨 하향 돌파 → 매도
|
||||
if (isCrossDown(prevK, currK, config.neutralLevel)) {
|
||||
return {
|
||||
date: current.date,
|
||||
signal: 'SELL',
|
||||
reason: `정배열 + Stochastic ${config.neutralLevel} 하향 돌파`,
|
||||
stochasticK: currK,
|
||||
stochasticD: current.stochasticD ?? 0,
|
||||
trendStrength,
|
||||
isBullish: true,
|
||||
crossLevel: config.neutralLevel,
|
||||
crossDirection: 'DOWN',
|
||||
};
|
||||
}
|
||||
|
||||
// 과매수 레벨 하향 돌파 → 매도
|
||||
if (isCrossDown(prevK, currK, config.overboughtLevel)) {
|
||||
return {
|
||||
date: current.date,
|
||||
signal: 'SELL',
|
||||
reason: `정배열 + Stochastic ${config.overboughtLevel} 하향 돌파`,
|
||||
stochasticK: currK,
|
||||
stochasticD: current.stochasticD ?? 0,
|
||||
trendStrength,
|
||||
isBullish: true,
|
||||
crossLevel: config.overboughtLevel,
|
||||
crossDirection: 'DOWN',
|
||||
};
|
||||
}
|
||||
}
|
||||
// 역배열인 경우
|
||||
else if (isBearishTrend(trendStrength) && config.enableBearishOnlyOverbought) {
|
||||
// 과매수 레벨 상향 돌파 → 매수
|
||||
if (isCrossUp(prevK, currK, config.overboughtLevel)) {
|
||||
return {
|
||||
date: current.date,
|
||||
signal: 'BUY',
|
||||
reason: `역배열 + Stochastic ${config.overboughtLevel} 상향 돌파`,
|
||||
stochasticK: currK,
|
||||
stochasticD: current.stochasticD ?? 0,
|
||||
trendStrength,
|
||||
isBullish: false,
|
||||
crossLevel: config.overboughtLevel,
|
||||
crossDirection: 'UP',
|
||||
};
|
||||
}
|
||||
|
||||
// 과매수 레벨 하향 돌파 → 매도
|
||||
if (isCrossDown(prevK, currK, config.overboughtLevel)) {
|
||||
return {
|
||||
date: current.date,
|
||||
signal: 'SELL',
|
||||
reason: `역배열 + Stochastic ${config.overboughtLevel} 하향 돌파`,
|
||||
stochasticK: currK,
|
||||
stochasticD: current.stochasticD ?? 0,
|
||||
trendStrength,
|
||||
isBullish: false,
|
||||
crossLevel: config.overboughtLevel,
|
||||
crossDirection: 'DOWN',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 사용 예시
|
||||
/*
|
||||
import {
|
||||
generateSignals,
|
||||
getLatestSignal,
|
||||
getSignalSummary,
|
||||
CandleWithIndicators
|
||||
} from './trendStochasticStrategy';
|
||||
|
||||
// 캔들 데이터 예시
|
||||
const candles: CandleWithIndicators[] = [
|
||||
{ date: '2024-01-01', open: 100, high: 105, low: 98, close: 103, volume: 1000, stochasticK: 18, stochasticD: 15 },
|
||||
{ date: '2024-01-02', open: 103, high: 110, low: 101, close: 108, volume: 1200, stochasticK: 22, stochasticD: 19 },
|
||||
// ...
|
||||
];
|
||||
|
||||
// 추세 강도 (양수: 정배열, 음수: 역배열)
|
||||
const trendStrength = 45.5; // 정배열
|
||||
|
||||
// 전체 신호 생성
|
||||
const signals = generateSignals(candles, trendStrength);
|
||||
console.log('전체 신호:', signals);
|
||||
|
||||
// 최근 신호 조회
|
||||
const latestSignal = getLatestSignal(candles, trendStrength);
|
||||
console.log('최근 신호:', latestSignal);
|
||||
|
||||
// 신호 요약
|
||||
const summary = getSignalSummary(signals);
|
||||
console.log('요약:', summary);
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* 추세 강도(정배열/역배열) 계산 유틸리티
|
||||
*
|
||||
* 정배열: 단기 이동평균 > 중기 이동평균 > 장기 이동평균 (상승 추세)
|
||||
* 역배열: 단기 이동평균 < 중기 이동평균 < 장기 이동평균 (하락 추세)
|
||||
*
|
||||
* 공식:
|
||||
* S = ((MA10 - MA60) + (MA20 - MA60)) / ATR(14)
|
||||
* TrendStrength = tanh(S) * 100
|
||||
*
|
||||
* 결과: -100 ~ +100
|
||||
* - +100에 가까움: 강한 정배열(상승추세)
|
||||
* - -100에 가까움: 강한 역배열(하락추세)
|
||||
* - 0 근처: 추세 약함 또는 혼조
|
||||
*/
|
||||
|
||||
// OHLC 캔들 데이터 인터페이스
|
||||
export interface OHLCData {
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume?: number;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
// 추세 강도 결과 인터페이스
|
||||
export interface TrendStrengthResult {
|
||||
symbol: string;
|
||||
koreanName: string;
|
||||
englishName?: string;
|
||||
trendStrength: number; // -100 ~ +100
|
||||
ma10: number;
|
||||
ma20: number;
|
||||
ma60: number;
|
||||
atr14: number;
|
||||
isFavorite?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단순 이동평균(SMA) 계산
|
||||
* @param data 종가 배열
|
||||
* @param period 이동평균 기간
|
||||
* @returns 이동평균 값 (데이터가 부족하면 null)
|
||||
*/
|
||||
export function calculateSMA(data: number[], period: number): number | null {
|
||||
if (data.length < period) return null;
|
||||
|
||||
const recentData = data.slice(-period);
|
||||
const sum = recentData.reduce((acc, val) => acc + val, 0);
|
||||
return sum / period;
|
||||
}
|
||||
|
||||
/**
|
||||
* ATR(Average True Range) 계산
|
||||
* @param ohlcData OHLC 데이터 배열
|
||||
* @param period ATR 기간 (기본: 14)
|
||||
* @returns ATR 값 (데이터가 부족하면 null)
|
||||
*/
|
||||
export function calculateATR(ohlcData: OHLCData[], period: number = 14): number | null {
|
||||
if (ohlcData.length < period + 1) return null;
|
||||
|
||||
const trueRanges: number[] = [];
|
||||
|
||||
// True Range 계산 (최근 period+1개 데이터 사용)
|
||||
const recentData = ohlcData.slice(-(period + 1));
|
||||
|
||||
for (let i = 1; i < recentData.length; i++) {
|
||||
const current = recentData[i];
|
||||
const previous = recentData[i - 1];
|
||||
|
||||
// True Range = max(High - Low, |High - Previous Close|, |Low - Previous Close|)
|
||||
const tr = Math.max(
|
||||
current.high - current.low,
|
||||
Math.abs(current.high - previous.close),
|
||||
Math.abs(current.low - previous.close)
|
||||
);
|
||||
trueRanges.push(tr);
|
||||
}
|
||||
|
||||
// ATR = SMA of True Ranges
|
||||
const atr = trueRanges.reduce((acc, val) => acc + val, 0) / trueRanges.length;
|
||||
return atr;
|
||||
}
|
||||
|
||||
/**
|
||||
* 추세 강도 계산
|
||||
*
|
||||
* 공식:
|
||||
* S = ((MA10 - MA60) + (MA20 - MA60)) / ATR(14)
|
||||
* TrendStrength = tanh(S) * 100
|
||||
*
|
||||
* @param ohlcData OHLC 데이터 배열 (최소 60개 이상 필요)
|
||||
* @returns 추세 강도 (-100 ~ +100) 또는 null
|
||||
*/
|
||||
export function calculateTrendStrength(ohlcData: OHLCData[]): {
|
||||
trendStrength: number;
|
||||
ma10: number;
|
||||
ma20: number;
|
||||
ma60: number;
|
||||
atr14: number;
|
||||
} | null {
|
||||
// 최소 60개 데이터 필요
|
||||
if (ohlcData.length < 60) {
|
||||
console.warn('추세 강도 계산에 최소 60개 데이터 필요');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 종가 배열 추출
|
||||
const closePrices = ohlcData.map(d => d.close);
|
||||
|
||||
// 이동평균 계산
|
||||
const ma10 = calculateSMA(closePrices, 10);
|
||||
const ma20 = calculateSMA(closePrices, 20);
|
||||
const ma60 = calculateSMA(closePrices, 60);
|
||||
|
||||
// ATR 계산
|
||||
const atr14 = calculateATR(ohlcData, 14);
|
||||
|
||||
// 유효성 검사
|
||||
if (ma10 === null || ma20 === null || ma60 === null || atr14 === null || atr14 === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// S 계산: ((MA10 - MA60) + (MA20 - MA60)) / ATR(14)
|
||||
const s = ((ma10 - ma60) + (ma20 - ma60)) / atr14;
|
||||
|
||||
// TrendStrength = tanh(S) * 100
|
||||
const trendStrength = Math.tanh(s) * 100;
|
||||
|
||||
return {
|
||||
trendStrength: Math.round(trendStrength * 100) / 100, // 소수점 2자리
|
||||
ma10: Math.round(ma10 * 100) / 100,
|
||||
ma20: Math.round(ma20 * 100) / 100,
|
||||
ma60: Math.round(ma60 * 100) / 100,
|
||||
atr14: Math.round(atr14 * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 추세 강도에 따른 라벨 반환
|
||||
* @param strength 추세 강도 값
|
||||
* @returns 라벨 문자열
|
||||
*/
|
||||
export function getTrendLabel(strength: number): string {
|
||||
if (strength >= 80) return '매우 강한 정배열';
|
||||
if (strength >= 50) return '강한 정배열';
|
||||
if (strength >= 20) return '정배열';
|
||||
if (strength >= -20) return '중립';
|
||||
if (strength >= -50) return '역배열';
|
||||
if (strength >= -80) return '강한 역배열';
|
||||
return '매우 강한 역배열';
|
||||
}
|
||||
|
||||
/**
|
||||
* 정배열 강도 계산 (0 ~ 100)
|
||||
* trendStrength가 양수일 때만 값이 있음
|
||||
* @param trendStrength 통합 추세 강도 (-100 ~ +100)
|
||||
* @returns 정배열 강도 (0 ~ 100)
|
||||
*/
|
||||
export function getBullishStrength(trendStrength: number): number {
|
||||
return Math.max(0, trendStrength);
|
||||
}
|
||||
|
||||
/**
|
||||
* 역배열 강도 계산 (0 ~ 100)
|
||||
* trendStrength가 음수일 때만 값이 있음
|
||||
* @param trendStrength 통합 추세 강도 (-100 ~ +100)
|
||||
* @returns 역배열 강도 (0 ~ 100)
|
||||
*/
|
||||
export function getBearishStrength(trendStrength: number): number {
|
||||
return Math.max(0, -trendStrength);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정배열/역배열 강도 분리 반환
|
||||
* @param trendStrength 통합 추세 강도 (-100 ~ +100)
|
||||
* @returns { bullish: 정배열 강도, bearish: 역배열 강도 }
|
||||
*/
|
||||
export function getStrengthValues(trendStrength: number): { bullish: number; bearish: number } {
|
||||
return {
|
||||
bullish: getBullishStrength(trendStrength),
|
||||
bearish: getBearishStrength(trendStrength),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 추세 강도에 따른 색상 반환
|
||||
* @param strength 추세 강도 값
|
||||
* @returns 색상 코드
|
||||
*/
|
||||
export function getTrendColor(strength: number): string {
|
||||
if (strength >= 50) return '#c62828'; // 강한 상승 - 빨간색
|
||||
if (strength >= 20) return '#ef5350'; // 상승 - 연한 빨간색
|
||||
if (strength >= -20) return '#9e9e9e'; // 중립 - 회색
|
||||
if (strength >= -50) return '#42a5f5'; // 하락 - 연한 파란색
|
||||
return '#1565c0'; // 강한 하락 - 파란색
|
||||
}
|
||||
|
||||
/**
|
||||
* 추세 강도 기준 정렬 함수
|
||||
* @param items 정렬할 항목 배열
|
||||
* @param sortMode 정렬 모드 ('default' | 'bullish' | 'bearish')
|
||||
* @returns 정렬된 배열
|
||||
*/
|
||||
export function sortByTrendStrength<T extends { trendStrength?: number; koreanName: string }>(
|
||||
items: T[],
|
||||
sortMode: 'default' | 'bullish' | 'bearish'
|
||||
): T[] {
|
||||
const sorted = [...items];
|
||||
|
||||
switch (sortMode) {
|
||||
case 'bullish':
|
||||
// 정배열: 추세 강도가 높은 순 (내림차순)
|
||||
return sorted.sort((a, b) => (b.trendStrength ?? 0) - (a.trendStrength ?? 0));
|
||||
|
||||
case 'bearish':
|
||||
// 역배열: 추세 강도가 낮은 순 (오름차순)
|
||||
return sorted.sort((a, b) => (a.trendStrength ?? 0) - (b.trendStrength ?? 0));
|
||||
|
||||
case 'default':
|
||||
default:
|
||||
// 기본: 이름순 정렬
|
||||
return sorted.sort((a, b) => a.koreanName.localeCompare(b.koreanName, 'ko'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 추세 강도 계산 (여러 종목 동시 처리)
|
||||
* @param symbolsWithData 종목별 OHLC 데이터 맵
|
||||
* @returns 추세 강도 결과 배열
|
||||
*/
|
||||
export function calculateBatchTrendStrength(
|
||||
symbolsWithData: Map<string, {
|
||||
koreanName: string;
|
||||
englishName?: string;
|
||||
ohlcData: OHLCData[];
|
||||
isFavorite?: boolean;
|
||||
}>
|
||||
): TrendStrengthResult[] {
|
||||
const results: TrendStrengthResult[] = [];
|
||||
|
||||
symbolsWithData.forEach((data, symbol) => {
|
||||
const strength = calculateTrendStrength(data.ohlcData);
|
||||
|
||||
if (strength) {
|
||||
results.push({
|
||||
symbol,
|
||||
koreanName: data.koreanName,
|
||||
englishName: data.englishName,
|
||||
trendStrength: strength.trendStrength,
|
||||
ma10: strength.ma10,
|
||||
ma20: strength.ma20,
|
||||
ma60: strength.ma60,
|
||||
atr14: strength.atr14,
|
||||
isFavorite: data.isFavorite,
|
||||
});
|
||||
} else {
|
||||
// 데이터 부족 시 기본값
|
||||
results.push({
|
||||
symbol,
|
||||
koreanName: data.koreanName,
|
||||
englishName: data.englishName,
|
||||
trendStrength: 0,
|
||||
ma10: 0,
|
||||
ma20: 0,
|
||||
ma60: 0,
|
||||
atr14: 0,
|
||||
isFavorite: data.isFavorite,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// 사용 예시
|
||||
/*
|
||||
import { calculateTrendStrength, sortByTrendStrength, getTrendLabel, getTrendColor } from './trendStrength';
|
||||
|
||||
// 단일 종목 추세 강도 계산
|
||||
const ohlcData = [
|
||||
{ open: 100, high: 105, low: 98, close: 103 },
|
||||
{ open: 103, high: 108, low: 101, close: 106 },
|
||||
// ... 최소 60개 데이터
|
||||
];
|
||||
|
||||
const result = calculateTrendStrength(ohlcData);
|
||||
if (result) {
|
||||
console.log(`추세 강도: ${result.trendStrength}`);
|
||||
console.log(`라벨: ${getTrendLabel(result.trendStrength)}`);
|
||||
console.log(`색상: ${getTrendColor(result.trendStrength)}`);
|
||||
}
|
||||
|
||||
// 여러 종목 정렬
|
||||
const items = [
|
||||
{ symbol: 'BTC', koreanName: '비트코인', trendStrength: 75 },
|
||||
{ symbol: 'ETH', koreanName: '이더리움', trendStrength: -30 },
|
||||
{ symbol: 'XRP', koreanName: '리플', trendStrength: 45 },
|
||||
];
|
||||
|
||||
// 정배열 순 정렬 (상승 추세가 강한 순)
|
||||
const bullishSorted = sortByTrendStrength(items, 'bullish');
|
||||
// 결과: BTC(75) -> XRP(45) -> ETH(-30)
|
||||
|
||||
// 역배열 순 정렬 (하락 추세가 강한 순)
|
||||
const bearishSorted = sortByTrendStrength(items, 'bearish');
|
||||
// 결과: ETH(-30) -> XRP(45) -> BTC(75)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 업비트 실시간 시세 WebSocket (브라우저 직접 연결)
|
||||
* @see https://global-docs.upbit.com/reference/websocket-candle
|
||||
*/
|
||||
|
||||
export const UPBIT_KR_WS_URL = 'wss://api.upbit.com/websocket/v1';
|
||||
|
||||
/** 투자분석 timeInterval(1m, 1h, …) → 업비트 candle 타입 */
|
||||
export function intervalToUpbitCandleType(interval: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'1m': 'candle.1m',
|
||||
'3m': 'candle.3m',
|
||||
'5m': 'candle.5m',
|
||||
'10m': 'candle.10m',
|
||||
'15m': 'candle.15m',
|
||||
'30m': 'candle.30m',
|
||||
'1h': 'candle.60m',
|
||||
'4h': 'candle.240m',
|
||||
'1d': 'candle.1d',
|
||||
'1w': 'candle.1w',
|
||||
'1M': 'candle.1mon',
|
||||
};
|
||||
return map[interval] || 'candle.1m';
|
||||
}
|
||||
|
||||
/** 업비트 candle 타입 문자열 → 앱 timeInterval (수신 메시지 라우팅용) */
|
||||
export function candleTypeToInterval(upbitType: string): string | null {
|
||||
const map: Record<string, string> = {
|
||||
'candle.1m': '1m',
|
||||
'candle.3m': '3m',
|
||||
'candle.5m': '5m',
|
||||
'candle.10m': '10m',
|
||||
'candle.15m': '15m',
|
||||
'candle.30m': '30m',
|
||||
'candle.60m': '1h',
|
||||
'candle.240m': '4h',
|
||||
'candle.1d': '1d',
|
||||
'candle.1w': '1w',
|
||||
'candle.1mon': '1M',
|
||||
};
|
||||
return map[upbitType] ?? null;
|
||||
}
|
||||
|
||||
export type UpbitCandleFrame = {
|
||||
/** KRW-BTC 등 — 멀티 마켓 구독 시 라우팅용 */
|
||||
code?: string;
|
||||
timestamp: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
};
|
||||
|
||||
/** 업비트 캔들 스트림 JSON → 차트용 (timestamp: ms) */
|
||||
export function parseUpbitCandleMessage(raw: unknown): UpbitCandleFrame | null {
|
||||
if (raw == null || typeof raw !== 'object') return null;
|
||||
const o = raw as Record<string, unknown>;
|
||||
const type = o.type;
|
||||
if (typeof type !== 'string' || !type.startsWith('candle.')) return null;
|
||||
|
||||
const code = typeof o.code === 'string' && o.code.trim() !== '' ? o.code.trim() : undefined;
|
||||
|
||||
const open = num(o.opening_price);
|
||||
const high = num(o.high_price);
|
||||
const low = num(o.low_price);
|
||||
const close = num(o.trade_price);
|
||||
const vol = num(o.candle_acc_trade_volume);
|
||||
const ts = num(o.timestamp);
|
||||
if (open == null || high == null || low == null || close == null) return null;
|
||||
|
||||
const timestamp = ts != null && ts > 0 ? (ts > 1e12 ? ts : ts * 1000) : Date.now();
|
||||
|
||||
return {
|
||||
...(code ? { code } : {}),
|
||||
timestamp,
|
||||
open,
|
||||
high,
|
||||
low,
|
||||
close,
|
||||
volume: vol ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function num(v: unknown): number | null {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
||||
if (typeof v === 'string' && v.trim() !== '') {
|
||||
const n = parseFloat(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 업비트 웹 거래소 심볼 딥링크 (차트·호가 탭이 있는 티커 화면)
|
||||
* @see https://upbit.com — code 파라미터는 CRIX.UPBIT.KRW-XXX 형식
|
||||
*
|
||||
* 시간봉: 업비트 차트 history 요청과 동일하게 `resolution` 쿼리로 전달합니다.
|
||||
* (예: 30분봉 → resolution=30, 1시간봉 → resolution=60, 4시간 → 240, 일봉 → 1D)
|
||||
*/
|
||||
|
||||
/**
|
||||
* 알림/스케줄 timeInterval → 업비트 차트 `resolution` (네트워크 payload와 동일 규칙)
|
||||
*/
|
||||
export function mapAlertTimeIntervalToTradingViewResolution(
|
||||
timeInterval: string | undefined | null
|
||||
): string | undefined {
|
||||
if (!timeInterval?.trim()) return undefined;
|
||||
const raw = timeInterval.trim();
|
||||
// 월봉: 숫자+대문자 M만 (1m 분봉과 구분)
|
||||
if (/^\d+M$/.test(raw)) {
|
||||
const monthMap: Record<string, string> = {
|
||||
'1M': '1M',
|
||||
'3M': '3M',
|
||||
'6M': '6M',
|
||||
'12M': '12M',
|
||||
};
|
||||
return monthMap[raw];
|
||||
}
|
||||
const key = raw.toLowerCase();
|
||||
const map: Record<string, string> = {
|
||||
'1m': '1',
|
||||
'3m': '3',
|
||||
'5m': '5',
|
||||
'10m': '10',
|
||||
'15m': '15',
|
||||
'30m': '30',
|
||||
'60m': '60',
|
||||
'1h': '60',
|
||||
'4h': '240',
|
||||
'240m': '240',
|
||||
'1d': '1D',
|
||||
'1w': '1W',
|
||||
};
|
||||
return map[key];
|
||||
}
|
||||
|
||||
export function getUpbitWebExchangeUrl(
|
||||
market: string | undefined | null,
|
||||
timeInterval?: string | undefined | null
|
||||
): string | null {
|
||||
if (!market?.trim()) return null;
|
||||
let m = market.trim().toUpperCase();
|
||||
if (m.startsWith('CRIX.UPBIT.')) {
|
||||
m = m.slice('CRIX.UPBIT.'.length);
|
||||
}
|
||||
if (!/^(KRW|USDT|BTC)-[A-Z0-9]+$/i.test(m)) {
|
||||
return null;
|
||||
}
|
||||
const code = `CRIX.UPBIT.${m}`;
|
||||
const base = `https://www.upbit.com/exchange?code=${encodeURIComponent(code)}`;
|
||||
const res = mapAlertTimeIntervalToTradingViewResolution(timeInterval);
|
||||
if (res) {
|
||||
return `${base}&resolution=${encodeURIComponent(res)}`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
Reference in New Issue
Block a user