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

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
+17
View File
@@ -2871,6 +2871,23 @@ html.theme-blue {
border-radius: 8px;
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 {
margin-bottom: 10px;
padding-bottom: 10px;
+22 -3
View File
@@ -52,18 +52,25 @@ const ColorInput: React.FC<ColorInputProps> = ({ value, onChange, disabled = fal
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
const onDocPointer = (e: Event) => {
const t = e.target as Node;
if (anchorRef.current?.contains(t) || popoverRef.current?.contains(t)) return;
setOpen(false);
};
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
// 설정 모달 등이 bubble 단계에서 stopPropagation 하므로 capture 사용
document.addEventListener('mousedown', onDocPointer, true);
document.addEventListener('pointerdown', onDocPointer, true);
return () => {
document.removeEventListener('mousedown', onDocPointer, true);
document.removeEventListener('pointerdown', onDocPointer, true);
};
}, [open]);
const patchHex = (h: string) => onChange(formatPlotColor(h, alpha));
const patchAlpha = (a: number) => onChange(formatPlotColor(hex6, a));
const closePopover = useCallback(() => setOpen(false), []);
const popover = open && typeof document !== 'undefined' ? (
<div
ref={popoverRef}
@@ -72,7 +79,19 @@ const ColorInput: React.FC<ColorInputProps> = ({ value, onChange, disabled = fal
role="dialog"
aria-label="색상 선택"
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">
<label className="ism-color-native-label">
<span className="ism-style-label"> </span>
+32 -7
View File
@@ -18,7 +18,7 @@ import {
genId,
type StrategyDto,
} from '../utils/strategyEditorShared';
import { syncLogicNodeWithGlobalDef } from '../utils/strategyDefSync';
import { syncLogicNodeWithGlobalDef, syncLogicRootWithGlobalDef } from '../utils/strategyDefSync';
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
import { findLogicNode, getIndicatorPeriodLabel } from '../utils/strategyFlowLayout';
import {
@@ -29,6 +29,7 @@ import {
hasMultipleStartSections,
normalizeStartCombineOp,
updateStartCombineOp,
updateStartCandleTypes,
type EditorConditionState,
} from '../utils/strategyConditionSerde';
import {
@@ -131,7 +132,7 @@ function normalizeTabLayoutState(snap: SignalFlowLayoutSnapshot) {
}
export default function StrategyEditorPage({ theme }: Props) {
const { getParams, getVisualConfig, settingsRevision } = useIndicatorSettings();
const { getParams, getVisualConfig, settingsRevision, settingsLoaded } = useIndicatorSettings();
const DEF = useMemo(
() => buildStrategyEditorDefFromSettings(getParams, getVisualConfig),
[getParams, getVisualConfig, settingsRevision],
@@ -391,6 +392,11 @@ export default function StrategyEditorPage({ theme }: Props) {
schedulePersistFlowLayout(layoutStrategyKey);
}, [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) => {
handleEditorStateChange(updateStartCombineOp(currentEditorState, op));
}, [handleEditorStateChange, currentEditorState]);
@@ -491,13 +497,26 @@ export default function StrategyEditorPage({ theme }: Props) {
const sellDecoded = decodeConditionForEditor(s.sellCondition ?? null);
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);
setStratName(s.name);
setStratDesc(s.description ?? '');
setBuyCondition(buyDecoded.root);
setSellCondition(sellDecoded.root);
setBuyOrphans(stored?.buy.orphans ?? []);
setSellOrphans(stored?.sell.orphans ?? []);
setBuyCondition(buySynced.root);
setSellCondition(sellSynced.root);
setBuyOrphans(buySynced.orphans);
setSellOrphans(sellSynced.orphans);
setBuyLayout({
positions: stored?.buy.positions ?? {},
edgeHandles: stored?.buy.edgeHandles ?? {},
@@ -802,7 +821,12 @@ export default function StrategyEditorPage({ theme }: Props) {
<header className="se-header">
<div className="se-header-left">
<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 className="se-header-actions">
<div className="se-editor-mode" role="group" aria-label="편집 방식">
@@ -993,6 +1017,7 @@ export default function StrategyEditorPage({ theme }: Props) {
extraStartIds={currentLayout.extraStartIds}
extraRoots={currentLayout.extraRoots}
onStartMetaChange={handleStartMetaChange}
onStartCandleTypesChange={handleStartCandleTypesChange}
onExtraStartIdsChange={handleExtraStartIdsChange}
onExtraRootsChange={handleExtraRootsChange}
/>
@@ -1,6 +1,6 @@
import React, { useEffect, useRef } from 'react';
import type { ConditionDSL } from '../../utils/strategyTypes';
import type { DefType } from '../../utils/strategyEditorShared';
import { getDefaultConditionFields, type DefType } from '../../utils/strategyEditorShared';
import {
getCompositeLeftCandleType,
getCompositeRightCandleType,
@@ -27,13 +27,19 @@ import ComboNumberInput from './ComboNumberInput';
interface Props {
condition: ConditionDSL;
def: DefType;
signalType?: 'buy' | 'sell';
onChange: (c: ConditionDSL) => void;
onClose: () => void;
popoverClassName?: string;
}
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) {
const ref = useRef<HTMLDivElement>(null);
@@ -45,7 +51,8 @@ export default function ConditionNodeSettings({
return () => document.removeEventListener('mousedown', onDoc);
}, [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 periodPresets = getPeriodPresetOptions(condition.indicatorType, def);
const thresholdPresets = getThresholdPresetOptions(condition.indicatorType);
@@ -283,6 +283,7 @@ export const ConditionFlowNode = memo(function ConditionFlowNode({ id, data, sel
<ConditionNodeSettings
condition={cond}
def={d.def}
signalType={d.signalTab ?? 'buy'}
onChange={handleSettingsChange}
onClose={() => setSettingsOpen(false)}
/>
@@ -105,6 +105,8 @@ interface Props {
extraStartIds?: string[];
extraRoots?: Record<string, LogicNode | null>;
onStartMetaChange?: (meta: Record<string, StartNodeMeta>) => void;
/** START 체크박스 — startMeta + 조건 트리 분봉 동기화 */
onStartCandleTypesChange?: (startId: string, candleTypes: string[]) => void;
onExtraStartIdsChange?: (ids: string[]) => void;
onExtraRootsChange?: (roots: Record<string, LogicNode | null>) => void;
}
@@ -268,6 +270,7 @@ function StrategyEditorCanvasInner({
extraStartIds = [],
extraRoots = {},
onStartMetaChange,
onStartCandleTypesChange,
onExtraStartIdsChange,
onExtraRootsChange,
}: Props) {
@@ -372,13 +375,17 @@ function StrategyEditorCanvasInner({
}, [emitLayoutChange]);
const handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => {
if (onStartCandleTypesChange) {
onStartCandleTypesChange(startId, candleTypes);
return;
}
const types = candleTypes.length > 0 ? candleTypes : [DEFAULT_START_CANDLE];
const normalized = types.map(normalizeStartCandleType);
onStartMetaChange?.({
...startMeta,
[startId]: { candleTypes: normalized, candleType: normalized[0] },
});
}, [startMeta, onStartMetaChange]);
}, [startMeta, onStartMetaChange, onStartCandleTypesChange]);
const handleDeleteStart = useCallback((startId: string) => {
if (startId === START_NODE_ID) return;
@@ -149,6 +149,7 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
<ConditionNodeSettings
condition={node.condition}
def={def}
signalType={signalType}
onChange={handleCondChange}
onClose={() => setSettingsOpen(false)}
popoverClassName="sp-node-settings-pop"
+10 -1
View File
@@ -105,9 +105,13 @@ export function invalidateIndicatorSettingsCache() {
export function useIndicatorSettings(sessionKey = 0) {
const [settingsRevision, setSettingsRevision] = useState(0);
const [settingsLoaded, setSettingsLoaded] = useState(
() => _cache !== null && _visualCache !== null,
);
// 로그인/로그아웃 시 지표 설정 재로드
useEffect(() => {
setSettingsLoaded(false);
invalidateIndicatorSettingsCache();
const unsub = subscribeIndicatorSettings(() => setSettingsRevision(r => r + 1));
Promise.all([
@@ -117,7 +121,10 @@ export function useIndicatorSettings(sessionKey = 0) {
ensureVisualLoaded().catch(err =>
console.error('[useIndicatorSettings] visual load failed', err),
),
]).then(() => setSettingsRevision(r => r + 1));
]).then(() => {
setSettingsLoaded(true);
setSettingsRevision(r => r + 1);
});
return unsub;
}, [sessionKey]);
@@ -302,5 +309,7 @@ export function useIndicatorSettings(sessionKey = 0) {
getVisualConfig,
saveVisual,
settingsRevision,
/** DB 파라미터·시각 설정 로드 완료 여부 (전략편집기 DEF 구성 전 대기용) */
settingsLoaded,
};
}
+1
View File
@@ -57,6 +57,7 @@
}
.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; }
+1 -4
View File
@@ -1298,7 +1298,7 @@ export class ChartManager {
.find(p => p.id === meta.plotId);
const prevVal = plotData.length >= 2 ? plotData[plotData.length - 2]?.value : null;
const barColor = plotDef
? histogramBarColor(curVal as number, prevVal, plotDef, lastPoint.color)
? histogramBarColor(curVal as number, prevVal, plotDef)
: (lastPoint.color ?? '#26A69A88');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(entry.seriesList[i] as ISeriesApi<any>).update({
@@ -2044,9 +2044,6 @@ export class ChartManager {
if (entry.seriesMeta.some(m => m.isHistogram)) {
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)) {
this._hideSubPaneIndicator(entry);
+14 -3
View File
@@ -168,9 +168,20 @@ export function hasEditableThreshold(cond: ConditionDSL): boolean {
|| parseThresholdField(cond.leftField) != null;
}
export function getConditionThreshold(cond: ConditionDSL, side: 'left' | 'right'): number | null {
if (side === 'right' && !isThresholdOverridden(cond)) {
return parseThresholdField(cond.rightField);
/**
* 임계값 표시 — thresholdOverride 가 false 이면 inheritedField(보조지표 설정 DEF) 우선.
*/
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;
return parseThresholdField(field);
+2 -3
View File
@@ -73,9 +73,7 @@ export function histogramBarColor(
value: number,
prev: number | null | undefined,
plot: PlotDef,
pointColor?: string,
): string {
if (pointColor) return withHistogramAlpha(pointColor);
const isDown = prev != null && !isNaN(prev) && value < prev;
const isNeg = value < 0;
const raw = (isDown || isNeg) ? resolveHistogramDownColor(plot) : resolveHistogramUpColor(plot);
@@ -89,6 +87,7 @@ export function mapHistogramSeriesData(
return filtered.map((p, idx, arr) => ({
time: p.time as import('lightweight-charts').Time,
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 {
const types = normalizeCandleTypesList(candleTypes);
if (types.length === 1) return wrapTimeframe(types[0], root);
return {
id: generateNodeId(),
type: 'TIMEFRAME',
+9 -15
View File
@@ -1,7 +1,7 @@
/** Shared strategy editor utilities — extracted from StrategyPage */
import React from 'react';
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 {
compositeDisplayName,
@@ -270,24 +270,17 @@ export function buildStrategyEditorDefFromSettings(
defaultHlines?: HLineDef[],
) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: unknown },
): 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[] = [];
for (const type of registryTypes) {
const def = getIndicatorDef(type);
if (!def) continue;
const params = getParams(type, def.defaultParams as Record<string, number | string | boolean>);
const { plots, hlines, cloudColors } = getVisualConfig(type, def.plots, def.hlines);
for (const regDef of INDICATOR_REGISTRY) {
const type = regDef.type;
const params = getParams(type, regDef.defaultParams as Record<string, number | string | boolean>);
const { plots, hlines, cloudColors } = getVisualConfig(type, regDef.plots, regDef.hlines);
configs.push({
id: `se-def-${type}`,
type,
params: { ...params },
plots,
hlines,
plots: plots.map(p => ({ ...p })),
hlines: hlines.map(h => ({ ...h })),
...(cloudColors ? { cloudColors } : {}),
} 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 defaults = getDefaultConditionFields(normalized.indicatorType, signalType, def);
const inheritedThresholdField = defaults.r;
const getLeftValue = () => {
const v = resolveFieldOptionValue(normalized.indicatorType, normalized.leftField);
@@ -1011,7 +1005,7 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
fieldValue={rightValue}
isPresetField={fieldOpts.some(o => o.value === normalized.rightField)}
customKind={resolveFieldCustomKind(normalized.indicatorType, normalized.rightField)}
customNumber={getConditionThreshold(normalized, 'right')
customNumber={getConditionThreshold(normalized, 'right', inheritedThresholdField)
?? parseThresholdField(normalized.rightField)
?? 0}
numberPresets={getThresholdPresetOptions(normalized.indicatorType)}