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 { EdgarAdapter } from '../adapters/EdgarAdapter.ts';
|
||||||
import { OptionsAdapter, parseOptionChainRows } from '../adapters/OptionsAdapter.ts';
|
import { OptionsAdapter, parseOptionChainRows } from '../adapters/OptionsAdapter.ts';
|
||||||
import type { OptionChainRow, OptionGreeks } 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
|
// 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({
|
export const appRouter = router({
|
||||||
auth: authRouter, onboarding: onboardingRouter, market: marketRouter, dashboard: dashboardRouter,
|
auth: authRouter, onboarding: onboardingRouter, market: marketRouter, dashboard: dashboardRouter,
|
||||||
admin: adminRouter, alerts: alertsRouter, edgar: edgarRouter, institutional: institutionalRouter,
|
admin: adminRouter, alerts: alertsRouter, edgar: edgarRouter, institutional: institutionalRouter,
|
||||||
@@ -5386,6 +5512,7 @@ export const appRouter = router({
|
|||||||
funds: fundsRouter,
|
funds: fundsRouter,
|
||||||
mirror: mirrorRouter,
|
mirror: mirrorRouter,
|
||||||
symbols: symbolsRouter,
|
symbols: symbolsRouter,
|
||||||
|
confluence: confluenceRouter,
|
||||||
});
|
});
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|
||||||
|
|||||||
@@ -1426,6 +1426,55 @@ export const api = {
|
|||||||
commentary: () => trpcQuery<{ shortTerm: string; longTerm: string; disclaimer: string }>("macro.commentary"),
|
commentary: () => trpcQuery<{ shortTerm: string; longTerm: string; disclaimer: string }>("macro.commentary"),
|
||||||
regimeClassify: () => trpcQuery<{ regime: string; confidence?: number; factors?: unknown }>("macro.regimeClassify"),
|
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 {
|
export interface TickerSuggestion {
|
||||||
|
|||||||
Reference in New Issue
Block a user