전략편집기 수정, 보조지표 설정 수정
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user