매수, 매도 색상 수정
This commit is contained in:
+44
-36
@@ -24,6 +24,11 @@
|
||||
--accent-h: #5a7dc7;
|
||||
--up: #ff6b6b;
|
||||
--down: #4dabf7;
|
||||
/* 매매 시그널 — 매수(빨강) · 매도(파랑). 시세 등락(--up/--down)과 동일 톤 */
|
||||
--gc-trade-buy: #ef5350;
|
||||
--gc-trade-sell: #4dabf7;
|
||||
--gc-trade-buy-hover: #e53935;
|
||||
--gc-trade-sell-hover: #339af0;
|
||||
--panel-bg: #24283bee;
|
||||
--sep: rgba(122, 162, 247, 0.15);
|
||||
--panel: #24283b;
|
||||
@@ -6648,8 +6653,8 @@ html.theme-blue {
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.strat-cond-dot--buy { background: var(--up); }
|
||||
.strat-cond-dot--sell { background: var(--down); }
|
||||
.strat-cond-dot--buy { background: var(--gc-trade-buy); }
|
||||
.strat-cond-dot--sell { background: var(--gc-trade-sell); }
|
||||
.strat-cond-empty {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
@@ -7024,8 +7029,8 @@ html.theme-blue {
|
||||
display: inline-block; font-size: 12px; padding: 3px 10px;
|
||||
border-radius: 99px; margin-bottom: 8px;
|
||||
}
|
||||
.sp-prev-badge--buy { background: rgba(72,199,116,.2); color: #48c774; }
|
||||
.sp-prev-badge--sell { background: rgba(255,107,107,.2); color: #ff6b6b; }
|
||||
.sp-prev-badge--buy { background: color-mix(in srgb, var(--gc-trade-buy) 22%, transparent); color: var(--gc-trade-buy); }
|
||||
.sp-prev-badge--sell { background: color-mix(in srgb, var(--gc-trade-sell) 22%, transparent); color: var(--gc-trade-sell); }
|
||||
.sp-prev-text {
|
||||
background: var(--bg3); border: 1px solid var(--border);
|
||||
padding: 12px; font-size: 13px; line-height: 1.6;
|
||||
@@ -7036,8 +7041,8 @@ html.theme-blue {
|
||||
border-bottom: 2px solid var(--border); padding-bottom: 4px; margin-bottom: 10px;
|
||||
}
|
||||
.sp-prev-formula { font-size: 13px; font-family: monospace; line-height: 1.6; margin-bottom: 6px; }
|
||||
.sp-prev-buy { color: #48c774; font-weight: 700; }
|
||||
.sp-prev-sell { color: #ff6b6b; font-weight: 700; }
|
||||
.sp-prev-buy { color: var(--gc-trade-buy); font-weight: 700; }
|
||||
.sp-prev-sell { color: var(--gc-trade-sell); font-weight: 700; }
|
||||
.sp-validation { margin-bottom: 8px; }
|
||||
.sp-val-head { font-size: 12px; font-weight: 600; margin-bottom: 4px; }
|
||||
.sp-val-ok { color: #48c774; font-size: 13px; padding: 5px 10px; background: rgba(72,199,116,.1); border-radius: var(--radius); }
|
||||
@@ -7096,11 +7101,11 @@ html.theme-blue {
|
||||
color: var(--text2); cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; gap: 4px;
|
||||
}
|
||||
.sp-signal-tab--buy { color: #ff6b6b; border-bottom-color: #ff6b6b; font-weight: 600; background: rgba(255,107,107,.06); }
|
||||
.sp-signal-tab--sell { color: #4dabf7; border-bottom-color: #4dabf7; font-weight: 600; background: rgba(77,171,247,.06); }
|
||||
.sp-signal-tab--buy { color: var(--gc-trade-buy); border-bottom-color: var(--gc-trade-buy); font-weight: 600; background: color-mix(in srgb, var(--gc-trade-buy) 8%, transparent); }
|
||||
.sp-signal-tab--sell { color: var(--gc-trade-sell); border-bottom-color: var(--gc-trade-sell); font-weight: 600; background: color-mix(in srgb, var(--gc-trade-sell) 8%, transparent); }
|
||||
.sp-signal-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
||||
.sp-signal-dot--buy { background: #48c774; }
|
||||
.sp-signal-dot--sell { background: #ff6b6b; }
|
||||
.sp-signal-dot--buy { background: var(--gc-trade-buy); }
|
||||
.sp-signal-dot--sell { background: var(--gc-trade-sell); }
|
||||
|
||||
/* 드롭존 */
|
||||
.sp-dropzone {
|
||||
@@ -7383,8 +7388,8 @@ html.theme-blue {
|
||||
.sp-tmpl-badge {
|
||||
font-size: 11px; padding: 3px 8px; border-radius: 99px; white-space: nowrap; font-weight: 600;
|
||||
}
|
||||
.sp-tmpl-badge--buy { background: rgba(72,199,116,.2); color: #48c774; }
|
||||
.sp-tmpl-badge--sell { background: rgba(255,107,107,.2); color: #ff6b6b; }
|
||||
.sp-tmpl-badge--buy { background: color-mix(in srgb, var(--gc-trade-buy) 22%, transparent); color: var(--gc-trade-buy); }
|
||||
.sp-tmpl-badge--sell { background: color-mix(in srgb, var(--gc-trade-sell) 22%, transparent); color: var(--gc-trade-sell); }
|
||||
.sp-tmpl-name { flex: 1 1 auto; color: var(--text); }
|
||||
.sp-tmpl-desc {
|
||||
flex: 1 1 100%;
|
||||
@@ -7444,8 +7449,8 @@ html.theme-blue {
|
||||
font-size: 11px; padding: 2px 8px; border-radius: 99px;
|
||||
border: 1px solid var(--border); color: var(--text2);
|
||||
}
|
||||
.sp-modal-chip--buy { border-color: #48c774; color: #48c774; }
|
||||
.sp-modal-chip--sell { border-color: #ff6b6b; color: #ff6b6b; }
|
||||
.sp-modal-chip--buy { border-color: var(--gc-trade-buy); color: var(--gc-trade-buy); }
|
||||
.sp-modal-chip--sell { border-color: var(--gc-trade-sell); color: var(--gc-trade-sell); }
|
||||
.sp-modal-lbl { display: block; font-size: 12px; color: var(--text2); margin: 10px 0 4px; }
|
||||
.sp-modal-inp {
|
||||
width: 100%; padding: 8px 10px; font-size: 13px;
|
||||
@@ -8717,6 +8722,8 @@ html.theme-blue {
|
||||
.brd-sig-badge.brd-pos { background: rgba(38,201,126,.12); }
|
||||
.brd-sig-badge.brd-neg { background: rgba(240,78,94,.12); }
|
||||
.brd-sig-badge.brd-warn{ background: rgba(255,159,67,.12); }
|
||||
.brd-sig-badge.brd-sig-buy { background: color-mix(in srgb, var(--gc-trade-buy) 22%, transparent); color: var(--gc-trade-buy); }
|
||||
.brd-sig-badge.brd-sig-sell { background: color-mix(in srgb, var(--gc-trade-sell) 22%, transparent); color: var(--gc-trade-sell); }
|
||||
|
||||
.brd-expand-btn {
|
||||
display: block; width: 100%;
|
||||
@@ -10024,12 +10031,12 @@ html.theme-blue {
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.rsp-trade-card--buy {
|
||||
border-color: rgba(255, 107, 107, 0.35);
|
||||
background: linear-gradient(180deg, rgba(255, 107, 107, 0.08) 0%, var(--bg3) 48px);
|
||||
border-color: color-mix(in srgb, var(--gc-trade-buy) 40%, transparent);
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--gc-trade-buy) 10%, transparent) 0%, var(--bg3) 48px);
|
||||
}
|
||||
.rsp-trade-card--sell {
|
||||
border-color: rgba(77, 171, 247, 0.35);
|
||||
background: linear-gradient(180deg, rgba(77, 171, 247, 0.08) 0%, var(--bg3) 48px);
|
||||
border-color: color-mix(in srgb, var(--gc-trade-sell) 40%, transparent);
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--gc-trade-sell) 10%, transparent) 0%, var(--bg3) 48px);
|
||||
}
|
||||
.rsp-trade-card-title {
|
||||
padding: 7px 12px 5px;
|
||||
@@ -10038,10 +10045,10 @@ html.theme-blue {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.rsp-trade-card--buy .rsp-trade-card-title {
|
||||
color: var(--up);
|
||||
color: var(--gc-trade-buy);
|
||||
}
|
||||
.rsp-trade-card--sell .rsp-trade-card-title {
|
||||
color: var(--down);
|
||||
color: var(--gc-trade-sell);
|
||||
}
|
||||
.rsp-trade-card-body .top-panel {
|
||||
flex: 1 1 auto;
|
||||
@@ -10547,16 +10554,17 @@ html.theme-blue {
|
||||
cursor: pointer;
|
||||
}
|
||||
.top-submit--buy {
|
||||
background: #ef5350;
|
||||
background: var(--gc-trade-buy);
|
||||
color: #fff;
|
||||
}
|
||||
.top-submit--buy:hover {
|
||||
background: #e53935;
|
||||
background: var(--gc-trade-buy-hover);
|
||||
}
|
||||
.top-submit--sell {
|
||||
background: #2962ff;
|
||||
background: var(--gc-trade-sell);
|
||||
}
|
||||
.top-submit--sell:hover {
|
||||
background: #1e53e5;
|
||||
background: var(--gc-trade-sell-hover);
|
||||
}
|
||||
.app.light .top-kind-btn--active {
|
||||
border-color: #1976d2;
|
||||
@@ -10658,10 +10666,10 @@ html.theme-blue {
|
||||
background: var(--bg3);
|
||||
}
|
||||
.chart-ctx-menu-item--buy {
|
||||
color: #ef5350;
|
||||
color: var(--gc-trade-buy);
|
||||
}
|
||||
.chart-ctx-menu-item--sell {
|
||||
color: #2962ff;
|
||||
color: var(--gc-trade-sell);
|
||||
}
|
||||
.app.light .chart-ctx-menu {
|
||||
background: #fff;
|
||||
@@ -11489,8 +11497,8 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
|
||||
.tsn-card--latest {
|
||||
box-shadow: 0 14px 44px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(77, 171, 247, 0.12);
|
||||
}
|
||||
.tsn-card--buy { --tsn-accent: #3f7ef5; }
|
||||
.tsn-card--sell { --tsn-accent: #ef5350; }
|
||||
.tsn-card--buy { --tsn-accent: var(--gc-trade-buy); }
|
||||
.tsn-card--sell { --tsn-accent: var(--gc-trade-sell); }
|
||||
|
||||
.tsn-card-header {
|
||||
cursor: grab;
|
||||
@@ -11512,8 +11520,8 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
|
||||
border-radius: 3px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.tsn-badge--buy { background: rgba(63,126,245,0.2); color: #7aa2f7; }
|
||||
.tsn-badge--sell { background: rgba(239,83,80,0.2); color: #f7768e; }
|
||||
.tsn-badge--buy { background: color-mix(in srgb, var(--gc-trade-buy) 25%, transparent); color: var(--gc-trade-buy); }
|
||||
.tsn-badge--sell { background: color-mix(in srgb, var(--gc-trade-sell) 25%, transparent); color: var(--gc-trade-sell); }
|
||||
.tsn-card-title {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
@@ -11551,8 +11559,8 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.tsd-direction--buy { color: var(--up, #ff6b6b); }
|
||||
.tsd-direction--sell { color: var(--down, #4dabf7); }
|
||||
.tsd-direction--buy { color: var(--gc-trade-buy); }
|
||||
.tsd-direction--sell { color: var(--gc-trade-sell); }
|
||||
.tsd-headline-price {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
@@ -11935,8 +11943,8 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tnl-signal--buy { background: rgba(63,126,245,0.2); color: #7aa2f7; }
|
||||
.tnl-signal--sell { background: rgba(239,83,80,0.2); color: #f7768e; }
|
||||
.tnl-signal--buy { background: color-mix(in srgb, var(--gc-trade-buy) 25%, transparent); color: var(--gc-trade-buy); }
|
||||
.tnl-signal--sell { background: color-mix(in srgb, var(--gc-trade-sell) 25%, transparent); color: var(--gc-trade-sell); }
|
||||
.tnl-row-body { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.tnl-row-title { font-size: 14px; font-weight: 600; }
|
||||
.tnl-row-price {
|
||||
@@ -12406,8 +12414,8 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
|
||||
.dashboard-kv--cols li { border: none; flex-direction: column; gap: 4px; }
|
||||
.dashboard-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.dashboard-table th, .dashboard-table td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--border); }
|
||||
.dashboard-table .buy { color: #26a69a; }
|
||||
.dashboard-table .sell { color: #ef5350; }
|
||||
.dashboard-table .buy { color: var(--gc-trade-buy); }
|
||||
.dashboard-table .sell { color: var(--gc-trade-sell); }
|
||||
.dashboard-link { background: none; border: none; color: var(--accent); cursor: pointer; padding: 0; }
|
||||
.dashboard-muted { color: var(--text2); font-size: 13px; }
|
||||
.dashboard-error { color: #ef5350; font-size: 13px; }
|
||||
|
||||
@@ -168,7 +168,7 @@ const SIG_LABEL: Record<string, string> = {
|
||||
BUY:'매수', SELL:'매도', SHORT_ENTRY:'공매도', SHORT_EXIT:'공매도청산', PARTIAL_SELL:'분할매도',
|
||||
};
|
||||
const SIG_COLOR: Record<string, string> = {
|
||||
BUY:'brd-pos', SELL:'brd-neg', SHORT_ENTRY:'brd-neg', SHORT_EXIT:'brd-pos', PARTIAL_SELL:'brd-warn',
|
||||
BUY:'brd-sig-buy', SELL:'brd-sig-sell', SHORT_ENTRY:'brd-neg', SHORT_EXIT:'brd-pos', PARTIAL_SELL:'brd-warn',
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { useState } from 'react';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
import { placePaperOrder, placeLiveOrder, loadLiveSummary } from '../utils/backendApi';
|
||||
import { formatSignalTime } from '../utils/tradeSignalDisplay';
|
||||
import { TRADE_BUY_COLOR, TRADE_SELL_COLOR } from '../utils/tradeSignalColors';
|
||||
import { useTradeAlertTimeFormat } from '../utils/tradeAlertTimeFormat';
|
||||
|
||||
export interface TradeSignalInfo {
|
||||
@@ -61,8 +62,8 @@ export const TradeAlertModal: React.FC<Props> = ({
|
||||
}) => {
|
||||
useTradeAlertTimeFormat();
|
||||
const isBuy = signal.signalType === 'BUY';
|
||||
const accentColor = isBuy ? '#3f7ef5' : '#ef5350';
|
||||
const accentGlow = isBuy ? 'rgba(63,126,245,0.22)' : 'rgba(239,83,80,0.22)';
|
||||
const accentColor = isBuy ? TRADE_BUY_COLOR : TRADE_SELL_COLOR;
|
||||
const accentGlow = isBuy ? 'rgba(239, 83, 80, 0.28)' : 'rgba(77, 171, 247, 0.28)';
|
||||
const signalLabel = isBuy ? 'BUY' : 'SELL';
|
||||
const signalKo = isBuy ? '매수 알림' : '매도 알림';
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
} from './virtual/VirtualGridUnifiedHeader';
|
||||
import type { VirtualCardDisplayMode } from './virtual/VirtualTargetCard';
|
||||
import { useVirtualIndicatorSnapshots } from '../hooks/useVirtualIndicatorSnapshots';
|
||||
import { useVirtualAutoTrade } from '../hooks/useVirtualAutoTrade';
|
||||
import { useVirtualBackendTradeSignals } from '../hooks/useVirtualBackendTradeSignals';
|
||||
import { useVirtualTargetLiveStatus } from '../hooks/useVirtualTargetLiveStatus';
|
||||
import type { ChartRealtimeSource } from '../hooks/useChartRealtimeData';
|
||||
import {
|
||||
@@ -197,11 +197,10 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
session.running,
|
||||
);
|
||||
|
||||
useVirtualAutoTrade({
|
||||
useVirtualBackendTradeSignals({
|
||||
enabled: paperTradingEnabled && paperAutoTradeEnabled,
|
||||
session,
|
||||
targets,
|
||||
snapshots,
|
||||
tickers,
|
||||
paperAutoTradeBudgetPct,
|
||||
positions: summary?.positions,
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* 가상투자 Match(전략 조건 100% 충족) 시 자동 모의 체결
|
||||
* 설정 · 가상투자 · 자동매매 ON + 세션 running 일 때만 동작
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { TickerData } from './useMarketTicker';
|
||||
import type { VirtualIndicatorSnapshot } from './useVirtualIndicatorSnapshots';
|
||||
import type { VirtualSessionConfig, VirtualTargetItem } from '../utils/virtualTradingStorage';
|
||||
import { placePaperOrder, type PaperSummaryDto } from '../utils/backendApi';
|
||||
import {
|
||||
buildConditionMetrics,
|
||||
resolveVirtualTradeTiming,
|
||||
type VirtualTradeTiming,
|
||||
} from '../utils/virtualSignalMetrics';
|
||||
import { coerceFiniteNumber } from '../utils/safeFormat';
|
||||
|
||||
const EXEC_COOLDOWN_MS = 15_000;
|
||||
|
||||
interface Options {
|
||||
enabled: boolean;
|
||||
session: VirtualSessionConfig;
|
||||
targets: VirtualTargetItem[];
|
||||
snapshots: Record<string, VirtualIndicatorSnapshot | undefined>;
|
||||
tickers?: Map<string, TickerData>;
|
||||
paperAutoTradeBudgetPct: number;
|
||||
positions?: PaperSummaryDto['positions'];
|
||||
onFilled?: () => void;
|
||||
}
|
||||
|
||||
export function useVirtualAutoTrade({
|
||||
enabled,
|
||||
session,
|
||||
targets,
|
||||
snapshots,
|
||||
tickers,
|
||||
paperAutoTradeBudgetPct,
|
||||
positions = [],
|
||||
onFilled,
|
||||
}: Options): void {
|
||||
const prevTimingRef = useRef<Record<string, VirtualTradeTiming>>({});
|
||||
const inflightRef = useRef<Set<string>>(new Set());
|
||||
const lastExecRef = useRef<Record<string, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !session.running) {
|
||||
prevTimingRef.current = {};
|
||||
return;
|
||||
}
|
||||
|
||||
for (const target of targets) {
|
||||
const snap = snapshots[target.market];
|
||||
if (!snap?.rows?.length) continue;
|
||||
|
||||
const metrics = buildConditionMetrics(snap.rows);
|
||||
const timing = resolveVirtualTradeTiming(metrics);
|
||||
const prev = prevTimingRef.current[target.market] ?? 'neutral';
|
||||
prevTimingRef.current[target.market] = timing;
|
||||
|
||||
if (timing !== 'buy' && timing !== 'sell') continue;
|
||||
if (timing === prev) continue;
|
||||
|
||||
const price = coerceFiniteNumber(tickers?.get(target.market)?.tradePrice);
|
||||
if (price == null || price <= 0) continue;
|
||||
|
||||
const heldQty = coerceFiniteNumber(
|
||||
positions.find(p => p.symbol === target.market)?.quantity,
|
||||
) ?? 0;
|
||||
|
||||
if (session.positionMode === 'LONG_ONLY') {
|
||||
if (timing === 'buy' && heldQty > 0) continue;
|
||||
if (timing === 'sell' && heldQty <= 0) continue;
|
||||
}
|
||||
|
||||
const side = timing === 'buy' ? 'BUY' : 'SELL';
|
||||
const execKey = `${target.market}:${side}`;
|
||||
if (inflightRef.current.has(execKey)) continue;
|
||||
if (Date.now() - (lastExecRef.current[execKey] ?? 0) < EXEC_COOLDOWN_MS) continue;
|
||||
|
||||
const strategyId = target.strategyId ?? session.globalStrategyId ?? null;
|
||||
inflightRef.current.add(execKey);
|
||||
|
||||
void placePaperOrder({
|
||||
market: target.market,
|
||||
side,
|
||||
orderKind: 'market',
|
||||
price,
|
||||
quantity: 0,
|
||||
budgetPct: timing === 'buy' ? paperAutoTradeBudgetPct : 100,
|
||||
source: 'STRATEGY',
|
||||
strategyId,
|
||||
})
|
||||
.then(trade => {
|
||||
if (trade) {
|
||||
lastExecRef.current[execKey] = Date.now();
|
||||
onFilled?.();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn('[VirtualAutoTrade]', target.market, side, err);
|
||||
})
|
||||
.finally(() => {
|
||||
inflightRef.current.delete(execKey);
|
||||
});
|
||||
}
|
||||
}, [
|
||||
enabled,
|
||||
session.running,
|
||||
session.globalStrategyId,
|
||||
session.positionMode,
|
||||
targets,
|
||||
snapshots,
|
||||
tickers,
|
||||
paperAutoTradeBudgetPct,
|
||||
positions,
|
||||
onFilled,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 가상투자 자동매매 — 백엔드 BarBuilder/LiveStrategyEvaluator 가 발행한 STOMP BUY/SELL 만 체결.
|
||||
* 프론트 근사 일치율(resolveVirtualTradeTiming) 사용 안 함.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { TickerData } from './useMarketTicker';
|
||||
import type { VirtualSessionConfig, VirtualTargetItem } from '../utils/virtualTradingStorage';
|
||||
import { loadStrategyTimeframes, placePaperOrder, type PaperSummaryDto } from '../utils/backendApi';
|
||||
import { parseStompJson } from '../utils/stompMessage';
|
||||
import { subscribeStompTopic } from '../utils/stompChartBroker';
|
||||
import { signalBelongsToCurrentSession } from '../utils/tradeSignalOwnership';
|
||||
import { resolveVirtualTargetStrategyId } from '../utils/virtualTargetStrategy';
|
||||
import { coerceFiniteNumber } from '../utils/safeFormat';
|
||||
|
||||
const EXEC_COOLDOWN_MS = 15_000;
|
||||
|
||||
interface Options {
|
||||
enabled: boolean;
|
||||
session: VirtualSessionConfig;
|
||||
targets: VirtualTargetItem[];
|
||||
tickers?: Map<string, TickerData>;
|
||||
paperAutoTradeBudgetPct: number;
|
||||
positions?: PaperSummaryDto['positions'];
|
||||
onFilled?: () => void;
|
||||
}
|
||||
|
||||
export function useVirtualBackendTradeSignals({
|
||||
enabled,
|
||||
session,
|
||||
targets,
|
||||
tickers,
|
||||
paperAutoTradeBudgetPct,
|
||||
positions = [],
|
||||
onFilled,
|
||||
}: Options): void {
|
||||
const inflightRef = useRef<Set<string>>(new Set());
|
||||
const lastExecRef = useRef<Record<string, number>>({});
|
||||
const seenSignalRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !session.running) {
|
||||
seenSignalRef.current.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const activeTargets = targets.filter(
|
||||
t => resolveVirtualTargetStrategyId(t, session.globalStrategyId) != null,
|
||||
);
|
||||
if (activeTargets.length === 0) return;
|
||||
|
||||
const unsubs: Array<() => void> = [];
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
for (const target of activeTargets) {
|
||||
if (cancelled) break;
|
||||
const strategyId = resolveVirtualTargetStrategyId(target, session.globalStrategyId)!;
|
||||
let timeframes: string[] = [];
|
||||
try {
|
||||
timeframes = await loadStrategyTimeframes(strategyId);
|
||||
} catch {
|
||||
timeframes = ['1m'];
|
||||
}
|
||||
if (timeframes.length === 0) timeframes = ['1m'];
|
||||
|
||||
for (const candleType of timeframes) {
|
||||
const topic = `/sub/charts/${target.market}/${candleType}`;
|
||||
const unsub = subscribeStompTopic(topic, msg => {
|
||||
const data = parseStompJson<{
|
||||
time: number;
|
||||
close: number;
|
||||
signal?: string;
|
||||
candleType?: string;
|
||||
strategyId?: number | null;
|
||||
userId?: number | null;
|
||||
deviceId?: string | null;
|
||||
}>(msg);
|
||||
if (!data?.signal || (data.signal !== 'BUY' && data.signal !== 'SELL')) return;
|
||||
if (data.strategyId != null && data.strategyId !== strategyId) return;
|
||||
if (!signalBelongsToCurrentSession({ userId: data.userId, deviceId: data.deviceId })) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dedup = `${target.market}:${data.candleType ?? candleType}:${data.time}:${data.signal}:${strategyId}`;
|
||||
if (seenSignalRef.current.has(dedup)) return;
|
||||
seenSignalRef.current.add(dedup);
|
||||
if (seenSignalRef.current.size > 5000) {
|
||||
seenSignalRef.current.clear();
|
||||
}
|
||||
|
||||
const side = data.signal as 'BUY' | 'SELL';
|
||||
const price = coerceFiniteNumber(tickers?.get(target.market)?.tradePrice)
|
||||
?? coerceFiniteNumber(data.close);
|
||||
if (price == null || price <= 0) return;
|
||||
|
||||
const heldQty = coerceFiniteNumber(
|
||||
positions.find(p => p.symbol === target.market)?.quantity,
|
||||
) ?? 0;
|
||||
if (session.positionMode === 'LONG_ONLY') {
|
||||
if (side === 'BUY' && heldQty > 0) return;
|
||||
if (side === 'SELL' && heldQty <= 0) return;
|
||||
}
|
||||
|
||||
const execKey = `${target.market}:${side}`;
|
||||
if (inflightRef.current.has(execKey)) return;
|
||||
if (Date.now() - (lastExecRef.current[execKey] ?? 0) < EXEC_COOLDOWN_MS) return;
|
||||
|
||||
inflightRef.current.add(execKey);
|
||||
void placePaperOrder({
|
||||
market: target.market,
|
||||
side,
|
||||
orderKind: 'market',
|
||||
price,
|
||||
quantity: 0,
|
||||
budgetPct: side === 'BUY' ? paperAutoTradeBudgetPct : 100,
|
||||
source: 'STRATEGY',
|
||||
strategyId,
|
||||
})
|
||||
.then(trade => {
|
||||
if (trade) {
|
||||
lastExecRef.current[execKey] = Date.now();
|
||||
onFilled?.();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn('[VirtualBackendTrade]', target.market, side, err);
|
||||
})
|
||||
.finally(() => {
|
||||
inflightRef.current.delete(execKey);
|
||||
});
|
||||
});
|
||||
unsubs.push(unsub);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubs.forEach(u => u());
|
||||
};
|
||||
}, [
|
||||
enabled,
|
||||
session.running,
|
||||
session.globalStrategyId,
|
||||
session.positionMode,
|
||||
targets,
|
||||
tickers,
|
||||
paperAutoTradeBudgetPct,
|
||||
positions,
|
||||
onFilled,
|
||||
]);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type StrategyDto,
|
||||
} from '../utils/backendApi';
|
||||
import { useVirtualIndicatorSnapshots } from './useVirtualIndicatorSnapshots';
|
||||
import { useVirtualAutoTrade } from './useVirtualAutoTrade';
|
||||
import { useVirtualBackendTradeSignals } from './useVirtualBackendTradeSignals';
|
||||
import { useVirtualTargetLiveStatus } from './useVirtualTargetLiveStatus';
|
||||
import {
|
||||
loadVirtualSession,
|
||||
@@ -206,11 +206,10 @@ export function useVirtualTradingCore(options: UseVirtualTradingCoreOptions = {}
|
||||
session.running,
|
||||
);
|
||||
|
||||
useVirtualAutoTrade({
|
||||
useVirtualBackendTradeSignals({
|
||||
enabled: paperTradingEnabled && paperAutoTradeEnabled,
|
||||
session,
|
||||
targets,
|
||||
snapshots,
|
||||
tickers,
|
||||
paperAutoTradeBudgetPct,
|
||||
positions: summary?.positions,
|
||||
|
||||
@@ -439,8 +439,8 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btd-table td.buy { color: var(--down, #3b82f6); font-weight: 700; }
|
||||
.btd-table td.sell { color: var(--up, #ef4444); font-weight: 700; }
|
||||
.btd-table td.buy { color: var(--gc-trade-buy); font-weight: 700; }
|
||||
.btd-table td.sell { color: var(--gc-trade-sell); font-weight: 700; }
|
||||
.btd-table td.up { color: #22c55e; font-weight: 700; }
|
||||
.btd-table td.down { color: #ef4444; font-weight: 700; }
|
||||
|
||||
|
||||
@@ -504,8 +504,8 @@
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.gc-signal-item--buy .gc-signal-arrow { color: #34d399; }
|
||||
.gc-signal-item--sell .gc-signal-arrow { color: #f87171; }
|
||||
.gc-signal-item--buy .gc-signal-arrow { color: var(--gc-trade-buy); }
|
||||
.gc-signal-item--sell .gc-signal-arrow { color: var(--gc-trade-sell); }
|
||||
|
||||
.gc-signal-main {
|
||||
display: flex;
|
||||
|
||||
@@ -821,34 +821,35 @@
|
||||
|
||||
/* 매수·매도 제출 버튼: 실시간 차트 우측 패널(.top-submit--buy/sell)과 동일 */
|
||||
.ptd-order-card .top-submit--buy {
|
||||
background: #ef5350;
|
||||
border-color: #ef5350;
|
||||
background: var(--gc-trade-buy);
|
||||
border-color: var(--gc-trade-buy);
|
||||
color: #fff;
|
||||
}
|
||||
.ptd-order-card .top-submit--buy:hover {
|
||||
background: #e53935;
|
||||
background: var(--gc-trade-buy-hover);
|
||||
}
|
||||
.ptd-order-card .top-submit--sell {
|
||||
background: #2962ff;
|
||||
border-color: #2962ff;
|
||||
background: var(--gc-trade-sell);
|
||||
border-color: var(--gc-trade-sell);
|
||||
}
|
||||
.ptd-order-card .top-submit--sell:hover {
|
||||
background: #1e53e5;
|
||||
background: var(--gc-trade-sell-hover);
|
||||
}
|
||||
|
||||
/* 매수·매도 카드 헤더: 실시간 차트 .rsp-trade-card--buy/sell 과 동일 */
|
||||
.ptd-split-panel--right .ptd-split-card--top {
|
||||
border-color: rgba(255, 107, 107, 0.35);
|
||||
background: linear-gradient(180deg, rgba(255, 107, 107, 0.08) 0%, var(--bg3) 48px);
|
||||
border-color: color-mix(in srgb, var(--gc-trade-buy) 40%, transparent);
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--gc-trade-buy) 10%, transparent) 0%, var(--bg3) 48px);
|
||||
}
|
||||
.ptd-split-panel--right .ptd-split-card--bottom {
|
||||
border-color: rgba(77, 171, 247, 0.35);
|
||||
background: linear-gradient(180deg, rgba(77, 171, 247, 0.08) 0%, var(--bg3) 48px);
|
||||
border-color: color-mix(in srgb, var(--gc-trade-sell) 40%, transparent);
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--gc-trade-sell) 10%, transparent) 0%, var(--bg3) 48px);
|
||||
}
|
||||
.ptd-split-panel--right .ptd-split-card--top .ptd-split-card-head {
|
||||
color: var(--up);
|
||||
color: var(--gc-trade-buy);
|
||||
}
|
||||
.ptd-split-panel--right .ptd-split-card--bottom .ptd-split-card-head {
|
||||
color: var(--down);
|
||||
color: var(--gc-trade-sell);
|
||||
}
|
||||
|
||||
.ptd-ob {
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
--se-gold: #e6c200;
|
||||
--se-success: #9ece6a;
|
||||
--se-danger: #f7768e;
|
||||
--se-buy: #7aa2f7;
|
||||
--se-sell: #f7768e;
|
||||
--se-buy: var(--gc-trade-buy, #ef5350);
|
||||
--se-sell: var(--gc-trade-sell, #4dabf7);
|
||||
--se-header-bg: linear-gradient(180deg, var(--bg2), var(--bg));
|
||||
--se-header-border: var(--border);
|
||||
--se-title-gradient: linear-gradient(90deg, #e6c200, #ffe566);
|
||||
@@ -156,8 +156,8 @@
|
||||
--se-gold: #f57f17;
|
||||
--se-success: #2e7d32;
|
||||
--se-danger: #c62828;
|
||||
--se-buy: #1976d2;
|
||||
--se-sell: #c62828;
|
||||
--se-buy: var(--gc-trade-buy, #ef5350);
|
||||
--se-sell: var(--gc-trade-sell, #4dabf7);
|
||||
--se-title-gradient: linear-gradient(90deg, #1565c0, #1976d2);
|
||||
--se-panel-card-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
--se-item-selected-bg: linear-gradient(135deg, rgba(25, 118, 210, 0.12), rgba(25, 118, 210, 0.04));
|
||||
@@ -228,8 +228,8 @@
|
||||
.se-page--blue {
|
||||
--se-gold: #ffd54f;
|
||||
--se-success: #69f0ae;
|
||||
--se-buy: #5eb5ff;
|
||||
--se-sell: #ff5555;
|
||||
--se-buy: var(--gc-trade-buy, #ef5350);
|
||||
--se-sell: var(--gc-trade-sell, #4dabf7);
|
||||
--se-title-gradient: linear-gradient(90deg, #ffd54f, #ffe082);
|
||||
--se-item-selected-bg: linear-gradient(135deg, rgba(255, 213, 79, 0.18), rgba(94, 181, 255, 0.08));
|
||||
--se-strat-item-bg: color-mix(in srgb, var(--bg2) 92%, transparent);
|
||||
|
||||
@@ -21,11 +21,11 @@
|
||||
}
|
||||
|
||||
.tnl-row.tnl-row--receiving .tnl-summary-card--buy {
|
||||
border-color: color-mix(in srgb, var(--accent, #3f7ef5) 35%, var(--se-border, var(--border)));
|
||||
border-color: color-mix(in srgb, var(--gc-trade-buy) 35%, var(--se-border, var(--border)));
|
||||
}
|
||||
|
||||
.tnl-row.tnl-row--receiving .tnl-summary-card--sell {
|
||||
border-color: color-mix(in srgb, #ef5350 35%, var(--se-border, var(--border)));
|
||||
border-color: color-mix(in srgb, var(--gc-trade-sell) 35%, var(--se-border, var(--border)));
|
||||
}
|
||||
|
||||
.tnl-row.tnl-row--receiving.tnl-row--selected .tnl-summary-card--selected {
|
||||
@@ -425,11 +425,11 @@
|
||||
}
|
||||
|
||||
.tnl-summary-card--buy {
|
||||
border-color: color-mix(in srgb, var(--accent, #3f7ef5) 35%, var(--se-border, var(--border)));
|
||||
border-color: color-mix(in srgb, var(--gc-trade-buy) 35%, var(--se-border, var(--border)));
|
||||
}
|
||||
|
||||
.tnl-summary-card--sell {
|
||||
border-color: color-mix(in srgb, #ef5350 35%, var(--se-border, var(--border)));
|
||||
border-color: color-mix(in srgb, var(--gc-trade-sell) 35%, var(--se-border, var(--border)));
|
||||
}
|
||||
|
||||
.tnl-summary-panel {
|
||||
@@ -530,11 +530,11 @@
|
||||
}
|
||||
|
||||
.tnl-summary-arrow--buy {
|
||||
color: var(--accent, #3f7ef5);
|
||||
color: var(--gc-trade-buy);
|
||||
}
|
||||
|
||||
.tnl-summary-arrow--sell {
|
||||
color: #ef5350;
|
||||
color: var(--gc-trade-sell);
|
||||
}
|
||||
|
||||
.tnl-summary-pair {
|
||||
@@ -551,11 +551,11 @@
|
||||
}
|
||||
|
||||
.tnl-summary-type--buy {
|
||||
color: var(--accent, #3f7ef5);
|
||||
color: var(--gc-trade-buy);
|
||||
}
|
||||
|
||||
.tnl-summary-type--sell {
|
||||
color: #ef5350;
|
||||
color: var(--gc-trade-sell);
|
||||
}
|
||||
|
||||
.tnl-summary-quote-right {
|
||||
@@ -574,11 +574,11 @@
|
||||
}
|
||||
|
||||
.tnl-summary-price--buy {
|
||||
color: var(--accent, #3f7ef5);
|
||||
color: var(--gc-trade-buy);
|
||||
}
|
||||
|
||||
.tnl-summary-price--sell {
|
||||
color: #ef5350;
|
||||
color: var(--gc-trade-sell);
|
||||
}
|
||||
|
||||
/* 3행+: 상세 정보 */
|
||||
|
||||
@@ -576,11 +576,11 @@
|
||||
}
|
||||
|
||||
.vtd-trade-item--buy {
|
||||
border-color: color-mix(in srgb, var(--up, #ef5350) 22%, var(--se-border));
|
||||
border-color: color-mix(in srgb, var(--gc-trade-buy) 22%, var(--se-border));
|
||||
}
|
||||
|
||||
.vtd-trade-item--sell {
|
||||
border-color: color-mix(in srgb, var(--down, #26a69a) 22%, var(--se-border));
|
||||
border-color: color-mix(in srgb, var(--gc-trade-sell) 22%, var(--se-border));
|
||||
}
|
||||
|
||||
.vtd-trade-side-badge {
|
||||
@@ -596,15 +596,15 @@
|
||||
}
|
||||
|
||||
.vtd-trade-side-badge--buy {
|
||||
color: var(--up, #ef5350);
|
||||
background: color-mix(in srgb, var(--up, #ef5350) 14%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--up, #ef5350) 28%, transparent);
|
||||
color: var(--gc-trade-buy);
|
||||
background: color-mix(in srgb, var(--gc-trade-buy) 14%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--gc-trade-buy) 28%, transparent);
|
||||
}
|
||||
|
||||
.vtd-trade-side-badge--sell {
|
||||
color: var(--down, #26a69a);
|
||||
background: color-mix(in srgb, var(--down, #26a69a) 14%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--down, #26a69a) 28%, transparent);
|
||||
color: var(--gc-trade-sell);
|
||||
background: color-mix(in srgb, var(--gc-trade-sell) 14%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--gc-trade-sell) 28%, transparent);
|
||||
}
|
||||
|
||||
.vtd-trade-quote {
|
||||
@@ -1322,9 +1322,13 @@
|
||||
}
|
||||
|
||||
/* 매수·매도 타이밍 — 카드 테두리 강조 비활성 (신호등·패널에서만 표시) */
|
||||
.vtd-card--timing-buy,
|
||||
.vtd-card--timing-buy {
|
||||
border-color: color-mix(in srgb, var(--gc-trade-buy) 40%, var(--se-border));
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.vtd-card--timing-sell {
|
||||
border-color: color-mix(in srgb, var(--se-gold) 35%, var(--se-border));
|
||||
border-color: color-mix(in srgb, var(--gc-trade-sell) 40%, var(--se-border));
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import { BollingerBandFillPrimitive } from './BollingerBandFillPrimitive';
|
||||
import { resolveBbBandBackground } from './bollingerConfig';
|
||||
import type { OHLCVBar, ChartType, Theme, IndicatorConfig, Timeframe } from '../types';
|
||||
import { formatChartAxisPrice, formatIndicatorAxisPrice } from './dataGenerator';
|
||||
import { TRADE_BUY_COLOR, TRADE_SELL_COLOR } from './tradeSignalColors';
|
||||
import {
|
||||
DEFAULT_CHART_TIME_FORMAT,
|
||||
formatUnixWithChartPattern,
|
||||
@@ -1181,7 +1182,7 @@ export class ChartManager {
|
||||
time: s.time,
|
||||
position: buy ? ('belowBar' as const) : ('aboveBar' as const),
|
||||
shape: buy ? ('arrowUp' as const) : ('arrowDown' as const),
|
||||
color: s.type === 'PARTIAL_SELL' ? '#FF9800' : (buy ? '#26A69A' : '#EF5350'),
|
||||
color: s.type === 'PARTIAL_SELL' ? '#FF9800' : (buy ? TRADE_BUY_COLOR : TRADE_SELL_COLOR),
|
||||
text,
|
||||
};
|
||||
});
|
||||
@@ -1213,7 +1214,7 @@ export class ChartManager {
|
||||
time: m.time,
|
||||
position: buy ? ('belowBar' as const) : ('aboveBar' as const),
|
||||
shape: buy ? ('arrowUp' as const) : ('arrowDown' as const),
|
||||
color: buy ? '#00BCD4' : '#FF9800', // 백테스팅과 구별: 청록/주황
|
||||
color: buy ? TRADE_BUY_COLOR : TRADE_SELL_COLOR,
|
||||
text,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 전략 편집기 DSL 직렬화 — DB 평가 원천은 buy/sell condition JSON 만.
|
||||
* flow_layout 은 UI(좌표·START 메타); Logic Expression 은 저장하지 않음.
|
||||
*/
|
||||
import { normalizeLogicRootForPersistence } from './strategyConditionNormalize';
|
||||
import type { LogicNode, ConditionDSL } from './strategyTypes';
|
||||
import { generateNodeId } from './strategyTypes';
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/** 프로젝트 공통 — 매매 시그널 색 (매수: 빨강, 매도: 파랑) */
|
||||
export const TRADE_BUY_COLOR = '#ef5350';
|
||||
export const TRADE_SELL_COLOR = '#4dabf7';
|
||||
export const TRADE_BUY_COLOR_HOVER = '#e53935';
|
||||
export const TRADE_SELL_COLOR_HOVER = '#339af0';
|
||||
@@ -1,7 +1,6 @@
|
||||
import { coerceFiniteNumber } from './safeFormat';
|
||||
import {
|
||||
formatIndicatorValue,
|
||||
isConditionMet,
|
||||
type VirtualConditionRow,
|
||||
} from './virtualStrategyConditions';
|
||||
|
||||
@@ -10,7 +9,7 @@ export type ConditionStatus = 'match' | 'pending' | 'nomatch' | 'unknown';
|
||||
export interface ConditionMetric {
|
||||
row: VirtualConditionRow & { currentValue: number | null };
|
||||
status: ConditionStatus;
|
||||
/** 조건 매칭율 0~100 */
|
||||
/** 조건 매칭율 0~100 — 백엔드 Ta4j satisfied 만 반영 */
|
||||
matchRate: number;
|
||||
/** @deprecated matchRate 와 동일 — 하위 호환 */
|
||||
progress: number | null;
|
||||
@@ -23,102 +22,28 @@ export function resolveHeatTier(matchRate: number, status: ConditionStatus): Hea
|
||||
if (status === 'match' || matchRate >= 100) return 'match';
|
||||
if (status === 'pending') return 'pending';
|
||||
if (status === 'nomatch') return 'nomatch';
|
||||
if (matchRate >= 45) return 'pending';
|
||||
return 'nomatch';
|
||||
}
|
||||
|
||||
export type TrafficLightState = 'red' | 'yellow' | 'blue';
|
||||
|
||||
const PENDING_PROGRESS = 72;
|
||||
|
||||
/** 목표값 대비 현재값 접근률 (0~100, 충족 시 100) */
|
||||
export function computeConditionProgress(
|
||||
conditionType: string,
|
||||
current: number | null | unknown,
|
||||
target: number | null | unknown,
|
||||
): number | null {
|
||||
const cur = coerceFiniteNumber(current);
|
||||
const tgt = coerceFiniteNumber(target);
|
||||
if (cur == null || tgt == null) return null;
|
||||
const met = isConditionMet(conditionType, cur, tgt);
|
||||
if (met === true) return 100;
|
||||
|
||||
const absTarget = Math.abs(tgt);
|
||||
const scale = absTarget > 1e-9 ? absTarget : Math.max(Math.abs(cur), 1);
|
||||
|
||||
switch (conditionType) {
|
||||
case 'LT':
|
||||
case 'CROSS_DOWN':
|
||||
case 'LTE': {
|
||||
if (cur <= tgt) return 100;
|
||||
const gap = cur - tgt;
|
||||
return Math.min(99, Math.max(0, 100 - (gap / scale) * 100));
|
||||
}
|
||||
case 'GT':
|
||||
case 'CROSS_UP':
|
||||
case 'GTE': {
|
||||
if (cur >= tgt) return 100;
|
||||
if (tgt <= 0 && cur <= 0) return 0;
|
||||
return Math.min(99, Math.max(0, (cur / scale) * 100));
|
||||
}
|
||||
case 'EQ': {
|
||||
const diff = Math.abs(cur - tgt);
|
||||
return Math.min(99, Math.max(0, 100 - (diff / scale) * 100));
|
||||
}
|
||||
default:
|
||||
return Math.min(99, Math.max(0, 100 - (Math.abs(cur - tgt) / scale) * 100));
|
||||
}
|
||||
}
|
||||
|
||||
const CROSS_CONDITION_TYPES = new Set([
|
||||
'CROSS_UP', 'CROSS_DOWN', 'SLOPE_UP', 'SLOPE_DOWN',
|
||||
]);
|
||||
|
||||
/** 조건별 매칭율 (0~100) — 백엔드 Ta4j satisfied 우선 */
|
||||
/** 조건별 매칭율 (0~100) — 백엔드 live-conditions 의 satisfied 만 사용 */
|
||||
export function computeConditionMatchRate(
|
||||
row: VirtualConditionRow & { currentValue: number | null },
|
||||
): number {
|
||||
if (row.satisfied === true) return 100;
|
||||
if (row.satisfied === false) {
|
||||
if (CROSS_CONDITION_TYPES.has(row.conditionType)) return 0;
|
||||
const progress = computeConditionProgress(
|
||||
row.conditionType,
|
||||
row.currentValue,
|
||||
row.targetValue,
|
||||
);
|
||||
if (progress == null) return 0;
|
||||
return Math.min(99, Math.max(0, Math.round(progress)));
|
||||
}
|
||||
if (row.currentValue == null) return 0;
|
||||
const progress = computeConditionProgress(
|
||||
row.conditionType,
|
||||
row.currentValue,
|
||||
row.targetValue,
|
||||
);
|
||||
if (progress == null) return 0;
|
||||
return Math.min(99, Math.max(0, Math.round(progress)));
|
||||
if (row.satisfied === false) return 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function getConditionStatus(
|
||||
conditionType: string,
|
||||
current: number | null,
|
||||
target: number | null,
|
||||
_conditionType: string,
|
||||
_current: number | null,
|
||||
_target: number | null,
|
||||
satisfied?: boolean | null,
|
||||
): ConditionStatus {
|
||||
if (satisfied === true) return 'match';
|
||||
if (satisfied === false) {
|
||||
if (CROSS_CONDITION_TYPES.has(conditionType)) return 'nomatch';
|
||||
const progress = computeConditionProgress(conditionType, current, target);
|
||||
if (progress != null && progress >= PENDING_PROGRESS) return 'pending';
|
||||
return 'nomatch';
|
||||
}
|
||||
const met = isConditionMet(conditionType, current, target);
|
||||
if (met === true) return 'match';
|
||||
if (met === false) {
|
||||
const progress = computeConditionProgress(conditionType, current, target);
|
||||
if (progress != null && progress >= PENDING_PROGRESS) return 'pending';
|
||||
return 'nomatch';
|
||||
}
|
||||
if (satisfied === false) return 'nomatch';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
@@ -196,35 +121,11 @@ export function computeMatchRate(
|
||||
|
||||
export function getTrafficLightState(matchRate: number, metrics: ConditionMetric[]): TrafficLightState {
|
||||
if (matchRate >= 100) return 'blue';
|
||||
const hasPending = metrics.some(m => m.status === 'pending');
|
||||
const hasPartial = metrics.some(m => m.status === 'match');
|
||||
if (hasPending || hasPartial || matchRate >= 40) return 'yellow';
|
||||
if (hasPartial || matchRate >= 40) return 'yellow';
|
||||
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 {
|
||||
if (!ts) return '—';
|
||||
return new Date(ts).toLocaleTimeString('ko-KR', {
|
||||
|
||||
@@ -83,26 +83,6 @@ export function extractVirtualConditions(strategy: StrategyDto | null | undefine
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 조건 충족 여부 (단순 임계값 비교) */
|
||||
export function isConditionMet(
|
||||
conditionType: string,
|
||||
current: number | null | unknown,
|
||||
target: number | null | unknown,
|
||||
): boolean | null {
|
||||
const cur = coerceFiniteNumber(current);
|
||||
const tgt = coerceFiniteNumber(target);
|
||||
if (cur == null || tgt == null) return null;
|
||||
switch (conditionType) {
|
||||
case 'GT': case 'CROSS_UP': return cur > tgt;
|
||||
case 'LT': case 'CROSS_DOWN': return cur < tgt;
|
||||
case 'GTE': return cur >= tgt;
|
||||
case 'LTE': return cur <= tgt;
|
||||
case 'EQ': return Math.abs(cur - tgt) < 1e-6;
|
||||
case 'NEQ': return Math.abs(cur - tgt) >= 1e-6;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatIndicatorValue(v: number | null | unknown): string {
|
||||
const n = coerceFiniteNumber(v);
|
||||
if (n == null) return '—';
|
||||
|
||||
Reference in New Issue
Block a user