가상투자 메뉴 기능 구현
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import type { ConditionMetric } from '../../utils/virtualSignalMetrics';
|
||||
import { formatStrategyThreshold } from '../../utils/virtualSignalMetrics';
|
||||
import { formatIndicatorValue } from '../../utils/virtualStrategyConditions';
|
||||
|
||||
interface Props {
|
||||
metrics: ConditionMetric[];
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: ConditionMetric['status'] }) {
|
||||
switch (status) {
|
||||
case 'match':
|
||||
return (
|
||||
<span className="vtd-sig-status vtd-sig-status--match">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
Match
|
||||
</span>
|
||||
);
|
||||
case 'pending':
|
||||
return (
|
||||
<span className="vtd-sig-status vtd-sig-status--pending">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M12 9v4"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
|
||||
</svg>
|
||||
Pending
|
||||
</span>
|
||||
);
|
||||
case 'nomatch':
|
||||
return (
|
||||
<span className="vtd-sig-status vtd-sig-status--nomatch">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
No Match
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return <span className="vtd-sig-status vtd-sig-status--unknown">—</span>;
|
||||
}
|
||||
}
|
||||
|
||||
const VirtualIndicatorCompareTable: React.FC<Props> = ({ metrics }) => (
|
||||
<div className="vtd-sig-table-wrap">
|
||||
<div className="vtd-sig-table-head">REAL-TIME INDICATOR COMPARISON</div>
|
||||
<table className="vtd-sig-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>TECHNICAL INDICATOR</th>
|
||||
<th>CURRENT VALUE</th>
|
||||
<th>STRATEGY THRESHOLD</th>
|
||||
<th>PROGRESS</th>
|
||||
<th>STATUS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{metrics.map(({ row, status, progress }) => (
|
||||
<tr key={row.id}>
|
||||
<td className="vtd-sig-table-ind">{row.displayName}</td>
|
||||
<td className="vtd-sig-table-val">{formatIndicatorValue(row.currentValue)}</td>
|
||||
<td className="vtd-sig-table-threshold">
|
||||
{row.thresholdLabel
|
||||
?? formatStrategyThreshold(row.conditionType, row.targetValue, row.conditionLabel)}
|
||||
</td>
|
||||
<td className="vtd-sig-table-progress">
|
||||
<div className="vtd-sig-row-bar">
|
||||
<div
|
||||
className={`vtd-sig-row-bar-fill vtd-sig-row-bar-fill--${status}`}
|
||||
style={{ width: `${progress ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="vtd-sig-row-pct">{progress != null ? `${Math.round(progress)}%` : '—'}</span>
|
||||
</td>
|
||||
<td><StatusBadge status={status} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default VirtualIndicatorCompareTable;
|
||||
@@ -0,0 +1,165 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { MarketSearchPanel } from '../MarketSearchPanel';
|
||||
import { getKoreanName } from '../../utils/marketNameCache';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import type { VirtualTargetItem } from '../../utils/virtualTradingStorage';
|
||||
|
||||
interface Props {
|
||||
targets: VirtualTargetItem[];
|
||||
globalStrategyId: number | null;
|
||||
strategies: StrategyDto[];
|
||||
selectedMarket: string;
|
||||
onTargetsChange: (items: VirtualTargetItem[]) => void;
|
||||
onSelectMarket: (market: string) => void;
|
||||
}
|
||||
|
||||
const VirtualLeftTargetPanel: React.FC<Props> = ({
|
||||
targets,
|
||||
globalStrategyId,
|
||||
strategies,
|
||||
selectedMarket,
|
||||
onTargetsChange,
|
||||
onSelectMarket,
|
||||
}) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const searchRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const addedSet = useMemo(() => new Set(targets.map(t => t.market)), [targets]);
|
||||
|
||||
const closeDropdown = useCallback(() => {
|
||||
setShowDropdown(false);
|
||||
}, []);
|
||||
|
||||
const openDropdown = useCallback(() => {
|
||||
setShowDropdown(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showDropdown) return;
|
||||
const onDocMouseDown = (e: MouseEvent) => {
|
||||
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onDocMouseDown);
|
||||
return () => document.removeEventListener('mousedown', onDocMouseDown);
|
||||
}, [showDropdown]);
|
||||
|
||||
const handleAdd = useCallback((market: string, meta: { koreanName: string; englishName: string }) => {
|
||||
if (addedSet.has(market)) return;
|
||||
onTargetsChange([
|
||||
...targets,
|
||||
{
|
||||
market,
|
||||
strategyId: globalStrategyId,
|
||||
koreanName: meta.koreanName,
|
||||
englishName: meta.englishName,
|
||||
},
|
||||
]);
|
||||
onSelectMarket(market);
|
||||
setQuery('');
|
||||
setShowDropdown(false);
|
||||
}, [addedSet, targets, globalStrategyId, onTargetsChange, onSelectMarket]);
|
||||
|
||||
const handleRemove = useCallback((market: string) => {
|
||||
onTargetsChange(targets.filter(t => t.market !== market));
|
||||
}, [targets, onTargetsChange]);
|
||||
|
||||
const handleStrategyChange = useCallback((market: string, strategyId: number | null) => {
|
||||
onTargetsChange(targets.map(t =>
|
||||
t.market === market ? { ...t, strategyId } : t,
|
||||
));
|
||||
}, [targets, onTargetsChange]);
|
||||
|
||||
return (
|
||||
<div className={`vtd-left${showDropdown ? ' vtd-left--dropdown-open' : ''}`}>
|
||||
<section className="vtd-search-section" ref={searchRef}>
|
||||
<div className="vtd-search-label">검색 (실시간 차트와 동일 목록)</div>
|
||||
<input
|
||||
className={`vtd-search-input${showDropdown ? ' vtd-search-input--open' : ''}`}
|
||||
placeholder="종목명 또는 심볼 검색"
|
||||
value={query}
|
||||
onChange={e => {
|
||||
setQuery(e.target.value);
|
||||
setShowDropdown(true);
|
||||
}}
|
||||
onFocus={openDropdown}
|
||||
onClick={openDropdown}
|
||||
/>
|
||||
{showDropdown && (
|
||||
<div className="vtd-search-dropdown">
|
||||
<MarketSearchPanel
|
||||
variant="embedded"
|
||||
currentMarket={selectedMarket}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
actionMode="add"
|
||||
addedMarkets={addedSet}
|
||||
onAdd={handleAdd}
|
||||
onSelect={onSelectMarket}
|
||||
onClose={closeDropdown}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="vtd-target-section">
|
||||
<div className="vtd-target-list-head">
|
||||
<span>투자대상</span>
|
||||
<span className="vtd-target-count">{targets.length}종목</span>
|
||||
</div>
|
||||
|
||||
<div className="vtd-target-list">
|
||||
{targets.length === 0 && (
|
||||
<p className="vtd-muted">검색 후 + 버튼으로 종목을 추가하세요.</p>
|
||||
)}
|
||||
{targets.map(item => {
|
||||
const ko = item.koreanName ?? getKoreanName(item.market);
|
||||
const en = item.englishName ?? item.market.replace(/^KRW-/, '');
|
||||
const active = item.market === selectedMarket;
|
||||
return (
|
||||
<div
|
||||
key={item.market}
|
||||
className={`vtd-target-item${active ? ' vtd-target-item--active' : ''}`}
|
||||
onClick={() => onSelectMarket(item.market)}
|
||||
>
|
||||
<div className="vtd-target-item-main">
|
||||
<div className="vtd-target-names">
|
||||
<span className="vtd-target-ko">{ko}</span>
|
||||
<span className="vtd-target-en">{en}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="vtd-target-remove"
|
||||
title="목록에서 제거"
|
||||
onClick={e => { e.stopPropagation(); handleRemove(item.market); }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<label className="vtd-target-strat" onClick={e => e.stopPropagation()}>
|
||||
<select
|
||||
className="vtd-left-select"
|
||||
value={item.strategyId ?? globalStrategyId ?? ''}
|
||||
onChange={e => {
|
||||
const v = e.target.value;
|
||||
handleStrategyChange(item.market, v ? Number(v) : null);
|
||||
}}
|
||||
>
|
||||
<option value="">— 전략 —</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VirtualLeftTargetPanel;
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
||||
|
||||
interface Props {
|
||||
status: VirtualLiveStatus;
|
||||
}
|
||||
|
||||
const LABEL: Record<VirtualLiveStatus, string | null> = {
|
||||
idle: null,
|
||||
connecting: '연결 중',
|
||||
live: '실시간',
|
||||
disconnected: '연결 끊김',
|
||||
};
|
||||
|
||||
const VirtualLiveBadge: React.FC<Props> = ({ status }) => {
|
||||
const label = LABEL[status];
|
||||
if (!label) return null;
|
||||
|
||||
return (
|
||||
<span className={`vtd-live-badge vtd-live-badge--${status}`}>
|
||||
<span className="vtd-live-badge-dot" aria-hidden />
|
||||
<span className="vtd-live-badge-text">{label}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default VirtualLiveBadge;
|
||||
@@ -0,0 +1,64 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
const SEGMENTS = 25;
|
||||
|
||||
interface Props {
|
||||
matchRate: number;
|
||||
}
|
||||
|
||||
const VirtualSignalEqualizer: React.FC<Props> = ({ matchRate }) => {
|
||||
const litCount = useMemo(
|
||||
() => Math.round((Math.min(100, Math.max(0, matchRate)) / 100) * SEGMENTS),
|
||||
[matchRate],
|
||||
);
|
||||
|
||||
const segments = useMemo(() => {
|
||||
const items: Array<{ lit: boolean; tone: 'blue' | 'gold' | 'peak' }> = [];
|
||||
for (let i = 0; i < SEGMENTS; i++) {
|
||||
const lit = i < litCount;
|
||||
if (!lit) {
|
||||
items.push({ lit: false, tone: 'blue' });
|
||||
continue;
|
||||
}
|
||||
if (matchRate >= 100 && i === SEGMENTS - 1) {
|
||||
items.push({ lit: true, tone: 'peak' });
|
||||
} else if (i >= Math.floor(SEGMENTS * 0.55)) {
|
||||
items.push({ lit: true, tone: 'gold' });
|
||||
} else {
|
||||
items.push({ lit: true, tone: 'blue' });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [litCount, matchRate]);
|
||||
|
||||
const ticks = [100, 90, 80, 70, 60, 50, 40, 30, 20, 10, 0];
|
||||
|
||||
return (
|
||||
<div className="vtd-sig-eq">
|
||||
<div className="vtd-sig-eq-scale">
|
||||
{ticks.map(t => (
|
||||
<span key={t} className="vtd-sig-eq-tick">{t}%</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="vtd-sig-eq-bar-wrap">
|
||||
<div className="vtd-sig-eq-bar">
|
||||
{segments.map((seg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={[
|
||||
'vtd-sig-eq-seg',
|
||||
seg.lit ? 'vtd-sig-eq-seg--lit' : '',
|
||||
seg.lit ? `vtd-sig-eq-seg--${seg.tone}` : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{matchRate >= 100 && (
|
||||
<div className="vtd-sig-eq-badge">100% MATCH</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VirtualSignalEqualizer;
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import type { TrafficLightState } from '../../utils/virtualSignalMetrics';
|
||||
|
||||
interface Props {
|
||||
state: TrafficLightState;
|
||||
matchRate: number;
|
||||
}
|
||||
|
||||
const LABELS: Record<TrafficLightState, string> = {
|
||||
red: '조건 미충족',
|
||||
yellow: '부분 일치 / 대기',
|
||||
blue: '시그널 일치 (100%)',
|
||||
};
|
||||
|
||||
const VirtualSignalTrafficLight: React.FC<Props> = ({ state, matchRate }) => (
|
||||
<div className="vtd-sig-light">
|
||||
<div className="vtd-sig-light-housing">
|
||||
<div className={`vtd-sig-light-bulb vtd-sig-light-bulb--red${state === 'red' ? ' vtd-sig-light-bulb--on' : ''}`} />
|
||||
<div className={`vtd-sig-light-bulb vtd-sig-light-bulb--yellow${state === 'yellow' ? ' vtd-sig-light-bulb--on' : ''}`} />
|
||||
<div className={`vtd-sig-light-bulb vtd-sig-light-bulb--blue${state === 'blue' ? ' vtd-sig-light-bulb--on' : ''}`} />
|
||||
</div>
|
||||
<div className="vtd-sig-light-info">
|
||||
<span className="vtd-sig-light-rate">{matchRate}%</span>
|
||||
<span className="vtd-sig-light-label">{LABELS[state]}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default VirtualSignalTrafficLight;
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 가상투자 — 종목×전략 차트 팝업 (캔들 + 전략 보조지표)
|
||||
*/
|
||||
import React, {
|
||||
useCallback, useEffect, useMemo, useRef, useState,
|
||||
} from 'react';
|
||||
import AppPopup from '../AppPopup';
|
||||
import TradingChart from '../TradingChart';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import { pinCandleWatch } from '../../utils/backendApi';
|
||||
import type {
|
||||
ChartMode, ChartType, Drawing, LegendData, OHLCVBar, Theme, Timeframe,
|
||||
} from '../../types';
|
||||
import { getKoreanName } from '../../utils/marketNameCache';
|
||||
import { isUpbitMarket } from '../../utils/upbitApi';
|
||||
import { useChartRealtimeData, type WsStatus } from '../../hooks/useChartRealtimeData';
|
||||
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
|
||||
import type { ChartManager } from '../../utils/ChartManager';
|
||||
import {
|
||||
buildStrategyChartIndicators,
|
||||
resolveStrategyPrimaryTimeframe,
|
||||
resolveStrategyTimeframes,
|
||||
} from '../../utils/strategyToChartIndicators';
|
||||
import { timeframeToCandleType } from '../../utils/chartCandleType';
|
||||
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
strategy: StrategyDto | undefined;
|
||||
theme?: Theme;
|
||||
running?: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function WsBadge({ status }: { status: WsStatus }) {
|
||||
const map: Record<WsStatus, { cls: string; label: string }> = {
|
||||
connecting: { cls: 'vtd-chart-ws--connecting', label: '연결 중' },
|
||||
connected: { cls: 'vtd-chart-ws--live', label: '실시간' },
|
||||
disconnected: { cls: 'vtd-chart-ws--off', label: '연결 끊김' },
|
||||
error: { cls: 'vtd-chart-ws--off', label: '오류' },
|
||||
};
|
||||
const { cls, label } = map[status];
|
||||
return (
|
||||
<span className={`vtd-chart-ws ${cls}`}>
|
||||
<span className="vtd-chart-ws-dot" aria-hidden />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const VirtualStrategyChartPopup: React.FC<Props> = ({
|
||||
market,
|
||||
strategy,
|
||||
theme = 'dark',
|
||||
running = false,
|
||||
onClose,
|
||||
}) => {
|
||||
const { getParams, getVisualConfig } = useIndicatorSettings();
|
||||
const tfOptions = useMemo(() => resolveStrategyTimeframes(strategy), [strategy]);
|
||||
const [timeframe, setTimeframe] = useState<Timeframe>(() => resolveStrategyPrimaryTimeframe(strategy));
|
||||
|
||||
useEffect(() => {
|
||||
setTimeframe(resolveStrategyPrimaryTimeframe(strategy));
|
||||
}, [strategy, market]);
|
||||
|
||||
const indicators = useMemo(
|
||||
() => buildStrategyChartIndicators(strategy, getParams, getVisualConfig),
|
||||
[strategy, getParams, getVisualConfig],
|
||||
);
|
||||
|
||||
const managerRef = useRef<ChartManager | null>(null);
|
||||
const pendingBarRef = useRef<OHLCVBar | null>(null);
|
||||
const barsMarketRef = useRef<string | null>(null);
|
||||
const chartLiveReadyRef = useRef(false);
|
||||
const marketRef = useRef(market);
|
||||
marketRef.current = market;
|
||||
|
||||
const handleCandlesReady = useCallback(() => {
|
||||
chartLiveReadyRef.current = true;
|
||||
const pending = pendingBarRef.current;
|
||||
const mgr = managerRef.current;
|
||||
if (!pending || !mgr || barsMarketRef.current !== marketRef.current) return;
|
||||
pendingBarRef.current = null;
|
||||
mgr.updateBar(pending);
|
||||
}, []);
|
||||
|
||||
const applyRealtimeBar = useCallback((bar: OHLCVBar, append: boolean) => {
|
||||
if (barsMarketRef.current !== marketRef.current) return;
|
||||
if (!chartLiveReadyRef.current || !managerRef.current) {
|
||||
pendingBarRef.current = bar;
|
||||
return;
|
||||
}
|
||||
if (append) managerRef.current.appendBar(bar);
|
||||
else managerRef.current.updateBar(bar);
|
||||
}, []);
|
||||
|
||||
const handleTickUpdate = useCallback((bar: OHLCVBar) => {
|
||||
applyRealtimeBar(bar, false);
|
||||
}, [applyRealtimeBar]);
|
||||
|
||||
const handleNewCandle = useCallback((bar: OHLCVBar) => {
|
||||
applyRealtimeBar(bar, true);
|
||||
}, [applyRealtimeBar]);
|
||||
|
||||
const useUpbit = isUpbitMarket(market);
|
||||
|
||||
const { bars, barsMarket, wsStatus, isLoading } = useChartRealtimeData(
|
||||
market,
|
||||
timeframe,
|
||||
useMemo(() => ({
|
||||
onTickUpdate: handleTickUpdate,
|
||||
onNewCandle: handleNewCandle,
|
||||
enabled: useUpbit,
|
||||
}), [handleTickUpdate, handleNewCandle, useUpbit]),
|
||||
);
|
||||
|
||||
barsMarketRef.current = barsMarket;
|
||||
|
||||
useEffect(() => {
|
||||
chartLiveReadyRef.current = false;
|
||||
pendingBarRef.current = null;
|
||||
}, [market, timeframe]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!useUpbit) return;
|
||||
void pinCandleWatch(market, timeframeToCandleType(timeframe));
|
||||
if (timeframeToCandleType(timeframe) !== '1m') {
|
||||
void pinCandleWatch(market, '1m');
|
||||
}
|
||||
}, [market, timeframe, useUpbit, running]);
|
||||
|
||||
const ko = getKoreanName(market);
|
||||
const sym = market.replace(/^KRW-/, '');
|
||||
const stratName = strategy?.name ?? '전략 미지정';
|
||||
|
||||
return (
|
||||
<AppPopup
|
||||
onClose={onClose}
|
||||
title={sym}
|
||||
titleKo={ko}
|
||||
badge="CHART"
|
||||
width={920}
|
||||
maxWidth="96vw"
|
||||
centered
|
||||
backdrop
|
||||
closeOnBackdrop
|
||||
bodyClassName="app-popup-body vtd-chart-popup-body"
|
||||
headerExtra={running ? <WsBadge status={wsStatus} /> : undefined}
|
||||
>
|
||||
<div className="vtd-chart-popup-meta">
|
||||
<span className="vtd-chart-popup-strat">{stratName}</span>
|
||||
{indicators.length > 0 && (
|
||||
<span className="vtd-chart-popup-ind-count">보조지표 {indicators.length}개</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="vtd-chart-popup-tf-row">
|
||||
{tfOptions.map(tf => (
|
||||
<button
|
||||
key={tf}
|
||||
type="button"
|
||||
className={`vtd-chart-popup-tf${timeframe === tf ? ' vtd-chart-popup-tf--on' : ''}`}
|
||||
onClick={() => setTimeframe(tf)}
|
||||
>
|
||||
{tf}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="vtd-chart-popup-canvas-wrap">
|
||||
{isLoading && <div className="vtd-chart-popup-loading">차트 로딩…</div>}
|
||||
<TradingChart
|
||||
bars={bars}
|
||||
barsMarket={barsMarket}
|
||||
market={market}
|
||||
timeframe={timeframe}
|
||||
chartType={'candlestick' as ChartType}
|
||||
theme={theme}
|
||||
mode={'chart' as ChartMode}
|
||||
indicators={indicators}
|
||||
drawingTool="cursor"
|
||||
drawings={[] as Drawing[]}
|
||||
logScale={false}
|
||||
drawingsLocked
|
||||
drawingsVisible={false}
|
||||
displayTimezone={DEFAULT_DISPLAY_TIMEZONE}
|
||||
onCrosshair={noop as (d: LegendData | null) => void}
|
||||
onManagerReady={mgr => { managerRef.current = mgr; }}
|
||||
onAddDrawing={noop as (d: Drawing) => void}
|
||||
onCandlesReady={handleCandlesReady}
|
||||
magnifierEnabled={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{indicators.length === 0 && (
|
||||
<p className="vtd-muted vtd-chart-popup-empty">
|
||||
전략에 차트로 표시할 보조지표가 없습니다.
|
||||
</p>
|
||||
)}
|
||||
</AppPopup>
|
||||
);
|
||||
};
|
||||
|
||||
export default VirtualStrategyChartPopup;
|
||||
@@ -0,0 +1,101 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { getKoreanName } from '../../utils/marketNameCache';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||
import {
|
||||
buildConditionMetrics,
|
||||
computeMatchRate,
|
||||
formatUpdatedTime,
|
||||
getTrafficLightState,
|
||||
} from '../../utils/virtualSignalMetrics';
|
||||
import VirtualLiveBadge from './VirtualLiveBadge';
|
||||
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
||||
import VirtualSignalEqualizer from './VirtualSignalEqualizer';
|
||||
import VirtualSignalTrafficLight from './VirtualSignalTrafficLight';
|
||||
import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage';
|
||||
import VirtualIndicatorCompareTable from './VirtualIndicatorCompareTable';
|
||||
|
||||
interface Props {
|
||||
market: string;
|
||||
strategy: StrategyDto | undefined;
|
||||
snapshot: VirtualIndicatorSnapshot | undefined;
|
||||
running: boolean;
|
||||
liveStatus?: VirtualLiveStatus;
|
||||
viewMode?: VirtualCardViewMode;
|
||||
onOpenChart?: () => void;
|
||||
}
|
||||
|
||||
const VirtualTargetCard: React.FC<Props> = ({
|
||||
market, strategy, snapshot, running, liveStatus = 'idle', viewMode = 'summary', onOpenChart,
|
||||
}) => {
|
||||
const ko = getKoreanName(market);
|
||||
const sym = market.replace(/^KRW-/, '');
|
||||
const tf = snapshot?.timeframe ?? '—';
|
||||
const rows = snapshot?.rows ?? [];
|
||||
|
||||
const metrics = useMemo(() => buildConditionMetrics(rows), [rows]);
|
||||
const matchRate = useMemo(
|
||||
() => computeMatchRate(metrics, snapshot?.matchRate),
|
||||
[metrics, snapshot?.matchRate],
|
||||
);
|
||||
const trafficState = useMemo(() => getTrafficLightState(matchRate, metrics), [matchRate, metrics]);
|
||||
const metCount = metrics.filter(m => m.status === 'match').length;
|
||||
const evalCount = metrics.filter(m => m.status !== 'unknown').length;
|
||||
|
||||
const isDetail = viewMode === 'detail';
|
||||
|
||||
return (
|
||||
<div className={`vtd-card vtd-card--signal${running ? ' vtd-card--live' : ''}${isDetail ? ' vtd-card--detail' : ' vtd-card--summary'}`}>
|
||||
<div className="vtd-card-head">
|
||||
<div className="vtd-card-title">
|
||||
<span className="vtd-card-ko">{ko}</span>
|
||||
<span className="vtd-card-sym">{sym}</span>
|
||||
</div>
|
||||
<div className="vtd-card-head-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="vtd-card-chart-btn"
|
||||
title="차트 보기"
|
||||
aria-label="차트 보기"
|
||||
onClick={onOpenChart}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
||||
<rect x="3" y="4" width="18" height="14" rx="2"/>
|
||||
<path d="M7 14l3-3 3 2 4-5"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span className="vtd-card-strat">{strategy?.name ?? '전략 미지정'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vtd-card-meta">
|
||||
<span className="vtd-card-tf">시간봉 {tf}</span>
|
||||
{isDetail && evalCount > 0 && (
|
||||
<span className="vtd-card-met-count">{metCount}/{evalCount} 조건 충족</span>
|
||||
)}
|
||||
{running && <VirtualLiveBadge status={liveStatus} />}
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="vtd-muted vtd-card-empty">전략 조건·지표 데이터 없음</p>
|
||||
) : (
|
||||
<>
|
||||
<div className={`vtd-sig-panel${isDetail ? '' : ' vtd-sig-panel--summary'}`}>
|
||||
<div className="vtd-sig-panel-title">SIGNAL INTELLIGENCE & MATCH RATES</div>
|
||||
<div className="vtd-sig-visual">
|
||||
<VirtualSignalEqualizer matchRate={matchRate} />
|
||||
<VirtualSignalTrafficLight state={trafficState} matchRate={matchRate} />
|
||||
</div>
|
||||
{isDetail && <VirtualIndicatorCompareTable metrics={metrics} />}
|
||||
</div>
|
||||
<div className="vtd-card-foot">
|
||||
<span className="vtd-card-updated">갱신 {formatUpdatedTime(snapshot?.updatedAt)}</span>
|
||||
<span className="vtd-card-match-summary">매매조건 일치율 {matchRate}%</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VirtualTargetCard;
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import VirtualTargetCard from './VirtualTargetCard';
|
||||
import VirtualStrategyChartPopup from './VirtualStrategyChartPopup';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import type { Theme } from '../../types';
|
||||
import type { VirtualTargetItem, VirtualCardViewMode } from '../../utils/virtualTradingStorage';
|
||||
import { loadVirtualCardViewMode, saveVirtualCardViewMode } from '../../utils/virtualTradingStorage';
|
||||
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||
|
||||
interface Props {
|
||||
targets: VirtualTargetItem[];
|
||||
strategies: StrategyDto[];
|
||||
snapshots: Record<string, VirtualIndicatorSnapshot>;
|
||||
running: boolean;
|
||||
globalStrategyId: number | null;
|
||||
liveStatusByMarket?: Record<string, VirtualLiveStatus>;
|
||||
theme?: Theme;
|
||||
}
|
||||
|
||||
const VirtualTargetGrid: React.FC<Props> = ({
|
||||
targets, strategies, snapshots, running, globalStrategyId, liveStatusByMarket = {}, theme = 'dark',
|
||||
}) => {
|
||||
const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => loadVirtualCardViewMode());
|
||||
const [chartTarget, setChartTarget] = useState<{ market: string; strategy: StrategyDto | undefined } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
saveVirtualCardViewMode(viewMode);
|
||||
}, [viewMode]);
|
||||
|
||||
if (targets.length === 0) {
|
||||
return (
|
||||
<div className="vtd-grid-empty">
|
||||
<p className="vtd-muted">좌측에서 투자대상 종목을 추가하면 카드가 표시됩니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="vtd-grid-wrap">
|
||||
<div className="vtd-grid-head">
|
||||
<span className="vtd-grid-head-title">종목별 매매시그널 일치 현황</span>
|
||||
<div className="vtd-grid-head-right">
|
||||
<div className="vtd-view-toggle" role="group" aria-label="카드 표시 방식">
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${viewMode === 'summary' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => setViewMode('summary')}
|
||||
>
|
||||
요약표시
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-view-toggle-btn${viewMode === 'detail' ? ' vtd-view-toggle-btn--on' : ''}`}
|
||||
onClick={() => setViewMode('detail')}
|
||||
>
|
||||
상세표시
|
||||
</button>
|
||||
</div>
|
||||
{viewMode === 'detail' && (
|
||||
<div className="vtd-grid-head-legend">
|
||||
<span className="vtd-legend vtd-legend--match">● 충족</span>
|
||||
<span className="vtd-legend vtd-legend--pending">● 대기</span>
|
||||
<span className="vtd-legend vtd-legend--nomatch">● 미충족</span>
|
||||
</div>
|
||||
)}
|
||||
{running && <span className="vtd-grid-live">실시간 갱신 3초</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="vtd-grid">
|
||||
{targets.map(t => {
|
||||
const strat = strategies.find(s => s.id === (t.strategyId ?? globalStrategyId));
|
||||
return (
|
||||
<VirtualTargetCard
|
||||
key={t.market}
|
||||
market={t.market}
|
||||
strategy={strat}
|
||||
snapshot={snapshots[t.market]}
|
||||
running={running}
|
||||
liveStatus={liveStatusByMarket[t.market] ?? (running ? 'connecting' : 'idle')}
|
||||
viewMode={viewMode}
|
||||
onOpenChart={() => setChartTarget({ market: t.market, strategy: strat })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{chartTarget && (
|
||||
<VirtualStrategyChartPopup
|
||||
market={chartTarget.market}
|
||||
strategy={chartTarget.strategy}
|
||||
theme={theme}
|
||||
running={running}
|
||||
onClose={() => setChartTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default VirtualTargetGrid;
|
||||
Reference in New Issue
Block a user