전략 설명 추가

This commit is contained in:
Macbook
2026-06-16 21:35:59 +09:00
parent 26bfca7b4c
commit 822d99f6b0
6 changed files with 363 additions and 11 deletions
+20 -1
View File
@@ -107,6 +107,7 @@ import {
type StrategyFlowLayoutStore, type StrategyFlowLayoutStore,
} from '../utils/strategyEditorLayoutStorage'; } from '../utils/strategyEditorLayoutStorage';
import LogicExpressionPreview from './strategyEditor/LogicExpressionPreview'; import LogicExpressionPreview from './strategyEditor/LogicExpressionPreview';
import LogicExpressionNarrative from './strategyEditor/LogicExpressionNarrative';
import StrategyEditorCanvas, { type FlowLayoutSeed } from './strategyEditor/StrategyEditorCanvas'; import StrategyEditorCanvas, { type FlowLayoutSeed } from './strategyEditor/StrategyEditorCanvas';
import StrategyListEditor from './strategyEditor/StrategyListEditor'; import StrategyListEditor from './strategyEditor/StrategyListEditor';
import StartCombineOpControl from './strategyEditor/StartCombineOpControl'; import StartCombineOpControl from './strategyEditor/StartCombineOpControl';
@@ -178,7 +179,7 @@ const RIGHT_PANEL_MAX = 560;
const RIGHT_PANEL_DEFAULT = 380; const RIGHT_PANEL_DEFAULT = 380;
const TERMINAL_MIN = 88; const TERMINAL_MIN = 88;
const TERMINAL_MAX = 420; const TERMINAL_MAX = 420;
const TERMINAL_DEFAULT = 140; const TERMINAL_DEFAULT = 220;
function readRightPanelWidth(): number { function readRightPanelWidth(): number {
return clampPanelSize( return clampPanelSize(
@@ -2088,6 +2089,8 @@ export default function StrategyEditorPage({
/> />
<footer className="se-terminal" style={{ height: terminalHeight }}> <footer className="se-terminal" style={{ height: terminalHeight }}>
<div className="se-terminal-scroll">
<section className="se-terminal-section">
<div className="se-terminal-label">LOGIC EXPRESSION</div> <div className="se-terminal-label">LOGIC EXPRESSION</div>
<LogicExpressionPreview <LogicExpressionPreview
buyCondition={buyCondition} buyCondition={buyCondition}
@@ -2097,6 +2100,22 @@ export default function StrategyEditorPage({
orphanCount={orphanTotal} orphanCount={orphanTotal}
def={DEF} def={DEF}
/> />
</section>
<section className="se-terminal-section se-terminal-section--narrative">
<div className="se-terminal-label se-terminal-label--guide"> </div>
<LogicExpressionNarrative
name={stratName}
description={stratDesc}
buyCondition={buyCondition}
sellCondition={sellCondition}
buyEditorState={buyEditorState}
sellEditorState={sellEditorState}
orphanCount={orphanTotal}
def={DEF}
compact
/>
</section>
</div>
</footer> </footer>
</div> </div>
</main> </main>
@@ -4,6 +4,7 @@
import '../styles/backtestDashboard.css'; import '../styles/backtestDashboard.css';
import '../styles/virtualTradingDashboard.css'; import '../styles/virtualTradingDashboard.css';
import '../styles/strategyEditorTheme.css'; import '../styles/strategyEditorTheme.css';
import '../styles/strategyEditor.css';
import '../styles/strategyEvaluation.css'; import '../styles/strategyEvaluation.css';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Theme, Timeframe } from '../types'; import type { Theme, Timeframe } from '../types';
@@ -20,6 +21,7 @@ import { resolveStrategyPrimaryTimeframe } from '../utils/strategyToChartIndicat
import { fetchEvaluationSnapshotAtBar } from '../utils/strategyEvaluationSnapshot'; import { fetchEvaluationSnapshotAtBar } from '../utils/strategyEvaluationSnapshot';
import StrategyEvaluationSignalPanel from './strategyEvaluation/StrategyEvaluationSignalPanel'; import StrategyEvaluationSignalPanel from './strategyEvaluation/StrategyEvaluationSignalPanel';
import StrategyEvaluationChart from './strategyEvaluation/StrategyEvaluationChart'; import StrategyEvaluationChart from './strategyEvaluation/StrategyEvaluationChart';
import StrategyEvaluationConditionPanel from './strategyEvaluation/StrategyEvaluationConditionPanel';
import StrategyEvaluationSettingsTab from './strategyEvaluation/StrategyEvaluationSettingsTab'; import StrategyEvaluationSettingsTab from './strategyEvaluation/StrategyEvaluationSettingsTab';
import StrategyEditorModal from './strategyEvaluation/StrategyEditorModal'; import StrategyEditorModal from './strategyEvaluation/StrategyEditorModal';
import SePanelCollapseHandle from './strategyEditor/SePanelCollapseHandle'; import SePanelCollapseHandle from './strategyEditor/SePanelCollapseHandle';
@@ -484,6 +486,11 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
evalLoading={evalLoading} evalLoading={evalLoading}
onSignalsChange={handleSignalsChange} onSignalsChange={handleSignalsChange}
/> />
<StrategyEvaluationConditionPanel
strategy={selectedStrategy}
getParams={baseGetParams}
appliedParams={appliedIndicatorParams}
/>
</div> </div>
</main> </main>
@@ -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 (
<div className={rootClass}>
{!compact && narrative.intro.map((p, i) => (
<p key={`intro-${i}`} className="se-narrative-intro">{p}</p>
))}
{narrative.sections.map(section => (
<section key={section.title} className="se-narrative-section">
<h4 className="se-narrative-section-title">{section.title}</h4>
{!compact && section.paragraphs.map((p, i) => (
<p key={`${section.title}-p-${i}`} className="se-narrative-para">{p}</p>
))}
{compact && section.paragraphs.length > 0 && (
<p className="se-narrative-para se-narrative-para--lead">
{section.paragraphs[section.paragraphs.length - 1]}
</p>
)}
{section.bullets && section.bullets.length > 0 && (
<ul className="se-narrative-list">
{section.bullets.map((line, i) => (
<li key={`${section.title}-b-${i}`} className="se-narrative-list-item">{line}</li>
))}
</ul>
)}
</section>
))}
{narrative.footnotes.length > 0 && (
<footer className="se-narrative-footnotes">
{narrative.footnotes.map((note, i) => (
<p key={`fn-${i}`} className="se-narrative-footnote">
{compact ? note : `${note}`}
</p>
))}
</footer>
)}
</div>
);
}
@@ -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<string, number | string | boolean>;
appliedParams: EvalIndicatorParams | null;
}
const StrategyEvaluationConditionPanel: 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(
() => (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 (
<div className="seval-condition-panel seval-condition-panel--empty">
<p className="seval-condition-panel-empty"> .</p>
</div>
);
}
return (
<div className="seval-condition-panel">
<section className="seval-condition-panel-section">
<div className="seval-condition-panel-label">LOGIC EXPRESSION</div>
<LogicExpressionPreview
buyCondition={buyCondition}
sellCondition={sellCondition}
buyEditorState={buyEditorState}
sellEditorState={sellEditorState}
orphanCount={0}
def={def}
/>
</section>
<section className="seval-condition-panel-section seval-condition-panel-section--narrative">
<div className="seval-condition-panel-label seval-condition-panel-label--guide"> </div>
<LogicExpressionNarrative
name={strategy.name}
description={strategy.description}
buyCondition={buyCondition}
sellCondition={sellCondition}
buyEditorState={buyEditorState}
sellEditorState={sellEditorState}
def={def}
compact
/>
</section>
</div>
);
};
export default StrategyEvaluationConditionPanel;
+76 -1
View File
@@ -1552,7 +1552,18 @@
border-radius: 0; border-radius: 0;
background: var(--se-terminal-bg); background: var(--se-terminal-bg);
overflow: hidden; 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 { .se-terminal-label {
flex-shrink: 0; flex-shrink: 0;
@@ -1563,6 +1574,9 @@
padding: 6px 12px; padding: 6px 12px;
border-bottom: 1px solid var(--se-border); 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 { .se-terminal-text {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -2626,3 +2640,64 @@ body.se-palette-drag-armed .se-palette-section--scroll {
color: var(--se-text-muted); color: var(--se-text-muted);
line-height: 1.5; 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;
}
@@ -428,6 +428,61 @@
font-variant-numeric: tabular-nums; 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 { .seval-bar-selector-layer {
position: absolute; position: absolute;