diff --git a/app/server/src/confluence/__tests__/confluenceRack.test.ts b/app/server/src/confluence/__tests__/confluenceRack.test.ts new file mode 100644 index 0000000..5b9bf36 --- /dev/null +++ b/app/server/src/confluence/__tests__/confluenceRack.test.ts @@ -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']); + }); +}); \ No newline at end of file diff --git a/app/server/src/confluence/confluenceLibrary.ts b/app/server/src/confluence/confluenceLibrary.ts new file mode 100644 index 0000000..f140019 --- /dev/null +++ b/app/server/src/confluence/confluenceLibrary.ts @@ -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 = 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 { + const counts: Record = { 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'; \ No newline at end of file diff --git a/app/server/src/confluence/confluenceRack.ts b/app/server/src/confluence/confluenceRack.ts new file mode 100644 index 0000000..48cb1b5 --- /dev/null +++ b/app/server/src/confluence/confluenceRack.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 = { + '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'; \ No newline at end of file diff --git a/app/server/src/confluence/confluenceSlots.ts b/app/server/src/confluence/confluenceSlots.ts new file mode 100644 index 0000000..420d68e --- /dev/null +++ b/app/server/src/confluence/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 = 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. */ \ No newline at end of file diff --git a/app/server/src/confluence/sentimentSource.ts b/app/server/src/confluence/sentimentSource.ts new file mode 100644 index 0000000..b2aa676 --- /dev/null +++ b/app/server/src/confluence/sentimentSource.ts @@ -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; +} + +/** 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'; +} \ No newline at end of file