feat(api): add confluence tRPC router (slots, racks, evaluation, backtest, scorecard, saveRack) + client exposure (M22 slice 6)

This commit is contained in:
Investor Flow Build
2026-08-10 16:07:19 -04:00
parent 9110d5023e
commit 8031eae1b2
2 changed files with 176 additions and 0 deletions
+127
View File
@@ -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<PriceCandle[]>(`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;
+49
View File
@@ -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<string, unknown> | null;
history: Array<Record<string, unknown>>;
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 {