// Investor Flow — Confluence Rack (M22, slice 2) // // The aggregator that turns per-slot firing states into a redundancy-aware // "picture quality" assessment for a symbol. It: // • buckets fired slots into bullish vs bearish evidence (exit slots count as // bear evidence), // • applies redundancy-group discounting so correlated slots are not // triple-counted, // • labels the overall picture with an evidence-based quality tier, // • detects picture *changes* between consecutive evaluations for the // confluence-change alert producer. // // Pure: consumes slot assessments, emits a labeled evaluation. NO I/O. // ADR-0007: the output is a description of the picture ("strong-bullish", // "mixed") — evidence, never a directive to buy or sell. import { CONFLUENCE_SLOTS, type ConfluenceSlot } from './confluenceSlots.ts'; import { groupWeight, redundancyGroupFor } from './confluenceLibrary.ts'; /** Per-slot assessment fed into a rack evaluation. */ export interface SlotAssessment { id: string; /** fired = the slot's evidence is present; not-fired = data seen, evidence absent. */ state: 'fired' | 'not-fired'; /** Optional 0..1 confidence of the evaluation itself. */ confidence?: number; /** Optional free-text note for explain output. */ note?: string; } /** A symbol's confluence evaluation for one as-of timestamp. */ export interface ConfluenceEvaluation { symbol: string; /** ISO date (YYYY-MM-DD) the evaluation is for. */ asOf: string; assessments: SlotAssessment[]; /** Redundancy-discounted bullish evidence weight. */ bullEvidence: number; /** Redundancy-discounted bearish (bear + exit) evidence weight. */ bearEvidence: number; /** Raw counts (before redundancy discounting). */ bullCount: number; bearCount: number; /** Slots assessed (with data); sheds light on coverage. */ assessedCount: number; /** Net bullish evidence (bull - bear). Positive = more bullish evidence. */ netEvidence: number; /** Total recognized evidence (bull + bear). */ totalEvidence: number; /** Evidence-based picture label. */ quality: PictureQuality; } /** Evidence-based picture labels, ordered from most to least constructive. */ export type PictureQuality = | 'strong-bullish' | 'moderate-bullish' | 'weak-bullish' | 'mixed' | 'weak-bearish' | 'moderate-bearish' | 'strong-bearish' | 'sparse'; /** Ordering for change detection / tier comparison. */ export const QUALITY_RANK: Record = { 'strong-bullish': 7, 'moderate-bullish': 6, 'weak-bullish': 5, 'mixed': 4, 'weak-bearish': 3, 'moderate-bearish': 2, 'strong-bearish': 1, 'sparse': 0, }; /** Minimum total evidence before the picture is meaningful rather than 'sparse'. */ export const MIN_TOTAL_EVIDENCE = 1.0; /** Evidence ratio that separates "bullish/bearish" from "mixed". */ export const DIRECTION_RATIO = 0.6; /** Total evidence marking a "strong" vs "moderate" picture. */ export const STRONG_EVIDENCE = 4.0; /** Total evidence marking a "moderate" vs "weak" picture. */ export const MODERATE_EVIDENCE = 2.0; /** Which side a slot's firing evidence serves. */ export function slotBody(slot: ConfluenceSlot): 'bull' | 'bear' { return slot.body === 'exit' ? 'bear' : slot.body; } /** Is `id` a known slot? */ function isKnownSlot(id: string): boolean { return CONFLUENCE_SLOTS.some((s) => s.id === id); } /** * Evaluate a rack: bucket the fired assessments into bull/bear evidence, * discount per redundancy group, and label the picture. */ export function evaluateRack( symbol: string, asOf: string, assessments: SlotAssessment[], ): ConfluenceEvaluation { const slotById = new Map(CONFLUENCE_SLOTS.map((s) => [s.id, s])); const firedBull: string[] = []; const firedBear: string[] = []; let assessedCount = 0; for (const a of assessments) { if (!isKnownSlot(a.id)) continue; assessedCount += 1; if (a.state !== 'fired') continue; const body = slotBody(slotById.get(a.id)!); if (body === 'bull') firedBull.push(a.id); else firedBear.push(a.id); } // Group fired slots by redundancy group; ungrouped slots get singleton buckets. const bucket = (id: string): string => { const g = redundancyGroupFor(id); return g ? `g:${g.slots.join(',')}` : `solo:${id}`; }; const bullGroups = new Map(); const bearGroups = new Map(); for (const id of firedBull) { const key = bucket(id); const arr = bullGroups.get(key) ?? []; arr.push(id); bullGroups.set(key, arr); } for (const id of firedBear) { const key = bucket(id); const arr = bearGroups.get(key) ?? []; arr.push(id); bearGroups.set(key, arr); } let bullEvidence = 0; for (const ids of bullGroups.values()) bullEvidence += groupWeight(ids); let bearEvidence = 0; for (const ids of bearGroups.values()) bearEvidence += groupWeight(ids); const netEvidence = bullEvidence - bearEvidence; const totalEvidence = bullEvidence + bearEvidence; const quality = labelQuality(bullEvidence, bearEvidence, totalEvidence); return { symbol, asOf, assessments, bullEvidence, bearEvidence, bullCount: firedBull.length, bearCount: firedBear.length, assessedCount, netEvidence, totalEvidence, quality, }; } /** Label the picture from the discounted evidence totals. Pure. */ export function labelQuality(bullEvidence: number, bearEvidence: number, totalEvidence: number): PictureQuality { if (totalEvidence < MIN_TOTAL_EVIDENCE) return 'sparse'; const ratio = bullEvidence / totalEvidence; if (ratio >= DIRECTION_RATIO) return magnitudeLabel(bullEvidence, 'bullish'); if (ratio <= 1 - DIRECTION_RATIO) return magnitudeLabel(bearEvidence, 'bearish'); return 'mixed'; } function magnitudeLabel(strongSideEvidence: number, side: 'bullish' | 'bearish'): PictureQuality { if (strongSideEvidence >= STRONG_EVIDENCE) return side === 'bullish' ? 'strong-bullish' : 'strong-bearish'; if (strongSideEvidence >= MODERATE_EVIDENCE) return side === 'bullish' ? 'moderate-bullish' : 'moderate-bearish'; return side === 'bullish' ? 'weak-bullish' : 'weak-bearish'; } /** A detected change between two evaluations. */ export interface ConfluenceChange { changed: boolean; /** Direction of the picture change from the consumer's perspective. */ changeType: 'improved' | 'deteriorated' | 'unchanged'; current: PictureQuality; previous: PictureQuality | null; netEvidenceShift: number; /** Slot ids whose firing state flipped between the two evaluations. */ flippedSlotIds: string[]; } /** Minimum net-evidence shift that counts as a *noteworthy* change. */ export const MIN_CHANGE_SHIFT = 0.35; /** * Detect whether the picture changed meaningfully between two consecutive * evaluations of the same symbol. Used by the confluence-change alert producer. * Pure. */ export function detectPictureChange( previous: ConfluenceEvaluation | null, current: ConfluenceEvaluation, ): ConfluenceChange { if (previous === null) { return { changed: false, changeType: 'unchanged', current: current.quality, previous: null, netEvidenceShift: 0, flippedSlotIds: [] }; } const flippedSlotIds = flippedSlots(previous.assessments, current.assessments); const netEvidenceShift = current.netEvidence - previous.netEvidence; const rankShift = QUALITY_RANK[current.quality] - QUALITY_RANK[previous.quality]; if (rankShift > 0 || (rankShift === 0 && netEvidenceShift >= MIN_CHANGE_SHIFT)) { return { changed: true, changeType: 'improved', current: current.quality, previous: previous.quality, netEvidenceShift, flippedSlotIds }; } if (rankShift < 0 || (rankShift === 0 && netEvidenceShift <= -MIN_CHANGE_SHIFT)) { return { changed: true, changeType: 'deteriorated', current: current.quality, previous: previous.quality, netEvidenceShift, flippedSlotIds }; } return { changed: false, changeType: 'unchanged', current: current.quality, previous: previous.quality, netEvidenceShift, flippedSlotIds }; } /** Fired-state flips between two assessment sets. Pure. */ export function flippedSlots(prev: SlotAssessment[], curr: SlotAssessment[]): string[] { const prevMap = new Map(prev.map((a) => [a.id, a.state])); const currMap = new Map(curr.map((a) => [a.id, a.state])); const flipped: string[] = []; for (const id of prevMap.keys()) { if (currMap.has(id) && prevMap.get(id) !== currMap.get(id)) flipped.push(id); } for (const id of currMap.keys()) { if (!prevMap.has(id)) flipped.push(id); } return flipped; } /** Marker re-export so the module documents the exit→bear convention. */ export { isBearEvidence } from './confluenceSlots.ts';