위젯항목 추가

This commit is contained in:
Macbook
2026-06-14 01:59:54 +09:00
parent 9737767777
commit 7a2f65088c
13 changed files with 485 additions and 3 deletions
@@ -0,0 +1,123 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
loadBacktestSettings,
runBacktest,
type BacktestSignal,
type StrategyDto,
} from '../../utils/backendApi';
import { loadAnalysisCandles } from '../../utils/analysisChartData';
import { normalizeChartTimeframe } from '../../utils/backtestUiUtils';
import { resolveStrategyPrimaryTimeframe } from '../../utils/strategyToChartIndicators';
import { resolveEvaluationFromLoadedBars } from '../../utils/backtestWarmup';
import type { OHLCVBar } from '../../types';
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
const BAR_COUNT = 300;
/** 선택 종목·전략에 대한 백테스트 매매 시그널 목록 */
export function useWidgetBacktestSignals(
market: string,
strategyId: number | null,
strategy: StrategyDto | undefined,
) {
const { getParams } = useIndicatorSettings();
const [signals, setSignals] = useState<BacktestSignal[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const genRef = useRef(0);
const toTimeSec = useMemo(() => Math.floor(Date.now() / 1000), [market, strategyId]);
const runSignals = useCallback(async (
bars: OHLCVBar[],
tf: string,
strat: StrategyDto,
sid: number,
) => {
const sym = market.startsWith('KRW-') ? market : `KRW-${market}`;
if (bars.length < 10) {
setSignals([]);
setError('캔들 데이터가 부족합니다.');
return;
}
const gen = ++genRef.current;
setLoading(true);
setError(null);
try {
const btSettings = await loadBacktestSettings();
const indicatorParams = Object.fromEntries(
['RSI', 'MACD', 'CCI', 'SMA', 'EMA', 'IchimokuCloud', 'Stochastic', 'ADX', 'MFI', 'NewPsychological'].map(t => [
t,
getParams(t) as Record<string, unknown>,
]),
);
const { evaluationBarCount } = resolveEvaluationFromLoadedBars(
bars.length,
strat.buyCondition,
strat.sellCondition,
indicatorParams,
);
const res = await runBacktest({
strategyId: sid,
bars: bars.map(b => ({
time: b.time,
open: b.open,
high: b.high,
low: b.low,
close: b.close,
volume: b.volume,
})),
timeframe: tf,
symbol: sym,
strategyName: strat.name,
settings: btSettings,
indicatorParams,
evaluationBarCount,
});
if (gen !== genRef.current) return;
if (!res) {
setSignals([]);
setError('시그널 계산에 실패했습니다.');
return;
}
setSignals(res.signals);
} catch (e) {
if (gen !== genRef.current) return;
setSignals([]);
setError(e instanceof Error ? e.message : '시그널 계산 실패');
} finally {
if (gen === genRef.current) setLoading(false);
}
}, [market, getParams]);
useEffect(() => {
if (!strategyId || !strategy) {
setSignals([]);
setError(strategyId ? null : '전략을 선택하세요.');
return;
}
let cancelled = false;
const tf = normalizeChartTimeframe(resolveStrategyPrimaryTimeframe(strategy));
void (async () => {
try {
const sym = market.startsWith('KRW-') ? market : `KRW-${market}`;
const bars = await loadAnalysisCandles(sym, tf, toTimeSec, BAR_COUNT);
if (cancelled) return;
await runSignals(bars, tf, strategy, strategyId);
} catch (e) {
if (!cancelled) {
setSignals([]);
setError(e instanceof Error ? e.message : '시그널 계산 실패');
setLoading(false);
}
}
})();
return () => {
cancelled = true;
++genRef.current;
};
}, [market, strategyId, strategy, toTimeSec, runSignals]);
return { signals, loading, error };
}
@@ -0,0 +1,45 @@
import { useEffect, useMemo, useState } from 'react';
import { loadStrategy, type StrategyDto } from '../../utils/backendApi';
import { useWidgetDashboard } from '../WidgetDashboardContext';
function strategyHasConditions(s: StrategyDto | undefined): boolean {
return Boolean(s?.buyCondition || s?.sellCondition);
}
/** 위젯 대시보드 — config·컨텍스트에서 전략 ID·전체 DTO 해석 */
export function useWidgetSelectedStrategy(configStrategyId?: unknown) {
const { strategies, selectedStrategyId: ctxStrategyId } = useWidgetDashboard();
const strategyId = useMemo(() => {
if (typeof configStrategyId === 'number' && configStrategyId > 0) return configStrategyId;
if (ctxStrategyId != null && ctxStrategyId > 0) return ctxStrategyId;
return strategies[0]?.id ?? null;
}, [configStrategyId, ctxStrategyId, strategies]);
const listStrategy = useMemo(
() => (strategyId != null ? strategies.find(s => s.id === strategyId) : undefined),
[strategies, strategyId],
);
const [strategy, setStrategy] = useState<StrategyDto | undefined>();
useEffect(() => {
if (strategyId == null) {
setStrategy(undefined);
return;
}
if (listStrategy && strategyHasConditions(listStrategy)) {
setStrategy(listStrategy);
return;
}
let cancelled = false;
void loadStrategy(strategyId).then(s => {
if (!cancelled) setStrategy(s ?? listStrategy);
});
return () => { cancelled = true; };
}, [strategyId, listStrategy]);
const strategyLabel = strategy?.name ?? listStrategy?.name;
return { strategyId, strategy, strategies, strategyLabel };
}
@@ -0,0 +1,33 @@
import React from 'react';
import BacktestSignalTable from '../../components/backtest/BacktestSignalTable';
import { useWidgetDashboard } from '../WidgetDashboardContext';
import { useWidgetSelectedStrategy } from '../hooks/useWidgetSelectedStrategy';
import { useWidgetBacktestSignals } from '../hooks/useWidgetBacktestSignals';
import '../../styles/backtestDashboard.css';
/** 백테스트 매매 시그널 목록 — BacktestSignalTable */
const BacktestSignalsWidgetHost: React.FC<{ config?: Record<string, unknown> }> = ({ config }) => {
const { selectedMarket } = useWidgetDashboard();
const { strategyId, strategy } = useWidgetSelectedStrategy(config?.strategyId);
const { signals, loading, error } = useWidgetBacktestSignals(selectedMarket, strategyId, strategy);
if (!strategyId) {
return <p className="wd-widget-empty"> .</p>;
}
if (loading && signals.length === 0 && !error) {
return <p className="wd-widget-empty wd-widget-empty--loading"> </p>;
}
if (error && signals.length === 0) {
return <p className="wd-widget-empty">{error}</p>;
}
return (
<div className="wd-widget-host wd-widget-host--analysis wd-widget-host--backtest-signals">
<BacktestSignalTable signals={signals} expanded className="wd-backtest-sig-table" />
</div>
);
};
export default BacktestSignalsWidgetHost;
@@ -0,0 +1,38 @@
import React from 'react';
import VirtualTargetSignalPanel from '../../components/virtual/VirtualTargetSignalPanel';
import { useTradeNotificationSignalSnapshot } from '../../hooks/useTradeNotificationSignalSnapshot';
import { useWidgetDashboard } from '../WidgetDashboardContext';
import { useWidgetSelectedStrategy } from '../hooks/useWidgetSelectedStrategy';
import '../../styles/virtualTradingDashboard.css';
/** 지표별 충족 상태 — VirtualTargetSignalPanel detail */
const IndicatorStatusWidgetHost: React.FC<{ config?: Record<string, unknown> }> = ({ config }) => {
const { selectedMarket } = useWidgetDashboard();
const { strategyId, strategy, strategies, strategyLabel } = useWidgetSelectedStrategy(config?.strategyId);
const { snapshot, loading } = useTradeNotificationSignalSnapshot(
selectedMarket,
strategyId,
strategy,
strategyLabel,
strategyId != null,
strategies,
);
if (!strategyId) {
return <p className="wd-widget-empty"> .</p>;
}
return (
<div className="wd-widget-host wd-widget-host--analysis wd-widget-host--indicator-status">
<VirtualTargetSignalPanel
snapshot={snapshot}
viewMode="detail"
loading={loading}
hasStrategy
/>
</div>
);
};
export default IndicatorStatusWidgetHost;
@@ -0,0 +1,42 @@
import React from 'react';
import StrategyEvaluationSignalPanel from '../../components/strategyEvaluation/StrategyEvaluationSignalPanel';
import { useTradeNotificationSignalSnapshot } from '../../hooks/useTradeNotificationSignalSnapshot';
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
import { useWidgetDashboard } from '../WidgetDashboardContext';
import { useWidgetSelectedStrategy } from '../hooks/useWidgetSelectedStrategy';
import '../../styles/strategyEvaluation.css';
import '../../styles/virtualTradingDashboard.css';
/** 매수·매도 조건 일치율 — StrategyEvaluationSignalPanel */
const SignalMatchWidgetHost: React.FC<{ config?: Record<string, unknown> }> = ({ config }) => {
const { selectedMarket } = useWidgetDashboard();
const { strategyId, strategy, strategies, strategyLabel } = useWidgetSelectedStrategy(config?.strategyId);
const { getParams } = useIndicatorSettings();
const { snapshot, loading } = useTradeNotificationSignalSnapshot(
selectedMarket,
strategyId,
strategy,
strategyLabel,
strategyId != null,
strategies,
);
if (!strategyId) {
return <p className="wd-widget-empty"> .</p>;
}
return (
<div className="wd-widget-host wd-widget-host--analysis wd-widget-host--signal-match">
<StrategyEvaluationSignalPanel
snapshot={snapshot}
strategy={strategy}
loading={loading}
hasStrategy
getParams={getParams}
/>
</div>
);
};
export default SignalMatchWidgetHost;
@@ -0,0 +1,31 @@
import React from 'react';
import TradeNotificationSignalSummary from '../../components/tradeNotification/TradeNotificationSignalSummary';
import { useWidgetDashboard } from '../WidgetDashboardContext';
import { useWidgetSelectedStrategy } from '../hooks/useWidgetSelectedStrategy';
import '../../styles/virtualTradingDashboard.css';
/** 실시간 매매 시그널 요약 — VirtualTargetCard 요약형 */
const SignalSummaryWidgetHost: React.FC<{ config?: Record<string, unknown> }> = ({ config }) => {
const { theme, selectedMarket, tickers, strategies } = useWidgetDashboard();
const { strategyId, strategy, strategyLabel } = useWidgetSelectedStrategy(config?.strategyId);
if (!strategyId) {
return <p className="wd-widget-empty"> .</p>;
}
return (
<div className={`wd-widget-host wd-widget-host--analysis wd-widget-host--signal-summary se-page se-page--${theme}`}>
<TradeNotificationSignalSummary
market={selectedMarket}
strategyId={strategyId}
strategy={strategy}
strategies={strategies}
theme={theme}
ticker={tickers.get(selectedMarket)}
strategyLabel={strategyLabel}
/>
</div>
);
};
export default SignalSummaryWidgetHost;
@@ -22,6 +22,7 @@ const StrategyChartWidgetHost: React.FC<{ config?: Record<string, unknown> }> =
market={selectedMarket}
strategyId={strategyId}
theme={theme}
showHoverToolbar={false}
/>
</div>
);
@@ -0,0 +1,29 @@
import React from 'react';
import StrategyConditionView from '../../components/tradeNotification/StrategyConditionView';
import { useWidgetSelectedStrategy } from '../hooks/useWidgetSelectedStrategy';
import '../../styles/appPopup.css';
/** 전략 매수·매도 조건 트리 — StrategyConditionView */
const StrategyConditionWidgetHost: React.FC<{ config?: Record<string, unknown> }> = ({ config }) => {
const { strategyId, strategy } = useWidgetSelectedStrategy(config?.strategyId);
if (!strategyId) {
return <p className="wd-widget-empty"> .</p>;
}
if (!strategy) {
return <p className="wd-widget-empty wd-widget-empty--loading"> </p>;
}
if (!strategy.buyCondition && !strategy.sellCondition) {
return <p className="wd-widget-empty"> · .</p>;
}
return (
<div className="wd-widget-host wd-widget-host--analysis wd-widget-host--strategy-condition">
<StrategyConditionView strategy={strategy} />
</div>
);
};
export default StrategyConditionWidgetHost;
+48 -1
View File
@@ -22,6 +22,11 @@ import NotificationListWidgetHost from './hosts/NotificationListWidgetHost';
import StrategyListWidgetHost from './hosts/StrategyListWidgetHost';
import BacktestListWidgetHost from './hosts/BacktestListWidgetHost';
import PaperKpiWidgetHost from './hosts/PaperKpiWidgetHost';
import SignalSummaryWidgetHost from './hosts/SignalSummaryWidgetHost';
import SignalMatchWidgetHost from './hosts/SignalMatchWidgetHost';
import StrategyConditionWidgetHost from './hosts/StrategyConditionWidgetHost';
import BacktestSignalsWidgetHost from './hosts/BacktestSignalsWidgetHost';
import IndicatorStatusWidgetHost from './hosts/IndicatorStatusWidgetHost';
export type WidgetGroupId =
| 'chart'
@@ -29,6 +34,7 @@ export type WidgetGroupId =
| 'trade'
| 'portfolio'
| 'strategy'
| 'analysis'
| 'alert';
export interface WidgetDefinition {
@@ -47,6 +53,7 @@ export const WIDGET_GROUP_LABELS: Record<WidgetGroupId, string> = {
trade: '매매·호가',
portfolio: '계좌·포트폴리오',
strategy: '전략·분석',
analysis: '매매 시그널',
alert: '알림',
};
@@ -227,6 +234,46 @@ export const WIDGET_REGISTRY: WidgetDefinition[] = [
group: 'strategy',
Host: BacktestListWidgetHost,
},
{
type: 'analysis.signalSummary',
label: '매매 시그널 요약',
description: '선택 종목·전략 실시간 신호 카드',
category: 'side',
group: 'analysis',
Host: SignalSummaryWidgetHost,
},
{
type: 'analysis.signalMatch',
label: '조건 일치율',
description: '매수·매도 조건별 지표 충족률',
category: 'side',
group: 'analysis',
Host: SignalMatchWidgetHost,
},
{
type: 'analysis.strategyCondition',
label: '전략 조건',
description: '매수·매도 DSL 조건 트리',
category: 'side',
group: 'analysis',
Host: StrategyConditionWidgetHost,
},
{
type: 'analysis.backtestSignals',
label: '백테스트 시그널',
description: '최근 캔들 구간 매매 시그널 목록',
category: 'side',
group: 'analysis',
Host: BacktestSignalsWidgetHost,
},
{
type: 'analysis.indicatorDetail',
label: '지표별 상태',
description: '조건별 현재값·충족 여부 상세',
category: 'side',
group: 'analysis',
Host: IndicatorStatusWidgetHost,
},
];
const byType = new Map(WIDGET_REGISTRY.map(w => [w.type, w]));
@@ -244,7 +291,7 @@ export function widgetsByGroup(category: WidgetCategory): { group: WidgetGroupId
const items = widgetsForCategory(category);
const order: WidgetGroupId[] = category === 'center'
? ['chart']
: ['market', 'trade', 'portfolio', 'strategy', 'alert'];
: ['market', 'trade', 'portfolio', 'strategy', 'analysis', 'alert'];
return order
.map(group => ({
group,