전략편집기 수정

This commit is contained in:
Macbook
2026-05-25 17:56:25 +09:00
parent aac6454724
commit 02d14e4b2b
39 changed files with 1974 additions and 288 deletions
@@ -6,6 +6,7 @@ import {
FAVORITES_CHANGED_EVENT,
} from '../utils/marketStorage';
import { UPBIT_API } from '../utils/upbitApi';
import { safeToFixed } from '../utils/safeFormat';
// ─── Types ───────────────────────────────────────────────────────────────────
interface MarketInfo {
@@ -393,7 +394,7 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
<span className={`msp-col msp-col-change ${rate === null ? '' : isUp ? 'up' : 'down'}`}>
{rate === null
? '-'
: `${isUp ? '+' : ''}${rate.toFixed(2)}%`}
: `${isUp ? '+' : ''}${safeToFixed(rate, 2)}%`}
</span>
<span className="msp-col msp-col-vol">
{tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'}
+83 -64
View File
@@ -32,10 +32,11 @@ import {
defaultStartMeta,
type StartCombineOp,
} from '../utils/strategyStartNodes';
import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab';
import {
COMPOSITE_INDICATOR_ITEMS,
compositePeriodLabel,
} from '../utils/compositeIndicators';
loadPaletteItems,
type PaletteItem,
} from '../utils/strategyPaletteStorage';
import {
emptySignalFlowLayout,
loadStrategyFlowLayout,
@@ -135,6 +136,9 @@ export default function StrategyEditorPage({ theme }: Props) {
const [sellCondition, setSellCondition] = useState<LogicNode | null>(null);
const [signalTab, setSignalTab] = useState<'buy' | 'sell'>('buy');
const [rightTab, setRightTab] = useState<'indicators' | 'templates'>('indicators');
const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite'>('auxiliary');
const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary'));
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite'));
const [paletteSearch, setPaletteSearch] = useState('');
const [selectedPaletteKey, setSelectedPaletteKey] = useState<string | null>(null);
const [stratName, setStratName] = useState('');
@@ -689,6 +693,19 @@ export default function StrategyEditorPage({ theme }: Props) {
else setCurrentRoot(mergeAtRoot(root, newNode, false));
}, [signalTab, DEF, currentRoot, setCurrentRoot, handleAddStartSection]);
const applyPaletteItem = useCallback((item: PaletteItem) => {
const composite = item.kind === 'composite';
const newNode = makeNode('indicator', item.value, signalTab, DEF, {
composite,
period: item.period,
leftPeriod: item.shortPeriod,
rightPeriod: item.longPeriod,
});
const root = currentRoot;
if (!root) setCurrentRoot(newNode);
else setCurrentRoot(mergeAtRoot(root, newNode, false));
}, [signalTab, DEF, currentRoot, setCurrentRoot]);
const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]);
const handleTemplate = useCallback((tmpl: StrategyTemplateDef) => {
@@ -724,34 +741,16 @@ export default function StrategyEditorPage({ theme }: Props) {
{ type: 'indicator' as const, value: 'ICHIMOKU', label: 'Ichimoku', desc: '일목균형표', color: 'band' },
];
const indicatorItems = [
{ value: 'RSI', label: 'RSI', desc: '상대강도지수', color: 'ind' as const },
{ value: 'MACD', label: 'MACD', desc: '이동평균 수렴확산', color: 'ind' as const },
{ value: 'STOCHASTIC', label: 'Stochastic', desc: '스토캐스틱', color: 'ind' as const },
{ value: 'CCI', label: 'CCI', desc: '상품채널지수', color: 'ind' as const },
{ value: 'ADX', label: 'ADX', desc: '평균방향지수', color: 'ind' as const },
{ value: 'DMI', label: 'DMI', desc: '방향성 지표', color: 'ind' as const },
{ value: 'OBV', label: 'OBV', desc: '거래량 균형', color: 'ind' as const },
{ value: 'WILLIAMS_R', label: 'Williams %R', desc: '윌리엄스 %R', color: 'ind' as const },
{ value: 'TRIX', label: 'TRIX', desc: '삼중지수이동평균', color: 'ind' as const },
{ value: 'VOLUME_OSC', label: 'Volume OSC', desc: '거래량 오실레이터', color: 'ind' as const },
{ value: 'VR', label: 'VR', desc: '거래량 비율', color: 'ind' as const },
{ value: 'DISPARITY', label: '이격도', desc: 'Disparity', color: 'ind' as const },
{ value: 'PSYCHOLOGICAL', label: '심리도', desc: 'Psychological', color: 'ind' as const },
{ value: 'INVEST_PSYCHOLOGICAL', label: '투자심리도', desc: 'Invest PSY', color: 'ind' as const },
{ value: 'VOLUME', label: '거래량', desc: 'Volume', color: 'ind' as const },
];
const q = paletteSearch.trim().toLowerCase();
const match = (label: string, desc?: string) =>
!q || label.toLowerCase().includes(q) || (desc?.toLowerCase().includes(q) ?? false);
const paletteKey = (type: 'operator' | 'indicator' | 'composite', value: string) => `${type}:${value}`;
const selectPalette = (type: 'operator' | 'indicator' | 'composite', value: string) => {
setSelectedPaletteKey(paletteKey(type, value));
const paletteKey = (type: string, id: string) => `${type}:${id}`;
const selectPalette = (type: string, id: string) => {
setSelectedPaletteKey(paletteKey(type, id));
};
const isPaletteSelected = (type: 'operator' | 'indicator' | 'composite', value: string) =>
selectedPaletteKey === paletteKey(type, value);
const isPaletteSelected = (type: string, id: string) =>
selectedPaletteKey === paletteKey(type, id);
return (
<div className={`se-page se-page--${theme}`}>
@@ -1085,45 +1084,65 @@ export default function StrategyEditorPage({ theme }: Props) {
))}
</div>
</div>
<div className="se-palette-section se-palette-section--aux">
<h3></h3>
<div className="se-palette-grid se-palette-grid--3">
{indicatorItems.filter(i => match(i.label, i.desc)).map(item => (
<PaletteChip
key={item.value}
type="indicator"
value={item.value}
label={item.label}
desc={item.desc}
color={item.color}
period={getIndicatorPeriodLabel(item.value, DEF)}
selected={isPaletteSelected('indicator', item.value)}
onSelect={() => selectPalette('indicator', item.value)}
onAdd={() => applyPalette('indicator', item.value, item.label)}
/>
))}
</div>
</div>
<div className="se-palette-section se-palette-section--composite se-palette-section--scroll">
<h3></h3>
<div className="se-palette-grid se-palette-grid--3">
{COMPOSITE_INDICATOR_ITEMS.filter(i => match(i.label, i.desc)).map(item => (
<PaletteChip
key={`composite-${item.value}`}
type="indicator"
value={item.value}
label={item.label}
desc={item.desc}
color="composite"
composite
period={compositePeriodLabel(item.value, DEF)}
selected={isPaletteSelected('composite', item.value)}
onSelect={() => selectPalette('composite', item.value)}
onAdd={() => applyPalette('indicator', item.value, item.label, true)}
/>
))}
</div>
<div className="se-palette-subtabs">
<button
type="button"
className={`se-palette-subtab${indicatorSubTab === 'auxiliary' ? ' se-palette-subtab--on' : ''}`}
onClick={() => {
setIndicatorSubTab('auxiliary');
setSelectedPaletteKey(null);
}}
>
</button>
<button
type="button"
className={`se-palette-subtab${indicatorSubTab === 'composite' ? ' se-palette-subtab--on' : ''}`}
onClick={() => {
setIndicatorSubTab('composite');
setSelectedPaletteKey(null);
}}
>
</button>
</div>
{indicatorSubTab === 'auxiliary' ? (
<IndicatorPaletteTab
kind="auxiliary"
items={auxiliaryPalette}
onItemsChange={setAuxiliaryPalette}
def={DEF}
searchQuery={paletteSearch}
selectedItemId={
selectedPaletteKey?.startsWith('auxiliary:')
? selectedPaletteKey.slice('auxiliary:'.length)
: null
}
onSelectItem={id => setSelectedPaletteKey(id ? paletteKey('auxiliary', id) : null)}
onAddToCanvas={item => {
selectPalette('auxiliary', item.id);
applyPaletteItem(item);
}}
/>
) : (
<IndicatorPaletteTab
kind="composite"
items={compositePalette}
onItemsChange={setCompositePalette}
def={DEF}
searchQuery={paletteSearch}
selectedItemId={
selectedPaletteKey?.startsWith('composite:')
? selectedPaletteKey.slice('composite:'.length)
: null
}
onSelectItem={id => setSelectedPaletteKey(id ? paletteKey('composite', id) : null)}
onAddToCanvas={item => {
selectPalette('composite', item.id);
applyPaletteItem(item);
}}
/>
)}
</>
)}
{rightTab === 'templates' && (
+7 -5
View File
@@ -7,6 +7,7 @@ import { getKoreanName } from '../utils/marketNameCache';
import { MarketSearchPanel } from './MarketSearchPanel';
import type { TradeOrderFillRequest } from '../types';
import { placePaperOrder } from '../utils/backendApi';
import { coerceFiniteNumber } from '../utils/safeFormat';
export type OrderKind = 'limit' | 'market' | 'stop_limit';
@@ -140,7 +141,8 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
const price = parseNum(priceStr);
const qty = parseNum(qtyStr);
const step = priceStep(price > 0 ? price : (tradePrice ?? 0));
const refPrice = coerceFiniteNumber(tradePrice) ?? 0;
const step = priceStep(price > 0 ? price : refPrice);
useEffect(() => {
if (orderKind === 'market') {
@@ -156,7 +158,7 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
if (isBuy) {
if (orderKind === 'market' || availableKrw <= 0) return;
const budget = (availableKrw * pct) / 100;
const p = orderKind === 'limit' && price > 0 ? price : (tradePrice ?? 0);
const p = orderKind === 'limit' && price > 0 ? price : refPrice;
if (p > 0) {
const q = budget / p;
setQtyStr(q >= 1 ? q.toFixed(8).replace(/\.?0+$/, '') : q.toFixed(8));
@@ -166,13 +168,13 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
if (availableCoinQty <= 0) return;
const q = (availableCoinQty * pct) / 100;
setQtyStr(q >= 1 ? q.toFixed(8).replace(/\.?0+$/, '') : q.toFixed(8));
}, [isBuy, availableKrw, availableCoinQty, orderKind, price, tradePrice]);
}, [isBuy, availableKrw, availableCoinQty, orderKind, price, refPrice]);
const bumpPrice = useCallback((delta: number) => {
const base = price > 0 ? price : (tradePrice ?? 0);
const base = price > 0 ? price : refPrice;
const next = Math.max(0, base + delta);
setPriceStr(fmtKrw(next));
}, [price, tradePrice]);
}, [price, refPrice]);
const handleReset = useCallback(() => {
setOrderKind('limit');
+10 -3
View File
@@ -29,6 +29,7 @@ import {
type VirtualTargetItem,
} from '../utils/virtualTradingStorage';
import { useAppSettings, resolveAppDefaults } from '../hooks/useAppSettings';
import { coerceFiniteNumber } from '../utils/safeFormat';
import '../styles/virtualTradingDashboard.css';
type RightTab = 'trade' | 'orderbook';
@@ -105,10 +106,13 @@ const VirtualTradingPage: React.FC<Props> = ({
const posQty = useMemo(() => {
const p = summary?.positions?.find(x => x.symbol === selectedMarket);
return p?.quantity ?? 0;
return coerceFiniteNumber(p?.quantity) ?? 0;
}, [summary?.positions, selectedMarket]);
const tradePrice = tickers?.get(selectedMarket)?.tradePrice ?? null;
const tradePrice = useMemo(
() => coerceFiniteNumber(tickers?.get(selectedMarket)?.tradePrice),
[tickers, selectedMarket],
);
const handleSelectMarket = useCallback((market: string) => {
setSelectedMarket(market);
@@ -226,10 +230,13 @@ const VirtualTradingPage: React.FC<Props> = ({
subtitle="Virtual Trading"
loading={loading}
loadingText="가상투자 대시보드 로딩…"
collapsiblePanels
leftStorageKey="vtd-left-width"
leftDefaultWidth={380}
leftCollapsedStorageKey="vtd-left-open"
rightStorageKey="vtd-right-width"
rightDefaultWidth={380}
rightCollapsedStorageKey="vtd-right-open"
left={(
<VirtualLeftTargetPanel
targets={targets}
@@ -278,7 +285,7 @@ const VirtualTradingPage: React.FC<Props> = ({
side="buy"
market={selectedMarket}
tradePrice={tradePrice}
availableKrw={summary?.cashBalance ?? 0}
availableKrw={coerceFiniteNumber(summary?.cashBalance) ?? 0}
fillRequest={fillBuy}
searchAnchorRef={orderAnchorRef}
onMarketSelect={handleSelectMarket}
@@ -1,6 +1,12 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import type { Theme } from '../../types';
import { readStoredSize, storeSize, usePanelResize } from '../strategyEditor/usePanelResize';
import {
readStoredBool,
readStoredSize,
storeBool,
storeSize,
usePanelResize,
} from '../strategyEditor/usePanelResize';
import '../../styles/strategyEditorTheme.css';
import '../../styles/builderPageShell.css';
@@ -36,10 +42,58 @@ export interface BuilderPageShellProps {
rightStorageKey?: string;
rightDefaultWidth?: number;
footerStorageKey?: string;
/** 실시간 차트처럼 좌·우 패널 접기/펼치기 */
collapsiblePanels?: boolean;
leftCollapsedStorageKey?: string;
rightCollapsedStorageKey?: string;
loading?: boolean;
loadingText?: string;
}
function PanelCollapseHandle({
side,
open,
onToggle,
label,
}: {
side: 'left' | 'right';
open: boolean;
onToggle: () => void;
label: string;
}) {
return (
<button
type="button"
className={`bps-panel-handle bps-panel-handle--${side}${open ? ' bps-panel-handle--open' : ''}`}
onClick={e => {
e.stopPropagation();
onToggle();
}}
title={open ? `${label} 닫기` : `${label} 열기`}
aria-expanded={open}
aria-label={open ? `${label} 닫기` : `${label} 열기`}
>
<svg
width="8"
height="14"
viewBox="0 0 8 14"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
{side === 'left' ? (
open ? <polyline points="6,1 2,7 6,13" /> : <polyline points="2,1 6,7 2,13" />
) : (
open ? <polyline points="2,1 6,7 2,13" /> : <polyline points="6,1 2,7 6,13" />
)}
</svg>
</button>
);
}
export default function BuilderPageShell({
theme,
title,
@@ -62,11 +116,24 @@ export default function BuilderPageShell({
rightStorageKey = 'bps-right-width',
rightDefaultWidth = RIGHT_DEFAULT,
footerStorageKey = 'bps-footer-height',
collapsiblePanels = false,
leftCollapsedStorageKey,
rightCollapsedStorageKey,
loading = false,
loadingText = '로딩 중…',
}: BuilderPageShellProps) {
const [leftWidth, setLeftWidth] = useState(() => readStoredSize(leftStorageKey, leftDefaultWidth));
const [rightWidth, setRightWidth] = useState(() => readStoredSize(rightStorageKey, rightDefaultWidth));
const [leftOpen, setLeftOpen] = useState(() =>
collapsiblePanels && leftCollapsedStorageKey
? readStoredBool(leftCollapsedStorageKey, true)
: true,
);
const [rightOpen, setRightOpen] = useState(() =>
collapsiblePanels && rightCollapsedStorageKey
? readStoredBool(rightCollapsedStorageKey, true)
: true,
);
const [footerHeight, setFooterHeight] = useState(() => readStoredSize(footerStorageKey, FOOTER_DEFAULT));
const leftWidthRef = useRef(leftWidth);
const rightWidthRef = useRef(rightWidth);
@@ -124,9 +191,34 @@ export default function BuilderPageShell({
v => storeSize(footerStorageKey, v),
);
const toggleLeft = useCallback(() => {
setLeftOpen(prev => {
const next = !prev;
if (leftCollapsedStorageKey) storeBool(leftCollapsedStorageKey, next);
return next;
});
}, [leftCollapsedStorageKey]);
const toggleRight = useCallback(() => {
setRightOpen(prev => {
const next = !prev;
if (rightCollapsedStorageKey) storeBool(rightCollapsedStorageKey, next);
return next;
});
}, [rightCollapsedStorageKey]);
const bodyStyle = useMemo(() => ({
'--bps-footer-height': `${footerHeight}px`,
}) as React.CSSProperties, [footerHeight]);
'--bps-left-width': leftOpen ? `${leftWidth}px` : '0px',
'--bps-right-width': rightOpen ? `${rightWidth}px` : '0px',
}) as React.CSSProperties, [footerHeight, leftOpen, leftWidth, rightOpen, rightWidth]);
const centerContentClass = [
'bps-center-content',
collapsiblePanels && !leftOpen ? 'bps-center-content--left-collapsed' : '',
collapsiblePanels && !rightOpen ? 'bps-center-content--right-collapsed' : '',
collapsiblePanels && !leftOpen && !rightOpen ? 'bps-center-content--both-collapsed' : '',
].filter(Boolean).join(' ');
if (loading) {
return (
@@ -155,39 +247,76 @@ export default function BuilderPageShell({
{banner}
<div className="bps-body" style={bodyStyle}>
<aside className="bps-left" style={{ width: leftWidth }}>
<div className="bps-panel">
{leftTabs ? (
<>
{leftTabs}
<div className="bps-panel-body bps-panel-body--tabs">{left}</div>
</>
) : (
<>
<div className="bps-panel-head">
<h2 className="bps-panel-title">{leftTitle}</h2>
{leftActions && <div className="bps-panel-actions">{leftActions}</div>}
</div>
<div className="bps-panel-body">{left}</div>
</>
)}
{collapsiblePanels ? (
<div className={`bps-side-wrap bps-side-wrap--left${leftOpen ? ' bps-side-wrap--open' : ''}`}>
<aside
className={`bps-left bps-left--collapsible${leftOpen ? ' bps-left--collapsible--open' : ''}`}
>
<div className="bps-panel">
{leftTabs ? (
<>
{leftTabs}
<div className="bps-panel-body bps-panel-body--tabs">{left}</div>
</>
) : (
<>
<div className="bps-panel-head">
<h2 className="bps-panel-title">{leftTitle}</h2>
{leftActions && <div className="bps-panel-actions">{leftActions}</div>}
</div>
<div className="bps-panel-body">{left}</div>
</>
)}
</div>
</aside>
<PanelCollapseHandle side="left" open={leftOpen} onToggle={toggleLeft} label="좌측 패널" />
</div>
</aside>
<div
className="bps-splitter bps-splitter--v se-splitter se-splitter--v"
role="separator"
aria-orientation="vertical"
aria-label="좌측 패널 너비 조절"
onPointerDown={onLeftSplitter}
/>
) : null}
{collapsiblePanels && leftOpen ? (
<div
className="bps-splitter bps-splitter--v se-splitter se-splitter--v"
role="separator"
aria-orientation="vertical"
aria-label="좌측 패널 너비 조절"
onPointerDown={onLeftSplitter}
/>
) : null}
{!collapsiblePanels ? (
<>
<aside className="bps-left" style={{ width: leftWidth }}>
<div className="bps-panel">
{leftTabs ? (
<>
{leftTabs}
<div className="bps-panel-body bps-panel-body--tabs">{left}</div>
</>
) : (
<>
<div className="bps-panel-head">
<h2 className="bps-panel-title">{leftTitle}</h2>
{leftActions && <div className="bps-panel-actions">{leftActions}</div>}
</div>
<div className="bps-panel-body">{left}</div>
</>
)}
</div>
</aside>
<div
className="bps-splitter bps-splitter--v se-splitter se-splitter--v"
role="separator"
aria-orientation="vertical"
aria-label="좌측 패널 너비 조절"
onPointerDown={onLeftSplitter}
/>
</>
) : null}
<div className="bps-main">
<div className="bps-main-row">
<main className="bps-center">
<div className="bps-center-work">
{centerHead && <div className="bps-center-head">{centerHead}</div>}
<div className="bps-center-content">{center}</div>
<div className={centerContentClass}>{center}</div>
</div>
{footer && (
@@ -207,7 +336,30 @@ export default function BuilderPageShell({
)}
</main>
{right && (
{right && collapsiblePanels && rightOpen ? (
<div
className="bps-splitter bps-splitter--v se-splitter se-splitter--v"
role="separator"
aria-orientation="vertical"
aria-label="우측 패널 너비 조절"
onPointerDown={handleRightSplitter}
/>
) : null}
{right && (collapsiblePanels ? (
<div className={`bps-side-wrap bps-side-wrap--right${rightOpen ? ' bps-side-wrap--open' : ''}`}>
<PanelCollapseHandle side="right" open={rightOpen} onToggle={toggleRight} label="우측 패널" />
<aside
className={`bps-right bps-right--collapsible${rightOpen ? ' bps-right--collapsible--open' : ''}`}
>
{rightTabs ?? (rightTitle ? (
<div className="bps-panel-head" style={{ margin: 0, padding: '12px 12px 10px', borderBottom: '1px solid var(--se-border)' }}>
<h2 className="bps-panel-title">{rightTitle}</h2>
</div>
) : null)}
<div className="bps-right-body">{right}</div>
</aside>
</div>
) : (
<>
<div
className="bps-splitter bps-splitter--v se-splitter se-splitter--v"
@@ -228,7 +380,7 @@ export default function BuilderPageShell({
<div className="bps-right-body">{right}</div>
</aside>
</>
)}
))}
</div>
</div>
</div>
@@ -1,5 +1,6 @@
import React, { memo } from 'react';
import { useUpbitOrderbook } from '../../hooks/useUpbitOrderbook';
import { coerceFiniteNumber, safeToFixed } from '../../utils/safeFormat';
interface Props {
market: string;
@@ -11,15 +12,17 @@ interface Props {
section?: 'all' | 'asks' | 'bids';
}
function fmtPrice(p: number): string {
if (!Number.isFinite(p)) return '-';
return p >= 1000 ? p.toLocaleString('ko-KR', { maximumFractionDigits: 0 }) : p.toFixed(4);
function fmtPrice(p: unknown): string {
const n = coerceFiniteNumber(p);
if (n == null) return '-';
return n >= 1000 ? n.toLocaleString('ko-KR', { maximumFractionDigits: 0 }) : safeToFixed(n, 4);
}
function fmtSize(s: number): string {
if (!Number.isFinite(s)) return '-';
if (s >= 1000) return s.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
return s.toFixed(4);
function fmtSize(s: unknown): string {
const n = coerceFiniteNumber(s);
if (n == null) return '-';
if (n >= 1000) return n.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
return safeToFixed(n, 4);
}
const PaperCompactOrderbook: React.FC<Props> = memo(({
@@ -0,0 +1,102 @@
import React, { useEffect, useState } from 'react';
import ComboNumberInput from './ComboNumberInput';
export const COMBO_FIELD_CUSTOM = '__field_custom__';
export type FieldOption = { value: string; label: string };
export type FieldCustomKind = 'period' | 'threshold' | 'none';
interface Props {
options: FieldOption[];
/** 현재 DSL 필드값 (예: CCI_VALUE_9, K_80) */
fieldValue: string;
/** 프리셋 목록에 있는 값인지 */
isPresetField: boolean;
customKind: FieldCustomKind;
/** 직접입력 시 표시·편집할 숫자 (기간 또는 임계값) */
customNumber: number;
numberPresets?: number[];
min?: number;
max?: number;
allowDecimal?: boolean;
onFieldChange: (field: string) => void;
onCustomNumberChange: (n: number) => void;
/** 직접입력 선택 시 상단 select에 표시할 라벨 (예: CCI 1 라인(37일)) */
customOptionLabel?: string;
disabled?: boolean;
}
export default function ComboFieldSelect({
options,
fieldValue,
isPresetField,
customKind,
customNumber,
numberPresets = [],
min = 1,
max = 500,
allowDecimal = false,
onFieldChange,
onCustomNumberChange,
customOptionLabel,
disabled = false,
}: Props) {
const canCustom = customKind !== 'none';
const [mode, setMode] = useState<'preset' | 'custom'>(() =>
(canCustom && !isPresetField) ? 'custom' : 'preset',
);
useEffect(() => {
if (!canCustom) {
setMode('preset');
return;
}
/* 프리셋 필드여도 직접입력 모드는 유지 — isPresetField만으로 preset으로 되돌리지 않음 */
if (!isPresetField) setMode('custom');
}, [isPresetField, canCustom, fieldValue]);
const selectValue = mode === 'custom' ? COMBO_FIELD_CUSTOM : fieldValue;
const handleSelect = (raw: string) => {
if (raw === COMBO_FIELD_CUSTOM) {
if (canCustom) setMode('custom');
return;
}
setMode('preset');
onFieldChange(raw);
};
return (
<div className="se-combo-field">
<select
className="se-combo-field-select sp-cond-sel"
value={selectValue}
disabled={disabled}
onChange={e => handleSelect(e.target.value)}
aria-label="조건 대상 선택"
>
{options.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
{canCustom && (
<option value={COMBO_FIELD_CUSTOM}>
{mode === 'custom' && customOptionLabel ? customOptionLabel : '직접입력'}
</option>
)}
</select>
{mode === 'custom' && canCustom && (
<ComboNumberInput
value={customNumber}
options={numberPresets}
min={min}
max={max}
allowDecimal={allowDecimal}
alwaysShowInput
disabled={disabled}
onChange={onCustomNumberChange}
/>
)}
</div>
);
}
@@ -1,4 +1,6 @@
import React, { useEffect, useId, useState } from 'react';
import React, { useEffect, useState } from 'react';
export const COMBO_CUSTOM = '__custom__';
interface Props {
value: number;
@@ -7,6 +9,9 @@ interface Props {
max?: number;
allowDecimal?: boolean;
onChange: (value: number) => void;
/** true: 상단 입력창 항상 표시 + 하단 목록 선택 (조건대상 직접입력) */
alwaysShowInput?: boolean;
disabled?: boolean;
}
function sanitizeDraft(raw: string, allowDecimal: boolean): string {
@@ -30,6 +35,17 @@ function parseDraft(raw: string, allowDecimal: boolean): number | null {
return Number.isFinite(n) ? n : null;
}
function clampValue(
parsed: number,
min: number,
max: number,
allowDecimal: boolean,
): number {
return allowDecimal
? Math.max(min, Math.min(max, parsed))
: Math.max(min, Math.min(max, Math.round(parsed)));
}
export default function ComboNumberInput({
value,
options,
@@ -37,56 +53,104 @@ export default function ComboNumberInput({
max = 500,
allowDecimal = false,
onChange,
alwaysShowInput = false,
disabled = false,
}: Props) {
const listId = useId().replace(/:/g, '');
const uniqueOptions = [...new Set(options)].sort((a, b) => a - b);
const isPreset = uniqueOptions.includes(value);
const [mode, setMode] = useState<'preset' | 'custom'>(() =>
(alwaysShowInput || !isPreset) ? 'custom' : 'preset',
);
const [draft, setDraft] = useState(String(value));
const [focused, setFocused] = useState(false);
useEffect(() => {
if (!focused) setDraft(String(value));
}, [value, focused]);
if (alwaysShowInput) {
if (!focused) setDraft(String(value));
return;
}
if (isPreset) {
setMode('preset');
if (!focused) setDraft(String(value));
} else {
setMode('custom');
if (!focused) setDraft(String(value));
}
}, [value, isPreset, focused, alwaysShowInput]);
const commit = () => {
const commitCustom = () => {
const parsed = parseDraft(draft, allowDecimal);
if (parsed == null) {
setDraft(String(value));
return;
}
const clamped = allowDecimal
? Math.max(min, Math.min(max, parsed))
: Math.max(min, Math.min(max, Math.round(parsed)));
const clamped = clampValue(parsed, min, max, allowDecimal);
onChange(clamped);
setDraft(String(clamped));
};
const uniqueOptions = [...new Set([...options, value])].sort((a, b) => a - b);
const handleSelect = (raw: string) => {
if (raw === COMBO_CUSTOM) {
if (!alwaysShowInput) setMode('custom');
setDraft(String(value));
return;
}
const n = allowDecimal ? parseFloat(raw) : parseInt(raw, 10);
if (!Number.isFinite(n)) return;
const clamped = clampValue(n, min, max, allowDecimal);
if (!alwaysShowInput) setMode('preset');
onChange(clamped);
setDraft(String(clamped));
};
const showInput = alwaysShowInput || mode === 'custom';
const selectValue = alwaysShowInput
? (isPreset ? String(value) : '')
: (mode === 'custom' || !isPreset ? COMBO_CUSTOM : String(value));
const inputEl = showInput ? (
<input
type="text"
className="se-combo-num-input"
inputMode={allowDecimal ? 'decimal' : 'numeric'}
value={draft}
placeholder="값 입력"
disabled={disabled}
onChange={e => setDraft(sanitizeDraft(e.target.value, allowDecimal))}
onFocus={() => setFocused(true)}
onBlur={() => {
setFocused(false);
commitCustom();
}}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}
}}
aria-label="직접 입력"
/>
) : null;
return (
<div className="se-combo-num">
<input
type="text"
className="se-combo-num-input"
inputMode={allowDecimal ? 'decimal' : 'numeric'}
list={listId}
value={draft}
onChange={e => setDraft(sanitizeDraft(e.target.value, allowDecimal))}
onFocus={() => setFocused(true)}
onBlur={() => {
setFocused(false);
commit();
}}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}
}}
/>
<datalist id={listId}>
<div className={`se-combo-num${alwaysShowInput ? ' se-combo-num--always-input' : ''}`}>
{alwaysShowInput ? inputEl : null}
<select
className="se-combo-num-select sp-cond-sel"
value={selectValue}
disabled={disabled}
onChange={e => handleSelect(e.target.value)}
aria-label="목록에서 선택"
>
{alwaysShowInput && !isPreset && (
<option value="" disabled> </option>
)}
{uniqueOptions.map(opt => (
<option key={opt} value={String(opt)} />
<option key={opt} value={String(opt)}>{opt}</option>
))}
</datalist>
{!alwaysShowInput && <option value={COMBO_CUSTOM}></option>}
</select>
{!alwaysShowInput ? inputEl : null}
</div>
);
}
@@ -2,6 +2,8 @@ import React, { useEffect, useRef } from 'react';
import type { ConditionDSL } from '../../utils/strategyTypes';
import type { DefType } from '../../utils/strategyEditorShared';
import {
getCompositeLeftCandleType,
getCompositeRightCandleType,
getConditionRightPeriod,
getConditionThreshold,
getConditionValuePeriod,
@@ -10,12 +12,15 @@ import {
getThresholdBounds,
getThresholdPresetOptions,
hasEditableThreshold,
setCompositeLeftCandleType,
setCompositeRightCandleType,
setConditionRightPeriod,
setConditionThreshold,
setConditionValuePeriod,
usesValuePeriodField,
} from '../../utils/conditionPeriods';
import { compositeDisplayName, compositeElementLabel } from '../../utils/compositeIndicators';
import { STRATEGY_CANDLE_TYPE_OPTIONS } from '../../utils/strategyStartNodes';
import ComboNumberInput from './ComboNumberInput';
interface Props {
@@ -67,6 +72,30 @@ export default function ConditionNodeSettings({
</div>
{condition.composite ? (
<>
<label className="se-flow-settings-field">
<span>{compositeElementLabel(condition.indicatorType, 1)} </span>
<select
className="se-combo-num-select sp-cond-sel"
value={getCompositeLeftCandleType(condition)}
onChange={e => onChange(setCompositeLeftCandleType(condition, e.target.value))}
>
{STRATEGY_CANDLE_TYPE_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</label>
<label className="se-flow-settings-field">
<span>{compositeElementLabel(condition.indicatorType, 2)} </span>
<select
className="se-combo-num-select sp-cond-sel"
value={getCompositeRightCandleType(condition)}
onChange={e => onChange(setCompositeRightCandleType(condition, e.target.value))}
>
{STRATEGY_CANDLE_TYPE_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</label>
<label className="se-flow-settings-field">
<span>{compositeElementLabel(condition.indicatorType, 1)} (, )</span>
<ComboNumberInput
@@ -0,0 +1,196 @@
import React, { useCallback, useMemo, useState } from 'react';
import PaletteChip from './PaletteChip';
import PaletteItemModal from './PaletteItemModal';
import type { DefType } from '../../utils/strategyEditorShared';
import { getCompositeDefaultPeriods } from '../../utils/compositeIndicators';
import { getDefaultIndicatorPeriod } from '../../utils/conditionPeriods';
import { getIndicatorPeriodLabel } from '../../utils/strategyFlowLayout';
import {
createPaletteItemId,
savePaletteItems,
type PaletteItem,
type PaletteItemKind,
} from '../../utils/strategyPaletteStorage';
function periodLabel(item: PaletteItem, def: DefType): string {
if (item.kind === 'composite') {
const d = getCompositeDefaultPeriods(item.value, def);
const s = item.shortPeriod ?? d.short;
const l = item.longPeriod ?? d.long;
return `${s} / ${l}`;
}
if (item.period != null) return String(item.period);
return getIndicatorPeriodLabel(item.value, def) || String(getDefaultIndicatorPeriod(item.value, def));
}
interface Props {
kind: PaletteItemKind;
items: PaletteItem[];
onItemsChange: (items: PaletteItem[]) => void;
def: DefType;
searchQuery: string;
selectedItemId: string | null;
onSelectItem: (id: string | null) => void;
onAddToCanvas: (item: PaletteItem) => void;
}
export default function IndicatorPaletteTab({
kind, items, onItemsChange, def, searchQuery, selectedItemId, onSelectItem, onAddToCanvas,
}: Props) {
const [modalOpen, setModalOpen] = useState(false);
const [modalMode, setModalMode] = useState<'add' | 'edit'>('add');
const [editTarget, setEditTarget] = useState<PaletteItem | null>(null);
const q = searchQuery.trim().toLowerCase();
const filtered = useMemo(() => {
if (!q) return items;
return items.filter(
i => i.label.toLowerCase().includes(q)
|| i.desc.toLowerCase().includes(q)
|| i.value.toLowerCase().includes(q),
);
}, [items, q]);
const selected = items.find(i => i.id === selectedItemId) ?? null;
const persist = useCallback((next: PaletteItem[]) => {
onItemsChange(next);
savePaletteItems(kind, next);
}, [kind, onItemsChange]);
const openAdd = () => {
setEditTarget(null);
setModalMode('add');
setModalOpen(true);
};
const openEdit = () => {
if (!selected) {
window.alert('수정할 지표 카드를 먼저 선택해 주세요.');
return;
}
setEditTarget(selected);
setModalMode('edit');
setModalOpen(true);
};
const handleDelete = () => {
if (!selected) {
window.alert('삭제할 지표 카드를 먼저 선택해 주세요.');
return;
}
if (!window.confirm(`${selected.label}」을(를) 목록에서 삭제할까요?`)) return;
const next = items.filter(i => i.id !== selected.id);
persist(next);
onSelectItem(null);
};
const handleSave = (draft: Omit<PaletteItem, 'id' | 'kind'> & { id?: string }) => {
if (modalMode === 'add') {
const item: PaletteItem = {
id: createPaletteItemId(),
kind,
value: draft.value,
label: draft.label,
desc: draft.desc,
period: draft.period,
shortPeriod: draft.shortPeriod,
longPeriod: draft.longPeriod,
};
persist([...items, item]);
onSelectItem(item.id);
return;
}
const id = draft.id ?? editTarget?.id;
if (!id) return;
persist(items.map(i => (i.id === id ? {
...i,
label: draft.label,
desc: draft.desc,
period: draft.period,
shortPeriod: draft.shortPeriod,
longPeriod: draft.longPeriod,
} : i)));
};
return (
<div className={`se-palette-section se-palette-section--${kind === 'auxiliary' ? 'aux' : 'composite'} se-palette-section--scroll`}>
<div className="se-palette-toolbar">
<span className="se-palette-toolbar-title">{kind === 'auxiliary' ? '보조지표' : '복합지표'}</span>
<div className="se-palette-toolbar-actions" role="toolbar" aria-label={`${kind === 'auxiliary' ? '보조' : '복합'}지표 관리`}>
<button
type="button"
className="se-palette-tool-btn"
title="지표 추가"
aria-label="지표 추가"
onClick={openAdd}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden>
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
<button
type="button"
className="se-palette-tool-btn"
title="선택 지표 수정"
aria-label="선택 지표 수정"
disabled={!selected}
onClick={openEdit}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden>
<path d="M4 20h4l10-10-4-4L4 16v4z" stroke="currentColor" strokeWidth="1.75" fill="none" strokeLinejoin="round" />
<path d="M14 6l4 4" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
</svg>
</button>
<button
type="button"
className="se-palette-tool-btn se-palette-tool-btn--danger"
title="선택 지표 삭제"
aria-label="선택 지표 삭제"
disabled={!selected}
onClick={handleDelete}
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden>
<path d="M6 7h12M9 7V5h6v2M10 11v6M14 11v6M8 7l1 12h6l1-12" stroke="currentColor" strokeWidth="1.75" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
</div>
<div className="se-palette-grid se-palette-grid--3">
{filtered.map(item => (
<PaletteChip
key={item.id}
type="indicator"
value={item.value}
label={item.label}
desc={item.desc}
color={kind === 'composite' ? 'composite' : 'ind'}
composite={kind === 'composite'}
period={periodLabel(item, def)}
periodValue={item.period}
shortPeriod={item.shortPeriod}
longPeriod={item.longPeriod}
selected={selectedItemId === item.id}
onSelect={() => onSelectItem(item.id)}
onAdd={() => onAddToCanvas(item)}
/>
))}
</div>
{filtered.length === 0 && (
<p className="se-palette-empty"> .</p>
)}
<PaletteItemModal
open={modalOpen}
mode={modalMode}
kind={kind}
initial={editTarget}
def={def}
onClose={() => setModalOpen(false)}
onSave={handleSave}
/>
</div>
);
}
@@ -5,6 +5,9 @@ export interface PaletteDragPayload {
value: string;
label: string;
composite?: boolean;
period?: number;
shortPeriod?: number;
longPeriod?: number;
}
interface Props {
@@ -14,6 +17,9 @@ interface Props {
desc?: string;
color?: string;
period?: string;
periodValue?: number;
shortPeriod?: number;
longPeriod?: number;
composite?: boolean;
selected?: boolean;
onSelect?: () => void;
@@ -21,10 +27,17 @@ interface Props {
}
export default function PaletteChip({
type, value, label, desc, color, period, composite = false, selected = false, onSelect, onAdd,
type, value, label, desc, color, period, periodValue, shortPeriod, longPeriod,
composite = false, selected = false, onSelect, onAdd,
}: Props) {
const onDragStart = (e: React.DragEvent) => {
const payload: PaletteDragPayload = { type, value, label, composite: composite || undefined };
const payload: PaletteDragPayload = {
type, value, label,
composite: composite || undefined,
period: periodValue,
shortPeriod,
longPeriod,
};
e.dataTransfer.setData('application/json', JSON.stringify(payload));
e.dataTransfer.effectAllowed = 'copy';
};
@@ -0,0 +1,197 @@
import React, { useEffect, useState } from 'react';
import DraggableModalFrame from '../DraggableModalFrame';
import ComboNumberInput from './ComboNumberInput';
import type { DefType } from '../../utils/strategyEditorShared';
import { getCompositeDefaultPeriods } from '../../utils/compositeIndicators';
import { getDefaultIndicatorPeriod, getCompositePeriodPresetOptions, getPeriodPresetOptions } from '../../utils/conditionPeriods';
import {
AUXILIARY_TYPE_OPTIONS,
COMPOSITE_TYPE_OPTIONS,
type PaletteItem,
type PaletteItemKind,
} from '../../utils/strategyPaletteStorage';
interface Props {
open: boolean;
mode: 'add' | 'edit';
kind: PaletteItemKind;
initial?: PaletteItem | null;
def: DefType;
onClose: () => void;
onSave: (item: Omit<PaletteItem, 'id' | 'kind'> & { id?: string }) => void;
}
export default function PaletteItemModal({
open, mode, kind, initial, def, onClose, onSave,
}: Props) {
const typeOptions = kind === 'auxiliary' ? AUXILIARY_TYPE_OPTIONS : COMPOSITE_TYPE_OPTIONS;
const [value, setValue] = useState(typeOptions[0]?.value ?? 'RSI');
const [label, setLabel] = useState('');
const [desc, setDesc] = useState('');
const [period, setPeriod] = useState(14);
const [shortPeriod, setShortPeriod] = useState(9);
const [longPeriod, setLongPeriod] = useState(20);
useEffect(() => {
if (!open) return;
if (mode === 'edit' && initial) {
setValue(initial.value);
setLabel(initial.label);
setDesc(initial.desc);
if (kind === 'auxiliary') {
setPeriod(initial.period ?? getDefaultIndicatorPeriod(initial.value, def));
} else {
const d = getCompositeDefaultPeriods(initial.value, def);
setShortPeriod(initial.shortPeriod ?? d.short);
setLongPeriod(initial.longPeriod ?? d.long);
}
return;
}
const v = typeOptions[0]?.value ?? 'RSI';
setValue(v);
const opt = typeOptions.find(o => o.value === v);
setLabel(opt?.label ?? v);
setDesc(kind === 'auxiliary' ? '보조지표' : `${opt?.label ?? v} 기간 교차`);
setPeriod(getDefaultIndicatorPeriod(v, def));
const cp = getCompositeDefaultPeriods(v, def);
setShortPeriod(cp.short);
setLongPeriod(cp.long);
}, [open, mode, initial, kind, def, typeOptions]);
const handleTypeChange = (next: string) => {
setValue(next);
const opt = typeOptions.find(o => o.value === next);
if (mode === 'add') {
setLabel(opt?.label ?? next);
setDesc(kind === 'auxiliary'
? (DEFAULT_DESC[next] ?? '보조지표')
: `${opt?.label ?? next} 기간 교차`);
}
setPeriod(getDefaultIndicatorPeriod(next, def));
const cp = getCompositeDefaultPeriods(next, def);
setShortPeriod(cp.short);
setLongPeriod(cp.long);
};
if (!open) return null;
const title = mode === 'add'
? (kind === 'auxiliary' ? '보조지표 추가' : '복합지표 추가')
: (kind === 'auxiliary' ? '보조지표 수정' : '복합지표 수정');
const handleSubmit = () => {
const trimmedLabel = label.trim();
if (!trimmedLabel) {
window.alert('표시 이름을 입력해 주세요.');
return;
}
if (kind === 'composite' && shortPeriod >= longPeriod) {
window.alert('단기 기간은 장기 기간보다 작아야 합니다.');
return;
}
onSave({
id: initial?.id,
value,
label: trimmedLabel,
desc: desc.trim() || trimmedLabel,
builtIn: initial?.builtIn,
...(kind === 'auxiliary' ? { period } : { shortPeriod, longPeriod }),
});
onClose();
};
return (
<DraggableModalFrame onClose={onClose} title={title}>
<div className="se-modal-body se-palette-item-modal">
<label className="se-field-lbl"> </label>
<select
className="se-field-inp"
value={value}
disabled={mode === 'edit'}
onChange={e => handleTypeChange(e.target.value)}
>
{typeOptions.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<label className="se-field-lbl"> </label>
<input
className="se-field-inp"
value={label}
onChange={e => setLabel(e.target.value)}
placeholder="예: RSI"
/>
<label className="se-field-lbl"></label>
<input
className="se-field-inp"
value={desc}
onChange={e => setDesc(e.target.value)}
placeholder="짧은 설명"
/>
{kind === 'auxiliary' ? (
<label className="se-field-lbl">
()
<ComboNumberInput
value={period}
options={getPeriodPresetOptions(value, def)}
min={1}
max={500}
onChange={setPeriod}
/>
</label>
) : (
<>
<label className="se-field-lbl">
()
<ComboNumberInput
value={shortPeriod}
options={getCompositePeriodPresetOptions(value, def, 'left')}
min={1}
max={500}
onChange={setShortPeriod}
/>
</label>
<label className="se-field-lbl">
()
<ComboNumberInput
value={longPeriod}
options={getCompositePeriodPresetOptions(value, def, 'right')}
min={1}
max={500}
onChange={setLongPeriod}
/>
</label>
</>
)}
<div className="se-modal-actions">
<button type="button" className="se-btn se-btn--ghost" onClick={onClose}></button>
<button type="button" className="se-btn se-btn--gold" onClick={handleSubmit}>
{mode === 'add' ? '추가' : '저장'}
</button>
</div>
</div>
</DraggableModalFrame>
);
}
const DEFAULT_DESC: Record<string, string> = {
RSI: '상대강도지수',
MACD: '이동평균 수렴확산',
STOCHASTIC: '스토캐스틱',
CCI: '상품채널지수',
ADX: '평균방향지수',
DMI: '방향성 지표',
OBV: '거래량 균형',
WILLIAMS_R: '윌리엄스 %R',
TRIX: '삼중지수이동평균',
VOLUME_OSC: '거래량 오실레이터',
VR: '거래량 비율',
DISPARITY: 'Disparity',
PSYCHOLOGICAL: 'Psychological',
INVEST_PSYCHOLOGICAL: 'Invest PSY',
VOLUME: 'Volume',
};
@@ -24,6 +24,7 @@ import {
deleteNode,
mergeAtRoot,
makeNode,
makeNodeOptionsFromPalette,
updateNode,
type DefType,
} from '../../utils/strategyEditorShared';
@@ -556,7 +557,7 @@ function StrategyEditorCanvasInner({
data: { type: string; value: string; label: string; composite?: boolean },
flowPos: { x: number; y: number },
) => {
const newNode = makeNode(data.type, data.value, signalTab, def, data.composite ? { composite: true } : undefined);
const newNode = makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
positionsRef.current.set(newNode.id, {
x: flowPos.x - FLOW_NODE_W / 2,
y: flowPos.y - FLOW_NODE_H / 2,
@@ -575,7 +576,7 @@ function StrategyEditorCanvasInner({
return;
}
const newNode = makeNode(data.type, data.value, signalTab, def, data.composite ? { composite: true } : undefined);
const newNode = makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
const anchor = nodesRef.current.find(n => n.id === anchorId);
if (!anchor) return;
@@ -4,6 +4,7 @@ import {
CondEditor,
addChild,
makeNode,
makeNodeOptionsFromPalette,
mergeAtRoot,
nodeToText,
type DefType,
@@ -213,7 +214,7 @@ function StartSectionBlock({
onAddStart?.();
return;
}
const newNode = makeNode(data.type, data.value, signalTab, def, data.composite ? { composite: true } : undefined);
const newNode = makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
if (!root) {
onRootChange(newNode);
return;
@@ -63,3 +63,18 @@ export function storeSize(key: string, value: number): void {
localStorage.setItem(key, String(Math.round(value)));
} catch { /* ignore */ }
}
export function readStoredBool(key: string, fallback: boolean): boolean {
try {
const raw = localStorage.getItem(key);
if (raw === '1' || raw === 'true') return true;
if (raw === '0' || raw === 'false') return false;
} catch { /* ignore */ }
return fallback;
}
export function storeBool(key: string, value: boolean): void {
try {
localStorage.setItem(key, value ? '1' : '0');
} catch { /* ignore */ }
}
@@ -1,5 +1,6 @@
import React from 'react';
import type { ConditionStatus } from '../../utils/virtualSignalMetrics';
import { coerceFiniteNumber } from '../../utils/safeFormat';
interface Props {
status: ConditionStatus;
@@ -22,10 +23,8 @@ const ConditionStatusSignal: React.FC<Props> = ({ status, matchRate, progress, c
return <span className="vtd-cond-signal vtd-cond-signal--unknown"></span>;
}
const rate = matchRate ?? progress;
const pct = rate != null && Number.isFinite(rate)
? `${Math.round(rate)}%`
: null;
const rate = coerceFiniteNumber(matchRate ?? progress);
const pct = rate != null ? `${Math.round(rate)}%` : null;
return (
<span className={`vtd-cond-signal vtd-cond-signal--${status}${compact ? ' vtd-cond-signal--compact' : ''}`}>
@@ -1,6 +1,7 @@
import React from 'react';
import type { ConditionMetric } from '../../utils/virtualSignalMetrics';
import { resolveHeatTier } from '../../utils/virtualSignalMetrics';
import { coerceFiniteNumber } from '../../utils/safeFormat';
interface Props {
metrics: ConditionMetric[];
@@ -41,7 +42,7 @@ const VirtualConditionList: React.FC<Props> = ({ metrics }) => {
<ul className="vtd-heat-list" aria-label="전략 지표 매치율">
{metrics.map(({ row, status, matchRate }) => {
const tier = resolveHeatTier(matchRate, status);
const pct = Math.min(100, Math.max(0, matchRate));
const pct = Math.min(100, Math.max(0, coerceFiniteNumber(matchRate) ?? 0));
const minFill = pct > 0 && tier === 'nomatch' ? 6 : pct > 0 ? 6 : 0;
return (
<li key={row.id} className={`vtd-heat-row vtd-heat-row--${tier}`}>
@@ -2,6 +2,7 @@ import React from 'react';
import type { ConditionMetric } from '../../utils/virtualSignalMetrics';
import { formatStrategyThreshold, resolveHeatTier } from '../../utils/virtualSignalMetrics';
import { formatIndicatorValue } from '../../utils/virtualStrategyConditions';
import { coerceFiniteNumber } from '../../utils/safeFormat';
import ConditionStatusSignal from './ConditionStatusSignal';
interface Props {
@@ -30,7 +31,8 @@ const VirtualIndicatorCompareTable: React.FC<Props> = ({ metrics }) => (
</thead>
<tbody>
{metrics.map(({ row, status, matchRate }) => {
const tier = resolveHeatTier(matchRate, status);
const rate = coerceFiniteNumber(matchRate) ?? 0;
const tier = resolveHeatTier(rate, status);
return (
<tr key={row.id}>
<td className="vtd-sig-table-ind">{row.displayName}</td>
@@ -43,13 +45,13 @@ const VirtualIndicatorCompareTable: React.FC<Props> = ({ metrics }) => (
<div className="vtd-sig-row-bar vtd-sig-row-bar--heat">
<div
className={`vtd-sig-row-bar-fill vtd-sig-row-bar-fill--${tier}`}
style={{ width: `${matchRate}%` }}
style={{ width: `${rate}%` }}
/>
</div>
<span className="vtd-sig-table-pct">{`${matchRate}%`}</span>
<span className="vtd-sig-table-pct">{`${rate}%`}</span>
</td>
<td className="vtd-sig-table-status-cell">
<ConditionStatusSignal status={status} matchRate={matchRate} compact />
<ConditionStatusSignal status={status} matchRate={rate} compact />
</td>
</tr>
);
@@ -1,4 +1,5 @@
import React, { useMemo } from 'react';
import { coerceFiniteNumber } from '../../utils/safeFormat';
const SEGMENTS = 25;
@@ -7,9 +8,10 @@ interface Props {
}
const VirtualSignalEqualizer: React.FC<Props> = ({ matchRate }) => {
const rate = coerceFiniteNumber(matchRate) ?? 0;
const litCount = useMemo(
() => Math.round((Math.min(100, Math.max(0, matchRate)) / 100) * SEGMENTS),
[matchRate],
() => Math.round((Math.min(100, Math.max(0, rate)) / 100) * SEGMENTS),
[rate],
);
const segments = useMemo(() => {
@@ -20,7 +22,7 @@ const VirtualSignalEqualizer: React.FC<Props> = ({ matchRate }) => {
items.push({ lit: false, tone: 'blue' });
continue;
}
if (matchRate >= 100 && i === SEGMENTS - 1) {
if (rate >= 100 && i === SEGMENTS - 1) {
items.push({ lit: true, tone: 'peak' });
} else if (i >= Math.floor(SEGMENTS * 0.55)) {
items.push({ lit: true, tone: 'gold' });
@@ -29,7 +31,7 @@ const VirtualSignalEqualizer: React.FC<Props> = ({ matchRate }) => {
}
}
return items;
}, [litCount, matchRate]);
}, [litCount, rate]);
const ticks = [100, 90, 80, 70, 60, 50, 40, 30, 20, 10, 0];
@@ -53,7 +55,7 @@ const VirtualSignalEqualizer: React.FC<Props> = ({ matchRate }) => {
/>
))}
</div>
{matchRate >= 100 && (
{rate >= 100 && (
<div className="vtd-sig-eq-badge">100% MATCH</div>
)}
</div>
@@ -111,9 +111,9 @@ const VirtualTargetCard: React.FC<Props> = ({
<span className="vtd-card-sym">{sym}</span>
</div>
<label className="vtd-card-strategy-field" onClick={e => e.stopPropagation()}>
<span className="vtd-card-strategy-label"></span>
<select
className="vtd-card-strategy-select"
aria-label="투자전략"
value={strategyId ?? globalStrategyId ?? ''}
onChange={e => {
const v = e.target.value;
@@ -70,9 +70,9 @@ const VirtualTargetFocusView: React.FC<Props> = ({
<span className="vtd-card-sym">{sym}</span>
</div>
<label className="vtd-card-strategy-field" onClick={e => e.stopPropagation()}>
<span className="vtd-card-strategy-label"></span>
<select
className="vtd-card-strategy-select"
aria-label="투자전략"
value={strategyId ?? globalStrategyId ?? ''}
onChange={e => {
const v = e.target.value;
@@ -1,27 +1,19 @@
import React, { memo, useEffect, useRef, useState } from 'react';
import type { ChangeDir, TickerData } from '../../hooks/useMarketTicker';
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;
}
import { coerceFiniteNumber, safePercentFromRate, safeToFixed } from '../../utils/safeFormat';
function fmtKrw(price: number | null | undefined | unknown): string {
const p = toFiniteNumber(price);
const p = coerceFiniteNumber(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);
if (p >= 0.01) return safeToFixed(p, 4, '-');
return safeToFixed(p, 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)}%`;
return safePercentFromRate(rate, 2).replace('—', '-');
}
const ChangeArrow = memo(function ChangeArrow({ change }: { change: ChangeDir }) {