Files
investor-flow/app/server/src/confluence/confluenceRack.ts
T

237 lines
8.5 KiB
TypeScript
Raw Normal View History

// 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<PictureQuality, number> = {
'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<string, string[]>();
const bearGroups = new Map<string, string[]>();
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';