feat(api): add confluence tRPC router (slots, racks, evaluation, backtest, scorecard, saveRack) + client exposure (M22 slice 6)
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user