추세검색 설정 추가
This commit is contained in:
@@ -7,16 +7,21 @@ import ConditionStatusSignal from './ConditionStatusSignal';
|
||||
|
||||
interface Props {
|
||||
metrics: ConditionMetric[];
|
||||
/** false — 전략 조건 칼럼 숨김 (추세검색 등) */
|
||||
showStrategyCondition?: boolean;
|
||||
}
|
||||
|
||||
const VirtualIndicatorCompareTable: React.FC<Props> = ({ metrics }) => (
|
||||
const VirtualIndicatorCompareTable: React.FC<Props> = ({
|
||||
metrics,
|
||||
showStrategyCondition = true,
|
||||
}) => (
|
||||
<div className="vtd-sig-table-wrap">
|
||||
<div className="vtd-sig-table-head">실시간 지표 비교</div>
|
||||
<table className="vtd-sig-table">
|
||||
<table className={`vtd-sig-table${showStrategyCondition ? '' : ' vtd-sig-table--no-threshold'}`}>
|
||||
<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" />
|
||||
{showStrategyCondition && <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>
|
||||
@@ -24,7 +29,9 @@ const VirtualIndicatorCompareTable: React.FC<Props> = ({ metrics }) => (
|
||||
<tr>
|
||||
<th className="vtd-sig-table-th--ind">기술적 지표</th>
|
||||
<th className="vtd-sig-table-th--val">현재값</th>
|
||||
<th className="vtd-sig-table-th--threshold">전략 조건</th>
|
||||
{showStrategyCondition && (
|
||||
<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>
|
||||
@@ -37,10 +44,12 @@ const VirtualIndicatorCompareTable: React.FC<Props> = ({ metrics }) => (
|
||||
<tr key={row.id}>
|
||||
<td className="vtd-sig-table-ind">{row.displayName}</td>
|
||||
<td className="vtd-sig-table-val">{formatIndicatorValue(row.currentValue)}</td>
|
||||
<td className="vtd-sig-table-threshold">
|
||||
{row.thresholdLabel
|
||||
?? formatStrategyThreshold(row.conditionType, row.targetValue, row.conditionLabel)}
|
||||
</td>
|
||||
{showStrategyCondition && (
|
||||
<td className="vtd-sig-table-threshold">
|
||||
{row.thresholdLabel
|
||||
?? formatStrategyThreshold(row.conditionType, row.targetValue, row.conditionLabel)}
|
||||
</td>
|
||||
)}
|
||||
<td className="vtd-sig-table-progress">
|
||||
<div className="vtd-sig-row-bar vtd-sig-row-bar--heat">
|
||||
<div
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { MarketSearchPanel } from '../MarketSearchPanel';
|
||||
import { getKoreanName } from '../../utils/marketNameCache';
|
||||
import { resolveVirtualTargetNames } from '../../utils/virtualTargetNames';
|
||||
import type { StrategyDto } from '../../utils/backendApi';
|
||||
import type { TickerData } from '../../hooks/useMarketTicker';
|
||||
import VirtualTargetQuote from './VirtualTargetQuote';
|
||||
import type { VirtualTargetItem } from '../../utils/virtualTradingStorage';
|
||||
import {
|
||||
isVirtualTargetAddAllowed,
|
||||
virtualTargetLimitMessage,
|
||||
} from '../../utils/virtualTargetLimits';
|
||||
|
||||
interface Props {
|
||||
targets: VirtualTargetItem[];
|
||||
@@ -15,6 +19,8 @@ interface Props {
|
||||
onTargetsChange: (items: VirtualTargetItem[]) => void;
|
||||
onSelectMarket: (market: string) => void;
|
||||
onTargetStrategyChange: (market: string, strategyId: number | null) => void;
|
||||
onTogglePin?: (market: string) => void;
|
||||
virtualTargetMaxCount?: number;
|
||||
}
|
||||
|
||||
const VirtualLeftTargetPanel: React.FC<Props> = ({
|
||||
@@ -26,12 +32,15 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
|
||||
onTargetsChange,
|
||||
onSelectMarket,
|
||||
onTargetStrategyChange,
|
||||
onTogglePin,
|
||||
virtualTargetMaxCount = 20,
|
||||
}) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const searchRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const addedSet = useMemo(() => new Set(targets.map(t => t.market)), [targets]);
|
||||
const atTargetLimit = !isVirtualTargetAddAllowed(targets.length, virtualTargetMaxCount);
|
||||
|
||||
const closeDropdown = useCallback(() => {
|
||||
setShowDropdown(false);
|
||||
@@ -54,21 +63,28 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
|
||||
|
||||
const handleAdd = useCallback((market: string, meta: { koreanName: string; englishName: string }) => {
|
||||
if (addedSet.has(market)) return;
|
||||
if (!isVirtualTargetAddAllowed(targets.length, virtualTargetMaxCount)) {
|
||||
window.alert(virtualTargetLimitMessage(virtualTargetMaxCount));
|
||||
return;
|
||||
}
|
||||
const names = resolveVirtualTargetNames(market, meta.koreanName, meta.englishName);
|
||||
onTargetsChange([
|
||||
...targets,
|
||||
{
|
||||
market,
|
||||
strategyId: globalStrategyId,
|
||||
koreanName: meta.koreanName,
|
||||
englishName: meta.englishName,
|
||||
koreanName: names.koreanName,
|
||||
englishName: names.englishName,
|
||||
},
|
||||
]);
|
||||
onSelectMarket(market);
|
||||
setQuery('');
|
||||
setShowDropdown(false);
|
||||
}, [addedSet, targets, globalStrategyId, onTargetsChange, onSelectMarket]);
|
||||
}, [addedSet, targets, globalStrategyId, onTargetsChange, onSelectMarket, virtualTargetMaxCount]);
|
||||
|
||||
const handleRemove = useCallback((market: string) => {
|
||||
const item = targets.find(t => t.market === market);
|
||||
if (item?.pinned) return;
|
||||
onTargetsChange(targets.filter(t => t.market !== market));
|
||||
}, [targets, onTargetsChange]);
|
||||
|
||||
@@ -96,6 +112,7 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
|
||||
onQueryChange={setQuery}
|
||||
actionMode="add"
|
||||
addedMarkets={addedSet}
|
||||
maxTargetsReached={atTargetLimit}
|
||||
onAdd={handleAdd}
|
||||
onSelect={onSelectMarket}
|
||||
onClose={closeDropdown}
|
||||
@@ -107,7 +124,7 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
|
||||
<section className="vtd-target-section">
|
||||
<div className="vtd-target-list-head">
|
||||
<span>투자대상</span>
|
||||
<span className="vtd-target-count">{targets.length}종목</span>
|
||||
<span className="vtd-target-count">{targets.length}/{virtualTargetMaxCount}종목</span>
|
||||
</div>
|
||||
|
||||
<div className="vtd-target-list">
|
||||
@@ -115,13 +132,20 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
|
||||
<p className="vtd-muted">검색 후 + 버튼으로 종목을 추가하세요.</p>
|
||||
)}
|
||||
{targets.map(item => {
|
||||
const ko = item.koreanName ?? getKoreanName(item.market);
|
||||
const en = item.englishName ?? item.market.replace(/^KRW-/, '');
|
||||
const { koreanName: ko, englishName: en } = resolveVirtualTargetNames(
|
||||
item.market,
|
||||
item.koreanName,
|
||||
item.englishName,
|
||||
);
|
||||
const active = item.market === selectedMarket;
|
||||
return (
|
||||
<div
|
||||
key={item.market}
|
||||
className={`vtd-target-item${active ? ' vtd-target-item--active' : ''}`}
|
||||
className={[
|
||||
'vtd-target-item',
|
||||
active ? 'vtd-target-item--active' : '',
|
||||
item.pinned ? 'vtd-target-item--pinned' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => onSelectMarket(item.market)}
|
||||
>
|
||||
<div className="vtd-target-item-main">
|
||||
@@ -129,14 +153,30 @@ const VirtualLeftTargetPanel: React.FC<Props> = ({
|
||||
<span className="vtd-target-ko">{ko}</span>
|
||||
<span className="vtd-target-en">{en}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="vtd-target-remove"
|
||||
title="목록에서 제거"
|
||||
onClick={e => { e.stopPropagation(); handleRemove(item.market); }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div className="vtd-target-item-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`vtd-target-pin${item.pinned ? ' vtd-target-pin--on' : ''}`}
|
||||
title={item.pinned ? '고정 해제' : '투자대상 고정'}
|
||||
aria-label={item.pinned ? '고정 해제' : '투자대상 고정'}
|
||||
aria-pressed={!!item.pinned}
|
||||
onClick={e => { e.stopPropagation(); onTogglePin?.(item.market); }}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden>
|
||||
<path d="M6 0C4.5 0 3.5 1.2 3.5 2.5c0 .6.2 1.1.5 1.5L2 7v1h8V7L8 4c.3-.4.5-.9.5-1.5C8.5 1.2 7.5 0 6 0zm0 10.5a1.25 1.25 0 1 0 0 2.5 1.25 1.25 0 0 0 0-2.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="vtd-target-remove"
|
||||
title={item.pinned ? '고정된 종목은 삭제할 수 없습니다' : '목록에서 제거'}
|
||||
aria-label="목록에서 제거"
|
||||
disabled={!!item.pinned}
|
||||
onClick={e => { e.stopPropagation(); handleRemove(item.market); }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<VirtualTargetQuote
|
||||
market={item.market}
|
||||
|
||||
@@ -15,20 +15,25 @@ const VirtualSignalEqualizer: React.FC<Props> = ({ matchRate }) => {
|
||||
);
|
||||
|
||||
const segments = useMemo(() => {
|
||||
const items: Array<{ lit: boolean; tone: 'blue' | 'gold' | 'peak' }> = [];
|
||||
const items: Array<{ lit: boolean; tone: 'red' | 'gold' | 'blue' | 'peak' }> = [];
|
||||
const third = SEGMENTS / 3;
|
||||
for (let i = 0; i < SEGMENTS; i++) {
|
||||
const lit = i < litCount;
|
||||
if (!lit) {
|
||||
items.push({ lit: false, tone: 'blue' });
|
||||
continue;
|
||||
}
|
||||
let tone: 'red' | 'gold' | 'blue' | 'peak';
|
||||
if (rate >= 100 && i === SEGMENTS - 1) {
|
||||
items.push({ lit: true, tone: 'peak' });
|
||||
} else if (i >= Math.floor(SEGMENTS * 0.55)) {
|
||||
items.push({ lit: true, tone: 'gold' });
|
||||
tone = 'peak';
|
||||
} else if (i < third) {
|
||||
tone = 'red';
|
||||
} else if (i < third * 2) {
|
||||
tone = 'gold';
|
||||
} else {
|
||||
items.push({ lit: true, tone: 'blue' });
|
||||
tone = 'blue';
|
||||
}
|
||||
items.push({ lit: true, tone });
|
||||
}
|
||||
return items;
|
||||
}, [litCount, rate]);
|
||||
|
||||
@@ -15,9 +15,9 @@ const LABELS: Record<TrafficLightState, string> = {
|
||||
const VirtualSignalTrafficLight: React.FC<Props> = ({ state, matchRate }) => (
|
||||
<div className="vtd-sig-light vtd-sig-light--card-right">
|
||||
<div className="vtd-sig-light-housing">
|
||||
<div className={`vtd-sig-light-bulb vtd-sig-light-bulb--red${state === 'red' ? ' vtd-sig-light-bulb--on' : ''}`} />
|
||||
<div className={`vtd-sig-light-bulb vtd-sig-light-bulb--yellow${state === 'yellow' ? ' vtd-sig-light-bulb--on' : ''}`} />
|
||||
<div className={`vtd-sig-light-bulb vtd-sig-light-bulb--blue${state === 'blue' ? ' vtd-sig-light-bulb--on' : ''}`} />
|
||||
<div className={`vtd-sig-light-bulb vtd-sig-light-bulb--yellow${state === 'yellow' ? ' vtd-sig-light-bulb--on' : ''}`} />
|
||||
<div className={`vtd-sig-light-bulb vtd-sig-light-bulb--red${state === 'red' ? ' vtd-sig-light-bulb--on' : ''}`} />
|
||||
</div>
|
||||
<div className="vtd-sig-light-info vtd-sig-light-info--below">
|
||||
<span className="vtd-sig-light-rate">{matchRate}%</span>
|
||||
|
||||
Reference in New Issue
Block a user