From 02d14e4b2b9faae0af47244f4fce9b80dcb441a0 Mon Sep 17 00:00:00 2001 From: Macbook Date: Mon, 25 May 2026 17:56:25 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EB=9E=B5=ED=8E=B8=EC=A7=91=EA=B8=B0?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/StrategyDslToTa4jAdapter.java | 110 +++++++- frontend/src/App.css | 6 + frontend/src/components/MarketSearchPanel.tsx | 3 +- .../src/components/StrategyEditorPage.tsx | 147 ++++++----- frontend/src/components/TradeOrderPanel.tsx | 12 +- .../src/components/VirtualTradingPage.tsx | 13 +- .../components/layout/BuilderPageShell.tsx | 212 +++++++++++++--- .../paper/PaperCompactOrderbook.tsx | 17 +- .../strategyEditor/ComboFieldSelect.tsx | 102 ++++++++ .../strategyEditor/ComboNumberInput.tsx | 128 +++++++--- .../strategyEditor/ConditionNodeSettings.tsx | 29 +++ .../strategyEditor/IndicatorPaletteTab.tsx | 196 +++++++++++++++ .../components/strategyEditor/PaletteChip.tsx | 17 +- .../strategyEditor/PaletteItemModal.tsx | 197 +++++++++++++++ .../strategyEditor/StrategyEditorCanvas.tsx | 5 +- .../strategyEditor/StrategyListEditor.tsx | 3 +- .../strategyEditor/usePanelResize.ts | 15 ++ .../virtual/ConditionStatusSignal.tsx | 7 +- .../virtual/VirtualConditionList.tsx | 3 +- .../virtual/VirtualIndicatorCompareTable.tsx | 10 +- .../virtual/VirtualSignalEqualizer.tsx | 12 +- .../components/virtual/VirtualTargetCard.tsx | 2 +- .../virtual/VirtualTargetFocusView.tsx | 2 +- .../components/virtual/VirtualTargetQuote.tsx | 18 +- frontend/src/hooks/useUpbitOrderbook.ts | 13 +- .../src/hooks/useVirtualIndicatorSnapshots.ts | 25 +- frontend/src/styles/builderPageShell.css | 181 +++++++++++++ frontend/src/styles/strategyEditor.css | 114 ++++++++- .../src/styles/virtualTradingDashboard.css | 40 +-- frontend/src/utils/compositeIndicators.ts | 63 ++++- frontend/src/utils/conditionPeriods.ts | 74 +++++- frontend/src/utils/safeFormat.ts | 33 +++ .../src/utils/strategyDescriptionNarrative.ts | 47 ++-- frontend/src/utils/strategyEditorShared.tsx | 237 +++++++++++++++--- frontend/src/utils/strategyPaletteStorage.ts | 116 +++++++++ frontend/src/utils/strategyStartNodes.ts | 22 ++ frontend/src/utils/strategyTypes.ts | 3 + frontend/src/utils/virtualSignalMetrics.ts | 15 +- .../src/utils/virtualStrategyConditions.ts | 13 +- 39 files changed, 1974 insertions(+), 288 deletions(-) create mode 100644 frontend/src/components/strategyEditor/ComboFieldSelect.tsx create mode 100644 frontend/src/components/strategyEditor/IndicatorPaletteTab.tsx create mode 100644 frontend/src/components/strategyEditor/PaletteItemModal.tsx create mode 100644 frontend/src/utils/safeFormat.ts create mode 100644 frontend/src/utils/strategyPaletteStorage.ts diff --git a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java index 302d938..0d99a33 100644 --- a/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java +++ b/backend/src/main/java/com/goldenchart/service/StrategyDslToTa4jAdapter.java @@ -135,7 +135,7 @@ public class StrategyDslToTa4jAdapter { case "OR" -> buildOrRule(node, ctx); case "NOT" -> buildNotRule(node, ctx); case "TIMEFRAME" -> buildTimeframeRule(node, ctx); - default -> buildConditionRule(node.path("condition"), ctx.primarySeries(), ctx.indicatorParams()); + default -> buildConditionRule(node.path("condition"), ctx); }; } @@ -261,10 +261,12 @@ public class StrategyDslToTa4jAdapter { // ── CONDITION ───────────────────────────────────────────────────────────── - private Rule buildConditionRule(JsonNode cond, BarSeries series, - Map> params) { + private Rule buildConditionRule(JsonNode cond, RuleBuildContext ctx) { if (cond == null || cond.isNull()) return new BooleanRule(false); + BarSeries series = ctx.primarySeries(); + Map> params = ctx.indicatorParams(); + String indType = cond.path("indicatorType").asText(""); String condType = cond.path("conditionType").asText("GT"); String leftField = cond.path("leftField").asText("NONE"); @@ -275,6 +277,10 @@ public class StrategyDslToTa4jAdapter { int slopePeriod = cond.path("slopePeriod").asInt(3); int holdDays = cond.path("holdDays").asInt(3); + if (needsCrossTimeframeComposite(cond, ctx)) { + return buildCrossTimeframeCompositeRule(cond, ctx); + } + // DSL 타입(STOCHASTIC 등) → DB 레지스트리 키(Stochastic 등) 변환 후 파라미터 조회 String registryKey = toRegistryKey(indType); Map indParams = params != null @@ -336,6 +342,104 @@ public class StrategyDslToTa4jAdapter { } } + /** 복합지표 — leftCandleType / rightCandleType 이 서로 다를 때 교차 시간봉 평가 */ + private boolean needsCrossTimeframeComposite(JsonNode cond, RuleBuildContext ctx) { + if (!cond.path("composite").asBoolean(false)) return false; + if (ctx.storage() == null || ctx.market() == null || ctx.market().isBlank()) return false; + String leftCt = LiveStrategyTimeframeService.normalize( + cond.path("leftCandleType").asText("1m")); + String rightCt = LiveStrategyTimeframeService.normalize( + cond.path("rightCandleType").asText("1m")); + return !leftCt.equals(rightCt); + } + + private Rule buildCrossTimeframeCompositeRule(JsonNode cond, RuleBuildContext ctx) { + BarSeries trigger = ctx.primarySeries(); + String indType = cond.path("indicatorType").asText(""); + String condType = cond.path("conditionType").asText("GT"); + String leftField = cond.path("leftField").asText("NONE"); + String rightField = cond.path("rightField").asText("NONE"); + int condPeriod = cond.path("period").asInt(-1); + int leftPeriod = cond.path("leftPeriod").asInt(-1); + int rightPeriod = cond.path("rightPeriod").asInt(-1); + + String registryKey = toRegistryKey(indType); + Map indParams = ctx.indicatorParams() != null + ? ctx.indicatorParams().getOrDefault(registryKey, Map.of()) + : Map.of(); + + String leftCt = LiveStrategyTimeframeService.normalize( + cond.path("leftCandleType").asText("1m")); + String rightCt = LiveStrategyTimeframeService.normalize( + cond.path("rightCandleType").asText("1m")); + BarSeries leftSeries = ctx.resolveSeries(leftCt); + BarSeries rightSeries = ctx.resolveSeries(rightCt); + + try { + Indicator left = resolveField(leftField, indType, indParams, leftSeries, condPeriod, leftPeriod); + Indicator right = resolveField(rightField, indType, indParams, rightSeries, condPeriod, rightPeriod); + return new CrossTimeframeCompositeRule(condType, left, right, trigger, leftSeries, rightSeries); + } catch (Exception e) { + log.warn("[Adapter] 복합 교차시간봉 빌드 실패 ind={} cond={}: {}", + indType, condType, e.getMessage()); + return new BooleanRule(false); + } + } + + private static int evalIndex(BarSeries trigger, BarSeries branch, int triggerIndex) { + if (trigger == branch) return triggerIndex; + int end = branch.getEndIndex(); + return end >= 0 ? end : 0; + } + + private static final class CrossTimeframeCompositeRule implements Rule { + private final String condType; + private final Indicator left; + private final Indicator right; + private final BarSeries triggerSeries; + private final BarSeries leftSeries; + private final BarSeries rightSeries; + + CrossTimeframeCompositeRule(String condType, + Indicator left, + Indicator right, + BarSeries triggerSeries, + BarSeries leftSeries, + BarSeries rightSeries) { + this.condType = condType; + this.left = left; + this.right = right; + this.triggerSeries = triggerSeries; + this.leftSeries = leftSeries; + this.rightSeries = rightSeries; + } + + @Override + public boolean isSatisfied(int index, TradingRecord tradingRecord) { + int li = evalIndex(triggerSeries, leftSeries, index); + int ri = evalIndex(triggerSeries, rightSeries, index); + if (li < 1 || ri < 1) return false; + + Num l0 = left.getValue(li - 1); + Num l1 = left.getValue(li); + Num r0 = right.getValue(ri - 1); + Num r1 = right.getValue(ri); + if (l0 == null || l1 == null || r0 == null || r1 == null) return false; + + return switch (condType) { + case "GT" -> l1.isGreaterThan(r1); + case "LT" -> l1.isLessThan(r1); + case "GTE" -> l1.isGreaterThan(r1) || l1.isEqual(r1); + case "LTE" -> l1.isLessThan(r1) || l1.isEqual(r1); + case "EQ" -> l1.isEqual(r1); + case "NEQ" -> !l1.isEqual(r1); + case "CROSS_UP" -> l0.isLessThanOrEqual(r0) && l1.isGreaterThan(r1); + case "CROSS_DOWN" -> l0.isGreaterThanOrEqual(r0) && l1.isLessThan(r1); + default -> false; + }; + } + } + // ── 필드 Resolver ───────────────────────────────────────────────────────── private Indicator resolveField(String field, String indType, diff --git a/frontend/src/App.css b/frontend/src/App.css index 3ec39ef..bc47e00 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -6363,6 +6363,12 @@ html.theme-blue { } .se-combo-num { width: 100%; + display: flex; + flex-direction: column; + gap: 6px; +} +.se-combo-num-select { + width: 100%; } .se-combo-num-input { width: 100%; diff --git a/frontend/src/components/MarketSearchPanel.tsx b/frontend/src/components/MarketSearchPanel.tsx index 7b310eb..4712b55 100644 --- a/frontend/src/components/MarketSearchPanel.tsx +++ b/frontend/src/components/MarketSearchPanel.tsx @@ -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 = ({ {rate === null ? '-' - : `${isUp ? '+' : ''}${rate.toFixed(2)}%`} + : `${isUp ? '+' : ''}${safeToFixed(rate, 2)}%`} {tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'} diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index 65d9add..e2c5c51 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -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(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(() => loadPaletteItems('auxiliary')); + const [compositePalette, setCompositePalette] = useState(() => loadPaletteItems('composite')); const [paletteSearch, setPaletteSearch] = useState(''); const [selectedPaletteKey, setSelectedPaletteKey] = useState(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 (
@@ -1085,45 +1084,65 @@ export default function StrategyEditorPage({ theme }: Props) { ))}
-
-

보조지표

-
- {indicatorItems.filter(i => match(i.label, i.desc)).map(item => ( - selectPalette('indicator', item.value)} - onAdd={() => applyPalette('indicator', item.value, item.label)} - /> - ))} -
-
-
-

복합지표

-
- {COMPOSITE_INDICATOR_ITEMS.filter(i => match(i.label, i.desc)).map(item => ( - selectPalette('composite', item.value)} - onAdd={() => applyPalette('indicator', item.value, item.label, true)} - /> - ))} -
+
+ +
+ {indicatorSubTab === 'auxiliary' ? ( + setSelectedPaletteKey(id ? paletteKey('auxiliary', id) : null)} + onAddToCanvas={item => { + selectPalette('auxiliary', item.id); + applyPaletteItem(item); + }} + /> + ) : ( + setSelectedPaletteKey(id ? paletteKey('composite', id) : null)} + onAddToCanvas={item => { + selectPalette('composite', item.id); + applyPaletteItem(item); + }} + /> + )} )} {rightTab === 'templates' && ( diff --git a/frontend/src/components/TradeOrderPanel.tsx b/frontend/src/components/TradeOrderPanel.tsx index 2028308..b02d512 100644 --- a/frontend/src/components/TradeOrderPanel.tsx +++ b/frontend/src/components/TradeOrderPanel.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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'); diff --git a/frontend/src/components/VirtualTradingPage.tsx b/frontend/src/components/VirtualTradingPage.tsx index 037d530..17bd76d 100644 --- a/frontend/src/components/VirtualTradingPage.tsx +++ b/frontend/src/components/VirtualTradingPage.tsx @@ -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 = ({ 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 = ({ 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={( = ({ side="buy" market={selectedMarket} tradePrice={tradePrice} - availableKrw={summary?.cashBalance ?? 0} + availableKrw={coerceFiniteNumber(summary?.cashBalance) ?? 0} fillRequest={fillBuy} searchAnchorRef={orderAnchorRef} onMarketSelect={handleSelectMarket} diff --git a/frontend/src/components/layout/BuilderPageShell.tsx b/frontend/src/components/layout/BuilderPageShell.tsx index e2b5796..8ad998d 100644 --- a/frontend/src/components/layout/BuilderPageShell.tsx +++ b/frontend/src/components/layout/BuilderPageShell.tsx @@ -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 ( + + ); +} + 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}
- - -
+ ) : null} + {collapsiblePanels && leftOpen ? ( +
+ ) : null} + {!collapsiblePanels ? ( + <> + +
+ + ) : null}
{centerHead &&
{centerHead}
} -
{center}
+
{center}
{footer && ( @@ -207,7 +336,30 @@ export default function BuilderPageShell({ )}
- {right && ( + {right && collapsiblePanels && rightOpen ? ( +
+ ) : null} + {right && (collapsiblePanels ? ( +
+ + +
+ ) : ( <>
{right}
- )} + ))}
diff --git a/frontend/src/components/paper/PaperCompactOrderbook.tsx b/frontend/src/components/paper/PaperCompactOrderbook.tsx index 3e576c5..dafc85f 100644 --- a/frontend/src/components/paper/PaperCompactOrderbook.tsx +++ b/frontend/src/components/paper/PaperCompactOrderbook.tsx @@ -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 = memo(({ diff --git a/frontend/src/components/strategyEditor/ComboFieldSelect.tsx b/frontend/src/components/strategyEditor/ComboFieldSelect.tsx new file mode 100644 index 0000000..237d595 --- /dev/null +++ b/frontend/src/components/strategyEditor/ComboFieldSelect.tsx @@ -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 ( +
+ + {mode === 'custom' && canCustom && ( + + )} +
+ ); +} diff --git a/frontend/src/components/strategyEditor/ComboNumberInput.tsx b/frontend/src/components/strategyEditor/ComboNumberInput.tsx index 951808b..3db9a05 100644 --- a/frontend/src/components/strategyEditor/ComboNumberInput.tsx +++ b/frontend/src/components/strategyEditor/ComboNumberInput.tsx @@ -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 ? ( + 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 ( -
- 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(); - } - }} - /> - +
+ {alwaysShowInput ? inputEl : null} + + {!alwaysShowInput ? inputEl : null}
); } diff --git a/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx b/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx index e76aae5..b684c17 100644 --- a/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx +++ b/frontend/src/components/strategyEditor/ConditionNodeSettings.tsx @@ -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({
{condition.composite ? ( <> + +
diff --git a/frontend/src/components/virtual/VirtualTargetCard.tsx b/frontend/src/components/virtual/VirtualTargetCard.tsx index 428bbf0..0598432 100644 --- a/frontend/src/components/virtual/VirtualTargetCard.tsx +++ b/frontend/src/components/virtual/VirtualTargetCard.tsx @@ -111,9 +111,9 @@ const VirtualTargetCard: React.FC = ({ {sym}