알림목록 전략지표 없음 문제 수정

This commit is contained in:
Macbook
2026-06-08 01:31:02 +09:00
parent 0229799383
commit 2f1a4d3bca
9 changed files with 230 additions and 36 deletions
+9
View File
@@ -9,6 +9,7 @@ import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage';
import {
isStrategyMissingOnServer,
markStrategyMissingFromPath,
unmarkStrategyMissingOnServer,
} from './strategyMissingCache';
export { isStrategyMissingOnServer } from './strategyMissingCache';
@@ -1097,6 +1098,14 @@ export async function loadStrategy(id: number): Promise<StrategyDto | null> {
return request<StrategyDto>(`/strategies/${id}`);
}
/** 알림·지표 표시 — missing 캐시 무시, 성공 시 캐시 해제 */
export async function loadStrategyForNotification(id: number): Promise<StrategyDto | null> {
if (!hasRegisteredUser() || id <= 0) return null;
const dto = await request<StrategyDto>(`/strategies/${id}`);
if (dto) unmarkStrategyMissingOnServer(id);
return dto;
}
/** 전략 저장 (id 없으면 신규, 있으면 수정) — 실패 시 Error throw */
export async function saveStrategy(dto: StrategyDto): Promise<StrategyDto> {
const res = await fetch(`${API_BASE}/strategies`, {
@@ -1,15 +1,16 @@
/**
* 매매 시그널 알림 — strategyId·전략명으로 StrategyDto 해석
* (서버 DB와 알림 이력의 strategyId 불일치 시 전략명 fallback)
* 매매 시그널 알림 — strategyId·전략명·종목별 live 설정으로 StrategyDto 해석
*/
import {
isStrategyMissingOnServer,
loadLiveStrategySettings,
loadStrategies,
loadStrategy,
loadStrategyForNotification,
type StrategyDto,
} from './backendApi';
import { reconcileStrategyMissingWithValidIds } from './strategyMissingCache';
import { reconcileStrategyMissingWithValidIds, unmarkStrategyMissingOnServer } from './strategyMissingCache';
import { coerceFiniteNumber } from './safeFormat';
import { hydrateStrategyDto, normalizeMarketCode } from './strategyHydrate';
import { extractVirtualConditions } from './virtualStrategyConditions';
let strategiesCache: StrategyDto[] | null = null;
let strategiesCacheAt = 0;
@@ -34,6 +35,28 @@ async function getStrategiesList(force = false): Promise<StrategyDto[]> {
return strategiesCache;
}
function findStrategyByName(list: StrategyDto[], name: string): StrategyDto | undefined {
const trimmed = name.trim();
if (!trimmed) return undefined;
const exact = list.find(s => s.name?.trim() === trimmed);
if (exact) return exact;
const lower = trimmed.toLowerCase();
return list.find(s => {
const n = s.name?.trim().toLowerCase();
return n === lower || n?.includes(lower) || lower.includes(n ?? '');
});
}
function pickStrategy(dto: StrategyDto, strategyId: number): ResolvedNotificationStrategy {
const hydrated = hydrateStrategyDto(dto);
unmarkStrategyMissingOnServer(strategyId);
return { strategy: hydrated, strategyId };
}
function hasExtractableConditions(strategy: StrategyDto): boolean {
return extractVirtualConditions(hydrateStrategyDto(strategy)).length > 0;
}
export interface ResolvedNotificationStrategy {
strategy: StrategyDto | undefined;
/** live-conditions·차트에 사용할 유효 strategyId */
@@ -44,16 +67,20 @@ export async function resolveNotificationStrategy(
strategyId: unknown,
strategyName?: string | null,
prefetched?: StrategyDto[],
market?: string | null,
): Promise<ResolvedNotificationStrategy> {
const normalizedId = normalizeStrategyId(strategyId);
const list = prefetched ?? await getStrategiesList();
if (normalizedId != null && !isStrategyMissingOnServer(normalizedId)) {
if (normalizedId != null) {
const fromList = list.find(s => s.id === normalizedId);
if (fromList) {
return { strategy: fromList, strategyId: normalizedId };
if (fromList?.id != null && hasExtractableConditions(fromList)) {
return pickStrategy(fromList, normalizedId);
}
const loaded = await loadStrategyForNotification(normalizedId);
if (loaded && extractVirtualConditions(loaded).length > 0) {
return { strategy: loaded, strategyId: normalizedId };
}
const loaded = await loadStrategy(normalizedId);
if (loaded) {
return { strategy: loaded, strategyId: normalizedId };
}
@@ -61,9 +88,31 @@ export async function resolveNotificationStrategy(
const name = strategyName?.trim();
if (name) {
const byName = list.find(s => s.name?.trim() === name);
const byName = findStrategyByName(list, name);
if (byName?.id != null && byName.id > 0) {
return { strategy: byName, strategyId: byName.id };
if (hasExtractableConditions(byName)) {
return pickStrategy(byName, byName.id);
}
const loaded = await loadStrategyForNotification(byName.id);
if (loaded) {
return { strategy: loaded, strategyId: byName.id };
}
}
}
const normalizedMarket = market ? normalizeMarketCode(market) : null;
if (normalizedMarket) {
try {
const live = await loadLiveStrategySettings(normalizedMarket);
const liveId = normalizeStrategyId(live.strategyId);
if (liveId != null) {
const loaded = await loadStrategyForNotification(liveId);
if (loaded) {
return { strategy: loaded, strategyId: liveId };
}
}
} catch {
/* ignore */
}
}
+63
View File
@@ -0,0 +1,63 @@
/**
* 전략 DTO 정규화 — API·DB에서 문자열 JSON·flowLayout 보정
*/
import type { LogicNode } from './strategyTypes';
import type { StrategyDto } from './backendApi';
import { extractVirtualConditions } from './virtualStrategyConditions';
import { decodeConditionForEditor, encodeConditionForSave } from './strategyConditionSerde';
import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage';
export function asLogicNode(raw: unknown): LogicNode | null {
if (!raw) return null;
if (typeof raw === 'string') {
const t = raw.trim();
if (!t) return null;
try {
return JSON.parse(t) as LogicNode;
} catch {
return null;
}
}
if (typeof raw === 'object') return raw as LogicNode;
return null;
}
export function normalizeMarketCode(market: string): string {
const s = market.trim().toUpperCase();
if (!s) return 'KRW-BTC';
return s.startsWith('KRW-') ? s : `KRW-${s}`;
}
/** buy/sell DSL 파싱·flowLayout 재인코딩으로 조건 추출 가능 상태로 보정 */
export function hydrateStrategyDto(strategy: StrategyDto): StrategyDto {
const buy = asLogicNode(strategy.buyCondition);
const sell = asLogicNode(strategy.sellCondition);
let next: StrategyDto = {
...strategy,
buyCondition: buy ?? undefined,
sellCondition: sell ?? undefined,
};
if (extractVirtualConditions(next).length > 0) return next;
const layout = strategy.flowLayout as StrategyFlowLayoutStore | null | undefined;
if (!layout) return next;
try {
const buyState = decodeConditionForEditor(buy);
const sellState = decodeConditionForEditor(sell);
const encodedBuy = encodeConditionForSave(buyState);
const encodedSell = encodeConditionForSave(sellState);
if (encodedBuy || encodedSell) {
next = {
...next,
buyCondition: encodedBuy ?? buy ?? undefined,
sellCondition: encodedSell ?? sell ?? undefined,
};
}
} catch {
/* flowLayout 보정 실패 — 원본 유지 */
}
return next;
}
@@ -42,6 +42,11 @@ export function markStrategyMissingOnServer(strategyId: number): void {
persistStrategyMissingCache();
}
export function unmarkStrategyMissingOnServer(strategyId: number): void {
if (strategyId <= 0) return;
if (strategyMissingIds.delete(strategyId)) persistStrategyMissingCache();
}
/** 전략 목록에 다시 나타난 ID 는 missing 에서 제거 */
export function reconcileStrategyMissingWithValidIds(validIds: Iterable<number>): void {
const valid = new Set(validIds);
+29 -2
View File
@@ -1,6 +1,6 @@
import { loadStrategyTimeframes, pinCandleWatch, repairStrategyTimeframes } from './backendApi';
import { loadStrategyTimeframes, pinCandleWatch, repairStrategyTimeframes, type StrategyDto } from './backendApi';
import { normalizeStartCandleType } from './strategyStartNodes';
import { syncStrategyTimeframesFromLayoutIfNeeded } from './strategyTimeframeSync';
import { collectUiEvaluationTimeframes, syncStrategyTimeframesFromLayoutIfNeeded } from './strategyTimeframeSync';
/** 전략 DSL 평가 분봉 전체 pin — 1m 집계·상위 봉 warm-up */
export async function pinStrategyEvaluationTimeframes(
@@ -22,3 +22,30 @@ export async function pinStrategyEvaluationTimeframes(
}
return timeframes;
}
/** 알림 목록 — DB sync/repair 없이 평가 분봉만 pin (서버 read-only 안전) */
export async function pinReadOnlyStrategyTimeframes(
market: string,
strategyId: number,
strategy?: StrategyDto,
): Promise<void> {
let timeframes: string[] = [];
try {
timeframes = collectUiEvaluationTimeframes(strategyId, strategy);
} catch {
/* ignore */
}
if (timeframes.length === 0) {
try {
const raw = await loadStrategyTimeframes(strategyId);
timeframes = raw.length ? raw : ['1m'];
} catch {
timeframes = ['1m'];
}
}
const unique = [...new Set(timeframes.map(tf => normalizeStartCandleType(tf)))];
if (!unique.includes('1m')) unique.unshift('1m');
for (const tf of unique) {
await pinCandleWatch(market, tf);
}
}
@@ -6,6 +6,7 @@ import { CONDITION_LABEL } from './strategyTypes';
import type { StrategyDto } from './backendApi';
import { formatIndicatorDisplayLabel } from './indicatorRegistry';
import { coerceFiniteNumber, safeToFixed } from './safeFormat';
import { asLogicNode } from './strategyHydrate';
export { coerceFiniteNumber } from './safeFormat';
@@ -79,8 +80,8 @@ export function extractVirtualConditions(strategy: StrategyDto | null | undefine
if (!strategy) return [];
const out: VirtualConditionRow[] = [];
const seen = new Set<string>();
walk(strategy.buyCondition as LogicNode | null, '1m', 'buy', out, seen);
walk(strategy.sellCondition as LogicNode | null, '1m', 'sell', out, seen);
walk(asLogicNode(strategy.buyCondition), '1m', 'buy', out, seen);
walk(asLogicNode(strategy.sellCondition), '1m', 'sell', out, seen);
return out;
}