전략편집기 복합지표 기능 반영
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
* 실시간 호가 — 증권앱형 통합 레이아웃
|
||||
* 상단 시세 · 서브탭 · 매도/현재가/매수 테이블 · 우측 시세 · 좌측 체결 · 하단 합계
|
||||
*/
|
||||
import React, { memo, useState, useEffect } from 'react';
|
||||
import React, { memo, useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import { formatNowClock, useDisplayTimezone } from '../utils/timezone';
|
||||
import type { OrderbookDisplayUnit, WsStatus } from '../hooks/useUpbitOrderbook';
|
||||
import type { RecentTrade } from '../hooks/useUpbitRecentTrades';
|
||||
@@ -35,16 +35,139 @@ function fmtPct(price: number, prevClose: number): string {
|
||||
return `${rate >= 0 ? '+' : ''}${rate.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function fmtTotalAmount(a: number): string {
|
||||
if (a == null || !isFinite(a)) return '-';
|
||||
return a.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
/** 하단 합계·호가 잔량 표시 모드 — 기본 총액(KRW) */
|
||||
type SummaryDisplayMode = 'amount' | 'quantity';
|
||||
|
||||
function unitDisplayValue(unit: OrderbookDisplayUnit, mode: SummaryDisplayMode): number {
|
||||
return mode === 'amount' ? unit.price * unit.size : unit.size;
|
||||
}
|
||||
|
||||
function formatDisplayValue(value: number, mode: SummaryDisplayMode): string {
|
||||
return mode === 'amount' ? fmtTotalAmount(value) : fmtSize(value);
|
||||
}
|
||||
|
||||
function fmtTotalSize(s: number): string {
|
||||
if (s >= 1_000) return s.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
|
||||
if (s >= 1) return s.toFixed(2);
|
||||
return s.toFixed(4);
|
||||
}
|
||||
|
||||
function SummaryBar({
|
||||
asks,
|
||||
bids,
|
||||
totalAskSize,
|
||||
totalBidSize,
|
||||
clock,
|
||||
mode,
|
||||
onModeChange,
|
||||
}: {
|
||||
asks: OrderbookDisplayUnit[];
|
||||
bids: OrderbookDisplayUnit[];
|
||||
totalAskSize: number;
|
||||
totalBidSize: number;
|
||||
clock: string;
|
||||
mode: SummaryDisplayMode;
|
||||
onModeChange: (mode: SummaryDisplayMode) => void;
|
||||
}) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const totalAskAmount = useMemo(
|
||||
() => asks.reduce((sum, u) => sum + u.price * u.size, 0),
|
||||
[asks],
|
||||
);
|
||||
const totalBidAmount = useMemo(
|
||||
() => bids.reduce((sum, u) => sum + u.price * u.size, 0),
|
||||
[bids],
|
||||
);
|
||||
|
||||
const askDisplay = mode === 'amount'
|
||||
? fmtTotalAmount(totalAskAmount)
|
||||
: fmtTotalSize(totalAskSize);
|
||||
const bidDisplay = mode === 'amount'
|
||||
? fmtTotalAmount(totalBidAmount)
|
||||
: fmtTotalSize(totalBidSize);
|
||||
|
||||
const closeMenu = useCallback(() => setMenuOpen(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
|
||||
closeMenu();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
return () => document.removeEventListener('mousedown', onDoc);
|
||||
}, [menuOpen, closeMenu]);
|
||||
|
||||
const pickMode = (next: SummaryDisplayMode) => {
|
||||
onModeChange(next);
|
||||
closeMenu();
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="ob-summary-bar">
|
||||
<div className="ob-summary ob-summary--ask">
|
||||
<span className="ob-summary-num">{askDisplay}</span>
|
||||
</div>
|
||||
<div className="ob-summary ob-summary--time ob-summary-time-wrap" ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="ob-summary-time-btn"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={() => setMenuOpen(o => !o)}
|
||||
>
|
||||
<span>{clock}</span>
|
||||
<span className="ob-summary-time-caret" aria-hidden>▾</span>
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="ob-summary-mode-menu" role="menu">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={mode === 'quantity'}
|
||||
className={`ob-summary-mode-item${mode === 'quantity' ? ' ob-summary-mode-item--active' : ''}`}
|
||||
onClick={() => pickMode('quantity')}
|
||||
>
|
||||
수량
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={mode === 'amount'}
|
||||
className={`ob-summary-mode-item${mode === 'amount' ? ' ob-summary-mode-item--active' : ''}`}
|
||||
onClick={() => pickMode('amount')}
|
||||
>
|
||||
총액
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ob-summary ob-summary--bid">
|
||||
<span className="ob-summary-num">{bidDisplay}</span>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function coinCode(market: string): string {
|
||||
return market.replace(/^KRW-/, '');
|
||||
}
|
||||
|
||||
/** 호가 가격과 현재가(체결가) 일치 여부 */
|
||||
function isLivePrice(levelPrice: number, currentPrice: number): boolean {
|
||||
if (levelPrice <= 0 || currentPrice <= 0) return false;
|
||||
const tol = Math.max(1e-8, Math.abs(currentPrice) * 1e-6);
|
||||
return Math.abs(levelPrice - currentPrice) <= tol;
|
||||
}
|
||||
|
||||
function pctClass(pct: string): string {
|
||||
if (pct.startsWith('+')) return 'ob-td-pct--up';
|
||||
if (pct.startsWith('-')) return 'ob-td-pct--dn';
|
||||
@@ -72,22 +195,28 @@ const COLGROUP = (
|
||||
);
|
||||
|
||||
const AskTableRow = memo(function AskTableRow({
|
||||
unit, prevClose, onClick,
|
||||
unit, prevClose, displayMode, maxDisplay, currentPrice, onClick,
|
||||
}: {
|
||||
unit: OrderbookDisplayUnit;
|
||||
prevClose: number;
|
||||
displayMode: SummaryDisplayMode;
|
||||
maxDisplay: number;
|
||||
currentPrice: number;
|
||||
onClick?: (price: number, type: 'ask' | 'bid') => void;
|
||||
}) {
|
||||
const pct = fmtPct(unit.price, prevClose);
|
||||
const displayVal = unitDisplayValue(unit, displayMode);
|
||||
const barPct = maxDisplay > 0 ? (displayVal / maxDisplay) * 100 : 0;
|
||||
const isLive = isLivePrice(unit.price, currentPrice);
|
||||
return (
|
||||
<tr className={onClick ? 'ob-tr--clickable' : undefined} onClick={onClick ? () => onClick(unit.price, 'ask') : undefined}>
|
||||
<td className="ob-td ob-td-qty ob-td-qty--ask">
|
||||
<div className="ob-td-bar-wrap">
|
||||
<div className="ob-td-bar ob-td-bar--ask" style={{ width: `${Math.min(unit.percentage, 100)}%` }} />
|
||||
<span className="ob-td-bar-text">{fmtSize(unit.size)}</span>
|
||||
<div className="ob-td-bar ob-td-bar--ask" style={{ width: `${Math.min(barPct, 100)}%` }} />
|
||||
<span className="ob-td-bar-text">{formatDisplayValue(displayVal, displayMode)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="ob-td ob-td-price">{fmtPrice(unit.price)}</td>
|
||||
<td className={`ob-td ob-td-price${isLive ? ' ob-td-price--live' : ''}`}>{fmtPrice(unit.price)}</td>
|
||||
<td className={`ob-td ob-td-pct ${pctClass(pct)}`}>{pct}</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -95,21 +224,27 @@ const AskTableRow = memo(function AskTableRow({
|
||||
|
||||
/** 가격은 항상 가운데 열 — 매수: 전일대비(좌) | 가격 | 잔량(우) */
|
||||
const BidTableRow = memo(function BidTableRow({
|
||||
unit, prevClose, onClick,
|
||||
unit, prevClose, displayMode, maxDisplay, currentPrice, onClick,
|
||||
}: {
|
||||
unit: OrderbookDisplayUnit;
|
||||
prevClose: number;
|
||||
displayMode: SummaryDisplayMode;
|
||||
maxDisplay: number;
|
||||
currentPrice: number;
|
||||
onClick?: (price: number, type: 'ask' | 'bid') => void;
|
||||
}) {
|
||||
const pct = fmtPct(unit.price, prevClose);
|
||||
const displayVal = unitDisplayValue(unit, displayMode);
|
||||
const barPct = maxDisplay > 0 ? (displayVal / maxDisplay) * 100 : 0;
|
||||
const isLive = isLivePrice(unit.price, currentPrice);
|
||||
return (
|
||||
<tr className={onClick ? 'ob-tr--clickable' : undefined} onClick={onClick ? () => onClick(unit.price, 'bid') : undefined}>
|
||||
<td className={`ob-td ob-td-pct ob-td-pct--left ${pctClass(pct)}`}>{pct}</td>
|
||||
<td className="ob-td ob-td-price">{fmtPrice(unit.price)}</td>
|
||||
<td className={`ob-td ob-td-price${isLive ? ' ob-td-price--live' : ''}`}>{fmtPrice(unit.price)}</td>
|
||||
<td className="ob-td ob-td-qty ob-td-qty--bid">
|
||||
<div className="ob-td-bar-wrap ob-td-bar-wrap--right">
|
||||
<div className="ob-td-bar ob-td-bar--bid" style={{ width: `${Math.min(unit.percentage, 100)}%` }} />
|
||||
<span className="ob-td-bar-text">{fmtSize(unit.size)}</span>
|
||||
<div className="ob-td-bar ob-td-bar--bid" style={{ width: `${Math.min(barPct, 100)}%` }} />
|
||||
<span className="ob-td-bar-text">{formatDisplayValue(displayVal, displayMode)}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -257,10 +392,15 @@ export const OrderbookPanel = memo(function OrderbookPanel({
|
||||
prevClose, tickerInfo, recentTrades = [], tradeStrength = null, onRowClick,
|
||||
}: OrderbookPanelProps) {
|
||||
const isEmpty = asks.length === 0 && bids.length === 0;
|
||||
const midPrice = tickerInfo?.tradePrice ?? 0;
|
||||
const midPct = midPrice > 0 ? fmtPct(midPrice, prevClose) : '';
|
||||
const currentPrice = tickerInfo?.tradePrice ?? 0;
|
||||
const clock = useClock();
|
||||
const isUp = (tickerInfo?.changeRate ?? 0) >= 0;
|
||||
const [summaryMode, setSummaryMode] = useState<SummaryDisplayMode>('amount');
|
||||
|
||||
const maxDisplay = useMemo(() => {
|
||||
const all = [...asks, ...bids];
|
||||
if (all.length === 0) return 0;
|
||||
return Math.max(...all.map(u => unitDisplayValue(u, summaryMode)), 0);
|
||||
}, [asks, bids, summaryMode]);
|
||||
|
||||
return (
|
||||
<div className="ob-panel ob-panel--classic">
|
||||
@@ -301,7 +441,15 @@ export const OrderbookPanel = memo(function OrderbookPanel({
|
||||
{COLGROUP}
|
||||
<tbody>
|
||||
{asks.map((unit, i) => (
|
||||
<AskTableRow key={`ask-${unit.price}-${i}`} unit={unit} prevClose={prevClose} onClick={onRowClick} />
|
||||
<AskTableRow
|
||||
key={`ask-${unit.price}-${i}`}
|
||||
unit={unit}
|
||||
prevClose={prevClose}
|
||||
displayMode={summaryMode}
|
||||
maxDisplay={maxDisplay}
|
||||
currentPrice={currentPrice}
|
||||
onClick={onRowClick}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -310,21 +458,6 @@ export const OrderbookPanel = memo(function OrderbookPanel({
|
||||
{tickerInfo && <StatsRail tickerInfo={tickerInfo} prevClose={prevClose} />}
|
||||
</div>
|
||||
|
||||
{/* 현재가 (가운데 열 정렬) */}
|
||||
<table className="ob-table ob-table--hoga ob-table--mid">
|
||||
{COLGROUP}
|
||||
<tbody>
|
||||
<tr className="ob-tr-mid">
|
||||
<td className="ob-td ob-td--pad" />
|
||||
<td className={`ob-td ob-td-price ob-td-price--current ${isUp ? 'ob-td-price--up' : 'ob-td-price--dn'}`}>
|
||||
<span className="ob-mid-price">{midPrice > 0 ? fmtPrice(midPrice) : '-'}</span>
|
||||
{midPct && <span className={`ob-mid-pct ${pctClass(midPct)}`}>{midPct}</span>}
|
||||
</td>
|
||||
<td className="ob-td ob-td--pad" />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 매수 구간 + 좌측 체결 */}
|
||||
<div className="ob-block ob-block--bid">
|
||||
<TradesPanel trades={recentTrades} strength={tradeStrength} />
|
||||
@@ -334,7 +467,15 @@ export const OrderbookPanel = memo(function OrderbookPanel({
|
||||
{COLGROUP}
|
||||
<tbody>
|
||||
{bids.map((unit, i) => (
|
||||
<BidTableRow key={`bid-${unit.price}-${i}`} unit={unit} prevClose={prevClose} onClick={onRowClick} />
|
||||
<BidTableRow
|
||||
key={`bid-${unit.price}-${i}`}
|
||||
unit={unit}
|
||||
prevClose={prevClose}
|
||||
displayMode={summaryMode}
|
||||
maxDisplay={maxDisplay}
|
||||
currentPrice={currentPrice}
|
||||
onClick={onRowClick}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -345,15 +486,15 @@ export const OrderbookPanel = memo(function OrderbookPanel({
|
||||
)}
|
||||
|
||||
{!isEmpty && (
|
||||
<footer className="ob-summary-bar">
|
||||
<div className="ob-summary ob-summary--ask">
|
||||
<span className="ob-summary-num">{fmtTotalSize(totalAskSize)}</span>
|
||||
</div>
|
||||
<div className="ob-summary ob-summary--time">{clock}</div>
|
||||
<div className="ob-summary ob-summary--bid">
|
||||
<span className="ob-summary-num">{fmtTotalSize(totalBidSize)}</span>
|
||||
</div>
|
||||
</footer>
|
||||
<SummaryBar
|
||||
asks={asks}
|
||||
bids={bids}
|
||||
totalAskSize={totalAskSize}
|
||||
totalBidSize={totalBidSize}
|
||||
clock={clock}
|
||||
mode={summaryMode}
|
||||
onModeChange={setSummaryMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { ReactFlowProvider } from '@xyflow/react';
|
||||
import DraggableModalFrame from './DraggableModalFrame';
|
||||
import type { Theme, IndicatorConfig } from '../types/index';
|
||||
import type { Theme } from '../types/index';
|
||||
import { loadStrategies, saveStrategy, deleteStrategy, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
|
||||
import type { LogicNode } from '../utils/strategyTypes';
|
||||
import {
|
||||
buildDef,
|
||||
buildStrategyEditorDef,
|
||||
loadStratsLocal,
|
||||
saveStratsLocal,
|
||||
mergeAtRoot,
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
type StrategyDto,
|
||||
} from '../utils/strategyEditorShared';
|
||||
import { findLogicNode, getIndicatorPeriodLabel } from '../utils/strategyFlowLayout';
|
||||
import {
|
||||
COMPOSITE_INDICATOR_ITEMS,
|
||||
compositePeriodLabel,
|
||||
} from '../utils/compositeIndicators';
|
||||
import {
|
||||
emptySignalFlowLayout,
|
||||
loadStrategyFlowLayout,
|
||||
@@ -66,7 +70,6 @@ const TERMINAL_DEFAULT = 140;
|
||||
|
||||
interface Props {
|
||||
theme: Theme;
|
||||
activeIndicators?: IndicatorConfig[];
|
||||
}
|
||||
|
||||
function readTabLayout(strategyKey: string, tab: 'buy' | 'sell'): SignalFlowLayoutSnapshot {
|
||||
@@ -80,8 +83,8 @@ function readTabLayout(strategyKey: string, tab: 'buy' | 'sell'): SignalFlowLayo
|
||||
};
|
||||
}
|
||||
|
||||
export default function StrategyEditorPage({ theme, activeIndicators = [] }: Props) {
|
||||
const DEF = useMemo(() => buildDef(activeIndicators), [activeIndicators]);
|
||||
export default function StrategyEditorPage({ theme }: Props) {
|
||||
const DEF = useMemo(() => buildStrategyEditorDef(), []);
|
||||
|
||||
const [strategies, setStrategies] = useState<StrategyDto[]>(() => loadStratsLocal());
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
@@ -511,8 +514,8 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyPalette = useCallback((type: string, value: string, label: string) => {
|
||||
const newNode = makeNode(type, value, signalTab, DEF);
|
||||
const applyPalette = useCallback((type: string, value: string, _label: string, composite = false) => {
|
||||
const newNode = makeNode(type, value, signalTab, DEF, composite ? { composite: true } : undefined);
|
||||
const root = currentRoot;
|
||||
if (!root) setCurrentRoot(newNode);
|
||||
else if (type === 'operator') setCurrentRoot(mergeAtRoot(root, newNode, true));
|
||||
@@ -576,11 +579,11 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
const match = (label: string, desc?: string) =>
|
||||
!q || label.toLowerCase().includes(q) || (desc?.toLowerCase().includes(q) ?? false);
|
||||
|
||||
const paletteKey = (type: 'operator' | 'indicator', value: string) => `${type}:${value}`;
|
||||
const selectPalette = (type: 'operator' | 'indicator', value: string) => {
|
||||
const paletteKey = (type: 'operator' | 'indicator' | 'composite', value: string) => `${type}:${value}`;
|
||||
const selectPalette = (type: 'operator' | 'indicator' | 'composite', value: string) => {
|
||||
setSelectedPaletteKey(paletteKey(type, value));
|
||||
};
|
||||
const isPaletteSelected = (type: 'operator' | 'indicator', value: string) =>
|
||||
const isPaletteSelected = (type: 'operator' | 'indicator' | 'composite', value: string) =>
|
||||
selectedPaletteKey === paletteKey(type, value);
|
||||
|
||||
return (
|
||||
@@ -780,7 +783,7 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="se-sync-tip">차트 설정 동기화 · 기간 {getIndicatorPeriodLabel(selectedLogicNode.condition.indicatorType, DEF) || '—'}</span>
|
||||
<span className="se-sync-tip">전략 조건 전용 설정 · 차트 보조지표와 무관</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -846,7 +849,7 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
</div>
|
||||
<div className="se-palette-section se-palette-section--band">
|
||||
<h3>밴드 · 추세</h3>
|
||||
<div className="se-palette-grid">
|
||||
<div className="se-palette-grid se-palette-grid--3">
|
||||
{maBandItems.filter(i => match(i.label, i.desc)).map(item => (
|
||||
<PaletteChip
|
||||
key={item.value}
|
||||
@@ -859,9 +862,9 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="se-palette-section se-palette-section--aux se-palette-section--scroll">
|
||||
<div className="se-palette-section se-palette-section--aux">
|
||||
<h3>보조지표</h3>
|
||||
<div className="se-palette-grid">
|
||||
<div className="se-palette-grid se-palette-grid--3">
|
||||
{indicatorItems.filter(i => match(i.label, i.desc)).map(item => (
|
||||
<PaletteChip
|
||||
key={item.value}
|
||||
@@ -878,6 +881,26 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro
|
||||
))}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
{rightTab === 'templates' && (
|
||||
|
||||
@@ -323,38 +323,41 @@ const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="top-field">
|
||||
<label className="top-label">{priceLabel} (KRW)</label>
|
||||
<div className="top-input-group">
|
||||
<div className="top-price-qty-block">
|
||||
<div className="top-field top-field--price">
|
||||
<label className="top-label">{priceLabel} (KRW)</label>
|
||||
<div className="top-input-group">
|
||||
<input
|
||||
type="text"
|
||||
className="top-input"
|
||||
value={priceStr}
|
||||
disabled={orderKind === 'market'}
|
||||
onChange={handlePriceChange}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9,]*"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button type="button" className="top-step" onClick={() => bumpPrice(-step)} disabled={orderKind === 'market'}>−</button>
|
||||
<button type="button" className="top-step" onClick={() => bumpPrice(step)} disabled={orderKind === 'market'}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="top-field top-field--qty">
|
||||
<label className="top-label">주문수량 ({code})</label>
|
||||
<input
|
||||
type="text"
|
||||
className="top-input"
|
||||
value={priceStr}
|
||||
className="top-input top-input--full"
|
||||
value={qtyStr}
|
||||
disabled={orderKind === 'market'}
|
||||
onChange={handlePriceChange}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9,]*"
|
||||
onChange={handleQtyChange}
|
||||
inputMode="decimal"
|
||||
pattern="[0-9.]*"
|
||||
autoComplete="off"
|
||||
placeholder="0"
|
||||
/>
|
||||
<button type="button" className="top-step" onClick={() => bumpPrice(-step)} disabled={orderKind === 'market'}>−</button>
|
||||
<button type="button" className="top-step" onClick={() => bumpPrice(step)} disabled={orderKind === 'market'}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="top-field">
|
||||
<label className="top-label">주문수량 ({code})</label>
|
||||
<input
|
||||
type="text"
|
||||
className="top-input top-input--full"
|
||||
value={qtyStr}
|
||||
disabled={orderKind === 'market'}
|
||||
onChange={handleQtyChange}
|
||||
inputMode="decimal"
|
||||
pattern="[0-9.]*"
|
||||
autoComplete="off"
|
||||
placeholder="0"
|
||||
/>
|
||||
<div className="top-pct-row">
|
||||
<div className="top-pct-row top-pct-row--block">
|
||||
{PCT_BTNS.map(p => (
|
||||
<button
|
||||
key={p}
|
||||
|
||||
@@ -154,6 +154,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
const prevChartType = useRef<ChartType>(chartType);
|
||||
const prevTheme = useRef<Theme>(theme);
|
||||
const prevLogScale = useRef<boolean>(logScale);
|
||||
const indicatorsRef = useRef(indicators);
|
||||
const recoveryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
|
||||
/** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */
|
||||
@@ -163,6 +165,10 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
barsRef.current = bars;
|
||||
}, [bars]);
|
||||
|
||||
useEffect(() => {
|
||||
indicatorsRef.current = indicators;
|
||||
}, [indicators]);
|
||||
|
||||
const toggleCandleOnly = useCallback(() => {
|
||||
setCandleOnlyMode(v => !v);
|
||||
}, []);
|
||||
@@ -259,6 +265,49 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
const reloadSafetyTimers = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
const reloadGenRef = useRef(0);
|
||||
const reloadRetryRef = useRef(0);
|
||||
const reloadAllRef = useRef<(
|
||||
mgr: ChartManager,
|
||||
newBars: OHLCVBar[],
|
||||
ct: ChartType,
|
||||
th: Theme,
|
||||
ls: boolean,
|
||||
inds: IndicatorConfig[],
|
||||
) => Promise<void>>(async () => {});
|
||||
|
||||
const queueIndicatorRecovery = useCallback(() => {
|
||||
if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current);
|
||||
recoveryTimerRef.current = window.setTimeout(() => {
|
||||
recoveryTimerRef.current = null;
|
||||
const m = managerRef.current;
|
||||
const barData = barsRef.current;
|
||||
const inds = indicatorsRef.current;
|
||||
if (!m || barData.length === 0) return;
|
||||
if (barsMarket !== undefined && barsMarket !== market) return;
|
||||
|
||||
const expected = countExpectedVisibleIndicators(inds);
|
||||
if (expected === 0) return;
|
||||
if (m.getLoadedIndicatorCount() >= expected) return;
|
||||
|
||||
prevBarsKey.current = '';
|
||||
const ct = prevChartType.current;
|
||||
const th = prevTheme.current;
|
||||
const ls = prevLogScale.current;
|
||||
|
||||
if (m.hasMainSeries()) {
|
||||
void m.reloadIndicatorsOnly(inds).then(() => {
|
||||
if (m.getLoadedIndicatorCount() >= expected) {
|
||||
prevBarsKey.current = barsKey(barData, market);
|
||||
prevIndKey.current = indKey(inds);
|
||||
applyPaneLayout(m);
|
||||
return;
|
||||
}
|
||||
reloadAllRef.current?.(m, barData, ct, th, ls, inds);
|
||||
});
|
||||
return;
|
||||
}
|
||||
reloadAllRef.current?.(m, barData, ct, th, ls, inds);
|
||||
}, 120);
|
||||
}, [market, barsMarket, applyPaneLayout]);
|
||||
|
||||
const reloadAll = useCallback(async (
|
||||
mgr: ChartManager,
|
||||
@@ -280,6 +329,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
mgr.setData(newBars, ct, th);
|
||||
if (gen !== reloadGenRef.current) {
|
||||
prevBarsKey.current = '';
|
||||
queueIndicatorRecovery();
|
||||
return;
|
||||
}
|
||||
mgr.setTheme(th);
|
||||
@@ -289,6 +339,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
for (const ind of inds) {
|
||||
if (gen !== reloadGenRef.current) {
|
||||
prevBarsKey.current = '';
|
||||
queueIndicatorRecovery();
|
||||
return;
|
||||
}
|
||||
await mgr.addIndicator(ind);
|
||||
@@ -296,21 +347,30 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
|
||||
if (gen !== reloadGenRef.current) {
|
||||
prevBarsKey.current = '';
|
||||
queueIndicatorRecovery();
|
||||
return;
|
||||
}
|
||||
|
||||
const expectIndicators = inds.some(i => i.hidden !== true);
|
||||
if (expectIndicators && mgr.getIndicatorCount() === 0) {
|
||||
const expected = countExpectedVisibleIndicators(inds);
|
||||
const loaded = mgr.getLoadedIndicatorCount();
|
||||
|
||||
if (expected > 0 && loaded < expected) {
|
||||
prevBarsKey.current = '';
|
||||
if (reloadRetryRef.current < 3) {
|
||||
if (reloadRetryRef.current < 4 && mgr.hasMainSeries()) {
|
||||
reloadRetryRef.current += 1;
|
||||
requestAnimationFrame(() => {
|
||||
if (managerRef.current) {
|
||||
void reloadAll(managerRef.current, newBars, ct, th, ls, inds);
|
||||
}
|
||||
});
|
||||
await mgr.reloadIndicatorsOnly(inds);
|
||||
if (gen !== reloadGenRef.current) {
|
||||
queueIndicatorRecovery();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (mgr.getLoadedIndicatorCount() < expected) {
|
||||
if (reloadRetryRef.current < 6) {
|
||||
reloadRetryRef.current += 1;
|
||||
queueIndicatorRecovery();
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
reloadRetryRef.current = 0;
|
||||
@@ -332,6 +392,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
|
||||
if (gen !== reloadGenRef.current) {
|
||||
prevBarsKey.current = '';
|
||||
queueIndicatorRecovery();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -341,6 +402,12 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
setTimeout(() => {
|
||||
const m = managerRef.current;
|
||||
if (!m || !m.hasMainSeries()) return;
|
||||
const expectedLater = countExpectedVisibleIndicators(indicatorsRef.current);
|
||||
if (expectedLater > 0 && m.getLoadedIndicatorCount() < expectedLater) {
|
||||
prevBarsKey.current = '';
|
||||
queueIndicatorRecovery();
|
||||
return;
|
||||
}
|
||||
const w = wrapperRef.current;
|
||||
if (!w || w.clientHeight <= 0) return;
|
||||
m.resetPaneHeights(w.clientHeight);
|
||||
@@ -349,7 +416,11 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
if (delay === 1400) onDataLoaded?.();
|
||||
}, delay)
|
||||
);
|
||||
}, [applyPaneLayout, onDataLoaded, displayTimezone, timeframe, market]);
|
||||
}, [applyPaneLayout, onDataLoaded, displayTimezone, timeframe, market, queueIndicatorRecovery]);
|
||||
|
||||
useEffect(() => {
|
||||
reloadAllRef.current = reloadAll;
|
||||
}, [reloadAll]);
|
||||
|
||||
// ── 차트 초기화 (마운트 시 1회) ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
@@ -633,6 +704,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
}
|
||||
reloadSafetyTimers.current.forEach(clearTimeout);
|
||||
reloadSafetyTimers.current = [];
|
||||
if (recoveryTimerRef.current) clearTimeout(recoveryTimerRef.current);
|
||||
ro.disconnect();
|
||||
managerRef.current?.destroy();
|
||||
managerRef.current = null;
|
||||
@@ -682,10 +754,11 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
const ctChanged = chartType !== prevChartType.current;
|
||||
const thChanged = theme !== prevTheme.current;
|
||||
const lsChanged = logScale !== prevLogScale.current;
|
||||
const expectIndicators = indicators.some(i => i.hidden !== true);
|
||||
const indicatorsIncomplete = expectIndicators
|
||||
const expectIndicators = countExpectedVisibleIndicators(indicators);
|
||||
const loadedIndicators = mgr.getLoadedIndicatorCount();
|
||||
const indicatorsIncomplete = expectIndicators > 0
|
||||
&& mgr.hasMainSeries()
|
||||
&& mgr.getIndicatorCount() === 0;
|
||||
&& loadedIndicators < expectIndicators;
|
||||
|
||||
if (!barsChanged && !indChanged && !ctChanged && !thChanged && !lsChanged && !indicatorsIncomplete) return;
|
||||
|
||||
@@ -778,18 +851,24 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
if (!mgr || !chartMgr || bars.length === 0) return;
|
||||
if (barsMarket !== undefined && barsMarket !== market) return;
|
||||
|
||||
const expectIndicators = indicators.some(i => i.hidden !== true);
|
||||
if (!expectIndicators) return;
|
||||
const expected = countExpectedVisibleIndicators(indicators);
|
||||
if (expected === 0) return;
|
||||
if (mgr.getLoadedIndicatorCount() >= expected) return;
|
||||
|
||||
const t = window.setTimeout(() => {
|
||||
const m = managerRef.current;
|
||||
if (!m || !m.hasMainSeries() || m.getIndicatorCount() > 0) return;
|
||||
prevBarsKey.current = '';
|
||||
void reloadAll(m, bars, chartType, theme, logScale, indicators);
|
||||
}, 120);
|
||||
const timers = [120, 400, 900].map(delay =>
|
||||
window.setTimeout(() => {
|
||||
const m = managerRef.current;
|
||||
if (!m || !m.hasMainSeries()) return;
|
||||
if (barsMarket !== undefined && barsMarket !== market) return;
|
||||
const exp = countExpectedVisibleIndicators(indicatorsRef.current);
|
||||
if (exp === 0 || m.getLoadedIndicatorCount() >= exp) return;
|
||||
prevBarsKey.current = '';
|
||||
queueIndicatorRecovery();
|
||||
}, delay),
|
||||
);
|
||||
|
||||
return () => window.clearTimeout(t);
|
||||
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, reloadAll]);
|
||||
return () => { timers.forEach(clearTimeout); };
|
||||
}, [chartMgr, bars, barsMarket, market, chartType, theme, logScale, indicators, queueIndicatorRecovery]);
|
||||
|
||||
// cursor 모드에서 드로잉 위에 있으면 pointer 커서로 변경
|
||||
const handleWrapperMouseMove = useCallback((e: React.PointerEvent) => {
|
||||
@@ -988,10 +1067,15 @@ const PaneLegendPortal: React.FC<
|
||||
};
|
||||
|
||||
// ── 변경 감지용 키 생성 헬퍼 ────────────────────────────────────────────────
|
||||
function countExpectedVisibleIndicators(inds: IndicatorConfig[]): number {
|
||||
return inds.filter(i => i.hidden !== true).length;
|
||||
}
|
||||
|
||||
function barsKey(bars: OHLCVBar[], market = ''): string {
|
||||
if (bars.length === 0) return '';
|
||||
const last = bars[bars.length - 1];
|
||||
return `${market}:${bars.length}:${bars[0].time}:${last.time}:${last.close}`;
|
||||
// last.close 제외 — 틱마다 full reload 방지 (실시간은 updateBar/appendBar 경로)
|
||||
return `${market}:${bars.length}:${bars[0].time}:${last.time}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { useEffect, useId, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
value: number;
|
||||
options: number[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
allowDecimal?: boolean;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
function sanitizeDraft(raw: string, allowDecimal: boolean): string {
|
||||
if (allowDecimal) {
|
||||
let s = raw.replace(/[^\d.-]/g, '');
|
||||
const minus = s.startsWith('-') ? '-' : '';
|
||||
s = s.replace(/-/g, '');
|
||||
const dot = s.indexOf('.');
|
||||
if (dot !== -1) {
|
||||
s = s.slice(0, dot + 1) + s.slice(dot + 1).replace(/\./g, '');
|
||||
}
|
||||
return minus + s;
|
||||
}
|
||||
return raw.replace(/\D/g, '');
|
||||
}
|
||||
|
||||
function parseDraft(raw: string, allowDecimal: boolean): number | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed || trimmed === '-' || trimmed === '.') return null;
|
||||
const n = allowDecimal ? parseFloat(trimmed) : parseInt(trimmed, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export default function ComboNumberInput({
|
||||
value,
|
||||
options,
|
||||
min = 1,
|
||||
max = 500,
|
||||
allowDecimal = false,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const listId = useId().replace(/:/g, '');
|
||||
const [draft, setDraft] = useState(String(value));
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focused) setDraft(String(value));
|
||||
}, [value, focused]);
|
||||
|
||||
const commit = () => {
|
||||
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)));
|
||||
onChange(clamped);
|
||||
setDraft(String(clamped));
|
||||
};
|
||||
|
||||
const uniqueOptions = [...new Set([...options, value])].sort((a, b) => a - b);
|
||||
|
||||
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}>
|
||||
{uniqueOptions.map(opt => (
|
||||
<option key={opt} value={String(opt)} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import type { ConditionDSL } from '../../utils/strategyTypes';
|
||||
import type { DefType } from '../../utils/strategyEditorShared';
|
||||
import {
|
||||
getConditionRightPeriod,
|
||||
getConditionThreshold,
|
||||
getConditionValuePeriod,
|
||||
getCompositePeriodPresetOptions,
|
||||
getPeriodPresetOptions,
|
||||
getThresholdBounds,
|
||||
getThresholdPresetOptions,
|
||||
hasEditableThreshold,
|
||||
setConditionRightPeriod,
|
||||
setConditionThreshold,
|
||||
setConditionValuePeriod,
|
||||
usesValuePeriodField,
|
||||
} from '../../utils/conditionPeriods';
|
||||
import { compositeDisplayName, compositeElementLabel } from '../../utils/compositeIndicators';
|
||||
import ComboNumberInput from './ComboNumberInput';
|
||||
|
||||
interface Props {
|
||||
condition: ConditionDSL;
|
||||
def: DefType;
|
||||
onChange: (c: ConditionDSL) => void;
|
||||
onClose: () => void;
|
||||
popoverClassName?: string;
|
||||
}
|
||||
|
||||
export default function ConditionNodeSettings({
|
||||
condition, def, onChange, onClose, popoverClassName = 'se-flow-settings-pop',
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||
};
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
return () => document.removeEventListener('mousedown', onDoc);
|
||||
}, [onClose]);
|
||||
|
||||
const rightThreshold = getConditionThreshold(condition, 'right');
|
||||
const showThreshold = hasEditableThreshold(condition) && rightThreshold != null;
|
||||
const periodPresets = getPeriodPresetOptions(condition.indicatorType, def);
|
||||
const thresholdPresets = getThresholdPresetOptions(condition.indicatorType);
|
||||
const thresholdBounds = getThresholdBounds(condition.indicatorType);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={popoverClassName}
|
||||
role="dialog"
|
||||
aria-label="지표 설정"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="se-flow-settings-head">
|
||||
<span>지표 설정</span>
|
||||
<button
|
||||
type="button"
|
||||
className="se-flow-settings-close"
|
||||
aria-label="닫기"
|
||||
title="닫기"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{condition.composite ? (
|
||||
<>
|
||||
<label className="se-flow-settings-field">
|
||||
<span>{compositeElementLabel(condition.indicatorType, 1)} 기준값 (기간, 일)</span>
|
||||
<ComboNumberInput
|
||||
key={`composite-left-${condition.indicatorType}-${getConditionValuePeriod(condition, def)}`}
|
||||
value={getConditionValuePeriod(condition, def)}
|
||||
options={getCompositePeriodPresetOptions(condition.indicatorType, def, 'left')}
|
||||
min={1}
|
||||
max={500}
|
||||
onChange={p => onChange(setConditionValuePeriod(condition, p, def))}
|
||||
/>
|
||||
</label>
|
||||
<label className="se-flow-settings-field">
|
||||
<span>{compositeElementLabel(condition.indicatorType, 2)} 기준값 (기간, 일)</span>
|
||||
<ComboNumberInput
|
||||
key={`composite-right-${condition.indicatorType}-${getConditionRightPeriod(condition, def)}`}
|
||||
value={getConditionRightPeriod(condition, def)}
|
||||
options={getCompositePeriodPresetOptions(condition.indicatorType, def, 'right')}
|
||||
min={1}
|
||||
max={500}
|
||||
onChange={p => onChange(setConditionRightPeriod(condition, p))}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : usesValuePeriodField(condition) ? (
|
||||
<label className="se-flow-settings-field">
|
||||
<span>{condition.indicatorType} 기간 (일)</span>
|
||||
<ComboNumberInput
|
||||
value={getConditionValuePeriod(condition, def)}
|
||||
options={periodPresets}
|
||||
min={1}
|
||||
max={500}
|
||||
onChange={p => onChange(setConditionValuePeriod(condition, p, def))}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{showThreshold && rightThreshold != null && (
|
||||
<label className="se-flow-settings-field">
|
||||
<span>임계값</span>
|
||||
<ComboNumberInput
|
||||
value={rightThreshold}
|
||||
options={thresholdPresets}
|
||||
min={thresholdBounds.min}
|
||||
max={thresholdBounds.max}
|
||||
allowDecimal
|
||||
onChange={v => onChange(setConditionThreshold(condition, 'right', v))}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{condition.composite && (
|
||||
<p className="se-flow-settings-hint">{compositeDisplayName(condition.indicatorType)}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import React, { memo, useCallback, useState } from 'react';
|
||||
import { Handle, Position, useConnection, useReactFlow, type NodeProps } from '@xyflow/react';
|
||||
import { START_NODE_ID, type HandleSide, type StrategyFlowNodeData } from '../../utils/strategyFlowLayout';
|
||||
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
||||
import ConditionNodeSettings from './ConditionNodeSettings';
|
||||
|
||||
const LOGIC_COLORS: Record<string, string> = {
|
||||
AND: '#00aaff',
|
||||
@@ -179,16 +181,23 @@ export const LogicGateNode = memo(function LogicGateNode({ id, data, selected }:
|
||||
export const ConditionFlowNode = memo(function ConditionFlowNode({ id, data, selected }: NodeProps) {
|
||||
const d = data as StrategyFlowNodeData;
|
||||
const node = d.logicNode!;
|
||||
const ind = node.condition?.indicatorType ?? 'COND';
|
||||
const condType = node.condition?.conditionType ?? '';
|
||||
const cond = node.condition;
|
||||
const ind = cond?.indicatorType ?? 'COND';
|
||||
const condType = cond?.conditionType ?? '';
|
||||
const isCross = ['CROSS_UP', 'CROSS_DOWN'].includes(condType);
|
||||
const tone = isCross ? 'cross' : 'value';
|
||||
const isSell = d.signalTab === 'sell';
|
||||
const { onDragOver, onDragLeave, onDrop } = usePaletteDropHandlers(id, d);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const showSettings = cond && hasNodeSettings(cond);
|
||||
|
||||
const handleSettingsChange = useCallback((next: NonNullable<typeof cond>) => {
|
||||
d.onUpdateCondition?.(id, next);
|
||||
}, [d, id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`se-flow-node se-flow-node--cond se-flow-node--${tone}${isSell ? ' se-flow-node--sell-mode' : ''}${d.isOrphan ? ' se-flow-node--orphan' : ''} ${selected ? 'se-flow-node--selected' : ''} ${d.dragOver ? 'se-flow-node--drop' : ''}`}
|
||||
className={`se-flow-node se-flow-node--cond se-flow-node--${tone}${isSell ? ' se-flow-node--sell-mode' : ''}${d.isOrphan ? ' se-flow-node--orphan' : ''} ${selected ? 'se-flow-node--selected' : ''} ${d.dragOver ? 'se-flow-node--drop' : ''}${settingsOpen ? ' se-flow-node--settings-open' : ''}`}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
@@ -198,9 +207,38 @@ export const ConditionFlowNode = memo(function ConditionFlowNode({ id, data, sel
|
||||
targetOnly
|
||||
canTargetConnect={d.canTargetConnect !== false}
|
||||
/>
|
||||
<div className="se-flow-cond-badge">{ind}</div>
|
||||
<div className="se-flow-cond-top">
|
||||
<div className="se-flow-cond-badge">{ind}</div>
|
||||
<div className="se-flow-cond-actions">
|
||||
{showSettings && (
|
||||
<button
|
||||
type="button"
|
||||
className="se-flow-settings"
|
||||
title="지표 설정"
|
||||
aria-label="지표 설정"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setSettingsOpen(v => !v);
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="se-flow-del" title="삭제" onClick={() => d.onDelete?.(id)}>×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="se-flow-cond-text">{d.label ?? ind}</div>
|
||||
<button type="button" className="se-flow-del" title="삭제" onClick={() => d.onDelete?.(id)}>×</button>
|
||||
{settingsOpen && cond && d.def && (
|
||||
<ConditionNodeSettings
|
||||
condition={cond}
|
||||
def={d.def}
|
||||
onChange={handleSettingsChange}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface PaletteDragPayload {
|
||||
type: 'operator' | 'indicator';
|
||||
value: string;
|
||||
label: string;
|
||||
composite?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -13,16 +14,17 @@ interface Props {
|
||||
desc?: string;
|
||||
color?: string;
|
||||
period?: string;
|
||||
composite?: boolean;
|
||||
selected?: boolean;
|
||||
onSelect?: () => void;
|
||||
onAdd: () => void;
|
||||
}
|
||||
|
||||
export default function PaletteChip({
|
||||
type, value, label, desc, color, period, selected = false, onSelect, onAdd,
|
||||
type, value, label, desc, color, period, composite = false, selected = false, onSelect, onAdd,
|
||||
}: Props) {
|
||||
const onDragStart = (e: React.DragEvent) => {
|
||||
const payload: PaletteDragPayload = { type, value, label };
|
||||
const payload: PaletteDragPayload = { type, value, label, composite: composite || undefined };
|
||||
e.dataTransfer.setData('application/json', JSON.stringify(payload));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
};
|
||||
@@ -51,7 +53,7 @@ export default function PaletteChip({
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
className={`se-palette-card ${color ? `se-palette-card--${color}` : 'se-palette-card--ind'}${selected ? ' se-palette-card--selected' : ''}`}
|
||||
className={`se-palette-card ${color ? `se-palette-card--${color}` : 'se-palette-card--ind'}${composite ? ' se-palette-card--composite' : ''}${selected ? ' se-palette-card--selected' : ''}`}
|
||||
onDragStart={onDragStart}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
@@ -62,7 +64,9 @@ export default function PaletteChip({
|
||||
<div className="se-palette-card-head">
|
||||
<span className="se-palette-card-icon">{label.slice(0, 1)}</span>
|
||||
<div className="se-palette-card-meta">
|
||||
<span className="se-palette-card-name">{label}</span>
|
||||
<span className="se-palette-card-name">
|
||||
{composite ? `${label} + ${label}` : label}
|
||||
</span>
|
||||
{desc ? <span className="se-palette-card-desc">{desc}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,12 +18,13 @@ import {
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import type { Theme } from '../../types/index';
|
||||
import type { LogicNode } from '../../utils/strategyTypes';
|
||||
import type { LogicNode, ConditionDSL } from '../../utils/strategyTypes';
|
||||
import {
|
||||
addChild,
|
||||
deleteNode,
|
||||
mergeAtRoot,
|
||||
makeNode,
|
||||
updateNode,
|
||||
type DefType,
|
||||
} from '../../utils/strategyEditorShared';
|
||||
import {
|
||||
@@ -408,10 +409,10 @@ function StrategyEditorCanvasInner({
|
||||
}, [setNodes]);
|
||||
|
||||
const addOrphanAt = useCallback((
|
||||
data: { type: string; value: string; label: string },
|
||||
data: { type: string; value: string; label: string; composite?: boolean },
|
||||
flowPos: { x: number; y: number },
|
||||
) => {
|
||||
const newNode = makeNode(data.type, data.value, signalTab, def);
|
||||
const newNode = makeNode(data.type, data.value, signalTab, def, data.composite ? { composite: true } : undefined);
|
||||
positionsRef.current.set(newNode.id, {
|
||||
x: flowPos.x - FLOW_NODE_W / 2,
|
||||
y: flowPos.y - FLOW_NODE_H / 2,
|
||||
@@ -422,10 +423,10 @@ function StrategyEditorCanvasInner({
|
||||
|
||||
const applyDropWithAttach = useCallback((
|
||||
anchorId: string,
|
||||
data: { type: string; value: string; label: string },
|
||||
data: { type: string; value: string; label: string; composite?: boolean },
|
||||
flowPos: { x: number; y: number },
|
||||
) => {
|
||||
const newNode = makeNode(data.type, data.value, signalTab, def);
|
||||
const newNode = makeNode(data.type, data.value, signalTab, def, data.composite ? { composite: true } : undefined);
|
||||
const anchor = nodesRef.current.find(n => n.id === anchorId);
|
||||
if (!anchor) return;
|
||||
|
||||
@@ -488,12 +489,24 @@ function StrategyEditorCanvasInner({
|
||||
}
|
||||
}, [clearDropPreview, applyDropWithAttach, addOrphanAt, root, orphans]);
|
||||
|
||||
const handleUpdateCondition = useCallback((id: string, condition: ConditionDSL) => {
|
||||
if (isOrphanNode(orphans, id)) {
|
||||
onOrphansChange(orphans.map(o => (
|
||||
o.id === id ? { ...o, condition } : o
|
||||
)));
|
||||
return;
|
||||
}
|
||||
if (!root) return;
|
||||
onChange(updateNode(root, id, n => ({ ...n, condition })));
|
||||
}, [root, orphans, onChange, onOrphansChange]);
|
||||
|
||||
const flowCallbacks = useMemo(() => ({
|
||||
onDelete: handleDelete,
|
||||
onUpdateCondition: handleUpdateCondition,
|
||||
onDropTarget: handleDropTarget,
|
||||
onDragOverTarget: handleDragOverTarget,
|
||||
onDragLeaveTarget: handleDragLeaveTarget,
|
||||
}), [handleDelete, handleDropTarget, handleDragOverTarget, handleDragLeaveTarget]);
|
||||
}), [handleDelete, handleUpdateCondition, handleDropTarget, handleDragOverTarget, handleDragLeaveTarget]);
|
||||
|
||||
const rebuildFlow = useCallback(() => logicNodeToFlow(
|
||||
root,
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
nodeToText,
|
||||
type DefType,
|
||||
} from '../../utils/strategyEditorShared';
|
||||
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
||||
import ConditionNodeSettings from './ConditionNodeSettings';
|
||||
|
||||
const NODE_COLORS: Record<string, string> = {
|
||||
AND: '#4caf50',
|
||||
@@ -41,6 +43,8 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
||||
}) => {
|
||||
const isLogic = ['AND', 'OR', 'NOT'].includes(node.type);
|
||||
const color = isLogic ? NODE_COLORS[node.type] : IND_COLOR;
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const showSettings = node.type === 'CONDITION' && node.condition && hasNodeSettings(node.condition);
|
||||
const label = node.type === 'CONDITION' && node.condition
|
||||
? nodeToText(node, def)
|
||||
: node.type === 'AND'
|
||||
@@ -83,12 +87,41 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="sp-node-head" style={{ borderLeftColor: color }}>
|
||||
<div className="sp-node-head sp-node-head--cond" style={{ borderLeftColor: color }}>
|
||||
<span className="sp-node-badge" style={{ background: color }}>
|
||||
{node.type === 'CONDITION' && node.condition ? node.condition.indicatorType : node.type}
|
||||
</span>
|
||||
<span className="sp-node-label">{label}</span>
|
||||
<button type="button" className="sp-node-del" title="삭제" onClick={onDelete}>✕</button>
|
||||
<div className="sp-node-actions">
|
||||
{showSettings && (
|
||||
<button
|
||||
type="button"
|
||||
className="sp-node-settings"
|
||||
title="지표 설정"
|
||||
aria-label="지표 설정"
|
||||
aria-expanded={settingsOpen}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setSettingsOpen(v => !v);
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="sp-node-del" title="삭제" onClick={onDelete}>✕</button>
|
||||
</div>
|
||||
{settingsOpen && node.condition && (
|
||||
<ConditionNodeSettings
|
||||
condition={node.condition}
|
||||
def={def}
|
||||
onChange={handleCondChange}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
popoverClassName="sp-node-settings-pop"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{node.type === 'CONDITION' && node.condition && (
|
||||
@@ -132,9 +165,9 @@ export default function StrategyListEditor({ root, signalTab, def, onChange }: P
|
||||
|
||||
const applyDrop = useCallback((
|
||||
targetId: string | null,
|
||||
data: { type: string; value: string; label: string },
|
||||
data: { type: string; value: string; label: string; composite?: boolean },
|
||||
) => {
|
||||
const newNode = makeNode(data.type, data.value, signalTab, def);
|
||||
const newNode = makeNode(data.type, data.value, signalTab, def, data.composite ? { composite: true } : undefined);
|
||||
if (!root) {
|
||||
onChange(newNode);
|
||||
return;
|
||||
@@ -156,7 +189,7 @@ export default function StrategyListEditor({ root, signalTab, def, onChange }: P
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropOnNode = (targetId: string, data: { type: string; value: string; label: string }) => {
|
||||
const handleDropOnNode = (targetId: string, data: { type: string; value: string; label: string; composite?: boolean }) => {
|
||||
applyDrop(targetId, data);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user