129 lines
4.6 KiB
TypeScript
129 lines
4.6 KiB
TypeScript
import type { ChartType, Timeframe } from '../types';
|
||
import type { BacktestResultRecord } from './backendApi';
|
||
import { formatUpbitKrwPrice } from './safeFormat';
|
||
import { INDICATOR_WARMUP } from './upbitApi';
|
||
|
||
export function toUpbitMarket(symbol: string): string {
|
||
return symbol.startsWith('KRW-') ? symbol : `KRW-${symbol}`;
|
||
}
|
||
|
||
const TIMEFRAME_KO: Record<Timeframe, string> = {
|
||
'1m': '1분',
|
||
'3m': '3분',
|
||
'5m': '5분',
|
||
'10m': '10분',
|
||
'15m': '15분',
|
||
'30m': '30분',
|
||
'1h': '1시간',
|
||
'4h': '4시간',
|
||
'1D': '1일',
|
||
'1W': '1주',
|
||
'1M': '1월',
|
||
};
|
||
|
||
/** 차트·목록용 한글 라벨 */
|
||
export function formatTimeframeKo(raw: string | undefined): string {
|
||
const tf = normalizeChartTimeframe(raw);
|
||
return TIMEFRAME_KO[tf] ?? tf;
|
||
}
|
||
|
||
/** 백테스트 결과 — 실제 실행 시간봉 (스냅샷 우선, 전략 DSL과 무관) */
|
||
export function resolveBacktestRecordExecTimeframe(record: BacktestResultRecord): string {
|
||
if (record.executionSnapshotJson) {
|
||
try {
|
||
const snap = JSON.parse(record.executionSnapshotJson) as { timeframe?: string };
|
||
const fromSnap = snap?.timeframe?.trim();
|
||
if (fromSnap) return fromSnap;
|
||
} catch { /* ignore */ }
|
||
}
|
||
const fromCol = record.timeframe?.trim();
|
||
if (fromCol && fromCol !== 'unknown') return fromCol;
|
||
return '1m';
|
||
}
|
||
|
||
/** 백테스트 결과 차트 로드 봉 수 — 평가 구간 + 지표 워밍업 (차트 SMA와 백테스트 엔진 정렬) */
|
||
export function resolveBacktestChartBarCount(record: BacktestResultRecord): number {
|
||
const evalCount = record.barCount > 0 ? record.barCount : 200;
|
||
let warmup = INDICATOR_WARMUP;
|
||
if (record.executionSnapshotJson) {
|
||
try {
|
||
const snap = JSON.parse(record.executionSnapshotJson) as { warmupBarCount?: number };
|
||
if (typeof snap.warmupBarCount === 'number' && snap.warmupBarCount > 0) {
|
||
warmup = snap.warmupBarCount;
|
||
}
|
||
} catch { /* ignore */ }
|
||
}
|
||
return evalCount + warmup;
|
||
}
|
||
|
||
const CHART_TYPE_KO: Record<ChartType, string> = {
|
||
candlestick: '캔들스틱',
|
||
bar: '바',
|
||
line: '라인',
|
||
area: '영역',
|
||
baseline: '기준선',
|
||
};
|
||
|
||
export function formatChartTypeKo(type: ChartType | string | undefined): string {
|
||
if (!type) return '캔들스틱';
|
||
return CHART_TYPE_KO[type as ChartType] ?? String(type);
|
||
}
|
||
|
||
export function normalizeChartTimeframe(raw: string | undefined): Timeframe {
|
||
const t = (raw ?? '1h').trim();
|
||
const lower = t.toLowerCase();
|
||
if (lower === '1d') return '1D';
|
||
if (lower === '1w') return '1W';
|
||
if (lower === '1m' && t === '1M') return '1M';
|
||
if (['1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h'].includes(lower)) return lower as Timeframe;
|
||
if (t === '1D' || t === '1W' || t === '1M') return t as Timeframe;
|
||
return '1h';
|
||
}
|
||
|
||
/** Unix epoch → 초 단위 정규화 (유효하지 않으면 0) */
|
||
export function normalizeEpochSecOptional(t: number | undefined | null): number {
|
||
if (t == null || !Number.isFinite(t) || t <= 0) return 0;
|
||
return t > 1e12 ? Math.floor(t / 1000) : Math.floor(t);
|
||
}
|
||
|
||
/** Unix epoch → 초 단위 정규화 (유효하지 않으면 현재 시각) */
|
||
export function normalizeEpochSec(t: number | undefined | null): number {
|
||
const v = normalizeEpochSecOptional(t);
|
||
return v > 0 ? v : Math.floor(Date.now() / 1000);
|
||
}
|
||
|
||
export function fmtListTimestamp(iso: string): string {
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return iso;
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||
}
|
||
|
||
export function fmtShortTimestamp(iso: string): string {
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return iso;
|
||
const yy = String(d.getFullYear()).slice(2);
|
||
return `${yy}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||
}
|
||
|
||
export const pct = (v: number, dec = 1) =>
|
||
isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(dec)}%` : '0.0%';
|
||
|
||
export const pctAbs = (v: number, dec = 0) =>
|
||
isFinite(v) ? `${(v * 100).toFixed(dec)}%` : '0%';
|
||
|
||
export const krw = (v: number) => {
|
||
if (!isFinite(v)) return '–';
|
||
const sign = v >= 0 ? '+' : '-';
|
||
return `${sign}₩${formatUpbitKrwPrice(Math.abs(v), '0')}`;
|
||
};
|
||
|
||
const TF_SEC: Record<string, number> = {
|
||
'1m': 60, '3m': 180, '5m': 300, '10m': 600, '15m': 900, '30m': 1800,
|
||
'1h': 3600, '4h': 14400, '1D': 86400, '1W': 604800, '1M': 2592000,
|
||
};
|
||
|
||
export function timeframeBarSeconds(tf: string): number {
|
||
const n = normalizeChartTimeframe(tf);
|
||
return TF_SEC[n] ?? 3600;
|
||
}
|