추세검색 설정 추가

This commit is contained in:
Macbook
2026-05-27 01:36:06 +09:00
parent c1bcf88c6c
commit 2e08c6b16f
42 changed files with 1507 additions and 226 deletions
@@ -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>