가상투자 화면 목록 선택 동작

This commit is contained in:
Macbook
2026-05-25 14:05:13 +09:00
parent 20f11f6c4b
commit 3102169541
24 changed files with 130 additions and 27 deletions
@@ -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);
@@ -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<>();
+2
View File
@@ -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' },
+1 -1
View File
@@ -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시간' },
+1 -1
View File
@@ -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: '바' },
+18 -5
View File
@@ -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 -1
View File
@@ -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'; // 차트뷰 / 트레이딩뷰
+1 -1
View File
@@ -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';
+2 -2
View File
@@ -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시간봉',
+1 -1
View File
@@ -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);
}); });
} }
+1
View File
@@ -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시간봉',
+2 -1
View File
@@ -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', {