전략편집기 수정
This commit is contained in:
@@ -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<String, Map<String, Object>> params) {
|
||||
private Rule buildConditionRule(JsonNode cond, RuleBuildContext ctx) {
|
||||
if (cond == null || cond.isNull()) return new BooleanRule(false);
|
||||
|
||||
BarSeries series = ctx.primarySeries();
|
||||
Map<String, Map<String, Object>> 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<String, Object> 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<String, Object> 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<Num> left = resolveField(leftField, indType, indParams, leftSeries, condPeriod, leftPeriod);
|
||||
Indicator<Num> 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<Num> left;
|
||||
private final Indicator<Num> right;
|
||||
private final BarSeries triggerSeries;
|
||||
private final BarSeries leftSeries;
|
||||
private final BarSeries rightSeries;
|
||||
|
||||
CrossTimeframeCompositeRule(String condType,
|
||||
Indicator<Num> left,
|
||||
Indicator<Num> 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<Num> resolveField(String field, String indType,
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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) : '-'}
|
||||
|
||||
@@ -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 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);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</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)}
|
||||
) : (
|
||||
<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);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{rightTab === 'templates' && (
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,6 +247,42 @@ export default function BuilderPageShell({
|
||||
{banner}
|
||||
|
||||
<div className="bps-body" style={bodyStyle}>
|
||||
{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>
|
||||
) : 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 ? (
|
||||
@@ -173,7 +301,6 @@ export default function BuilderPageShell({
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div
|
||||
className="bps-splitter bps-splitter--v se-splitter se-splitter--v"
|
||||
role="separator"
|
||||
@@ -181,13 +308,15 @@ export default function BuilderPageShell({
|
||||
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,43 +53,74 @@ 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 (alwaysShowInput) {
|
||||
if (!focused) setDraft(String(value));
|
||||
}, [value, focused]);
|
||||
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));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="se-combo-num">
|
||||
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'}
|
||||
list={listId}
|
||||
value={draft}
|
||||
placeholder="값 입력"
|
||||
disabled={disabled}
|
||||
onChange={e => setDraft(sanitizeDraft(e.target.value, allowDecimal))}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
commit();
|
||||
commitCustom();
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
@@ -81,12 +128,29 @@ export default function ComboNumberInput({
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
aria-label="직접 입력"
|
||||
/>
|
||||
<datalist id={listId}>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<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 }) {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { coerceFiniteNumber } from '../utils/safeFormat';
|
||||
|
||||
// ── 타입 ────────────────────────────────────────────────────────────────────
|
||||
export interface OrderbookUnit {
|
||||
@@ -74,18 +75,18 @@ export function useUpbitOrderbook(market: string) {
|
||||
// 매도: 높은 가격 → 낮은 가격 (역순)
|
||||
const asks: OrderbookDisplayUnit[] = units
|
||||
.map(u => ({
|
||||
price: u.ask_price,
|
||||
size: u.ask_size,
|
||||
percentage: maxSize > 0 ? (u.ask_size / maxSize) * 100 : 0,
|
||||
price: coerceFiniteNumber(u.ask_price) ?? 0,
|
||||
size: coerceFiniteNumber(u.ask_size) ?? 0,
|
||||
percentage: maxSize > 0 ? ((coerceFiniteNumber(u.ask_size) ?? 0) / maxSize) * 100 : 0,
|
||||
type: 'ask' as const,
|
||||
}))
|
||||
.reverse();
|
||||
|
||||
// 매수: 높은 가격 → 낮은 가격 (정순 – 1번 unit이 best bid)
|
||||
const bids: OrderbookDisplayUnit[] = units.map(u => ({
|
||||
price: u.bid_price,
|
||||
size: u.bid_size,
|
||||
percentage: maxSize > 0 ? (u.bid_size / maxSize) * 100 : 0,
|
||||
price: coerceFiniteNumber(u.bid_price) ?? 0,
|
||||
size: coerceFiniteNumber(u.bid_size) ?? 0,
|
||||
percentage: maxSize > 0 ? ((coerceFiniteNumber(u.bid_size) ?? 0) / maxSize) * 100 : 0,
|
||||
type: 'bid' as const,
|
||||
}));
|
||||
|
||||
|
||||
@@ -10,11 +10,12 @@ import {
|
||||
type LiveConditionRowDto,
|
||||
} from '../utils/backendApi';
|
||||
import { formatIndicatorDisplayLabel } from '../utils/indicatorRegistry';
|
||||
import { coerceFiniteNumber } from '../utils/safeFormat';
|
||||
import {
|
||||
coerceFiniteNumber,
|
||||
extractVirtualConditions,
|
||||
type VirtualConditionRow,
|
||||
} from '../utils/virtualStrategyConditions';
|
||||
import { normalizeConditionRow } from '../utils/virtualSignalMetrics';
|
||||
|
||||
export interface VirtualIndicatorSnapshot {
|
||||
market: string;
|
||||
@@ -37,20 +38,20 @@ function liveFallbackKey(r: LiveConditionRowDto): string {
|
||||
|
||||
function liveRowToVirtual(r: LiveConditionRowDto): VirtualConditionRow & { currentValue: number | null } {
|
||||
const plotKey = r.plotKey ?? r.indicatorType;
|
||||
return {
|
||||
return normalizeConditionRow({
|
||||
id: r.id,
|
||||
indicatorType: r.indicatorType,
|
||||
displayName: formatIndicatorDisplayLabel(r.indicatorType),
|
||||
conditionType: r.conditionType,
|
||||
conditionLabel: r.conditionLabel,
|
||||
targetValue: coerceFiniteNumber(r.targetValue),
|
||||
targetValue: r.targetValue,
|
||||
timeframe: r.timeframe,
|
||||
side: r.side,
|
||||
plotKey,
|
||||
satisfied: r.satisfied,
|
||||
thresholdLabel: r.thresholdLabel,
|
||||
currentValue: coerceFiniteNumber(r.currentValue),
|
||||
};
|
||||
currentValue: r.currentValue,
|
||||
});
|
||||
}
|
||||
|
||||
function mergeRows(
|
||||
@@ -71,15 +72,15 @@ function mergeRows(
|
||||
return base.map(row => {
|
||||
const liveRow = liveById.get(row.id) ?? liveByKey.get(rowFallbackKey(row));
|
||||
if (!liveRow) {
|
||||
return { ...row, currentValue: null as number | null, satisfied: null };
|
||||
return normalizeConditionRow({ ...row, currentValue: null, satisfied: null });
|
||||
}
|
||||
return {
|
||||
return normalizeConditionRow({
|
||||
...row,
|
||||
satisfied: liveRow.satisfied,
|
||||
thresholdLabel: liveRow.thresholdLabel,
|
||||
currentValue: coerceFiniteNumber(liveRow.currentValue),
|
||||
targetValue: coerceFiniteNumber(liveRow.targetValue) ?? row.targetValue,
|
||||
};
|
||||
currentValue: liveRow.currentValue,
|
||||
targetValue: liveRow.targetValue ?? row.targetValue,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -97,7 +98,7 @@ async function buildSnapshot(
|
||||
market,
|
||||
strategyId,
|
||||
timeframe: [...new Set(baseRows.map(r => r.timeframe))].join(', '),
|
||||
rows: baseRows.map(r => ({ ...r, currentValue: null, satisfied: null })),
|
||||
rows: baseRows.map(r => normalizeConditionRow({ ...r, currentValue: null, satisfied: null })),
|
||||
updatedAt: Date.now(),
|
||||
matchRate: 0,
|
||||
};
|
||||
@@ -115,7 +116,7 @@ async function buildSnapshot(
|
||||
market,
|
||||
strategyId,
|
||||
timeframe: [...new Set(baseRows.map(r => r.timeframe))].join(', '),
|
||||
rows: baseRows.map(r => ({ ...r, currentValue: null, satisfied: null })),
|
||||
rows: baseRows.map(r => normalizeConditionRow({ ...r, currentValue: null, satisfied: null })),
|
||||
updatedAt: Date.now(),
|
||||
matchRate: 0,
|
||||
};
|
||||
|
||||
@@ -77,6 +77,163 @@
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* 좌·우 접이식 패널 — 실시간 차트 mp-panel-handle / rsp-handle 과 동일 연결감 */
|
||||
.bps-side-wrap {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
align-self: stretch;
|
||||
flex-shrink: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
z-index: 8;
|
||||
}
|
||||
|
||||
.bps-side-wrap--right {
|
||||
flex-direction: row;
|
||||
flex-shrink: 0;
|
||||
min-width: 16px;
|
||||
}
|
||||
|
||||
/* 접기 핸들 — 패널·탭 배경과 동일 색, 경계에 밀착 */
|
||||
.bps-panel-handle {
|
||||
align-self: center;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
pointer-events: auto;
|
||||
width: 16px;
|
||||
height: 56px;
|
||||
border: 1px solid var(--se-border);
|
||||
color: var(--se-text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s, border-color 0.15s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bps-panel-handle--left {
|
||||
margin-left: -1px;
|
||||
background: var(--se-bg-elevated);
|
||||
border-left: none;
|
||||
border-radius: 0 8px 8px 0;
|
||||
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.bps-panel-handle--right {
|
||||
margin-right: -1px;
|
||||
background: var(--se-palette-bg);
|
||||
border-right: none;
|
||||
border-radius: 8px 0 0 8px;
|
||||
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.bps-panel-handle:hover {
|
||||
color: var(--se-text);
|
||||
}
|
||||
|
||||
.bps-panel-handle--left:hover {
|
||||
background: var(--se-bg-muted);
|
||||
box-shadow: 3px 0 14px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.bps-panel-handle--right:hover {
|
||||
background: color-mix(in srgb, var(--se-bg-muted) 70%, var(--se-palette-bg));
|
||||
box-shadow: -3px 0 14px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.bps-panel-handle--open.bps-panel-handle--left {
|
||||
border-color: color-mix(in srgb, var(--se-accent) 30%, var(--se-border));
|
||||
}
|
||||
|
||||
.bps-side-wrap--right.bps-side-wrap--open .bps-panel-handle--right {
|
||||
border-color: color-mix(in srgb, var(--se-accent) 35%, var(--se-border));
|
||||
}
|
||||
|
||||
/* 좌측 패널 — market-panel 처럼 핸들과 한 덩어리 */
|
||||
.bps-side-wrap--left .bps-left.bps-left--collapsible {
|
||||
background: var(--se-bg-elevated);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bps-left--collapsible {
|
||||
width: 0;
|
||||
flex: 0 0 0;
|
||||
min-width: 0;
|
||||
border-right: none;
|
||||
transition: width 0.22s cubic-bezier(0.4, 0, 0.2, 1), flex-basis 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.bps-left--collapsible.bps-left--collapsible--open,
|
||||
.bps-side-wrap--left.bps-side-wrap--open .bps-left--collapsible {
|
||||
width: var(--bps-left-width, 380px);
|
||||
flex: 0 0 var(--bps-left-width, 380px);
|
||||
border-right: 1px solid var(--se-border);
|
||||
}
|
||||
|
||||
.bps-side-wrap--left .bps-left--collapsible .bps-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 12px 10px 10px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 우측 패널 — rsp-wrap 처럼 탭+본문과 핸들 연결 */
|
||||
.bps-side-wrap--right .bps-right.bps-right--collapsible {
|
||||
background: var(--se-palette-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bps-side-wrap--right .bps-right--collapsible .bps-right-tabs {
|
||||
flex-shrink: 0;
|
||||
background: var(--se-bg-muted);
|
||||
border-bottom: 1px solid var(--se-border);
|
||||
}
|
||||
|
||||
.bps-side-wrap--right:not(.bps-side-wrap--open) .bps-right--collapsible .bps-right-tabs,
|
||||
.bps-side-wrap--right:not(.bps-side-wrap--open) .bps-right--collapsible .bps-right-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bps-side-wrap--right.bps-side-wrap--open .bps-right--collapsible .bps-right-tabs,
|
||||
.bps-side-wrap--right.bps-side-wrap--open .bps-right--collapsible .bps-right-body,
|
||||
.bps-right--collapsible.bps-right--collapsible--open .bps-right-tabs,
|
||||
.bps-right--collapsible.bps-right--collapsible--open .bps-right-body {
|
||||
min-width: var(--bps-right-width, 380px);
|
||||
}
|
||||
|
||||
.bps-side-wrap--right .bps-right--collapsible .bps-right-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.bps-center-content--left-collapsed,
|
||||
.bps-center-content--right-collapsed,
|
||||
.bps-center-content--both-collapsed {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bps-left {
|
||||
@@ -219,6 +376,30 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 접이식 우측 — .bps-right 고정 너비(280px)보다 뒤에 두어 덮어씀 */
|
||||
.bps-right.bps-right--collapsible {
|
||||
width: 0;
|
||||
flex: 0 0 0;
|
||||
min-width: 0;
|
||||
max-width: 0;
|
||||
border-left: none;
|
||||
transition: width 0.22s cubic-bezier(0.4, 0, 0.2, 1), flex-basis 0.22s cubic-bezier(0.4, 0, 0.2, 1), max-width 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.bps-right.bps-right--collapsible.bps-right--collapsible--open,
|
||||
.bps-side-wrap--right.bps-side-wrap--open .bps-right.bps-right--collapsible {
|
||||
width: var(--bps-right-width, 380px);
|
||||
flex: 0 0 var(--bps-right-width, 380px);
|
||||
max-width: var(--bps-right-width, 380px);
|
||||
border-left: 1px solid var(--se-border);
|
||||
}
|
||||
|
||||
.bps-page .bps-right.bps-right--collapsible:not(.bps-right--collapsible--open) {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.bps-right-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--se-border);
|
||||
|
||||
@@ -1087,7 +1087,18 @@
|
||||
color: var(--se-text);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.se-combo-num { width: 100%; }
|
||||
.se-combo-num { width: 100%; display: flex; flex-direction: column; gap: 6px; }
|
||||
.se-combo-num--always-input .se-combo-num-input { order: -1; }
|
||||
.se-combo-num-select { width: 100%; }
|
||||
|
||||
.se-combo-field {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.se-combo-field-select { width: 100%; }
|
||||
.sp-cond-field .se-combo-field .se-combo-num { margin-top: 0; }
|
||||
.se-combo-num-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--se-input-focus);
|
||||
@@ -1188,6 +1199,13 @@
|
||||
color: color-mix(in srgb, var(--se-success) 75%, transparent);
|
||||
}
|
||||
.se-node-config-bar .sp-cond-editor { margin: 0; }
|
||||
|
||||
.sp-cond-row-composite-tf {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.se-node-config-bar .sp-cond-sel,
|
||||
.se-node-config-bar .sp-cond-num {
|
||||
background: var(--se-input-bg);
|
||||
@@ -1339,6 +1357,100 @@
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--se-accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.se-palette-subtabs {
|
||||
display: flex;
|
||||
margin: 4px 10px 0;
|
||||
border-bottom: 1px solid var(--se-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.se-palette-subtab {
|
||||
flex: 1;
|
||||
padding: 8px 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--se-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.se-palette-subtab--on {
|
||||
color: var(--se-tab-active);
|
||||
box-shadow: inset 0 -2px 0 var(--se-tab-active);
|
||||
}
|
||||
.se-palette-subtab:first-child.se-palette-subtab--on {
|
||||
color: var(--se-palette-section-ind, #2dd4bf);
|
||||
box-shadow: inset 0 -2px 0 var(--se-palette-section-ind, #2dd4bf);
|
||||
}
|
||||
.se-palette-subtab:last-child.se-palette-subtab--on {
|
||||
color: var(--se-palette-section-composite, #c084fc);
|
||||
box-shadow: inset 0 -2px 0 var(--se-palette-section-composite, #c084fc);
|
||||
}
|
||||
|
||||
.se-palette-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin: 8px 0 8px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.se-palette-toolbar-title {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--se-text-dim);
|
||||
font-weight: 700;
|
||||
}
|
||||
.se-palette-section--aux .se-palette-toolbar-title { color: var(--se-palette-section-ind); }
|
||||
.se-palette-section--composite .se-palette-toolbar-title { color: var(--se-palette-section-composite, #c084fc); }
|
||||
|
||||
.se-palette-toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.se-palette-tool-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--se-border);
|
||||
background: var(--se-btn-bg);
|
||||
color: var(--se-text-muted);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
.se-palette-tool-btn:hover:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--se-accent) 45%, transparent);
|
||||
color: var(--se-text);
|
||||
background: var(--se-bg-elevated);
|
||||
}
|
||||
.se-palette-tool-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.se-palette-tool-btn--danger:hover:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--se-danger) 50%, transparent);
|
||||
color: var(--se-danger);
|
||||
}
|
||||
|
||||
.se-palette-empty {
|
||||
margin: 12px 0 0;
|
||||
font-size: 0.72rem;
|
||||
color: var(--se-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.se-palette-item-modal .se-field-lbl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.se-palette-section { padding: 0 10px 10px; }
|
||||
.se-palette-section--scroll { flex: 1; overflow-y: auto; min-height: 0; }
|
||||
.se-palette-section h3 {
|
||||
|
||||
@@ -469,6 +469,8 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
container-type: inline-size;
|
||||
container-name: vtd-wrap;
|
||||
}
|
||||
|
||||
.vtd-grid-wrap--focus {
|
||||
@@ -838,8 +840,9 @@
|
||||
.vtd-grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 360px), 1fr));
|
||||
grid-auto-rows: minmax(520px, auto);
|
||||
gap: 14px;
|
||||
padding: 4px 4px 12px;
|
||||
@@ -851,13 +854,20 @@
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
/* 좌·우 패널 접힘 시 중앙 영역이 넓어지면 카드 열 수 자동 증가 */
|
||||
@container vtd-wrap (min-width: 1180px) {
|
||||
.vtd-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
@container vtd-wrap (min-width: 1540px) {
|
||||
.vtd-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@container vtd-wrap (max-width: 720px) {
|
||||
.vtd-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
@@ -1001,21 +1011,13 @@
|
||||
.vtd-card-strategy-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vtd-card-strategy-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--se-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vtd-card-strategy-select {
|
||||
min-width: 100px;
|
||||
max-width: 180px;
|
||||
min-width: 108px;
|
||||
max-width: 160px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--se-border);
|
||||
@@ -1177,16 +1179,26 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1 1 96px;
|
||||
min-width: 72px;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vtd-card-ko {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vtd-card-sym {
|
||||
font-size: 10px;
|
||||
color: var(--se-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vtd-card-meta {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** 복합지표 — 동일 지표 2개(서로 다른 기간) 간 교차·비교 조건 */
|
||||
import type { ConditionDSL } from './strategyTypes';
|
||||
import { DEFAULT_START_CANDLE, normalizeStartCandleType } from './strategyStartNodes';
|
||||
|
||||
export interface CompositePeriodDef {
|
||||
rsiPeriod: number;
|
||||
@@ -65,8 +66,37 @@ export function compositeValueField(ind: string, period: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** K_ 접두 임계값 필드 — 기간 파싱·복합지표 승격에서 제외 */
|
||||
export function isThresholdField(field?: string): boolean {
|
||||
return !!field && field.startsWith('K_');
|
||||
}
|
||||
|
||||
const COMPOSITE_VALUE_PREFIX: Record<string, string> = {
|
||||
RSI: 'RSI_VALUE',
|
||||
CCI: 'CCI_VALUE',
|
||||
ADX: 'ADX_VALUE',
|
||||
WILLIAMS_R: 'WILLIAMS_R_VALUE',
|
||||
TRIX: 'TRIX_VALUE',
|
||||
VR: 'VR_VALUE',
|
||||
PSYCHOLOGICAL: 'PSY_VALUE',
|
||||
NEW_PSYCHOLOGICAL: 'PSY_VALUE',
|
||||
INVEST_PSYCHOLOGICAL: 'INVEST_PSY_VALUE',
|
||||
};
|
||||
|
||||
/** 복합지표용 값 라인 필드(CCI_VALUE_9 등)인지 — K_100 임계값은 false */
|
||||
export function isCompositeValueField(ind: string, field?: string): boolean {
|
||||
if (!field || isThresholdField(field)) return false;
|
||||
if (ind === 'DISPARITY') {
|
||||
return field.startsWith('DISPARITY') && field.length > 'DISPARITY'.length
|
||||
&& /^\d+$/.test(field.slice('DISPARITY'.length));
|
||||
}
|
||||
const prefix = COMPOSITE_VALUE_PREFIX[ind];
|
||||
if (!prefix) return false;
|
||||
return field === prefix || field.startsWith(`${prefix}_`);
|
||||
}
|
||||
|
||||
export function parsePeriodFromCompositeField(ind: string, field?: string): number | null {
|
||||
if (!field) return null;
|
||||
if (!field || isThresholdField(field)) return null;
|
||||
if (ind === 'DISPARITY' && field.startsWith('DISPARITY')) {
|
||||
const n = parseInt(field.slice('DISPARITY'.length), 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
@@ -93,6 +123,8 @@ export function syncCompositeFields(cond: ConditionDSL): ConditionDSL {
|
||||
...(rightP != null ? { rightPeriod: rightP } : null),
|
||||
};
|
||||
}
|
||||
const leftCt = normalizeStartCandleType(cond.leftCandleType ?? DEFAULT_START_CANDLE);
|
||||
const rightCt = normalizeStartCandleType(cond.rightCandleType ?? DEFAULT_START_CANDLE);
|
||||
return {
|
||||
...cond,
|
||||
composite: true,
|
||||
@@ -100,14 +132,30 @@ export function syncCompositeFields(cond: ConditionDSL): ConditionDSL {
|
||||
rightPeriod: rightP,
|
||||
leftField: compositeValueField(cond.indicatorType, leftP),
|
||||
rightField: compositeValueField(cond.indicatorType, rightP),
|
||||
leftCandleType: leftCt,
|
||||
rightCandleType: rightCt,
|
||||
period: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCompositeCondition(cond: ConditionDSL): ConditionDSL {
|
||||
if (cond.composite) return syncCompositeFields(cond);
|
||||
const leftP = parsePeriodFromCompositeField(cond.indicatorType, cond.leftField);
|
||||
const rightP = parsePeriodFromCompositeField(cond.indicatorType, cond.rightField);
|
||||
if (cond.composite) {
|
||||
if (isThresholdField(cond.leftField) || isThresholdField(cond.rightField)) {
|
||||
return {
|
||||
...cond,
|
||||
composite: false,
|
||||
leftPeriod: undefined,
|
||||
rightPeriod: undefined,
|
||||
};
|
||||
}
|
||||
return syncCompositeFields(cond);
|
||||
}
|
||||
const ind = cond.indicatorType;
|
||||
if (!isCompositeValueField(ind, cond.leftField) || !isCompositeValueField(ind, cond.rightField)) {
|
||||
return cond;
|
||||
}
|
||||
const leftP = parsePeriodFromCompositeField(ind, cond.leftField);
|
||||
const rightP = parsePeriodFromCompositeField(ind, cond.rightField);
|
||||
if (leftP && rightP && leftP !== rightP) {
|
||||
return syncCompositeFields({
|
||||
...cond,
|
||||
@@ -131,6 +179,8 @@ export function makeCompositeCondition(
|
||||
composite: true,
|
||||
leftPeriod: short,
|
||||
rightPeriod: long,
|
||||
leftCandleType: DEFAULT_START_CANDLE,
|
||||
rightCandleType: DEFAULT_START_CANDLE,
|
||||
candleRange: 1,
|
||||
});
|
||||
}
|
||||
@@ -151,3 +201,8 @@ export function compositeElementLabel(ind: string, slot: 1 | 2): string {
|
||||
const name = item?.label ?? ind;
|
||||
return `${name} ${slot}`;
|
||||
}
|
||||
|
||||
/** 복합지표 조건대상 라벨 — 예: CCI 1 라인(9일) */
|
||||
export function compositeFieldLabel(ind: string, slot: 1 | 2, period: number): string {
|
||||
return `${compositeElementLabel(ind, slot)} 라인(${period}일)`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
/** 조건 노드별 지표 기간·임계값 — 차트 DEF 기본값 대비 노드 단위 오버라이드 */
|
||||
import type { ConditionDSL } from './strategyTypes';
|
||||
import { getCompositeDefaultPeriods, parsePeriodFromCompositeField, syncCompositeFields, type CompositePeriodDef } from './compositeIndicators';
|
||||
import {
|
||||
compositeFieldLabel,
|
||||
compositeValueField,
|
||||
getCompositeDefaultPeriods,
|
||||
isThresholdField,
|
||||
parsePeriodFromCompositeField,
|
||||
syncCompositeFields,
|
||||
type CompositePeriodDef,
|
||||
} from './compositeIndicators';
|
||||
import { DEFAULT_START_CANDLE, normalizeStartCandleType } from './strategyStartNodes';
|
||||
|
||||
export interface IndicatorPeriodDef {
|
||||
rsiPeriod: number;
|
||||
@@ -41,7 +50,7 @@ export function getDefaultIndicatorPeriod(indicatorType: string, def: IndicatorP
|
||||
}
|
||||
|
||||
function parseFieldPeriod(field?: string): number | null {
|
||||
if (!field) return null;
|
||||
if (!field || isThresholdField(field)) return null;
|
||||
const m = field.match(/_(\d+)$/);
|
||||
if (!m) return null;
|
||||
const n = parseInt(m[1], 10);
|
||||
@@ -72,6 +81,24 @@ export function getConditionRightPeriod(cond: ConditionDSL, def: IndicatorPeriod
|
||||
return getDefaultIndicatorPeriod(cond.indicatorType, def);
|
||||
}
|
||||
|
||||
/** 조건대상 직접입력 — 기간(일) 또는 임계값 */
|
||||
export function resolveFieldCustomKind(
|
||||
indicatorType: string,
|
||||
field: string | undefined,
|
||||
): 'period' | 'threshold' | 'none' {
|
||||
if (!field) return 'none';
|
||||
if (isThresholdField(field)) return 'threshold';
|
||||
const prefix = VALUE_FIELD_PREFIX[indicatorType];
|
||||
if (prefix && (field === prefix || field.startsWith(`${prefix}_`))) return 'period';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
export function isValuePeriodFieldStored(indicatorType: string, field?: string): boolean {
|
||||
const prefix = VALUE_FIELD_PREFIX[indicatorType];
|
||||
if (!prefix || !field) return false;
|
||||
return field.startsWith(`${prefix}_`) && field.length > prefix.length + 1;
|
||||
}
|
||||
|
||||
export function usesValuePeriodField(cond: ConditionDSL): boolean {
|
||||
if (cond.composite) return true;
|
||||
const prefix = VALUE_FIELD_PREFIX[cond.indicatorType];
|
||||
@@ -178,6 +205,49 @@ export function getCompositePeriodPresetOptions(
|
||||
return [...new Set([base, short, long, ...COMMON_PERIODS])].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** 복합지표 조건대상1/2 드롭다운 — CCI 1 라인(9일) 형식 */
|
||||
export function getCompositeLeftCandleType(cond: ConditionDSL): string {
|
||||
return normalizeStartCandleType(cond.leftCandleType ?? DEFAULT_START_CANDLE);
|
||||
}
|
||||
|
||||
export function getCompositeRightCandleType(cond: ConditionDSL): string {
|
||||
return normalizeStartCandleType(cond.rightCandleType ?? DEFAULT_START_CANDLE);
|
||||
}
|
||||
|
||||
export function setCompositeLeftCandleType(cond: ConditionDSL, candleType: string): ConditionDSL {
|
||||
return syncCompositeFields({
|
||||
...cond,
|
||||
composite: true,
|
||||
leftCandleType: normalizeStartCandleType(candleType),
|
||||
});
|
||||
}
|
||||
|
||||
export function setCompositeRightCandleType(cond: ConditionDSL, candleType: string): ConditionDSL {
|
||||
return syncCompositeFields({
|
||||
...cond,
|
||||
composite: true,
|
||||
rightCandleType: normalizeStartCandleType(candleType),
|
||||
});
|
||||
}
|
||||
|
||||
export function getCompositeFieldOpts(
|
||||
indicatorType: string,
|
||||
slot: 1 | 2,
|
||||
def: IndicatorPeriodDef,
|
||||
cond: ConditionDSL,
|
||||
): { value: string; label: string }[] {
|
||||
const side = slot === 1 ? 'left' : 'right';
|
||||
const current = slot === 1
|
||||
? getConditionValuePeriod(cond, def)
|
||||
: getConditionRightPeriod(cond, def);
|
||||
const presets = getCompositePeriodPresetOptions(indicatorType, def, side);
|
||||
const periods = [...new Set([...presets, current])].sort((a, b) => a - b);
|
||||
return periods.map(p => ({
|
||||
value: compositeValueField(indicatorType, p),
|
||||
label: compositeFieldLabel(indicatorType, slot, p),
|
||||
}));
|
||||
}
|
||||
|
||||
export function getThresholdPresetOptions(indicatorType: string): number[] {
|
||||
switch (indicatorType) {
|
||||
case 'RSI':
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* API·JSON·WebSocket에서 문자열로 올 수 있는 숫자를 안전하게 처리
|
||||
*/
|
||||
export function coerceFiniteNumber(v: unknown): number | null {
|
||||
if (v == null || v === '') return null;
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||
if (typeof v === 'string') {
|
||||
const trimmed = v.trim().replace(/,/g, '');
|
||||
if (!trimmed) return null;
|
||||
const n = Number(trimmed);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/** toFixed 크래시 방지 — 숫자가 아니면 '—' */
|
||||
export function safeToFixed(v: unknown, digits = 2, fallback = '—'): string {
|
||||
const n = coerceFiniteNumber(v);
|
||||
if (n == null) return fallback;
|
||||
try {
|
||||
return n.toFixed(digits);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function safePercentFromRate(v: unknown, digits = 2): string {
|
||||
const n = coerceFiniteNumber(v);
|
||||
if (n == null) return '—';
|
||||
const sign = n >= 0 ? '+' : '';
|
||||
return `${sign}${safeToFixed(n * 100, digits, '0.00')}%`;
|
||||
}
|
||||
@@ -8,16 +8,19 @@ import {
|
||||
normalizeStartCombineOp,
|
||||
type EditorConditionState,
|
||||
} from './strategyConditionSerde';
|
||||
import {
|
||||
compositeDisplayName,
|
||||
normalizeCompositeCondition,
|
||||
} from './compositeIndicators';
|
||||
import { normalizeCompositeCondition } from './compositeIndicators';
|
||||
import {
|
||||
getFieldOpts,
|
||||
resolveFieldOptionValue,
|
||||
type DefType,
|
||||
} from './strategyEditorShared';
|
||||
import { parseThresholdField } from './conditionPeriods';
|
||||
import {
|
||||
getCompositeFieldOpts,
|
||||
getCompositeLeftCandleType,
|
||||
getCompositeRightCandleType,
|
||||
parseThresholdField,
|
||||
} from './conditionPeriods';
|
||||
import { formatStrategyCandleLabel } from './strategyStartNodes';
|
||||
import { formatStartCandleLabel } from './strategyStartNodes';
|
||||
|
||||
export interface StrategyDescriptionInput {
|
||||
@@ -66,7 +69,13 @@ function fieldLabel(
|
||||
def: DefType,
|
||||
signalType: 'buy' | 'sell',
|
||||
cond?: ReturnType<typeof normalizeCompositeCondition>,
|
||||
slot?: 1 | 2,
|
||||
): string {
|
||||
if (cond?.composite && field && slot) {
|
||||
const compositeOpts = getCompositeFieldOpts(indicatorType, slot, def, cond);
|
||||
const hit = compositeOpts.find(o => o.value === field);
|
||||
if (hit) return hit.label;
|
||||
}
|
||||
const opts = getFieldOpts(indicatorType, signalType, def, cond);
|
||||
const resolved = resolveFieldOptionValue(indicatorType, field);
|
||||
const hit = opts.find(o => o.value === resolved);
|
||||
@@ -128,21 +137,19 @@ function describeCondition(
|
||||
const ct = cond.conditionType;
|
||||
|
||||
if (cond.composite && cond.leftPeriod && cond.rightPeriod) {
|
||||
const name = compositeDisplayName(ind).split(' + ')[0] ?? ind;
|
||||
const short = `${name} ${cond.leftPeriod}기간`;
|
||||
const long = `${name} ${cond.rightPeriod}기간`;
|
||||
switch (ct) {
|
||||
case 'GT': return `${short} 값이 ${long} 값보다 큰 경우`;
|
||||
case 'LT': return `${short} 값이 ${long} 값보다 작은 경우`;
|
||||
case 'GTE': return `${short} 값이 ${long} 값 이상인 경우`;
|
||||
case 'LTE': return `${short} 값이 ${long} 값 이하인 경우`;
|
||||
case 'CROSS_UP': return `${short} 값이 ${long} 값을 상향 돌파하는 경우`;
|
||||
case 'CROSS_DOWN': return `${short} 값이 ${long} 값을 하향 돌파하는 경우`;
|
||||
default: {
|
||||
const label = CONDITION_LABEL[ct] ?? ct;
|
||||
return `${short}과(와) ${long}을(를) 비교할 때 「${label}」 조건이 성립하는 경우`;
|
||||
}
|
||||
}
|
||||
const left = fieldLabel(ind, cond.leftField, def, signalType, cond, 1);
|
||||
const right = fieldLabel(ind, cond.rightField, def, signalType, cond, 2);
|
||||
const lCt = formatStrategyCandleLabel(getCompositeLeftCandleType(cond));
|
||||
const rCt = formatStrategyCandleLabel(getCompositeRightCandleType(cond));
|
||||
const tfNote = lCt !== rCt
|
||||
? ` (${lCt} 봉의 ${left}과(와) ${rCt} 봉의 ${right} 비교)`
|
||||
: ` (${lCt} 봉)`;
|
||||
const base = describeConditionType(ct, left, right, {
|
||||
compareValue: cond.compareValue,
|
||||
slopePeriod: cond.slopePeriod,
|
||||
holdDays: cond.holdDays,
|
||||
});
|
||||
return base + tfNote;
|
||||
}
|
||||
|
||||
const left = fieldLabel(ind, cond.leftField, def, signalType, cond);
|
||||
|
||||
@@ -6,21 +6,36 @@ import type { LogicNode, LogicNodeType, ConditionDSL } from '../utils/strategyTy
|
||||
import {
|
||||
compositeDisplayName,
|
||||
compositeElementLabel,
|
||||
compositeFieldLabel,
|
||||
makeCompositeCondition,
|
||||
normalizeCompositeCondition,
|
||||
parsePeriodFromCompositeField,
|
||||
syncCompositeFields,
|
||||
} from '../utils/compositeIndicators';
|
||||
import ComboNumberInput from '../components/strategyEditor/ComboNumberInput';
|
||||
import ComboFieldSelect from '../components/strategyEditor/ComboFieldSelect';
|
||||
import {
|
||||
getCompositeFieldOpts,
|
||||
getCompositePeriodPresetOptions,
|
||||
getConditionRightPeriod,
|
||||
getConditionThreshold,
|
||||
getConditionValuePeriod,
|
||||
getDefaultIndicatorPeriod,
|
||||
getPeriodPresetOptions,
|
||||
getThresholdBounds,
|
||||
getThresholdPresetOptions,
|
||||
initConditionPeriods,
|
||||
isValuePeriodFieldStored,
|
||||
parseThresholdField,
|
||||
resolveFieldCustomKind,
|
||||
getCompositeLeftCandleType,
|
||||
getCompositeRightCandleType,
|
||||
setCompositeLeftCandleType,
|
||||
setCompositeRightCandleType,
|
||||
setConditionThreshold,
|
||||
setConditionRightPeriod,
|
||||
setConditionValuePeriod,
|
||||
} from '../utils/conditionPeriods';
|
||||
import { STRATEGY_CANDLE_TYPE_OPTIONS } from '../utils/strategyStartNodes';
|
||||
|
||||
export interface StrategyDto {
|
||||
id: number;
|
||||
@@ -305,7 +320,7 @@ export const getFieldOpts = (
|
||||
const { over, mid, under } = th({ over:70, mid:50, under:30 });
|
||||
const rsiP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'RSI' }, DEF) : DEF.rsiPeriod;
|
||||
return condOpts([
|
||||
{ value:'RSI_VALUE', label:`RSI 값(${rsiP}일)` },
|
||||
{ value:'RSI_VALUE', label:`RSI 라인(${rsiP}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
@@ -325,7 +340,7 @@ export const getFieldOpts = (
|
||||
const { over, mid, under } = th({ over:100, mid:0, under:-100 });
|
||||
const cciP = cond ? getConditionValuePeriod({ ...cond, indicatorType: 'CCI' }, DEF) : DEF.cciPeriod;
|
||||
return condOpts([
|
||||
{ value:'CCI_VALUE', label:`CCI 값(${cciP}일)` },
|
||||
{ value:'CCI_VALUE', label:`CCI 라인(${cciP}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
@@ -334,7 +349,7 @@ export const getFieldOpts = (
|
||||
case 'ADX': {
|
||||
const { over, mid, under } = th({ over: 40, mid: 25, under: 20 });
|
||||
return condOpts([
|
||||
{ value:'ADX_VALUE', label:`ADX 값(${DEF.adxPeriod}일)` },
|
||||
{ value:'ADX_VALUE', label:`ADX 라인(${DEF.adxPeriod}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
@@ -343,7 +358,7 @@ export const getFieldOpts = (
|
||||
case 'TRIX': {
|
||||
const { mid } = th({ mid:0 });
|
||||
return condOpts([
|
||||
{ value:'TRIX_VALUE', label:`TRIX 값(${DEF.trixPeriod}일)` },
|
||||
{ value:'TRIX_VALUE', label:`TRIX 라인(${DEF.trixPeriod}일)` },
|
||||
{ value:'TRIX_SIGNAL', label:`TRIX 시그널(${DEF.trixSignal}일)` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
]);
|
||||
@@ -387,7 +402,7 @@ export const getFieldOpts = (
|
||||
case 'NEW_PSYCHOLOGICAL': {
|
||||
const { over, mid, under } = th({ over:75, mid:50, under:25 });
|
||||
return condOpts([
|
||||
{ value:'PSY_VALUE', label:`심리도 값(${DEF.newPsy}일)` },
|
||||
{ value:'PSY_VALUE', label:`심리도 라인(${DEF.newPsy}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
@@ -396,7 +411,7 @@ export const getFieldOpts = (
|
||||
case 'INVEST_PSYCHOLOGICAL': {
|
||||
const { over, mid, under } = th({ over:75, mid:50, under:25 });
|
||||
return condOpts([
|
||||
{ value:'INVEST_PSY_VALUE', label:`투자심리도 값(${DEF.investPsy}일)` },
|
||||
{ value:'INVEST_PSY_VALUE', label:`투자심리도 라인(${DEF.investPsy}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
@@ -405,7 +420,7 @@ export const getFieldOpts = (
|
||||
case 'WILLIAMS_R': {
|
||||
const { over, mid, under } = th({ over:-20, mid:-50, under:-80 });
|
||||
return condOpts([
|
||||
{ value:'WILLIAMS_R_VALUE', label:`Williams %R 값(${DEF.williamsR}일)` },
|
||||
{ value:'WILLIAMS_R_VALUE', label:`Williams %R 라인(${DEF.williamsR}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
@@ -414,7 +429,7 @@ export const getFieldOpts = (
|
||||
case 'BWI': {
|
||||
const { over, mid, under } = th({ over:80, mid:50, under:20 });
|
||||
return condOpts([
|
||||
{ value:'BWI_VALUE', label:`BWI 값(${DEF.bwiPeriod}일)` },
|
||||
{ value:'BWI_VALUE', label:`BWI 라인(${DEF.bwiPeriod}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
@@ -423,7 +438,7 @@ export const getFieldOpts = (
|
||||
case 'VR': {
|
||||
const { over, mid, under } = th({ over:200, mid:100, under:50 });
|
||||
return condOpts([
|
||||
{ value:'VR_VALUE', label:`VR 값(${DEF.vrPeriod}일)` },
|
||||
{ value:'VR_VALUE', label:`VR 라인(${DEF.vrPeriod}일)` },
|
||||
{ value:kv(over), label:`${overTerm}(${over})` },
|
||||
{ value:kv(mid), label:`중앙선(${mid})` },
|
||||
{ value:kv(under), label:`${underTerm}(${under})` },
|
||||
@@ -586,8 +601,16 @@ export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string
|
||||
const opts = getFieldOpts(c.indicatorType, 'buy', DEF, c);
|
||||
const C = condLabel(c.conditionType);
|
||||
if (c.composite && c.leftPeriod && c.rightPeriod) {
|
||||
const name = compositeDisplayName(c.indicatorType).split(' + ')[0];
|
||||
return `${c.indicatorType} - ${name}(${c.leftPeriod}) ${C} ${name}(${c.rightPeriod})`;
|
||||
const leftOpts = getCompositeFieldOpts(c.indicatorType, 1, DEF, c);
|
||||
const rightOpts = getCompositeFieldOpts(c.indicatorType, 2, DEF, c);
|
||||
const L = leftOpts.find(o => o.value === c.leftField)?.label
|
||||
?? compositeFieldLabel(c.indicatorType, 1, c.leftPeriod);
|
||||
const R = rightOpts.find(o => o.value === c.rightField)?.label
|
||||
?? compositeFieldLabel(c.indicatorType, 2, c.rightPeriod);
|
||||
const lCt = getCompositeLeftCandleType(c);
|
||||
const rCt = getCompositeRightCandleType(c);
|
||||
const tf = lCt !== rCt ? ` [${lCt}/${rCt}]` : ` [${lCt}]`;
|
||||
return `${c.indicatorType} - ${L} ${C} ${R}${tf}`;
|
||||
}
|
||||
const lv = resolveFieldOptionValue(c.indicatorType, c.leftField);
|
||||
const rv = resolveFieldOptionValue(c.indicatorType, c.rightField);
|
||||
@@ -734,11 +757,59 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
|
||||
};
|
||||
|
||||
if (normalized.composite) {
|
||||
const leftOpts = getCompositeFieldOpts(normalized.indicatorType, 1, def, normalized);
|
||||
const rightOpts = getCompositeFieldOpts(normalized.indicatorType, 2, def, normalized);
|
||||
const leftField = normalized.leftField ?? leftOpts[0]?.value ?? 'NONE';
|
||||
const rightField = normalized.rightField ?? rightOpts[0]?.value ?? 'NONE';
|
||||
const leftIsPreset = leftOpts.some(o => o.value === leftField);
|
||||
const rightIsPreset = rightOpts.some(o => o.value === rightField);
|
||||
const leftPeriod = getConditionValuePeriod(normalized, def);
|
||||
const rightPeriod = getConditionRightPeriod(normalized, def);
|
||||
const periodPresetsLeft = getCompositePeriodPresetOptions(normalized.indicatorType, def, 'left');
|
||||
const periodPresetsRight = getCompositePeriodPresetOptions(normalized.indicatorType, def, 'right');
|
||||
|
||||
const handleCompositeField = (side: 'left' | 'right', field: string) => {
|
||||
const p = parsePeriodFromCompositeField(normalized.indicatorType, field);
|
||||
if (p == null) return;
|
||||
if (side === 'left') {
|
||||
onChange(syncCompositeFields({ ...normalized, leftPeriod: p, leftField: field }));
|
||||
} else {
|
||||
onChange(syncCompositeFields({ ...normalized, rightPeriod: p, rightField: field }));
|
||||
}
|
||||
};
|
||||
|
||||
const leftCt = getCompositeLeftCandleType(normalized);
|
||||
const rightCt = getCompositeRightCandleType(normalized);
|
||||
|
||||
return (
|
||||
<div className="sp-cond-editor sp-cond-editor--composite">
|
||||
<div className="sp-cond-composite-badge">복합지표 · {compositeDisplayName(normalized.indicatorType)}</div>
|
||||
<div className="sp-cond-row-composite-tf">
|
||||
<div className="sp-cond-field">
|
||||
<label className="sp-cond-lbl">{compositeElementLabel(normalized.indicatorType, 1)} 시간봉</label>
|
||||
<select
|
||||
className="sp-cond-sel"
|
||||
value={leftCt}
|
||||
onChange={e => onChange(setCompositeLeftCandleType(normalized, e.target.value))}
|
||||
>
|
||||
{STRATEGY_CANDLE_TYPE_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sp-cond-field">
|
||||
<label className="sp-cond-lbl">{compositeElementLabel(normalized.indicatorType, 2)} 시간봉</label>
|
||||
<select
|
||||
className="sp-cond-sel"
|
||||
value={rightCt}
|
||||
onChange={e => onChange(setCompositeRightCandleType(normalized, e.target.value))}
|
||||
>
|
||||
{STRATEGY_CANDLE_TYPE_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sp-cond-row4">
|
||||
<div className="sp-cond-field">
|
||||
<label className="sp-cond-lbl">캔들 범위</label>
|
||||
@@ -752,23 +823,35 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
|
||||
</select>
|
||||
</div>
|
||||
<div className="sp-cond-field">
|
||||
<label className="sp-cond-lbl">{compositeElementLabel(normalized.indicatorType, 1)} 기준값 (기간)</label>
|
||||
<ComboNumberInput
|
||||
value={leftPeriod}
|
||||
options={getCompositePeriodPresetOptions(normalized.indicatorType, def, 'left')}
|
||||
<label className="sp-cond-lbl">조건대상1</label>
|
||||
<ComboFieldSelect
|
||||
options={leftOpts}
|
||||
fieldValue={leftField}
|
||||
isPresetField={leftIsPreset}
|
||||
customKind="period"
|
||||
customNumber={leftPeriod}
|
||||
customOptionLabel={compositeFieldLabel(normalized.indicatorType, 1, leftPeriod)}
|
||||
numberPresets={periodPresetsLeft}
|
||||
min={1}
|
||||
max={500}
|
||||
onChange={p => onChange(setConditionValuePeriod(normalized, p, def))}
|
||||
onFieldChange={f => handleCompositeField('left', f)}
|
||||
onCustomNumberChange={p => onChange(setConditionValuePeriod(normalized, p, def))}
|
||||
/>
|
||||
</div>
|
||||
<div className="sp-cond-field">
|
||||
<label className="sp-cond-lbl">{compositeElementLabel(normalized.indicatorType, 2)} 기준값 (기간)</label>
|
||||
<ComboNumberInput
|
||||
value={rightPeriod}
|
||||
options={getCompositePeriodPresetOptions(normalized.indicatorType, def, 'right')}
|
||||
<label className="sp-cond-lbl">조건대상2</label>
|
||||
<ComboFieldSelect
|
||||
options={rightOpts}
|
||||
fieldValue={rightField}
|
||||
isPresetField={rightIsPreset}
|
||||
customKind="period"
|
||||
customNumber={rightPeriod}
|
||||
customOptionLabel={compositeFieldLabel(normalized.indicatorType, 2, rightPeriod)}
|
||||
numberPresets={periodPresetsRight}
|
||||
min={1}
|
||||
max={500}
|
||||
onChange={p => onChange(setConditionRightPeriod(normalized, p))}
|
||||
onFieldChange={f => handleCompositeField('right', f)}
|
||||
onCustomNumberChange={p => onChange(setConditionRightPeriod(normalized, p))}
|
||||
/>
|
||||
</div>
|
||||
<div className="sp-cond-field">
|
||||
@@ -803,20 +886,57 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
|
||||
{/* 조건대상1 */}
|
||||
<div className="sp-cond-field">
|
||||
<label className="sp-cond-lbl">조건대상1</label>
|
||||
<select className="sp-cond-sel"
|
||||
value={isHoldType ? 'NONE' : getLeftValue()}
|
||||
onChange={e => onChange({ ...cond, leftField: e.target.value })}>
|
||||
{fieldOpts.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<ComboFieldSelect
|
||||
options={fieldOpts}
|
||||
fieldValue={isHoldType ? 'NONE' : (normalized.leftField ?? getLeftValue())}
|
||||
isPresetField={!isHoldType && (
|
||||
fieldOpts.some(o => o.value === normalized.leftField)
|
||||
|| (fieldOpts.some(o => o.value === getLeftValue())
|
||||
&& !isValuePeriodFieldStored(normalized.indicatorType, normalized.leftField))
|
||||
)}
|
||||
customKind={resolveFieldCustomKind(normalized.indicatorType, normalized.leftField)}
|
||||
customNumber={getConditionValuePeriod(normalized, def)}
|
||||
numberPresets={getPeriodPresetOptions(normalized.indicatorType, def)}
|
||||
min={1}
|
||||
max={500}
|
||||
disabled={isHoldType}
|
||||
onFieldChange={v => {
|
||||
const bases: Record<string, string> = {
|
||||
RSI: 'RSI_VALUE', CCI: 'CCI_VALUE', ADX: 'ADX_VALUE',
|
||||
WILLIAMS_R: 'WILLIAMS_R_VALUE', TRIX: 'TRIX_VALUE', VR: 'VR_VALUE',
|
||||
PSYCHOLOGICAL: 'PSY_VALUE', NEW_PSYCHOLOGICAL: 'PSY_VALUE',
|
||||
INVEST_PSYCHOLOGICAL: 'INVEST_PSY_VALUE',
|
||||
};
|
||||
const base = bases[normalized.indicatorType];
|
||||
if (base && v === base) {
|
||||
const p = getConditionValuePeriod(normalized, def);
|
||||
onChange({ ...normalized, leftField: `${base}_${p}`, period: p });
|
||||
} else {
|
||||
onChange({ ...normalized, leftField: v });
|
||||
}
|
||||
}}
|
||||
onCustomNumberChange={p => onChange(setConditionValuePeriod(normalized, p, def))}
|
||||
/>
|
||||
</div>
|
||||
{/* 조건대상2 */}
|
||||
<div className="sp-cond-field">
|
||||
<label className="sp-cond-lbl">조건대상2</label>
|
||||
<select className="sp-cond-sel"
|
||||
value={rightValue}
|
||||
onChange={e => handleRight(e.target.value)}>
|
||||
{fieldOpts.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<ComboFieldSelect
|
||||
options={fieldOpts}
|
||||
fieldValue={rightValue}
|
||||
isPresetField={fieldOpts.some(o => o.value === normalized.rightField)}
|
||||
customKind={resolveFieldCustomKind(normalized.indicatorType, normalized.rightField)}
|
||||
customNumber={getConditionThreshold(normalized, 'right')
|
||||
?? parseThresholdField(normalized.rightField)
|
||||
?? 0}
|
||||
numberPresets={getThresholdPresetOptions(normalized.indicatorType)}
|
||||
min={getThresholdBounds(normalized.indicatorType).min}
|
||||
max={getThresholdBounds(normalized.indicatorType).max}
|
||||
allowDecimal={normalized.indicatorType === 'CCI'}
|
||||
disabled={isSlopeType || isHoldType}
|
||||
onFieldChange={handleRight}
|
||||
onCustomNumberChange={v => onChange(setConditionThreshold(normalized, 'right', v))}
|
||||
/>
|
||||
</div>
|
||||
{/* 조건 */}
|
||||
<div className="sp-cond-field">
|
||||
@@ -858,7 +978,38 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
|
||||
};
|
||||
|
||||
// ─── 노드 생성 헬퍼 ───────────────────────────────────────────────────────────
|
||||
export type MakeNodeOptions = { composite?: boolean };
|
||||
export type MakeNodeOptions = {
|
||||
composite?: boolean;
|
||||
/** 단일 보조지표 기간 오버라이드 */
|
||||
period?: number;
|
||||
/** 복합지표 단기·장기 기간 오버라이드 */
|
||||
leftPeriod?: number;
|
||||
rightPeriod?: number;
|
||||
leftCandleType?: string;
|
||||
rightCandleType?: string;
|
||||
};
|
||||
|
||||
/** 팔레트 드래그 payload → makeNode 옵션 */
|
||||
export function makeNodeOptionsFromPalette(data: {
|
||||
composite?: boolean;
|
||||
period?: number;
|
||||
shortPeriod?: number;
|
||||
longPeriod?: number;
|
||||
leftCandleType?: string;
|
||||
rightCandleType?: string;
|
||||
}): MakeNodeOptions | undefined {
|
||||
if (data.composite) {
|
||||
return {
|
||||
composite: true,
|
||||
leftPeriod: data.shortPeriod,
|
||||
rightPeriod: data.longPeriod,
|
||||
leftCandleType: data.leftCandleType,
|
||||
rightCandleType: data.rightCandleType,
|
||||
};
|
||||
}
|
||||
if (data.period != null) return { period: data.period };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const makeNode = (
|
||||
type: string,
|
||||
@@ -869,20 +1020,28 @@ export const makeNode = (
|
||||
): LogicNode => {
|
||||
if (type === 'operator') return { id: genId(), type: value as LogicNodeType, children: [] };
|
||||
if (options?.composite) {
|
||||
return {
|
||||
id: genId(),
|
||||
type: 'CONDITION',
|
||||
condition: makeCompositeCondition(value, signalType, DEF),
|
||||
};
|
||||
let condition = makeCompositeCondition(value, signalType, DEF);
|
||||
if (options.leftPeriod != null || options.rightPeriod != null
|
||||
|| options.leftCandleType != null || options.rightCandleType != null) {
|
||||
condition = syncCompositeFields({
|
||||
...condition,
|
||||
leftPeriod: options.leftPeriod ?? condition.leftPeriod,
|
||||
rightPeriod: options.rightPeriod ?? condition.rightPeriod,
|
||||
leftCandleType: options.leftCandleType ?? condition.leftCandleType,
|
||||
rightCandleType: options.rightCandleType ?? condition.rightCandleType,
|
||||
});
|
||||
}
|
||||
return { id: genId(), type: 'CONDITION', condition };
|
||||
}
|
||||
const def = getDefaultFields(value, signalType, DEF);
|
||||
const period = options?.period ?? getDefaultIndicatorPeriod(value, DEF);
|
||||
const baseCondition: ConditionDSL = {
|
||||
indicatorType: value,
|
||||
conditionType: signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN',
|
||||
leftField: def.l,
|
||||
rightField: def.r,
|
||||
candleRange: 1,
|
||||
period: getDefaultIndicatorPeriod(value, DEF),
|
||||
period,
|
||||
};
|
||||
return {
|
||||
id: genId(), type: 'CONDITION',
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/** 전략편집기 우측 팔레트 — 보조·복합 지표 목록 (localStorage) */
|
||||
import { COMPOSITE_INDICATOR_ITEMS } from './compositeIndicators';
|
||||
|
||||
export type PaletteItemKind = 'auxiliary' | 'composite';
|
||||
|
||||
export interface PaletteItem {
|
||||
id: string;
|
||||
kind: PaletteItemKind;
|
||||
/** 지표 타입 (RSI, CCI, …) */
|
||||
value: string;
|
||||
label: string;
|
||||
desc: string;
|
||||
/** 단일 보조지표 기간 */
|
||||
period?: number;
|
||||
/** 복합지표 단기·장기 기간 */
|
||||
shortPeriod?: number;
|
||||
longPeriod?: number;
|
||||
builtIn?: boolean;
|
||||
}
|
||||
|
||||
const STORAGE_AUX = 'se-palette-auxiliary-v1';
|
||||
const STORAGE_COMP = 'se-palette-composite-v1';
|
||||
|
||||
export const DEFAULT_AUXILIARY_ITEMS: Omit<PaletteItem, 'id' | 'kind'>[] = [
|
||||
{ value: 'RSI', label: 'RSI', desc: '상대강도지수', builtIn: true },
|
||||
{ value: 'MACD', label: 'MACD', desc: '이동평균 수렴확산', builtIn: true },
|
||||
{ value: 'STOCHASTIC', label: 'Stochastic', desc: '스토캐스틱', builtIn: true },
|
||||
{ value: 'CCI', label: 'CCI', desc: '상품채널지수', builtIn: true },
|
||||
{ value: 'ADX', label: 'ADX', desc: '평균방향지수', builtIn: true },
|
||||
{ value: 'DMI', label: 'DMI', desc: '방향성 지표', builtIn: true },
|
||||
{ value: 'OBV', label: 'OBV', desc: '거래량 균형', builtIn: true },
|
||||
{ value: 'WILLIAMS_R', label: 'Williams %R', desc: '윌리엄스 %R', builtIn: true },
|
||||
{ value: 'TRIX', label: 'TRIX', desc: '삼중지수이동평균', builtIn: true },
|
||||
{ value: 'VOLUME_OSC', label: 'Volume OSC', desc: '거래량 오실레이터', builtIn: true },
|
||||
{ value: 'VR', label: 'VR', desc: '거래량 비율', builtIn: true },
|
||||
{ value: 'DISPARITY', label: '이격도', desc: 'Disparity', builtIn: true },
|
||||
{ value: 'PSYCHOLOGICAL', label: '심리도', desc: 'Psychological', builtIn: true },
|
||||
{ value: 'INVEST_PSYCHOLOGICAL', label: '투자심리도', desc: 'Invest PSY', builtIn: true },
|
||||
{ value: 'VOLUME', label: '거래량', desc: 'Volume', builtIn: true },
|
||||
];
|
||||
|
||||
export const DEFAULT_COMPOSITE_ITEMS: Omit<PaletteItem, 'id' | 'kind'>[] =
|
||||
COMPOSITE_INDICATOR_ITEMS.map(i => ({
|
||||
value: i.value,
|
||||
label: i.label,
|
||||
desc: i.desc,
|
||||
builtIn: true,
|
||||
}));
|
||||
|
||||
export const AUXILIARY_TYPE_OPTIONS = DEFAULT_AUXILIARY_ITEMS.map(i => ({
|
||||
value: i.value,
|
||||
label: i.label,
|
||||
}));
|
||||
|
||||
export const COMPOSITE_TYPE_OPTIONS = DEFAULT_COMPOSITE_ITEMS.map(i => ({
|
||||
value: i.value,
|
||||
label: i.label,
|
||||
}));
|
||||
|
||||
function builtinId(kind: PaletteItemKind, value: string): string {
|
||||
return `builtin-${kind}-${value}`;
|
||||
}
|
||||
|
||||
function seedItems(
|
||||
kind: PaletteItemKind,
|
||||
defaults: Omit<PaletteItem, 'id' | 'kind'>[],
|
||||
): PaletteItem[] {
|
||||
return defaults.map(d => ({
|
||||
...d,
|
||||
id: builtinId(kind, d.value),
|
||||
kind,
|
||||
}));
|
||||
}
|
||||
|
||||
function parseStored(raw: string | null): PaletteItem[] | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const arr = JSON.parse(raw) as PaletteItem[];
|
||||
if (!Array.isArray(arr)) return null;
|
||||
return arr.filter(
|
||||
i => i && typeof i.id === 'string' && typeof i.value === 'string' && i.label,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadPaletteItems(kind: PaletteItemKind): PaletteItem[] {
|
||||
const key = kind === 'auxiliary' ? STORAGE_AUX : STORAGE_COMP;
|
||||
const defaults = kind === 'auxiliary' ? DEFAULT_AUXILIARY_ITEMS : DEFAULT_COMPOSITE_ITEMS;
|
||||
try {
|
||||
const stored = parseStored(localStorage.getItem(key));
|
||||
if (stored && stored.length > 0) {
|
||||
return stored.map(i => ({ ...i, kind }));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return seedItems(kind, defaults);
|
||||
}
|
||||
|
||||
export function savePaletteItems(kind: PaletteItemKind, items: PaletteItem[]): void {
|
||||
const key = kind === 'auxiliary' ? STORAGE_AUX : STORAGE_COMP;
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(items));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function resetPaletteItems(kind: PaletteItemKind): PaletteItem[] {
|
||||
const defaults = kind === 'auxiliary' ? DEFAULT_AUXILIARY_ITEMS : DEFAULT_COMPOSITE_ITEMS;
|
||||
const items = seedItems(kind, defaults);
|
||||
savePaletteItems(kind, items);
|
||||
return items;
|
||||
}
|
||||
|
||||
export function createPaletteItemId(): string {
|
||||
return `custom-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
@@ -39,3 +39,25 @@ export function normalizeStartCandleType(value: string | undefined | null): stri
|
||||
export function formatStartCandleLabel(candleType: string): string {
|
||||
return normalizeStartCandleType(candleType);
|
||||
}
|
||||
|
||||
const CANDLE_TYPE_LABELS: Record<string, string> = {
|
||||
'1m': '1분봉',
|
||||
'3m': '3분봉',
|
||||
'5m': '5분봉',
|
||||
'10m': '10분봉',
|
||||
'15m': '15분봉',
|
||||
'30m': '30분봉',
|
||||
'1h': '1시간봉',
|
||||
'4h': '4시간봉',
|
||||
'1d': '일봉',
|
||||
};
|
||||
|
||||
export function formatStrategyCandleLabel(candleType: string): string {
|
||||
const ct = normalizeStartCandleType(candleType);
|
||||
return CANDLE_TYPE_LABELS[ct] ?? ct;
|
||||
}
|
||||
|
||||
export const STRATEGY_CANDLE_TYPE_OPTIONS = STRATEGY_CANDLE_TYPES.map(ct => ({
|
||||
value: ct,
|
||||
label: CANDLE_TYPE_LABELS[ct] ?? ct,
|
||||
}));
|
||||
|
||||
@@ -11,6 +11,9 @@ export interface ConditionDSL {
|
||||
composite?: boolean;
|
||||
leftPeriod?: number;
|
||||
rightPeriod?: number;
|
||||
/** 복합지표 — 요소1·요소2 각각의 조건 판별 시간봉 (1m, 5m, …) */
|
||||
leftCandleType?: string;
|
||||
rightCandleType?: string;
|
||||
leftField?: string;
|
||||
rightField?: string;
|
||||
compareValue?: number;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { coerceFiniteNumber } from './safeFormat';
|
||||
import {
|
||||
coerceFiniteNumber,
|
||||
formatIndicatorValue,
|
||||
isConditionMet,
|
||||
type VirtualConditionRow,
|
||||
@@ -149,10 +149,21 @@ export function formatStrategyThreshold(
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeConditionRow(
|
||||
row: VirtualConditionRow & { currentValue?: unknown },
|
||||
): VirtualConditionRow & { currentValue: number | null } {
|
||||
return {
|
||||
...row,
|
||||
targetValue: coerceFiniteNumber(row.targetValue),
|
||||
currentValue: coerceFiniteNumber(row.currentValue ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildConditionMetrics(
|
||||
rows: Array<VirtualConditionRow & { currentValue: number | null }>,
|
||||
): ConditionMetric[] {
|
||||
return rows.map(row => {
|
||||
return rows.map(raw => {
|
||||
const row = normalizeConditionRow(raw);
|
||||
const matchRate = computeConditionMatchRate(row);
|
||||
return {
|
||||
row,
|
||||
|
||||
@@ -5,6 +5,9 @@ import type { LogicNode } from './strategyTypes';
|
||||
import { CONDITION_LABEL } from './strategyTypes';
|
||||
import type { StrategyDto } from './backendApi';
|
||||
import { formatIndicatorDisplayLabel } from './indicatorRegistry';
|
||||
import { coerceFiniteNumber, safeToFixed } from './safeFormat';
|
||||
|
||||
export { coerceFiniteNumber } from './safeFormat';
|
||||
|
||||
export interface VirtualConditionRow {
|
||||
id: string;
|
||||
@@ -71,14 +74,6 @@ function walk(
|
||||
node.children?.forEach(c => walk(c, timeframe, side, out, seen));
|
||||
}
|
||||
|
||||
/** API·DSL에서 문자열로 올 수 있는 값을 안전하게 숫자로 변환 */
|
||||
export function coerceFiniteNumber(v: unknown): number | null {
|
||||
if (v == null || v === '') return null;
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export function extractVirtualConditions(strategy: StrategyDto | null | undefined): VirtualConditionRow[] {
|
||||
if (!strategy) return [];
|
||||
const out: VirtualConditionRow[] = [];
|
||||
@@ -112,5 +107,5 @@ export function formatIndicatorValue(v: number | null | unknown): string {
|
||||
const n = coerceFiniteNumber(v);
|
||||
if (n == null) return '—';
|
||||
if (Math.abs(n) >= 1000) return n.toLocaleString(undefined, { maximumFractionDigits: 0 });
|
||||
return n.toFixed(2);
|
||||
return safeToFixed(n, 2);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user