From aac6454724530709980190b2256164adc4239f3d Mon Sep 17 00:00:00 2001 From: Macbook Date: Mon, 25 May 2026 16:13:25 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B0=80=EC=83=81=EC=88=98=EC=A0=952?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/virtual/VirtualTargetQuote.tsx | 30 ++++++++------ .../src/hooks/useVirtualIndicatorSnapshots.ts | 18 ++++++--- frontend/src/utils/virtualSignalMetrics.ts | 36 +++++++++-------- .../src/utils/virtualStrategyConditions.ts | 39 ++++++++++++------- 4 files changed, 76 insertions(+), 47 deletions(-) diff --git a/frontend/src/components/virtual/VirtualTargetQuote.tsx b/frontend/src/components/virtual/VirtualTargetQuote.tsx index 493d402..44880c0 100644 --- a/frontend/src/components/virtual/VirtualTargetQuote.tsx +++ b/frontend/src/components/virtual/VirtualTargetQuote.tsx @@ -1,19 +1,27 @@ import React, { memo, useEffect, useRef, useState } from 'react'; import type { ChangeDir, TickerData } from '../../hooks/useMarketTicker'; -function fmtKrw(price: number | null | undefined): string { - if (price == null || !Number.isFinite(price)) return '-'; - if (price >= 1_000_000) return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 }); - if (price >= 1_000) return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 }); - if (price >= 1) return price.toLocaleString('ko-KR', { maximumFractionDigits: 2 }); - if (price >= 0.01) return price.toFixed(4); - return price.toFixed(8); +function toFiniteNumber(v: unknown): number | null { + if (v == null || v === '') return null; + const n = typeof v === 'number' ? v : Number(v); + return Number.isFinite(n) ? n : null; } -function fmtPct(rate: number | null | undefined): string { - if (rate == null || !Number.isFinite(rate)) return '-'; - const sign = rate >= 0 ? '+' : ''; - return `${sign}${(rate * 100).toFixed(2)}%`; +function fmtKrw(price: number | null | undefined | unknown): string { + const p = toFiniteNumber(price); + if (p == null) return '-'; + if (p >= 1_000_000) return p.toLocaleString('ko-KR', { maximumFractionDigits: 0 }); + if (p >= 1_000) return p.toLocaleString('ko-KR', { maximumFractionDigits: 0 }); + if (p >= 1) return p.toLocaleString('ko-KR', { maximumFractionDigits: 2 }); + if (p >= 0.01) return p.toFixed(4); + return p.toFixed(8); +} + +function fmtPct(rate: number | null | undefined | unknown): string { + const r = toFiniteNumber(rate); + if (r == null) return '-'; + const sign = r >= 0 ? '+' : ''; + return `${sign}${(r * 100).toFixed(2)}%`; } const ChangeArrow = memo(function ChangeArrow({ change }: { change: ChangeDir }) { diff --git a/frontend/src/hooks/useVirtualIndicatorSnapshots.ts b/frontend/src/hooks/useVirtualIndicatorSnapshots.ts index 0c1c2ac..f2039c4 100644 --- a/frontend/src/hooks/useVirtualIndicatorSnapshots.ts +++ b/frontend/src/hooks/useVirtualIndicatorSnapshots.ts @@ -11,6 +11,7 @@ import { } from '../utils/backendApi'; import { formatIndicatorDisplayLabel } from '../utils/indicatorRegistry'; import { + coerceFiniteNumber, extractVirtualConditions, type VirtualConditionRow, } from '../utils/virtualStrategyConditions'; @@ -42,13 +43,13 @@ function liveRowToVirtual(r: LiveConditionRowDto): VirtualConditionRow & { curre displayName: formatIndicatorDisplayLabel(r.indicatorType), conditionType: r.conditionType, conditionLabel: r.conditionLabel, - targetValue: r.targetValue, + targetValue: coerceFiniteNumber(r.targetValue), timeframe: r.timeframe, side: r.side, plotKey, satisfied: r.satisfied, thresholdLabel: r.thresholdLabel, - currentValue: r.currentValue, + currentValue: coerceFiniteNumber(r.currentValue), }; } @@ -76,8 +77,8 @@ function mergeRows( ...row, satisfied: liveRow.satisfied, thresholdLabel: liveRow.thresholdLabel, - currentValue: liveRow.currentValue, - targetValue: liveRow.targetValue ?? row.targetValue, + currentValue: coerceFiniteNumber(liveRow.currentValue), + targetValue: coerceFiniteNumber(liveRow.targetValue) ?? row.targetValue, }; }); } @@ -102,7 +103,12 @@ async function buildSnapshot( }; } - const status = await fetchLiveConditionStatus(market, strategyId); + let status: Awaited> = null; + try { + status = await fetchLiveConditionStatus(market, strategyId); + } catch { + status = null; + } if (!status || status.market !== market || status.strategyId !== strategyId) { if (baseRows.length === 0) return null; return { @@ -121,7 +127,7 @@ async function buildSnapshot( timeframe: status.timeframe || [...new Set(baseRows.map(r => r.timeframe))].join(', '), rows: mergeRows(baseRows, status.rows), updatedAt: status.updatedAt || Date.now(), - matchRate: status.matchRate, + matchRate: coerceFiniteNumber(status.matchRate) ?? 0, }; } diff --git a/frontend/src/utils/virtualSignalMetrics.ts b/frontend/src/utils/virtualSignalMetrics.ts index 848df1d..c48dca3 100644 --- a/frontend/src/utils/virtualSignalMetrics.ts +++ b/frontend/src/utils/virtualSignalMetrics.ts @@ -1,4 +1,5 @@ import { + coerceFiniteNumber, formatIndicatorValue, isConditionMet, type VirtualConditionRow, @@ -33,37 +34,39 @@ const PENDING_PROGRESS = 72; /** 목표값 대비 현재값 접근률 (0~100, 충족 시 100) */ export function computeConditionProgress( conditionType: string, - current: number | null, - target: number | null, + current: number | null | unknown, + target: number | null | unknown, ): number | null { - if (current == null || target == null) return null; - const met = isConditionMet(conditionType, current, target); + const cur = coerceFiniteNumber(current); + const tgt = coerceFiniteNumber(target); + if (cur == null || tgt == null) return null; + const met = isConditionMet(conditionType, cur, tgt); if (met === true) return 100; - const absTarget = Math.abs(target); - const scale = absTarget > 1e-9 ? absTarget : Math.max(Math.abs(current), 1); + const absTarget = Math.abs(tgt); + const scale = absTarget > 1e-9 ? absTarget : Math.max(Math.abs(cur), 1); switch (conditionType) { case 'LT': case 'CROSS_DOWN': case 'LTE': { - if (current <= target) return 100; - const gap = current - target; + if (cur <= tgt) return 100; + const gap = cur - tgt; return Math.min(99, Math.max(0, 100 - (gap / scale) * 100)); } case 'GT': case 'CROSS_UP': case 'GTE': { - if (current >= target) return 100; - if (target <= 0 && current <= 0) return 0; - return Math.min(99, Math.max(0, (current / scale) * 100)); + if (cur >= tgt) return 100; + if (tgt <= 0 && cur <= 0) return 0; + return Math.min(99, Math.max(0, (cur / scale) * 100)); } case 'EQ': { - const diff = Math.abs(current - target); + const diff = Math.abs(cur - tgt); return Math.min(99, Math.max(0, 100 - (diff / scale) * 100)); } default: - return Math.min(99, Math.max(0, 100 - (Math.abs(current - target) / scale) * 100)); + return Math.min(99, Math.max(0, 100 - (Math.abs(cur - tgt) / scale) * 100)); } } @@ -168,10 +171,11 @@ export function buildConditionMetrics( /** 전체 매매조건 일치율 — 백엔드 matchRate 우선 */ export function computeMatchRate( metrics: ConditionMetric[], - backendMatchRate?: number, + backendMatchRate?: number | unknown, ): number { - if (backendMatchRate != null && Number.isFinite(backendMatchRate)) { - return Math.round(Math.min(100, Math.max(0, backendMatchRate))); + const backend = coerceFiniteNumber(backendMatchRate); + if (backend != null) { + return Math.round(Math.min(100, Math.max(0, backend))); } const evaluable = metrics.filter(m => m.status !== 'unknown'); if (evaluable.length === 0) return 0; diff --git a/frontend/src/utils/virtualStrategyConditions.ts b/frontend/src/utils/virtualStrategyConditions.ts index 87b120f..9218f8b 100644 --- a/frontend/src/utils/virtualStrategyConditions.ts +++ b/frontend/src/utils/virtualStrategyConditions.ts @@ -51,7 +51,7 @@ function walk( seen.add(key); const condLabel = CONDITION_LABEL[c.conditionType] ?? c.conditionType; - const target = c.targetValue ?? c.compareValue ?? null; + const target = coerceFiniteNumber(c.targetValue ?? c.compareValue ?? null); const plotKey = c.leftField && c.leftField !== 'none' ? c.leftField : c.indicatorType; out.push({ @@ -71,6 +71,14 @@ function walk( node.children?.forEach(c => walk(c, timeframe, side, out, seen)); } +/** API·DSL에서 문자열로 올 수 있는 값을 안전하게 숫자로 변환 */ +export function coerceFiniteNumber(v: unknown): number | null { + if (v == null || v === '') return null; + if (typeof v === 'number') return Number.isFinite(v) ? v : null; + const n = Number(v); + return Number.isFinite(n) ? n : null; +} + export function extractVirtualConditions(strategy: StrategyDto | null | undefined): VirtualConditionRow[] { if (!strategy) return []; const out: VirtualConditionRow[] = []; @@ -83,23 +91,26 @@ export function extractVirtualConditions(strategy: StrategyDto | null | undefine /** 조건 충족 여부 (단순 임계값 비교) */ export function isConditionMet( conditionType: string, - current: number | null, - target: number | null, + current: number | null | unknown, + target: number | null | unknown, ): boolean | null { - if (current == null || target == null) return null; + const cur = coerceFiniteNumber(current); + const tgt = coerceFiniteNumber(target); + if (cur == null || tgt == null) return null; switch (conditionType) { - case 'GT': case 'CROSS_UP': return current > target; - case 'LT': case 'CROSS_DOWN': return current < target; - case 'GTE': return current >= target; - case 'LTE': return current <= target; - case 'EQ': return Math.abs(current - target) < 1e-6; - case 'NEQ': return Math.abs(current - target) >= 1e-6; + case 'GT': case 'CROSS_UP': return cur > tgt; + case 'LT': case 'CROSS_DOWN': return cur < tgt; + case 'GTE': return cur >= tgt; + case 'LTE': return cur <= tgt; + case 'EQ': return Math.abs(cur - tgt) < 1e-6; + case 'NEQ': return Math.abs(cur - tgt) >= 1e-6; default: return null; } } -export function formatIndicatorValue(v: number | null): string { - if (v == null || Number.isNaN(v)) return '—'; - if (Math.abs(v) >= 1000) return v.toLocaleString(undefined, { maximumFractionDigits: 0 }); - return v.toFixed(2); +export function formatIndicatorValue(v: number | null | unknown): string { + const n = coerceFiniteNumber(v); + if (n == null) return '—'; + if (Math.abs(n) >= 1000) return n.toLocaleString(undefined, { maximumFractionDigits: 0 }); + return n.toFixed(2); }