지표탭 추가
This commit is contained in:
@@ -1194,6 +1194,31 @@ html.theme-blue {
|
|||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: var(--bg4) transparent;
|
scrollbar-color: var(--bg4) transparent;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
overscroll-behavior-x: contain;
|
||||||
|
}
|
||||||
|
.ind-panel-add-all {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg3);
|
||||||
|
color: var(--text2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s, color 0.1s, border-color 0.1s;
|
||||||
|
}
|
||||||
|
.ind-panel-add-all:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, var(--bg3));
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
||||||
|
}
|
||||||
|
.ind-panel-add-all:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.ind-panel-cats-actions {
|
.ind-panel-cats-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -11492,6 +11517,42 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
|
|||||||
}
|
}
|
||||||
.gc-popup-close:hover { color: var(--text); background: var(--bg4); }
|
.gc-popup-close:hover { color: var(--text); background: var(--bg4); }
|
||||||
|
|
||||||
|
/* 지표 추가 팝업 — 타이틀바 탭 관리 */
|
||||||
|
.ind-panel-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ind-panel-header-btn {
|
||||||
|
padding: 3px 8px;
|
||||||
|
background: var(--bg2);
|
||||||
|
color: var(--text2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.1s, color 0.1s, border-color 0.1s;
|
||||||
|
}
|
||||||
|
.ind-panel-header-btn:hover:not(:disabled) {
|
||||||
|
background: var(--bg4);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.ind-panel-header-btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.ind-panel-header-btn.danger:hover:not(:disabled) {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: #ef4444;
|
||||||
|
border-color: rgba(239, 68, 68, 0.35);
|
||||||
|
}
|
||||||
|
.ind-panel-header-actions .gc-popup-close {
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.mcs-dialog,
|
.mcs-dialog,
|
||||||
.ism-dialog,
|
.ism-dialog,
|
||||||
.indicator-modal,
|
.indicator-modal,
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
mergeIndicators,
|
mergeIndicators,
|
||||||
reorderIndicatorGroup,
|
reorderIndicatorGroup,
|
||||||
splitIndicatorPane,
|
splitIndicatorPane,
|
||||||
|
buildIndicatorConfigsFromTypes,
|
||||||
} from './utils/indicatorPaneMerge';
|
} from './utils/indicatorPaneMerge';
|
||||||
import { useAppSettings } from './hooks/useAppSettings';
|
import { useAppSettings } from './hooks/useAppSettings';
|
||||||
import { clampVirtualTargetMax } from './utils/virtualTargetLimits';
|
import { clampVirtualTargetMax } from './utils/virtualTargetLimits';
|
||||||
@@ -1365,6 +1366,22 @@ function App() {
|
|||||||
});
|
});
|
||||||
}, [layoutDef.count, activeSlot, getParams, getVisualConfig]);
|
}, [layoutDef.count, activeSlot, getParams, getVisualConfig]);
|
||||||
|
|
||||||
|
/** 지표 추가 팝업 — 탭 전체 추가 (한 번에 상태 반영) */
|
||||||
|
const handleAddIndicators = useCallback((types: string[]) => {
|
||||||
|
if (!types.length) return;
|
||||||
|
if (layoutDef.count > 1) {
|
||||||
|
const slotRef = slotRefs.current[activeSlot];
|
||||||
|
if (slotRef) {
|
||||||
|
slotRef.addIndicators(types);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIndicators(prev => {
|
||||||
|
const added = buildIndicatorConfigsFromTypes(types, prev, getParams, getVisualConfig, newIndId);
|
||||||
|
return added.length > 0 ? [...prev, ...added] : prev;
|
||||||
|
});
|
||||||
|
}, [layoutDef.count, activeSlot, getParams, getVisualConfig]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 지표 제거 (type 기준):
|
* 지표 제거 (type 기준):
|
||||||
* - 싱글: 메인 차트에서 제거
|
* - 싱글: 메인 차트에서 제거
|
||||||
@@ -1795,6 +1812,7 @@ function App() {
|
|||||||
onFitContent={() => managerRef.current?.fitContent()}
|
onFitContent={() => managerRef.current?.fitContent()}
|
||||||
onScrollToNow={() => managerRef.current?.scrollToRealTime()}
|
onScrollToNow={() => managerRef.current?.scrollToRealTime()}
|
||||||
onAddIndicator={handleAddIndicator}
|
onAddIndicator={handleAddIndicator}
|
||||||
|
onAddIndicators={handleAddIndicators}
|
||||||
onRemoveByType={handleRemoveByType}
|
onRemoveByType={handleRemoveByType}
|
||||||
onOpenBulkIndicatorSettings={() => setShowBulkIndSettings(true)}
|
onOpenBulkIndicatorSettings={() => setShowBulkIndSettings(true)}
|
||||||
onOpenIndicatorSettings={handleOpenIndicatorSettings}
|
onOpenIndicatorSettings={handleOpenIndicatorSettings}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
mergeIndicators,
|
mergeIndicators,
|
||||||
reorderIndicatorGroup,
|
reorderIndicatorGroup,
|
||||||
splitIndicatorPane,
|
splitIndicatorPane,
|
||||||
|
buildIndicatorConfigsFromTypes,
|
||||||
} from '../utils/indicatorPaneMerge';
|
} from '../utils/indicatorPaneMerge';
|
||||||
import { saveSlot } from '../utils/backendApi';
|
import { saveSlot } from '../utils/backendApi';
|
||||||
import type {
|
import type {
|
||||||
@@ -85,6 +86,8 @@ function newIndId() { return `sind_${++_slotIndCounter}_${Date.now()}`; }
|
|||||||
export interface ChartSlotHandle {
|
export interface ChartSlotHandle {
|
||||||
/** 지표 추가 */
|
/** 지표 추가 */
|
||||||
addIndicator: (type: string, fromConfig?: IndicatorConfig) => void;
|
addIndicator: (type: string, fromConfig?: IndicatorConfig) => void;
|
||||||
|
/** 지표 일괄 추가 (탭 전체 추가) */
|
||||||
|
addIndicators: (types: string[]) => void;
|
||||||
/** 지표 제거 (type 기준) */
|
/** 지표 제거 (type 기준) */
|
||||||
removeIndicatorByType: (type: string) => void;
|
removeIndicatorByType: (type: string) => void;
|
||||||
/** 보조지표 pane 복사 */
|
/** 보조지표 pane 복사 */
|
||||||
@@ -268,6 +271,13 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
return [...prev, cfg];
|
return [...prev, cfg];
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
addIndicators: (types: string[]) => {
|
||||||
|
if (!types.length) return;
|
||||||
|
setIndicators(prev => {
|
||||||
|
const added = buildIndicatorConfigsFromTypes(types, prev, getParams, getVisualConfig, newIndId);
|
||||||
|
return added.length > 0 ? [...prev, ...added] : prev;
|
||||||
|
});
|
||||||
|
},
|
||||||
removeIndicatorByType: (type: string) => {
|
removeIndicatorByType: (type: string) => {
|
||||||
setIndicators(prev => prev.filter(i => i.type !== type));
|
setIndicators(prev => prev.filter(i => i.type !== type));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,9 +27,14 @@ const IndicatorCustomTabEditor: React.FC<IndicatorCustomTabEditorProps> = ({
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
const onKey = (e: KeyboardEvent) => {
|
||||||
window.addEventListener('keydown', onKey);
|
if (e.key === 'Escape') {
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
e.stopImmediatePropagation();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey, true);
|
||||||
|
return () => window.removeEventListener('keydown', onKey, true);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { Theme } from '../types/index';
|
|||||||
import { loadStrategies, saveStrategy, deleteStrategy, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
|
import { loadStrategies, saveStrategy, deleteStrategy, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
|
||||||
import type { LogicNode } from '../utils/strategyTypes';
|
import type { LogicNode } from '../utils/strategyTypes';
|
||||||
import {
|
import {
|
||||||
buildStrategyEditorDef,
|
buildStrategyEditorDefFromSettings,
|
||||||
loadStratsLocal,
|
loadStratsLocal,
|
||||||
saveStratsLocal,
|
saveStratsLocal,
|
||||||
mergeAtRoot,
|
mergeAtRoot,
|
||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
genId,
|
genId,
|
||||||
type StrategyDto,
|
type StrategyDto,
|
||||||
} from '../utils/strategyEditorShared';
|
} from '../utils/strategyEditorShared';
|
||||||
|
import { syncLogicNodeWithGlobalDef } from '../utils/strategyDefSync';
|
||||||
|
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
|
||||||
import { findLogicNode, getIndicatorPeriodLabel } from '../utils/strategyFlowLayout';
|
import { findLogicNode, getIndicatorPeriodLabel } from '../utils/strategyFlowLayout';
|
||||||
import {
|
import {
|
||||||
decodeConditionForEditor,
|
decodeConditionForEditor,
|
||||||
@@ -128,7 +130,12 @@ function normalizeTabLayoutState(snap: SignalFlowLayoutSnapshot) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function StrategyEditorPage({ theme }: Props) {
|
export default function StrategyEditorPage({ theme }: Props) {
|
||||||
const DEF = useMemo(() => buildStrategyEditorDef(), []);
|
const { getParams, getVisualConfig, settingsRevision } = useIndicatorSettings();
|
||||||
|
const DEF = useMemo(
|
||||||
|
() => buildStrategyEditorDefFromSettings(getParams, getVisualConfig),
|
||||||
|
[getParams, getVisualConfig, settingsRevision],
|
||||||
|
);
|
||||||
|
const settingsSyncRef = useRef(0);
|
||||||
|
|
||||||
const [strategies, setStrategies] = useState<StrategyDto[]>(() => loadStratsLocal());
|
const [strategies, setStrategies] = useState<StrategyDto[]>(() => loadStratsLocal());
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
@@ -247,6 +254,36 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
|
|
||||||
const orphanTotal = buyOrphans.length + sellOrphans.length;
|
const orphanTotal = buyOrphans.length + sellOrphans.length;
|
||||||
|
|
||||||
|
/** 보조지표 설정 변경 시 오버라이드되지 않은 조건 노드 기준값 동기화 */
|
||||||
|
useEffect(() => {
|
||||||
|
if (settingsRevision <= settingsSyncRef.current) return;
|
||||||
|
settingsSyncRef.current = settingsRevision;
|
||||||
|
|
||||||
|
const syncExtraRoots = (
|
||||||
|
roots: Record<string, LogicNode | null>,
|
||||||
|
signalType: 'buy' | 'sell',
|
||||||
|
) => {
|
||||||
|
const next: Record<string, LogicNode | null> = {};
|
||||||
|
for (const [key, node] of Object.entries(roots)) {
|
||||||
|
next[key] = node ? syncLogicNodeWithGlobalDef(node, DEF, signalType) : null;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
setBuyCondition(prev => (prev ? syncLogicNodeWithGlobalDef(prev, DEF, 'buy') : prev));
|
||||||
|
setSellCondition(prev => (prev ? syncLogicNodeWithGlobalDef(prev, DEF, 'sell') : prev));
|
||||||
|
setBuyOrphans(prev => prev.map(n => syncLogicNodeWithGlobalDef(n, DEF, 'buy')));
|
||||||
|
setSellOrphans(prev => prev.map(n => syncLogicNodeWithGlobalDef(n, DEF, 'sell')));
|
||||||
|
setBuyLayout(prev => ({
|
||||||
|
...prev,
|
||||||
|
extraRoots: syncExtraRoots(prev.extraRoots ?? {}, 'buy'),
|
||||||
|
}));
|
||||||
|
setSellLayout(prev => ({
|
||||||
|
...prev,
|
||||||
|
extraRoots: syncExtraRoots(prev.extraRoots ?? {}, 'sell'),
|
||||||
|
}));
|
||||||
|
}, [settingsRevision, DEF]);
|
||||||
|
|
||||||
const layoutStrategyKey = selectedId != null ? String(selectedId) : 'draft';
|
const layoutStrategyKey = selectedId != null ? String(selectedId) : 'draft';
|
||||||
|
|
||||||
const persistFlowLayout = useCallback((strategyKey: string) => {
|
const persistFlowLayout = useCallback((strategyKey: string) => {
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export interface ToolbarProps {
|
|||||||
onFitContent: () => void;
|
onFitContent: () => void;
|
||||||
onScrollToNow?: () => void;
|
onScrollToNow?: () => void;
|
||||||
onAddIndicator: (type: string) => void;
|
onAddIndicator: (type: string) => void;
|
||||||
|
onAddIndicators?: (types: string[]) => void;
|
||||||
onRemoveByType: (type: string) => void;
|
onRemoveByType: (type: string) => void;
|
||||||
onAutoFib: () => void;
|
onAutoFib: () => void;
|
||||||
onClearFib: () => void;
|
onClearFib: () => void;
|
||||||
@@ -302,13 +303,14 @@ const CHART_TYPES: { value: ChartType; label: string }[] = [
|
|||||||
interface IndDropdownProps {
|
interface IndDropdownProps {
|
||||||
activeIndicators: IndicatorConfig[];
|
activeIndicators: IndicatorConfig[];
|
||||||
onAdd: (type: string) => void;
|
onAdd: (type: string) => void;
|
||||||
|
onAddMany?: (types: string[]) => void;
|
||||||
onRemove: (type: string) => void;
|
onRemove: (type: string) => void;
|
||||||
onOpenSettings?: (type: string) => void;
|
onOpenSettings?: (type: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const IndDropdown: React.FC<IndDropdownProps> = ({
|
const IndDropdown: React.FC<IndDropdownProps> = ({
|
||||||
activeIndicators, onAdd, onRemove, onOpenSettings, onClose,
|
activeIndicators, onAdd, onAddMany, onRemove, onOpenSettings, onClose,
|
||||||
}) => {
|
}) => {
|
||||||
const [search, setSearch] = React.useState('');
|
const [search, setSearch] = React.useState('');
|
||||||
const [category, setCategory] = React.useState<IndicatorTabKey>('All');
|
const [category, setCategory] = React.useState<IndicatorTabKey>('All');
|
||||||
@@ -316,6 +318,7 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
|
|||||||
const [editorOpen, setEditorOpen] = React.useState(false);
|
const [editorOpen, setEditorOpen] = React.useState(false);
|
||||||
const [editorMode, setEditorMode] = React.useState<'create' | 'edit'>('create');
|
const [editorMode, setEditorMode] = React.useState<'create' | 'edit'>('create');
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
const searchFocusedRef = useRef(false);
|
||||||
|
|
||||||
const refreshCustomTabs = useCallback(() => {
|
const refreshCustomTabs = useCallback(() => {
|
||||||
setCustomTabs(loadCustomTabs());
|
setCustomTabs(loadCustomTabs());
|
||||||
@@ -358,13 +361,63 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
|
|||||||
setSearch('');
|
setSearch('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addAllContext = React.useMemo(() => {
|
||||||
|
if (category === 'All') return null;
|
||||||
|
if (category === 'Main') {
|
||||||
|
return { label: '주요지표', types: [...MAIN_INDICATOR_TYPES] };
|
||||||
|
}
|
||||||
|
if (isCustomTabKey(category) && selectedCustomTab) {
|
||||||
|
const types = selectedCustomTab.indicatorTypes.filter(
|
||||||
|
t => INDICATOR_REGISTRY.some(d => d.type === t),
|
||||||
|
);
|
||||||
|
return { label: selectedCustomTab.name, types };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [category, selectedCustomTab]);
|
||||||
|
|
||||||
|
const addAllDisabled = category === 'All' || !addAllContext || addAllContext.types.length === 0;
|
||||||
|
|
||||||
|
const handleAddAllInTab = () => {
|
||||||
|
if (!addAllContext || addAllContext.types.length === 0) return;
|
||||||
|
const validTypes = addAllContext.types.filter(
|
||||||
|
type => INDICATOR_REGISTRY.some(d => d.type === type),
|
||||||
|
);
|
||||||
|
const toAdd = validTypes.filter(type => !isActive(type));
|
||||||
|
if (toAdd.length === 0) {
|
||||||
|
window.alert('추가할 지표가 없습니다. (이미 모두 차트에 있습니다)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const msg = `${addAllContext.label} 탭에 있는 지표 ${toAdd.length}개를 차트에 추가하시겠습니까?`;
|
||||||
|
if (!window.confirm(msg)) return;
|
||||||
|
if (onAddMany) {
|
||||||
|
onAddMany(toAdd);
|
||||||
|
} else {
|
||||||
|
toAdd.forEach(type => onAdd(type));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopHeaderBtn = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (editorOpen || searchFocusedRef.current) return;
|
||||||
searchRef.current?.focus();
|
searchRef.current?.focus();
|
||||||
// ESC 키로 닫기
|
searchFocusedRef.current = true;
|
||||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
}, [editorOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
if (editorOpen) {
|
||||||
|
setEditorOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [onClose]);
|
}, [onClose, editorOpen]);
|
||||||
|
|
||||||
const isActive = (type: string) => activeIndicators.some(a => a.type === type);
|
const isActive = (type: string) => activeIndicators.some(a => a.type === type);
|
||||||
|
|
||||||
@@ -429,7 +482,35 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
|
|||||||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||||||
>
|
>
|
||||||
<span className="gc-popup-title">지표 추가</span>
|
<span className="gc-popup-title">지표 추가</span>
|
||||||
|
<div className="ind-panel-header-actions">
|
||||||
<button type="button" className="gc-popup-close" onClick={onClose} aria-label="닫기">✕</button>
|
<button type="button" className="gc-popup-close" onClick={onClose} aria-label="닫기">✕</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ind-panel-header-btn"
|
||||||
|
onPointerDown={stopHeaderBtn}
|
||||||
|
onClick={openCreateEditor}
|
||||||
|
>
|
||||||
|
탭 추가
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ind-panel-header-btn"
|
||||||
|
disabled={!selectedCustomTab}
|
||||||
|
onPointerDown={stopHeaderBtn}
|
||||||
|
onClick={openEditEditor}
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ind-panel-header-btn danger"
|
||||||
|
disabled={!selectedCustomTab}
|
||||||
|
onPointerDown={stopHeaderBtn}
|
||||||
|
onClick={handleDeleteTab}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 검색창 */}
|
{/* 검색창 */}
|
||||||
@@ -451,7 +532,7 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
|
|||||||
|
|
||||||
{/* 카테고리 탭 */}
|
{/* 카테고리 탭 */}
|
||||||
<div className="ind-panel-cats-row">
|
<div className="ind-panel-cats-row">
|
||||||
<div className="ind-panel-cats">
|
<div className="ind-panel-cats" role="tablist" aria-label="지표 탭">
|
||||||
{BUILTIN_TABS.map(c => {
|
{BUILTIN_TABS.map(c => {
|
||||||
const cnt = c.key === 'All'
|
const cnt = c.key === 'All'
|
||||||
? INDICATOR_REGISTRY.length
|
? INDICATOR_REGISTRY.length
|
||||||
@@ -460,6 +541,8 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
|
|||||||
<button
|
<button
|
||||||
key={c.key}
|
key={c.key}
|
||||||
type="button"
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={category === c.key}
|
||||||
className={`ind-panel-cat${category === c.key ? ' active' : ''}`}
|
className={`ind-panel-cat${category === c.key ? ' active' : ''}`}
|
||||||
onClick={() => { setCategory(c.key); setSearch(''); }}
|
onClick={() => { setCategory(c.key); setSearch(''); }}
|
||||||
>
|
>
|
||||||
@@ -472,6 +555,8 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
|
|||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
type="button"
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={category === customTabKey(tab.id)}
|
||||||
className={`ind-panel-cat${category === customTabKey(tab.id) ? ' active' : ''}`}
|
className={`ind-panel-cat${category === customTabKey(tab.id) ? ' active' : ''}`}
|
||||||
onClick={() => { setCategory(customTabKey(tab.id)); setSearch(''); }}
|
onClick={() => { setCategory(customTabKey(tab.id)); setSearch(''); }}
|
||||||
>
|
>
|
||||||
@@ -480,27 +565,16 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="ind-panel-cats-actions">
|
|
||||||
<button type="button" className="ind-panel-cat-action" onClick={openCreateEditor}>
|
|
||||||
추가
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="ind-panel-cat-action"
|
className="ind-panel-add-all"
|
||||||
disabled={!selectedCustomTab}
|
disabled={addAllDisabled}
|
||||||
onClick={openEditEditor}
|
title={addAllDisabled ? '전체 탭에서는 사용할 수 없습니다' : '현재 탭의 모든 지표 추가'}
|
||||||
|
aria-label="현재 탭의 모든 지표 추가"
|
||||||
|
onClick={handleAddAllInTab}
|
||||||
>
|
>
|
||||||
수정
|
<IcPlus />
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ind-panel-cat-action danger"
|
|
||||||
disabled={!selectedCustomTab}
|
|
||||||
onClick={handleDeleteTab}
|
|
||||||
>
|
|
||||||
삭제
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 지표 목록 */}
|
{/* 지표 목록 */}
|
||||||
@@ -618,7 +692,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
symbol, timeframe, chartType, theme, mode, logScale, showGrid,
|
symbol, timeframe, chartType, theme, mode, logScale, showGrid,
|
||||||
activeIndicators,
|
activeIndicators,
|
||||||
onSymbol, onTimeframe, onChartType, onTheme, onMode,
|
onSymbol, onTimeframe, onChartType, onTheme, onMode,
|
||||||
onFitContent, onScrollToNow, onAddIndicator, onRemoveByType,
|
onFitContent, onScrollToNow, onAddIndicator, onAddIndicators, onRemoveByType,
|
||||||
onAutoFib, onClearFib, onScreenshot,
|
onAutoFib, onClearFib, onScreenshot,
|
||||||
onToggleStats, onToggleWatch, onToggleAlert,
|
onToggleStats, onToggleWatch, onToggleAlert,
|
||||||
tradeNotifyUnread = 0, onOpenTradeNotifications,
|
tradeNotifyUnread = 0, onOpenTradeNotifications,
|
||||||
@@ -795,6 +869,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
|
|||||||
<IndDropdown
|
<IndDropdown
|
||||||
activeIndicators={activeIndicators}
|
activeIndicators={activeIndicators}
|
||||||
onAdd={type => onAddIndicator(type)}
|
onAdd={type => onAddIndicator(type)}
|
||||||
|
onAddMany={onAddIndicators}
|
||||||
onRemove={type => onRemoveByType(type)}
|
onRemove={type => onRemoveByType(type)}
|
||||||
onOpenSettings={onOpenIndicatorSettings}
|
onOpenSettings={onOpenIndicatorSettings}
|
||||||
onClose={() => setShowIndMenu(false)}
|
onClose={() => setShowIndMenu(false)}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, useCallback } from 'react';
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
import { getIndicatorDef, mergePlotDefs, normalizeHLines, PlotDef, HLineDef } from '../utils/indicatorRegistry';
|
import { getIndicatorDef, mergePlotDefs, normalizeHLines, PlotDef, HLineDef } from '../utils/indicatorRegistry';
|
||||||
import { createDefaultSmaParams, createDefaultSmaPlots, normalizeSmaConfig } from '../utils/smaConfig';
|
import { createDefaultSmaParams, createDefaultSmaPlots, normalizeSmaConfig } from '../utils/smaConfig';
|
||||||
import { normalizeIchimokuConfig, mergeIchimokuPlots } from '../utils/ichimokuConfig';
|
import { normalizeIchimokuConfig, mergeIchimokuPlots } from '../utils/ichimokuConfig';
|
||||||
@@ -61,6 +61,7 @@ function ensureLoaded(): Promise<AllSettings> {
|
|||||||
_loadPromise = loadIndicatorSettings().then(data => {
|
_loadPromise = loadIndicatorSettings().then(data => {
|
||||||
_cache = (data as AllSettings) ?? {};
|
_cache = (data as AllSettings) ?? {};
|
||||||
_loadPromise = null;
|
_loadPromise = null;
|
||||||
|
notifyIndicatorSettingsChanged();
|
||||||
return _cache;
|
return _cache;
|
||||||
});
|
});
|
||||||
return _loadPromise;
|
return _loadPromise;
|
||||||
@@ -72,11 +73,25 @@ function ensureVisualLoaded(): Promise<VisualCache> {
|
|||||||
_visualLoadPromise = loadIndicatorVisualSettings().then(data => {
|
_visualLoadPromise = loadIndicatorVisualSettings().then(data => {
|
||||||
_visualCache = (data as VisualCache) ?? {};
|
_visualCache = (data as VisualCache) ?? {};
|
||||||
_visualLoadPromise = null;
|
_visualLoadPromise = null;
|
||||||
|
notifyIndicatorSettingsChanged();
|
||||||
return _visualCache;
|
return _visualCache;
|
||||||
});
|
});
|
||||||
return _visualLoadPromise;
|
return _visualLoadPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SettingsListener = () => void;
|
||||||
|
const _settingsListeners = new Set<SettingsListener>();
|
||||||
|
|
||||||
|
function notifyIndicatorSettingsChanged() {
|
||||||
|
_settingsListeners.forEach(fn => fn());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 보조지표 설정 변경 구독 (전략편집기 DEF 동기화 등) */
|
||||||
|
export function subscribeIndicatorSettings(listener: SettingsListener): () => void {
|
||||||
|
_settingsListeners.add(listener);
|
||||||
|
return () => { _settingsListeners.delete(listener); };
|
||||||
|
}
|
||||||
|
|
||||||
/** 캐시를 강제로 무효화 (테스트 또는 로그아웃 시) */
|
/** 캐시를 강제로 무효화 (테스트 또는 로그아웃 시) */
|
||||||
export function invalidateIndicatorSettingsCache() {
|
export function invalidateIndicatorSettingsCache() {
|
||||||
_cache = null;
|
_cache = null;
|
||||||
@@ -88,15 +103,21 @@ export function invalidateIndicatorSettingsCache() {
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function useIndicatorSettings(sessionKey = 0) {
|
export function useIndicatorSettings(sessionKey = 0) {
|
||||||
|
const [settingsRevision, setSettingsRevision] = useState(0);
|
||||||
|
|
||||||
// 로그인/로그아웃 시 지표 설정 재로드
|
// 로그인/로그아웃 시 지표 설정 재로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
invalidateIndicatorSettingsCache();
|
invalidateIndicatorSettingsCache();
|
||||||
|
const unsub = subscribeIndicatorSettings(() => setSettingsRevision(r => r + 1));
|
||||||
|
Promise.all([
|
||||||
ensureLoaded().catch(err =>
|
ensureLoaded().catch(err =>
|
||||||
console.error('[useIndicatorSettings] params load failed', err)
|
console.error('[useIndicatorSettings] params load failed', err),
|
||||||
);
|
),
|
||||||
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));
|
||||||
|
return unsub;
|
||||||
}, [sessionKey]);
|
}, [sessionKey]);
|
||||||
|
|
||||||
// ── 파라미터 ────────────────────────────────────────────────────────────
|
// ── 파라미터 ────────────────────────────────────────────────────────────
|
||||||
@@ -135,6 +156,7 @@ export function useIndicatorSettings(sessionKey = 0) {
|
|||||||
saveIndicatorSettings(type, params as ParamMap).catch(err =>
|
saveIndicatorSettings(type, params as ParamMap).catch(err =>
|
||||||
console.error(`[useIndicatorSettings] saveParams failed for ${type}`, err)
|
console.error(`[useIndicatorSettings] saveParams failed for ${type}`, err)
|
||||||
);
|
);
|
||||||
|
notifyIndicatorSettingsChanged();
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
@@ -146,6 +168,7 @@ export function useIndicatorSettings(sessionKey = 0) {
|
|||||||
saveAllIndicatorSettings(allParams).catch(err =>
|
saveAllIndicatorSettings(allParams).catch(err =>
|
||||||
console.error('[useIndicatorSettings] saveAll failed', err)
|
console.error('[useIndicatorSettings] saveAll failed', err)
|
||||||
);
|
);
|
||||||
|
notifyIndicatorSettingsChanged();
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
@@ -222,9 +245,10 @@ export function useIndicatorSettings(sessionKey = 0) {
|
|||||||
saveIndicatorVisualSettings(type, visual as { plots?: unknown[]; hlines?: unknown[]; cloudColors?: IchimokuCloudColors }).catch(err =>
|
saveIndicatorVisualSettings(type, visual as { plots?: unknown[]; hlines?: unknown[]; cloudColors?: IchimokuCloudColors }).catch(err =>
|
||||||
console.error(`[useIndicatorSettings] saveVisual failed for ${type}`, err)
|
console.error(`[useIndicatorSettings] saveVisual failed for ${type}`, err)
|
||||||
);
|
);
|
||||||
|
notifyIndicatorSettingsChanged();
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
return { getParams, saveParams, saveAll, getVisualConfig, saveVisual };
|
return { getParams, saveParams, saveAll, getVisualConfig, saveVisual, settingsRevision };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export interface IndicatorPeriodDef {
|
|||||||
investPsy: number;
|
investPsy: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VALUE_FIELD_PREFIX: Record<string, string> = {
|
export const VALUE_FIELD_PREFIX: Record<string, string> = {
|
||||||
RSI: 'RSI_VALUE',
|
RSI: 'RSI_VALUE',
|
||||||
CCI: 'CCI_VALUE',
|
CCI: 'CCI_VALUE',
|
||||||
ADX: 'ADX_VALUE',
|
ADX: 'ADX_VALUE',
|
||||||
@@ -69,33 +69,70 @@ function parseFieldPeriod(field?: string): number | null {
|
|||||||
return Number.isFinite(n) && n > 0 ? n : null;
|
return Number.isFinite(n) && n > 0 ? n : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 조건 노드의 RSI 등 값(좌측) 기간 — 오버라이드 시 DSL, 아니면 보조지표 설정(DEF) */
|
||||||
|
export function isValuePeriodOverridden(cond: ConditionDSL): boolean {
|
||||||
|
if (cond.valuePeriodOverride === true) return true;
|
||||||
|
if (cond.valuePeriodOverride === false) return false;
|
||||||
|
if (cond.period != null && cond.period > 0) return true;
|
||||||
|
if (cond.leftPeriod != null && cond.leftPeriod > 0) return true;
|
||||||
|
if (isValuePeriodFieldStored(cond.indicatorType, cond.leftField)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRightPeriodOverridden(cond: ConditionDSL): boolean {
|
||||||
|
if (cond.rightPeriodOverride === true) return true;
|
||||||
|
if (cond.rightPeriodOverride === false) return false;
|
||||||
|
if (cond.rightPeriod != null && cond.rightPeriod > 0) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isThresholdOverridden(cond: ConditionDSL): boolean {
|
||||||
|
if (cond.thresholdOverride === true) return true;
|
||||||
|
if (cond.thresholdOverride === false) return false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/** 조건 노드의 RSI 등 값(좌측) 기간 */
|
/** 조건 노드의 RSI 등 값(좌측) 기간 */
|
||||||
export function getConditionValuePeriod(cond: ConditionDSL, def: IndicatorPeriodDef): number {
|
export function getConditionValuePeriod(cond: ConditionDSL, def: IndicatorPeriodDef): number {
|
||||||
if (isPriceExtremeIndicator(cond.indicatorType)) {
|
if (isPriceExtremeIndicator(cond.indicatorType)) {
|
||||||
|
if (isValuePeriodOverridden(cond)) {
|
||||||
if (cond.period && cond.period > 0) return cond.period;
|
if (cond.period && cond.period > 0) return cond.period;
|
||||||
const fromRight = parsePriorExtremePeriod(cond.rightField);
|
const fromRight = parsePriorExtremePeriod(cond.rightField);
|
||||||
if (fromRight) return fromRight;
|
if (fromRight) return fromRight;
|
||||||
|
}
|
||||||
return getDefaultIndicatorPeriod(cond.indicatorType, def);
|
return getDefaultIndicatorPeriod(cond.indicatorType, def);
|
||||||
}
|
}
|
||||||
if (cond.composite) {
|
if (cond.composite) {
|
||||||
|
if (isValuePeriodOverridden(cond)) {
|
||||||
if (cond.leftPeriod && cond.leftPeriod > 0) return cond.leftPeriod;
|
if (cond.leftPeriod && cond.leftPeriod > 0) return cond.leftPeriod;
|
||||||
const fromField = parsePeriodFromCompositeField(cond.indicatorType, cond.leftField);
|
const fromField = parsePeriodFromCompositeField(cond.indicatorType, cond.leftField);
|
||||||
if (fromField) return fromField;
|
if (fromField) return fromField;
|
||||||
}
|
}
|
||||||
|
const { short } = getCompositeDefaultPeriods(cond.indicatorType, def as CompositePeriodDef);
|
||||||
|
return short;
|
||||||
|
}
|
||||||
|
if (isValuePeriodOverridden(cond)) {
|
||||||
if (cond.period && cond.period > 0) return cond.period;
|
if (cond.period && cond.period > 0) return cond.period;
|
||||||
const fromField = parseFieldPeriod(cond.leftField);
|
const fromField = parseFieldPeriod(cond.leftField);
|
||||||
if (fromField) return fromField;
|
if (fromField) return fromField;
|
||||||
|
}
|
||||||
return getDefaultIndicatorPeriod(cond.indicatorType, def);
|
return getDefaultIndicatorPeriod(cond.indicatorType, def);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getConditionRightPeriod(cond: ConditionDSL, def: IndicatorPeriodDef): number {
|
export function getConditionRightPeriod(cond: ConditionDSL, def: IndicatorPeriodDef): number {
|
||||||
if (cond.composite) {
|
if (cond.composite) {
|
||||||
|
if (isRightPeriodOverridden(cond)) {
|
||||||
if (cond.rightPeriod && cond.rightPeriod > 0) return cond.rightPeriod;
|
if (cond.rightPeriod && cond.rightPeriod > 0) return cond.rightPeriod;
|
||||||
const fromField = parsePeriodFromCompositeField(cond.indicatorType, cond.rightField);
|
const fromField = parsePeriodFromCompositeField(cond.indicatorType, cond.rightField);
|
||||||
if (fromField) return fromField;
|
if (fromField) return fromField;
|
||||||
}
|
}
|
||||||
|
const { long } = getCompositeDefaultPeriods(cond.indicatorType, def as CompositePeriodDef);
|
||||||
|
return long;
|
||||||
|
}
|
||||||
|
if (isValuePeriodOverridden(cond)) {
|
||||||
const fromField = parseFieldPeriod(cond.rightField);
|
const fromField = parseFieldPeriod(cond.rightField);
|
||||||
if (fromField) return fromField;
|
if (fromField) return fromField;
|
||||||
|
}
|
||||||
return getDefaultIndicatorPeriod(cond.indicatorType, def);
|
return getDefaultIndicatorPeriod(cond.indicatorType, def);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +179,9 @@ export function hasEditableThreshold(cond: ConditionDSL): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getConditionThreshold(cond: ConditionDSL, side: 'left' | 'right'): number | null {
|
export function getConditionThreshold(cond: ConditionDSL, side: 'left' | 'right'): number | null {
|
||||||
|
if (side === 'right' && !isThresholdOverridden(cond)) {
|
||||||
|
return parseThresholdField(cond.rightField);
|
||||||
|
}
|
||||||
const field = side === 'left' ? cond.leftField : cond.rightField;
|
const field = side === 'left' ? cond.leftField : cond.rightField;
|
||||||
return parseThresholdField(field);
|
return parseThresholdField(field);
|
||||||
}
|
}
|
||||||
@@ -153,13 +193,13 @@ export function setConditionValuePeriod(
|
|||||||
): ConditionDSL {
|
): ConditionDSL {
|
||||||
const p = Math.max(1, Math.min(500, Math.round(period)));
|
const p = Math.max(1, Math.min(500, Math.round(period)));
|
||||||
if (isPriceExtremeIndicator(cond.indicatorType)) {
|
if (isPriceExtremeIndicator(cond.indicatorType)) {
|
||||||
return syncPriceExtremeSimpleFields({ ...cond, period: p });
|
return syncPriceExtremeSimpleFields({ ...cond, period: p, valuePeriodOverride: true });
|
||||||
}
|
}
|
||||||
if (cond.composite) {
|
if (cond.composite) {
|
||||||
return syncCompositeFields({ ...cond, composite: true, leftPeriod: p });
|
return syncCompositeFields({ ...cond, composite: true, leftPeriod: p, valuePeriodOverride: true });
|
||||||
}
|
}
|
||||||
const prefix = VALUE_FIELD_PREFIX[cond.indicatorType];
|
const prefix = VALUE_FIELD_PREFIX[cond.indicatorType];
|
||||||
const next: ConditionDSL = { ...cond, period: p };
|
const next: ConditionDSL = { ...cond, period: p, valuePeriodOverride: true };
|
||||||
if (prefix && (cond.leftField === prefix || cond.leftField?.startsWith(`${prefix}_`))) {
|
if (prefix && (cond.leftField === prefix || cond.leftField?.startsWith(`${prefix}_`))) {
|
||||||
next.leftField = `${prefix}_${p}`;
|
next.leftField = `${prefix}_${p}`;
|
||||||
}
|
}
|
||||||
@@ -169,7 +209,7 @@ export function setConditionValuePeriod(
|
|||||||
export function setConditionRightPeriod(cond: ConditionDSL, period: number): ConditionDSL {
|
export function setConditionRightPeriod(cond: ConditionDSL, period: number): ConditionDSL {
|
||||||
const p = Math.max(1, Math.min(500, Math.round(period)));
|
const p = Math.max(1, Math.min(500, Math.round(period)));
|
||||||
if (!cond.composite) return cond;
|
if (!cond.composite) return cond;
|
||||||
return syncCompositeFields({ ...cond, composite: true, rightPeriod: p });
|
return syncCompositeFields({ ...cond, composite: true, rightPeriod: p, rightPeriodOverride: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setConditionThreshold(
|
export function setConditionThreshold(
|
||||||
@@ -184,9 +224,41 @@ export function setConditionThreshold(
|
|||||||
...cond,
|
...cond,
|
||||||
[fieldKey]: thresholdField(value),
|
[fieldKey]: thresholdField(value),
|
||||||
targetValue: value,
|
targetValue: value,
|
||||||
|
thresholdOverride: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 신규 조건 노드 — 보조지표 설정 기본값 상속(오버라이드 없음) */
|
||||||
|
export function initConditionPeriodsInherit(
|
||||||
|
indicatorType: string,
|
||||||
|
condition: ConditionDSL,
|
||||||
|
def: IndicatorPeriodDef,
|
||||||
|
): ConditionDSL {
|
||||||
|
const base = {
|
||||||
|
...condition,
|
||||||
|
valuePeriodOverride: false as const,
|
||||||
|
thresholdOverride: false as const,
|
||||||
|
rightPeriodOverride: false as const,
|
||||||
|
};
|
||||||
|
if (isPriceExtremeIndicator(indicatorType)) {
|
||||||
|
const period = getDefaultIndicatorPeriod(indicatorType, def);
|
||||||
|
return syncPriceExtremeSimpleFields({ ...base, period });
|
||||||
|
}
|
||||||
|
if (condition.composite) return base;
|
||||||
|
const prefix = VALUE_FIELD_PREFIX[indicatorType];
|
||||||
|
if (!prefix) {
|
||||||
|
const { period: _p, ...rest } = base;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
const lf = condition.leftField ?? '';
|
||||||
|
if (lf === prefix || lf.startsWith(`${prefix}_`)) {
|
||||||
|
const { period: _p, ...rest } = base;
|
||||||
|
return { ...rest, leftField: prefix };
|
||||||
|
}
|
||||||
|
const { period: _p, ...rest } = base;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
export function initConditionPeriods(
|
export function initConditionPeriods(
|
||||||
indicatorType: string,
|
indicatorType: string,
|
||||||
condition: ConditionDSL,
|
condition: ConditionDSL,
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { IndicatorConfig } from '../types';
|
import type { IndicatorConfig } from '../types';
|
||||||
import { enrichIndicatorConfig, getIndicatorDef } from './indicatorRegistry';
|
import { enrichIndicatorConfig, getIndicatorDef } from './indicatorRegistry';
|
||||||
|
import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from './smaConfig';
|
||||||
|
import { normalizeIchimokuConfig } from './ichimokuConfig';
|
||||||
|
import type { PlotDef, HLineDef } from './indicatorRegistry';
|
||||||
|
import type { IchimokuCloudColors } from './ichimokuConfig';
|
||||||
|
|
||||||
/** 보조지표 인스턴스 복제 (새 id, 별도 pane) */
|
/** 보조지표 인스턴스 복제 (새 id, 별도 pane) */
|
||||||
export function cloneIndicatorConfig(src: IndicatorConfig, newId: string): IndicatorConfig {
|
export function cloneIndicatorConfig(src: IndicatorConfig, newId: string): IndicatorConfig {
|
||||||
@@ -137,3 +141,54 @@ export function splitIndicatorPane(
|
|||||||
export function layoutKey(inds: IndicatorConfig[]): string {
|
export function layoutKey(inds: IndicatorConfig[]): string {
|
||||||
return inds.map(i => `${i.id}>${i.mergedWith ?? ''}`).join(';');
|
return inds.map(i => `${i.id}>${i.mergedWith ?? ''}`).join(';');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GetParams = (
|
||||||
|
type: string,
|
||||||
|
defaults?: Record<string, number | string | boolean>,
|
||||||
|
) => Record<string, number | string | boolean>;
|
||||||
|
|
||||||
|
type GetVisual = (
|
||||||
|
type: string,
|
||||||
|
defaultPlots?: PlotDef[],
|
||||||
|
defaultHlines?: HLineDef[],
|
||||||
|
) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors };
|
||||||
|
|
||||||
|
/** 지표 타입 목록 → 차트에 추가할 IndicatorConfig 배열 (이미 활성인 type 제외) */
|
||||||
|
export function buildIndicatorConfigsFromTypes(
|
||||||
|
types: string[],
|
||||||
|
existing: IndicatorConfig[],
|
||||||
|
getParams: GetParams,
|
||||||
|
getVisualConfig: GetVisual,
|
||||||
|
newId: () => string,
|
||||||
|
): IndicatorConfig[] {
|
||||||
|
const seen = new Set(existing.map(i => i.type));
|
||||||
|
const out: IndicatorConfig[] = [];
|
||||||
|
|
||||||
|
for (const type of types) {
|
||||||
|
if (seen.has(type)) continue;
|
||||||
|
const def = getIndicatorDef(type);
|
||||||
|
if (!def) continue;
|
||||||
|
seen.add(type);
|
||||||
|
|
||||||
|
const params = getParams(type, def.defaultParams);
|
||||||
|
const { plots, hlines, cloudColors } = getVisualConfig(type, def.plots, def.hlines);
|
||||||
|
let cfg: IndicatorConfig = {
|
||||||
|
id: newId(),
|
||||||
|
type: def.type,
|
||||||
|
params,
|
||||||
|
plots,
|
||||||
|
hlines,
|
||||||
|
...(cloudColors ? { cloudColors } : {}),
|
||||||
|
};
|
||||||
|
if (type === 'SMA') {
|
||||||
|
cfg = normalizeSmaConfig({
|
||||||
|
...cfg,
|
||||||
|
plotVisibility: cfg.plotVisibility ?? createDefaultSmaPlotVisibility(),
|
||||||
|
});
|
||||||
|
} else if (type === 'IchimokuCloud') {
|
||||||
|
cfg = normalizeIchimokuConfig(cfg);
|
||||||
|
}
|
||||||
|
out.push(cfg);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/** 보조지표 전역 설정(DEF) 변경 시 전략 조건 트리 동기화 — 오버라이드되지 않은 노드만 */
|
||||||
|
import type { LogicNode } from './strategyTypes';
|
||||||
|
import type { ConditionDSL } from './strategyTypes';
|
||||||
|
import {
|
||||||
|
getCompositeDefaultPeriods,
|
||||||
|
syncCompositeFields,
|
||||||
|
type CompositePeriodDef,
|
||||||
|
} from './compositeIndicators';
|
||||||
|
import {
|
||||||
|
isPriceExtremeIndicator,
|
||||||
|
syncPriceExtremeSimpleFields,
|
||||||
|
} from './priceExtremeIndicators';
|
||||||
|
import {
|
||||||
|
getDefaultIndicatorPeriod,
|
||||||
|
isRightPeriodOverridden,
|
||||||
|
isThresholdOverridden,
|
||||||
|
isValuePeriodOverridden,
|
||||||
|
parseThresholdField,
|
||||||
|
usesValuePeriodField,
|
||||||
|
VALUE_FIELD_PREFIX,
|
||||||
|
} from './conditionPeriods';
|
||||||
|
import type { DefType } from './strategyEditorShared';
|
||||||
|
import { getDefaultConditionFields } from './strategyEditorShared';
|
||||||
|
|
||||||
|
function applyGlobalDefToCondition(
|
||||||
|
cond: ConditionDSL,
|
||||||
|
def: DefType,
|
||||||
|
signalType: 'buy' | 'sell',
|
||||||
|
): ConditionDSL {
|
||||||
|
let next: ConditionDSL = { ...cond };
|
||||||
|
|
||||||
|
if (isPriceExtremeIndicator(cond.indicatorType)) {
|
||||||
|
if (!isValuePeriodOverridden(cond)) {
|
||||||
|
const period = getDefaultIndicatorPeriod(cond.indicatorType, def);
|
||||||
|
next = syncPriceExtremeSimpleFields({ ...next, period, valuePeriodOverride: false });
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cond.composite) {
|
||||||
|
const { short, long } = getCompositeDefaultPeriods(cond.indicatorType, def as CompositePeriodDef);
|
||||||
|
if (!isValuePeriodOverridden(cond)) {
|
||||||
|
next = syncCompositeFields({
|
||||||
|
...next,
|
||||||
|
composite: true,
|
||||||
|
leftPeriod: short,
|
||||||
|
valuePeriodOverride: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!isRightPeriodOverridden(cond)) {
|
||||||
|
next = syncCompositeFields({
|
||||||
|
...next,
|
||||||
|
composite: true,
|
||||||
|
rightPeriod: long,
|
||||||
|
rightPeriodOverride: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValuePeriodOverridden(cond)) {
|
||||||
|
const prefix = VALUE_FIELD_PREFIX[cond.indicatorType];
|
||||||
|
const { period: _omit, ...rest } = next;
|
||||||
|
next = { ...rest, valuePeriodOverride: false };
|
||||||
|
if (prefix && usesValuePeriodField(cond)) {
|
||||||
|
next.leftField = prefix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isThresholdOverridden(cond) && parseThresholdField(next.rightField) != null) {
|
||||||
|
const defaults = getDefaultConditionFields(cond.indicatorType, signalType, def);
|
||||||
|
next.rightField = defaults.r;
|
||||||
|
const parsed = parseThresholdField(defaults.r);
|
||||||
|
if (parsed != null) next.targetValue = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncConditionNode(node: LogicNode, def: DefType, signalType: 'buy' | 'sell'): LogicNode {
|
||||||
|
if (node.type !== 'CONDITION' || !node.condition) return node;
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
condition: applyGlobalDefToCondition(node.condition, def, signalType),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTreeNode(node: LogicNode, def: DefType, signalType: 'buy' | 'sell'): LogicNode {
|
||||||
|
let next = syncConditionNode(node, def, signalType);
|
||||||
|
if (next.children?.length) {
|
||||||
|
next = {
|
||||||
|
...next,
|
||||||
|
children: next.children.map(c => syncTreeNode(c, def, signalType)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncLogicNodeWithGlobalDef(
|
||||||
|
node: LogicNode,
|
||||||
|
def: DefType,
|
||||||
|
signalType: 'buy' | 'sell',
|
||||||
|
): LogicNode {
|
||||||
|
return syncTreeNode(node, def, signalType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncLogicRootWithGlobalDef(
|
||||||
|
root: LogicNode | null,
|
||||||
|
orphans: LogicNode[],
|
||||||
|
def: DefType,
|
||||||
|
signalType: 'buy' | 'sell',
|
||||||
|
): { root: LogicNode | null; orphans: LogicNode[] } {
|
||||||
|
return {
|
||||||
|
root: root ? syncTreeNode(root, def, signalType) : null,
|
||||||
|
orphans: orphans.map(n => syncTreeNode(n, def, signalType)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 } from '../utils/indicatorRegistry';
|
import { getIndicatorDef, getHLineLabel, 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,
|
||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
getPeriodPresetOptions,
|
getPeriodPresetOptions,
|
||||||
getThresholdBounds,
|
getThresholdBounds,
|
||||||
getThresholdPresetOptions,
|
getThresholdPresetOptions,
|
||||||
initConditionPeriods,
|
initConditionPeriodsInherit,
|
||||||
isValuePeriodFieldStored,
|
isValuePeriodFieldStored,
|
||||||
parseThresholdField,
|
parseThresholdField,
|
||||||
resolveFieldCustomKind,
|
resolveFieldCustomKind,
|
||||||
@@ -256,9 +256,45 @@ export function buildDef(activeIndicators: IndicatorConfig[]): DefType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 전략편집기 전용 DEF — 차트 activeIndicators와 무관한 고정 기본값.
|
* 전략편집기 DEF — DB 보조지표 설정(파라미터·hline)에서 구성.
|
||||||
* 조건 노드의 기간·임계값은 ConditionDSL(leftPeriod/rightPeriod/period)에만 저장된다.
|
* 조건 노드 기본값은 valuePeriodOverride/thresholdOverride=false 일 때 여기서 상속.
|
||||||
*/
|
*/
|
||||||
|
export function buildStrategyEditorDefFromSettings(
|
||||||
|
getParams: (
|
||||||
|
type: string,
|
||||||
|
defaults?: Record<string, number | string | boolean>,
|
||||||
|
) => Record<string, number | string | boolean>,
|
||||||
|
getVisualConfig: (
|
||||||
|
type: string,
|
||||||
|
defaultPlots?: PlotDef[],
|
||||||
|
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);
|
||||||
|
configs.push({
|
||||||
|
id: `se-def-${type}`,
|
||||||
|
type,
|
||||||
|
params: { ...params },
|
||||||
|
plots,
|
||||||
|
hlines,
|
||||||
|
...(cloudColors ? { cloudColors } : {}),
|
||||||
|
} as IndicatorConfig);
|
||||||
|
}
|
||||||
|
return buildDef(configs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated buildStrategyEditorDefFromSettings 사용 — 하드코딩 폴백 */
|
||||||
export function buildStrategyEditorDef(): DefType {
|
export function buildStrategyEditorDef(): DefType {
|
||||||
return buildDef([]);
|
return buildDef([]);
|
||||||
}
|
}
|
||||||
@@ -529,7 +565,7 @@ export const getFieldOpts = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDefaultFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType): { l: string; r: string } => {
|
const getDefaultConditionFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType): { l: string; r: string } => {
|
||||||
// 저장된 hline 과열선 값 우선 사용
|
// 저장된 hline 과열선 값 우선 사용
|
||||||
const over = (def: number) => kv(DEF.hlThresh[ind]?.over ?? def);
|
const over = (def: number) => kv(DEF.hlThresh[ind]?.over ?? def);
|
||||||
const mid = (def: number) => kv(DEF.hlThresh[ind]?.mid ?? def);
|
const mid = (def: number) => kv(DEF.hlThresh[ind]?.mid ?? def);
|
||||||
@@ -566,6 +602,8 @@ const getDefaultFields = (ind: string, signalType: 'buy'|'sell', DEF: DefType):
|
|||||||
return map[ind] ?? { l:'NONE', r:'NONE' };
|
return map[ind] ?? { l:'NONE', r:'NONE' };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { getDefaultConditionFields };
|
||||||
|
|
||||||
// conditionType 변경 시 자동 필드 조정
|
// conditionType 변경 시 자동 필드 조정
|
||||||
const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, DEF: DefType): ConditionDSL => {
|
const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, DEF: DefType): ConditionDSL => {
|
||||||
const updated = { ...cond, conditionType: newCondType };
|
const updated = { ...cond, conditionType: newCondType };
|
||||||
@@ -579,7 +617,7 @@ const applyCondTypeDefaults = (cond: ConditionDSL, newCondType: string, DEF: Def
|
|||||||
}
|
}
|
||||||
const fieldOpts = getFieldOpts(cond.indicatorType, 'buy', DEF);
|
const fieldOpts = getFieldOpts(cond.indicatorType, 'buy', DEF);
|
||||||
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 def = getDefaultFields(cond.indicatorType, 'buy', DEF);
|
const def = getDefaultConditionFields(cond.indicatorType, 'buy', DEF);
|
||||||
|
|
||||||
if (['GT','LT','GTE','LTE','EQ','NEQ','CROSS_UP','CROSS_DOWN','DIFF_GT','DIFF_LT'].includes(newCondType)) {
|
if (['GT','LT','GTE','LTE','EQ','NEQ','CROSS_UP','CROSS_DOWN','DIFF_GT','DIFF_LT'].includes(newCondType)) {
|
||||||
if (!isValid(updated.leftField)) updated.leftField = def.l;
|
if (!isValid(updated.leftField)) updated.leftField = def.l;
|
||||||
@@ -761,7 +799,7 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
|
|||||||
const condOpts = normalized.indicatorType === 'ICHIMOKU' ? ICHIMOKU_CONDS : COND_OPTIONS;
|
const condOpts = normalized.indicatorType === 'ICHIMOKU' ? ICHIMOKU_CONDS : COND_OPTIONS;
|
||||||
|
|
||||||
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 = getDefaultFields(normalized.indicatorType, signalType, def);
|
const defaults = getDefaultConditionFields(normalized.indicatorType, signalType, def);
|
||||||
|
|
||||||
const getLeftValue = () => {
|
const getLeftValue = () => {
|
||||||
const v = resolveFieldOptionValue(normalized.indicatorType, normalized.leftField);
|
const v = resolveFieldOptionValue(normalized.indicatorType, normalized.leftField);
|
||||||
@@ -791,7 +829,10 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
|
|||||||
ZERO_LINE: 0,
|
ZERO_LINE: 0,
|
||||||
};
|
};
|
||||||
let upd: ConditionDSL = { ...normalized, rightField: v };
|
let upd: ConditionDSL = { ...normalized, rightField: v };
|
||||||
if (thresholds[v] !== undefined) upd.targetValue = thresholds[v];
|
if (thresholds[v] !== undefined) {
|
||||||
|
upd.targetValue = thresholds[v];
|
||||||
|
upd.thresholdOverride = true;
|
||||||
|
}
|
||||||
const priorP = parsePriorExtremePeriod(v);
|
const priorP = parsePriorExtremePeriod(v);
|
||||||
if (priorP != null && isPriceExtremeIndicator(normalized.indicatorType)) {
|
if (priorP != null && isPriceExtremeIndicator(normalized.indicatorType)) {
|
||||||
upd = syncPriceExtremeSimpleFields({ ...upd, period: priorP });
|
upd = syncPriceExtremeSimpleFields({ ...upd, period: priorP });
|
||||||
@@ -816,9 +857,9 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
|
|||||||
?? parsePeriodFromCompositeField(normalized.indicatorType, field);
|
?? parsePeriodFromCompositeField(normalized.indicatorType, field);
|
||||||
if (p == null) return;
|
if (p == null) return;
|
||||||
if (side === 'left') {
|
if (side === 'left') {
|
||||||
onChange(syncCompositeFields({ ...normalized, leftPeriod: p, leftField: field }));
|
onChange(syncCompositeFields({ ...normalized, leftPeriod: p, leftField: field, valuePeriodOverride: true }));
|
||||||
} else {
|
} else {
|
||||||
onChange(syncCompositeFields({ ...normalized, rightPeriod: p, rightField: field }));
|
onChange(syncCompositeFields({ ...normalized, rightPeriod: p, rightField: field, rightPeriodOverride: true }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -954,7 +995,7 @@ export const CondEditor: React.FC<CondEditorProps> = ({ cond, signalType, onChan
|
|||||||
const base = bases[normalized.indicatorType];
|
const base = bases[normalized.indicatorType];
|
||||||
if (base && v === base) {
|
if (base && v === base) {
|
||||||
const p = getConditionValuePeriod(normalized, def);
|
const p = getConditionValuePeriod(normalized, def);
|
||||||
onChange({ ...normalized, leftField: `${base}_${p}`, period: p });
|
onChange({ ...normalized, leftField: `${base}_${p}`, period: p, valuePeriodOverride: true });
|
||||||
} else {
|
} else {
|
||||||
onChange({ ...normalized, leftField: v });
|
onChange({ ...normalized, leftField: v });
|
||||||
}
|
}
|
||||||
@@ -1077,8 +1118,7 @@ export const makeNode = (
|
|||||||
}
|
}
|
||||||
return { id: genId(), type: 'CONDITION', condition };
|
return { id: genId(), type: 'CONDITION', condition };
|
||||||
}
|
}
|
||||||
const def = getDefaultFields(value, signalType, DEF);
|
const def = getDefaultConditionFields(value, signalType, DEF);
|
||||||
const period = options?.period ?? getDefaultIndicatorPeriod(value, DEF);
|
|
||||||
let conditionType = signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN';
|
let conditionType = signalType === 'buy' ? 'CROSS_UP' : 'CROSS_DOWN';
|
||||||
if (value === 'NEW_HIGH' && signalType === 'buy') conditionType = 'CROSS_UP';
|
if (value === 'NEW_HIGH' && signalType === 'buy') conditionType = 'CROSS_UP';
|
||||||
if (value === 'NEW_LOW' && signalType === 'sell') conditionType = 'CROSS_DOWN';
|
if (value === 'NEW_LOW' && signalType === 'sell') conditionType = 'CROSS_DOWN';
|
||||||
@@ -1088,9 +1128,11 @@ export const makeNode = (
|
|||||||
leftField: def.l,
|
leftField: def.l,
|
||||||
rightField: def.r,
|
rightField: def.r,
|
||||||
candleRange: 1,
|
candleRange: 1,
|
||||||
period,
|
valuePeriodOverride: false,
|
||||||
|
thresholdOverride: false,
|
||||||
|
rightPeriodOverride: false,
|
||||||
};
|
};
|
||||||
const condition = initConditionPeriods(value, baseCondition, DEF);
|
const condition = initConditionPeriodsInherit(value, baseCondition, DEF);
|
||||||
return {
|
return {
|
||||||
id: genId(), type: 'CONDITION',
|
id: genId(), type: 'CONDITION',
|
||||||
condition: isPriceExtremeIndicator(value)
|
condition: isPriceExtremeIndicator(value)
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ export interface ConditionDSL {
|
|||||||
slopePeriod?: number;
|
slopePeriod?: number;
|
||||||
holdDays?: number;
|
holdDays?: number;
|
||||||
candleRange?: number;
|
candleRange?: number;
|
||||||
|
/** false=보조지표 설정 기본값 상속, true=전략 조건 전용 기간 */
|
||||||
|
valuePeriodOverride?: boolean;
|
||||||
|
/** 복합지표 요소2 — false=보조지표 설정 기본값 상속 */
|
||||||
|
rightPeriodOverride?: boolean;
|
||||||
|
/** false=보조지표 hline 기본값 상속, true=전략 조건 전용 임계값 */
|
||||||
|
thresholdOverride?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LogicNode {
|
export interface LogicNode {
|
||||||
|
|||||||
Reference in New Issue
Block a user