feat(confluence): add confluence slot catalog, library, rack, and sentiment source (M22 slice 2)

This commit is contained in:
Investor Flow Build
2026-08-10 15:06:10 -04:00
parent ef2c39167c
commit 49bcfc3d3a
5 changed files with 665 additions and 0 deletions
@@ -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';
+237
View File
@@ -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';
}