전략조건 상세설명 기능 추가
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user