feat(confluence): add confluence slot catalog, library, rack, and sentiment source (M22 slice 2)
This commit is contained in:
@@ -0,0 +1,169 @@
|
|||||||
|
// Investor Flow — confluenceRack.test.ts
|
||||||
|
// Pure-logic tests for rack evaluation, redundancy discounting, and
|
||||||
|
// picture-change detection.
|
||||||
|
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
evaluateRack,
|
||||||
|
labelQuality,
|
||||||
|
detectPictureChange,
|
||||||
|
flippedSlots,
|
||||||
|
slotBody,
|
||||||
|
} from '../confluenceRack.ts';
|
||||||
|
import type { SlotAssessment } from '../confluenceRack.ts';
|
||||||
|
import { confluenceslotById, CONFLUENCE_SLOTS } from '../confluenceSlots.ts';
|
||||||
|
|
||||||
|
const fired = (id: string): SlotAssessment => ({ id, state: 'fired' });
|
||||||
|
const notFired = (id: string): SlotAssessment => ({ id, state: 'not-fired' });
|
||||||
|
|
||||||
|
describe('confluence catalog', () => {
|
||||||
|
it('defines exactly 34 slots across all six families', () => {
|
||||||
|
assert.equal(CONFLUENCE_SLOTS.length, 34);
|
||||||
|
const families = new Set(CONFLUENCE_SLOTS.map((s) => s.family));
|
||||||
|
assert.deepEqual([...families].sort(), ['flows', 'institutional', 'macro', 'seasonal', 'sentiment', 'technical']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every defined slot has a primary-rule-safe explain sentence', () => {
|
||||||
|
const directivePattern = /\b(should|recommend|buy now|sell now|go long|go short|recommendation|buy the|sell the|long position|short position)\b/i;
|
||||||
|
for (const s of CONFLUENCE_SLOTS) {
|
||||||
|
assert.ok(s.explain.length > 10, `${s.id} explain note too short`);
|
||||||
|
assert.ok(!directivePattern.test(s.explain), `${s.id} explain note reads like a directive: ${s.explain}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('slotBody', () => {
|
||||||
|
it('exit slots map to bear evidence, bull/bear stay put', () => {
|
||||||
|
assert.equal(slotBody(confluenceslotById.get('goldenCross')!), 'bull');
|
||||||
|
assert.equal(slotBody(confluenceslotById.get('deathCross')!), 'bear');
|
||||||
|
assert.equal(slotBody(confluenceslotById.get('consumerSentimentHigh')!), 'bear');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('labelQuality', () => {
|
||||||
|
it('returns sparse below the minimum evidence threshold', () => {
|
||||||
|
assert.equal(labelQuality(0.5, 0.2, 0.7), 'sparse');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels strong-bullish from a one-sided heavy bull weighting', () => {
|
||||||
|
assert.equal(labelQuality(7, 0.5, 7.5), 'strong-bullish');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels strong-bearish from heavy bear + exit evidence', () => {
|
||||||
|
assert.equal(labelQuality(0.5, 6, 6.5), 'strong-bearish');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels moderate-bullish between strong and weak', () => {
|
||||||
|
assert.equal(labelQuality(2.5, 0.5, 3), 'moderate-bullish');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels weak-bullish when barely directional', () => {
|
||||||
|
assert.equal(labelQuality(1.7, 0.7, 2.4), 'weak-bullish');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('labels mixed when neither side holds 60% of evidence', () => {
|
||||||
|
assert.equal(labelQuality(1.2, 1.1, 2.3), 'mixed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('evaluateRack', () => {
|
||||||
|
it('applies redundancy discount within a redundancy group', () => {
|
||||||
|
// goldenCross + trendAlignment + pullbackToEMA21 all fire (all same group).
|
||||||
|
// groupWeight = 1 + 0.5 + 0.25 = 1.75, not 3.0.
|
||||||
|
const ev = evaluateRack('PLTR', '2026-01-05', [
|
||||||
|
fired('goldenCross'),
|
||||||
|
fired('trendAlignment'),
|
||||||
|
fired('pullbackToEMA21'),
|
||||||
|
]);
|
||||||
|
assert.equal(ev.bullCount, 3);
|
||||||
|
assert.ok(Math.abs(ev.bullEvidence - 1.75) < 1e-9, `bullEvidence=${ev.bullEvidence}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts distinct families as independent evidence', () => {
|
||||||
|
// One technical + one institutional + one seasonal + one flows: 4 independent facts.
|
||||||
|
const ev = evaluateRack('PLTR', '2026-01-05', [
|
||||||
|
fired('goldenCross'),
|
||||||
|
fired('instNetActivePositive'),
|
||||||
|
fired('seasonalFavorableMonth'),
|
||||||
|
fired('etfFlowPositive'),
|
||||||
|
]);
|
||||||
|
assert.equal(ev.bullEvidence, 4);
|
||||||
|
assert.equal(ev.bullCount, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts exit slots as bear evidence', () => {
|
||||||
|
const ev = evaluateRack('PLTR', '2026-01-05', [
|
||||||
|
fired('deathCross'),
|
||||||
|
fired('rsiOverbought'),
|
||||||
|
fired('insiderInformedSell'),
|
||||||
|
]);
|
||||||
|
assert.equal(ev.bearCount, 3);
|
||||||
|
assert.equal(ev.bullCount, 0);
|
||||||
|
assert.equal(ev.quality, 'moderate-bearish');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores unknown slot ids but still counts assessed coverage', () => {
|
||||||
|
const ev = evaluateRack('PLTR', '2026-01-05', [
|
||||||
|
fired('goldenCross'),
|
||||||
|
fired('notARealSlot'),
|
||||||
|
notFired('relVolume'),
|
||||||
|
]);
|
||||||
|
assert.equal(ev.assessedCount, 2);
|
||||||
|
assert.equal(ev.bullCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixed picture when bull and bear evidence are both present and balanced', () => {
|
||||||
|
const ev = evaluateRack('NVDA', '2026-01-05', [
|
||||||
|
fired('goldenCross'),
|
||||||
|
fired('instNetActivePositive'),
|
||||||
|
fired('deathCross'),
|
||||||
|
fired('insiderInformedSell'),
|
||||||
|
]);
|
||||||
|
assert.equal(ev.quality, 'mixed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectPictureChange', () => {
|
||||||
|
it('reports improved when quality rank rises', () => {
|
||||||
|
const prev = evaluateRack('PLTR', '2026-01-05', [fired('deathCross')]);
|
||||||
|
const curr = evaluateRack('PLTR', '2026-01-07', [fired('goldenCross')]);
|
||||||
|
const change = detectPictureChange(prev, curr);
|
||||||
|
assert.equal(change.changed, true);
|
||||||
|
assert.equal(change.changeType, 'improved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports deteriorated when quality rank falls', () => {
|
||||||
|
const prev = evaluateRack('PLTR', '2026-01-05', [fired('goldenCross'), fired('instNetActivePositive')]);
|
||||||
|
const curr = evaluateRack('PLTR', '2026-01-07', [fired('deathCross'), fired('insiderInformedSell')]);
|
||||||
|
const change = detectPictureChange(prev, curr);
|
||||||
|
assert.equal(change.changed, true);
|
||||||
|
assert.equal(change.changeType, 'deteriorated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports unchanged for identical evaluations', () => {
|
||||||
|
const ev = evaluateRack('PLTR', '2026-01-05', [fired('goldenCross')]);
|
||||||
|
const change = detectPictureChange(ev, ev);
|
||||||
|
assert.equal(change.changed, false);
|
||||||
|
assert.equal(change.changeType, 'unchanged');
|
||||||
|
assert.deepEqual(change.flippedSlotIds, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flag lists flipped slot ids', () => {
|
||||||
|
const prev = evaluateRack('PLTR', '2026-01-05', [fired('goldenCross'), notFired('relVolume')]);
|
||||||
|
const curr = evaluateRack('PLTR', '2026-01-07', [notFired('goldenCross'), fired('relVolume')]);
|
||||||
|
const change = detectPictureChange(prev, curr);
|
||||||
|
assert.deepEqual(change.flippedSlotIds.sort(), ['goldenCross', 'relVolume']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('flippedSlots', () => {
|
||||||
|
it('finds flips including newly-added and removed slots', () => {
|
||||||
|
const flips = flippedSlots(
|
||||||
|
[fired('goldenCross'), notFired('relVolume')],
|
||||||
|
[notFired('goldenCross'), fired('relVolume'), fired('cotPositioning')],
|
||||||
|
);
|
||||||
|
assert.deepEqual(flips.sort(), ['cotPositioning', 'goldenCross', 'relVolume']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// Investor Flow — Confluence Library (M22, slice 2)
|
||||||
|
//
|
||||||
|
// Redundancy modeling for the Confluence Signal Engine. Correlated slots that
|
||||||
|
// measure the same underlying phenomenon ("golden cross" and "trend alignment"
|
||||||
|
// both read the same moving-average structure) are grouped so a rack does not
|
||||||
|
// over-count them as independent evidence.
|
||||||
|
//
|
||||||
|
// Pure data + accessors. NO I/O. ADR-0007: these are evidence-accounting rules,
|
||||||
|
// never recommendations.
|
||||||
|
|
||||||
|
import { CONFLUENCE_SLOTS, type ConfluenceSlot, type SlotFamily } from './confluenceSlots.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A redundancy group: slots that corroborate each other because they consume
|
||||||
|
* overlapping inputs. Within a group, the first firing slot contributes full
|
||||||
|
* evidence and each additional firing slot contributes a discounted margin
|
||||||
|
* (`REDUNDANCY_DISCOUNT`, applied multiplicatively) so three correlated
|
||||||
|
* bullish slots are not counted as three independent bullish facts.
|
||||||
|
*/
|
||||||
|
export interface RedundancyGroup {
|
||||||
|
family: SlotFamily;
|
||||||
|
slots: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default discount applied per additional firing slot within a group. */
|
||||||
|
export const REDUNDANCY_DISCOUNT = 0.5;
|
||||||
|
|
||||||
|
/** Evidence redundancy groups keyed by the phenomenon they all measure. */
|
||||||
|
export const REDUNDANCY_GROUPS: RedundancyGroup[] = [
|
||||||
|
{ family: 'technical', slots: ['goldenCross', 'trendAlignment', 'pullbackToEMA21'] },
|
||||||
|
{ family: 'technical', slots: ['timeSeriesMomentum12_1', 'relativeStrengthVsSpy', 'momentumForward'] },
|
||||||
|
{ family: 'technical', slots: ['macdBullish', 'rsiOversold'] },
|
||||||
|
{ family: 'technical', slots: ['macdBearish', 'rsiOverbought'] },
|
||||||
|
{ family: 'technical', slots: ['relVolume', 'volumeProfileShelf'] },
|
||||||
|
{ family: 'institutional', slots: ['instNetActivePositive', 'insiderInformedBuy30d', 'new13da'] },
|
||||||
|
{ family: 'macro', slots: ['ratesRegime', 'macroRegimeUp'] },
|
||||||
|
{ family: 'macro', slots: ['consumerSentimentLow', 'breadthThrust'] },
|
||||||
|
{ family: 'seasonal', slots: ['seasonalFavorableMonth', 'winterHalfOn', 'electionCycleFavorableYear'] },
|
||||||
|
{ family: 'flows', slots: ['etfFlowPositive', 'cotPositioning'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Map slot id -> the group it belongs to (a slot may be in one group). */
|
||||||
|
export const slotGroupIndex: ReadonlyMap<string, RedundancyGroup> = new Map(
|
||||||
|
REDUNDANCY_GROUPS.flatMap((g) => g.slots.map((id) => [id, g] as const)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The group a slot belongs to, if any. */
|
||||||
|
export function redundancyGroupFor(slotId: string): RedundancyGroup | undefined {
|
||||||
|
return slotGroupIndex.get(slotId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective evidence weight for a set of firing slots *within one group*.
|
||||||
|
* First firing slot contributes 1.0; each additional contributes
|
||||||
|
* `REDUNDANCY_DISCOUNT` of the previous marginal — so N same-group firings
|
||||||
|
* weigh 1 + 0.5 + 0.25 + ... , never N.
|
||||||
|
*/
|
||||||
|
export function groupWeight(firingSlotIds: string[]): number {
|
||||||
|
if (firingSlotIds.length === 0) return 0;
|
||||||
|
let w = 0;
|
||||||
|
let margin = 1;
|
||||||
|
for (let i = 0; i < firingSlotIds.length; i++) {
|
||||||
|
w += margin;
|
||||||
|
margin *= REDUNDANCY_DISCOUNT;
|
||||||
|
}
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A flat description of every slot for explain-note / docs rendering. */
|
||||||
|
export function slotCatalog(): { id: string; name: string; family: SlotFamily; body: string; explain: string }[] {
|
||||||
|
return CONFLUENCE_SLOTS.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
family: s.family,
|
||||||
|
body: s.body,
|
||||||
|
explain: s.explain,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience: number of slots per family. */
|
||||||
|
export function familyCounts(): Record<SlotFamily, number> {
|
||||||
|
const counts: Record<SlotFamily, number> = { technical: 0, institutional: 0, macro: 0, seasonal: 0, flows: 0, sentiment: 0 };
|
||||||
|
for (const s of CONFLUENCE_SLOTS) counts[s.family] += 1;
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-export the slot type for consumers of the library. */
|
||||||
|
export type { ConfluenceSlot } from './confluenceSlots.ts';
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
// 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';
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// Investor Flow — Confluence Slot Catalog (M22, slice 2)
|
||||||
|
//
|
||||||
|
// The 34-slot confluence inventory for the Confluence Signal Engine. Each slot is
|
||||||
|
// a named, independently-evaluable check whose *firing* state contributes bullish
|
||||||
|
// or bearish evidence about a symbol's entry/exit quality.
|
||||||
|
//
|
||||||
|
// Pure data + retrieval helpers: NO I/O. ADR-0007: slots describe "quality of the
|
||||||
|
// picture" — never a buy/sell recommendation. Each slot has an ADR-safe explain
|
||||||
|
// note phrased as evidence ("relative strength is in the upper decile") rather
|
||||||
|
// than a directive ("buy").
|
||||||
|
|
||||||
|
/** Slot body: which side of the picture a firing slot supports. */
|
||||||
|
export type SlotBody = 'bull' | 'bear' | 'exit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "exit" is a subclass of bear evidence: it indicates weakening conditions for an
|
||||||
|
* existing position rather than a fresh short thesis. Exit slots are still counted
|
||||||
|
* as bear evidence (risk-off side of the picture).
|
||||||
|
*/
|
||||||
|
export const isBearEvidence = (body: SlotBody): boolean => body === 'bear' || body === 'exit';
|
||||||
|
|
||||||
|
/** The seven confluence families (independent evidence axes). */
|
||||||
|
export type SlotFamily =
|
||||||
|
| 'technical'
|
||||||
|
| 'institutional'
|
||||||
|
| 'macro'
|
||||||
|
| 'seasonal'
|
||||||
|
| 'flows'
|
||||||
|
| 'sentiment';
|
||||||
|
|
||||||
|
/** Candle granularity a slot consumes (daily is the engine default). */
|
||||||
|
export type SlotGranularity = '1d' | '1wk';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A confluence slot definition. `id` is the stable machine key; `name` plain
|
||||||
|
* English; `explain` is the ADR-0007-safe evidence sentence surfaced to users.
|
||||||
|
*/
|
||||||
|
export interface ConfluenceSlot {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
family: SlotFamily;
|
||||||
|
body: SlotBody;
|
||||||
|
/** Default candle granularity the slot evaluates on. */
|
||||||
|
granularity: SlotGranularity;
|
||||||
|
/** Short evidence sentence shown in UI / explain notes. */
|
||||||
|
explain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Complete 34-slot confluence catalog in evaluation order. */
|
||||||
|
export const CONFLUENCE_SLOTS: ConfluenceSlot[] = [
|
||||||
|
// ---------------------------------------------------------------- technical
|
||||||
|
{ id: 'goldenCross', name: 'Golden Cross', family: 'technical', body: 'bull', granularity: '1wk', explain: 'The 50-window average has crossed above the 200-window average, a widely-watched trend-quality marker.' },
|
||||||
|
{ id: 'deathCross', name: 'Death Cross', family: 'technical', body: 'exit', granularity: '1wk', explain: 'The 50-window average has crossed below the 200-window average, a commonly-cited trend-weakening marker.' },
|
||||||
|
{ id: 'trendAlignment', name: 'Trend Alignment', family: 'technical', body: 'bull', granularity: '1wk', explain: 'Price sits above both the 50- and 200-window averages, indicating an aligned long-term trend.' },
|
||||||
|
{ id: 'timeSeriesMomentum12_1', name: '12-1 Momentum', family: 'technical', body: 'bull', granularity: '1d', explain: 'The 12-month-return-minus-1-month gauge is positive, a period-validated trend-following signal.' },
|
||||||
|
{ id: 'relativeStrengthVsSpy', name: 'Relative Strength vs SPY', family: 'technical', body: 'bull', granularity: '1d', explain: 'The symbol is outperforming the S&P 500 over the 12-1 momentum window.' },
|
||||||
|
{ id: 'rsiOversold', name: 'RSI Oversold', family: 'technical', body: 'bull', granularity: '1d', explain: 'Momentum gauge oversold, a bounce-prone condition after sustained weakness.' },
|
||||||
|
{ id: 'rsiOverbought', name: 'RSI Overbought', family: 'technical', body: 'exit', granularity: '1d', explain: 'Momentum gauge overbought, a stretched condition after sustained strength.' },
|
||||||
|
{ id: 'macdBullish', name: 'MACD Bullish', family: 'technical', body: 'bull', granularity: '1d', explain: 'The MACD line sits above its signal line, indicating positive short-term momentum.' },
|
||||||
|
{ id: 'macdBearish', name: 'MACD Bearish', family: 'technical', body: 'exit', granularity: '1d', explain: 'The MACD line sits below its signal line, indicating negative short-term momentum.' },
|
||||||
|
{ id: 'relVolume', name: 'Relative Volume', family: 'technical', body: 'bull', granularity: '1d', explain: 'Current volume is notably above its recent average, increasing the confidence contribution of concurrent signals.' },
|
||||||
|
{ id: 'volumeProfileShelf', name: 'Volume-Profile Shelf', family: 'technical', body: 'bull', granularity: '1d', explain: 'Price is trading near a high-volume shelf of the volume profile, historically a supported level.' },
|
||||||
|
{ id: 'fibLevelCluster', name: 'Fib Level Cluster', family: 'technical', body: 'bull', granularity: '1d', explain: 'Price is near a Fibonacci level that confluences with other technical levels, a commonly-watched confluence.' },
|
||||||
|
{ id: 'volatilityRegimeLow', name: 'Low-Volatility Regime', family: 'technical', body: 'bull', granularity: '1d', explain: 'Realized volatility is in a low percentile versus its own history, historically favorable to trend continuation.' },
|
||||||
|
{ id: 'pullbackToEMA21', name: 'Pullback to EMA-21', family: 'technical', body: 'bull', granularity: '1d', explain: 'Price has pulled back toward a rising short-term average within a broader uptrend.' },
|
||||||
|
{ id: 'momentumForward', name: 'Forward Momentum', family: 'technical', body: 'bull', granularity: '1d', explain: 'Short-horizon rate of change is positive on a daily basis.' },
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- institutional
|
||||||
|
{ id: 'instNetActivePositive', name: 'Active Funds Net Positive', family: 'institutional', body: 'bull', granularity: '1d', explain: 'Net institutional flows from active managers are positive in the latest observable quarter.' },
|
||||||
|
{ id: 'insiderInformedBuy30d', name: 'Informed Insider Buys (30d)', family: 'institutional', body: 'bull', granularity: '1d', explain: 'Informed insiders have been net buyers over the trailing 30 days, the strongest insider-feedback class.' },
|
||||||
|
{ id: 'insiderInformedSell', name: 'Informed Insider Selling', family: 'institutional', body: 'exit', granularity: '1d', explain: 'Informed insiders have been net sellers over the trailing window, a caution signal.' },
|
||||||
|
{ id: 'new13da', name: 'New 13D/A Disclosure', family: 'institutional', body: 'bull', granularity: '1d', explain: 'A fresh Schedule 13D/A was disclosed, signaling an activist or concentrated position just formed.' },
|
||||||
|
{ id: 'instBuyZoneProximity', name: 'Institutional Buy-Zone Proximity', family: 'institutional', body: 'bull', granularity: '1d', explain: 'Price is near the estimated institutional accumulation zone from the flow engine estimation model.' },
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------- macro
|
||||||
|
{ id: 'consumerSentimentLow', name: 'Consumer Sentiment Low', family: 'macro', body: 'bull', granularity: '1wk', explain: 'The consumer-sentiment gauge is in its low historical band, a contrarian backdrop historically followed by higher forward returns.' },
|
||||||
|
{ id: 'consumerSentimentHigh', name: 'Consumer Sentiment High', family: 'macro', body: 'exit', granularity: '1wk', explain: 'The consumer-sentiment gauge is in its high historical band, a backdrop historically followed by lower forward returns.' },
|
||||||
|
{ id: 'ratesRegime', name: 'Falling-Rates Regime', family: 'macro', body: 'bull', granularity: '1wk', explain: 'Interest-rate expectations are on a falling trajectory, historically supportive of equities.' },
|
||||||
|
{ id: 'macroRegimeUp', name: 'Macro Regime Up', family: 'macro', body: 'bull', granularity: '1wk', explain: 'Leading macroeconomic indicators are on a rising trajectory.' },
|
||||||
|
{ id: 'breadthThrust', name: 'Breadth Thrust', family: 'macro', body: 'bull', granularity: '1wk', explain: 'Market breadth has surged from oversold, a historically constructive regime marker.' },
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- seasonal
|
||||||
|
{ id: 'seasonalFavorableMonth', name: 'Seasonally Favorable Month', family: 'seasonal', body: 'bull', granularity: '1d', explain: 'The calendar month is historically favorable for the symbol, a mild cyclical tailwind.' },
|
||||||
|
{ id: 'winterHalfOn', name: 'Winter Half Indicator', family: 'seasonal', body: 'bull', granularity: '1d', explain: 'The November-April half of the year is historically favorable, a mild cyclical tailwind.' },
|
||||||
|
{ id: 'electionCycleFavorableYear', name: 'Election-Cycle Favorable Year', family: 'seasonal', body: 'bull', granularity: '1d', explain: 'The current year of the presidential cycle is historically above-average for equities.' },
|
||||||
|
{ id: 'nearTurnOfMonth', name: 'Turn-of-Month', family: 'seasonal', body: 'bull', granularity: '1d', explain: 'The calendar is near the turn of the month, historically a benign pocket of liquidity.' },
|
||||||
|
{ id: 'nearQuarterEnd', name: 'Near Quarter-End', family: 'seasonal', body: 'bull', granularity: '1d', explain: 'The calendar is near quarter-end, historically associated with institutional window dressing.' },
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------- flows
|
||||||
|
{ id: 'etfFlowPositive', name: 'ETF Inflows', family: 'flows', body: 'bull', granularity: '1d', explain: 'The symbol is seeing net positive ETF inflows, evidence of systematic buying pressure.' },
|
||||||
|
{ id: 'cotPositioning', name: 'COT Positioning', family: 'flows', body: 'bull', granularity: '1wk', explain: 'CFTC futures positioning is in a supportive band, evidence of aligned speculative flows.' },
|
||||||
|
{ id: 'rotationIncipient', name: 'Incipient Sector Rotation', family: 'flows', body: 'bull', granularity: '1d', explain: 'Cross-sectional relative-strength analysis flags the symbol as an emerging rotation target.' },
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- sentiment
|
||||||
|
{ id: 'commentatorSentiment', name: 'Informed Commentator Sentiment', family: 'sentiment', body: 'bull', granularity: '1d', explain: 'Informed commentators tracked via the configured sentiment source are net-positive on the symbol in the measurement window.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Indexed by slot id for O(1) lookup. */
|
||||||
|
export const confluenceslotById: ReadonlyMap<string, ConfluenceSlot> = new Map(
|
||||||
|
CONFLUENCE_SLOTS.map((s) => [s.id, s]),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** All slot ids, stable evaluation order. */
|
||||||
|
export const CONFLUENCE_SLOT_IDS: readonly string[] = CONFLUENCE_SLOTS.map((s) => s.id);
|
||||||
|
|
||||||
|
/** Type guard: is `id` a known confluence slot? */
|
||||||
|
export function isConfluenceSlot(id: string): id is string {
|
||||||
|
return confluenceslotById.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Slots grouped by family. */
|
||||||
|
export function slotsByFamily(family: SlotFamily): ConfluenceSlot[] {
|
||||||
|
return CONFLUENCE_SLOTS.filter((s) => s.family === family);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DSL helper removed — catalog written literally above for full control. */
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Investor Flow — SentimentSource interface (M22, slice 2)
|
||||||
|
//
|
||||||
|
// Abstraction over "informed commentator sentiment". Phase 1 wires tweet
|
||||||
|
// sentiment from the existing X cookie adapter; later phases can swap in other
|
||||||
|
// sources (X radar, curated news sentiment) behind the same interface.
|
||||||
|
//
|
||||||
|
// The rack consumes this so the `commentatorSentiment` slot is evaluable without
|
||||||
|
// coupling the confluence engine to any specific social provider. ADR-0007 / ADR-0009:
|
||||||
|
// sentiment here is an evidence input (with sample size + window), sourced only
|
||||||
|
// through rate-limited adapters — never a recommendation.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A windowed, sample-sized sentiment measurement for one symbol.
|
||||||
|
* `netSentiment` is in [-1, 1]: +1 = fully bullish, -1 = fully bearish.
|
||||||
|
* `sampleSize` and `collectedAt` keep the measurement honest: thin windows are
|
||||||
|
* low-authority evidence and the rack can discount them.
|
||||||
|
*/
|
||||||
|
export interface CommentatorSentiment {
|
||||||
|
symbol: string;
|
||||||
|
/** Trading days the measurement window spans. */
|
||||||
|
windowDays: number;
|
||||||
|
/** Number of individual comments/tweets sampled in the window. */
|
||||||
|
sampleSize: number;
|
||||||
|
/** Bullish fraction minus bearish fraction in [-1, 1]. */
|
||||||
|
netSentiment: number;
|
||||||
|
bullCount: number;
|
||||||
|
bearCount: number;
|
||||||
|
neutralCount: number;
|
||||||
|
/** ISO timestamp the window closed. */
|
||||||
|
collectedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Any provider that can produce a sentiment measurement for a symbol.
|
||||||
|
* Returning `null` means the source has no usable data (not an error).
|
||||||
|
*/
|
||||||
|
export interface SentimentSource {
|
||||||
|
readonly kind: 'commentatorSentiment';
|
||||||
|
/** Minimum sample size before the rack treats a measurement as evidence. */
|
||||||
|
readonly minSampleSize: number;
|
||||||
|
/** Fetch the latest windowed measurement for `symbol`. Safe to call often; implementers throttle. */
|
||||||
|
fetch(symbol: string, opts?: { windowDays?: number }): Promise<CommentatorSentiment | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience: does a measurement carry enough evidence to be admitted? */
|
||||||
|
export function sentimentIsUsable(src: SentimentSource, s: CommentatorSentiment | null): s is CommentatorSentiment {
|
||||||
|
return s !== null && s.sampleSize >= src.minSampleSize && s.netSentiment !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Directional read of a usable sentiment measurement. */
|
||||||
|
export function sentimentDirection(s: CommentatorSentiment): 'bull' | 'bear' | 'neutral' {
|
||||||
|
if (s.netSentiment > 0) return 'bull';
|
||||||
|
if (s.netSentiment < 0) return 'bear';
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user