From 822d99f6b05bbe40e8f84817a197589ee5d35134 Mon Sep 17 00:00:00 2001 From: Macbook Date: Tue, 16 Jun 2026 21:35:59 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EB=9E=B5=20=EC=84=A4=EB=AA=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/StrategyEditorPage.tsx | 39 +++++-- .../src/components/StrategyEvaluationPage.tsx | 7 ++ .../LogicExpressionNarrative.tsx | 104 ++++++++++++++++++ .../StrategyEvaluationConditionPanel.tsx | 92 ++++++++++++++++ frontend/src/styles/strategyEditor.css | 77 ++++++++++++- frontend/src/styles/strategyEvaluation.css | 55 +++++++++ 6 files changed, 363 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/strategyEditor/LogicExpressionNarrative.tsx create mode 100644 frontend/src/components/strategyEvaluation/StrategyEvaluationConditionPanel.tsx diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index bfcf43e..bc34666 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -107,6 +107,7 @@ import { type StrategyFlowLayoutStore, } from '../utils/strategyEditorLayoutStorage'; import LogicExpressionPreview from './strategyEditor/LogicExpressionPreview'; +import LogicExpressionNarrative from './strategyEditor/LogicExpressionNarrative'; import StrategyEditorCanvas, { type FlowLayoutSeed } from './strategyEditor/StrategyEditorCanvas'; import StrategyListEditor from './strategyEditor/StrategyListEditor'; import StartCombineOpControl from './strategyEditor/StartCombineOpControl'; @@ -178,7 +179,7 @@ const RIGHT_PANEL_MAX = 560; const RIGHT_PANEL_DEFAULT = 380; const TERMINAL_MIN = 88; const TERMINAL_MAX = 420; -const TERMINAL_DEFAULT = 140; +const TERMINAL_DEFAULT = 220; function readRightPanelWidth(): number { return clampPanelSize( @@ -2088,15 +2089,33 @@ export default function StrategyEditorPage({ /> diff --git a/frontend/src/components/StrategyEvaluationPage.tsx b/frontend/src/components/StrategyEvaluationPage.tsx index 97c4575..7623404 100644 --- a/frontend/src/components/StrategyEvaluationPage.tsx +++ b/frontend/src/components/StrategyEvaluationPage.tsx @@ -4,6 +4,7 @@ import '../styles/backtestDashboard.css'; import '../styles/virtualTradingDashboard.css'; import '../styles/strategyEditorTheme.css'; +import '../styles/strategyEditor.css'; import '../styles/strategyEvaluation.css'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { Theme, Timeframe } from '../types'; @@ -20,6 +21,7 @@ import { resolveStrategyPrimaryTimeframe } from '../utils/strategyToChartIndicat import { fetchEvaluationSnapshotAtBar } from '../utils/strategyEvaluationSnapshot'; import StrategyEvaluationSignalPanel from './strategyEvaluation/StrategyEvaluationSignalPanel'; import StrategyEvaluationChart from './strategyEvaluation/StrategyEvaluationChart'; +import StrategyEvaluationConditionPanel from './strategyEvaluation/StrategyEvaluationConditionPanel'; import StrategyEvaluationSettingsTab from './strategyEvaluation/StrategyEvaluationSettingsTab'; import StrategyEditorModal from './strategyEvaluation/StrategyEditorModal'; import SePanelCollapseHandle from './strategyEditor/SePanelCollapseHandle'; @@ -484,6 +486,11 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) { evalLoading={evalLoading} onSignalsChange={handleSignalsChange} /> + diff --git a/frontend/src/components/strategyEditor/LogicExpressionNarrative.tsx b/frontend/src/components/strategyEditor/LogicExpressionNarrative.tsx new file mode 100644 index 0000000..117bb51 --- /dev/null +++ b/frontend/src/components/strategyEditor/LogicExpressionNarrative.tsx @@ -0,0 +1,104 @@ +/** + * LOGIC EXPRESSION 아래 — 조건을 알기 쉬운 문장으로 설명 + */ +import React, { useMemo } from 'react'; +import type { LogicNode } from '../../utils/strategyTypes'; +import type { DefType } from '../../utils/strategyEditorShared'; +import { + buildStrategyNarrative, + type StrategyDescriptionInput, +} from '../../utils/strategyDescriptionNarrative'; +import type { EditorConditionState } from '../../utils/strategyConditionSerde'; +import { emptyEditorConditionState } from '../../utils/strategyConditionSerde'; + +interface Props { + name?: string; + description?: string; + buyCondition: LogicNode | null; + sellCondition: LogicNode | null; + buyEditorState?: EditorConditionState; + sellEditorState?: EditorConditionState; + orphanCount?: number; + def: DefType; + /** 터미널·하단 패널 등 좁은 공간 — 서두·각주 생략, 핵심 bullet 위주 */ + compact?: boolean; + className?: string; +} + +export default function LogicExpressionNarrative({ + name, + description, + buyCondition, + sellCondition, + buyEditorState, + sellEditorState, + orphanCount, + def, + compact = false, + className, +}: Props) { + const input: StrategyDescriptionInput = useMemo(() => ({ + name, + description, + buyEditorState: buyEditorState ?? emptyEditorConditionState(), + sellEditorState: sellEditorState ?? emptyEditorConditionState(), + buyCondition, + sellCondition, + orphanCount, + def, + }), [ + name, + description, + buyEditorState, + sellEditorState, + buyCondition, + sellCondition, + orphanCount, + def, + ]); + + const narrative = useMemo(() => buildStrategyNarrative(input), [input]); + + const rootClass = ['se-narrative', compact ? 'se-narrative--compact' : '', className] + .filter(Boolean) + .join(' '); + + return ( +
+ {!compact && narrative.intro.map((p, i) => ( +

{p}

+ ))} + + {narrative.sections.map(section => ( +
+

{section.title}

+ {!compact && section.paragraphs.map((p, i) => ( +

{p}

+ ))} + {compact && section.paragraphs.length > 0 && ( +

+ {section.paragraphs[section.paragraphs.length - 1]} +

+ )} + {section.bullets && section.bullets.length > 0 && ( +
    + {section.bullets.map((line, i) => ( +
  • {line}
  • + ))} +
+ )} +
+ ))} + + {narrative.footnotes.length > 0 && ( +
+ {narrative.footnotes.map((note, i) => ( +

+ {compact ? note : `※ ${note}`} +

+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationConditionPanel.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationConditionPanel.tsx new file mode 100644 index 0000000..4846be5 --- /dev/null +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationConditionPanel.tsx @@ -0,0 +1,92 @@ +/** + * 전략 평가 — 중앙 하단 LOGIC EXPRESSION + 조건 설명 + */ +import React, { useCallback, useMemo } from 'react'; +import type { StrategyDto } from '../../utils/backendApi'; +import { asLogicNode } from '../../utils/strategyHydrate'; +import { decodeConditionForEditor } from '../../utils/strategyConditionSerde'; +import { buildDefFromGetParams } from '../../utils/strategyEditorShared'; +import LogicExpressionPreview from '../strategyEditor/LogicExpressionPreview'; +import LogicExpressionNarrative from '../strategyEditor/LogicExpressionNarrative'; +import type { EvalIndicatorParams } from '../../utils/strategyEvaluationParams'; + +interface Props { + strategy: StrategyDto | null; + getParams: (type: string) => Record; + appliedParams: EvalIndicatorParams | null; +} + +const StrategyEvaluationConditionPanel: React.FC = ({ + strategy, + getParams, + appliedParams, +}) => { + const resolveGetParams = useCallback( + (type: string, defaults?: Record) => { + 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( + () => (strategy ? asLogicNode(strategy.buyCondition) : null), + [strategy?.buyCondition], + ); + const sellCondition = useMemo( + () => (strategy ? asLogicNode(strategy.sellCondition) : null), + [strategy?.sellCondition], + ); + + const buyEditorState = useMemo( + () => decodeConditionForEditor(buyCondition), + [buyCondition], + ); + const sellEditorState = useMemo( + () => decodeConditionForEditor(sellCondition), + [sellCondition], + ); + + if (!strategy || (!buyCondition && !sellCondition)) { + return ( +
+

전략을 선택하면 조건식과 설명이 표시됩니다.

+
+ ); + } + + return ( +
+
+
LOGIC EXPRESSION
+ +
+
+
조건 설명
+ +
+
+ ); +}; + +export default StrategyEvaluationConditionPanel; diff --git a/frontend/src/styles/strategyEditor.css b/frontend/src/styles/strategyEditor.css index 4581423..f8c4206 100644 --- a/frontend/src/styles/strategyEditor.css +++ b/frontend/src/styles/strategyEditor.css @@ -1552,7 +1552,18 @@ border-radius: 0; background: var(--se-terminal-bg); overflow: hidden; - min-height: 88px; + min-height: 120px; +} +.se-terminal-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; +} +.se-terminal-section + .se-terminal-section { + border-top: 1px solid var(--se-border); +} +.se-terminal-section--narrative { + background: color-mix(in srgb, var(--se-terminal-bg) 88%, var(--se-gold) 12%); } .se-terminal-label { flex-shrink: 0; @@ -1563,6 +1574,9 @@ padding: 6px 12px; border-bottom: 1px solid var(--se-border); } +.se-terminal-label--guide { + color: color-mix(in srgb, var(--se-gold) 85%, var(--se-text) 15%); +} .se-terminal-text { flex: 1; min-height: 0; @@ -2626,3 +2640,64 @@ body.se-palette-drag-armed .se-palette-section--scroll { color: var(--se-text-muted); line-height: 1.5; } + +/* ── Terminal narrative (조건 설명) ── */ +.se-narrative { + padding: 10px 14px 12px; + font-size: 0.8rem; + line-height: 1.55; + color: var(--se-text); +} +.se-narrative--compact { + padding: 8px 14px 10px; + font-size: 0.78rem; +} +.se-narrative-intro { + margin: 0 0 8px; + color: var(--se-text-muted); +} +.se-narrative-section + .se-narrative-section { + margin-top: 10px; + padding-top: 8px; + border-top: 1px dashed var(--se-border); +} +.se-narrative-section-title { + margin: 0 0 6px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--se-gold); +} +.se-narrative-para { + margin: 0 0 6px; + color: var(--se-text-muted); +} +.se-narrative-para--lead { + font-size: 0.76rem; +} +.se-narrative-list { + margin: 4px 0 0; + padding: 0 0 0 1.1em; + list-style: none; +} +.se-narrative-list-item { + margin: 3px 0; + padding-left: 2px; + white-space: pre-wrap; + word-break: keep-all; + color: var(--se-text); +} +.se-narrative-footnotes { + margin-top: 8px; + padding-top: 6px; + border-top: 1px dashed var(--se-border); +} +.se-narrative-footnote { + margin: 3px 0 0; + font-size: 0.72rem; + color: var(--se-text-muted); + line-height: 1.45; +} +.se-narrative--compact .se-narrative-footnotes { + display: none; +} diff --git a/frontend/src/styles/strategyEvaluation.css b/frontend/src/styles/strategyEvaluation.css index ed7f614..527e114 100644 --- a/frontend/src/styles/strategyEvaluation.css +++ b/frontend/src/styles/strategyEvaluation.css @@ -428,6 +428,61 @@ font-variant-numeric: tabular-nums; } +/* ── 중앙 하단 — LOGIC EXPRESSION + 조건 설명 ── */ +.seval-condition-panel { + flex-shrink: 0; + max-height: min(38vh, 320px); + margin-top: 6px; + border: 1px solid var(--se-terminal-border, rgba(255, 255, 255, 0.08)); + border-radius: 8px; + background: var(--se-terminal-bg, color-mix(in srgb, var(--bg) 92%, #000)); + overflow: hidden; + display: flex; + flex-direction: column; +} +.seval-condition-panel--empty { + max-height: none; + padding: 12px 14px; +} +.seval-condition-panel-empty { + margin: 0; + font-size: 0.8rem; + color: var(--se-text-muted); +} +.seval-condition-panel-section { + flex-shrink: 0; + min-height: 0; + overflow-y: auto; +} +.seval-condition-panel-section--narrative { + flex: 1; + min-height: 0; + border-top: 1px solid var(--se-border); + background: color-mix(in srgb, var(--se-terminal-bg, var(--bg)) 88%, var(--se-gold, #c9a227) 12%); +} +.seval-condition-panel-label { + position: sticky; + top: 0; + z-index: 1; + font-size: 0.62rem; + font-weight: 800; + letter-spacing: 0.14em; + color: var(--se-terminal-label, var(--se-text-muted)); + padding: 6px 12px; + border-bottom: 1px solid var(--se-border); + background: inherit; +} +.seval-condition-panel-label--guide { + color: color-mix(in srgb, var(--se-gold, #c9a227) 85%, var(--se-text) 15%); +} +.seval-condition-panel .se-terminal-text { + max-height: 120px; +} +.seval-condition-panel .se-narrative--compact { + max-height: 160px; + overflow-y: auto; +} + /* ── 세로 선택바 ─────────────────────────────────────────────────────────── */ .seval-bar-selector-layer { position: absolute;