전략편집기 팝업 기능 적용
This commit is contained in:
@@ -191,6 +191,11 @@ function readRightPanelWidth(): number {
|
||||
interface Props {
|
||||
theme: Theme;
|
||||
onNavigateToBacktest?: () => void;
|
||||
/** 팝업(임베드) — 전략평가 등 */
|
||||
variant?: 'page' | 'modal';
|
||||
onClose?: () => void;
|
||||
initialStrategyId?: number | null;
|
||||
onStrategiesChanged?: () => void;
|
||||
}
|
||||
|
||||
function toEditorState(
|
||||
@@ -262,13 +267,25 @@ function isDuplicateStrategyName(
|
||||
return strategies.some(s => s.name.trim() === trimmed && s.id !== excludeId);
|
||||
}
|
||||
|
||||
export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Props) {
|
||||
export default function StrategyEditorPage({
|
||||
theme,
|
||||
onNavigateToBacktest,
|
||||
variant = 'page',
|
||||
onClose,
|
||||
initialStrategyId = null,
|
||||
onStrategiesChanged,
|
||||
}: Props) {
|
||||
const { getParams, getVisualConfig, settingsRevision, settingsLoaded } = useIndicatorSettings();
|
||||
const DEF = useMemo(
|
||||
() => buildStrategyEditorDefFromSettings(getParams, getVisualConfig),
|
||||
[getParams, getVisualConfig, settingsRevision],
|
||||
);
|
||||
const settingsSyncRef = useRef(-1);
|
||||
const initialStrategyAppliedRef = useRef(false);
|
||||
|
||||
const notifyStrategiesChanged = useCallback(() => {
|
||||
onStrategiesChanged?.();
|
||||
}, [onStrategiesChanged]);
|
||||
|
||||
const [strategies, setStrategies] = useState<StrategyDto[]>(() => loadStratsLocal());
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
@@ -953,6 +970,16 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
})();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (variant !== 'modal' || initialStrategyId == null || initialStrategyAppliedRef.current) return;
|
||||
if (strategies.length === 0) return;
|
||||
const found = strategies.find(s => s.id === initialStrategyId);
|
||||
if (!found) return;
|
||||
initialStrategyAppliedRef.current = true;
|
||||
handleSelectStrategy(found);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [variant, initialStrategyId, strategies]);
|
||||
|
||||
const handleNew = () => {
|
||||
setSelectedId(null);
|
||||
setStratName('');
|
||||
@@ -1045,6 +1072,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
} else {
|
||||
showSnack('전략이 저장되었습니다');
|
||||
}
|
||||
notifyStrategiesChanged();
|
||||
} catch (e) {
|
||||
showSnack(e instanceof Error ? e.message : '저장 실패', false);
|
||||
} finally {
|
||||
@@ -1061,6 +1089,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
setDeleteOpen(false);
|
||||
setDeleteId(null);
|
||||
showSnack('전략이 삭제되었습니다');
|
||||
notifyStrategiesChanged();
|
||||
};
|
||||
|
||||
const applyImportedFlowLayout = useCallback((layout: StrategyFlowLayoutStore | undefined, tab: 'buy' | 'sell' = signalTab) => {
|
||||
@@ -1617,7 +1646,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
selectedPaletteKey === paletteKey(type, id);
|
||||
|
||||
return (
|
||||
<div className={`se-page se-page--${theme}`}>
|
||||
<div className={`se-page se-page--${theme}${variant === 'modal' ? ' se-page--modal' : ''}`}>
|
||||
{needsPointerPaletteDrag() ? <PaletteDragOverlay /> : null}
|
||||
{saveToast && (
|
||||
<div className="se-save-toast">전략이 DB에 성공적으로 저장되었습니다.</div>
|
||||
@@ -1711,6 +1740,17 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
</div>
|
||||
|
||||
<div className="se-header-actions">
|
||||
{variant === 'modal' && onClose && (
|
||||
<button
|
||||
type="button"
|
||||
className="se-btn se-btn--ghost se-btn--icon se-btn--modal-close"
|
||||
title="닫기"
|
||||
aria-label="전략 편집 닫기"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="se-btn se-btn--ghost se-btn--icon se-btn--desc"
|
||||
|
||||
@@ -21,6 +21,7 @@ import { fetchEvaluationSnapshotAtBar } from '../utils/strategyEvaluationSnapsho
|
||||
import StrategyEvaluationSignalPanel from './strategyEvaluation/StrategyEvaluationSignalPanel';
|
||||
import StrategyEvaluationChart from './strategyEvaluation/StrategyEvaluationChart';
|
||||
import StrategyEvaluationSettingsTab from './strategyEvaluation/StrategyEvaluationSettingsTab';
|
||||
import StrategyEditorModal from './strategyEvaluation/StrategyEditorModal';
|
||||
import SePanelCollapseHandle from './strategyEditor/SePanelCollapseHandle';
|
||||
import { readStoredSize, readStoredBool, storeBool, storeSize, usePanelResize } from './strategyEditor/usePanelResize';
|
||||
import { getKoreanName } from '../utils/marketNameCache';
|
||||
@@ -75,6 +76,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
||||
const [paramsRevision, setParamsRevision] = useState(0);
|
||||
const [chartLoading, setChartLoading] = useState(false);
|
||||
const [backtestSignals, setBacktestSignals] = useState<BacktestSignal[]>([]);
|
||||
const [editorModalOpen, setEditorModalOpen] = useState(false);
|
||||
const evalSessionRef = useRef(0);
|
||||
const paramsRevisionRef = useRef(paramsRevision);
|
||||
|
||||
@@ -169,6 +171,27 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
||||
void loadStrategies().then(list => setStrategies(list ?? []));
|
||||
}, []);
|
||||
|
||||
const refreshStrategies = useCallback(async () => {
|
||||
const list = await loadStrategies();
|
||||
const next = list ?? [];
|
||||
setStrategies(next);
|
||||
if (selectedStrategyId == null) return;
|
||||
const still = next.find(s => s.id === selectedStrategyId);
|
||||
if (still) {
|
||||
setSelectedStrategy(hydrateStrategyDto(still));
|
||||
return;
|
||||
}
|
||||
setSelectedStrategyId(null);
|
||||
setSelectedStrategy(null);
|
||||
setAppliedIndicatorParams(null);
|
||||
setParamsRevision(v => v + 1);
|
||||
}, [selectedStrategyId]);
|
||||
|
||||
const handleCloseStrategyEditor = useCallback(() => {
|
||||
setEditorModalOpen(false);
|
||||
void refreshStrategies();
|
||||
}, [refreshStrategies]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedStrategyId) {
|
||||
setSelectedStrategy(null);
|
||||
@@ -308,26 +331,41 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
||||
>
|
||||
<aside className={`btd-sidebar seval-left-sidebar${leftOpen ? ' seval-left-sidebar--open' : ''}`}>
|
||||
<div className="btd-exec-list seval-left-panel">
|
||||
<div className="btd-exec-tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={leftTab === 'strategy'}
|
||||
className={`btd-exec-tab${leftTab === 'strategy' ? ' btd-exec-tab--on' : ''}`}
|
||||
onClick={() => setLeftTab('strategy')}
|
||||
>
|
||||
전략
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={leftTab === 'settings'}
|
||||
className={`btd-exec-tab${leftTab === 'settings' ? ' btd-exec-tab--on' : ''}`}
|
||||
onClick={() => setLeftTab('settings')}
|
||||
disabled={!selectedStrategy}
|
||||
>
|
||||
전략설정
|
||||
</button>
|
||||
<div className="seval-left-tabs-row">
|
||||
<div className="btd-exec-tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={leftTab === 'strategy'}
|
||||
className={`btd-exec-tab${leftTab === 'strategy' ? ' btd-exec-tab--on' : ''}`}
|
||||
onClick={() => setLeftTab('strategy')}
|
||||
>
|
||||
전략
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={leftTab === 'settings'}
|
||||
className={`btd-exec-tab${leftTab === 'settings' ? ' btd-exec-tab--on' : ''}`}
|
||||
onClick={() => setLeftTab('settings')}
|
||||
disabled={!selectedStrategy}
|
||||
>
|
||||
전략설정
|
||||
</button>
|
||||
</div>
|
||||
{leftTab === 'strategy' && (
|
||||
<button
|
||||
type="button"
|
||||
className="seval-strategy-add-btn"
|
||||
title="전략 추가·편집"
|
||||
aria-label="전략 추가·편집"
|
||||
onClick={() => setEditorModalOpen(true)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" aria-hidden>
|
||||
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{leftTab === 'strategy' ? (
|
||||
@@ -487,6 +525,15 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
|
||||
</footer>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{editorModalOpen && (
|
||||
<StrategyEditorModal
|
||||
theme={theme}
|
||||
initialStrategyId={selectedStrategyId}
|
||||
onClose={handleCloseStrategyEditor}
|
||||
onStrategiesChanged={() => { void refreshStrategies(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 전략 평가 — 전략편집기 전체 화면 팝업
|
||||
*/
|
||||
import React, { Suspense, lazy, useEffect } from 'react';
|
||||
import type { Theme } from '../../types';
|
||||
|
||||
const StrategyEditorPage = lazy(() => import('../StrategyEditorPage'));
|
||||
|
||||
interface Props {
|
||||
theme: Theme;
|
||||
initialStrategyId?: number | null;
|
||||
onClose: () => void;
|
||||
onStrategiesChanged?: () => void;
|
||||
}
|
||||
|
||||
export default function StrategyEditorModal({
|
||||
theme,
|
||||
initialStrategyId = null,
|
||||
onClose,
|
||||
onStrategiesChanged,
|
||||
}: Props) {
|
||||
useEffect(() => {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
window.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="seval-editor-modal-overlay" role="dialog" aria-modal aria-label="전략 편집">
|
||||
<div className="seval-editor-modal-shell">
|
||||
<Suspense fallback={<div className="seval-editor-modal-loading">전략 편집기 불러오는 중…</div>}>
|
||||
<StrategyEditorPage
|
||||
theme={theme}
|
||||
variant="modal"
|
||||
initialStrategyId={initialStrategyId}
|
||||
onClose={onClose}
|
||||
onStrategiesChanged={onStrategiesChanged}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type EvalIndicatorParams,
|
||||
} from '../../utils/strategyEvaluationParams';
|
||||
import { repairUtf8Mojibake } from '../../utils/textEncoding';
|
||||
import StrategyEvaluationStrategyDetail from './StrategyEvaluationStrategyDetail';
|
||||
|
||||
interface Props {
|
||||
strategy: StrategyDto | null;
|
||||
@@ -124,75 +125,83 @@ const StrategyEvaluationSettingsTab: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<div className="seval-settings-tab">
|
||||
<div className="seval-settings-head">
|
||||
<div className="seval-settings-head-row">
|
||||
<p className="seval-settings-strategy">{strategyName}</p>
|
||||
<div className="seval-settings-top">
|
||||
<div className="seval-settings-head">
|
||||
<div className="seval-settings-head-row">
|
||||
<p className="seval-settings-strategy">{strategyName}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="seval-settings-apply"
|
||||
disabled={!dirty || applying}
|
||||
onClick={handleApply}
|
||||
title="변경된 지표 값을 차트·일치율에 반영"
|
||||
>
|
||||
{applying ? '적용 중…' : '적용'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="seval-settings-hint">지표 값을 변경한 뒤 적용을 누르면 차트·일치율이 갱신됩니다</p>
|
||||
</div>
|
||||
|
||||
<div className="seval-settings-list">
|
||||
{indicatorTypes.map(type => {
|
||||
const def = getIndicatorDef(type);
|
||||
const params = draft[type] ?? {};
|
||||
const paramKeys = getEditableParamKeys(type, params);
|
||||
const isOpen = expanded === type;
|
||||
const label = def?.koreanName ?? def?.shortName ?? type;
|
||||
|
||||
return (
|
||||
<div key={type} className={`seval-settings-card${isOpen ? ' seval-settings-card--open' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="seval-settings-card-head"
|
||||
onClick={() => setExpanded(isOpen ? null : type)}
|
||||
>
|
||||
<span className="seval-settings-card-title">{label}</span>
|
||||
<span className="seval-settings-card-type">{type}</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="seval-settings-card-body">
|
||||
{paramKeys.map(key => (
|
||||
<GlobalParamRow
|
||||
key={key}
|
||||
indicatorType={type}
|
||||
paramKey={key}
|
||||
value={params[key] ?? getParams(type)[key]}
|
||||
onChange={(k, raw) => patchParam(type, k, raw)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="seval-settings-reset-type"
|
||||
onClick={() => resetType(type)}
|
||||
>
|
||||
이 지표 기본값
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="seval-settings-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="seval-settings-apply"
|
||||
disabled={!dirty || applying}
|
||||
onClick={handleApply}
|
||||
title="변경된 지표 값을 차트·일치율에 반영"
|
||||
className="seval-settings-reset"
|
||||
disabled={(!appliedParams && !dirty) || applying}
|
||||
onClick={onReset}
|
||||
>
|
||||
{applying ? '적용 중…' : '적용'}
|
||||
초기화
|
||||
</button>
|
||||
</div>
|
||||
<p className="seval-settings-hint">지표 값을 변경한 뒤 적용을 누르면 차트·일치율이 갱신됩니다</p>
|
||||
</div>
|
||||
|
||||
<div className="seval-settings-list">
|
||||
{indicatorTypes.map(type => {
|
||||
const def = getIndicatorDef(type);
|
||||
const params = draft[type] ?? {};
|
||||
const paramKeys = getEditableParamKeys(type, params);
|
||||
const isOpen = expanded === type;
|
||||
const label = def?.koreanName ?? def?.shortName ?? type;
|
||||
|
||||
return (
|
||||
<div key={type} className={`seval-settings-card${isOpen ? ' seval-settings-card--open' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="seval-settings-card-head"
|
||||
onClick={() => setExpanded(isOpen ? null : type)}
|
||||
>
|
||||
<span className="seval-settings-card-title">{label}</span>
|
||||
<span className="seval-settings-card-type">{type}</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="seval-settings-card-body">
|
||||
{paramKeys.map(key => (
|
||||
<GlobalParamRow
|
||||
key={key}
|
||||
indicatorType={type}
|
||||
paramKey={key}
|
||||
value={params[key] ?? getParams(type)[key]}
|
||||
onChange={(k, raw) => patchParam(type, k, raw)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="seval-settings-reset-type"
|
||||
onClick={() => resetType(type)}
|
||||
>
|
||||
이 지표 기본값
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="seval-settings-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="seval-settings-reset"
|
||||
disabled={(!appliedParams && !dirty) || applying}
|
||||
onClick={onReset}
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
</div>
|
||||
<StrategyEvaluationStrategyDetail
|
||||
strategy={strategy}
|
||||
getParams={getParams}
|
||||
appliedParams={appliedParams}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 전략 평가 — 전략설정 탭 하단: 조건식 + 상세 설명
|
||||
*/
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import type { LogicNode } from '../../utils/strategyTypes';
|
||||
import { asLogicNode } from '../../utils/strategyHydrate';
|
||||
import { decodeConditionForEditor } from '../../utils/strategyConditionSerde';
|
||||
import { buildStrategyNarrative } from '../../utils/strategyDescriptionNarrative';
|
||||
import { buildDefFromGetParams, nodeToFormula, nodeToText } from '../../utils/strategyEditorShared';
|
||||
import { repairUtf8Mojibake } from '../../utils/textEncoding';
|
||||
import type { EvalIndicatorParams } from '../../utils/strategyEvaluationParams';
|
||||
|
||||
interface Props {
|
||||
strategy: StrategyDto;
|
||||
getParams: (type: string) => Record<string, number | string | boolean>;
|
||||
appliedParams: EvalIndicatorParams | null;
|
||||
}
|
||||
|
||||
const StrategyEvaluationStrategyDetail: React.FC<Props> = ({
|
||||
strategy,
|
||||
getParams,
|
||||
appliedParams,
|
||||
}) => {
|
||||
const resolveGetParams = useCallback(
|
||||
(type: string, defaults?: Record<string, number | string | boolean>) => {
|
||||
const base = getParams(type);
|
||||
const merged = defaults ? { ...defaults, ...base } : base;
|
||||
const override = appliedParams?.[type];
|
||||
return override ? { ...merged, ...override } : merged;
|
||||
},
|
||||
[getParams, appliedParams],
|
||||
);
|
||||
|
||||
const def = useMemo(() => buildDefFromGetParams(resolveGetParams), [resolveGetParams]);
|
||||
|
||||
const buyCondition = useMemo(
|
||||
() => asLogicNode(strategy.buyCondition),
|
||||
[strategy.buyCondition],
|
||||
);
|
||||
const sellCondition = useMemo(
|
||||
() => asLogicNode(strategy.sellCondition),
|
||||
[strategy.sellCondition],
|
||||
);
|
||||
|
||||
const buyEditorState = useMemo(
|
||||
() => decodeConditionForEditor(buyCondition),
|
||||
[buyCondition],
|
||||
);
|
||||
const sellEditorState = useMemo(
|
||||
() => decodeConditionForEditor(sellCondition),
|
||||
[sellCondition],
|
||||
);
|
||||
|
||||
const narrative = useMemo(
|
||||
() => buildStrategyNarrative({
|
||||
name: strategy.name,
|
||||
description: strategy.description,
|
||||
buyEditorState,
|
||||
sellEditorState,
|
||||
buyCondition,
|
||||
sellCondition,
|
||||
def,
|
||||
}),
|
||||
[
|
||||
strategy.name,
|
||||
strategy.description,
|
||||
buyEditorState,
|
||||
sellEditorState,
|
||||
buyCondition,
|
||||
sellCondition,
|
||||
def,
|
||||
],
|
||||
);
|
||||
|
||||
const strategyName = repairUtf8Mojibake(strategy.name ?? `전략 #${strategy.id}`);
|
||||
|
||||
const renderFormula = (label: string, tone: 'buy' | 'sell', root: LogicNode | null) => {
|
||||
if (!root) return null;
|
||||
return (
|
||||
<div className={`seval-detail-formula seval-detail-formula--${tone}`}>
|
||||
<div className="seval-detail-formula-head">
|
||||
<span className={`seval-detail-formula-badge seval-detail-formula-badge--${tone}`}>{label}</span>
|
||||
</div>
|
||||
<pre className="seval-detail-formula-expr">{nodeToFormula(root, def)}</pre>
|
||||
<p className="seval-detail-formula-text">{nodeToText(root, def)}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const hasConditions = Boolean(buyCondition || sellCondition);
|
||||
|
||||
return (
|
||||
<div className="seval-detail">
|
||||
<div className="seval-detail-head">
|
||||
<h3 className="seval-detail-title">전략 조건 · 설명</h3>
|
||||
<span className="seval-detail-strategy">{strategyName}</span>
|
||||
</div>
|
||||
|
||||
<div className="seval-detail-scroll">
|
||||
<section className="seval-detail-section">
|
||||
<h4 className="seval-detail-section-title">조건식</h4>
|
||||
{!hasConditions ? (
|
||||
<p className="seval-detail-empty">등록된 매수·매도 조건이 없습니다.</p>
|
||||
) : (
|
||||
<div className="seval-detail-formulas">
|
||||
{renderFormula('매수 신호', 'buy', buyCondition)}
|
||||
{renderFormula('매도 신호', 'sell', sellCondition)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="seval-detail-section">
|
||||
<h4 className="seval-detail-section-title">상세 설명</h4>
|
||||
<div className="seval-detail-narrative">
|
||||
{narrative.intro.map((p, i) => (
|
||||
<p key={`intro-${i}`} className="seval-detail-intro">{p}</p>
|
||||
))}
|
||||
|
||||
{narrative.sections.map(section => (
|
||||
<div key={section.title} className="seval-detail-block">
|
||||
<h5 className="seval-detail-block-title">{section.title}</h5>
|
||||
{section.paragraphs.map((p, i) => (
|
||||
<p key={`${section.title}-p-${i}`} className="seval-detail-para">{p}</p>
|
||||
))}
|
||||
{section.bullets && section.bullets.length > 0 && (
|
||||
<ul className="seval-detail-list">
|
||||
{section.bullets.map((line, i) => (
|
||||
<li key={`${section.title}-b-${i}`} className="seval-detail-list-item">{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{narrative.footnotes.length > 0 && (
|
||||
<footer className="seval-detail-footnotes">
|
||||
{narrative.footnotes.map((note, i) => (
|
||||
<p key={`fn-${i}`} className="seval-detail-footnote">※ {note}</p>
|
||||
))}
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StrategyEvaluationStrategyDetail;
|
||||
@@ -12,6 +12,21 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.se-page--modal {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.se-btn--modal-close {
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
color: var(--se-text-muted);
|
||||
}
|
||||
|
||||
.se-btn--modal-close:hover {
|
||||
color: var(--se-text);
|
||||
}
|
||||
|
||||
.se-save-toast {
|
||||
position: absolute;
|
||||
top: 56px;
|
||||
|
||||
@@ -80,6 +80,69 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.seval-left-tabs-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.seval-left-tabs-row .btd-exec-tabs {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.seval-strategy-add-btn {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-bottom: 6px;
|
||||
padding: 0;
|
||||
border: 1px solid color-mix(in srgb, var(--btd-gold, var(--se-gold)) 40%, var(--se-border));
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--btd-gold, var(--se-gold)) 12%, var(--se-input-bg));
|
||||
color: var(--btd-gold, var(--se-gold));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.seval-strategy-add-btn:hover {
|
||||
background: color-mix(in srgb, var(--btd-gold, var(--se-gold)) 22%, var(--se-input-bg));
|
||||
}
|
||||
|
||||
.seval-editor-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 12000;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.seval-editor-modal-shell {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
margin: 8px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--se-gold) 35%, transparent);
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.seval-editor-modal-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--se-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.seval-main-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -969,6 +1032,14 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.seval-settings-top {
|
||||
flex: 1 1 52%;
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.seval-settings-head {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 2px 8px;
|
||||
@@ -1108,6 +1179,184 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── 전략설정 탭 하단 — 조건식 · 상세 설명 ─────────────────────────────── */
|
||||
.seval-detail {
|
||||
flex: 1 1 48%;
|
||||
min-height: 160px;
|
||||
max-height: 52%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid var(--btd-divider, var(--se-border));
|
||||
background: color-mix(in srgb, var(--btd-surface, var(--se-palette-card-bg)) 88%, transparent);
|
||||
}
|
||||
|
||||
.seval-detail-head {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 2px 6px;
|
||||
border-bottom: 1px solid var(--btd-divider, var(--se-border));
|
||||
}
|
||||
|
||||
.seval-detail-title {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: var(--btd-gold, var(--se-gold));
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.seval-detail-strategy {
|
||||
font-size: 0.62rem;
|
||||
color: var(--se-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.seval-detail-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 8px 2px 10px;
|
||||
}
|
||||
|
||||
.seval-detail-section + .seval-detail-section {
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed color-mix(in srgb, var(--btd-divider, var(--se-border)) 70%, transparent);
|
||||
}
|
||||
|
||||
.seval-detail-section-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
color: var(--se-text);
|
||||
}
|
||||
|
||||
.seval-detail-empty {
|
||||
margin: 0;
|
||||
font-size: 0.66rem;
|
||||
color: var(--se-text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.seval-detail-formulas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.seval-detail-formula {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--btd-divider, var(--se-border));
|
||||
background: var(--btd-surface, var(--se-input-bg));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.seval-detail-formula--buy {
|
||||
border-color: color-mix(in srgb, #4caf50 28%, var(--se-border));
|
||||
}
|
||||
|
||||
.seval-detail-formula--sell {
|
||||
border-color: color-mix(in srgb, #ef5350 28%, var(--se-border));
|
||||
}
|
||||
|
||||
.seval-detail-formula-head {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--btd-divider, var(--se-border));
|
||||
}
|
||||
|
||||
.seval-detail-formula-badge {
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.seval-detail-formula-badge--buy {
|
||||
color: #66bb6a;
|
||||
}
|
||||
|
||||
.seval-detail-formula-badge--sell {
|
||||
color: #ef5350;
|
||||
}
|
||||
|
||||
.seval-detail-formula-expr {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.62rem;
|
||||
line-height: 1.45;
|
||||
color: var(--se-text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.seval-detail-formula-text {
|
||||
margin: 0;
|
||||
padding: 6px 8px 8px;
|
||||
font-size: 0.64rem;
|
||||
line-height: 1.45;
|
||||
color: var(--se-text-muted);
|
||||
}
|
||||
|
||||
.seval-detail-narrative {
|
||||
font-size: 0.64rem;
|
||||
line-height: 1.5;
|
||||
color: var(--se-text);
|
||||
}
|
||||
|
||||
.seval-detail-intro {
|
||||
margin: 0 0 8px;
|
||||
color: var(--se-text-muted);
|
||||
}
|
||||
|
||||
.seval-detail-block + .seval-detail-block {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.seval-detail-block-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
color: var(--se-text);
|
||||
}
|
||||
|
||||
.seval-detail-para {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.seval-detail-list {
|
||||
margin: 4px 0 0;
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.seval-detail-list-item {
|
||||
margin: 0 0 4px;
|
||||
padding-left: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--se-text-muted);
|
||||
}
|
||||
|
||||
.seval-detail-footnotes {
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--btd-divider, var(--se-border));
|
||||
}
|
||||
|
||||
.seval-detail-footnote {
|
||||
margin: 0 0 4px;
|
||||
font-size: 0.6rem;
|
||||
color: var(--se-text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── 조건 분석보기 버튼 · 팝업 ─────────────────────────────────────────── */
|
||||
.seval-analyze-btn {
|
||||
flex-shrink: 0;
|
||||
|
||||
Reference in New Issue
Block a user