가상투자 화면 목록 선택 동작
This commit is contained in:
@@ -535,6 +535,7 @@ public class BacktestingService {
|
|||||||
case "1m" -> Duration.ofMinutes(1);
|
case "1m" -> Duration.ofMinutes(1);
|
||||||
case "3m" -> Duration.ofMinutes(3);
|
case "3m" -> Duration.ofMinutes(3);
|
||||||
case "5m" -> Duration.ofMinutes(5);
|
case "5m" -> Duration.ofMinutes(5);
|
||||||
|
case "10m" -> Duration.ofMinutes(10);
|
||||||
case "15m" -> Duration.ofMinutes(15);
|
case "15m" -> Duration.ofMinutes(15);
|
||||||
case "30m" -> Duration.ofMinutes(30);
|
case "30m" -> Duration.ofMinutes(30);
|
||||||
case "1h" -> Duration.ofHours(1);
|
case "1h" -> Duration.ofHours(1);
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ public class IndicatorService {
|
|||||||
case "1m" -> Duration.ofMinutes(1);
|
case "1m" -> Duration.ofMinutes(1);
|
||||||
case "3m" -> Duration.ofMinutes(3);
|
case "3m" -> Duration.ofMinutes(3);
|
||||||
case "5m" -> Duration.ofMinutes(5);
|
case "5m" -> Duration.ofMinutes(5);
|
||||||
|
case "10m" -> Duration.ofMinutes(10);
|
||||||
case "15m" -> Duration.ofMinutes(15);
|
case "15m" -> Duration.ofMinutes(15);
|
||||||
case "30m" -> Duration.ofMinutes(30);
|
case "30m" -> Duration.ofMinutes(30);
|
||||||
case "1h" -> Duration.ofHours(1);
|
case "1h" -> Duration.ofHours(1);
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ public class LiveStrategyTimeframeService {
|
|||||||
|
|
||||||
private static String mapChartTfToCandleType(String chartTf) {
|
private static String mapChartTfToCandleType(String chartTf) {
|
||||||
return switch (chartTf) {
|
return switch (chartTf) {
|
||||||
case "1m", "3m", "5m", "15m", "30m" -> chartTf;
|
case "1m", "3m", "5m", "10m", "15m", "30m" -> chartTf;
|
||||||
case "1h", "4h" -> chartTf;
|
case "1h", "4h" -> chartTf;
|
||||||
case "1D" -> "1d";
|
case "1D" -> "1d";
|
||||||
case "1W", "1M" -> "1d";
|
case "1W", "1M" -> "1d";
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
*
|
*
|
||||||
* 구조: ConcurrentHashMap<market, Map<candleType, BarSeries>>
|
* 구조: ConcurrentHashMap<market, Map<candleType, BarSeries>>
|
||||||
* - market : "KRW-BTC", "KRW-ETH" 등 업비트 종목 코드
|
* - market : "KRW-BTC", "KRW-ETH" 등 업비트 종목 코드
|
||||||
* - candleType: "1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"
|
* - candleType: "1m", "3m", "5m", "10m", "15m", "30m", "1h", "4h", "1d"
|
||||||
*
|
*
|
||||||
* 슬라이딩 윈도우: 모든 BarSeries 에 setMaximumBarCount(300) 적용.
|
* 슬라이딩 윈도우: 모든 BarSeries 에 setMaximumBarCount(300) 적용.
|
||||||
* 301번째 캔들이 들어오면 가장 오래된 캔들이 자동 소멸.
|
* 301번째 캔들이 들어오면 가장 오래된 캔들이 자동 소멸.
|
||||||
@@ -51,6 +51,7 @@ public class Ta4jStorage {
|
|||||||
"1m", "minutes/1",
|
"1m", "minutes/1",
|
||||||
"3m", "minutes/3",
|
"3m", "minutes/3",
|
||||||
"5m", "minutes/5",
|
"5m", "minutes/5",
|
||||||
|
"10m", "minutes/10",
|
||||||
"15m", "minutes/15",
|
"15m", "minutes/15",
|
||||||
"30m", "minutes/30",
|
"30m", "minutes/30",
|
||||||
"1h", "minutes/60",
|
"1h", "minutes/60",
|
||||||
@@ -231,6 +232,7 @@ public class Ta4jStorage {
|
|||||||
case "1m" -> Duration.ofMinutes(1);
|
case "1m" -> Duration.ofMinutes(1);
|
||||||
case "3m" -> Duration.ofMinutes(3);
|
case "3m" -> Duration.ofMinutes(3);
|
||||||
case "5m" -> Duration.ofMinutes(5);
|
case "5m" -> Duration.ofMinutes(5);
|
||||||
|
case "10m" -> Duration.ofMinutes(10);
|
||||||
case "15m" -> Duration.ofMinutes(15);
|
case "15m" -> Duration.ofMinutes(15);
|
||||||
case "30m" -> Duration.ofMinutes(30);
|
case "30m" -> Duration.ofMinutes(30);
|
||||||
case "1h" -> Duration.ofHours(1);
|
case "1h" -> Duration.ofHours(1);
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@ public class CandleGapBackfillService {
|
|||||||
@Value("${upbit.api.request-delay-ms:110}")
|
@Value("${upbit.api.request-delay-ms:110}")
|
||||||
private long requestDelayMs;
|
private long requestDelayMs;
|
||||||
|
|
||||||
private static final String[] BACKFILL_TYPES = {"1m", "3m", "5m", "15m", "30m", "1h", "4h", "1d"};
|
private static final String[] BACKFILL_TYPES = {"1m", "3m", "5m", "10m", "15m", "30m", "1h", "4h", "1d"};
|
||||||
|
|
||||||
private volatile long lastRunAtMs;
|
private volatile long lastRunAtMs;
|
||||||
private final AtomicInteger lastRunMarkets = new AtomicInteger();
|
private final AtomicInteger lastRunMarkets = new AtomicInteger();
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
* 명세서 3.2 준수:
|
* 명세서 3.2 준수:
|
||||||
* - 동일 분(minute) 내 틱 → series.addPrice(price, volume) 로 현재 Bar 갱신
|
* - 동일 분(minute) 내 틱 → series.addPrice(price, volume) 로 현재 Bar 갱신
|
||||||
* - 분이 바뀌는 틱 → 이전 Bar 를 확정하고 series.addBar(newBar) 로 새 캔들 열기
|
* - 분이 바뀌는 틱 → 이전 Bar 를 확정하고 series.addBar(newBar) 로 새 캔들 열기
|
||||||
* - 1분봉 확정 시 상위 타임프레임(3m, 5m, 15m, 30m, 1h, 4h, 1d)으로 집계 전파
|
* - 1분봉 확정 시 상위 타임프레임(3m, 5m, 10m, 15m, 30m, 1h, 4h, 1d)으로 집계 전파
|
||||||
*
|
*
|
||||||
* 지원 캔들 타입: 1m, 3m, 5m, 15m, 30m, 1h, 4h, 1d
|
* 지원 캔들 타입: 1m, 3m, 5m, 10m, 15m, 30m, 1h, 4h, 1d
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -49,9 +49,9 @@ public class BarBuilder {
|
|||||||
private final GcLiveStrategySettingsRepository liveSettingsRepo;
|
private final GcLiveStrategySettingsRepository liveSettingsRepo;
|
||||||
private final StrategyConditionTimeframeService conditionTimeframes;
|
private final StrategyConditionTimeframeService conditionTimeframes;
|
||||||
|
|
||||||
/** 상위 집계 타임프레임 (분 단위 집계 주기: 3, 5, 15, 30, 60, 240, 1440) */
|
/** 상위 집계 타임프레임 (분 단위 집계 주기: 3, 5, 10, 15, 30, 60, 240, 1440) */
|
||||||
private static final int[] UPPER_MINUTES = {3, 5, 15, 30, 60, 240, 1440};
|
private static final int[] UPPER_MINUTES = {3, 5, 10, 15, 30, 60, 240, 1440};
|
||||||
private static final String[] UPPER_TYPES = {"3m", "5m", "15m", "30m", "1h", "4h", "1d"};
|
private static final String[] UPPER_TYPES = {"3m", "5m", "10m", "15m", "30m", "1h", "4h", "1d"};
|
||||||
|
|
||||||
/** 진행 중인 1분봉 상태 (market → PartialBar) */
|
/** 진행 중인 1분봉 상태 (market → PartialBar) */
|
||||||
private final ConcurrentHashMap<String, PartialBar> partialBars = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, PartialBar> partialBars = new ConcurrentHashMap<>();
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const PERIODS: { label: string; tf: Timeframe }[] = [
|
|||||||
{ label: '4h', tf: '4h' },
|
{ label: '4h', tf: '4h' },
|
||||||
{ label: '1h', tf: '1h' },
|
{ label: '1h', tf: '1h' },
|
||||||
{ label: '30m',tf: '30m' },
|
{ label: '30m',tf: '30m' },
|
||||||
|
{ label: '15m',tf: '15m' },
|
||||||
|
{ label: '10m',tf: '10m' },
|
||||||
{ label: '5m', tf: '5m' },
|
{ label: '5m', tf: '5m' },
|
||||||
{ label: '3m', tf: '3m' },
|
{ label: '3m', tf: '3m' },
|
||||||
{ label: '1m', tf: '1m' },
|
{ label: '1m', tf: '1m' },
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import type {
|
|||||||
import type { ChartManager } from '../utils/ChartManager';
|
import type { ChartManager } from '../utils/ChartManager';
|
||||||
|
|
||||||
// ── 타임프레임 옵션 ────────────────────────────────────────────────────────
|
// ── 타임프레임 옵션 ────────────────────────────────────────────────────────
|
||||||
const TF_OPTIONS: Timeframe[] = ['1m','3m','5m','15m','30m','1h','4h','1D','1W','1M'];
|
const TF_OPTIONS: Timeframe[] = ['1m','3m','5m','10m','15m','30m','1h','4h','1D','1W','1M'];
|
||||||
|
|
||||||
// ── Ws 배지 ───────────────────────────────────────────────────────────────
|
// ── Ws 배지 ───────────────────────────────────────────────────────────────
|
||||||
const WsBadge: React.FC<{ status: WsStatus; isUpbit: boolean }> = ({ status, isUpbit }) => {
|
const WsBadge: React.FC<{ status: WsStatus; isUpbit: boolean }> = ({ status, isUpbit }) => {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const ALL_TIMEFRAMES: { tf: Timeframe; label: string }[] = [
|
|||||||
{ tf: '1m', label: '1분' },
|
{ tf: '1m', label: '1분' },
|
||||||
{ tf: '3m', label: '3분' },
|
{ tf: '3m', label: '3분' },
|
||||||
{ tf: '5m', label: '5분' },
|
{ tf: '5m', label: '5분' },
|
||||||
|
{ tf: '10m', label: '10분' },
|
||||||
{ tf: '15m', label: '15분' },
|
{ tf: '15m', label: '15분' },
|
||||||
{ tf: '30m', label: '30분' },
|
{ tf: '30m', label: '30분' },
|
||||||
{ tf: '1h', label: '1시간' },
|
{ tf: '1h', label: '1시간' },
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ const ChartIcon: Record<ChartType, React.ReactNode> = {
|
|||||||
baseline: <IcLine />,
|
baseline: <IcLine />,
|
||||||
};
|
};
|
||||||
|
|
||||||
const TIMEFRAMES: Timeframe[] = ['1m','3m','5m','15m','30m','1h','4h','1D','1W','1M'];
|
const TIMEFRAMES: Timeframe[] = ['1m','3m','5m','10m','15m','30m','1h','4h','1D','1W','1M'];
|
||||||
const CHART_TYPES: { value: ChartType; label: string }[] = [
|
const CHART_TYPES: { value: ChartType; label: string }[] = [
|
||||||
{ value: 'candlestick', label: '캔들스틱' },
|
{ value: 'candlestick', label: '캔들스틱' },
|
||||||
{ value: 'bar', label: '바' },
|
{ value: 'bar', label: '바' },
|
||||||
|
|||||||
@@ -110,12 +110,23 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
|
|
||||||
const tradePrice = tickers?.get(selectedMarket)?.tradePrice ?? null;
|
const tradePrice = tickers?.get(selectedMarket)?.tradePrice ?? null;
|
||||||
|
|
||||||
|
const handleSelectMarket = useCallback((market: string) => {
|
||||||
|
setSelectedMarket(market);
|
||||||
|
const price = tickers?.get(market)?.tradePrice;
|
||||||
|
if (price != null && price > 0) {
|
||||||
|
const seq = Date.now();
|
||||||
|
setFillBuy({ market, price, side: 'buy', seq });
|
||||||
|
setFillSell({ market, price, side: 'sell', seq: seq + 1 });
|
||||||
|
}
|
||||||
|
setRightTab('trade');
|
||||||
|
}, [tickers]);
|
||||||
|
|
||||||
const handleTargetsChange = useCallback((items: VirtualTargetItem[]) => {
|
const handleTargetsChange = useCallback((items: VirtualTargetItem[]) => {
|
||||||
setTargets(items);
|
setTargets(items);
|
||||||
if (items.length > 0 && !items.some(t => t.market === selectedMarket)) {
|
if (items.length > 0 && !items.some(t => t.market === selectedMarket)) {
|
||||||
setSelectedMarket(items[0].market);
|
handleSelectMarket(items[0].market);
|
||||||
}
|
}
|
||||||
}, [selectedMarket]);
|
}, [selectedMarket, handleSelectMarket]);
|
||||||
|
|
||||||
const handleGlobalStrategyChange = useCallback((strategyId: number | null) => {
|
const handleGlobalStrategyChange = useCallback((strategyId: number | null) => {
|
||||||
setSession(s => ({ ...s, globalStrategyId: strategyId }));
|
setSession(s => ({ ...s, globalStrategyId: strategyId }));
|
||||||
@@ -227,7 +238,7 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
selectedMarket={selectedMarket}
|
selectedMarket={selectedMarket}
|
||||||
tickers={tickers}
|
tickers={tickers}
|
||||||
onTargetsChange={handleTargetsChange}
|
onTargetsChange={handleTargetsChange}
|
||||||
onSelectMarket={setSelectedMarket}
|
onSelectMarket={handleSelectMarket}
|
||||||
onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)}
|
onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -245,6 +256,8 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)}
|
onTargetStrategyChange={(market, strategyId) => void handleTargetStrategyChange(market, strategyId)}
|
||||||
liveStatusByMarket={mergedLiveStatus}
|
liveStatusByMarket={mergedLiveStatus}
|
||||||
lastTickAtByMarket={lastTickAtByMarket}
|
lastTickAtByMarket={lastTickAtByMarket}
|
||||||
|
selectedMarket={selectedMarket}
|
||||||
|
onSelectMarket={handleSelectMarket}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
chartRealtimeSource={chartRealtimeSource}
|
chartRealtimeSource={chartRealtimeSource}
|
||||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||||
@@ -267,7 +280,7 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
availableKrw={summary?.cashBalance ?? 0}
|
availableKrw={summary?.cashBalance ?? 0}
|
||||||
fillRequest={fillBuy}
|
fillRequest={fillBuy}
|
||||||
searchAnchorRef={orderAnchorRef}
|
searchAnchorRef={orderAnchorRef}
|
||||||
onMarketSelect={setSelectedMarket}
|
onMarketSelect={handleSelectMarket}
|
||||||
showSymbolField
|
showSymbolField
|
||||||
paperTradingEnabled={paperTradingEnabled}
|
paperTradingEnabled={paperTradingEnabled}
|
||||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||||
@@ -283,7 +296,7 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
tradePrice={tradePrice}
|
tradePrice={tradePrice}
|
||||||
availableCoinQty={posQty}
|
availableCoinQty={posQty}
|
||||||
fillRequest={fillSell}
|
fillRequest={fillSell}
|
||||||
onMarketSelect={setSelectedMarket}
|
onMarketSelect={handleSelectMarket}
|
||||||
showSymbolField={false}
|
showSymbolField={false}
|
||||||
paperTradingEnabled={paperTradingEnabled}
|
paperTradingEnabled={paperTradingEnabled}
|
||||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createChart, CandlestickSeries, type IChartApi, type ISeriesApi, type T
|
|||||||
import type { Theme, Timeframe } from '../../types';
|
import type { Theme, Timeframe } from '../../types';
|
||||||
import { fetchUpbitCandles } from '../../utils/upbitApi';
|
import { fetchUpbitCandles } from '../../utils/upbitApi';
|
||||||
|
|
||||||
const TF_OPTIONS: Timeframe[] = ['1m', '5m', '15m', '1h', '4h', '1D'];
|
const TF_OPTIONS: Timeframe[] = ['1m', '5m', '10m', '15m', '1h', '4h', '1D'];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
market: string;
|
market: string;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { StrategyDto } from '../../utils/backendApi';
|
|||||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||||
import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash';
|
import { useLiveReceiveFlash } from '../../hooks/useLiveReceiveFlash';
|
||||||
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
|
import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
|
||||||
import { buildConditionMetrics } from '../../utils/virtualSignalMetrics';
|
import { buildConditionMetrics, resolveVirtualTradeTiming } from '../../utils/virtualSignalMetrics';
|
||||||
import VirtualLiveBadge from './VirtualLiveBadge';
|
import VirtualLiveBadge from './VirtualLiveBadge';
|
||||||
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
||||||
import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage';
|
import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage';
|
||||||
@@ -29,6 +29,8 @@ interface Props {
|
|||||||
displayMode?: VirtualCardDisplayMode;
|
displayMode?: VirtualCardDisplayMode;
|
||||||
onDisplayModeChange?: (mode: VirtualCardDisplayMode) => void;
|
onDisplayModeChange?: (mode: VirtualCardDisplayMode) => void;
|
||||||
onEnterFocus?: () => void;
|
onEnterFocus?: () => void;
|
||||||
|
selected?: boolean;
|
||||||
|
onSelect?: () => void;
|
||||||
theme?: Theme;
|
theme?: Theme;
|
||||||
chartRealtimeSource?: ChartRealtimeSource;
|
chartRealtimeSource?: ChartRealtimeSource;
|
||||||
chartSeriesPriceLabels?: boolean;
|
chartSeriesPriceLabels?: boolean;
|
||||||
@@ -49,6 +51,8 @@ const VirtualTargetCard: React.FC<Props> = ({
|
|||||||
displayMode = 'signal',
|
displayMode = 'signal',
|
||||||
onDisplayModeChange,
|
onDisplayModeChange,
|
||||||
onEnterFocus,
|
onEnterFocus,
|
||||||
|
selected = false,
|
||||||
|
onSelect,
|
||||||
theme = 'dark',
|
theme = 'dark',
|
||||||
chartRealtimeSource = 'BACKEND_STOMP',
|
chartRealtimeSource = 'BACKEND_STOMP',
|
||||||
chartSeriesPriceLabels = true,
|
chartSeriesPriceLabels = true,
|
||||||
@@ -59,6 +63,7 @@ const VirtualTargetCard: React.FC<Props> = ({
|
|||||||
const rows = snapshot?.rows ?? [];
|
const rows = snapshot?.rows ?? [];
|
||||||
|
|
||||||
const metrics = useMemo(() => buildConditionMetrics(rows), [rows]);
|
const metrics = useMemo(() => buildConditionMetrics(rows), [rows]);
|
||||||
|
const tradeTiming = useMemo(() => resolveVirtualTradeTiming(metrics), [metrics]);
|
||||||
const metCount = metrics.filter(m => m.status === 'match').length;
|
const metCount = metrics.filter(m => m.status === 'match').length;
|
||||||
const evalCount = metrics.filter(m => m.status !== 'unknown').length;
|
const evalCount = metrics.filter(m => m.status !== 'unknown').length;
|
||||||
|
|
||||||
@@ -80,7 +85,20 @@ const VirtualTargetCard: React.FC<Props> = ({
|
|||||||
receiving ? 'vtd-card--receiving' : '',
|
receiving ? 'vtd-card--receiving' : '',
|
||||||
isDetail ? 'vtd-card--detail' : 'vtd-card--summary',
|
isDetail ? 'vtd-card--detail' : 'vtd-card--summary',
|
||||||
isChart ? 'vtd-card--chart-mode' : '',
|
isChart ? 'vtd-card--chart-mode' : '',
|
||||||
|
selected ? 'vtd-card--selected' : '',
|
||||||
|
tradeTiming === 'buy' ? 'vtd-card--timing-buy' : '',
|
||||||
|
tradeTiming === 'sell' ? 'vtd-card--timing-sell' : '',
|
||||||
].filter(Boolean).join(' ')}
|
].filter(Boolean).join(' ')}
|
||||||
|
onClick={onSelect}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (onSelect && (e.key === 'Enter' || e.key === ' ')) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSelect();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role={onSelect ? 'button' : undefined}
|
||||||
|
tabIndex={onSelect ? 0 : undefined}
|
||||||
|
aria-pressed={onSelect ? selected : undefined}
|
||||||
>
|
>
|
||||||
<div className="vtd-card-head">
|
<div className="vtd-card-head">
|
||||||
<div className="vtd-card-head-main">
|
<div className="vtd-card-head-main">
|
||||||
@@ -122,7 +140,7 @@ const VirtualTargetCard: React.FC<Props> = ({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="vtd-card-mode-toggle" role="group" aria-label="이 종목 표시">
|
<div className="vtd-card-mode-toggle" role="group" aria-label="이 종목 표시" onClick={e => e.stopPropagation()}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`vtd-card-mode-btn${displayMode === 'signal' ? ' vtd-card-mode-btn--on' : ''}`}
|
className={`vtd-card-mode-btn${displayMode === 'signal' ? ' vtd-card-mode-btn--on' : ''}`}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ interface Props {
|
|||||||
onTargetStrategyChange: (market: string, strategyId: number | null) => void;
|
onTargetStrategyChange: (market: string, strategyId: number | null) => void;
|
||||||
liveStatusByMarket?: Record<string, VirtualLiveStatus>;
|
liveStatusByMarket?: Record<string, VirtualLiveStatus>;
|
||||||
lastTickAtByMarket?: Record<string, number>;
|
lastTickAtByMarket?: Record<string, number>;
|
||||||
|
selectedMarket?: string;
|
||||||
|
onSelectMarket?: (market: string) => void;
|
||||||
theme?: Theme;
|
theme?: Theme;
|
||||||
chartRealtimeSource?: ChartRealtimeSource;
|
chartRealtimeSource?: ChartRealtimeSource;
|
||||||
chartSeriesPriceLabels?: boolean;
|
chartSeriesPriceLabels?: boolean;
|
||||||
@@ -32,7 +34,9 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
|||||||
targets, strategies, snapshots, session,
|
targets, strategies, snapshots, session,
|
||||||
onStrategyChange, onExecutionTypeChange, onPositionModeChange, onStart, onStop,
|
onStrategyChange, onExecutionTypeChange, onPositionModeChange, onStart, onStop,
|
||||||
onTargetStrategyChange,
|
onTargetStrategyChange,
|
||||||
liveStatusByMarket = {}, lastTickAtByMarket = {}, theme = 'dark',
|
liveStatusByMarket = {}, lastTickAtByMarket = {}, selectedMarket,
|
||||||
|
onSelectMarket,
|
||||||
|
theme = 'dark',
|
||||||
chartRealtimeSource = 'BACKEND_STOMP',
|
chartRealtimeSource = 'BACKEND_STOMP',
|
||||||
chartSeriesPriceLabels = true,
|
chartSeriesPriceLabels = true,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -155,6 +159,8 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
|||||||
displayMode={resolveCardDisplayMode(t.market)}
|
displayMode={resolveCardDisplayMode(t.market)}
|
||||||
onDisplayModeChange={mode => handleCardDisplayMode(t.market, mode)}
|
onDisplayModeChange={mode => handleCardDisplayMode(t.market, mode)}
|
||||||
onEnterFocus={() => setFocusMarket(t.market)}
|
onEnterFocus={() => setFocusMarket(t.market)}
|
||||||
|
selected={t.market === selectedMarket}
|
||||||
|
onSelect={onSelectMarket ? () => onSelectMarket(t.market) : undefined}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
chartRealtimeSource={chartRealtimeSource}
|
chartRealtimeSource={chartRealtimeSource}
|
||||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||||
|
|||||||
@@ -798,6 +798,39 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vtd-card[role='button'] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vtd-card--selected {
|
||||||
|
outline: 2px solid var(--accent, #3f7ef5);
|
||||||
|
outline-offset: 2px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TradeAlertModal: 매수 #3f7ef5, 매도 #ef5350 */
|
||||||
|
.vtd-card--timing-buy {
|
||||||
|
border-color: #3f7ef5 !important;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(63, 126, 245, 0.45),
|
||||||
|
0 0 16px rgba(63, 126, 245, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vtd-card--timing-sell {
|
||||||
|
border-color: #ef5350 !important;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(239, 83, 80, 0.45),
|
||||||
|
0 0 16px rgba(239, 83, 80, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vtd-card--selected.vtd-card--timing-buy {
|
||||||
|
outline-color: #3f7ef5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vtd-card--selected.vtd-card--timing-sell {
|
||||||
|
outline-color: #ef5350;
|
||||||
|
}
|
||||||
|
|
||||||
.vtd-card--live {
|
.vtd-card--live {
|
||||||
border-color: color-mix(in srgb, var(--accent, #3f7ef5) 40%, var(--se-border));
|
border-color: color-mix(in srgb, var(--accent, #3f7ef5) 40%, var(--se-border));
|
||||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #3f7ef5) 12%, transparent);
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #3f7ef5) 12%, transparent);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type Timeframe = '1m' | '3m' | '5m' | '15m' | '30m' | '1h' | '4h' | '1D' | '1W' | '1M';
|
export type Timeframe = '1m' | '3m' | '5m' | '10m' | '15m' | '30m' | '1h' | '4h' | '1D' | '1W' | '1M';
|
||||||
export type ChartType = 'candlestick' | 'bar' | 'line' | 'area' | 'baseline';
|
export type ChartType = 'candlestick' | 'bar' | 'line' | 'area' | 'baseline';
|
||||||
export type Theme = 'dark' | 'light' | 'blue';
|
export type Theme = 'dark' | 'light' | 'blue';
|
||||||
export type ChartMode = 'chart' | 'trading'; // 차트뷰 / 트레이딩뷰
|
export type ChartMode = 'chart' | 'trading'; // 차트뷰 / 트레이딩뷰
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Timeframe } from '../types';
|
|||||||
/** 차트 Timeframe → 백엔드 candleType */
|
/** 차트 Timeframe → 백엔드 candleType */
|
||||||
export function timeframeToCandleType(tf: Timeframe): string {
|
export function timeframeToCandleType(tf: Timeframe): string {
|
||||||
switch (tf) {
|
switch (tf) {
|
||||||
case '1m': case '3m': case '5m': case '15m': case '30m': case '1h': case '4h':
|
case '1m': case '3m': case '5m': case '10m': case '15m': case '30m': case '1h': case '4h':
|
||||||
return tf;
|
return tf;
|
||||||
case '1D': return '1d';
|
case '1D': return '1d';
|
||||||
case '1W': case '1M': return '1d';
|
case '1W': case '1M': return '1d';
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ export const SYMBOLS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const TF_SECONDS: Record<Timeframe, number> = {
|
const TF_SECONDS: Record<Timeframe, number> = {
|
||||||
'1m':60,'3m':180,'5m':300,'15m':900,'30m':1800,'1h':3600,'4h':14400,'1D':86400,'1W':604800,'1M':2592000,
|
'1m':60,'3m':180,'5m':300,'10m':600,'15m':900,'30m':1800,'1h':3600,'4h':14400,'1D':86400,'1W':604800,'1M':2592000,
|
||||||
};
|
};
|
||||||
|
|
||||||
const TF_BARS: Record<Timeframe, number> = {
|
const TF_BARS: Record<Timeframe, number> = {
|
||||||
'1m':300,'3m':300,'5m':300,'15m':300,'30m':300,'1h':500,'4h':500,'1D':500,'1W':300,'1M':120,
|
'1m':300,'3m':300,'5m':300,'10m':300,'15m':300,'30m':300,'1h':500,'4h':500,'1D':500,'1W':300,'1M':120,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SEED_MAP: Record<string, number> = {
|
const SEED_MAP: Record<string, number> = {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const CANDLE_KO: Record<string, string> = {
|
|||||||
'1m': '1분봉',
|
'1m': '1분봉',
|
||||||
'3m': '3분봉',
|
'3m': '3분봉',
|
||||||
'5m': '5분봉',
|
'5m': '5분봉',
|
||||||
|
'10m': '10분봉',
|
||||||
'15m': '15분봉',
|
'15m': '15분봉',
|
||||||
'30m': '30분봉',
|
'30m': '30분봉',
|
||||||
'1h': '1시간봉',
|
'1h': '1시간봉',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export const START_NODE_ID = '__strategy_start__';
|
|||||||
export const DEFAULT_START_CANDLE = '1m';
|
export const DEFAULT_START_CANDLE = '1m';
|
||||||
|
|
||||||
export const STRATEGY_CANDLE_TYPES = [
|
export const STRATEGY_CANDLE_TYPES = [
|
||||||
'1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d',
|
'1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h', '1d',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type StrategyCandleType = (typeof STRATEGY_CANDLE_TYPES)[number];
|
export type StrategyCandleType = (typeof STRATEGY_CANDLE_TYPES)[number];
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export function candleTypeToTimeframe(candleType: string): Timeframe {
|
|||||||
if (c === '1d') return '1D';
|
if (c === '1d') return '1D';
|
||||||
if (c === '1h') return '1h';
|
if (c === '1h') return '1h';
|
||||||
if (c === '4h') return '4h';
|
if (c === '4h') return '4h';
|
||||||
if (['1m', '3m', '5m', '15m', '30m'].includes(c)) return c as Timeframe;
|
if (['1m', '3m', '5m', '10m', '15m', '30m'].includes(c)) return c as Timeframe;
|
||||||
return '1m';
|
return '1m';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ export function resolveStrategyTimeframes(strategy: StrategyDto | undefined): Ti
|
|||||||
}
|
}
|
||||||
if (set.size === 0) set.add('1m');
|
if (set.size === 0) set.add('1m');
|
||||||
return [...set].sort((a, b) => {
|
return [...set].sort((a, b) => {
|
||||||
const order: Timeframe[] = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1D', '1W', '1M'];
|
const order: Timeframe[] = ['1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h', '1D', '1W', '1M'];
|
||||||
return order.indexOf(a) - order.indexOf(b);
|
return order.indexOf(a) - order.indexOf(b);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const CANDLE_TYPE_KO: Record<string, string> = {
|
|||||||
'1m': '1분봉',
|
'1m': '1분봉',
|
||||||
'3m': '3분봉',
|
'3m': '3분봉',
|
||||||
'5m': '5분봉',
|
'5m': '5분봉',
|
||||||
|
'10m': '10분봉',
|
||||||
'15m': '15분봉',
|
'15m': '15분봉',
|
||||||
'30m': '30분봉',
|
'30m': '30분봉',
|
||||||
'1h': '1시간봉',
|
'1h': '1시간봉',
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ function candleEndpoint(
|
|||||||
case '1m': return `${base}/minutes/1?market=${market}&count=${count}${toParam}`;
|
case '1m': return `${base}/minutes/1?market=${market}&count=${count}${toParam}`;
|
||||||
case '3m': return `${base}/minutes/3?market=${market}&count=${count}${toParam}`;
|
case '3m': return `${base}/minutes/3?market=${market}&count=${count}${toParam}`;
|
||||||
case '5m': return `${base}/minutes/5?market=${market}&count=${count}${toParam}`;
|
case '5m': return `${base}/minutes/5?market=${market}&count=${count}${toParam}`;
|
||||||
|
case '10m': return `${base}/minutes/10?market=${market}&count=${count}${toParam}`;
|
||||||
case '15m': return `${base}/minutes/15?market=${market}&count=${count}${toParam}`;
|
case '15m': return `${base}/minutes/15?market=${market}&count=${count}${toParam}`;
|
||||||
case '30m': return `${base}/minutes/30?market=${market}&count=${count}${toParam}`;
|
case '30m': return `${base}/minutes/30?market=${market}&count=${count}${toParam}`;
|
||||||
case '1h': return `${base}/minutes/60?market=${market}&count=${count}${toParam}`;
|
case '1h': return `${base}/minutes/60?market=${market}&count=${count}${toParam}`;
|
||||||
@@ -318,7 +319,7 @@ export class UpbitWebSocketClient {
|
|||||||
|
|
||||||
// ─── 타임프레임 초 단위 ───────────────────────────────────────────────────
|
// ─── 타임프레임 초 단위 ───────────────────────────────────────────────────
|
||||||
export const TF_SECONDS: Record<Timeframe, number> = {
|
export const TF_SECONDS: Record<Timeframe, number> = {
|
||||||
'1m': 60, '3m': 180, '5m': 300, '15m': 900, '30m': 1800,
|
'1m': 60, '3m': 180, '5m': 300, '10m': 600, '15m': 900, '30m': 1800,
|
||||||
'1h': 3600, '4h': 14400, '1D': 86400, '1W': 604800, '1M': 2592000,
|
'1h': 3600, '4h': 14400, '1D': 86400, '1W': 604800, '1M': 2592000,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -143,6 +143,29 @@ export function getTrafficLightState(matchRate: number, metrics: ConditionMetric
|
|||||||
return 'red';
|
return 'red';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 매수·매도 조건 충족률 기준 타이밍 (TradeAlertModal: 매수=블루, 매도=레드) */
|
||||||
|
export type VirtualTradeTiming = 'buy' | 'sell' | 'neutral';
|
||||||
|
|
||||||
|
function sideMatchRate(metrics: ConditionMetric[], side: 'buy' | 'sell'): number {
|
||||||
|
const rows = metrics.filter(m => m.row.side === side && m.status !== 'unknown');
|
||||||
|
if (rows.length === 0) return 0;
|
||||||
|
const met = rows.filter(m => m.status === 'match').length;
|
||||||
|
return Math.round((met / rows.length) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveVirtualTradeTiming(metrics: ConditionMetric[]): VirtualTradeTiming {
|
||||||
|
const buyRate = sideMatchRate(metrics, 'buy');
|
||||||
|
const sellRate = sideMatchRate(metrics, 'sell');
|
||||||
|
if (buyRate === 0 && sellRate === 0) return 'neutral';
|
||||||
|
if (buyRate >= 100 && buyRate > sellRate) return 'buy';
|
||||||
|
if (sellRate >= 100 && sellRate > buyRate) return 'sell';
|
||||||
|
if (buyRate >= 100 && sellRate < 100) return 'buy';
|
||||||
|
if (sellRate >= 100 && buyRate < 100) return 'sell';
|
||||||
|
if (buyRate > sellRate && buyRate >= 50) return 'buy';
|
||||||
|
if (sellRate > buyRate && sellRate >= 50) return 'sell';
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
export function formatUpdatedTime(ts: number | undefined): string {
|
export function formatUpdatedTime(ts: number | undefined): string {
|
||||||
if (!ts) return '—';
|
if (!ts) return '—';
|
||||||
return new Date(ts).toLocaleTimeString('ko-KR', {
|
return new Date(ts).toLocaleTimeString('ko-KR', {
|
||||||
|
|||||||
Reference in New Issue
Block a user