알림팝업 수정, 전략평가 로직 개선

This commit is contained in:
Macbook
2026-06-05 10:32:40 +09:00
parent 238dd0cebe
commit 5278177bbb
9 changed files with 484 additions and 116 deletions
@@ -35,6 +35,7 @@ import {
resolveStrategyPrimaryTimeframe,
} from '../../utils/strategyToChartIndicators';
import type { ChartOverlayToggleKey } from '../../utils/chartOverlayVisibility';
import StrategyOscillatorPanes from './StrategyOscillatorPanes';
interface Props {
symbol: string;
@@ -154,10 +155,21 @@ const BacktestAnalysisChart: React.FC<Props> = ({
return () => { cancelled = true; };
}, [strategyId]);
/**
* overlayVisibility 가 없는 경우(BacktestHistoryPage 컨텍스트)에는
* 보조지표(오실레이터)를 TradingChart sub-pane 이 아닌 아래쪽
* StrategyOscillatorPanes 섹션에서 표시한다.
*/
const showOscillatorPanel = !overlayVisibility;
const baseIndicators = useMemo(() => {
let inds: IndicatorConfig[];
if (strategy) {
inds = buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig);
// 오실레이터 패널을 별도 섹션으로 표시할 때는 TradingChart 에서 제거
if (showOscillatorPanel) {
inds = inds.filter(i => isOverlayType(i.type));
}
inds = inds.filter(i => i.timeframeVisibility?.[chartTimeframe] !== false);
} else {
inds = defaultOverlayIndicators(getParams);
@@ -166,7 +178,7 @@ const BacktestAnalysisChart: React.FC<Props> = ({
inds = ensurePaperChartOverlays(inds, getParams, getVisualConfig);
}
return inds;
}, [strategy, getParams, getVisualConfig, chartTimeframe, overlayVisibility]);
}, [strategy, getParams, getVisualConfig, chartTimeframe, overlayVisibility, showOscillatorPanel]);
const indicators = useMemo(() => {
let inds = overlayVisibility
@@ -455,6 +467,9 @@ const BacktestAnalysisChart: React.FC<Props> = ({
<div className="btd-analysis-error"> .</div>
)}
</div>
{showOscillatorPanel && !loading && bars.length > 0 && (
<StrategyOscillatorPanes bars={bars} strategy={strategy} />
)}
</div>
</div>
);
@@ -113,6 +113,16 @@ function saveHiddenIds(ids: Set<string>) {
patchUiPreferences({ tradeNotifications: { hiddenIds: [...ids] } });
}
/** 전체닫기·삭제 시각 (epoch 초) 로드 */
function loadSuppressBefore(): number {
return getUiPreferences().tradeNotifications?.suppressBefore ?? 0;
}
/** 전체닫기·삭제 시각 저장 */
function saveSuppressBefore(ts: number, immediate = false) {
patchUiPreferences({ tradeNotifications: { suppressBefore: ts } }, immediate);
}
export function useTradeNotification(): TradeNotificationContextValue {
const ctx = useContext(TradeNotificationContext);
if (!ctx) {
@@ -157,6 +167,12 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
* 같은 틱에 수신된 신호 ID 까지 readIds 에 포함시킬 수 있다.
*/
const allSeenIdsRef = useRef<Set<string>>(new Set());
/**
* 전체닫기·삭제 실행 시각 (epoch 초).
* candleTime < suppressBefore 인 오래된 신호는 팝업을 띄우지 않는다.
* 백엔드 backfill 이 STOMP 로 수십 일치 과거 신호를 재전송해도 무시한다.
*/
const suppressBeforeRef = useRef<number>(loadSuppressBefore());
const [detailSignal, setDetailSignal] = useState<TradeSignalInfo | null>(null);
const [detailCentered, setDetailCentered] = useState(false);
@@ -176,7 +192,29 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
*/
const cacheRead = loadReadIds();
const memRead = readIdsRef.current;
const read = new Set([...cacheRead, ...memRead]);
const suppressBefore = suppressBeforeRef.current;
/**
* suppressBefore 이전 캔들 신호: readIds 에 추가하고 isRead=true 처리.
* DB 에 남아있는 오래된 신호가 폴링에서 미읽음으로 잡히는 것을 방지한다.
*/
const suppressed = dtos.filter(
d => suppressBefore > 0 && (d.candleTime ?? 0) < suppressBefore,
);
let effectiveRead = new Set([...cacheRead, ...memRead]);
if (suppressed.length > 0) {
const suppressedIds = suppressed.map(
d => `${d.market}:${d.candleTime}:${d.signalType}`,
);
const nextRead = new Set([...effectiveRead, ...suppressedIds]);
if (nextRead.size !== effectiveRead.size) {
readIdsRef.current = nextRead;
effectiveRead = nextRead;
saveReadIds(nextRead, true);
}
}
const read = effectiveRead;
const hidden = opts?.rawServerList ? new Set<string>() : loadHiddenIds();
const items = dtos
.map(d => dtoToItem(d, read))
@@ -252,6 +290,8 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
readIdsRef.current = merged;
setReadIds(merged);
}
// 전체닫기·삭제 시각 복원 (재접속 후에도 오래된 신호 팝업 방지)
suppressBeforeRef.current = loadSuppressBefore();
setToastNotifications([]);
void refreshHistory({ serverOnly: true, rawServerList: true });
}, [refreshHistory, settingsLoaded, settingsSessionKey]);
@@ -276,19 +316,29 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
// 수신한 모든 ID 를 동기적으로 누적 (dismissAllToasts 가 참조)
allSeenIdsRef.current.add(id);
/**
* ── 오래된 신호 억제 (핵심 필터) ───────────────────────────────────────
* 전체닫기·삭제 시각(suppressBefore) 이전에 시작된 캔들의 신호는
* 백엔드 backfill 이 STOMP 로 재전송해도 팝업을 띄우지 않는다.
* candleTime 은 epoch 초 단위이고 suppressBefore 도 epoch 초이다.
*/
const isSuppressed =
suppressBeforeRef.current > 0 &&
signal.candleTime < suppressBeforeRef.current;
/**
* 읽음 여부 3중 확인:
* 1. readIdsRef.current — dismissAllToasts 가 즉시 업데이트하는 인메모리 ref
* 2. loadReadIds() — DB 캐시 기반 값
* 3. allNotificationsRef — dismissAllToasts 의 functional updater 가
* isRead:true 를 예약했지만 readIdsRef 에 아직 반영되지 않은 경우 커버
* 3. allNotificationsRef — functional updater 가 isRead:true 예약 후 ref 미반영 커버
*/
const alreadyRead =
isSuppressed ||
readIdsRef.current.has(id) ||
loadReadIds().has(id) ||
(allNotificationsRef.current.find(n => n.id === id)?.isRead === true);
// readIds 에 누락된 읽음 항목 보정 (다음 STOMP/폴링에서 즉시 필터링)
// readIds 에 누락된 읽음 항목 보정
if (alreadyRead && !readIdsRef.current.has(id)) {
const next = new Set(readIdsRef.current);
next.add(id);
@@ -389,6 +439,11 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
...allSeenIdsRef.current, // 동일 틱에 수신됐지만 ref 미반영 ID
]);
// 전체닫기 시각 기록 — 이 시각 이전 candleTime 신호는 이후 팝업 금지
const nowSec = Math.floor(Date.now() / 1000);
suppressBeforeRef.current = nowSec;
saveSuppressBefore(nowSec, true);
// 1) 인메모리 ref 즉시 갱신 (다음 addNotification 호출에서 즉시 참조)
readIdsRef.current = nextRead;
@@ -490,18 +545,21 @@ export const TradeNotificationProvider: React.FC<ProviderProps> = ({
]),
];
// 전체 삭제 시각 기록 — 이 시각 이전 candleTime 신호는 이후 팝업 금지
const nowSec = Math.floor(Date.now() / 1000);
suppressBeforeRef.current = nowSec;
saveSuppressBefore(nowSec, true);
/**
* readIds 를 new Set() 으로 초기화하지 않는다.
* 초기화하면 백엔드가 삭제된 신호를 STOMP 로 재전송할 때
* readIds 에 없어서 팝업이 다시 뜨는 버그가 발생한다.
* 대신 삭제된 ID 를 readIds 에 추가하여 재전송 신호를 필터링한다.
* readIds 에 삭제된 ID 를 추가 (초기화 X).
* 백엔드가 동일 신호를 STOMP 로 재전송해도 suppressBefore + readIds 이중 필터.
*/
const nextRead = new Set([...readIdsRef.current, ...ids]);
readIdsRef.current = nextRead;
setReadIds(nextRead);
saveReadIds(nextRead, true);
// hiddenIds 초기화 (전체 삭제 후 목록 완전히 비움)
// hiddenIds 초기화 (전체 삭제 후 목록 완전히 비움)
saveHiddenIds(new Set());
setToastNotifications([]);
+6
View File
@@ -33,6 +33,12 @@ export interface UiPreferences {
tradeNotifications?: {
readIds?: string[];
hiddenIds?: string[];
/**
* 전체닫기·전체삭제 실행 시각 (epoch 초).
* candleTime < suppressBefore 인 신호는 팝업 표시하지 않는다.
* 백엔드 backfill 로 오래된 신호가 STOMP 재전송되어도 팝업이 뜨지 않게 한다.
*/
suppressBefore?: number;
/** 매매 시그널 알림 목록 화면 — list | grid */
listLayout?: 'list' | 'grid';
/** 그리드형 열 배치 프리셋 id (tradeNotificationGridPresets) */