전략조건 상세설명 기능 추가

This commit is contained in:
Macbook
2026-05-25 03:15:05 +09:00
parent 30dedc4abc
commit 67324ded9d
22 changed files with 1151 additions and 187 deletions
+7 -4
View File
@@ -3,7 +3,7 @@
* 레이아웃 내 개별 차트 슬롯. 자체 심볼/타임프레임/데이터를 관리한다.
*/
import React, {
useState, useEffect, useCallback, useRef, useMemo,
useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo,
useImperativeHandle, forwardRef,
} from 'react';
import ReactDOM from 'react-dom';
@@ -249,6 +249,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
getTimeframe: () => timeframeRef.current,
getIndicators: () => indicatorsRef.current,
setSymbol: (s: string) => {
pendingRealtimeBarRef.current = null;
// 새 종목의 캐시를 미리 무효화해 신선한 데이터를 요청하도록 함
if (isUpbitMarket(s)) invalidateMarketCache(s);
setSymbol(s);
@@ -380,7 +381,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
// 데이터 로딩 완료 후 차트가 여전히 비어 있으면 1회만 재마운트 (data + blank guard)
const [reloadTick, setReloadTick] = useState(0);
const reloadTriggeredRef = useRef(false); // 재마운트는 최대 1회
useEffect(() => {
useLayoutEffect(() => {
reloadTriggeredRef.current = false;
pendingRealtimeBarRef.current = null;
}, [symbol, timeframe]);
@@ -652,12 +653,14 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
onManagerReady={mgr => {
managerRef.current = mgr;
const pending = pendingRealtimeBarRef.current;
if (pending) {
if (pending && barsMarketRef.current === symbolRef.current && bars.length > 0) {
pendingRealtimeBarRef.current = null;
const last = bars.length > 0 ? bars[bars.length - 1] : null;
const last = bars[bars.length - 1];
if (last && pending.time === last.time) mgr.updateBar(pending);
else if (!last || pending.time > last.time) void mgr.appendBar(pending);
else mgr.updateBar({ ...pending, time: last.time });
} else {
pendingRealtimeBarRef.current = null;
}
mgr.setSeriesPriceLabelsEnabled(chartSeriesPriceLabels);
mgr.setVolumeVisible(chartVolumeVisible);
+5 -18
View File
@@ -14,8 +14,6 @@ import {
} from '../utils/backendApi';
import { getKoreanName } from '../utils/marketNameCache';
const CANDLE_TYPES = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d'] as const;
interface Strategy {
id: number;
name: string;
@@ -94,7 +92,9 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
const monCount = monitoredMarkets.length;
const selectedStrategy = strategies.find(s => s.id === stratId);
const candleType = settings.candleType ?? '1m';
const strategyTimeframes = settings.strategyCandleTypes?.length
? settings.strategyCandleTypes.join(', ')
: '1m';
const execLabel = execType === 'REALTIME_TICK' ? '실시간 틱 (3초)' : '봉 마감 직후';
const posModeLabel = (settings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY'
? '보유 자산 기준'
@@ -175,7 +175,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
</div>
<div className="lsp-info-row">
<span className="lsp-info-label"> </span>
<span className="lsp-info-val">{candleType}</span>
<span className="lsp-info-val" title="전략 편집기 START에서 설정">{strategyTimeframes}</span>
</div>
</>
)}
@@ -211,19 +211,6 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
</select>
</div>
<div className={`lsp-field-row${!isOn ? ' lsp-field-row--disabled' : ''}`}>
<span className="lsp-field-label"> </span>
<select
className="lsp-select"
disabled={!isOn}
value={candleType}
onChange={e => persist({ candleType: e.target.value })}
>
{CANDLE_TYPES.map(ct => (
<option key={ct} value={ct}>{ct}</option>
))}
</select>
</div>
<div className={`lsp-section${!isOn ? ' lsp-section--disabled' : ''}`}>
<span className="lsp-section-label"> </span>
@@ -302,7 +289,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
<span className={`lsp-dot${isOn ? ' lsp-dot--on' : ''}`} />
<span className="lsp-status-text">
{stratId
? `${selectedStrategy?.name ?? '전략'} · ${candleType} · ${execLabel} · ${posModeLabel} · ${monCount}종목`
? `${selectedStrategy?.name ?? '전략'} · ${strategyTimeframes} · ${execLabel} · ${posModeLabel} · ${monCount}종목`
: '전략을 선택하세요'}
</span>
</div>
-16
View File
@@ -300,8 +300,6 @@ interface PaperPanelProps {
onLiveAutoTradeBudgetPct?: (v: number) => void;
}
const CANDLE_TYPES = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d'] as const;
const PaperPanel: React.FC<PaperPanelProps> = ({
paperTradingEnabled = true,
onPaperTradingEnabled,
@@ -844,20 +842,6 @@ const StrategyPanel: React.FC<StrategyPanelProps> = ({
</select>
</SettingRow>
<SettingRow label="전략 평가 분봉" desc="이 종목의 매수·매도 시그널을 계산할 캔들 주기입니다.">
<select
className="stg-select"
disabled={!isOn}
value={liveSettings.candleType ?? '1m'}
onChange={e => persistLive({ candleType: e.target.value })}
style={{ opacity: isOn ? 1 : 0.45 }}
>
{CANDLE_TYPES.map(ct => (
<option key={ct} value={ct}>{ct}</option>
))}
</select>
</SettingRow>
<SettingRow
className="stg-row--block"
label="체크 방식"
@@ -72,6 +72,7 @@ import {
} from '../utils/strategyImportExport';
import PaletteChip from './strategyEditor/PaletteChip';
import { readStoredSize, storeSize, usePanelResize } from './strategyEditor/usePanelResize';
import StrategyDescriptionModal from './strategyEditor/StrategyDescriptionModal';
import '../styles/strategyEditor.css';
import '../styles/strategyEditorTheme.css';
@@ -141,6 +142,7 @@ export default function StrategyEditorPage({ theme }: Props) {
const [isSaving, setIsSaving] = useState(false);
const [saveOpen, setSaveOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [descOpen, setDescOpen] = useState(false);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const initialDraftBuy = readTabLayout('draft', 'buy');
@@ -780,6 +782,21 @@ export default function StrategyEditorPage({ theme }: Props) {
</button>
</div>
<button type="button" className="se-btn se-btn--ghost" onClick={handleNew}> </button>
<button
type="button"
className="se-btn se-btn--ghost se-btn--icon se-btn--desc"
title="전략 설명 — 현재 조건을 서술형으로 보기"
aria-label="전략 설명"
onClick={() => setDescOpen(true)}
>
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden className="se-desc-icon">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="1.75" />
<path
fill="currentColor"
d="M11.25 10.5h1.5V17h-1.5V10.5zm0-3.25h1.5V9h-1.5V6.25z"
/>
</svg>
</button>
<button
type="button"
className="se-btn se-btn--ghost se-btn--icon"
@@ -1154,6 +1171,20 @@ export default function StrategyEditorPage({ theme }: Props) {
</DraggableModalFrame>
)}
{descOpen && (
<StrategyDescriptionModal
onClose={() => setDescOpen(false)}
name={stratName}
description={stratDesc}
buyEditorState={buyEditorState}
sellEditorState={sellEditorState}
buyCondition={buyCondition}
sellCondition={sellCondition}
orphanCount={orphanTotal}
def={DEF}
/>
)}
{snack && <div className={`se-snack${snack.ok ? '' : ' se-snack--err'}`}>{snack.msg}</div>}
</div>
);
+20 -5
View File
@@ -1,4 +1,4 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
import React, { useRef, useEffect, useLayoutEffect, useState, useCallback } from 'react';
import type { MouseEventParams, Time } from 'lightweight-charts';
import type { OHLCVBar, ChartType, Theme, IndicatorConfig, LegendData, Drawing, ChartMode, Timeframe } from '../types';
import { ChartManager } from '../utils/ChartManager';
@@ -139,6 +139,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
onSeriesDoubleClickRef.current = onSeriesDoubleClick;
const marketRef = useRef(market);
marketRef.current = market;
const barsMarketRef = useRef(barsMarket);
barsMarketRef.current = barsMarket;
const [ctxMenu, setCtxMenu] = useState<{
x: number; y: number; price: number;
@@ -165,8 +167,17 @@ const TradingChart: React.FC<TradingChartProps> = ({
const [candleOnlyMode, setCandleOnlyMode] = useState(false);
useEffect(() => {
barsRef.current = bars;
}, [bars]);
const ready = barsMarket == null
? false
: barsMarket === undefined
? true
: barsMarket === market;
if (ready) {
barsRef.current = bars;
} else if (barsMarket != null && barsMarket !== market) {
barsRef.current = [];
}
}, [bars, barsMarket, market]);
useEffect(() => {
indicatorsRef.current = indicators;
@@ -687,13 +698,16 @@ const TradingChart: React.FC<TradingChartProps> = ({
// 컨테이너가 처음으로 유효한 크기를 가질 때:
// 데이터가 이미 barsRef에 있지만 차트에 아직 세팅되지 않은 경우 (예: 멀티레이아웃 초기 렌더) 재로드
if (barsRef.current.length > 0 && !m.hasMainSeries()) {
reloadAll(
const bm = barsMarketRef.current;
if (bm != null && bm !== marketRef.current) return;
if (marketRef.current.startsWith('KRW-') && barsRef.current.length < MIN_BARS_FOR_FULL_RELOAD) return;
void reloadAll(
m,
barsRef.current,
prevChartType.current,
prevTheme.current,
prevLogScale.current,
[] // indicators는 별도 useEffect에서 처리
indicatorsRef.current,
);
} else {
applyPaneLayout(m);
@@ -746,6 +760,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
prevMarketTf.current = timeframe;
prevBarsKey.current = '';
prevIndKey.current = '';
barsRef.current = [];
reloadRetryRef.current = 0;
reloadGenRef.current += 1;
reloadSafetyTimers.current.forEach(clearTimeout);
@@ -10,8 +10,6 @@ import {
import { fmtKrw } from '../TradeOrderPanel';
import PaperSplitPanel from './PaperSplitPanel';
const CANDLE_TYPES = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d'] as const;
interface Props {
market: string;
summary: PaperSummaryDto | null;
@@ -45,7 +43,6 @@ const PaperLeftStrategyTab: React.FC<Props> = ({
isLiveCheck: false,
executionType: 'CANDLE_CLOSE',
positionMode: 'LONG_ONLY',
candleType: '1m',
});
}
});
@@ -108,18 +105,14 @@ const PaperLeftStrategyTab: React.FC<Props> = ({
))}
</select>
</label>
{selected && <p className="ptd-left-hint">: {selected.name}</p>}
<label className="ptd-left-field">
<span> </span>
<select
className="ptd-left-select"
value={settings?.candleType ?? '1m'}
disabled={saving}
onChange={e => void persist({ candleType: e.target.value })}
>
{CANDLE_TYPES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</label>
{selected && (
<p className="ptd-left-hint">
: {selected.name}
{settings?.strategyCandleTypes?.length
? ` · ${settings.strategyCandleTypes.join(', ')}`
: ''}
</p>
)}
<label className="ptd-left-field">
<span> </span>
<select
@@ -0,0 +1,85 @@
/**
* 전략 조건 서술형 설명 팝업
*/
import React, { useMemo } from 'react';
import DraggableModalFrame from '../DraggableModalFrame';
import {
buildStrategyNarrative,
type StrategyDescriptionInput,
} from '../../utils/strategyDescriptionNarrative';
interface Props extends StrategyDescriptionInput {
onClose: () => void;
}
const StrategyDescriptionModal: React.FC<Props> = ({
onClose,
name,
description,
buyEditorState,
sellEditorState,
buyCondition,
sellCondition,
orphanCount,
def,
}) => {
const narrative = useMemo(
() => buildStrategyNarrative({
name,
description,
buyEditorState,
sellEditorState,
buyCondition,
sellCondition,
orphanCount,
def,
}),
[name, description, buyEditorState, sellEditorState, buyCondition, sellCondition, orphanCount, def],
);
return (
<DraggableModalFrame
onClose={onClose}
title="전략 설명"
titleKo="Strategy Description"
badge="INFO"
width={560}
overlayClassName="se-desc-overlay"
dialogClassName="se-desc-modal app-popup-shell"
bodyClassName="se-desc-body app-popup-body"
zIndex={11000}
>
<div className="se-desc-content">
{narrative.intro.map((p, i) => (
<p key={`intro-${i}`} className="se-desc-intro">{p}</p>
))}
{narrative.sections.map(section => (
<section key={section.title} className="se-desc-section">
<h3 className="se-desc-section-title">{section.title}</h3>
{section.paragraphs.map((p, i) => (
<p key={`${section.title}-p-${i}`} className="se-desc-para">{p}</p>
))}
{section.bullets && section.bullets.length > 0 && (
<ul className="se-desc-list">
{section.bullets.map((line, i) => (
<li key={`${section.title}-b-${i}`} className="se-desc-list-item">{line}</li>
))}
</ul>
)}
</section>
))}
{narrative.footnotes.length > 0 && (
<footer className="se-desc-footnotes">
{narrative.footnotes.map((note, i) => (
<p key={`fn-${i}`} className="se-desc-footnote"> {note}</p>
))}
</footer>
)}
</div>
</DraggableModalFrame>
);
};
export default StrategyDescriptionModal;