전략편집기 수정, 보조지표 설정 수정

This commit is contained in:
Macbook
2026-05-27 23:59:30 +09:00
parent 8cc0d1c88c
commit 2713b2951d
17 changed files with 210 additions and 46 deletions
@@ -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) {
@@ -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 = """
+17
View File
@@ -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;
+22 -3
View File
@@ -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>
+32 -7
View File
@@ -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"
+10 -1
View File
@@ -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,
}; };
} }
+1
View File
@@ -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; }
+1 -4
View File
@@ -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);
+14 -3
View File
@@ -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);
+2 -3
View File
@@ -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',
+9 -15
View File
@@ -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)}