추세검색 설정 수정

This commit is contained in:
Macbook
2026-05-27 02:10:00 +09:00
parent 2e08c6b16f
commit fc06a16184
27 changed files with 1262 additions and 147 deletions
+93 -59
View File
@@ -13,6 +13,7 @@ import type { TickerData } from '../hooks/useMarketTicker';
import {
DEFAULT_TREND_SEARCH_REQUEST,
scanTrendSearch,
loadTrendSearchResults,
fetchTrendSearchDetail,
type TrendSearchRequest,
type TrendSearchResultDto,
@@ -20,11 +21,12 @@ import {
import { sortTrendSearchByStrength } from '../utils/trendSearchMetrics';
import { resolveVirtualTargetNames } from '../utils/virtualTargetNames';
import { useVirtualTargetRegistry } from '../hooks/useVirtualTargetRegistry';
import { useAppSettings } from '../hooks/useAppSettings';
import { getAppSettingsCache, useAppSettings } from '../hooks/useAppSettings';
import {
extractTrendSearchAppSettings,
mergeTrendSearchRequest,
mergeFiltersFromTrendSearchSettings,
mergeTrendSearchSettingsFromRequest,
resolveTrendSearchAppSettings,
trendSearchSettingsSignature,
type TrendSearchAppSettings,
} from '../utils/trendSearchAppSettings';
import { autoAddTrendSearchTargets } from '../utils/trendSearchAutoAddTargets';
@@ -55,11 +57,10 @@ const TrendSearchPage: React.FC<Props> = ({
tickers,
}) => {
const { defaults, isLoaded: appSettingsLoaded, save: saveAppSettings } = useAppSettings();
const [pageSettings, setPageSettings] = useState<TrendSearchAppSettings>(() =>
resolveTrendSearchAppSettings(),
);
const tsSettings = defaults.trendSearchSettings;
const tsSettingsSig = trendSearchSettingsSignature(tsSettings);
const [filters, setFilters] = useState<TrendSearchRequest>(() =>
mergeTrendSearchRequest(
mergeFiltersFromTrendSearchSettings(
{ ...DEFAULT_TREND_SEARCH_REQUEST },
resolveTrendSearchAppSettings(),
),
@@ -67,75 +68,104 @@ const TrendSearchPage: React.FC<Props> = ({
const [results, setResults] = useState<TrendSearchResultDto[]>([]);
const [selectedMarket, setSelectedMarket] = useState<string | null>(null);
const [searching, setSearching] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(true);
const [displayMode, setDisplayMode] = useState<TrendSearchDisplayMode>(() => loadDisplayMode());
const [flashMarkets, setFlashMarkets] = useState<Set<string>>(new Set());
const [lastUpdatedAt, setLastUpdatedAt] = useState<number>(Date.now());
const filtersRef = useRef(filters);
filtersRef.current = filters;
const pageSettingsRef = useRef(pageSettings);
pageSettingsRef.current = pageSettings;
const tsSettingsRef = useRef(tsSettings);
tsSettingsRef.current = tsSettings;
const lastSyncedSettingsSigRef = useRef<string | null>(null);
const schedulerSeededRef = useRef(false);
const { isInTargets, isPinned, add, remove, busyMarket } = useVirtualTargetRegistry();
useEffect(() => {
if (!appSettingsLoaded) return;
const resolved = defaults.trendSearchSettings;
setPageSettings(resolved);
setFilters(prev => mergeTrendSearchRequest(prev, resolved));
}, [appSettingsLoaded, defaults.trendSearchSettings]);
if (lastSyncedSettingsSigRef.current === tsSettingsSig) return;
lastSyncedSettingsSigRef.current = tsSettingsSig;
setFilters(prev => mergeFiltersFromTrendSearchSettings(prev, tsSettings));
}, [appSettingsLoaded, tsSettingsSig, tsSettings]);
const persistPageSettings = useCallback((patch: Partial<TrendSearchAppSettings>) => {
const next = resolveTrendSearchAppSettings({ ...pageSettingsRef.current, ...patch });
setPageSettings(next);
pageSettingsRef.current = next;
useEffect(() => {
if (!appSettingsLoaded || schedulerSeededRef.current) return;
schedulerSeededRef.current = true;
saveAppSettings({ trendSearchSettings: tsSettingsRef.current });
}, [appSettingsLoaded, saveAppSettings]);
const persistTrendSettings = useCallback((patch: Partial<TrendSearchAppSettings>) => {
const base = resolveTrendSearchAppSettings(
getAppSettingsCache().trendSearchSettings as Partial<TrendSearchAppSettings> | undefined,
);
const next = resolveTrendSearchAppSettings({ ...base, ...patch });
lastSyncedSettingsSigRef.current = trendSearchSettingsSignature(next);
saveAppSettings({ trendSearchSettings: next });
}, [saveAppSettings]);
const handleFiltersChange = useCallback((next: TrendSearchRequest) => {
setFilters(next);
const extracted = extractTrendSearchAppSettings(next);
persistPageSettings(extracted);
}, [persistPageSettings]);
const base = resolveTrendSearchAppSettings(
getAppSettingsCache().trendSearchSettings as Partial<TrendSearchAppSettings> | undefined,
);
const merged = mergeTrendSearchSettingsFromRequest(base, next);
lastSyncedSettingsSigRef.current = trendSearchSettingsSignature(merged);
saveAppSettings({ trendSearchSettings: merged });
}, [saveAppSettings]);
const runSearch = useCallback(async (opts?: { silent?: boolean }) => {
if (!opts?.silent) setSearching(true);
const applyResults = useCallback(async (sorted: TrendSearchResultDto[], scannedAtMs?: number) => {
setResults(sorted);
setLastUpdatedAt(scannedAtMs ?? Date.now());
setSelectedMarket(prev => {
if (prev && sorted.some(r => r.market === prev)) return prev;
return sorted[0]?.market ?? null;
});
if (sorted.length > 0) {
setFlashMarkets(new Set(sorted.slice(0, 5).map(r => r.market)));
setTimeout(() => setFlashMarkets(new Set()), 600);
}
await autoAddTrendSearchTargets(
sorted,
tsSettingsRef.current,
filtersRef.current.timeframe,
);
}, []);
const loadFromDb = useCallback(async (opts?: { silent?: boolean }) => {
try {
const payload = await loadTrendSearchResults();
const sorted = sortTrendSearchByStrength(payload.results ?? []);
const scannedAt = payload.scannedAt ? Date.parse(payload.scannedAt) : undefined;
await applyResults(sorted, Number.isFinite(scannedAt) ? scannedAt : undefined);
} catch (e) {
console.warn('[TrendSearch] load results', e);
if (!opts?.silent) window.alert('추세검색 결과를 불러오지 못했습니다. 백엔드 연결을 확인하세요.');
}
}, [applyResults]);
const runScan = useCallback(async () => {
setSearching(true);
try {
const list = await scanTrendSearch(filtersRef.current);
const sorted = sortTrendSearchByStrength(list);
setResults(sorted);
setLastUpdatedAt(Date.now());
setSelectedMarket(prev => {
if (prev && sorted.some(r => r.market === prev)) return prev;
return sorted[0]?.market ?? null;
});
if (sorted.length > 0) {
setFlashMarkets(new Set(sorted.slice(0, 5).map(r => r.market)));
setTimeout(() => setFlashMarkets(new Set()), 600);
}
await autoAddTrendSearchTargets(
sorted,
pageSettingsRef.current,
filtersRef.current.timeframe,
);
await applyResults(sorted);
} catch (e) {
console.warn('[TrendSearch]', e);
if (!opts?.silent) window.alert('추세검색 스캔에 실패했습니다. 백엔드 연결을 확인하세요.');
console.warn('[TrendSearch] scan', e);
window.alert('추세검색 스캔에 실패했습니다. 백엔드 연결을 확인하세요.');
} finally {
if (!opts?.silent) setSearching(false);
setSearching(false);
}
}, []);
}, [applyResults]);
useEffect(() => {
if (!appSettingsLoaded) return;
void runSearch();
}, [appSettingsLoaded, runSearch]);
void loadFromDb({ silent: true });
}, [appSettingsLoaded, loadFromDb]);
useEffect(() => {
if (!autoRefresh) return;
const ms = Math.max(1, pageSettings.autoRefreshSeconds) * 1000;
const id = window.setInterval(() => void runSearch({ silent: true }), ms);
if (!tsSettings.autoRefreshEnabled) return;
const ms = Math.max(1, tsSettings.autoRefreshSeconds) * 1000;
const id = window.setInterval(() => void loadFromDb({ silent: true }), ms);
return () => clearInterval(id);
}, [autoRefresh, pageSettings.autoRefreshSeconds, runSearch]);
}, [tsSettings.autoRefreshEnabled, tsSettings.autoRefreshSeconds, loadFromDb]);
useEffect(() => {
try { localStorage.setItem(DISPLAY_MODE_KEY, displayMode); } catch { /* ignore */ }
@@ -167,15 +197,19 @@ const TrendSearchPage: React.FC<Props> = ({
}, [remove]);
const handleAutoAddToggle = useCallback((enabled: boolean) => {
persistPageSettings({ autoAddTargetsEnabled: enabled });
persistTrendSettings({ autoAddTargetsEnabled: enabled });
if (enabled && results.length > 0) {
void autoAddTrendSearchTargets(results, { ...pageSettingsRef.current, autoAddTargetsEnabled: true }, filtersRef.current.timeframe);
void autoAddTrendSearchTargets(results, { ...tsSettingsRef.current, autoAddTargetsEnabled: true }, filtersRef.current.timeframe);
}
}, [persistPageSettings, results]);
}, [persistTrendSettings, results]);
const handleAutoRefreshToggle = useCallback((enabled: boolean) => {
persistTrendSettings({ autoRefreshEnabled: enabled });
}, [persistTrendSettings]);
const handleRefreshSecondsChange = useCallback((sec: number) => {
persistPageSettings({ autoRefreshSeconds: sec });
}, [persistPageSettings]);
persistTrendSettings({ autoRefreshSeconds: sec });
}, [persistTrendSettings]);
return (
<BuilderPageShell
@@ -195,14 +229,14 @@ const TrendSearchPage: React.FC<Props> = ({
<TrendSearchViewHeaderControls
displayMode={displayMode}
onDisplayModeChange={setDisplayMode}
autoRefresh={autoRefresh}
onAutoRefreshChange={setAutoRefresh}
refreshSeconds={pageSettings.autoRefreshSeconds}
autoRefresh={tsSettings.autoRefreshEnabled}
onAutoRefreshChange={handleAutoRefreshToggle}
refreshSeconds={tsSettings.autoRefreshSeconds}
onRefreshSecondsChange={handleRefreshSecondsChange}
autoAddTargets={pageSettings.autoAddTargetsEnabled}
autoAddTargets={tsSettings.autoAddTargetsEnabled}
onAutoAddTargetsChange={handleAutoAddToggle}
searching={searching}
onRefresh={() => void runSearch()}
onRefresh={() => void loadFromDb()}
resultCount={results.length}
/>
)}
@@ -214,7 +248,7 @@ const TrendSearchPage: React.FC<Props> = ({
<TrendSearchFilterPanel
filters={filters}
onChange={handleFiltersChange}
onSearch={() => void runSearch()}
onSearch={() => void runScan()}
searching={searching}
/>
)}
+51 -30
View File
@@ -16,8 +16,10 @@ import {
import type { Theme, TradeOrderFillRequest } from '../types';
import type { TickerData } from '../hooks/useMarketTicker';
import TradeOrderPanel from './TradeOrderPanel';
import PaperCompactOrderbook from './paper/PaperCompactOrderbook';
import { OrderbookPanel } from './OrderbookPanel';
import PaperSplitPanel from './paper/PaperSplitPanel';
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
import PaperTradeHistoryList from './paper/PaperTradeHistoryList';
import BuilderPageShell from './layout/BuilderPageShell';
import VirtualLeftTargetPanel from './virtual/VirtualLeftTargetPanel';
@@ -199,6 +201,23 @@ const VirtualTradingPage: React.FC<Props> = ({
[tickers, selectedMarket],
);
const { orderbook, wsStatus: obWsStatus, spread } = useUpbitOrderbook(selectedMarket);
const { trades: recentTrades, strength: tradeStrength } = useUpbitRecentTrades(selectedMarket);
const selectedTicker = tickers?.get(selectedMarket);
const orderbookPrevClose = selectedTicker
? (selectedTicker.tradePrice ?? 0) - (selectedTicker.changePrice ?? 0)
: 0;
const orderbookTickerInfo = selectedTicker ? {
tradePrice: selectedTicker.tradePrice,
changeRate: selectedTicker.changeRate,
changePrice: selectedTicker.changePrice,
accTradePrice24: selectedTicker.accTradePrice24,
accTradeVolume24: selectedTicker.accTradeVolume24,
highPrice: selectedTicker.highPrice,
lowPrice: selectedTicker.lowPrice,
openingPrice: selectedTicker.openingPrice,
} : undefined;
const handleSelectMarket = useCallback((market: string) => {
setSelectedMarket(market);
const price = tickers?.get(market)?.tradePrice;
@@ -265,11 +284,13 @@ const VirtualTradingPage: React.FC<Props> = ({
setSession(s => ({ ...s, running: false }));
}, [targets, session]);
const handleObPick = useCallback((price: number, rowType: 'ask' | 'bid') => {
const side = rowType === 'ask' ? 'buy' : 'sell';
const req: TradeOrderFillRequest = { market: selectedMarket, price, side, seq: Date.now() };
if (side === 'buy') setFillBuy(req); else setFillSell(req);
/** 실시간 차트 우측 호가와 동일: 매수 호가(bid) → 매수 탭, 매도 호가(ask) → 매도 탭 */
const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => {
setRightTab('trade');
const side = rowType === 'bid' ? 'buy' : 'sell';
const req: TradeOrderFillRequest = { market: selectedMarket, price, side, seq: Date.now() };
if (side === 'buy') setFillBuy(req);
else setFillSell(req);
}, [selectedMarket]);
const handleTargetStrategyChange = useCallback(async (market: string, strategyId: number | null) => {
@@ -497,35 +518,35 @@ const VirtualTradingPage: React.FC<Props> = ({
)}
/>
) : rightTab === 'orderbook' ? (
<PaperSplitPanel
className="ptd-split-panel--right"
topTitle="매도 호가"
bottomTitle="매수 호가"
top={(
<PaperCompactOrderbook
market={selectedMarket}
onPick={handleObPick}
section="asks"
fillHeight
hideHeader
depth={10}
/>
)}
bottom={(
<PaperCompactOrderbook
market={selectedMarket}
onPick={handleObPick}
section="bids"
fillHeight
hideHeader
depth={10}
/>
)}
/>
<div className="rsp-ob-stack ptd-ob-stack--fill">
<div className="rsp-trade-card rsp-ob-card">
<div className="rsp-trade-card-title rsp-ob-card-title"> </div>
<div className="rsp-trade-card-body rsp-ob-card-body">
<OrderbookPanel
market={selectedMarket}
asks={orderbook.asks}
bids={orderbook.bids}
totalAskSize={orderbook.totalAskSize}
totalBidSize={orderbook.totalBidSize}
wsStatus={obWsStatus}
bestAsk={spread.bestAsk}
bestBid={spread.bestBid}
spread={spread.spread}
spreadPct={spread.pct}
prevClose={orderbookPrevClose}
tickerInfo={orderbookTickerInfo}
recentTrades={recentTrades}
tradeStrength={tradeStrength}
onRowClick={handleOrderbookRowClick}
/>
</div>
</div>
</div>
) : (
<PaperTradeHistoryList
trades={trades}
strategyNames={strategyNames}
tickers={tickers}
onSelectMarket={handleSelectMarket}
emptyText="수동·자동 매매 체결 내역이 없습니다."
className="ptd-trade-history--fill"
@@ -1,5 +1,7 @@
import React from 'react';
import type { PaperTradeDto } from '../../utils/backendApi';
import type { TickerData } from '../../hooks/useMarketTicker';
import { resolveVirtualTargetNames } from '../../utils/virtualTargetNames';
import { fmtKrw } from '../TradeOrderPanel';
function coinCode(symbol: string): string {
@@ -22,6 +24,7 @@ function sourceLabel(source: string | null | undefined): string {
interface Props {
trades: PaperTradeDto[];
strategyNames?: Record<number, string>;
tickers?: Map<string, TickerData>;
emptyText?: string;
className?: string;
onSelectMarket?: (market: string) => void;
@@ -30,57 +33,102 @@ interface Props {
const PaperTradeHistoryList: React.FC<Props> = ({
trades,
strategyNames = {},
tickers,
emptyText = '거래 내역이 없습니다.',
className = '',
onSelectMarket,
}) => (
<div className={`ptd-trade-history${className ? ` ${className}` : ''}`}>
{trades.length === 0 ? (
<p className="ptd-muted ptd-trade-history-empty">{emptyText}</p>
) : (
<div className="ptd-trade-history-scroll">
<table className="ptd-table ptd-table--compact ptd-table--trades">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<div className={`ptd-trade-history vtd-trade-history${className ? ` ${className}` : ''}`}>
<section className="vtd-target-section vtd-trade-section">
<div className="vtd-target-list-head">
<span> </span>
<span className="vtd-target-count">{trades.length}</span>
</div>
{trades.length === 0 ? (
<p className="vtd-muted ptd-trade-history-empty">{emptyText}</p>
) : (
<div className="ptd-trade-history-scroll">
<div className="vtd-target-list vtd-trade-list">
{trades.map(t => {
const { koreanName: ko, englishName: en } = resolveVirtualTargetNames(
t.symbol,
tickers?.get(t.symbol)?.koreanName,
);
const strategyName = t.strategyId != null
? (strategyNames[t.strategyId] ?? `#${t.strategyId}`)
: null;
: '—';
const isBuy = t.side === 'BUY';
const clickable = Boolean(onSelectMarket);
const sideCls = isBuy ? 'vtd-trade-item--buy' : 'vtd-trade-item--sell';
const colorCls = isBuy ? 'vtd-target-up' : 'vtd-target-dn';
return (
<tr
<div
key={t.id}
className={onSelectMarket ? 'ptd-row--click' : undefined}
onClick={onSelectMarket ? () => onSelectMarket(t.symbol) : undefined}
title={strategyName ? `전략: ${strategyName}` : undefined}
className={[
'vtd-target-item',
'vtd-trade-item',
sideCls,
clickable ? 'vtd-trade-item--click' : '',
].filter(Boolean).join(' ')}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={clickable ? () => onSelectMarket!(t.symbol) : undefined}
onKeyDown={clickable ? e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelectMarket!(t.symbol);
}
} : undefined}
>
<td className="ptd-time">{fmtTradeTime(t.createdAt)}</td>
<td>{coinCode(t.symbol)}</td>
<td className={t.side === 'BUY' ? 'up' : 'down'}>
{t.side === 'BUY' ? '매수' : '매도'}
</td>
<td className={t.source === 'STRATEGY' ? 'ptd-source--auto' : 'ptd-source--manual'}>
{sourceLabel(t.source)}
</td>
<td>{fmtKrw(t.price)}</td>
<td>{t.quantity}</td>
<td>{fmtKrw(t.netAmount)}</td>
</tr>
<div className="vtd-target-item-main">
<div className="vtd-target-names">
<span className="vtd-target-ko">{ko}</span>
<span className="vtd-target-en">{fmtTradeTime(t.createdAt)} · {en}</span>
</div>
<div className="vtd-target-item-actions">
<span className={`vtd-trade-side-badge vtd-trade-side-badge--${isBuy ? 'buy' : 'sell'}`}>
{isBuy ? '매수' : '매도'}
</span>
</div>
</div>
<div className="vtd-target-quote vtd-trade-quote" aria-label="체결 정보">
<div className="vtd-target-quote-left">
<span className={`vtd-target-arrow vtd-target-arrow--${isBuy ? 'rise' : 'fall'}`}>
{isBuy ? '▲' : '▼'}
</span>
<span className="vtd-target-quote-pair">{coinCode(t.symbol)}/KRW</span>
</div>
<div className="vtd-target-quote-right">
<span className={`vtd-target-price ${colorCls}`}>{fmtKrw(t.price)}</span>
<span className={`vtd-target-change ${colorCls}`}>{fmtKrw(t.netAmount)}</span>
</div>
</div>
<div className="vtd-target-strategy-field vtd-trade-meta-field">
<span className="vtd-target-strategy-label"></span>
<span
className={`vtd-trade-meta-value${t.source === 'STRATEGY' ? ' vtd-trade-meta-value--auto' : ''}`}
>
{sourceLabel(t.source)}
</span>
</div>
<div className="vtd-target-strategy-field vtd-trade-meta-field">
<span className="vtd-target-strategy-label"></span>
<span className="vtd-trade-meta-value vtd-trade-meta-value--strategy" title={strategyName}>
{strategyName}
</span>
</div>
</div>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
</section>
</div>
);
@@ -90,6 +90,19 @@ const TrendSearchSettingsPanel: React.FC<Props> = ({ settings, onChange }) => {
</SettingSection>
<SettingSection title="자동 갱신">
<SettingRow
label="자동 갱신 사용"
desc="백엔드에서 설정 간격으로 스캔하고, 화면은 DB 결과를 주기적으로 불러옵니다."
>
<label className="stg-toggle">
<input
type="checkbox"
checked={settings.autoRefreshEnabled}
onChange={e => patch({ autoRefreshEnabled: e.target.checked })}
/>
<span>{settings.autoRefreshEnabled ? '켜짐' : '꺼짐'}</span>
</label>
</SettingRow>
<SettingRow label="기본 갱신 간격" desc="추세검색 화면 자동 갱신 드롭다운의 기본값입니다.">
<select
className="stg-select"