가상 수정
This commit is contained in:
@@ -26,4 +26,6 @@ public class LiveConditionRowDto {
|
||||
private String timeframe;
|
||||
/** buy | sell */
|
||||
private String side;
|
||||
/** 지표 plot/leftField — 프론트 조건 병합용 */
|
||||
private String plotKey;
|
||||
}
|
||||
|
||||
@@ -197,6 +197,7 @@ public class LiveConditionStatusService {
|
||||
private LiveConditionRowDto toRowUnevaluated(PendingCond p) {
|
||||
JsonNode c = p.condition();
|
||||
String indType = c.path("indicatorType").asText("");
|
||||
String plotKey = plotKeyFromCondition(c, indType);
|
||||
String condType = c.path("conditionType").asText("GT");
|
||||
String condLabel = CONDITION_LABEL.getOrDefault(condType, condType);
|
||||
Double target = readTargetNumeric(c);
|
||||
@@ -212,6 +213,7 @@ public class LiveConditionStatusService {
|
||||
.satisfied(null)
|
||||
.timeframe(p.timeframe())
|
||||
.side(p.side())
|
||||
.plotKey(plotKey)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -240,6 +242,7 @@ public class LiveConditionStatusService {
|
||||
Map<String, Map<String, Object>> params, int index) {
|
||||
JsonNode c = p.condition();
|
||||
String indType = c.path("indicatorType").asText("");
|
||||
String plotKey = plotKeyFromCondition(c, indType);
|
||||
String condType = c.path("conditionType").asText("GT");
|
||||
String condLabel = CONDITION_LABEL.getOrDefault(condType, condType);
|
||||
|
||||
@@ -255,6 +258,7 @@ public class LiveConditionStatusService {
|
||||
.satisfied(satisfied)
|
||||
.timeframe(p.timeframe())
|
||||
.side(p.side())
|
||||
.plotKey(plotKey)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -310,4 +314,12 @@ public class LiveConditionStatusService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String plotKeyFromCondition(JsonNode cond, String indicatorType) {
|
||||
String left = cond.path("leftField").asText("");
|
||||
if (left != null && !left.isBlank() && !"none".equalsIgnoreCase(left)) {
|
||||
return left;
|
||||
}
|
||||
return indicatorType;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -261,6 +261,7 @@ const VirtualTradingPage: React.FC<Props> = ({
|
||||
theme={theme}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
tickers={tickers}
|
||||
/>
|
||||
)}
|
||||
rightTabs={rightTabs}
|
||||
|
||||
@@ -3,24 +3,28 @@ import type { ConditionStatus } from '../../utils/virtualSignalMetrics';
|
||||
|
||||
interface Props {
|
||||
status: ConditionStatus;
|
||||
/** 조건 매칭율 0~100 */
|
||||
matchRate?: number | null;
|
||||
/** @deprecated matchRate 사용 */
|
||||
progress?: number | null;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const LABELS: Record<ConditionStatus, string> = {
|
||||
match: 'Match',
|
||||
pending: 'Pending',
|
||||
nomatch: 'Mismatch',
|
||||
match: '충족',
|
||||
pending: '대기',
|
||||
nomatch: '미충족',
|
||||
unknown: '—',
|
||||
};
|
||||
|
||||
const ConditionStatusSignal: React.FC<Props> = ({ status, progress, compact = false }) => {
|
||||
const ConditionStatusSignal: React.FC<Props> = ({ status, matchRate, progress, compact = false }) => {
|
||||
if (status === 'unknown') {
|
||||
return <span className="vtd-cond-signal vtd-cond-signal--unknown">—</span>;
|
||||
}
|
||||
|
||||
const pct = progress != null && Number.isFinite(progress)
|
||||
? `${Math.round(progress)}%`
|
||||
const rate = matchRate ?? progress;
|
||||
const pct = rate != null && Number.isFinite(rate)
|
||||
? `${Math.round(rate)}%`
|
||||
: null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,26 +1,70 @@
|
||||
import React from 'react';
|
||||
import type { ConditionMetric } from '../../utils/virtualSignalMetrics';
|
||||
import ConditionStatusSignal from './ConditionStatusSignal';
|
||||
import { resolveHeatTier } from '../../utils/virtualSignalMetrics';
|
||||
|
||||
interface Props {
|
||||
metrics: ConditionMetric[];
|
||||
}
|
||||
|
||||
const IconMismatch = () => (
|
||||
<svg className="vtd-heat-icon-svg" width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconPending = () => (
|
||||
<svg className="vtd-heat-icon-svg" width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M12 9v4" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
<path
|
||||
d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconMatch = () => (
|
||||
<svg className="vtd-heat-icon-svg" width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<polyline points="20 6 9 17 4 12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const VirtualConditionList: React.FC<Props> = ({ metrics }) => {
|
||||
if (metrics.length === 0) {
|
||||
return <p className="vtd-cond-list-empty vtd-muted">조건 없음</p>;
|
||||
return <p className="vtd-heat-list-empty vtd-muted">조건 없음</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="vtd-cond-list" aria-label="전략 조건 목록">
|
||||
{metrics.map(({ row, status, progress }) => (
|
||||
<li key={row.id} className="vtd-cond-list-item">
|
||||
<span className="vtd-cond-list-name" title={row.displayName}>
|
||||
<ul className="vtd-heat-list" aria-label="전략 지표 매치율">
|
||||
{metrics.map(({ row, status, matchRate }) => {
|
||||
const tier = resolveHeatTier(matchRate, status);
|
||||
const pct = Math.min(100, Math.max(0, matchRate));
|
||||
const minFill = pct > 0 && tier === 'nomatch' ? 6 : pct > 0 ? 6 : 0;
|
||||
return (
|
||||
<li key={row.id} className={`vtd-heat-row vtd-heat-row--${tier}`}>
|
||||
<div
|
||||
className="vtd-heat-track"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={pct}
|
||||
aria-label={`${row.displayName} 매치율 ${pct}%`}
|
||||
>
|
||||
<span className="vtd-heat-label" title={row.displayName}>
|
||||
{row.displayName}
|
||||
</span>
|
||||
<ConditionStatusSignal status={status} progress={progress} compact />
|
||||
<div className="vtd-heat-fill" style={{ width: `${Math.max(pct, minFill)}%` }} />
|
||||
<span className="vtd-heat-pct">{pct}%</span>
|
||||
</div>
|
||||
<span className="vtd-heat-icon" aria-hidden>
|
||||
{tier === 'match' ? <IconMatch /> : tier === 'pending' ? <IconPending /> : <IconMismatch />}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import type { ConditionMetric } from '../../utils/virtualSignalMetrics';
|
||||
import { formatStrategyThreshold } from '../../utils/virtualSignalMetrics';
|
||||
import { formatStrategyThreshold, resolveHeatTier } from '../../utils/virtualSignalMetrics';
|
||||
import { formatIndicatorValue } from '../../utils/virtualStrategyConditions';
|
||||
import ConditionStatusSignal from './ConditionStatusSignal';
|
||||
|
||||
@@ -10,19 +10,28 @@ interface Props {
|
||||
|
||||
const VirtualIndicatorCompareTable: React.FC<Props> = ({ metrics }) => (
|
||||
<div className="vtd-sig-table-wrap">
|
||||
<div className="vtd-sig-table-head">REAL-TIME INDICATOR COMPARISON</div>
|
||||
<div className="vtd-sig-table-head">실시간 지표 비교</div>
|
||||
<table className="vtd-sig-table">
|
||||
<colgroup>
|
||||
<col className="vtd-sig-col vtd-sig-col--ind" />
|
||||
<col className="vtd-sig-col vtd-sig-col--val" />
|
||||
<col className="vtd-sig-col vtd-sig-col--threshold" />
|
||||
<col className="vtd-sig-col vtd-sig-col--progress" />
|
||||
<col className="vtd-sig-col vtd-sig-col--status" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>TECHNICAL INDICATOR</th>
|
||||
<th>CURRENT VALUE</th>
|
||||
<th>STRATEGY THRESHOLD</th>
|
||||
<th>PROGRESS</th>
|
||||
<th>STATUS</th>
|
||||
<th className="vtd-sig-table-th--ind">기술적 지표</th>
|
||||
<th className="vtd-sig-table-th--val">현재값</th>
|
||||
<th className="vtd-sig-table-th--threshold">전략 조건</th>
|
||||
<th className="vtd-sig-table-th--progress">진행률</th>
|
||||
<th className="vtd-sig-table-th--status">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{metrics.map(({ row, status, progress }) => (
|
||||
{metrics.map(({ row, status, matchRate }) => {
|
||||
const tier = resolveHeatTier(matchRate, status);
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
<td className="vtd-sig-table-ind">{row.displayName}</td>
|
||||
<td className="vtd-sig-table-val">{formatIndicatorValue(row.currentValue)}</td>
|
||||
@@ -31,17 +40,20 @@ const VirtualIndicatorCompareTable: React.FC<Props> = ({ metrics }) => (
|
||||
?? formatStrategyThreshold(row.conditionType, row.targetValue, row.conditionLabel)}
|
||||
</td>
|
||||
<td className="vtd-sig-table-progress">
|
||||
<div className="vtd-sig-row-bar">
|
||||
<div className="vtd-sig-row-bar vtd-sig-row-bar--heat">
|
||||
<div
|
||||
className={`vtd-sig-row-bar-fill vtd-sig-row-bar-fill--${status}`}
|
||||
style={{ width: `${progress ?? 0}%` }}
|
||||
className={`vtd-sig-row-bar-fill vtd-sig-row-bar-fill--${tier}`}
|
||||
style={{ width: `${matchRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="vtd-sig-row-pct">{progress != null ? `${Math.round(progress)}%` : '—'}</span>
|
||||
<span className="vtd-sig-table-pct">{`${matchRate}%`}</span>
|
||||
</td>
|
||||
<td className="vtd-sig-table-status-cell">
|
||||
<ConditionStatusSignal status={status} matchRate={matchRate} compact />
|
||||
</td>
|
||||
<td><ConditionStatusSignal status={status} progress={progress} /></td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage';
|
||||
import VirtualTargetCardChart from './VirtualTargetCardChart';
|
||||
import VirtualTargetSignalPanel from './VirtualTargetSignalPanel';
|
||||
import type { Theme } from '../../types';
|
||||
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||
import VirtualTargetQuote from './VirtualTargetQuote';
|
||||
|
||||
export type VirtualCardDisplayMode = 'signal' | 'chart';
|
||||
|
||||
@@ -34,6 +36,7 @@ interface Props {
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
ticker?: TickerData;
|
||||
}
|
||||
|
||||
const VirtualTargetCard: React.FC<Props> = ({
|
||||
@@ -56,6 +59,7 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
ticker,
|
||||
}) => {
|
||||
const ko = getKoreanName(market);
|
||||
const sym = market.replace(/^KRW-/, '');
|
||||
@@ -122,6 +126,7 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span className="vtd-card-tf">시간봉 {tf}</span>
|
||||
</div>
|
||||
<div className="vtd-card-head-actions">
|
||||
{onEnterFocus && (
|
||||
@@ -162,7 +167,7 @@ const VirtualTargetCard: React.FC<Props> = ({
|
||||
</div>
|
||||
|
||||
<div className="vtd-card-meta">
|
||||
<span className="vtd-card-tf">시간봉 {tf}</span>
|
||||
<VirtualTargetQuote market={market} ticker={ticker} compact />
|
||||
{!isChart && isDetail && evalCount > 0 && (
|
||||
<span className="vtd-card-met-count">{metCount}/{evalCount} 조건 충족</span>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { ChartRealtimeSource } from '../../hooks/useChartRealtimeData';
|
||||
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
||||
import type { VirtualCardViewMode } from '../../utils/virtualTradingStorage';
|
||||
import type { Theme } from '../../types';
|
||||
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||
import VirtualLiveBadge from './VirtualLiveBadge';
|
||||
import VirtualTargetQuote from './VirtualTargetQuote';
|
||||
import VirtualTargetCardChart from './VirtualTargetCardChart';
|
||||
import VirtualTargetSignalPanel from './VirtualTargetSignalPanel';
|
||||
|
||||
@@ -27,6 +29,7 @@ interface Props {
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
ticker?: TickerData;
|
||||
}
|
||||
|
||||
/** 전체보기 — 좌 차트 · 우 신호 */
|
||||
@@ -46,6 +49,7 @@ const VirtualTargetFocusView: React.FC<Props> = ({
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
ticker,
|
||||
}) => {
|
||||
const ko = getKoreanName(market);
|
||||
const sym = market.replace(/^KRW-/, '');
|
||||
@@ -100,6 +104,10 @@ const VirtualTargetFocusView: React.FC<Props> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="vtd-focus-meta">
|
||||
<VirtualTargetQuote market={market} ticker={ticker} compact />
|
||||
</div>
|
||||
|
||||
<div className="vtd-focus-split">
|
||||
<section className="vtd-focus-pane vtd-focus-pane--chart" aria-label="차트 보기">
|
||||
<div className="vtd-focus-pane-head">차트</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { VirtualTargetItem, VirtualCardViewMode, VirtualSessionConfig } fro
|
||||
import { loadVirtualCardViewMode, saveVirtualCardViewMode } from '../../utils/virtualTradingStorage';
|
||||
import type { VirtualLiveStatus } from '../../hooks/useVirtualTargetLiveStatus';
|
||||
import type { VirtualIndicatorSnapshot } from '../../hooks/useVirtualIndicatorSnapshots';
|
||||
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||
|
||||
interface Props {
|
||||
targets: VirtualTargetItem[];
|
||||
@@ -28,6 +29,7 @@ interface Props {
|
||||
theme?: Theme;
|
||||
chartRealtimeSource?: ChartRealtimeSource;
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
tickers?: Map<string, TickerData>;
|
||||
}
|
||||
|
||||
const VirtualTargetGrid: React.FC<Props> = ({
|
||||
@@ -39,6 +41,7 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
theme = 'dark',
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
chartSeriesPriceLabels = true,
|
||||
tickers,
|
||||
}) => {
|
||||
const [viewMode, setViewMode] = useState<VirtualCardViewMode>(() => loadVirtualCardViewMode());
|
||||
const [globalDisplayMode, setGlobalDisplayMode] = useState<VirtualCardDisplayMode>('signal');
|
||||
@@ -137,6 +140,7 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
theme={theme}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
ticker={tickers?.get(focusTarget.market)}
|
||||
/>
|
||||
) : (
|
||||
<div className="vtd-grid">
|
||||
@@ -164,6 +168,7 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
||||
theme={theme}
|
||||
chartRealtimeSource={chartRealtimeSource}
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
ticker={tickers?.get(t.market)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -25,9 +25,11 @@ const ChangeArrow = memo(function ChangeArrow({ change }: { change: ChangeDir })
|
||||
interface Props {
|
||||
market: string;
|
||||
ticker?: TickerData;
|
||||
/** 카드 메타 행 — 현재가만 간략 표시 */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const VirtualTargetQuote: React.FC<Props> = ({ market, ticker }) => {
|
||||
const VirtualTargetQuote: React.FC<Props> = ({ market, ticker, compact = false }) => {
|
||||
const code = market.replace(/^KRW-/, '');
|
||||
const change: ChangeDir = ticker?.change ?? 'EVEN';
|
||||
const colorCls = change === 'RISE' ? 'vtd-target-up' : change === 'FALL' ? 'vtd-target-dn' : 'vtd-target-even';
|
||||
@@ -49,6 +51,20 @@ const VirtualTargetQuote: React.FC<Props> = ({ market, ticker }) => {
|
||||
prevPriceRef.current = price;
|
||||
}, [ticker?.tradePrice]);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={`vtd-target-quote vtd-target-quote--compact ${flashClass}`.trim()} aria-label="현재가">
|
||||
<span className="vtd-card-price-label">현재가</span>
|
||||
<span className={`vtd-target-price ${colorCls}`}>
|
||||
{ticker ? fmtKrw(ticker.tradePrice) : '-'}
|
||||
</span>
|
||||
<span className={`vtd-target-change ${colorCls}`}>
|
||||
{ticker ? fmtPct(ticker.changeRate) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`vtd-target-quote ${flashClass}`.trim()} aria-label="현재가 및 전일 대비">
|
||||
<div className="vtd-target-quote-left">
|
||||
|
||||
@@ -34,8 +34,6 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
|
||||
[metrics, snapshot?.matchRate],
|
||||
);
|
||||
const trafficState = useMemo(() => getTrafficLightState(matchRate, metrics), [matchRate, metrics]);
|
||||
const metCount = metrics.filter(m => m.status === 'match').length;
|
||||
const evalCount = metrics.filter(m => m.status !== 'unknown').length;
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
@@ -49,17 +47,12 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
|
||||
<div
|
||||
className={[
|
||||
'vtd-sig-panel-wrap',
|
||||
isDetail ? '' : 'vtd-sig-panel-wrap--summary',
|
||||
isDetail ? 'vtd-sig-panel-wrap--detail' : 'vtd-sig-panel-wrap--summary',
|
||||
receiving ? 'vtd-sig-panel-wrap--receiving' : '',
|
||||
className,
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{isDetail && evalCount > 0 && (
|
||||
<div className="vtd-focus-sig-meta">
|
||||
<span className="vtd-card-met-count">{metCount}/{evalCount} 조건 충족</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`vtd-sig-panel${isDetail ? '' : ' vtd-sig-panel--summary'}`}>
|
||||
<div className={`vtd-sig-panel${isDetail ? ' vtd-sig-panel--detail-top' : ' vtd-sig-panel--summary'}`}>
|
||||
<div className="vtd-sig-panel-title">SIGNAL INTELLIGENCE & MATCH RATES</div>
|
||||
<div className="vtd-sig-visual">
|
||||
<div className="vtd-sig-visual-eq">
|
||||
@@ -68,8 +61,12 @@ const VirtualTargetSignalPanel: React.FC<Props> = ({
|
||||
<VirtualConditionList metrics={metrics} />
|
||||
<VirtualSignalTrafficLight state={trafficState} matchRate={matchRate} />
|
||||
</div>
|
||||
{isDetail && <VirtualIndicatorCompareTable metrics={metrics} />}
|
||||
</div>
|
||||
{isDetail && (
|
||||
<div className="vtd-sig-detail-table" aria-label="지표별 상태 목록">
|
||||
<VirtualIndicatorCompareTable metrics={metrics} />
|
||||
</div>
|
||||
)}
|
||||
<div className="vtd-card-foot">
|
||||
<span className="vtd-card-updated">갱신 {formatUpdatedTime(snapshot?.updatedAt)}</span>
|
||||
<span className="vtd-card-match-summary">매매조건 일치율 {matchRate}%</span>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
pinCandleWatch,
|
||||
type LiveConditionRowDto,
|
||||
} from '../utils/backendApi';
|
||||
import { formatIndicatorDisplayLabel } from '../utils/indicatorRegistry';
|
||||
import {
|
||||
extractVirtualConditions,
|
||||
type VirtualConditionRow,
|
||||
@@ -29,7 +30,26 @@ function rowFallbackKey(row: Pick<VirtualConditionRow, 'side' | 'timeframe' | 'i
|
||||
}
|
||||
|
||||
function liveFallbackKey(r: LiveConditionRowDto): string {
|
||||
return `${r.side}:${r.timeframe}:${r.indicatorType}:${r.conditionType}:${r.targetValue ?? ''}:${r.indicatorType}`;
|
||||
const plotKey = r.plotKey ?? r.indicatorType;
|
||||
return `${r.side}:${r.timeframe}:${r.indicatorType}:${r.conditionType}:${r.targetValue ?? ''}:${plotKey}`;
|
||||
}
|
||||
|
||||
function liveRowToVirtual(r: LiveConditionRowDto): VirtualConditionRow & { currentValue: number | null } {
|
||||
const plotKey = r.plotKey ?? r.indicatorType;
|
||||
return {
|
||||
id: r.id,
|
||||
indicatorType: r.indicatorType,
|
||||
displayName: formatIndicatorDisplayLabel(r.indicatorType),
|
||||
conditionType: r.conditionType,
|
||||
conditionLabel: r.conditionLabel,
|
||||
targetValue: r.targetValue,
|
||||
timeframe: r.timeframe,
|
||||
side: r.side,
|
||||
plotKey,
|
||||
satisfied: r.satisfied,
|
||||
thresholdLabel: r.thresholdLabel,
|
||||
currentValue: r.currentValue,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeRows(
|
||||
@@ -44,26 +64,13 @@ function mergeRows(
|
||||
}
|
||||
|
||||
if (base.length === 0) {
|
||||
return live.map(r => ({
|
||||
id: r.id,
|
||||
indicatorType: r.indicatorType,
|
||||
displayName: r.displayName,
|
||||
conditionType: r.conditionType,
|
||||
conditionLabel: r.conditionLabel,
|
||||
targetValue: r.targetValue,
|
||||
timeframe: r.timeframe,
|
||||
side: r.side,
|
||||
plotKey: r.indicatorType,
|
||||
satisfied: r.satisfied,
|
||||
thresholdLabel: r.thresholdLabel,
|
||||
currentValue: r.currentValue,
|
||||
}));
|
||||
return live.map(liveRowToVirtual);
|
||||
}
|
||||
|
||||
return base.map(row => {
|
||||
const liveRow = liveById.get(row.id) ?? liveByKey.get(rowFallbackKey(row));
|
||||
if (!liveRow) {
|
||||
return { ...row, currentValue: null as number | null };
|
||||
return { ...row, currentValue: null as number | null, satisfied: null };
|
||||
}
|
||||
return {
|
||||
...row,
|
||||
@@ -89,20 +96,20 @@ async function buildSnapshot(
|
||||
market,
|
||||
strategyId,
|
||||
timeframe: [...new Set(baseRows.map(r => r.timeframe))].join(', '),
|
||||
rows: baseRows.map(r => ({ ...r, currentValue: null })),
|
||||
rows: baseRows.map(r => ({ ...r, currentValue: null, satisfied: null })),
|
||||
updatedAt: Date.now(),
|
||||
matchRate: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const status = await fetchLiveConditionStatus(market, strategyId);
|
||||
if (!status || status.market !== market) {
|
||||
if (!status || status.market !== market || status.strategyId !== strategyId) {
|
||||
if (baseRows.length === 0) return null;
|
||||
return {
|
||||
market,
|
||||
strategyId,
|
||||
timeframe: [...new Set(baseRows.map(r => r.timeframe))].join(', '),
|
||||
rows: baseRows.map(r => ({ ...r, currentValue: null })),
|
||||
rows: baseRows.map(r => ({ ...r, currentValue: null, satisfied: null })),
|
||||
updatedAt: Date.now(),
|
||||
matchRate: 0,
|
||||
};
|
||||
@@ -133,6 +140,7 @@ export function useVirtualIndicatorSnapshots(
|
||||
const strategiesRef = useRef(strategies);
|
||||
strategiesRef.current = strategies;
|
||||
const pinnedRef = useRef<Set<string>>(new Set());
|
||||
const refreshGenRef = useRef<Record<string, number>>({});
|
||||
const targetsKey = targets.map(t => `${t.market}:${t.strategyId ?? ''}`).join('|');
|
||||
|
||||
const pinTargets = useCallback(async () => {
|
||||
@@ -173,16 +181,17 @@ export function useVirtualIndicatorSnapshots(
|
||||
const jobs = targets
|
||||
.filter(t => t.strategyId != null)
|
||||
.map(async t => {
|
||||
const gen = (refreshGenRef.current[t.market] ?? 0) + 1;
|
||||
refreshGenRef.current[t.market] = gen;
|
||||
const strat = strats.find(s => s.id === t.strategyId);
|
||||
const snap = await buildSnapshot(t.market, t.strategyId!, strat, running);
|
||||
if (snap) {
|
||||
if (!snap || refreshGenRef.current[t.market] !== gen) return snap;
|
||||
if (snap.strategyId !== t.strategyId) return snap;
|
||||
setSnapshots(prev => ({ ...prev, [snap.market]: snap }));
|
||||
}
|
||||
return snap;
|
||||
});
|
||||
await Promise.all(jobs);
|
||||
|
||||
// 제거된 종목 스냅샷 정리
|
||||
const activeMarkets = new Set(targets.map(t => t.market));
|
||||
setSnapshots(prev => {
|
||||
const next = { ...prev };
|
||||
@@ -201,6 +210,7 @@ export function useVirtualIndicatorSnapshots(
|
||||
if (targets.length === 0) {
|
||||
setSnapshots({});
|
||||
pinnedRef.current.clear();
|
||||
refreshGenRef.current = {};
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
|
||||
@@ -684,8 +684,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ptd-order-card .top-submit--buy { background: #22c55e !important; border-color: #22c55e !important; }
|
||||
.ptd-order-card .top-submit--sell { background: var(--accent) !important; border-color: var(--accent) !important; }
|
||||
/* 매수·매도 제출 버튼: 실시간 차트 우측 패널(.top-submit--buy/sell)과 동일 */
|
||||
.ptd-order-card .top-submit--buy {
|
||||
background: #ef5350;
|
||||
border-color: #ef5350;
|
||||
}
|
||||
.ptd-order-card .top-submit--buy:hover {
|
||||
background: #e53935;
|
||||
}
|
||||
.ptd-order-card .top-submit--sell {
|
||||
background: #2962ff;
|
||||
border-color: #2962ff;
|
||||
}
|
||||
.ptd-order-card .top-submit--sell:hover {
|
||||
background: #1e53e5;
|
||||
}
|
||||
|
||||
/* 매수·매도 카드 헤더: 실시간 차트 .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);
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.ptd-split-panel--right .ptd-split-card--top .ptd-split-card-head {
|
||||
color: var(--up);
|
||||
}
|
||||
.ptd-split-panel--right .ptd-split-card--bottom .ptd-split-card-head {
|
||||
color: var(--down);
|
||||
}
|
||||
|
||||
.ptd-ob {
|
||||
background: var(--bg2);
|
||||
|
||||
@@ -536,6 +536,18 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vtd-focus-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vtd-focus-meta .vtd-target-quote--compact {
|
||||
margin-top: 0;
|
||||
padding: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.vtd-focus-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -612,7 +624,12 @@
|
||||
}
|
||||
|
||||
.vtd-focus-pane--signal .vtd-focus-pane-body {
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vtd-focus-pane--signal .vtd-focus-pane-body > .vtd-sig-panel-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.vtd-sig-panel-wrap {
|
||||
@@ -623,6 +640,73 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.vtd-sig-panel-wrap .vtd-card-foot {
|
||||
margin-top: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 상세표시 — 상단 고정 + 중간 테이블 스크롤 + 푸터는 wrap 하단(margin-top:auto) */
|
||||
.vtd-sig-panel-wrap--detail {
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vtd-sig-panel-wrap--detail > .vtd-sig-panel--detail-top {
|
||||
flex: 0 0 auto;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vtd-sig-panel-wrap--detail .vtd-sig-panel--detail-top .vtd-sig-visual {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vtd-sig-panel-wrap--detail .vtd-sig-detail-table {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.vtd-sig-panel-wrap--detail .vtd-sig-detail-table .vtd-sig-table-wrap {
|
||||
margin: 0;
|
||||
min-width: min(100%, 320px);
|
||||
}
|
||||
|
||||
/* 전체보기(분할) 등 좁은 패널: 테이블만 내부 스크롤 */
|
||||
.vtd-focus-pane--signal .vtd-sig-panel-wrap--detail .vtd-sig-detail-table {
|
||||
flex: 1 1 0;
|
||||
min-height: 100px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.vtd-card--detail .vtd-card-meta {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 6px 10px;
|
||||
}
|
||||
|
||||
.vtd-card--detail .vtd-card-meta .vtd-target-quote--compact {
|
||||
grid-column: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vtd-card--detail .vtd-card-met-count {
|
||||
grid-column: 2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vtd-card--detail .vtd-card-meta .vtd-live-badge {
|
||||
grid-column: 3;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.vtd-sig-panel-wrap--receiving .vtd-sig-panel {
|
||||
box-shadow: inset 0 0 24px color-mix(in srgb, #b9f6ca 8%, transparent);
|
||||
}
|
||||
@@ -756,6 +840,7 @@
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-auto-rows: minmax(520px, auto);
|
||||
gap: 14px;
|
||||
padding: 4px 4px 12px;
|
||||
overflow-y: auto;
|
||||
@@ -763,6 +848,7 @@
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
align-content: start;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
@@ -787,6 +873,46 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.vtd-card:not(.vtd-card--chart-mode):not(.vtd-card--detail) {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.vtd-card > .vtd-sig-panel-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 상세 카드 — 그리드 행 높이에 맞춰 동일 높이, 푸터는 카드 하단 고정 */
|
||||
.vtd-card.vtd-card--detail {
|
||||
height: 100%;
|
||||
align-self: stretch;
|
||||
overflow: hidden;
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.vtd-card.vtd-card--detail > .vtd-sig-panel-wrap--detail {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vtd-card.vtd-card--detail .vtd-sig-panel.vtd-sig-panel--detail-top {
|
||||
flex: 0 0 auto;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vtd-card.vtd-card--detail .vtd-sig-panel-wrap--detail .vtd-sig-panel--detail-top .vtd-heat-list {
|
||||
max-height: 168px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.vtd-card.vtd-card--detail .vtd-sig-panel-wrap .vtd-card-foot {
|
||||
margin-top: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vtd-card--signal {
|
||||
@@ -898,6 +1024,14 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.vtd-card-tf {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--se-text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vtd-card-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1064,6 +1198,29 @@
|
||||
color: var(--se-text-muted);
|
||||
}
|
||||
|
||||
.vtd-card-meta .vtd-target-quote--compact {
|
||||
margin-top: 0;
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.vtd-card-price-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--se-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vtd-card-meta .vtd-target-quote--compact .vtd-target-price {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.vtd-card-meta .vtd-target-quote--compact .vtd-target-change {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.vtd-card-empty {
|
||||
min-height: 120px;
|
||||
display: flex;
|
||||
@@ -1155,36 +1312,53 @@
|
||||
max-height: 160px;
|
||||
}
|
||||
|
||||
.vtd-card--summary .vtd-card-foot {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.vtd-card-met-count {
|
||||
color: color-mix(in srgb, #c9a227 80%, var(--se-text));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vtd-card--detail .vtd-card-met-count {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: color-mix(in srgb, #f0c040 85%, #fff);
|
||||
}
|
||||
|
||||
.vtd-card-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
color: var(--se-text-muted);
|
||||
padding-top: 4px;
|
||||
padding-top: 8px;
|
||||
margin-top: auto;
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--se-border);
|
||||
}
|
||||
|
||||
.vtd-card-match-summary {
|
||||
font-weight: 700;
|
||||
color: var(--accent, #3f7ef5);
|
||||
}
|
||||
|
||||
/* 시그널 일치율 패널 — 카드 높이 고정·축소 방지, 전체 UI 항상 표시 */
|
||||
/* 시그널 일치율 패널 — 요약: 스크롤 / 상세: 상단 고정 높이 */
|
||||
.vtd-sig-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.vtd-sig-panel--summary {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.vtd-sig-panel--detail-top {
|
||||
flex: 0 0 auto;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
.vtd-card-match-summary {
|
||||
font-weight: 700;
|
||||
color: var(--accent, #3f7ef5);
|
||||
}
|
||||
|
||||
.vtd-sig-panel-title {
|
||||
@@ -1283,7 +1457,184 @@
|
||||
text-shadow: 0 0 8px color-mix(in srgb, #82b1ff 60%, transparent);
|
||||
}
|
||||
|
||||
/* 조건 목록 (이퀄라이저 ↔ 신호등 사이) */
|
||||
/* Proximity Heatmap Bar — 이퀄라이저 ↔ 신호등 사이 (Cybernetic Command Desk) */
|
||||
.vtd-heat-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 2px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 188px;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.vtd-heat-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.vtd-heat-list::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, #c9a227 35%, var(--se-border));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.vtd-heat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vtd-heat-track {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 26px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--se-border) 70%, #3a4a6b);
|
||||
background: color-mix(in srgb, #060a14 85%, #000);
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.vtd-heat-label {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 2;
|
||||
max-width: calc(100% - 48px);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: color-mix(in srgb, var(--se-text) 88%, #fff);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 0 6px rgba(0, 0, 0, 0.9), 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.vtd-heat-fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
min-width: 0;
|
||||
border-radius: 7px 0 0 7px;
|
||||
transition: width 0.35s ease, box-shadow 0.35s ease, background 0.35s ease;
|
||||
}
|
||||
|
||||
.vtd-heat-pct {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 2;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 0 8px rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.vtd-heat-icon {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vtd-heat-icon-svg {
|
||||
display: block;
|
||||
filter: drop-shadow(0 0 4px currentColor);
|
||||
}
|
||||
|
||||
/* Mismatch — 낮음 (빨강) */
|
||||
.vtd-heat-row--nomatch .vtd-heat-track {
|
||||
border-color: color-mix(in srgb, #ef5350 40%, var(--se-border));
|
||||
box-shadow:
|
||||
inset 0 1px 4px rgba(0, 0, 0, 0.45),
|
||||
0 0 8px color-mix(in srgb, #ef5350 15%, transparent);
|
||||
}
|
||||
|
||||
.vtd-heat-row--nomatch .vtd-heat-fill {
|
||||
background: linear-gradient(90deg, #b71c1c 0%, #ef5350 55%, #ff8a80 100%);
|
||||
box-shadow:
|
||||
0 0 12px color-mix(in srgb, #ef5350 50%, transparent),
|
||||
inset 0 0 8px color-mix(in srgb, #ffcdd2 20%, transparent);
|
||||
}
|
||||
|
||||
.vtd-heat-row--nomatch .vtd-heat-pct {
|
||||
color: #ff8a80;
|
||||
}
|
||||
|
||||
.vtd-heat-row--nomatch .vtd-heat-icon {
|
||||
color: #ef5350;
|
||||
}
|
||||
|
||||
/* Pending — 중간 (오렌지) */
|
||||
.vtd-heat-row--pending .vtd-heat-track {
|
||||
border-color: color-mix(in srgb, #ffb300 45%, var(--se-border));
|
||||
box-shadow:
|
||||
inset 0 1px 4px rgba(0, 0, 0, 0.45),
|
||||
0 0 10px color-mix(in srgb, #ffb300 18%, transparent);
|
||||
}
|
||||
|
||||
.vtd-heat-row--pending .vtd-heat-fill {
|
||||
background: linear-gradient(90deg, #e65100 0%, #ffb300 45%, #ffe082 100%);
|
||||
box-shadow:
|
||||
0 0 14px color-mix(in srgb, #ffb300 45%, transparent),
|
||||
inset 0 0 10px color-mix(in srgb, #fff59d 18%, transparent);
|
||||
}
|
||||
|
||||
.vtd-heat-row--pending .vtd-heat-pct {
|
||||
color: #ffe082;
|
||||
}
|
||||
|
||||
.vtd-heat-row--pending .vtd-heat-icon {
|
||||
color: #ffb300;
|
||||
}
|
||||
|
||||
/* Match — 높음 (푸른색) */
|
||||
.vtd-heat-row--match .vtd-heat-track {
|
||||
border-color: color-mix(in srgb, #3f7ef5 45%, var(--se-border));
|
||||
box-shadow:
|
||||
inset 0 1px 4px rgba(0, 0, 0, 0.45),
|
||||
0 0 12px color-mix(in srgb, #3f7ef5 22%, transparent);
|
||||
}
|
||||
|
||||
.vtd-heat-row--match .vtd-heat-fill {
|
||||
background: linear-gradient(90deg, #1565c0 0%, #3f7ef5 45%, #82b1ff 100%);
|
||||
box-shadow:
|
||||
0 0 16px color-mix(in srgb, #3f7ef5 55%, transparent),
|
||||
inset 0 0 12px color-mix(in srgb, #bbdefb 25%, transparent);
|
||||
}
|
||||
|
||||
.vtd-heat-row--match .vtd-heat-pct {
|
||||
color: #82b1ff;
|
||||
}
|
||||
|
||||
.vtd-heat-row--match .vtd-heat-icon {
|
||||
color: #3f7ef5;
|
||||
filter: drop-shadow(0 0 5px #3f7ef5);
|
||||
}
|
||||
|
||||
.vtd-heat-list-empty {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.vtd-sig-panel--summary .vtd-heat-list {
|
||||
max-height: 160px;
|
||||
}
|
||||
|
||||
/* legacy 조건 목록 (상세 테이블 STATUS 열) */
|
||||
.vtd-cond-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@@ -1357,7 +1708,7 @@
|
||||
}
|
||||
|
||||
.vtd-cond-signal--match {
|
||||
color: var(--down, #ef5350);
|
||||
color: #3f7ef5;
|
||||
}
|
||||
|
||||
.vtd-cond-signal--pending {
|
||||
@@ -1365,7 +1716,7 @@
|
||||
}
|
||||
|
||||
.vtd-cond-signal--nomatch {
|
||||
color: var(--up, #26a69a);
|
||||
color: #ef5350;
|
||||
}
|
||||
|
||||
.vtd-cond-signal--unknown {
|
||||
@@ -1471,8 +1822,10 @@
|
||||
.vtd-sig-table-wrap {
|
||||
border: 1px solid color-mix(in srgb, #c9a227 35%, var(--se-border));
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
background: color-mix(in srgb, #0d111f 50%, transparent);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.vtd-sig-table-head {
|
||||
@@ -1487,23 +1840,37 @@
|
||||
|
||||
.vtd-sig-table {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 10px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.vtd-sig-col--ind { width: 30%; }
|
||||
.vtd-sig-col--val { width: 14%; }
|
||||
.vtd-sig-col--threshold { width: 22%; }
|
||||
.vtd-sig-col--progress { width: 16%; }
|
||||
.vtd-sig-col--status { width: 18%; min-width: 4.75rem; }
|
||||
|
||||
.vtd-sig-table th {
|
||||
text-align: left;
|
||||
padding: 6px 8px;
|
||||
padding: 6px 6px;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
letter-spacing: 0.02em;
|
||||
color: color-mix(in srgb, #c9a227 80%, var(--se-text-muted));
|
||||
border-bottom: 1px solid var(--se-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vtd-sig-table th.vtd-sig-table-th--status,
|
||||
.vtd-sig-table td.vtd-sig-table-status-cell {
|
||||
padding-right: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.vtd-sig-table td {
|
||||
padding: 7px 8px;
|
||||
padding: 7px 6px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--se-border) 60%, transparent);
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -1515,6 +1882,9 @@
|
||||
.vtd-sig-table-ind {
|
||||
font-weight: 600;
|
||||
color: var(--se-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vtd-sig-table-val {
|
||||
@@ -1528,7 +1898,24 @@
|
||||
}
|
||||
|
||||
.vtd-sig-table-progress {
|
||||
min-width: 88px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vtd-sig-table-status-cell {
|
||||
min-width: 4.75rem;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.vtd-sig-table-status-cell .vtd-cond-signal {
|
||||
justify-content: flex-end;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.vtd-sig-table-status-cell .vtd-cond-signal--compact .vtd-cond-signal-label {
|
||||
font-size: 9px;
|
||||
max-width: none;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.vtd-sig-row-bar {
|
||||
@@ -1561,6 +1948,56 @@
|
||||
background: var(--se-border);
|
||||
}
|
||||
|
||||
.vtd-sig-row-bar-fill--cold {
|
||||
background: linear-gradient(90deg, #b71c1c, #ef5350);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, #ef5350 40%, transparent);
|
||||
}
|
||||
|
||||
.vtd-sig-row-bar-fill--warming {
|
||||
background: linear-gradient(90deg, #e65100, #ffb300, #ffe082);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, #ffb300 40%, transparent);
|
||||
}
|
||||
|
||||
.vtd-sig-row-bar-fill--hot {
|
||||
background: linear-gradient(90deg, #1565c0, #3f7ef5, #82b1ff);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, #3f7ef5 50%, transparent);
|
||||
}
|
||||
|
||||
.vtd-sig-row-bar-fill--nomatch {
|
||||
background: linear-gradient(90deg, #b71c1c, #ef5350);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, #ef5350 40%, transparent);
|
||||
}
|
||||
|
||||
.vtd-sig-row-bar-fill--pending {
|
||||
background: linear-gradient(90deg, #e65100, #ffb300, #ffe082);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, #ffb300 40%, transparent);
|
||||
}
|
||||
|
||||
.vtd-sig-row-bar-fill--match {
|
||||
background: linear-gradient(90deg, #1565c0, #3f7ef5, #82b1ff);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, #3f7ef5 50%, transparent);
|
||||
}
|
||||
|
||||
.vtd-sig-table-pct {
|
||||
font-size: 9px;
|
||||
color: var(--se-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.vtd-sig-table-status {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.vtd-sig-table-status--cold { color: #ef5350; }
|
||||
.vtd-sig-table-status--warming { color: #ffe082; }
|
||||
.vtd-sig-table-status--hot { color: #82b1ff; }
|
||||
|
||||
.vtd-sig-table-status--nomatch { color: #ef5350; }
|
||||
.vtd-sig-table-status--pending { color: #ffe082; }
|
||||
.vtd-sig-table-status--match { color: #82b1ff; }
|
||||
|
||||
.vtd-sig-row-pct {
|
||||
font-size: 9px;
|
||||
color: var(--se-text-muted);
|
||||
|
||||
@@ -1083,6 +1083,7 @@ export interface LiveConditionRowDto {
|
||||
satisfied: boolean | null;
|
||||
timeframe: string;
|
||||
side: 'buy' | 'sell';
|
||||
plotKey?: string;
|
||||
}
|
||||
|
||||
export interface LiveConditionStatusDto {
|
||||
|
||||
@@ -384,6 +384,16 @@ export function getIndicatorDef(type: string): IndicatorDef | undefined {
|
||||
return INDICATOR_REGISTRY.find(d => d.type === type);
|
||||
}
|
||||
|
||||
/** 가상투자·조건 목록 — 한글명 (영문약어) 예: 상품채널지수 (CCI) */
|
||||
export function formatIndicatorDisplayLabel(indicatorType: string): string {
|
||||
const def = getIndicatorDef(indicatorType);
|
||||
const abbr = def?.shortName ?? indicatorType;
|
||||
const ko = def?.koreanName ?? abbr;
|
||||
if (!ko || ko === abbr) return abbr;
|
||||
if (ko.includes(`(${abbr})`)) return ko;
|
||||
return `${ko} (${abbr})`;
|
||||
}
|
||||
|
||||
/** 실시간 차트 pane·범례 타이틀 (심리도·투자심리도·이격도 등 한글명 우선) */
|
||||
export function getIndicatorChartTitle(type: string): string {
|
||||
const resolved = type === 'NewPsychological' ? 'Psychological' : type;
|
||||
|
||||
@@ -9,9 +9,23 @@ export type ConditionStatus = 'match' | 'pending' | 'nomatch' | 'unknown';
|
||||
export interface ConditionMetric {
|
||||
row: VirtualConditionRow & { currentValue: number | null };
|
||||
status: ConditionStatus;
|
||||
/** 조건 매칭율 0~100 */
|
||||
matchRate: number;
|
||||
/** @deprecated matchRate 와 동일 — 하위 호환 */
|
||||
progress: number | null;
|
||||
}
|
||||
|
||||
export type HeatTier = 'nomatch' | 'pending' | 'match';
|
||||
|
||||
/** Heatmap 색상 — Mismatch(빨강) · Pending(오렌지) · Match(푸른색) */
|
||||
export function resolveHeatTier(matchRate: number, status: ConditionStatus): HeatTier {
|
||||
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;
|
||||
@@ -53,6 +67,35 @@ export function computeConditionProgress(
|
||||
}
|
||||
}
|
||||
|
||||
const CROSS_CONDITION_TYPES = new Set([
|
||||
'CROSS_UP', 'CROSS_DOWN', 'SLOPE_UP', 'SLOPE_DOWN',
|
||||
]);
|
||||
|
||||
/** 조건별 매칭율 (0~100) — 백엔드 Ta4j 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)));
|
||||
}
|
||||
|
||||
export function getConditionStatus(
|
||||
conditionType: string,
|
||||
current: number | null,
|
||||
@@ -61,6 +104,7 @@ export function getConditionStatus(
|
||||
): 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';
|
||||
@@ -105,7 +149,9 @@ export function formatStrategyThreshold(
|
||||
export function buildConditionMetrics(
|
||||
rows: Array<VirtualConditionRow & { currentValue: number | null }>,
|
||||
): ConditionMetric[] {
|
||||
return rows.map(row => ({
|
||||
return rows.map(row => {
|
||||
const matchRate = computeConditionMatchRate(row);
|
||||
return {
|
||||
row,
|
||||
status: getConditionStatus(
|
||||
row.conditionType,
|
||||
@@ -113,12 +159,10 @@ export function buildConditionMetrics(
|
||||
row.targetValue,
|
||||
row.satisfied,
|
||||
),
|
||||
progress: row.satisfied === true
|
||||
? 100
|
||||
: row.satisfied === false
|
||||
? (computeConditionProgress(row.conditionType, row.currentValue, row.targetValue) ?? 0)
|
||||
: computeConditionProgress(row.conditionType, row.currentValue, row.targetValue),
|
||||
}));
|
||||
matchRate,
|
||||
progress: matchRate,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 전체 매매조건 일치율 — 백엔드 matchRate 우선 */
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import type { LogicNode } from './strategyTypes';
|
||||
import { CONDITION_LABEL } from './strategyTypes';
|
||||
import type { StrategyDto } from './backendApi';
|
||||
import { getIndicatorDef } from './indicatorRegistry';
|
||||
import { formatIndicatorDisplayLabel } from './indicatorRegistry';
|
||||
|
||||
export interface VirtualConditionRow {
|
||||
id: string;
|
||||
@@ -38,13 +38,18 @@ function walk(
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.type === 'NOT') {
|
||||
const notChild = node.children?.[0] ?? (node as LogicNode & { child?: LogicNode }).child;
|
||||
if (notChild) walk(notChild, timeframe, side, out, seen);
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.type === 'CONDITION' && node.condition) {
|
||||
const c = node.condition;
|
||||
const key = `${side}:${timeframe}:${c.indicatorType}:${c.conditionType}:${c.targetValue ?? ''}:${c.leftField ?? ''}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
|
||||
const def = getIndicatorDef(c.indicatorType);
|
||||
const condLabel = CONDITION_LABEL[c.conditionType] ?? c.conditionType;
|
||||
const target = c.targetValue ?? c.compareValue ?? null;
|
||||
const plotKey = c.leftField && c.leftField !== 'none' ? c.leftField : c.indicatorType;
|
||||
@@ -52,7 +57,7 @@ function walk(
|
||||
out.push({
|
||||
id: `${node.id}-${side}`,
|
||||
indicatorType: c.indicatorType,
|
||||
displayName: def?.koreanName ?? def?.shortName ?? c.indicatorType,
|
||||
displayName: formatIndicatorDisplayLabel(c.indicatorType),
|
||||
conditionType: c.conditionType,
|
||||
conditionLabel: condLabel,
|
||||
targetValue: target,
|
||||
|
||||
Reference in New Issue
Block a user