From 8031eae1b29aee2b5ccdf475f3075b6b1a1168e1 Mon Sep 17 00:00:00 2001 From: Investor Flow Build Date: Mon, 10 Aug 2026 16:07:19 -0400 Subject: [PATCH] feat(api): add confluence tRPC router (slots, racks, evaluation, backtest, scorecard, saveRack) + client exposure (M22 slice 6) --- app/server/src/trpc/router.ts | 127 ++++++++++++++++++++++++++++++++++ app/src/lib/trpc.ts | 49 +++++++++++++ 2 files changed, 176 insertions(+) diff --git a/app/server/src/trpc/router.ts b/app/server/src/trpc/router.ts index 70e3a85..4ceeff4 100644 --- a/app/server/src/trpc/router.ts +++ b/app/server/src/trpc/router.ts @@ -33,6 +33,11 @@ import { computeOwnershipPercentages, fetchAndPersistFloat } from '../services/s import { EdgarAdapter } from '../adapters/EdgarAdapter.ts'; import { OptionsAdapter, parseOptionChainRows } from '../adapters/OptionsAdapter.ts'; import type { OptionChainRow, OptionGreeks } from '../adapters/OptionsAdapter.ts'; +import { CONFLUENCE_SLOTS, slotsByFamily, type SlotFamily } from '../confluence/confluenceSlots.ts'; +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'; // --------------------------------------------------------------------------- // X cookie credential helpers. Loads AES-256-GCM encrypted ct0/auth_token from @@ -5374,6 +5379,127 @@ const mirrorRouter = router({ }), }); +// ─── Confluence Signal Engine (M22) ───────────────────────────────────────── + +const confluenceRouter = router({ + /** The read-only 34-slot catalog, grouped by family, with slot metadata. */ + slots: publicProcedure.query(async () => { + return { + slots: CONFLUENCE_SLOTS.map((s) => ({ + id: s.id, + name: s.name, + family: s.family, + body: s.body, + granularity: s.granularity, + explain: s.explain, + })), + slotIds: CONFLUENCE_SLOT_IDS, + families: ['technical', 'institutional', 'macro', 'seasonal', 'flows', 'sentiment'] as SlotFamily[], + }; + }), + + /** List rack definitions (system + the calling user's own). */ + racks: publicProcedure.query(({ ctx }) => { + const repo = new ConfluenceRepository(ctx.db); + const system = repo.listSystemRacks(); + const user = ctx.userId ? repo.listUserRacks(ctx.userId) : []; + return { system, user }; + }), + + /** Latest evaluation for a symbol+rack, with the picture + change hint. */ + evaluation: publicProcedure + .input(z.object({ + symbol: z.string().min(1).max(12), + rackId: z.string().min(1).optional(), + limit: z.number().int().min(1).max(60).optional(), + })) + .query(({ ctx, input }) => { + const repo = new ConfluenceRepository(ctx.db); + const symbol = input.symbol.toUpperCase(); + const evals = repo.listEvaluationsForSymbol(symbol); + const rackId = input.rackId ?? (repo.listSystemRacks()[0]?.id ?? null); + const forRack = rackId ? evals.filter((e) => e.rackId === rackId) : evals; + const latest = forRack.length > 0 ? forRack[0] : null; + + let change = null; + if (forRack.length >= 2) { + const prev = forRack[1]; + change = detectPictureChange( + { symbol, asOf: prev.asOf, assessments: prev.assessments, bullEvidence: prev.bullEvidence, bearEvidence: prev.bearEvidence, bullCount: prev.bullCount, bearCount: prev.bearCount, assessedCount: prev.assessedCount, netEvidence: prev.netEvidence, totalEvidence: prev.totalEvidence, quality: prev.quality }, + { symbol, asOf: latest!.asOf, assessments: latest!.assessments, bullEvidence: latest!.bullEvidence, bearEvidence: latest!.bearEvidence, bullCount: latest!.bullCount, bearCount: latest!.bearCount, assessedCount: latest!.assessedCount, netEvidence: latest!.netEvidence, totalEvidence: latest!.totalEvidence, quality: latest!.quality }, + ); + } + + return { + rackId, + symbol, + latest, + history: forRack.slice(0, input.limit ?? 10), + change, + }; + }), + + /** Run the slot backtest for a symbol from cached daily candles. */ + backtest: publicProcedure + .input(z.object({ + symbol: z.string().min(1).max(12), + weeks: z.number().int().min(2).max(12).optional(), + })) + .query(async ({ ctx, input }) => { + const repo = new ConfluenceRepository(ctx.db); + const symbol = input.symbol.toUpperCase(); + const entry = await ctx.cache.get(`yfinance:candles:${symbol}:1d`); + const candles = (entry?.value ?? []) as PriceCandle[]; + + // Historic fire events come from signal history rows (as-of was the fire date). + const signals = repo.listSignalsForSymbol(symbol); + const fires: ConfluenceFireEvent[] = signals.map((s) => ({ symbol, asOf: s.firedAt, slotId: s.slotId })); + return runSlotBacktest(symbol, candles, fires, input.weeks ?? 4); + }), + + /** Per-slot reliability scorecard from resolved signal history. */ + scorecard: publicProcedure + .input(z.object({ symbol: z.string().min(1).max(12).optional() })) + .query(({ ctx, input }) => { + const symbol = input?.symbol?.toUpperCase(); + const stats = signalHistoryToStats(ctx.db, symbol); + return { + slots: stats, + // Row semantics: clear, evidence-based. + note: 'Follow-through is measured over the standard 4-week horizon. Reliability is context, not a promise.', + }; + }), + + /** Create or update a user-owned rack. */ + saveRack: protectedProcedure + .input(z.object({ + 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), + })) + .mutation(({ ctx, input }) => { + const repo = new ConfluenceRepository(ctx.db); + const userId = ctx.userId!; + // Validate slot ids against the catalog. + for (const id of input.slotIds) { + if (!CONFLUENCE_SLOT_IDS.includes(id)) throw new TRPCError({ code: 'BAD_REQUEST', message: `Unknown slot id: ${id}` }); + } + let rack = repo.getRack(input.id ?? ''); + if (rack && rack.ownerId !== userId) throw new TRPCError({ code: 'FORBIDDEN', message: 'Not your rack.' }); + if (rack) { + repo.updateRackSlots(rack.id, input.slotIds, input.name, input.description ?? null); + rack = repo.getRack(rack.id)!; + } else { + rack = rackFromSlots(input.id ?? `${userId}-${Date.now()}`, input.name, input.slotIds, { + ownerId: userId, description: input.description ?? null, + }); + repo.saveRack(rack); + } + return rack; + }), +}); + export const appRouter = router({ auth: authRouter, onboarding: onboardingRouter, market: marketRouter, dashboard: dashboardRouter, admin: adminRouter, alerts: alertsRouter, edgar: edgarRouter, institutional: institutionalRouter, @@ -5386,6 +5512,7 @@ export const appRouter = router({ funds: fundsRouter, mirror: mirrorRouter, symbols: symbolsRouter, + confluence: confluenceRouter, }); export type AppRouter = typeof appRouter; diff --git a/app/src/lib/trpc.ts b/app/src/lib/trpc.ts index 6349195..3d9382e 100644 --- a/app/src/lib/trpc.ts +++ b/app/src/lib/trpc.ts @@ -1426,6 +1426,55 @@ export const api = { commentary: () => trpcQuery<{ shortTerm: string; longTerm: string; disclaimer: string }>("macro.commentary"), regimeClassify: () => trpcQuery<{ regime: string; confidence?: number; factors?: unknown }>("macro.regimeClassify"), }, + confluence: { + slots: () => trpcQuery<{ + slots: Array<{ id: string; name: string; family: string; body: string; granularity: string; explain: string }>; + slotIds: string[]; + families: string[]; + }>("confluence.slots", {}), + racks: () => trpcQuery<{ + system: Array<{ id: string; ownerId: string | null; name: string; slotIds: string[]; description: string | null; isSystem: boolean; createdAt: string }>; + user: Array<{ id: string; ownerId: string | null; name: string; slotIds: string[]; description: string | null; isSystem: boolean; createdAt: string }>; + }>("confluence.racks", {}), + evaluation: (symbol: string, opts?: { rackId?: string; limit?: number }) => + trpcQuery<{ + rackId: string | null; + symbol: string; + latest: Record | null; + history: Array>; + change: { + changed: boolean; + changeType: string; + current: string; + previous: string | null; + netEvidenceShift: number; + flippedSlotIds: string[]; + } | null; + }>("confluence.evaluation", { symbol, ...(opts?.rackId ? { rackId: opts.rackId } : {}), ...(opts?.limit ? { limit: opts.limit } : {}) }), + backtest: (symbol: string, opts?: { weeks?: number }) => + trpcQuery<{ + symbol: string; + fires: number; + horizonDays: number; + slots: Array<{ + slotId: string; + name: string; + body: string; + direction: string; + fires: number; + resolvedFires: number; + confirmedFires: number; + falseAlarms: number; + hitRate: number | null; + reliabilityWeight: number; + }>; + sampleSizeCaveat: string; + }>("confluence.backtest", { symbol, ...(opts?.weeks ? { weeks: opts.weeks } : {}) }), + scorecard: (symbol?: string) => + trpcQuery<{ slots: unknown[]; note: string }>("confluence.scorecard", symbol ? { symbol } : {}), + saveRack: (input: { id?: string; name: string; description?: string; slotIds: string[] }) => + trpcMutate<{ id: string; name: string; slotIds: string[]; isSystem: boolean; description: string | null }>("confluence.saveRack", input), + }, }; export interface TickerSuggestion {