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

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
+22 -25
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import React, { useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo } from 'react';
import DrawingToolbar from './components/DrawingToolbar';
import BottomBar from './components/BottomBar';
import TradingChart from './components/TradingChart';
@@ -70,7 +70,7 @@ import { BacktestHistoryPage } from './components/BacktestHistoryPage';
import SettingsPage from './components/SettingsPage';
import PaperTradingPage from './components/PaperTradingPage';
import DashboardPage from './components/DashboardPage';
import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings } from './utils/backendApi';
import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings, expandLiveStrategySubscriptions } from './utils/backendApi';
import ChartLegendBar from './components/ChartLegendBar';
import RightSidePanel from './components/RightSidePanel';
import {
@@ -697,16 +697,14 @@ function App() {
// ── 실시간 전략 체크 (전역 설정 + DB 관심종목 = 체크 대상) ─────────────────
const [showLivePanel, setShowLivePanel] = useState(false);
const [liveStrategies, setLiveStrategies] = useState<{id:number;name:string}[]>([]);
const [liveCandleType, setLiveCandleType] = useState('1m');
const [liveStrategyCandleTypes, setLiveStrategyCandleTypes] = useState<string[] | undefined>();
useEffect(() => {
if (!appSettingsLoaded) return;
loadLiveStrategySettings(symbol)
.then(s => {
if (s?.candleType) setLiveCandleType(s.candleType);
})
.catch(() => { /* 기본 1m 유지 */ });
}, [symbol, appSettingsLoaded]);
.then(s => setLiveStrategyCandleTypes(s.strategyCandleTypes))
.catch(() => { /* optional */ });
}, [symbol, appSettingsLoaded, appDefaults.liveStrategyId]);
const liveStrategySettings: LiveStrategySettingsDto = useMemo(() => ({
market: symbol,
@@ -714,8 +712,8 @@ function App() {
isLiveCheck: appDefaults.liveStrategyCheck ?? false,
executionType: appDefaults.liveExecutionType ?? 'CANDLE_CLOSE',
positionMode: appDefaults.livePositionMode ?? 'LONG_ONLY',
candleType: liveCandleType,
}), [symbol, appDefaults, liveCandleType]);
strategyCandleTypes: liveStrategyCandleTypes,
}), [symbol, appDefaults, liveStrategyCandleTypes]);
/** 실시간 체크 ON + 전략 선택 시 STOMP 구독 대상 (관심종목 + 현재 차트 종목) */
const monitoredMarkets = useMemo(() => {
@@ -727,13 +725,12 @@ function App() {
const [marketSubscriptions, setMarketSubscriptions] = useState<{ market: string; candleType: string }[]>([]);
/** STOMP 구독 목록 — API 로드 전에도 관심종목 기준으로 바로 구독 (연결 끊김 방지) */
/** STOMP 구독 목록 — API 로드 전에도 관심종목 × 1m 폴백 구독 */
const liveStompSubscriptions = useMemo(() => {
if (!appDefaults.liveStrategyCheck || !appDefaults.liveStrategyId) return [];
if (marketSubscriptions.length > 0) return marketSubscriptions;
const ct = liveCandleType || '1m';
return monitoredMarkets.map(m => ({ market: m, candleType: ct }));
}, [appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, marketSubscriptions, monitoredMarkets, liveCandleType]);
return monitoredMarkets.map(m => ({ market: m, candleType: '1m' }));
}, [appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, marketSubscriptions, monitoredMarkets]);
useEffect(() => {
if (!appDefaults.liveStrategyCheck) {
@@ -741,11 +738,9 @@ function App() {
return;
}
loadActiveLiveStrategySettings()
.then(list => setMarketSubscriptions(
list.map(s => ({ market: s.market, candleType: s.candleType ?? '1m' })),
))
.then(list => setMarketSubscriptions(expandLiveStrategySubscriptions(list)))
.catch(() => { /* liveStompSubscriptions 가 monitoredMarkets 폴백 사용 */ });
}, [appDefaults.liveStrategyCheck, monitoredMarkets.join(',')]);
}, [appDefaults.liveStrategyCheck, appDefaults.liveStrategyId, monitoredMarkets.join(',')]);
const handleLiveSettingsChange = useCallback((saved: LiveStrategySettingsDto) => {
saveAppDef({
@@ -754,12 +749,12 @@ function App() {
liveExecutionType: saved.executionType,
livePositionMode: saved.positionMode,
});
if (saved.candleType) setLiveCandleType(saved.candleType);
if (saved.strategyCandleTypes) {
setLiveStrategyCandleTypes(saved.strategyCandleTypes);
}
if (appDefaults.liveStrategyCheck && saved.isLiveCheck) {
loadActiveLiveStrategySettings()
.then(list => setMarketSubscriptions(
list.map(s => ({ market: s.market, candleType: s.candleType ?? '1m' })),
))
.then(list => setMarketSubscriptions(expandLiveStrategySubscriptions(list)))
.catch(() => { /* 폴백 유지 */ });
}
}, [saveAppDef, appDefaults.liveStrategyCheck]);
@@ -983,8 +978,8 @@ function App() {
const barsMarket = useUpbit ? upbitBarsMarket : symbol;
barsMarketRef.current = barsMarket;
/** 종목·타임프레임 전환 시 이전 틱 버퍼 폐기 */
useEffect(() => {
/** 종목·타임프레임 전환 시 이전 틱 버퍼 폐기 (paint 전에 실행) */
useLayoutEffect(() => {
pendingRealtimeBarRef.current = null;
}, [symbol, timeframe]);
@@ -1981,12 +1976,14 @@ function App() {
onManagerReady={mgr => {
managerRef.current = mgr;
const pending = pendingRealtimeBarRef.current;
if (pending && bars.length > 0) {
if (pending && barsMarketRef.current === symbol && bars.length > 0) {
pendingRealtimeBarRef.current = 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);
const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null;
+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;
-1
View File
@@ -266,7 +266,6 @@ export function useStompChartData(
setWsStatus('connecting');
setError(null);
historyReadyRef.current = false;
last1mVolRef.current = 0;
last1mTimeRef.current = 0;
void pinChartWatch(market, STOMP_TICK_CANDLE_TYPE);
+76
View File
@@ -1552,6 +1552,82 @@
color: var(--se-danger);
}
/* ── 전략 설명 팝업 ── */
.se-btn--desc .se-desc-icon {
display: block;
opacity: 0.9;
}
.se-btn--desc:hover .se-desc-icon {
opacity: 1;
color: var(--se-accent, #3f7ef5);
}
.se-desc-overlay {
z-index: 10999;
}
.se-desc-modal {
max-height: min(88vh, 720px);
display: flex;
flex-direction: column;
}
.se-desc-body {
overflow: auto;
max-height: min(72vh, 600px);
padding: 12px 16px 16px !important;
}
.se-desc-content {
font-size: 0.84rem;
line-height: 1.65;
color: var(--se-text);
}
.se-desc-intro {
margin: 0 0 12px;
color: var(--se-text-muted);
}
.se-desc-section {
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px solid var(--se-border, rgba(255, 255, 255, 0.08));
}
.se-desc-section:last-of-type {
border-bottom: none;
margin-bottom: 8px;
}
.se-desc-section-title {
margin: 0 0 8px;
font-size: 0.92rem;
font-weight: 700;
color: var(--se-text);
}
.se-desc-section-title:first-child {
margin-top: 0;
}
.se-desc-para {
margin: 0 0 8px;
}
.se-desc-list {
margin: 6px 0 0;
padding-left: 0;
list-style: none;
}
.se-desc-list-item {
margin: 4px 0;
padding-left: 4px;
white-space: pre-wrap;
word-break: keep-all;
}
.se-desc-footnotes {
margin-top: 12px;
padding-top: 10px;
border-top: 1px dashed var(--se-border, rgba(255, 255, 255, 0.1));
}
.se-desc-footnote {
margin: 4px 0 0;
font-size: 0.75rem;
color: var(--se-text-muted);
line-height: 1.5;
}
@media (max-width: 1100px) {
.se-right { flex: 0 0 300px; width: 300px; }
}
+14 -2
View File
@@ -1015,8 +1015,10 @@ export interface LiveStrategySettingsDto {
executionType: string;
/** "LONG_ONLY" | "SIGNAL_ONLY" — 매도 시그널 포지션 종속성 모드 */
positionMode?: string;
/** 전략 평가 분봉: 1m, 3m, 5m, 15m, 30m, 1h, 4h, 1d */
/** @deprecated 전략 DSL에서 시간봉을 자동 추출합니다 */
candleType?: string;
/** 전략 조건 DSL에 포함된 평가 시간봉 (응답 전용) */
strategyCandleTypes?: string[];
}
/** 실시간 전략 체크 설정 로드 */
@@ -1025,7 +1027,7 @@ export async function loadLiveStrategySettings(
): Promise<LiveStrategySettingsDto> {
return (await request<LiveStrategySettingsDto>(
`/strategy/settings?market=${encodeURIComponent(market)}`,
)) ?? { market, strategyId: null, isLiveCheck: false, executionType: 'CANDLE_CLOSE', positionMode: 'LONG_ONLY', candleType: '1m' };
)) ?? { market, strategyId: null, isLiveCheck: false, executionType: 'CANDLE_CLOSE', positionMode: 'LONG_ONLY' };
}
/** 실시간 전략 체크 설정 저장 */
@@ -1038,6 +1040,16 @@ export async function saveLiveStrategySettings(
});
}
/** 실시간 전략 STOMP 구독 목록 (종목 × 전략 DSL 시간봉) */
export function expandLiveStrategySubscriptions(
list: LiveStrategySettingsDto[],
): { market: string; candleType: string }[] {
return list.flatMap(s => {
const types = s.strategyCandleTypes?.length ? s.strategyCandleTypes : ['1m'];
return types.map(candleType => ({ market: s.market, candleType }));
});
}
/** 현재 디바이스에서 실시간 체크 ON + 전략 지정된 종목 목록 */
export async function loadActiveLiveStrategySettings(): Promise<LiveStrategySettingsDto[]> {
return (await request<LiveStrategySettingsDto[]>('/strategy/settings/active')) ?? [];
@@ -0,0 +1,350 @@
/**
* 전략 조건 DSL → 알기 쉬운 한국어 서술형 설명
*/
import type { LogicNode } from './strategyTypes';
import { CONDITION_LABEL } from './strategyTypes';
import {
collectEditorBranches,
normalizeStartCombineOp,
type EditorConditionState,
} from './strategyConditionSerde';
import {
compositeDisplayName,
normalizeCompositeCondition,
} from './compositeIndicators';
import {
getFieldOpts,
resolveFieldOptionValue,
type DefType,
} from './strategyEditorShared';
import { parseThresholdField } from './conditionPeriods';
import { formatStartCandleLabel } from './strategyStartNodes';
export interface StrategyDescriptionInput {
name?: string;
description?: string;
buyEditorState: EditorConditionState;
sellEditorState: EditorConditionState;
buyCondition: LogicNode | null;
sellCondition: LogicNode | null;
orphanCount?: number;
def: DefType;
}
export interface StrategyNarrativeSection {
title: string;
paragraphs: string[];
bullets?: string[];
}
export interface StrategyNarrative {
intro: string[];
sections: StrategyNarrativeSection[];
footnotes: string[];
}
const CANDLE_KO: Record<string, string> = {
'1m': '1분봉',
'3m': '3분봉',
'5m': '5분봉',
'15m': '15분봉',
'30m': '30분봉',
'1h': '1시간봉',
'4h': '4시간봉',
'1d': '일봉',
};
function candleKo(ct: string): string {
const key = formatStartCandleLabel(ct);
return CANDLE_KO[key] ?? `${key}`;
}
function fieldLabel(
indicatorType: string,
field: string | undefined,
def: DefType,
signalType: 'buy' | 'sell',
cond?: ReturnType<typeof normalizeCompositeCondition>,
): string {
const opts = getFieldOpts(indicatorType, signalType, def, cond);
const resolved = resolveFieldOptionValue(indicatorType, field);
const hit = opts.find(o => o.value === resolved);
if (hit && hit.label !== '선택안함') return hit.label;
const thresh = field ? parseThresholdField(field) : null;
if (thresh != null) return `임계값 ${thresh}`;
return field ?? indicatorType;
}
function describeConditionType(
conditionType: string,
left: string,
right: string,
extras: { compareValue?: number; slopePeriod?: number; holdDays?: number },
): string {
const cv = extras.compareValue;
const sp = extras.slopePeriod ?? 3;
const hd = extras.holdDays ?? 3;
switch (conditionType) {
case 'GT': return `${left}이(가) ${right}보다 큰 경우`;
case 'LT': return `${left}이(가) ${right}보다 작은 경우`;
case 'GTE': return `${left}이(가) ${right} 이상인 경우`;
case 'LTE': return `${left}이(가) ${right} 이하인 경우`;
case 'EQ': return `${left}과(와) ${right}이(가) 같은 경우`;
case 'NEQ': return `${left}과(와) ${right}이(가) 다른 경우`;
case 'CROSS_UP': return `${left}이(가) ${right}을(를) 상향 돌파하는 경우`;
case 'CROSS_DOWN': return `${left}이(가) ${right}을(를) 하향 돌파하는 경우`;
case 'SLOPE_UP': return `${left}이(가) 최근 ${sp}봉 동안 상승 추세인 경우`;
case 'SLOPE_DOWN': return `${left}이(가) 최근 ${sp}봉 동안 하락 추세인 경우`;
case 'DIFF_GT': return `${left}과(와) ${right}의 차이가 ${cv ?? 0}보다 큰 경우`;
case 'DIFF_LT': return `${left}과(와) ${right}의 차이가 ${cv ?? 0}보다 작은 경우`;
case 'HOLD_N_DAYS': return `조건이 ${hd}봉 연속 유지되는 경우`;
case 'ABOVE_CLOUD': return '가격이 일목균형표 구름대 위에 있는 경우';
case 'BELOW_CLOUD': return '가격이 일목균형표 구름대 아래에 있는 경우';
case 'IN_CLOUD': return '가격이 일목균형표 구름 안에 있는 경우';
case 'CLOUD_BREAK_UP': return '가격이 구름대를 상향 돌파하는 경우';
case 'CLOUD_BREAK_DOWN': return '가격이 구름대를 하향 돌파하는 경우';
case 'SPAN1_GT_SPAN2': return '선행스팬1이 선행스팬2보다 위에 있는 경우';
case 'SPAN1_LT_SPAN2': return '선행스팬1이 선행스팬2보다 아래에 있는 경우';
case 'SPAN1_CROSS_UP_SPAN2': return '선행스팬1이 선행스팬2를 상향 돌파하는 경우';
case 'SPAN1_CROSS_DOWN_SPAN2': return '선행스팬1이 선행스팬2를 하향 돌파하는 경우';
case 'LAGGING_GT_PRICE': return '후행스팬이 종가보다 위에 있는 경우';
case 'LAGGING_LT_PRICE': return '후행스팬이 종가보다 아래에 있는 경우';
default: {
const label = CONDITION_LABEL[conditionType] ?? conditionType;
if (right && right !== '선택안함') return `${left}${label}${right}`;
return `${left}${label}`;
}
}
}
function describeCondition(
cond: ReturnType<typeof normalizeCompositeCondition>,
signalType: 'buy' | 'sell',
def: DefType,
): string {
const ind = cond.indicatorType;
const ct = cond.conditionType;
if (cond.composite && cond.leftPeriod && cond.rightPeriod) {
const name = compositeDisplayName(ind).split(' + ')[0] ?? ind;
const short = `${name} ${cond.leftPeriod}기간`;
const long = `${name} ${cond.rightPeriod}기간`;
switch (ct) {
case 'GT': return `${short} 값이 ${long} 값보다 큰 경우`;
case 'LT': return `${short} 값이 ${long} 값보다 작은 경우`;
case 'GTE': return `${short} 값이 ${long} 값 이상인 경우`;
case 'LTE': return `${short} 값이 ${long} 값 이하인 경우`;
case 'CROSS_UP': return `${short} 값이 ${long} 값을 상향 돌파하는 경우`;
case 'CROSS_DOWN': return `${short} 값이 ${long} 값을 하향 돌파하는 경우`;
default: {
const label = CONDITION_LABEL[ct] ?? ct;
return `${short}과(와) ${long}을(를) 비교할 때 「${label}」 조건이 성립하는 경우`;
}
}
}
const left = fieldLabel(ind, cond.leftField, def, signalType, cond);
const right = fieldLabel(ind, cond.rightField, def, signalType, cond);
if (['ABOVE_CLOUD', 'BELOW_CLOUD', 'IN_CLOUD', 'CLOUD_BREAK_UP', 'CLOUD_BREAK_DOWN'].includes(ct)) {
return describeConditionType(ct, left, right, cond);
}
if (right && right !== '선택안함') {
return describeConditionType(ct, left, right, cond);
}
const label = CONDITION_LABEL[ct] ?? ct;
return `${left}에 대해 「${label}」 조건이 성립하는 경우`;
}
function describeNode(
node: LogicNode,
signalType: 'buy' | 'sell',
def: DefType,
depth = 0,
): string[] {
if (node.type === 'CONDITION' && node.condition) {
const c = normalizeCompositeCondition(node.condition);
const line = describeCondition(c, signalType, def);
return [depth > 0 ? `${line}` : line];
}
if (node.type === 'TIMEFRAME') {
const inner = node.children?.[0];
const tf = candleKo(node.candleType ?? '1m');
if (!inner) return [`[${tf}] 연결된 조건이 없습니다.`];
const innerLines = describeNode(inner, signalType, def, depth + 1);
return [`[${tf} 기준]`, ...innerLines.map(l => (l.startsWith('•') ? ` ${l}` : `${l}`))];
}
if (node.type === 'AND') {
const children = node.children ?? [];
if (children.length === 0) return ['(비어 있는 AND 그룹)'];
const lines: string[] = [];
if (depth === 0 && children.length > 1) {
lines.push('다음 조건을 모두 동시에 만족해야 합니다.');
}
children.forEach((child, i) => {
const sub = describeNode(child, signalType, def, depth + 1);
if (children.length > 1) lines.push(`${i + 1})`);
lines.push(...sub);
});
return lines;
}
if (node.type === 'OR') {
const children = node.children ?? [];
if (children.length === 0) return ['(비어 있는 OR 그룹)'];
const lines: string[] = [];
if (depth === 0 && children.length > 1) {
lines.push('다음 조건 중 하나라도 만족하면 됩니다.');
}
children.forEach((child, i) => {
const sub = describeNode(child, signalType, def, depth + 1);
if (children.length > 1) lines.push(`${i + 1})`);
lines.push(...sub);
});
return lines;
}
if (node.type === 'NOT') {
const child = node.children?.[0];
if (!child) return ['(비어 있는 NOT 그룹)'];
const sub = describeNode(child, signalType, def, depth + 1);
return ['다음 조건이 성립하지 않을 때:', ...sub.map(l => ` (부정) ${l.replace(/^•\s*/, '')}`)];
}
return [];
}
function describeSignalBranches(
editorState: EditorConditionState | undefined,
fallbackRoot: LogicNode | null,
signalType: 'buy' | 'sell',
def: DefType,
): { hasContent: boolean; paragraphs: string[]; bullets: string[] } {
const branches = editorState
? collectEditorBranches(editorState)
: fallbackRoot
? [{ candleType: '1m', root: fallbackRoot }]
: [];
const active = branches.filter(b => b.root);
if (active.length === 0) {
return { hasContent: false, paragraphs: [], bullets: [] };
}
const bullets: string[] = [];
const paragraphs: string[] = [];
if (active.length === 1) {
const { candleType, root } = active[0];
const tf = candleKo(candleType);
paragraphs.push(`${tf} 차트를 기준으로 아래 조건을 평가합니다.`);
bullets.push(...describeNode(root!, signalType, def));
return { hasContent: true, paragraphs, bullets };
}
const combineOp = normalizeStartCombineOp(editorState?.startCombineOp);
paragraphs.push(
combineOp === 'AND'
? '여러 시간봉에 걸친 조건을 모두 충족해야 합니다.'
: '여러 시간봉 중 하나의 조건만 충족해도 됩니다.',
);
active.forEach((branch, idx) => {
const tf = candleKo(branch.candleType);
bullets.push(`[시간봉 ${idx + 1}: ${tf}]`);
if (branch.root) {
bullets.push(...describeNode(branch.root, signalType, def, 1).map(l =>
l.startsWith('•') ? ` ${l}` : `${l}`,
));
}
});
return { hasContent: true, paragraphs, bullets };
}
function hasSignalContent(
editorState: EditorConditionState,
fallback: LogicNode | null,
): boolean {
if (collectEditorBranches(editorState).some(b => b.root)) return true;
return !!fallback;
}
export function buildStrategyNarrative(input: StrategyDescriptionInput): StrategyNarrative {
const {
name,
description,
buyEditorState,
sellEditorState,
buyCondition,
sellCondition,
orphanCount = 0,
def,
} = input;
const intro: string[] = [];
if (name?.trim()) {
intro.push(`${name.trim()}」 전략의 매수·매도 조건을 사람이 읽기 쉬운 문장으로 풀어 쓴 설명입니다.`);
} else {
intro.push('현재 편집 중인 전략의 매수·매도 조건을 설명합니다.');
}
if (description?.trim()) {
intro.push(`메모: ${description.trim()}`);
}
const sections: StrategyNarrativeSection[] = [];
const footnotes: string[] = [];
const buy = describeSignalBranches(buyEditorState, buyCondition, 'buy', def);
if (hasSignalContent(buyEditorState, buyCondition)) {
sections.push({
title: '매수 조건 (진입)',
paragraphs: [
'실시간 체크 또는 백테스트에서 아래 매수 조건이 충족되면 매수 신호가 발생합니다.',
...buy.paragraphs,
],
bullets: buy.bullets.length > 0 ? buy.bullets : undefined,
});
} else {
sections.push({
title: '매수 조건 (진입)',
paragraphs: ['매수 조건이 아직 설정되지 않았습니다. 매수 탭에서 지표·논리 블록을 연결해 주세요.'],
});
}
const sell = describeSignalBranches(sellEditorState, sellCondition, 'sell', def);
if (hasSignalContent(sellEditorState, sellCondition)) {
sections.push({
title: '매도 조건 (청산)',
paragraphs: [
'포지션을 보유한 상태에서 아래 매도 조건이 충족되면 매도 신호가 발생합니다. (시그널 모드에 따라 포지션 없이도 표시될 수 있습니다.)',
...sell.paragraphs,
],
bullets: sell.bullets.length > 0 ? sell.bullets : undefined,
});
} else {
sections.push({
title: '매도 조건 (청산)',
paragraphs: ['매도 조건이 아직 설정되지 않았습니다. 매도 탭에서 조건을 구성해 주세요.'],
});
}
if (orphanCount > 0) {
footnotes.push(
`캔버스에 연결되지 않은 요소 ${orphanCount}개는 실제 매매 판정에 포함되지 않습니다.`,
);
}
footnotes.push(
'각 START 노드에 지정한 시간봉이 실시간 전략 체크·백테스트의 평가 주기가 됩니다.',
);
footnotes.push(
'지표 기간·임계값은 전략 빌더 우측 「전략 조건 전용 설정」에 표시된 값을 기준으로 합니다.',
);
return { intro, sections, footnotes };
}