가상투자 메뉴 기능 구현
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 가상투자 카드 — 종목×전략별 지표·조건 스냅샷
|
||||
* running 시: candles/watch pin + 3초마다 백엔드 live-conditions API (종목별 독립)
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { StrategyDto } from '../utils/backendApi';
|
||||
import {
|
||||
fetchLiveConditionStatus,
|
||||
pinCandleWatch,
|
||||
type LiveConditionRowDto,
|
||||
} from '../utils/backendApi';
|
||||
import {
|
||||
extractVirtualConditions,
|
||||
type VirtualConditionRow,
|
||||
} from '../utils/virtualStrategyConditions';
|
||||
|
||||
export interface VirtualIndicatorSnapshot {
|
||||
market: string;
|
||||
strategyId: number;
|
||||
timeframe: string;
|
||||
rows: Array<VirtualConditionRow & { currentValue: number | null }>;
|
||||
updatedAt: number;
|
||||
/** 백엔드 Ta4j 집계 일치율 (0~100) */
|
||||
matchRate?: number;
|
||||
}
|
||||
|
||||
function rowFallbackKey(row: Pick<VirtualConditionRow, 'side' | 'timeframe' | 'indicatorType' | 'conditionType' | 'targetValue' | 'plotKey'>): string {
|
||||
return `${row.side}:${row.timeframe}:${row.indicatorType}:${row.conditionType}:${row.targetValue ?? ''}:${row.plotKey}`;
|
||||
}
|
||||
|
||||
function liveFallbackKey(r: LiveConditionRowDto): string {
|
||||
return `${r.side}:${r.timeframe}:${r.indicatorType}:${r.conditionType}:${r.targetValue ?? ''}:${r.indicatorType}`;
|
||||
}
|
||||
|
||||
function mergeRows(
|
||||
base: VirtualConditionRow[],
|
||||
live: LiveConditionRowDto[],
|
||||
): Array<VirtualConditionRow & { currentValue: number | null }> {
|
||||
const liveById = new Map<string, LiveConditionRowDto>();
|
||||
const liveByKey = new Map<string, LiveConditionRowDto>();
|
||||
for (const r of live) {
|
||||
liveById.set(r.id, r);
|
||||
liveByKey.set(liveFallbackKey(r), r);
|
||||
}
|
||||
|
||||
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 base.map(row => {
|
||||
const liveRow = liveById.get(row.id) ?? liveByKey.get(rowFallbackKey(row));
|
||||
if (!liveRow) {
|
||||
return { ...row, currentValue: null as number | null };
|
||||
}
|
||||
return {
|
||||
...row,
|
||||
satisfied: liveRow.satisfied,
|
||||
thresholdLabel: liveRow.thresholdLabel,
|
||||
currentValue: liveRow.currentValue,
|
||||
targetValue: liveRow.targetValue ?? row.targetValue,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function buildSnapshot(
|
||||
market: string,
|
||||
strategyId: number,
|
||||
strategy: StrategyDto | undefined,
|
||||
running: boolean,
|
||||
): Promise<VirtualIndicatorSnapshot | null> {
|
||||
const baseRows = strategy ? extractVirtualConditions(strategy) : [];
|
||||
|
||||
if (!running) {
|
||||
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 })),
|
||||
updatedAt: Date.now(),
|
||||
matchRate: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const status = await fetchLiveConditionStatus(market, strategyId);
|
||||
if (!status || status.market !== market) {
|
||||
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 })),
|
||||
updatedAt: Date.now(),
|
||||
matchRate: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
market,
|
||||
strategyId,
|
||||
timeframe: status.timeframe || [...new Set(baseRows.map(r => r.timeframe))].join(', '),
|
||||
rows: mergeRows(baseRows, status.rows),
|
||||
updatedAt: status.updatedAt || Date.now(),
|
||||
matchRate: status.matchRate,
|
||||
};
|
||||
}
|
||||
|
||||
interface TargetRef {
|
||||
market: string;
|
||||
strategyId: number | null;
|
||||
}
|
||||
|
||||
export function useVirtualIndicatorSnapshots(
|
||||
targets: TargetRef[],
|
||||
strategies: StrategyDto[],
|
||||
running: boolean,
|
||||
pollMs = 3000,
|
||||
): Record<string, VirtualIndicatorSnapshot> {
|
||||
const [snapshots, setSnapshots] = useState<Record<string, VirtualIndicatorSnapshot>>({});
|
||||
const strategiesRef = useRef(strategies);
|
||||
strategiesRef.current = strategies;
|
||||
const pinnedRef = useRef<Set<string>>(new Set());
|
||||
const targetsKey = targets.map(t => `${t.market}:${t.strategyId ?? ''}`).join('|');
|
||||
|
||||
const pinTargets = useCallback(async () => {
|
||||
const strats = strategiesRef.current;
|
||||
const pins = new Set<string>();
|
||||
for (const t of targets) {
|
||||
if (t.strategyId == null) continue;
|
||||
const strat = strats.find(s => s.id === t.strategyId);
|
||||
const conditions = strat ? extractVirtualConditions(strat) : [];
|
||||
const tfs = conditions.length > 0
|
||||
? [...new Set(conditions.map(c => c.timeframe))]
|
||||
: ['1m'];
|
||||
for (const tf of tfs) {
|
||||
const key = `${t.market}:${tf}`;
|
||||
pins.add(key);
|
||||
if (!pinnedRef.current.has(key)) {
|
||||
await pinCandleWatch(t.market, tf);
|
||||
pinnedRef.current.add(key);
|
||||
}
|
||||
}
|
||||
const k1 = `${t.market}:1m`;
|
||||
if (!pins.has(k1) || !tfs.includes('1m')) {
|
||||
if (!pinnedRef.current.has(k1)) {
|
||||
await pinCandleWatch(t.market, '1m');
|
||||
pinnedRef.current.add(k1);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const k of pinnedRef.current) {
|
||||
if (!pins.has(k)) pinnedRef.current.delete(k);
|
||||
}
|
||||
}, [targets]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const strats = strategiesRef.current;
|
||||
if (running) await pinTargets();
|
||||
|
||||
const jobs = targets
|
||||
.filter(t => t.strategyId != null)
|
||||
.map(async t => {
|
||||
const strat = strats.find(s => s.id === t.strategyId);
|
||||
const snap = await buildSnapshot(t.market, t.strategyId!, strat, running);
|
||||
if (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 };
|
||||
let changed = false;
|
||||
for (const key of Object.keys(next)) {
|
||||
if (!activeMarkets.has(key)) {
|
||||
delete next[key];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [targets, running, pinTargets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (targets.length === 0) {
|
||||
setSnapshots({});
|
||||
pinnedRef.current.clear();
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
if (!running) return;
|
||||
const id = window.setInterval(() => void refresh(), pollMs);
|
||||
return () => clearInterval(id);
|
||||
}, [targetsKey, running, pollMs, refresh]);
|
||||
|
||||
return snapshots;
|
||||
}
|
||||
Reference in New Issue
Block a user