goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
@@ -0,0 +1,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);
*/
+310
View File
@@ -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;
}
+64
View File
@@ -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;
}