전략편집기 수정
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import ComboNumberInput from './ComboNumberInput';
|
||||
|
||||
export const COMBO_FIELD_CUSTOM = '__field_custom__';
|
||||
|
||||
export type FieldOption = { value: string; label: string };
|
||||
|
||||
export type FieldCustomKind = 'period' | 'threshold' | 'none';
|
||||
|
||||
interface Props {
|
||||
options: FieldOption[];
|
||||
/** 현재 DSL 필드값 (예: CCI_VALUE_9, K_80) */
|
||||
fieldValue: string;
|
||||
/** 프리셋 목록에 있는 값인지 */
|
||||
isPresetField: boolean;
|
||||
customKind: FieldCustomKind;
|
||||
/** 직접입력 시 표시·편집할 숫자 (기간 또는 임계값) */
|
||||
customNumber: number;
|
||||
numberPresets?: number[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
allowDecimal?: boolean;
|
||||
onFieldChange: (field: string) => void;
|
||||
onCustomNumberChange: (n: number) => void;
|
||||
/** 직접입력 선택 시 상단 select에 표시할 라벨 (예: CCI 1 라인(37일)) */
|
||||
customOptionLabel?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function ComboFieldSelect({
|
||||
options,
|
||||
fieldValue,
|
||||
isPresetField,
|
||||
customKind,
|
||||
customNumber,
|
||||
numberPresets = [],
|
||||
min = 1,
|
||||
max = 500,
|
||||
allowDecimal = false,
|
||||
onFieldChange,
|
||||
onCustomNumberChange,
|
||||
customOptionLabel,
|
||||
disabled = false,
|
||||
}: Props) {
|
||||
const canCustom = customKind !== 'none';
|
||||
const [mode, setMode] = useState<'preset' | 'custom'>(() =>
|
||||
(canCustom && !isPresetField) ? 'custom' : 'preset',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canCustom) {
|
||||
setMode('preset');
|
||||
return;
|
||||
}
|
||||
/* 프리셋 필드여도 직접입력 모드는 유지 — isPresetField만으로 preset으로 되돌리지 않음 */
|
||||
if (!isPresetField) setMode('custom');
|
||||
}, [isPresetField, canCustom, fieldValue]);
|
||||
|
||||
const selectValue = mode === 'custom' ? COMBO_FIELD_CUSTOM : fieldValue;
|
||||
|
||||
const handleSelect = (raw: string) => {
|
||||
if (raw === COMBO_FIELD_CUSTOM) {
|
||||
if (canCustom) setMode('custom');
|
||||
return;
|
||||
}
|
||||
setMode('preset');
|
||||
onFieldChange(raw);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="se-combo-field">
|
||||
<select
|
||||
className="se-combo-field-select sp-cond-sel"
|
||||
value={selectValue}
|
||||
disabled={disabled}
|
||||
onChange={e => handleSelect(e.target.value)}
|
||||
aria-label="조건 대상 선택"
|
||||
>
|
||||
{options.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
{canCustom && (
|
||||
<option value={COMBO_FIELD_CUSTOM}>
|
||||
{mode === 'custom' && customOptionLabel ? customOptionLabel : '직접입력'}
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
{mode === 'custom' && canCustom && (
|
||||
<ComboNumberInput
|
||||
value={customNumber}
|
||||
options={numberPresets}
|
||||
min={min}
|
||||
max={max}
|
||||
allowDecimal={allowDecimal}
|
||||
alwaysShowInput
|
||||
disabled={disabled}
|
||||
onChange={onCustomNumberChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import React, { useEffect, useId, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
export const COMBO_CUSTOM = '__custom__';
|
||||
|
||||
interface Props {
|
||||
value: number;
|
||||
@@ -7,6 +9,9 @@ interface Props {
|
||||
max?: number;
|
||||
allowDecimal?: boolean;
|
||||
onChange: (value: number) => void;
|
||||
/** true: 상단 입력창 항상 표시 + 하단 목록 선택 (조건대상 직접입력) */
|
||||
alwaysShowInput?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function sanitizeDraft(raw: string, allowDecimal: boolean): string {
|
||||
@@ -30,6 +35,17 @@ function parseDraft(raw: string, allowDecimal: boolean): number | null {
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function clampValue(
|
||||
parsed: number,
|
||||
min: number,
|
||||
max: number,
|
||||
allowDecimal: boolean,
|
||||
): number {
|
||||
return allowDecimal
|
||||
? Math.max(min, Math.min(max, parsed))
|
||||
: Math.max(min, Math.min(max, Math.round(parsed)));
|
||||
}
|
||||
|
||||
export default function ComboNumberInput({
|
||||
value,
|
||||
options,
|
||||
@@ -37,56 +53,104 @@ export default function ComboNumberInput({
|
||||
max = 500,
|
||||
allowDecimal = false,
|
||||
onChange,
|
||||
alwaysShowInput = false,
|
||||
disabled = false,
|
||||
}: Props) {
|
||||
const listId = useId().replace(/:/g, '');
|
||||
const uniqueOptions = [...new Set(options)].sort((a, b) => a - b);
|
||||
const isPreset = uniqueOptions.includes(value);
|
||||
const [mode, setMode] = useState<'preset' | 'custom'>(() =>
|
||||
(alwaysShowInput || !isPreset) ? 'custom' : 'preset',
|
||||
);
|
||||
const [draft, setDraft] = useState(String(value));
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focused) setDraft(String(value));
|
||||
}, [value, focused]);
|
||||
if (alwaysShowInput) {
|
||||
if (!focused) setDraft(String(value));
|
||||
return;
|
||||
}
|
||||
if (isPreset) {
|
||||
setMode('preset');
|
||||
if (!focused) setDraft(String(value));
|
||||
} else {
|
||||
setMode('custom');
|
||||
if (!focused) setDraft(String(value));
|
||||
}
|
||||
}, [value, isPreset, focused, alwaysShowInput]);
|
||||
|
||||
const commit = () => {
|
||||
const commitCustom = () => {
|
||||
const parsed = parseDraft(draft, allowDecimal);
|
||||
if (parsed == null) {
|
||||
setDraft(String(value));
|
||||
return;
|
||||
}
|
||||
const clamped = allowDecimal
|
||||
? Math.max(min, Math.min(max, parsed))
|
||||
: Math.max(min, Math.min(max, Math.round(parsed)));
|
||||
const clamped = clampValue(parsed, min, max, allowDecimal);
|
||||
onChange(clamped);
|
||||
setDraft(String(clamped));
|
||||
};
|
||||
|
||||
const uniqueOptions = [...new Set([...options, value])].sort((a, b) => a - b);
|
||||
const handleSelect = (raw: string) => {
|
||||
if (raw === COMBO_CUSTOM) {
|
||||
if (!alwaysShowInput) setMode('custom');
|
||||
setDraft(String(value));
|
||||
return;
|
||||
}
|
||||
const n = allowDecimal ? parseFloat(raw) : parseInt(raw, 10);
|
||||
if (!Number.isFinite(n)) return;
|
||||
const clamped = clampValue(n, min, max, allowDecimal);
|
||||
if (!alwaysShowInput) setMode('preset');
|
||||
onChange(clamped);
|
||||
setDraft(String(clamped));
|
||||
};
|
||||
|
||||
const showInput = alwaysShowInput || mode === 'custom';
|
||||
const selectValue = alwaysShowInput
|
||||
? (isPreset ? String(value) : '')
|
||||
: (mode === 'custom' || !isPreset ? COMBO_CUSTOM : String(value));
|
||||
|
||||
const inputEl = showInput ? (
|
||||
<input
|
||||
type="text"
|
||||
className="se-combo-num-input"
|
||||
inputMode={allowDecimal ? 'decimal' : 'numeric'}
|
||||
value={draft}
|
||||
placeholder="값 입력"
|
||||
disabled={disabled}
|
||||
onChange={e => setDraft(sanitizeDraft(e.target.value, allowDecimal))}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
commitCustom();
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
aria-label="직접 입력"
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="se-combo-num">
|
||||
<input
|
||||
type="text"
|
||||
className="se-combo-num-input"
|
||||
inputMode={allowDecimal ? 'decimal' : 'numeric'}
|
||||
list={listId}
|
||||
value={draft}
|
||||
onChange={e => setDraft(sanitizeDraft(e.target.value, allowDecimal))}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
commit();
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<datalist id={listId}>
|
||||
<div className={`se-combo-num${alwaysShowInput ? ' se-combo-num--always-input' : ''}`}>
|
||||
{alwaysShowInput ? inputEl : null}
|
||||
<select
|
||||
className="se-combo-num-select sp-cond-sel"
|
||||
value={selectValue}
|
||||
disabled={disabled}
|
||||
onChange={e => handleSelect(e.target.value)}
|
||||
aria-label="목록에서 선택"
|
||||
>
|
||||
{alwaysShowInput && !isPreset && (
|
||||
<option value="" disabled>목록에서 선택</option>
|
||||
)}
|
||||
{uniqueOptions.map(opt => (
|
||||
<option key={opt} value={String(opt)} />
|
||||
<option key={opt} value={String(opt)}>{opt}</option>
|
||||
))}
|
||||
</datalist>
|
||||
{!alwaysShowInput && <option value={COMBO_CUSTOM}>직접입력</option>}
|
||||
</select>
|
||||
{!alwaysShowInput ? inputEl : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { useEffect, useRef } from 'react';
|
||||
import type { ConditionDSL } from '../../utils/strategyTypes';
|
||||
import type { DefType } from '../../utils/strategyEditorShared';
|
||||
import {
|
||||
getCompositeLeftCandleType,
|
||||
getCompositeRightCandleType,
|
||||
getConditionRightPeriod,
|
||||
getConditionThreshold,
|
||||
getConditionValuePeriod,
|
||||
@@ -10,12 +12,15 @@ import {
|
||||
getThresholdBounds,
|
||||
getThresholdPresetOptions,
|
||||
hasEditableThreshold,
|
||||
setCompositeLeftCandleType,
|
||||
setCompositeRightCandleType,
|
||||
setConditionRightPeriod,
|
||||
setConditionThreshold,
|
||||
setConditionValuePeriod,
|
||||
usesValuePeriodField,
|
||||
} from '../../utils/conditionPeriods';
|
||||
import { compositeDisplayName, compositeElementLabel } from '../../utils/compositeIndicators';
|
||||
import { STRATEGY_CANDLE_TYPE_OPTIONS } from '../../utils/strategyStartNodes';
|
||||
import ComboNumberInput from './ComboNumberInput';
|
||||
|
||||
interface Props {
|
||||
@@ -67,6 +72,30 @@ export default function ConditionNodeSettings({
|
||||
</div>
|
||||
{condition.composite ? (
|
||||
<>
|
||||
<label className="se-flow-settings-field">
|
||||
<span>{compositeElementLabel(condition.indicatorType, 1)} 시간봉</span>
|
||||
<select
|
||||
className="se-combo-num-select sp-cond-sel"
|
||||
value={getCompositeLeftCandleType(condition)}
|
||||
onChange={e => onChange(setCompositeLeftCandleType(condition, e.target.value))}
|
||||
>
|
||||
{STRATEGY_CANDLE_TYPE_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="se-flow-settings-field">
|
||||
<span>{compositeElementLabel(condition.indicatorType, 2)} 시간봉</span>
|
||||
<select
|
||||
className="se-combo-num-select sp-cond-sel"
|
||||
value={getCompositeRightCandleType(condition)}
|
||||
onChange={e => onChange(setCompositeRightCandleType(condition, e.target.value))}
|
||||
>
|
||||
{STRATEGY_CANDLE_TYPE_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="se-flow-settings-field">
|
||||
<span>{compositeElementLabel(condition.indicatorType, 1)} 기준값 (기간, 일)</span>
|
||||
<ComboNumberInput
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import PaletteChip from './PaletteChip';
|
||||
import PaletteItemModal from './PaletteItemModal';
|
||||
import type { DefType } from '../../utils/strategyEditorShared';
|
||||
import { getCompositeDefaultPeriods } from '../../utils/compositeIndicators';
|
||||
import { getDefaultIndicatorPeriod } from '../../utils/conditionPeriods';
|
||||
import { getIndicatorPeriodLabel } from '../../utils/strategyFlowLayout';
|
||||
import {
|
||||
createPaletteItemId,
|
||||
savePaletteItems,
|
||||
type PaletteItem,
|
||||
type PaletteItemKind,
|
||||
} from '../../utils/strategyPaletteStorage';
|
||||
|
||||
function periodLabel(item: PaletteItem, def: DefType): string {
|
||||
if (item.kind === 'composite') {
|
||||
const d = getCompositeDefaultPeriods(item.value, def);
|
||||
const s = item.shortPeriod ?? d.short;
|
||||
const l = item.longPeriod ?? d.long;
|
||||
return `${s} / ${l}`;
|
||||
}
|
||||
if (item.period != null) return String(item.period);
|
||||
return getIndicatorPeriodLabel(item.value, def) || String(getDefaultIndicatorPeriod(item.value, def));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
kind: PaletteItemKind;
|
||||
items: PaletteItem[];
|
||||
onItemsChange: (items: PaletteItem[]) => void;
|
||||
def: DefType;
|
||||
searchQuery: string;
|
||||
selectedItemId: string | null;
|
||||
onSelectItem: (id: string | null) => void;
|
||||
onAddToCanvas: (item: PaletteItem) => void;
|
||||
}
|
||||
|
||||
export default function IndicatorPaletteTab({
|
||||
kind, items, onItemsChange, def, searchQuery, selectedItemId, onSelectItem, onAddToCanvas,
|
||||
}: Props) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<'add' | 'edit'>('add');
|
||||
const [editTarget, setEditTarget] = useState<PaletteItem | null>(null);
|
||||
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
const filtered = useMemo(() => {
|
||||
if (!q) return items;
|
||||
return items.filter(
|
||||
i => i.label.toLowerCase().includes(q)
|
||||
|| i.desc.toLowerCase().includes(q)
|
||||
|| i.value.toLowerCase().includes(q),
|
||||
);
|
||||
}, [items, q]);
|
||||
|
||||
const selected = items.find(i => i.id === selectedItemId) ?? null;
|
||||
|
||||
const persist = useCallback((next: PaletteItem[]) => {
|
||||
onItemsChange(next);
|
||||
savePaletteItems(kind, next);
|
||||
}, [kind, onItemsChange]);
|
||||
|
||||
const openAdd = () => {
|
||||
setEditTarget(null);
|
||||
setModalMode('add');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = () => {
|
||||
if (!selected) {
|
||||
window.alert('수정할 지표 카드를 먼저 선택해 주세요.');
|
||||
return;
|
||||
}
|
||||
setEditTarget(selected);
|
||||
setModalMode('edit');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!selected) {
|
||||
window.alert('삭제할 지표 카드를 먼저 선택해 주세요.');
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`「${selected.label}」을(를) 목록에서 삭제할까요?`)) return;
|
||||
const next = items.filter(i => i.id !== selected.id);
|
||||
persist(next);
|
||||
onSelectItem(null);
|
||||
};
|
||||
|
||||
const handleSave = (draft: Omit<PaletteItem, 'id' | 'kind'> & { id?: string }) => {
|
||||
if (modalMode === 'add') {
|
||||
const item: PaletteItem = {
|
||||
id: createPaletteItemId(),
|
||||
kind,
|
||||
value: draft.value,
|
||||
label: draft.label,
|
||||
desc: draft.desc,
|
||||
period: draft.period,
|
||||
shortPeriod: draft.shortPeriod,
|
||||
longPeriod: draft.longPeriod,
|
||||
};
|
||||
persist([...items, item]);
|
||||
onSelectItem(item.id);
|
||||
return;
|
||||
}
|
||||
const id = draft.id ?? editTarget?.id;
|
||||
if (!id) return;
|
||||
persist(items.map(i => (i.id === id ? {
|
||||
...i,
|
||||
label: draft.label,
|
||||
desc: draft.desc,
|
||||
period: draft.period,
|
||||
shortPeriod: draft.shortPeriod,
|
||||
longPeriod: draft.longPeriod,
|
||||
} : i)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`se-palette-section se-palette-section--${kind === 'auxiliary' ? 'aux' : 'composite'} se-palette-section--scroll`}>
|
||||
<div className="se-palette-toolbar">
|
||||
<span className="se-palette-toolbar-title">{kind === 'auxiliary' ? '보조지표' : '복합지표'}</span>
|
||||
<div className="se-palette-toolbar-actions" role="toolbar" aria-label={`${kind === 'auxiliary' ? '보조' : '복합'}지표 관리`}>
|
||||
<button
|
||||
type="button"
|
||||
className="se-palette-tool-btn"
|
||||
title="지표 추가"
|
||||
aria-label="지표 추가"
|
||||
onClick={openAdd}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden>
|
||||
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="se-palette-tool-btn"
|
||||
title="선택 지표 수정"
|
||||
aria-label="선택 지표 수정"
|
||||
disabled={!selected}
|
||||
onClick={openEdit}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden>
|
||||
<path d="M4 20h4l10-10-4-4L4 16v4z" stroke="currentColor" strokeWidth="1.75" fill="none" strokeLinejoin="round" />
|
||||
<path d="M14 6l4 4" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="se-palette-tool-btn se-palette-tool-btn--danger"
|
||||
title="선택 지표 삭제"
|
||||
aria-label="선택 지표 삭제"
|
||||
disabled={!selected}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden>
|
||||
<path d="M6 7h12M9 7V5h6v2M10 11v6M14 11v6M8 7l1 12h6l1-12" stroke="currentColor" strokeWidth="1.75" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="se-palette-grid se-palette-grid--3">
|
||||
{filtered.map(item => (
|
||||
<PaletteChip
|
||||
key={item.id}
|
||||
type="indicator"
|
||||
value={item.value}
|
||||
label={item.label}
|
||||
desc={item.desc}
|
||||
color={kind === 'composite' ? 'composite' : 'ind'}
|
||||
composite={kind === 'composite'}
|
||||
period={periodLabel(item, def)}
|
||||
periodValue={item.period}
|
||||
shortPeriod={item.shortPeriod}
|
||||
longPeriod={item.longPeriod}
|
||||
selected={selectedItemId === item.id}
|
||||
onSelect={() => onSelectItem(item.id)}
|
||||
onAdd={() => onAddToCanvas(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<p className="se-palette-empty">검색 결과가 없습니다.</p>
|
||||
)}
|
||||
|
||||
<PaletteItemModal
|
||||
open={modalOpen}
|
||||
mode={modalMode}
|
||||
kind={kind}
|
||||
initial={editTarget}
|
||||
def={def}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,9 @@ export interface PaletteDragPayload {
|
||||
value: string;
|
||||
label: string;
|
||||
composite?: boolean;
|
||||
period?: number;
|
||||
shortPeriod?: number;
|
||||
longPeriod?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -14,6 +17,9 @@ interface Props {
|
||||
desc?: string;
|
||||
color?: string;
|
||||
period?: string;
|
||||
periodValue?: number;
|
||||
shortPeriod?: number;
|
||||
longPeriod?: number;
|
||||
composite?: boolean;
|
||||
selected?: boolean;
|
||||
onSelect?: () => void;
|
||||
@@ -21,10 +27,17 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function PaletteChip({
|
||||
type, value, label, desc, color, period, composite = false, selected = false, onSelect, onAdd,
|
||||
type, value, label, desc, color, period, periodValue, shortPeriod, longPeriod,
|
||||
composite = false, selected = false, onSelect, onAdd,
|
||||
}: Props) {
|
||||
const onDragStart = (e: React.DragEvent) => {
|
||||
const payload: PaletteDragPayload = { type, value, label, composite: composite || undefined };
|
||||
const payload: PaletteDragPayload = {
|
||||
type, value, label,
|
||||
composite: composite || undefined,
|
||||
period: periodValue,
|
||||
shortPeriod,
|
||||
longPeriod,
|
||||
};
|
||||
e.dataTransfer.setData('application/json', JSON.stringify(payload));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
};
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import DraggableModalFrame from '../DraggableModalFrame';
|
||||
import ComboNumberInput from './ComboNumberInput';
|
||||
import type { DefType } from '../../utils/strategyEditorShared';
|
||||
import { getCompositeDefaultPeriods } from '../../utils/compositeIndicators';
|
||||
import { getDefaultIndicatorPeriod, getCompositePeriodPresetOptions, getPeriodPresetOptions } from '../../utils/conditionPeriods';
|
||||
import {
|
||||
AUXILIARY_TYPE_OPTIONS,
|
||||
COMPOSITE_TYPE_OPTIONS,
|
||||
type PaletteItem,
|
||||
type PaletteItemKind,
|
||||
} from '../../utils/strategyPaletteStorage';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
mode: 'add' | 'edit';
|
||||
kind: PaletteItemKind;
|
||||
initial?: PaletteItem | null;
|
||||
def: DefType;
|
||||
onClose: () => void;
|
||||
onSave: (item: Omit<PaletteItem, 'id' | 'kind'> & { id?: string }) => void;
|
||||
}
|
||||
|
||||
export default function PaletteItemModal({
|
||||
open, mode, kind, initial, def, onClose, onSave,
|
||||
}: Props) {
|
||||
const typeOptions = kind === 'auxiliary' ? AUXILIARY_TYPE_OPTIONS : COMPOSITE_TYPE_OPTIONS;
|
||||
const [value, setValue] = useState(typeOptions[0]?.value ?? 'RSI');
|
||||
const [label, setLabel] = useState('');
|
||||
const [desc, setDesc] = useState('');
|
||||
const [period, setPeriod] = useState(14);
|
||||
const [shortPeriod, setShortPeriod] = useState(9);
|
||||
const [longPeriod, setLongPeriod] = useState(20);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (mode === 'edit' && initial) {
|
||||
setValue(initial.value);
|
||||
setLabel(initial.label);
|
||||
setDesc(initial.desc);
|
||||
if (kind === 'auxiliary') {
|
||||
setPeriod(initial.period ?? getDefaultIndicatorPeriod(initial.value, def));
|
||||
} else {
|
||||
const d = getCompositeDefaultPeriods(initial.value, def);
|
||||
setShortPeriod(initial.shortPeriod ?? d.short);
|
||||
setLongPeriod(initial.longPeriod ?? d.long);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const v = typeOptions[0]?.value ?? 'RSI';
|
||||
setValue(v);
|
||||
const opt = typeOptions.find(o => o.value === v);
|
||||
setLabel(opt?.label ?? v);
|
||||
setDesc(kind === 'auxiliary' ? '보조지표' : `${opt?.label ?? v} 기간 교차`);
|
||||
setPeriod(getDefaultIndicatorPeriod(v, def));
|
||||
const cp = getCompositeDefaultPeriods(v, def);
|
||||
setShortPeriod(cp.short);
|
||||
setLongPeriod(cp.long);
|
||||
}, [open, mode, initial, kind, def, typeOptions]);
|
||||
|
||||
const handleTypeChange = (next: string) => {
|
||||
setValue(next);
|
||||
const opt = typeOptions.find(o => o.value === next);
|
||||
if (mode === 'add') {
|
||||
setLabel(opt?.label ?? next);
|
||||
setDesc(kind === 'auxiliary'
|
||||
? (DEFAULT_DESC[next] ?? '보조지표')
|
||||
: `${opt?.label ?? next} 기간 교차`);
|
||||
}
|
||||
setPeriod(getDefaultIndicatorPeriod(next, def));
|
||||
const cp = getCompositeDefaultPeriods(next, def);
|
||||
setShortPeriod(cp.short);
|
||||
setLongPeriod(cp.long);
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const title = mode === 'add'
|
||||
? (kind === 'auxiliary' ? '보조지표 추가' : '복합지표 추가')
|
||||
: (kind === 'auxiliary' ? '보조지표 수정' : '복합지표 수정');
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmedLabel = label.trim();
|
||||
if (!trimmedLabel) {
|
||||
window.alert('표시 이름을 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
if (kind === 'composite' && shortPeriod >= longPeriod) {
|
||||
window.alert('단기 기간은 장기 기간보다 작아야 합니다.');
|
||||
return;
|
||||
}
|
||||
onSave({
|
||||
id: initial?.id,
|
||||
value,
|
||||
label: trimmedLabel,
|
||||
desc: desc.trim() || trimmedLabel,
|
||||
builtIn: initial?.builtIn,
|
||||
...(kind === 'auxiliary' ? { period } : { shortPeriod, longPeriod }),
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<DraggableModalFrame onClose={onClose} title={title}>
|
||||
<div className="se-modal-body se-palette-item-modal">
|
||||
<label className="se-field-lbl">지표 종류</label>
|
||||
<select
|
||||
className="se-field-inp"
|
||||
value={value}
|
||||
disabled={mode === 'edit'}
|
||||
onChange={e => handleTypeChange(e.target.value)}
|
||||
>
|
||||
{typeOptions.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="se-field-lbl">표시 이름</label>
|
||||
<input
|
||||
className="se-field-inp"
|
||||
value={label}
|
||||
onChange={e => setLabel(e.target.value)}
|
||||
placeholder="예: RSI"
|
||||
/>
|
||||
|
||||
<label className="se-field-lbl">설명</label>
|
||||
<input
|
||||
className="se-field-inp"
|
||||
value={desc}
|
||||
onChange={e => setDesc(e.target.value)}
|
||||
placeholder="짧은 설명"
|
||||
/>
|
||||
|
||||
{kind === 'auxiliary' ? (
|
||||
<label className="se-field-lbl">
|
||||
기간 (일)
|
||||
<ComboNumberInput
|
||||
value={period}
|
||||
options={getPeriodPresetOptions(value, def)}
|
||||
min={1}
|
||||
max={500}
|
||||
onChange={setPeriod}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<>
|
||||
<label className="se-field-lbl">
|
||||
단기 기간 (일)
|
||||
<ComboNumberInput
|
||||
value={shortPeriod}
|
||||
options={getCompositePeriodPresetOptions(value, def, 'left')}
|
||||
min={1}
|
||||
max={500}
|
||||
onChange={setShortPeriod}
|
||||
/>
|
||||
</label>
|
||||
<label className="se-field-lbl">
|
||||
장기 기간 (일)
|
||||
<ComboNumberInput
|
||||
value={longPeriod}
|
||||
options={getCompositePeriodPresetOptions(value, def, 'right')}
|
||||
min={1}
|
||||
max={500}
|
||||
onChange={setLongPeriod}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="se-modal-actions">
|
||||
<button type="button" className="se-btn se-btn--ghost" onClick={onClose}>취소</button>
|
||||
<button type="button" className="se-btn se-btn--gold" onClick={handleSubmit}>
|
||||
{mode === 'add' ? '추가' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DraggableModalFrame>
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_DESC: Record<string, string> = {
|
||||
RSI: '상대강도지수',
|
||||
MACD: '이동평균 수렴확산',
|
||||
STOCHASTIC: '스토캐스틱',
|
||||
CCI: '상품채널지수',
|
||||
ADX: '평균방향지수',
|
||||
DMI: '방향성 지표',
|
||||
OBV: '거래량 균형',
|
||||
WILLIAMS_R: '윌리엄스 %R',
|
||||
TRIX: '삼중지수이동평균',
|
||||
VOLUME_OSC: '거래량 오실레이터',
|
||||
VR: '거래량 비율',
|
||||
DISPARITY: 'Disparity',
|
||||
PSYCHOLOGICAL: 'Psychological',
|
||||
INVEST_PSYCHOLOGICAL: 'Invest PSY',
|
||||
VOLUME: 'Volume',
|
||||
};
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
deleteNode,
|
||||
mergeAtRoot,
|
||||
makeNode,
|
||||
makeNodeOptionsFromPalette,
|
||||
updateNode,
|
||||
type DefType,
|
||||
} from '../../utils/strategyEditorShared';
|
||||
@@ -556,7 +557,7 @@ function StrategyEditorCanvasInner({
|
||||
data: { type: string; value: string; label: string; composite?: boolean },
|
||||
flowPos: { x: number; y: number },
|
||||
) => {
|
||||
const newNode = makeNode(data.type, data.value, signalTab, def, data.composite ? { composite: true } : undefined);
|
||||
const newNode = makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
|
||||
positionsRef.current.set(newNode.id, {
|
||||
x: flowPos.x - FLOW_NODE_W / 2,
|
||||
y: flowPos.y - FLOW_NODE_H / 2,
|
||||
@@ -575,7 +576,7 @@ function StrategyEditorCanvasInner({
|
||||
return;
|
||||
}
|
||||
|
||||
const newNode = makeNode(data.type, data.value, signalTab, def, data.composite ? { composite: true } : undefined);
|
||||
const newNode = makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
|
||||
const anchor = nodesRef.current.find(n => n.id === anchorId);
|
||||
if (!anchor) return;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CondEditor,
|
||||
addChild,
|
||||
makeNode,
|
||||
makeNodeOptionsFromPalette,
|
||||
mergeAtRoot,
|
||||
nodeToText,
|
||||
type DefType,
|
||||
@@ -213,7 +214,7 @@ function StartSectionBlock({
|
||||
onAddStart?.();
|
||||
return;
|
||||
}
|
||||
const newNode = makeNode(data.type, data.value, signalTab, def, data.composite ? { composite: true } : undefined);
|
||||
const newNode = makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
|
||||
if (!root) {
|
||||
onRootChange(newNode);
|
||||
return;
|
||||
|
||||
@@ -63,3 +63,18 @@ export function storeSize(key: string, value: number): void {
|
||||
localStorage.setItem(key, String(Math.round(value)));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function readStoredBool(key: string, fallback: boolean): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === '1' || raw === 'true') return true;
|
||||
if (raw === '0' || raw === 'false') return false;
|
||||
} catch { /* ignore */ }
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function storeBool(key: string, value: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(key, value ? '1' : '0');
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user