전략편집기 수정, 보조지표 설정 수정
This commit is contained in:
@@ -39,12 +39,16 @@ public class StrategyDslTimeframeNormalizer {
|
|||||||
if (root == null || root.isNull()) return root;
|
if (root == null || root.isNull()) return root;
|
||||||
|
|
||||||
if ("TIMEFRAME".equals(root.path("type").asText(""))) {
|
if ("TIMEFRAME".equals(root.path("type").asText(""))) {
|
||||||
// START 다중 분봉(candleTypes) — 편집기 저장값 유지
|
// START 다중 분봉(candleTypes) — 편집기 저장값 유지, candleType 은 배열 첫 값과 동기화
|
||||||
if (hasMultipleCandleTypes(root)) {
|
if (hasCandleTypesArray(root)) {
|
||||||
|
return syncTimeframePrimary(root);
|
||||||
|
}
|
||||||
|
String current = LiveStrategyTimeframeService.normalize(root.path("candleType").asText("1m"));
|
||||||
|
// 편집기에서 명시한 단일 분봉(3m, 5m 등)은 조건 노드 기본 1m 추론으로 덮어쓰지 않음
|
||||||
|
if (!"1m".equals(current)) {
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
String expected = inferPrimaryCandleType(root, strategyName);
|
String expected = inferPrimaryCandleType(root, strategyName);
|
||||||
String current = LiveStrategyTimeframeService.normalize(root.path("candleType").asText("1m"));
|
|
||||||
if (!expected.equals(current)) {
|
if (!expected.equals(current)) {
|
||||||
return copyTimeframeWithCandleType(root, expected);
|
return copyTimeframeWithCandleType(root, expected);
|
||||||
}
|
}
|
||||||
@@ -197,9 +201,20 @@ public class StrategyDslTimeframeNormalizer {
|
|||||||
return "1m";
|
return "1m";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean hasMultipleCandleTypes(JsonNode timeframeNode) {
|
private static boolean hasCandleTypesArray(JsonNode timeframeNode) {
|
||||||
JsonNode arr = timeframeNode.path("candleTypes");
|
JsonNode arr = timeframeNode.path("candleTypes");
|
||||||
return arr.isArray() && arr.size() > 1;
|
return arr.isArray() && !arr.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** candleTypes[] 가 있으면 candleType 을 첫 항목과 맞춤 (stale 1m 방지) */
|
||||||
|
private ObjectNode syncTimeframePrimary(JsonNode tf) {
|
||||||
|
ObjectNode copy = tf.deepCopy();
|
||||||
|
JsonNode arr = tf.path("candleTypes");
|
||||||
|
if (arr.isArray() && !arr.isEmpty()) {
|
||||||
|
String primary = LiveStrategyTimeframeService.normalize(arr.get(0).asText("1m"));
|
||||||
|
copy.put("candleType", primary);
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ObjectNode copyTimeframeWithCandleType(JsonNode tf, String candleType) {
|
private ObjectNode copyTimeframeWithCandleType(JsonNode tf, String candleType) {
|
||||||
|
|||||||
+24
@@ -55,6 +55,30 @@ class StrategyConditionTimeframeServiceTest {
|
|||||||
assertFalse(service.usesTimeframe(1L, "1m"));
|
assertFalse(service.usesTimeframe(1L, "1m"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void collectForStrategy_reads3mAnd5mWithout1m() {
|
||||||
|
GcStrategy onlyUpper = new GcStrategy();
|
||||||
|
onlyUpper.setId(4L);
|
||||||
|
onlyUpper.setBuyConditionJson("""
|
||||||
|
{
|
||||||
|
"type": "TIMEFRAME",
|
||||||
|
"candleType": "3m",
|
||||||
|
"candleTypes": ["3m", "5m"],
|
||||||
|
"children": [{
|
||||||
|
"type": "CONDITION",
|
||||||
|
"condition": { "indicatorType": "RSI", "period": 14 }
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
when(strategyRepo.findById(4L)).thenReturn(Optional.of(onlyUpper));
|
||||||
|
|
||||||
|
Set<String> tfs = service.collectForStrategy(4L);
|
||||||
|
assertEquals(Set.of("3m", "5m"), tfs);
|
||||||
|
assertFalse(service.usesTimeframe(4L, "1m"));
|
||||||
|
assertTrue(service.usesTimeframe(4L, "3m"));
|
||||||
|
assertTrue(service.usesTimeframe(4L, "5m"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void collectForStrategy_readsMultipleCandleTypesFromTimeframeNode() {
|
void collectForStrategy_readsMultipleCandleTypesFromTimeframeNode() {
|
||||||
GcStrategy multi = new GcStrategy();
|
GcStrategy multi = new GcStrategy();
|
||||||
|
|||||||
@@ -11,6 +11,44 @@ class StrategyDslTimeframeNormalizerTest {
|
|||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
private final StrategyDslTimeframeNormalizer normalizer = new StrategyDslTimeframeNormalizer(objectMapper);
|
private final StrategyDslTimeframeNormalizer normalizer = new StrategyDslTimeframeNormalizer(objectMapper);
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void normalize_preserves3mAnd5mWithout1m() throws Exception {
|
||||||
|
String json = """
|
||||||
|
{
|
||||||
|
"type": "TIMEFRAME",
|
||||||
|
"candleType": "1m",
|
||||||
|
"candleTypes": ["3m", "5m"],
|
||||||
|
"children": [{
|
||||||
|
"type": "CONDITION",
|
||||||
|
"condition": { "indicatorType": "RSI", "period": 14 }
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
String normalized = normalizer.normalizeJson(json, "테스트");
|
||||||
|
JsonNode root = objectMapper.readTree(normalized);
|
||||||
|
assertEquals(2, root.path("candleTypes").size());
|
||||||
|
assertEquals("3m", root.path("candleTypes").get(0).asText());
|
||||||
|
assertEquals("5m", root.path("candleTypes").get(1).asText());
|
||||||
|
assertEquals("3m", root.path("candleType").asText());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void normalize_preservesSingleExplicit3m() throws Exception {
|
||||||
|
String json = """
|
||||||
|
{
|
||||||
|
"type": "TIMEFRAME",
|
||||||
|
"candleType": "3m",
|
||||||
|
"children": [{
|
||||||
|
"type": "CONDITION",
|
||||||
|
"condition": { "indicatorType": "RSI", "period": 14 }
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
String normalized = normalizer.normalizeJson(json, "테스트");
|
||||||
|
JsonNode root = objectMapper.readTree(normalized);
|
||||||
|
assertEquals("3m", root.path("candleType").asText());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void normalize_preservesMultiCandleTypesOnSave() throws Exception {
|
void normalize_preservesMultiCandleTypesOnSave() throws Exception {
|
||||||
String json = """
|
String json = """
|
||||||
|
|||||||
@@ -2871,6 +2871,23 @@ html.theme-blue {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: var(--popup-shadow);
|
box-shadow: var(--popup-shadow);
|
||||||
}
|
}
|
||||||
|
.ism-color-popover-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin: -2px 0 10px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--sep);
|
||||||
|
}
|
||||||
|
.ism-color-popover-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.ism-color-popover-close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
.ism-color-popover-native {
|
.ism-color-popover-native {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
|
|||||||
@@ -52,18 +52,25 @@ const ColorInput: React.FC<ColorInputProps> = ({ value, onChange, disabled = fal
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const onDoc = (e: MouseEvent) => {
|
const onDocPointer = (e: Event) => {
|
||||||
const t = e.target as Node;
|
const t = e.target as Node;
|
||||||
if (anchorRef.current?.contains(t) || popoverRef.current?.contains(t)) return;
|
if (anchorRef.current?.contains(t) || popoverRef.current?.contains(t)) return;
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
document.addEventListener('mousedown', onDoc);
|
// 설정 모달 등이 bubble 단계에서 stopPropagation 하므로 capture 사용
|
||||||
return () => document.removeEventListener('mousedown', onDoc);
|
document.addEventListener('mousedown', onDocPointer, true);
|
||||||
|
document.addEventListener('pointerdown', onDocPointer, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDocPointer, true);
|
||||||
|
document.removeEventListener('pointerdown', onDocPointer, true);
|
||||||
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const patchHex = (h: string) => onChange(formatPlotColor(h, alpha));
|
const patchHex = (h: string) => onChange(formatPlotColor(h, alpha));
|
||||||
const patchAlpha = (a: number) => onChange(formatPlotColor(hex6, a));
|
const patchAlpha = (a: number) => onChange(formatPlotColor(hex6, a));
|
||||||
|
|
||||||
|
const closePopover = useCallback(() => setOpen(false), []);
|
||||||
|
|
||||||
const popover = open && typeof document !== 'undefined' ? (
|
const popover = open && typeof document !== 'undefined' ? (
|
||||||
<div
|
<div
|
||||||
ref={popoverRef}
|
ref={popoverRef}
|
||||||
@@ -72,7 +79,19 @@ const ColorInput: React.FC<ColorInputProps> = ({ value, onChange, disabled = fal
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label="색상 선택"
|
aria-label="색상 선택"
|
||||||
onMouseDown={e => e.stopPropagation()}
|
onMouseDown={e => e.stopPropagation()}
|
||||||
|
onPointerDown={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
<div className="ism-color-popover-header">
|
||||||
|
<span className="ism-color-popover-title">색상 선택</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="gc-popup-close ism-color-popover-close"
|
||||||
|
onClick={closePopover}
|
||||||
|
aria-label="색상 선택 닫기"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="ism-color-popover-native">
|
<div className="ism-color-popover-native">
|
||||||
<label className="ism-color-native-label">
|
<label className="ism-color-native-label">
|
||||||
<span className="ism-style-label">직접 선택</span>
|
<span className="ism-style-label">직접 선택</span>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
genId,
|
genId,
|
||||||
type StrategyDto,
|
type StrategyDto,
|
||||||
} from '../utils/strategyEditorShared';
|
} from '../utils/strategyEditorShared';
|
||||||
import { syncLogicNodeWithGlobalDef } from '../utils/strategyDefSync';
|
import { syncLogicNodeWithGlobalDef, syncLogicRootWithGlobalDef } from '../utils/strategyDefSync';
|
||||||
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
|
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
|
||||||
import { findLogicNode, getIndicatorPeriodLabel } from '../utils/strategyFlowLayout';
|
import { findLogicNode, getIndicatorPeriodLabel } from '../utils/strategyFlowLayout';
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
hasMultipleStartSections,
|
hasMultipleStartSections,
|
||||||
normalizeStartCombineOp,
|
normalizeStartCombineOp,
|
||||||
updateStartCombineOp,
|
updateStartCombineOp,
|
||||||
|
updateStartCandleTypes,
|
||||||
type EditorConditionState,
|
type EditorConditionState,
|
||||||
} from '../utils/strategyConditionSerde';
|
} from '../utils/strategyConditionSerde';
|
||||||
import {
|
import {
|
||||||
@@ -131,7 +132,7 @@ function normalizeTabLayoutState(snap: SignalFlowLayoutSnapshot) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function StrategyEditorPage({ theme }: Props) {
|
export default function StrategyEditorPage({ theme }: Props) {
|
||||||
const { getParams, getVisualConfig, settingsRevision } = useIndicatorSettings();
|
const { getParams, getVisualConfig, settingsRevision, settingsLoaded } = useIndicatorSettings();
|
||||||
const DEF = useMemo(
|
const DEF = useMemo(
|
||||||
() => buildStrategyEditorDefFromSettings(getParams, getVisualConfig),
|
() => buildStrategyEditorDefFromSettings(getParams, getVisualConfig),
|
||||||
[getParams, getVisualConfig, settingsRevision],
|
[getParams, getVisualConfig, settingsRevision],
|
||||||
@@ -391,6 +392,11 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
schedulePersistFlowLayout(layoutStrategyKey);
|
schedulePersistFlowLayout(layoutStrategyKey);
|
||||||
}, [setCurrentRoot, setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
|
}, [setCurrentRoot, setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
|
||||||
|
|
||||||
|
const handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => {
|
||||||
|
const state = signalTab === 'buy' ? buyEditorState : sellEditorState;
|
||||||
|
handleEditorStateChange(updateStartCandleTypes(state, startId, candleTypes));
|
||||||
|
}, [signalTab, buyEditorState, sellEditorState, handleEditorStateChange]);
|
||||||
|
|
||||||
const handleStartCombineOpChange = useCallback((op: StartCombineOp) => {
|
const handleStartCombineOpChange = useCallback((op: StartCombineOp) => {
|
||||||
handleEditorStateChange(updateStartCombineOp(currentEditorState, op));
|
handleEditorStateChange(updateStartCombineOp(currentEditorState, op));
|
||||||
}, [handleEditorStateChange, currentEditorState]);
|
}, [handleEditorStateChange, currentEditorState]);
|
||||||
@@ -491,13 +497,26 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
const sellDecoded = decodeConditionForEditor(s.sellCondition ?? null);
|
const sellDecoded = decodeConditionForEditor(s.sellCondition ?? null);
|
||||||
const stored = loadStrategyFlowLayout(String(s.id));
|
const stored = loadStrategyFlowLayout(String(s.id));
|
||||||
|
|
||||||
|
const buySynced = syncLogicRootWithGlobalDef(
|
||||||
|
buyDecoded.root,
|
||||||
|
stored?.buy.orphans ?? [],
|
||||||
|
DEF,
|
||||||
|
'buy',
|
||||||
|
);
|
||||||
|
const sellSynced = syncLogicRootWithGlobalDef(
|
||||||
|
sellDecoded.root,
|
||||||
|
stored?.sell.orphans ?? [],
|
||||||
|
DEF,
|
||||||
|
'sell',
|
||||||
|
);
|
||||||
|
|
||||||
setSelectedId(s.id);
|
setSelectedId(s.id);
|
||||||
setStratName(s.name);
|
setStratName(s.name);
|
||||||
setStratDesc(s.description ?? '');
|
setStratDesc(s.description ?? '');
|
||||||
setBuyCondition(buyDecoded.root);
|
setBuyCondition(buySynced.root);
|
||||||
setSellCondition(sellDecoded.root);
|
setSellCondition(sellSynced.root);
|
||||||
setBuyOrphans(stored?.buy.orphans ?? []);
|
setBuyOrphans(buySynced.orphans);
|
||||||
setSellOrphans(stored?.sell.orphans ?? []);
|
setSellOrphans(sellSynced.orphans);
|
||||||
setBuyLayout({
|
setBuyLayout({
|
||||||
positions: stored?.buy.positions ?? {},
|
positions: stored?.buy.positions ?? {},
|
||||||
edgeHandles: stored?.buy.edgeHandles ?? {},
|
edgeHandles: stored?.buy.edgeHandles ?? {},
|
||||||
@@ -802,7 +821,12 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
<header className="se-header">
|
<header className="se-header">
|
||||||
<div className="se-header-left">
|
<div className="se-header-left">
|
||||||
<h1 className="se-title">전략 빌더</h1>
|
<h1 className="se-title">전략 빌더</h1>
|
||||||
<span className="se-subtitle">Strategy Builder</span>
|
<span className="se-subtitle">
|
||||||
|
Strategy Builder
|
||||||
|
{!settingsLoaded && (
|
||||||
|
<span className="se-subtitle-hint"> · 보조지표 설정 로드 중…</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="se-header-actions">
|
<div className="se-header-actions">
|
||||||
<div className="se-editor-mode" role="group" aria-label="편집 방식">
|
<div className="se-editor-mode" role="group" aria-label="편집 방식">
|
||||||
@@ -993,6 +1017,7 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
extraStartIds={currentLayout.extraStartIds}
|
extraStartIds={currentLayout.extraStartIds}
|
||||||
extraRoots={currentLayout.extraRoots}
|
extraRoots={currentLayout.extraRoots}
|
||||||
onStartMetaChange={handleStartMetaChange}
|
onStartMetaChange={handleStartMetaChange}
|
||||||
|
onStartCandleTypesChange={handleStartCandleTypesChange}
|
||||||
onExtraStartIdsChange={handleExtraStartIdsChange}
|
onExtraStartIdsChange={handleExtraStartIdsChange}
|
||||||
onExtraRootsChange={handleExtraRootsChange}
|
onExtraRootsChange={handleExtraRootsChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import type { ConditionDSL } from '../../utils/strategyTypes';
|
import type { ConditionDSL } from '../../utils/strategyTypes';
|
||||||
import type { DefType } from '../../utils/strategyEditorShared';
|
import { getDefaultConditionFields, type DefType } from '../../utils/strategyEditorShared';
|
||||||
import {
|
import {
|
||||||
getCompositeLeftCandleType,
|
getCompositeLeftCandleType,
|
||||||
getCompositeRightCandleType,
|
getCompositeRightCandleType,
|
||||||
@@ -27,13 +27,19 @@ import ComboNumberInput from './ComboNumberInput';
|
|||||||
interface Props {
|
interface Props {
|
||||||
condition: ConditionDSL;
|
condition: ConditionDSL;
|
||||||
def: DefType;
|
def: DefType;
|
||||||
|
signalType?: 'buy' | 'sell';
|
||||||
onChange: (c: ConditionDSL) => void;
|
onChange: (c: ConditionDSL) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
popoverClassName?: string;
|
popoverClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ConditionNodeSettings({
|
export default function ConditionNodeSettings({
|
||||||
condition, def, onChange, onClose, popoverClassName = 'se-flow-settings-pop',
|
condition,
|
||||||
|
def,
|
||||||
|
signalType = 'buy',
|
||||||
|
onChange,
|
||||||
|
onClose,
|
||||||
|
popoverClassName = 'se-flow-settings-pop',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -45,7 +51,8 @@ export default function ConditionNodeSettings({
|
|||||||
return () => document.removeEventListener('mousedown', onDoc);
|
return () => document.removeEventListener('mousedown', onDoc);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const rightThreshold = getConditionThreshold(condition, 'right');
|
const inheritedThreshold = getDefaultConditionFields(condition.indicatorType, signalType, def).r;
|
||||||
|
const rightThreshold = getConditionThreshold(condition, 'right', inheritedThreshold);
|
||||||
const showThreshold = hasEditableThreshold(condition) && rightThreshold != null;
|
const showThreshold = hasEditableThreshold(condition) && rightThreshold != null;
|
||||||
const periodPresets = getPeriodPresetOptions(condition.indicatorType, def);
|
const periodPresets = getPeriodPresetOptions(condition.indicatorType, def);
|
||||||
const thresholdPresets = getThresholdPresetOptions(condition.indicatorType);
|
const thresholdPresets = getThresholdPresetOptions(condition.indicatorType);
|
||||||
|
|||||||
@@ -283,6 +283,7 @@ export const ConditionFlowNode = memo(function ConditionFlowNode({ id, data, sel
|
|||||||
<ConditionNodeSettings
|
<ConditionNodeSettings
|
||||||
condition={cond}
|
condition={cond}
|
||||||
def={d.def}
|
def={d.def}
|
||||||
|
signalType={d.signalTab ?? 'buy'}
|
||||||
onChange={handleSettingsChange}
|
onChange={handleSettingsChange}
|
||||||
onClose={() => setSettingsOpen(false)}
|
onClose={() => setSettingsOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ interface Props {
|
|||||||
extraStartIds?: string[];
|
extraStartIds?: string[];
|
||||||
extraRoots?: Record<string, LogicNode | null>;
|
extraRoots?: Record<string, LogicNode | null>;
|
||||||
onStartMetaChange?: (meta: Record<string, StartNodeMeta>) => void;
|
onStartMetaChange?: (meta: Record<string, StartNodeMeta>) => void;
|
||||||
|
/** START 체크박스 — startMeta + 조건 트리 분봉 동기화 */
|
||||||
|
onStartCandleTypesChange?: (startId: string, candleTypes: string[]) => void;
|
||||||
onExtraStartIdsChange?: (ids: string[]) => void;
|
onExtraStartIdsChange?: (ids: string[]) => void;
|
||||||
onExtraRootsChange?: (roots: Record<string, LogicNode | null>) => void;
|
onExtraRootsChange?: (roots: Record<string, LogicNode | null>) => void;
|
||||||
}
|
}
|
||||||
@@ -268,6 +270,7 @@ function StrategyEditorCanvasInner({
|
|||||||
extraStartIds = [],
|
extraStartIds = [],
|
||||||
extraRoots = {},
|
extraRoots = {},
|
||||||
onStartMetaChange,
|
onStartMetaChange,
|
||||||
|
onStartCandleTypesChange,
|
||||||
onExtraStartIdsChange,
|
onExtraStartIdsChange,
|
||||||
onExtraRootsChange,
|
onExtraRootsChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -372,13 +375,17 @@ function StrategyEditorCanvasInner({
|
|||||||
}, [emitLayoutChange]);
|
}, [emitLayoutChange]);
|
||||||
|
|
||||||
const handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => {
|
const handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => {
|
||||||
|
if (onStartCandleTypesChange) {
|
||||||
|
onStartCandleTypesChange(startId, candleTypes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const types = candleTypes.length > 0 ? candleTypes : [DEFAULT_START_CANDLE];
|
const types = candleTypes.length > 0 ? candleTypes : [DEFAULT_START_CANDLE];
|
||||||
const normalized = types.map(normalizeStartCandleType);
|
const normalized = types.map(normalizeStartCandleType);
|
||||||
onStartMetaChange?.({
|
onStartMetaChange?.({
|
||||||
...startMeta,
|
...startMeta,
|
||||||
[startId]: { candleTypes: normalized, candleType: normalized[0] },
|
[startId]: { candleTypes: normalized, candleType: normalized[0] },
|
||||||
});
|
});
|
||||||
}, [startMeta, onStartMetaChange]);
|
}, [startMeta, onStartMetaChange, onStartCandleTypesChange]);
|
||||||
|
|
||||||
const handleDeleteStart = useCallback((startId: string) => {
|
const handleDeleteStart = useCallback((startId: string) => {
|
||||||
if (startId === START_NODE_ID) return;
|
if (startId === START_NODE_ID) return;
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
|||||||
<ConditionNodeSettings
|
<ConditionNodeSettings
|
||||||
condition={node.condition}
|
condition={node.condition}
|
||||||
def={def}
|
def={def}
|
||||||
|
signalType={signalType}
|
||||||
onChange={handleCondChange}
|
onChange={handleCondChange}
|
||||||
onClose={() => setSettingsOpen(false)}
|
onClose={() => setSettingsOpen(false)}
|
||||||
popoverClassName="sp-node-settings-pop"
|
popoverClassName="sp-node-settings-pop"
|
||||||
|
|||||||
@@ -105,9 +105,13 @@ export function invalidateIndicatorSettingsCache() {
|
|||||||
|
|
||||||
export function useIndicatorSettings(sessionKey = 0) {
|
export function useIndicatorSettings(sessionKey = 0) {
|
||||||
const [settingsRevision, setSettingsRevision] = useState(0);
|
const [settingsRevision, setSettingsRevision] = useState(0);
|
||||||
|
const [settingsLoaded, setSettingsLoaded] = useState(
|
||||||
|
() => _cache !== null && _visualCache !== null,
|
||||||
|
);
|
||||||
|
|
||||||
// 로그인/로그아웃 시 지표 설정 재로드
|
// 로그인/로그아웃 시 지표 설정 재로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setSettingsLoaded(false);
|
||||||
invalidateIndicatorSettingsCache();
|
invalidateIndicatorSettingsCache();
|
||||||
const unsub = subscribeIndicatorSettings(() => setSettingsRevision(r => r + 1));
|
const unsub = subscribeIndicatorSettings(() => setSettingsRevision(r => r + 1));
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -117,7 +121,10 @@ export function useIndicatorSettings(sessionKey = 0) {
|
|||||||
ensureVisualLoaded().catch(err =>
|
ensureVisualLoaded().catch(err =>
|
||||||
console.error('[useIndicatorSettings] visual load failed', err),
|
console.error('[useIndicatorSettings] visual load failed', err),
|
||||||
),
|
),
|
||||||
]).then(() => setSettingsRevision(r => r + 1));
|
]).then(() => {
|
||||||
|
setSettingsLoaded(true);
|
||||||
|
setSettingsRevision(r => r + 1);
|
||||||
|
});
|
||||||
return unsub;
|
return unsub;
|
||||||
}, [sessionKey]);
|
}, [sessionKey]);
|
||||||
|
|
||||||
@@ -302,5 +309,7 @@ export function useIndicatorSettings(sessionKey = 0) {
|
|||||||
getVisualConfig,
|
getVisualConfig,
|
||||||
saveVisual,
|
saveVisual,
|
||||||
settingsRevision,
|
settingsRevision,
|
||||||
|
/** DB 파라미터·시각 설정 로드 완료 여부 (전략편집기 DEF 구성 전 대기용) */
|
||||||
|
settingsLoaded,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.se-subtitle { font-size: 0.72rem; color: var(--se-text-muted); }
|
.se-subtitle { font-size: 0.72rem; color: var(--se-text-muted); }
|
||||||
|
.se-subtitle-hint { color: var(--se-accent, #7aa2f7); font-weight: 500; }
|
||||||
|
|
||||||
.se-header-actions { display: flex; align-items: center; gap: 8px; }
|
.se-header-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
|
|||||||
@@ -1298,7 +1298,7 @@ export class ChartManager {
|
|||||||
.find(p => p.id === meta.plotId);
|
.find(p => p.id === meta.plotId);
|
||||||
const prevVal = plotData.length >= 2 ? plotData[plotData.length - 2]?.value : null;
|
const prevVal = plotData.length >= 2 ? plotData[plotData.length - 2]?.value : null;
|
||||||
const barColor = plotDef
|
const barColor = plotDef
|
||||||
? histogramBarColor(curVal as number, prevVal, plotDef, lastPoint.color)
|
? histogramBarColor(curVal as number, prevVal, plotDef)
|
||||||
: (lastPoint.color ?? '#26A69A88');
|
: (lastPoint.color ?? '#26A69A88');
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(entry.seriesList[i] as ISeriesApi<any>).update({
|
(entry.seriesList[i] as ISeriesApi<any>).update({
|
||||||
@@ -2044,9 +2044,6 @@ export class ChartManager {
|
|||||||
if (entry.seriesMeta.some(m => m.isHistogram)) {
|
if (entry.seriesMeta.some(m => m.isHistogram)) {
|
||||||
void this._repaintHistogramSeries(config.id, config);
|
void this._repaintHistogramSeries(config.id, config);
|
||||||
}
|
}
|
||||||
if (entry.seriesMeta.some(m => m.isHistogram)) {
|
|
||||||
void this._repaintHistogramSeries(config.id, config);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._candleOnlyLayout && this._isSubPaneIndicator(entry)) {
|
if (this._candleOnlyLayout && this._isSubPaneIndicator(entry)) {
|
||||||
this._hideSubPaneIndicator(entry);
|
this._hideSubPaneIndicator(entry);
|
||||||
|
|||||||
@@ -168,9 +168,20 @@ export function hasEditableThreshold(cond: ConditionDSL): boolean {
|
|||||||
|| parseThresholdField(cond.leftField) != null;
|
|| parseThresholdField(cond.leftField) != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getConditionThreshold(cond: ConditionDSL, side: 'left' | 'right'): number | null {
|
/**
|
||||||
if (side === 'right' && !isThresholdOverridden(cond)) {
|
* 임계값 표시 — thresholdOverride 가 false 이면 inheritedField(보조지표 설정 DEF) 우선.
|
||||||
return parseThresholdField(cond.rightField);
|
*/
|
||||||
|
export function getConditionThreshold(
|
||||||
|
cond: ConditionDSL,
|
||||||
|
side: 'left' | 'right',
|
||||||
|
inheritedField?: string,
|
||||||
|
): number | null {
|
||||||
|
if (!isThresholdOverridden(cond) && inheritedField) {
|
||||||
|
const fromDef = parseThresholdField(inheritedField);
|
||||||
|
if (fromDef != null) return fromDef;
|
||||||
|
}
|
||||||
|
if (isThresholdOverridden(cond) && cond.targetValue != null && Number.isFinite(cond.targetValue)) {
|
||||||
|
return cond.targetValue;
|
||||||
}
|
}
|
||||||
const field = side === 'left' ? cond.leftField : cond.rightField;
|
const field = side === 'left' ? cond.leftField : cond.rightField;
|
||||||
return parseThresholdField(field);
|
return parseThresholdField(field);
|
||||||
|
|||||||
@@ -73,9 +73,7 @@ export function histogramBarColor(
|
|||||||
value: number,
|
value: number,
|
||||||
prev: number | null | undefined,
|
prev: number | null | undefined,
|
||||||
plot: PlotDef,
|
plot: PlotDef,
|
||||||
pointColor?: string,
|
|
||||||
): string {
|
): string {
|
||||||
if (pointColor) return withHistogramAlpha(pointColor);
|
|
||||||
const isDown = prev != null && !isNaN(prev) && value < prev;
|
const isDown = prev != null && !isNaN(prev) && value < prev;
|
||||||
const isNeg = value < 0;
|
const isNeg = value < 0;
|
||||||
const raw = (isDown || isNeg) ? resolveHistogramDownColor(plot) : resolveHistogramUpColor(plot);
|
const raw = (isDown || isNeg) ? resolveHistogramDownColor(plot) : resolveHistogramUpColor(plot);
|
||||||
@@ -89,6 +87,7 @@ export function mapHistogramSeriesData(
|
|||||||
return filtered.map((p, idx, arr) => ({
|
return filtered.map((p, idx, arr) => ({
|
||||||
time: p.time as import('lightweight-charts').Time,
|
time: p.time as import('lightweight-charts').Time,
|
||||||
value: p.value,
|
value: p.value,
|
||||||
color: histogramBarColor(p.value, idx > 0 ? arr[idx - 1].value : null, plot, p.color),
|
// lightweight-charts-indicators MACD 등이 막대별 color를 넣어도 설정(상승/하락) 색을 우선
|
||||||
|
color: histogramBarColor(p.value, idx > 0 ? arr[idx - 1].value : null, plot),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,7 +264,6 @@ function wrapTimeframe(candleType: string, root: LogicNode): LogicNode {
|
|||||||
|
|
||||||
function wrapTimeframes(candleTypes: string[], root: LogicNode): LogicNode {
|
function wrapTimeframes(candleTypes: string[], root: LogicNode): LogicNode {
|
||||||
const types = normalizeCandleTypesList(candleTypes);
|
const types = normalizeCandleTypesList(candleTypes);
|
||||||
if (types.length === 1) return wrapTimeframe(types[0], root);
|
|
||||||
return {
|
return {
|
||||||
id: generateNodeId(),
|
id: generateNodeId(),
|
||||||
type: 'TIMEFRAME',
|
type: 'TIMEFRAME',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/** Shared strategy editor utilities — extracted from StrategyPage */
|
/** Shared strategy editor utilities — extracted from StrategyPage */
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { IndicatorConfig } from '../types/index';
|
import type { IndicatorConfig } from '../types/index';
|
||||||
import { getIndicatorDef, getHLineLabel, type PlotDef, type HLineDef } from '../utils/indicatorRegistry';
|
import { getIndicatorDef, getHLineLabel, INDICATOR_REGISTRY, type PlotDef, type HLineDef } from '../utils/indicatorRegistry';
|
||||||
import type { LogicNode, LogicNodeType, ConditionDSL } from '../utils/strategyTypes';
|
import type { LogicNode, LogicNodeType, ConditionDSL } from '../utils/strategyTypes';
|
||||||
import {
|
import {
|
||||||
compositeDisplayName,
|
compositeDisplayName,
|
||||||
@@ -270,24 +270,17 @@ export function buildStrategyEditorDefFromSettings(
|
|||||||
defaultHlines?: HLineDef[],
|
defaultHlines?: HLineDef[],
|
||||||
) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: unknown },
|
) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: unknown },
|
||||||
): DefType {
|
): DefType {
|
||||||
const registryTypes = [
|
|
||||||
'RSI', 'MACD', 'CCI', 'Stochastic', 'ADX', 'TRIX', 'DMI', 'BollingerBands',
|
|
||||||
'IchimokuCloud', 'WilliamsPercentRange', 'VolumeOscillator', 'VR', 'Disparity',
|
|
||||||
'Psychological', 'InvestPsychological', 'OBV', 'DonchianChannels', 'PriceExtreme',
|
|
||||||
'SMA',
|
|
||||||
];
|
|
||||||
const configs: IndicatorConfig[] = [];
|
const configs: IndicatorConfig[] = [];
|
||||||
for (const type of registryTypes) {
|
for (const regDef of INDICATOR_REGISTRY) {
|
||||||
const def = getIndicatorDef(type);
|
const type = regDef.type;
|
||||||
if (!def) continue;
|
const params = getParams(type, regDef.defaultParams as Record<string, number | string | boolean>);
|
||||||
const params = getParams(type, def.defaultParams as Record<string, number | string | boolean>);
|
const { plots, hlines, cloudColors } = getVisualConfig(type, regDef.plots, regDef.hlines);
|
||||||
const { plots, hlines, cloudColors } = getVisualConfig(type, def.plots, def.hlines);
|
|
||||||
configs.push({
|
configs.push({
|
||||||
id: `se-def-${type}`,
|
id: `se-def-${type}`,
|
||||||
type,
|
type,
|
||||||
params: { ...params },
|
params: { ...params },
|
||||||
plots,
|
plots: plots.map(p => ({ ...p })),
|
||||||
hlines,
|
hlines: hlines.map(h => ({ ...h })),
|
||||||
...(cloudColors ? { cloudColors } : {}),
|
...(cloudColors ? { cloudColors } : {}),
|
||||||
} as IndicatorConfig);
|
} as IndicatorConfig);
|
||||||
}
|
}
|
||||||
@@ -800,6 +793,7 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
|
|||||||
|
|
||||||
const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v);
|
const isValid = (v: string|undefined) => !!v && fieldOpts.some(o => o.value === v);
|
||||||
const defaults = getDefaultConditionFields(normalized.indicatorType, signalType, def);
|
const defaults = getDefaultConditionFields(normalized.indicatorType, signalType, def);
|
||||||
|
const inheritedThresholdField = defaults.r;
|
||||||
|
|
||||||
const getLeftValue = () => {
|
const getLeftValue = () => {
|
||||||
const v = resolveFieldOptionValue(normalized.indicatorType, normalized.leftField);
|
const v = resolveFieldOptionValue(normalized.indicatorType, normalized.leftField);
|
||||||
@@ -1011,7 +1005,7 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
|
|||||||
fieldValue={rightValue}
|
fieldValue={rightValue}
|
||||||
isPresetField={fieldOpts.some(o => o.value === normalized.rightField)}
|
isPresetField={fieldOpts.some(o => o.value === normalized.rightField)}
|
||||||
customKind={resolveFieldCustomKind(normalized.indicatorType, normalized.rightField)}
|
customKind={resolveFieldCustomKind(normalized.indicatorType, normalized.rightField)}
|
||||||
customNumber={getConditionThreshold(normalized, 'right')
|
customNumber={getConditionThreshold(normalized, 'right', inheritedThresholdField)
|
||||||
?? parseThresholdField(normalized.rightField)
|
?? parseThresholdField(normalized.rightField)
|
||||||
?? 0}
|
?? 0}
|
||||||
numberPresets={getThresholdPresetOptions(normalized.indicatorType)}
|
numberPresets={getThresholdPresetOptions(normalized.indicatorType)}
|
||||||
|
|||||||
Reference in New Issue
Block a user