237 lines
8.5 KiB
TypeScript
237 lines
8.5 KiB
TypeScript
// 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';
|