fix: IREN/ASTS/IRE stuck-pending + corridor feature
Fix stuck adapter_queue jobs (ASTS/IRE/IREN stuck pending forever): 1. fetchSpec early-return paths (cooldown checks) now update job status to 'backoff' with last_attempt set and 30s backoff_until, instead of returning without any status change. Prevents jobs from being re-processed every drain cycle indefinitely. 2. Wrap adapter.fetchOne() in 30s Promise.race timeout. A hung HTTP request no longer blocks the entire per-source promise chain forever, preventing all subsequent jobs for that source. Also includes corridor/confluence feature, tiered quote schedules, cache improvements, and HoldingsBookView refinements.
This commit is contained in:
+121
-14
@@ -38,6 +38,9 @@ import { CONFLUENCE_SLOT_IDS } from '../confluence/confluenceSlots.ts';
|
||||
import { ConfluenceRepository, rackFromSlots } from '../db/confluenceRepository.ts';
|
||||
import { runSlotBacktest, signalHistoryToStats, type ConfluenceFireEvent } from '../confluence/confluenceBacktest.ts';
|
||||
import { detectPictureChange } from '../confluence/confluenceRack.ts';
|
||||
import { runCorridorBacktest, aggregateBacktest } from '../analysis/corridorBacktest.ts';
|
||||
import { runConfluenceEvaluationCycle } from '../confluence/confluenceEngine.ts';
|
||||
import { CorridorRepository } from '../db/corridorRepository.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// X cookie credential helpers. Loads AES-256-GCM encrypted ct0/auth_token from
|
||||
@@ -452,8 +455,8 @@ const marketRouter = router({
|
||||
const { classifyRegime } = await import('../macro/MacroRegime.ts');
|
||||
|
||||
const symbol = input.symbol.toUpperCase();
|
||||
try { await ctx.cache.ensureInDemand(symbol, 'equity'); } catch { /* ignore */ }
|
||||
try { await ctx.cache.ensureInDemand(BENCHMARK_SYMBOL, 'etf'); } catch { /* ignore */ }
|
||||
try { await ctx.cache.bumpToWatched(symbol, 'equity'); } catch { /* ignore */ }
|
||||
try { await ctx.cache.bumpToWatched(BENCHMARK_SYMBOL, 'etf'); } catch { /* ignore */ }
|
||||
|
||||
const metaEntry = await ctx.cache.get<SymbolMeta>(`yfinance:symbol:${symbol}`);
|
||||
const meta = metaEntry.value;
|
||||
@@ -516,7 +519,7 @@ const marketRouter = router({
|
||||
|
||||
for (const s of symbolsToLoad) {
|
||||
const kind = s === BENCHMARK_SYMBOL || MARKET_ROTATION_UNIVERSE.some((u) => u.symbol === s) ? 'etf' : 'equity';
|
||||
try { await ctx.cache.ensureInDemand(s, kind); } catch { /* ignore */ }
|
||||
try { await ctx.cache.bumpToWatched(s, kind); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const candleKeys = symbolsToLoad.map((s) => `yfinance:candles:${s}:1d`);
|
||||
@@ -776,8 +779,8 @@ const marketRouter = router({
|
||||
// SPY trend + VIX from cache/queue only (ADR-0009: no live Yahoo on request path).
|
||||
let spyCandles: PriceCandle[] = [];
|
||||
try {
|
||||
try { await ctx.cache.ensureInDemand('SPY', 'etf'); } catch { /* ignore */ }
|
||||
try { await ctx.cache.ensureInDemand('^VIX', 'index'); } catch { /* ignore */ }
|
||||
try { await ctx.cache.bumpToWatched('SPY', 'etf'); } catch { /* ignore */ }
|
||||
try { await ctx.cache.bumpToWatched('^VIX', 'index'); } catch { /* ignore */ }
|
||||
const spyEntry = await ctx.cache.get<PriceCandle[]>('yfinance:candles:SPY:1d');
|
||||
spyCandles = (spyEntry?.value ?? []) as PriceCandle[];
|
||||
factors.spy1M = totalReturnPct(spyCandles, 30 * 86_400_000);
|
||||
@@ -981,7 +984,7 @@ const marketRouter = router({
|
||||
|
||||
const symbols = [BENCHMARK_SYMBOL, ...MARKET_ROTATION_UNIVERSE.map((s) => s.symbol)];
|
||||
for (const sym of symbols) {
|
||||
try { await ctx.cache.ensureInDemand(sym, 'etf'); } catch { /* ignore */ }
|
||||
try { await ctx.cache.bumpToWatched(sym, 'etf'); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const keys = symbols.map((s) => `yfinance:candles:${s}:1d`);
|
||||
@@ -1137,7 +1140,7 @@ const marketRouter = router({
|
||||
|
||||
const symbols = [BENCHMARK_SYMBOL, ...dedupedDefs.map((s) => s.symbol)];
|
||||
for (const sym of symbols) {
|
||||
try { await ctx.cache.ensureInDemand(sym, 'etf'); } catch { /* ignore */ }
|
||||
try { await ctx.cache.bumpToWatched(sym, 'etf'); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const keys = symbols.map((s) => `yfinance:candles:${s}:1d`);
|
||||
@@ -1281,7 +1284,7 @@ const marketRouter = router({
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { buildSeasonalitySnapshot, upcomingSimpleEvents } = await import('../analysis/seasonality.ts');
|
||||
const symbol = (input?.symbol ?? 'SPY').toUpperCase();
|
||||
try { await ctx.cache.ensureInDemand(symbol, symbol === 'SPY' ? 'etf' : 'equity'); } catch { /* ignore */ }
|
||||
try { await ctx.cache.bumpToWatched(symbol, symbol === 'SPY' ? 'etf' : 'equity'); } catch { /* ignore */ }
|
||||
const entry = await ctx.cache.get<PriceCandle[]>(`yfinance:candles:${symbol}:1d`);
|
||||
const candles = (entry?.value ?? []) as PriceCandle[];
|
||||
const snapshot = buildSeasonalitySnapshot(
|
||||
@@ -1429,7 +1432,7 @@ const marketRouter = router({
|
||||
for (const sym of symbols) {
|
||||
if (quoteMap.has(sym) && quoteMap.get(sym)!.price != null) continue;
|
||||
if (sym.includes('.') || sym.length > 5) continue;
|
||||
try { await ctx.cache.ensureInDemand(sym, 'equity'); } catch { /* ignore */ }
|
||||
try { await ctx.cache.bumpToWatched(sym, 'equity'); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1467,7 +1470,7 @@ const marketRouter = router({
|
||||
ctx.db.prepare(
|
||||
'INSERT INTO rotation_custom_symbols (id, owner_id, symbol, name, grp, created_at) VALUES (?,?,?,?,?,?)'
|
||||
).run(crypto.randomUUID(), userId, input.symbol, input.name ?? input.symbol, input.group ?? 'Custom', new Date().toISOString());
|
||||
try { await ctx.cache.ensureInDemand(input.symbol, 'etf'); } catch { /* ignore */ }
|
||||
try { await ctx.cache.bumpToWatched(input.symbol, 'etf'); } catch { /* ignore */ }
|
||||
return { added: true };
|
||||
}),
|
||||
|
||||
@@ -3399,7 +3402,7 @@ const dealerStudyRouter = router({
|
||||
);
|
||||
// Ensure daily candles are in demand for later grading (ADR-0009).
|
||||
try {
|
||||
await ctx.cache.ensureInDemand(input.symbol.toUpperCase(), 'equity');
|
||||
await ctx.cache.bumpToWatched(input.symbol.toUpperCase(), 'equity');
|
||||
} catch { /* optional */ }
|
||||
return { ok: true as const, id, loggedAt };
|
||||
}),
|
||||
@@ -3835,7 +3838,7 @@ const mentorLedgerRouter = router({
|
||||
: row.logged_at;
|
||||
|
||||
try {
|
||||
await ctx.cache.ensureInDemand(row.symbol, 'equity');
|
||||
await ctx.cache.bumpToWatched(row.symbol, 'equity');
|
||||
} catch { /* optional */ }
|
||||
|
||||
const candles = ctx.db.prepare(
|
||||
@@ -5382,7 +5385,7 @@ const mirrorRouter = router({
|
||||
// ─── Confluence Signal Engine (M22) ─────────────────────────────────────────
|
||||
|
||||
const confluenceRouter = router({
|
||||
/** The read-only 34-slot catalog, grouped by family, with slot metadata. */
|
||||
/** The read-only slot catalog, grouped by family, with slot metadata. */
|
||||
slots: publicProcedure.query(async () => {
|
||||
return {
|
||||
slots: CONFLUENCE_SLOTS.map((s) => ({
|
||||
@@ -5476,7 +5479,7 @@ const confluenceRouter = router({
|
||||
id: z.string().min(1).max(80).optional(),
|
||||
name: z.string().min(1).max(80),
|
||||
description: z.string().max(300).optional(),
|
||||
slotIds: z.array(z.string()).min(1).max(34),
|
||||
slotIds: z.array(z.string()).min(1).max(CONFLUENCE_SLOT_IDS.length),
|
||||
}))
|
||||
.mutation(({ ctx, input }) => {
|
||||
const repo = new ConfluenceRepository(ctx.db);
|
||||
@@ -5498,6 +5501,110 @@ const confluenceRouter = router({
|
||||
}
|
||||
return rack;
|
||||
}),
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Price Corridor (M24): valuation-corridor snapshots + backtest ledger + link
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Latest valuation-corridor snapshot for a symbol, with SPY market context. */
|
||||
corridorSnapshot: publicProcedure
|
||||
.input(z.object({ symbol: z.string().min(1).max(12) }))
|
||||
.query(({ ctx, input }) => {
|
||||
const repo = new CorridorRepository(ctx.db);
|
||||
const symbol = input.symbol.toUpperCase();
|
||||
const snapshot = repo.latestSnapshot(symbol);
|
||||
const spy = repo.latestSnapshot('SPY');
|
||||
const series = repo.snapshotsForSymbol(symbol, 90);
|
||||
const corridor = (snapshot?.corridor1yLow ?? null) !== null
|
||||
? {
|
||||
low: snapshot!.corridor1yLow,
|
||||
high: snapshot!.corridor1yHigh,
|
||||
median: snapshot!.corridor1yMedian,
|
||||
currentPE: snapshot!.trailingPE ?? snapshot!.forwardPE,
|
||||
fairValue1y: snapshot!.fairValue1y,
|
||||
impliedUpside1y: snapshot!.impliedUpside1y,
|
||||
pePercentile1y: snapshot!.pePercentile1y,
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
symbol,
|
||||
snapshot,
|
||||
spy,
|
||||
series,
|
||||
corridor,
|
||||
};
|
||||
}),
|
||||
|
||||
/** Estuary user's corridor watchlist (tickers surfaced in the Corridor panel). */
|
||||
corridorWatchlist: protectedProcedure
|
||||
.query(({ ctx }) => {
|
||||
const repo = new CorridorRepository(ctx.db);
|
||||
return { symbols: repo.listWatchlist(ctx.userId!) };
|
||||
}),
|
||||
|
||||
/** Add a ticker to the corridor watchlist. */
|
||||
corridorWatchlistAdd: protectedProcedure
|
||||
.input(z.object({ symbol: z.string().min(1).max(12).transform((s) => s.toUpperCase()) }))
|
||||
.mutation(({ ctx, input }) => {
|
||||
const repo = new CorridorRepository(ctx.db);
|
||||
repo.addToWatchlist(ctx.userId!, input.symbol);
|
||||
return { symbols: repo.listWatchlist(ctx.userId!) };
|
||||
}),
|
||||
|
||||
/** Remove a ticker from the corridor watchlist. */
|
||||
corridorWatchlistRemove: protectedProcedure
|
||||
.input(z.object({ symbol: z.string().min(1).max(12).transform((s) => s.toUpperCase()) }))
|
||||
.mutation(({ ctx, input }) => {
|
||||
const repo = new CorridorRepository(ctx.db);
|
||||
repo.removeFromWatchlist(ctx.userId!, input.symbol);
|
||||
return { symbols: repo.listWatchlist(ctx.userId!) };
|
||||
}),
|
||||
|
||||
/** Corridor-method backtest scorecard: @alojoh's rated names vs actual forward returns. */
|
||||
corridorBacktest: publicProcedure
|
||||
.query(({ ctx }) => {
|
||||
const repo = new CorridorRepository(ctx.db);
|
||||
const rows = repo.listBacktests();
|
||||
return {
|
||||
rows,
|
||||
aggregate: aggregateBacktest(
|
||||
rows.map((r) => ({
|
||||
articleDate: r.articleDate,
|
||||
articleId: r.articleId,
|
||||
rankingType: r.rankingType as 'entry_1y' | 'entry_90d',
|
||||
topSymbols: r.topSymbols,
|
||||
bottomSymbols: r.bottomSymbols,
|
||||
topAvgReturn: r.topAvgReturn,
|
||||
bottomAvgReturn: r.bottomAvgReturn,
|
||||
spread: r.spread,
|
||||
isWin: r.isWin,
|
||||
horizonDays: r.horizonDays,
|
||||
resolvableCount: 0,
|
||||
})),
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
/** Replay the corridor-method backtest corpus against cached candles (offline; idempotent). */
|
||||
corridorBacktestRun: publicProcedure
|
||||
.mutation(async ({ ctx }) => {
|
||||
const { runCorridorBacktest } = await import('../analysis/corridorBacktest.ts');
|
||||
const result = await runCorridorBacktest(ctx.db, async (symbol) => {
|
||||
const entry = await ctx.cache.get<PriceCandle[]>(`yfinance:candles:${symbol.toUpperCase()}:1d`);
|
||||
return (entry?.value ?? []) as PriceCandle[];
|
||||
});
|
||||
return { grades: result.grades.length, resolved: result.aggregate.resolved, aggregate: result.aggregate };
|
||||
}),
|
||||
|
||||
/** Force a confluence evaluation cycle for the current symbol now (debug/admin). */
|
||||
runEvaluationNow: protectedProcedure
|
||||
.input(z.object({ symbol: z.string().min(1).max(12).optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const summary = await runConfluenceEvaluationCycle(ctx.db, ctx.cache, {
|
||||
symbols: input?.symbol ? [input.symbol] : undefined,
|
||||
});
|
||||
return summary;
|
||||
}),
|
||||
});
|
||||
|
||||
export const appRouter = router({
|
||||
|
||||
Reference in New Issue
Block a user