추세검색 설정 수정
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -49,6 +49,22 @@ import {
|
||||
// 전역 캐시 — 여러 컴포넌트에서 공유하여 중복 요청 방지
|
||||
let _cache: AppSettingsDto | null = null;
|
||||
let _loadPromise: Promise<AppSettingsDto> | null = null;
|
||||
type AppSettingsListener = () => void;
|
||||
const _listeners = new Set<AppSettingsListener>();
|
||||
|
||||
function notifyAppSettingsListeners() {
|
||||
_listeners.forEach(fn => fn());
|
||||
}
|
||||
|
||||
/** 훅 외부에서 최신 캐시 읽기 (추세검색 설정 병합 등) */
|
||||
export function getAppSettingsCache(): AppSettingsDto {
|
||||
return _cache ?? {};
|
||||
}
|
||||
|
||||
export function subscribeAppSettings(listener: AppSettingsListener): () => void {
|
||||
_listeners.add(listener);
|
||||
return () => _listeners.delete(listener);
|
||||
}
|
||||
|
||||
function ensureLoaded(): Promise<AppSettingsDto> {
|
||||
if (_cache !== null) return Promise.resolve(_cache);
|
||||
@@ -56,6 +72,7 @@ function ensureLoaded(): Promise<AppSettingsDto> {
|
||||
_loadPromise = loadAppSettings().then(data => {
|
||||
_cache = data ?? {};
|
||||
_loadPromise = null;
|
||||
notifyAppSettingsListeners();
|
||||
return _cache;
|
||||
});
|
||||
return _loadPromise;
|
||||
@@ -141,7 +158,13 @@ export function useAppSettings(sessionKey = 0) {
|
||||
console.error('[useAppSettings] load failed', err);
|
||||
if (mountedRef.current) setIsLoaded(true);
|
||||
});
|
||||
return () => { mountedRef.current = false; };
|
||||
const unsub = subscribeAppSettings(() => {
|
||||
if (mountedRef.current) setSettings({ ...(getAppSettingsCache()) });
|
||||
});
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
unsub();
|
||||
};
|
||||
}, [sessionKey]);
|
||||
|
||||
/**
|
||||
@@ -151,14 +174,18 @@ export function useAppSettings(sessionKey = 0) {
|
||||
const save = useCallback((patch: AppSettingsDto) => {
|
||||
const merged = { ...(_cache ?? {}), ...patch };
|
||||
_cache = merged;
|
||||
notifyAppSettingsListeners();
|
||||
setSettings(merged);
|
||||
if (patch.displayTimezone != null) {
|
||||
setDisplayTimezone(normalizeTimezone(patch.displayTimezone));
|
||||
}
|
||||
saveAppSettings(patch).then(updated => {
|
||||
if (updated && mountedRef.current) {
|
||||
if (updated) {
|
||||
_cache = { ...(_cache ?? {}), ...updated };
|
||||
setSettings(prev => ({ ...prev, ...updated }));
|
||||
notifyAppSettingsListeners();
|
||||
if (mountedRef.current) {
|
||||
setSettings(prev => ({ ...prev, ...updated }));
|
||||
}
|
||||
}
|
||||
}).catch(err =>
|
||||
console.error('[useAppSettings] save failed', err)
|
||||
|
||||
@@ -837,13 +837,14 @@
|
||||
|
||||
.ptd-trade-history--fill {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
padding: 10px 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ptd-trade-history-empty {
|
||||
margin: 12px 0;
|
||||
margin: 16px 0;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ptd-trade-history-scroll {
|
||||
@@ -852,6 +853,15 @@
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.bps-page--vtd .ptd-trade-history--fill .vtd-trade-section {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bps-page--vtd .ptd-trade-history--fill .ptd-trade-history-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ptd-table--trades th,
|
||||
.ptd-table--trades td {
|
||||
white-space: nowrap;
|
||||
@@ -877,6 +887,18 @@
|
||||
.bps-page--vtd .ptd-right-body > .ptd-trade-history--fill {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 가상매매 — 실시간 차트와 동일한 호가 탭 레이아웃 */
|
||||
.bps-page--vtd .ptd-right-body > .ptd-ob-stack--fill {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bps-page--vtd .ptd-right-body > .ptd-ob-stack--fill.rsp-ob-stack {
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
.ptd-status {
|
||||
display: inline-block;
|
||||
padding: 2px 5px;
|
||||
|
||||
@@ -538,6 +538,113 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── 우측 거래 탭 — 투자대상과 동일 카드 박스 ── */
|
||||
.vtd-trade-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.vtd-trade-section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
|
||||
.vtd-trade-list {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vtd-trade-item {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.vtd-trade-item--click {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vtd-trade-item--click:hover {
|
||||
border-color: color-mix(in srgb, var(--accent, #3f7ef5) 40%, var(--se-border));
|
||||
background: color-mix(in srgb, var(--accent, #3f7ef5) 6%, var(--se-panel-card-bg));
|
||||
}
|
||||
|
||||
.vtd-trade-item--click:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent, #3f7ef5) 55%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.vtd-trade-item--buy {
|
||||
border-color: color-mix(in srgb, var(--up, #ef5350) 22%, var(--se-border));
|
||||
}
|
||||
|
||||
.vtd-trade-item--sell {
|
||||
border-color: color-mix(in srgb, var(--down, #26a69a) 22%, var(--se-border));
|
||||
}
|
||||
|
||||
.vtd-trade-side-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 40px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.vtd-trade-quote {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.vtd-trade-meta-field {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.vtd-trade-meta-field:first-of-type {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.vtd-trade-meta-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 5px 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--se-border);
|
||||
background: color-mix(in srgb, var(--se-panel-card-bg) 88%, var(--bg2, #12161c));
|
||||
color: var(--se-text);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.vtd-trade-meta-value--auto {
|
||||
color: var(--accent, #3f7ef5);
|
||||
font-weight: 600;
|
||||
border-color: color-mix(in srgb, var(--accent, #3f7ef5) 28%, var(--se-border));
|
||||
background: color-mix(in srgb, var(--accent, #3f7ef5) 8%, var(--se-panel-card-bg));
|
||||
}
|
||||
|
||||
.vtd-trade-meta-value--strategy {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vtd-muted {
|
||||
font-size: 12px;
|
||||
color: var(--se-text-muted);
|
||||
|
||||
@@ -1256,6 +1256,12 @@ export const DEFAULT_TREND_SEARCH_REQUEST: TrendSearchRequest = {
|
||||
|
||||
export const TREND_TIMEFRAMES = ['1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h', '1d', '1w', '1M'] as const;
|
||||
|
||||
export interface TrendSearchResultsResponse {
|
||||
results: TrendSearchResultDto[];
|
||||
timeframe?: string;
|
||||
scannedAt?: string;
|
||||
}
|
||||
|
||||
export async function scanTrendSearch(body: TrendSearchRequest): Promise<TrendSearchResultDto[]> {
|
||||
return (await request<TrendSearchResultDto[]>('/trend-search/scan', {
|
||||
method: 'POST',
|
||||
@@ -1263,6 +1269,10 @@ export async function scanTrendSearch(body: TrendSearchRequest): Promise<TrendSe
|
||||
})) ?? [];
|
||||
}
|
||||
|
||||
export async function fetchTrendSearchResults(): Promise<TrendSearchResultsResponse> {
|
||||
return (await request<TrendSearchResultsResponse>('/trend-search/results')) ?? { results: [] };
|
||||
}
|
||||
|
||||
export async function fetchTrendSearchDetail(
|
||||
market: string,
|
||||
timeframe?: string,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/**
|
||||
* 추세검색 API
|
||||
*/
|
||||
import { scanTrendSearch as apiScan, fetchTrendSearchDetail as apiDetail } from './backendApi';
|
||||
import {
|
||||
scanTrendSearch as apiScan,
|
||||
fetchTrendSearchDetail as apiDetail,
|
||||
fetchTrendSearchResults as apiResults,
|
||||
} from './backendApi';
|
||||
|
||||
export type {
|
||||
TrendSearchConditionDto,
|
||||
@@ -18,3 +22,7 @@ export async function scanTrendSearch(body: import('./backendApi').TrendSearchRe
|
||||
export async function fetchTrendSearchDetail(market: string, timeframe?: string) {
|
||||
return apiDetail(market, timeframe);
|
||||
}
|
||||
|
||||
export async function loadTrendSearchResults() {
|
||||
return apiResults();
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface TrendSearchAppSettings {
|
||||
limit: number;
|
||||
/** 스캔 대상 종목 수 (거래대금 상위) */
|
||||
scanLimit: number;
|
||||
/** 자동 갱신 ON (백엔드 스케줄러·화면 폴링) */
|
||||
autoRefreshEnabled: boolean;
|
||||
/** 자동 갱신 간격(초) */
|
||||
autoRefreshSeconds: number;
|
||||
/** 검색 후 상위 N위까지 투자대상 자동추가 */
|
||||
@@ -38,6 +40,7 @@ export const DEFAULT_TREND_SEARCH_APP_SETTINGS: TrendSearchAppSettings = {
|
||||
minTrendScore: DEFAULT_TREND_SEARCH_REQUEST.minTrendScore,
|
||||
limit: DEFAULT_TREND_SEARCH_REQUEST.limit,
|
||||
scanLimit: DEFAULT_TREND_SEARCH_REQUEST.scanLimit,
|
||||
autoRefreshEnabled: true,
|
||||
autoRefreshSeconds: 3,
|
||||
autoAddTargetsEnabled: false,
|
||||
autoAddTopRank: 5,
|
||||
@@ -65,6 +68,7 @@ export function resolveTrendSearchAppSettings(
|
||||
minTrendScore: clampInt(raw.minTrendScore, 0, 100, d.minTrendScore),
|
||||
limit: clampInt(limit, 5, 50, d.limit),
|
||||
scanLimit: clampInt(scanLimit, 20, 120, d.scanLimit),
|
||||
autoRefreshEnabled: raw.autoRefreshEnabled !== false,
|
||||
autoRefreshSeconds: TREND_SEARCH_REFRESH_OPTIONS.includes(sec as typeof TREND_SEARCH_REFRESH_OPTIONS[number])
|
||||
? sec
|
||||
: d.autoRefreshSeconds,
|
||||
@@ -126,3 +130,27 @@ export function formatTrendRefreshLabel(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}초`;
|
||||
return `${seconds / 60}분`;
|
||||
}
|
||||
|
||||
/** 설정 변경 감지용 (useEffect 의존성) */
|
||||
export function trendSearchSettingsSignature(s: TrendSearchAppSettings): string {
|
||||
return JSON.stringify(s);
|
||||
}
|
||||
|
||||
/** 캐시/설정 → 추세검색 조건 패널 필드 병합 (timeframe 등 화면 전용 값 유지) */
|
||||
export function mergeFiltersFromTrendSearchSettings(
|
||||
filters: TrendSearchRequest,
|
||||
settings: TrendSearchAppSettings,
|
||||
): TrendSearchRequest {
|
||||
return mergeTrendSearchRequest(filters, settings);
|
||||
}
|
||||
|
||||
/** 추세검색 조건 변경 → 저장할 전체 추세설정 */
|
||||
export function mergeTrendSearchSettingsFromRequest(
|
||||
current: TrendSearchAppSettings,
|
||||
req: Pick<TrendSearchRequest, BullishWeightKey | 'minTrendScore' | 'limit' | 'scanLimit'>,
|
||||
): TrendSearchAppSettings {
|
||||
return resolveTrendSearchAppSettings({
|
||||
...current,
|
||||
...extractTrendSearchAppSettings(req),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user