Compare commits
3
Commits
b04193e615
...
24349a8b6d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24349a8b6d | ||
|
|
74ff851ed2 | ||
|
|
44243618d5 |
+22
@@ -152,6 +152,28 @@ Screener output policy (both modules): symbol + "why matched" + one-tap "open in
|
||||
|
||||
**Portfolio Impact Commentary** — LLM-generated (Druckenmiller lens), two-horizon: short-term reaction risk (per-event, per-correlation-cluster, with historical averages + sample size + disclaimer, never a forecast) and long-term structural read (regime-shift framing). Cites M18 regime + the user's actual holdings. Always on (matches Analyst Voice "always on" decision).
|
||||
|
||||
## Confluence Signal Engine (M22)
|
||||
|
||||
**Confluence Signal Engine** — The multi-axis evidence aggregator that produces a per-symbol "picture quality" (strong/moderate/weak-bullish, mixed, weak/moderate/strong-bearish, sparse) from34 independently-evaluable slots across 6 families: technical (15), institutional (5), macro (5), seasonal (5), flows (3), sentiment (1). ADR-0007: describes the picture, never recommends action. ADR-0012.
|
||||
|
||||
**Slot** — A named, independently-evaluable check whose firing state contributes bullish or bearish evidence to a confluence rack. Each slot has a `SlotBody` (bull / bear / exit), a `SlotFamily`, a `SlotGranularity` (1d / 1wk), and an ADR-safe `explain` note (evidence sentence, never a directive). The 34-slot catalog is defined in `confluenceSlots.ts`.
|
||||
|
||||
**Rack** — A named subset of the 34 slots that evaluates a symbol's confluence. Can be a system preset (Full Confluence, Technical Momentum, Macro+Flows+Sentiment) or user-created. A rack evaluation produces the redundancy-discounted evidence totals and picture quality label.
|
||||
|
||||
**Redundancy Group** — A set of slots that measure the same underlying condition (e.g. goldenCross + trendAlignment + pullbackToEMA21 all measure trend state). Evidence within a group decays geometrically (1 + 0.5 + 0.25 ...) so correlated signals count once, not triple. Defined in `confluenceLibrary.ts`.
|
||||
|
||||
**Picture Quality** — The evidence-based label for a symbol's confluence: strong/moderate/weak-bullish, mixed, weak/moderate/strong-bearish, sparse. Labeled from redundancy-discounted evidence totals using direction ratio (0.6) and magnitude thresholds (strong ≥ 4.0, moderate ≥ 2.0, sparse < 1.0 total evidence).
|
||||
|
||||
**CandleProvider** — The seam that resolves a symbol's candles per-granularity (1d/1wk) from the cache, with a realtime fold-in of the freshest live quote. Used by confluence evaluators instead of `cache.get` inline so a future realtime/replay source can slot in without touching slot logic. `candleProvider.ts`.
|
||||
|
||||
**Signal History** — The closed-loop log: every slot fire is recorded with the as-of date, rack, and picture quality. A resolver later checks whether price moved the expected direction over 4 weeks (bull → up, bear/exit → down). Verdicts: `real` (confirmed), `false_alarm`, or `deferred` (not enough forward bars). Per-slot reliability weights (0.5–1.25) allow the rack to self-tune.
|
||||
|
||||
**Reliability Weight** — A 0.5–1.25 multiplier applied to a slot's evidence based on its historical follow-through: ≥8 resolved fires at ≥90% hit rate → 1.25×; <2 resolved or ≤50% → 0.5×; thin sample → 0.75×. The rack can multiply per-slot evidence by this to self-tune.
|
||||
|
||||
**Confluence Change** — A detected shift in picture quality tier or net evidence (≥ 0.35 shift). Driven by the `confluence_change` alert producer. Throttled to 5/hr. ADR-0007 framing: "the picture has changed," never "act now."
|
||||
|
||||
**Confluence Universe** — The 15 research symbols + SPY benchmark that the confluence engine tracks: PLTR, NVDA, AMD, AAPL, MSFT, SMH, XOM, JPM, UNH, COST, AMZN, CAT, LMT, LIN, NEE. Pinned into the demand set on startup.
|
||||
|
||||
**Regime History** — Timeline of regime classifications over time; cross-references Rotation Signal History. Pedagogy for "did we detect the shift correctly" — beginner learns which classifier calls were early vs whipsawed.
|
||||
|
||||
## LLM Data Provenance (ADR-0006)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Investor Flow — confluenceSeed.test.ts (M22 slice 10)
|
||||
// Integration test: pin symbols and create system racks against an in-memory DB.
|
||||
|
||||
import { describe, it, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createDb, initSchema } from '../../db/client.ts';
|
||||
import { ConfluenceRepository } from '../../db/confluenceRepository.ts';
|
||||
import { createCacheRepository, type CacheRepository } from '../../cache/CacheRepository.ts';
|
||||
import { FakeSourceAdapter } from '../../adapters/SourceAdapter.ts';
|
||||
import { AdapterQueue } from '../../queue/AdapterQueue.ts';
|
||||
import {
|
||||
seedConfluence,
|
||||
CONFLUENCE_UNIVERSE,
|
||||
BENCHMARK_SYMBOL,
|
||||
defineSystemRackPresets,
|
||||
} from '../confluenceSeed.ts';
|
||||
import { CONFLUENCE_SLOT_IDS } from '../confluenceSlots.ts';
|
||||
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let cache: CacheRepository;
|
||||
let repo: ConfluenceRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createDb({ path: ':memory:' });
|
||||
initSchema(db);
|
||||
repo = new ConfluenceRepository(db);
|
||||
const adapter = new FakeSourceAdapter('yfinance');
|
||||
const queue = new AdapterQueue({ db, adapters: new Map([['yfinance', adapter as any]]) });
|
||||
cache = createCacheRepository({ db, scheduler: queue });
|
||||
(queue as any).cache = cache;
|
||||
});
|
||||
|
||||
describe('CONFLUENCE_UNIVERSE', () => {
|
||||
it('contains exactly15 symbols plus benchmark', () => {
|
||||
assert.equal(CONFLUENCE_UNIVERSE.length, 15);
|
||||
assert.equal(BENCHMARK_SYMBOL, 'SPY');
|
||||
const symbols = CONFLUENCE_UNIVERSE.map((s) => s.symbol);
|
||||
assert.equal(new Set(symbols).size, symbols.length, 'no duplicate symbols');
|
||||
});
|
||||
|
||||
it('SMH is the only ETF; the rest are equities', () => {
|
||||
const etfs = CONFLUENCE_UNIVERSE.filter((s) => s.kind === 'etf');
|
||||
assert.deepEqual(etfs.map((s) => s.symbol), ['SMH']);
|
||||
for (const s of CONFLUENCE_UNIVERSE.filter((s) => s.symbol !== 'SMH')) {
|
||||
assert.equal(s.kind, 'equity', `${s.symbol} should be equity`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('defineSystemRackPresets', () => {
|
||||
it('returns exactly 3 presets', () => {
|
||||
const presets = defineSystemRackPresets();
|
||||
assert.equal(presets.length, 3);
|
||||
});
|
||||
|
||||
it('all slot ids in presets are valid', () => {
|
||||
const validIds = new Set(CONFLUENCE_SLOT_IDS);
|
||||
for (const preset of defineSystemRackPresets()) {
|
||||
for (const id of preset.slotIds) {
|
||||
assert.ok(validIds.has(id), `${preset.name} has unknown slot id: ${id}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('"Full Confluence" uses all34 slots', () => {
|
||||
const full = defineSystemRackPresets().find((p) => p.id === 'confluence-full')!;
|
||||
assert.equal(full.slotIds.length, 34);
|
||||
});
|
||||
|
||||
it('"Technical Momentum" has 15 slots', () => {
|
||||
const tech = defineSystemRackPresets().find((p) => p.id === 'confluence-technical')!;
|
||||
assert.equal(tech.slotIds.length, 15);
|
||||
});
|
||||
|
||||
it('"Macro + Flows + Sentiment" has 14 slots', () => {
|
||||
const macro = defineSystemRackPresets().find((p) => p.id === 'confluence-macro-flows')!;
|
||||
assert.equal(macro.slotIds.length, 14);
|
||||
});
|
||||
});
|
||||
|
||||
describe('seedConfluence', () => {
|
||||
it('pins16 symbols (15 universe + benchmark) and creates 3 system racks', async () => {
|
||||
const { symbolsPinned, racksCreated } = await seedConfluence(db, cache);
|
||||
assert.equal(symbolsPinned, 16);
|
||||
assert.equal(racksCreated, 3);
|
||||
const systemRacks = repo.listSystemRacks();
|
||||
assert.equal(systemRacks.length, 3);
|
||||
});
|
||||
|
||||
it('is idempotent: running twice does not duplicate racks', async () => {
|
||||
await seedConfluence(db, cache);
|
||||
await seedConfluence(db, cache);
|
||||
assert.equal(repo.listSystemRacks().length, 3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
// Investor Flow — Confluence Starter Seed (M22, slice 10)
|
||||
//
|
||||
// Defines the 15-symbol confluence universe, three system rack presets, and the
|
||||
// startup function that pins the universe into the demand set and persists the
|
||||
// system racks if they don't exist yet.
|
||||
//
|
||||
// Called once on server start from index.ts. All operations are idempotent:
|
||||
// running twice is a no-op.
|
||||
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type { CacheRepository, TickerKind } from '../cache/CacheRepository.ts';
|
||||
import { ConfluenceRepository, rackFromSlots } from '../db/confluenceRepository.ts';
|
||||
import { CONFLUENCE_SLOTS, CONFLUENCE_SLOT_IDS } from './confluenceSlots.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Universe
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ConfluenceSymbol {
|
||||
symbol: string;
|
||||
kind: TickerKind;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The15-symbol confluence research universe + SPY benchmark. Every confluence
|
||||
* evaluation and backtest assumes these symbols are warm. SMH is the
|
||||
* semiconductor ETF; the rest are single-name equities.
|
||||
*/
|
||||
export const CONFLUENCE_UNIVERSE: ConfluenceSymbol[] = [
|
||||
{ symbol: 'PLTR', kind: 'equity', label: 'Palantir' },
|
||||
{ symbol: 'NVDA', kind: 'equity', label: 'NVIDIA' },
|
||||
{ symbol: 'AMD', kind: 'equity', label: 'AMD' },
|
||||
{ symbol: 'AAPL', kind: 'equity', label: 'Apple' },
|
||||
{ symbol: 'MSFT', kind: 'equity', label: 'Microsoft' },
|
||||
{ symbol: 'SMH', kind: 'etf', label: 'Semiconductor ETF' },
|
||||
{ symbol: 'XOM', kind: 'equity', label: 'ExxonMobil' },
|
||||
{ symbol: 'JPM', kind: 'equity', label: 'JPMorgan' },
|
||||
{ symbol: 'UNH', kind: 'equity', label: 'UnitedHealth' },
|
||||
{ symbol: 'COST', kind: 'equity', label: 'Costco' },
|
||||
{ symbol: 'AMZN', kind: 'equity', label: 'Amazon' },
|
||||
{ symbol: 'CAT', kind: 'equity', label: 'Caterpillar' },
|
||||
{ symbol: 'LMT', kind: 'equity', label: 'Lockheed Martin' },
|
||||
{ symbol: 'LIN', kind: 'equity', label: 'Linde' },
|
||||
{ symbol: 'NEE', kind: 'equity', label: 'NextEra Energy' },
|
||||
];
|
||||
|
||||
export const BENCHMARK_SYMBOL = 'SPY';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System rack presets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RackPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
slotIds: string[];
|
||||
}
|
||||
|
||||
const ALL_IDS = CONFLUENCE_SLOT_IDS as readonly string[];
|
||||
|
||||
function familySlots(...families: string[]): string[] {
|
||||
return CONFLUENCE_SLOTS.filter((s) => families.includes(s.family)).map((s) => s.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Three curated system rack presets. Each is a different lens on the same
|
||||
* symbol data, expressed as a subset of the 34-slot catalog:
|
||||
*
|
||||
* 1. "Full Confluence" — all 34 slots (the default every-picture view)
|
||||
* 2. "Technical Momentum" — the15 technical slots only (price-action focus)
|
||||
* 3. "Macro + Flows + Sentiment" — macro 5 + seasonal 5 + flows 3 +
|
||||
* sentiment 1 = 14 slots (the macro/structural lens)
|
||||
*/
|
||||
export function defineSystemRackPresets(): RackPreset[] {
|
||||
return [
|
||||
{
|
||||
id: 'confluence-full',
|
||||
name: 'Full Confluence',
|
||||
description: 'All 34 slots. The broadest evidence view of a symbol\'s picture.',
|
||||
slotIds: [...ALL_IDS],
|
||||
},
|
||||
{
|
||||
id: 'confluence-technical',
|
||||
name: 'Technical Momentum',
|
||||
description: 'The 15 technical slots: trend, momentum, mean-reversion, and volume.',
|
||||
slotIds: familySlots('technical'),
|
||||
},
|
||||
{
|
||||
id: 'confluence-macro-flows',
|
||||
name: 'Macro + Flows + Sentiment',
|
||||
description: 'Macro regime, seasonal calendar, ETF/COT flows, and informed-commentator sentiment (14 slots).',
|
||||
slotIds: familySlots('macro', 'seasonal', 'flows', 'sentiment'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed routine
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pin the confluence universe into the demand set and create the three system
|
||||
* rack presets if they don't already exist. Safe to call on every startup.
|
||||
*/
|
||||
export async function seedConfluence(
|
||||
db: DatabaseSync,
|
||||
cache: CacheRepository,
|
||||
): Promise<{ symbolsPinned: number; racksCreated: number }> {
|
||||
// 1. Pin universe symbols + benchmark into the permanent demand set.
|
||||
const allSymbols: ConfluenceSymbol[] = [
|
||||
...CONFLUENCE_UNIVERSE,
|
||||
{ symbol: BENCHMARK_SYMBOL, kind: 'etf', label: 'S&P 500 benchmark' },
|
||||
];
|
||||
let symbolsPinned = 0;
|
||||
for (const { symbol, kind } of allSymbols) {
|
||||
try {
|
||||
await cache.pinSystemSymbol(symbol, kind);
|
||||
symbolsPinned++;
|
||||
} catch { /* symbol already pinned — ignore */ }
|
||||
}
|
||||
|
||||
// 2. Create system rack presets that don't already exist.
|
||||
const repo = new ConfluenceRepository(db);
|
||||
const existing = new Set(repo.listSystemRacks().map((r) => r.id));
|
||||
let racksCreated = 0;
|
||||
for (const preset of defineSystemRackPresets()) {
|
||||
if (existing.has(preset.id)) continue;
|
||||
const rack = rackFromSlots(preset.id, preset.name, preset.slotIds, {
|
||||
isSystem: true,
|
||||
description: preset.description,
|
||||
});
|
||||
repo.saveRack(rack);
|
||||
racksCreated++;
|
||||
}
|
||||
|
||||
return { symbolsPinned, racksCreated };
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import cryptoMod from './lib/crypto.ts';
|
||||
import { AdapterQueue } from './queue/AdapterQueue.ts';
|
||||
import { seedCuratedCusips } from './services/cusipRegistry.ts';
|
||||
import { seedAdminDefaultAlertSubscriptions } from './db/alertSubscriptionRepository.ts';
|
||||
import { seedConfluence } from './confluence/confluenceSeed.ts';
|
||||
import { makeCreateContext } from './trpc/context.ts';
|
||||
import { appRouter } from './trpc/router.ts';
|
||||
import type { Alert } from './alerts/AlertEngine.ts';
|
||||
@@ -100,6 +101,16 @@ try {
|
||||
console.error('[investor-flow] admin alert seed failed', e);
|
||||
}
|
||||
|
||||
// Confluence starter seed: pin15-symbol universe + 3 system rack presets
|
||||
try {
|
||||
const { symbolsPinned, racksCreated } = await seedConfluence(database, cache);
|
||||
if (symbolsPinned > 0 || racksCreated > 0) {
|
||||
console.log(`[investor-flow] confluence seed: ${symbolsPinned} symbols pinned, ${racksCreated} system racks created`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[investor-flow] confluence seed failed', e);
|
||||
}
|
||||
|
||||
// Demand hygiene: junk test symbols + inflated refcounts from page-view subscribe spam
|
||||
try {
|
||||
const hygiene = queue.cleanupDemandHygiene();
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client';
|
||||
import { LayoutShell } from '@/components/LayoutShell';
|
||||
import { ConfluencePanel } from '@/components/ConfluencePanel';
|
||||
|
||||
/**
|
||||
* Confluence — entry/exit picture quality from the34-slot confluence rack.
|
||||
* Evidence-based descriptions of a symbol's current setup (ADR-0007).
|
||||
*/
|
||||
export default function ConfluencePage() {
|
||||
return (
|
||||
<LayoutShell>
|
||||
<ConfluencePanel />
|
||||
</LayoutShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '@/lib/trpc';
|
||||
import { useActiveSymbol } from '@/stores/active-symbol-store';
|
||||
import { CollapsibleSection } from '@/components/CollapsibleSection';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types (subset of server types for display)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Quality = 'strong-bullish' | 'moderate-bullish' | 'weak-bullish' | 'mixed' | 'weak-bearish' | 'moderate-bearish' | 'strong-bearish' | 'sparse';
|
||||
|
||||
const QUALITY_COLOR: Record<Quality, string> = {
|
||||
'strong-bullish': 'bg-green-600/20 text-green-300 border-green-600/40',
|
||||
'moderate-bullish': 'bg-green-600/10 text-green-400 border-green-600/20',
|
||||
'weak-bullish': 'bg-green-600/5 text-green-500 border-green-600/10',
|
||||
'mixed': 'bg-zinc-700/30 text-zinc-300 border-zinc-600/30',
|
||||
'weak-bearish': 'bg-red-600/5 text-red-500 border-red-600/10',
|
||||
'moderate-bearish': 'bg-red-600/10 text-red-400 border-red-600/20',
|
||||
'strong-bearish': 'bg-red-600/20 text-red-300 border-red-600/40',
|
||||
'sparse': 'bg-zinc-800/50 text-zinc-500 border-zinc-700/30',
|
||||
};
|
||||
|
||||
const QUALITY_LABEL: Record<Quality, string> = {
|
||||
'strong-bullish': 'Strong Bullish',
|
||||
'moderate-bullish': 'Moderate Bullish',
|
||||
'weak-bullish': 'Weak Bullish',
|
||||
'mixed': 'Mixed',
|
||||
'weak-bearish': 'Weak Bearish',
|
||||
'moderate-bearish': 'Moderate Bearish',
|
||||
'strong-bearish': 'Strong Bearish',
|
||||
'sparse': 'Sparse Data',
|
||||
};
|
||||
|
||||
const BODY_BADGE: Record<string, string> = {
|
||||
bull: 'bg-green-600/15 text-green-400',
|
||||
bear: 'bg-red-600/15 text-red-400',
|
||||
exit: 'bg-amber-600/15 text-amber-400',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ConfluencePanel() {
|
||||
const activeSymbol = useActiveSymbol((s) => s.activeSymbol);
|
||||
const [symbol, setSymbol] = useState(activeSymbol);
|
||||
const [selectedRackId, setSelectedRackId] = useState<string | null>(null);
|
||||
|
||||
// Racks
|
||||
const [racks, setRacks] = useState<{ system: Array<{ id: string; name: string }>; user: Array<{ id: string; name: string }> }>({ system: [], user: [] });
|
||||
useEffect(() => {
|
||||
api.confluence.racks().then(setRacks).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Evaluation
|
||||
const [evaluation, setEvaluation] = useState<Record<string, unknown> | null>(null);
|
||||
const rackId = selectedRackId ?? racks.system[0]?.id ?? null;
|
||||
useEffect(() => {
|
||||
if (!symbol) return;
|
||||
api.confluence.evaluation(symbol, { rackId: rackId ?? undefined, limit: 6 })
|
||||
.then((r) => setEvaluation(r as Record<string, unknown>))
|
||||
.catch(() => setEvaluation(null));
|
||||
}, [symbol, rackId]);
|
||||
|
||||
// Scorecard
|
||||
const [scorecard, setScorecard] = useState<{ slots: Array<Record<string, unknown>>; note: string } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!symbol) return;
|
||||
api.confluence.scorecard(symbol)
|
||||
.then((r) => setScorecard(r as { slots: Array<Record<string, unknown>>; note: string }))
|
||||
.catch(() => setScorecard(null));
|
||||
}, [symbol]);
|
||||
|
||||
// Slots
|
||||
const [slotMeta, setSlotMeta] = useState<Map<string, { name: string; body: string; explain: string }>>(new Map());
|
||||
useEffect(() => {
|
||||
api.confluence.slots()
|
||||
.then((r) => setSlotMeta(new Map(r.slots.map((s) => [s.id, { name: s.name, body: s.body, explain: s.explain }]))))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const latest = (evaluation?.latest as Record<string, unknown>) ?? null;
|
||||
const quality = (latest?.quality as Quality) ?? null;
|
||||
const assessments = (latest?.assessments as Array<{ id: string; state: string; note?: string }>) ?? [];
|
||||
const bullEvidence = Number(latest?.bullEvidence ?? 0);
|
||||
const bearEvidence = Number(latest?.bearEvidence ?? 0);
|
||||
const change = evaluation?.change as { changed: boolean; changeType: string; previous: string | null } | null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-fg-muted mb-1">Symbol</label>
|
||||
<input
|
||||
type="text"
|
||||
value={symbol}
|
||||
onChange={(e) => setSymbol(e.target.value.toUpperCase().trim())}
|
||||
className="w-24 rounded bg-bg-secondary border border-fg-muted/20 px-2 py-1.5 text-sm text-fg"
|
||||
maxLength={12}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-fg-muted mb-1">Rack</label>
|
||||
<select
|
||||
value={rackId ?? ''}
|
||||
onChange={(e) => setSelectedRackId(e.target.value || null)}
|
||||
className="rounded bg-bg-secondary border border-fg-muted/20 px-2 py-1.5 text-sm text-fg min-w-[180px]"
|
||||
>
|
||||
{racks.system.map((r) => (
|
||||
<option key={r.id} value={r.id}>{r.name}</option>
|
||||
))}
|
||||
{racks.user.map((r) => (
|
||||
<option key={r.id} value={r.id}>{r.name} (yours)</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Picture quality + evidence summary */}
|
||||
{quality ? (
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className={`inline-flex items-center gap-2 rounded border px-3 py-1.5 text-sm font-medium ${QUALITY_COLOR[quality]}`}>
|
||||
{QUALITY_LABEL[quality]}
|
||||
</div>
|
||||
<div className="text-xs text-fg-muted">
|
||||
Bull <span className="text-green-400 font-medium">{bullEvidence.toFixed(1)}</span>
|
||||
{' '}·{' '}
|
||||
Bear <span className="text-red-400 font-medium">{bearEvidence.toFixed(1)}</span>
|
||||
{' '}·{' '}
|
||||
Net <span className={bullEvidence - bearEvidence >= 0 ? 'text-green-400' : 'text-red-400'}>
|
||||
{(bullEvidence - bearEvidence).toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
{change?.changed && (
|
||||
<div className={`text-xs px-2 py-0.5 rounded ${change.changeType === 'improved' ? 'bg-green-600/10 text-green-400' : 'bg-red-600/10 text-red-400'}`}>
|
||||
{change.changeType === 'improved' ? 'Improved' : 'Deteriorated'} from {change.previous ?? '?'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : evaluation !== null ? (
|
||||
<div className="text-sm text-fg-muted">No evaluations yet for {symbol}. Data will appear once the confluence evaluator runs.</div>
|
||||
) : null}
|
||||
|
||||
{/* Slot evidence grid */}
|
||||
{assessments.length > 0 && (
|
||||
<CollapsibleSection title="Slot Evidence" defaultOpen>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{assessments.map((a) => {
|
||||
const meta = slotMeta.get(a.id);
|
||||
const fired = a.state === 'fired';
|
||||
return (
|
||||
<div key={a.id} className={`rounded border px-2.5 py-2 text-xs ${fired ? 'border-fg-muted/30 bg-bg-secondary' : 'border-fg-muted/10 bg-bg-secondary/50 opacity-60'}`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`inline-block w-1.5 h-1.5 rounded-full ${fired ? 'bg-green-400' : 'bg-zinc-600'}`} />
|
||||
<span className="font-medium text-fg truncate">{meta?.name ?? a.id}</span>
|
||||
<span className={`ml-auto text-[10px] px-1 rounded ${BODY_BADGE[meta?.body ?? 'bull']}`}>
|
||||
{meta?.body ?? '?'}
|
||||
</span>
|
||||
</div>
|
||||
{a.note && <div className="text-fg-muted leading-tight">{a.note}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Scorecard */}
|
||||
{scorecard && scorecard.slots.length > 0 && (
|
||||
<CollapsibleSection title="Reliability Scorecard">
|
||||
<p className="text-xs text-fg-muted mb-2">{scorecard.note}</p>
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-fg-muted border-b border-fg-muted/20">
|
||||
<th className="text-left py-1">Slot</th>
|
||||
<th className="text-right py-1">Fires</th>
|
||||
<th className="text-right py-1">Hit Rate</th>
|
||||
<th className="text-right py-1">Reliability</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scorecard.slots
|
||||
.sort((a, b) => ((b.hitRate as number) ?? 0) - ((a.hitRate as number) ?? 0))
|
||||
.map((s) => (
|
||||
<tr key={s.slotId as string} className="border-b border-fg-muted/10">
|
||||
<td className="py-1">{slotMeta.get(s.slotId as string)?.name ?? (s.slotId as string)}</td>
|
||||
<td className="text-right py-1">{s.fires as number}</td>
|
||||
<td className="text-right py-1">{s.hitRate != null ? `${((s.hitRate as number) * 100).toFixed(0)}%` : '-'}</td>
|
||||
<td className="text-right py-1">{(s.reliabilityWeight as number).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
# ADR-0012: Confluence Signal Engine (M22)
|
||||
|
||||
Date: 2026-08-10
|
||||
Status: Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Investor Flow evaluates entry/exit quality for a symbol by combining multiple
|
||||
evidence axes (technical, institutional, macro, seasonal, flows, sentiment).
|
||||
The existing codebase had per-indicator helpers (RSI, MACD, moving averages,
|
||||
volume-by-price, rotation, seasonality) but no unified layer that combined
|
||||
them into a single, per-symbol, per-date assessment. Users saw individual
|
||||
indicators but not the synthesized picture.
|
||||
|
||||
The product needs an evidence-based confluence layer that:
|
||||
- Aggregates multi-axis signals into a single "picture quality" for a symbol
|
||||
- Discounts redundant signals (e.g. golden cross + trend alignment both measure
|
||||
the same thing) so correlated evidence isn't double-counted
|
||||
- Produces an ADR-0007-safe output: evidence descriptions ("strong bullish
|
||||
picture"), never buy/sell directives
|
||||
- Supports a closed loop: slot fires are logged and later resolved to
|
||||
confirmed/false-alarm by measuring whether price followed through
|
||||
|
||||
## Decision
|
||||
|
||||
Build a **34-slot Confluence Signal Engine** as a first-class module
|
||||
(`app/server/src/confluence/`).
|
||||
|
||||
### 1. Slot catalog (34 slots, 6 families)
|
||||
|
||||
Each slot is an independently-evaluable check whose firing state contributes
|
||||
bullish or bearish evidence. Families: technical (15), institutional (5),
|
||||
macro (5), seasonal (5), flows (3), sentiment (1). Slots carry an ADR-safe
|
||||
`explain` note (evidence sentence, never a directive). `SlotBody`: bull / bear
|
||||
/ exit (exit = bear evidence for an existing position).
|
||||
|
||||
### 2. Redundancy-aware rack evaluation
|
||||
|
||||
Slots are bucketed into redundancy groups (e.g. goldenCross +
|
||||
trendAlignment + pullbackToEMA21). Within each group, evidence decays
|
||||
geometrically (1 + 0.5 + 0.25 ...) so correlated signals count once, not
|
||||
three times. The rack then labels the picture using evidence totals:
|
||||
|
||||
- `MIN_TOTAL_EVIDENCE = 1.0` (below → sparse)
|
||||
- `DIRECTION_RATIO = 0.6` (bull/totals must reach this for bullish label)
|
||||
- `STRONG_EVIDENCE = 4.0`, `MODERATE_EVIDENCE = 2.0` (magnitude thresholds)
|
||||
|
||||
Quality labels: strong/moderate/weak-bullish, mixed, weak/moderate/strong-
|
||||
bearish, sparse.
|
||||
|
||||
### 3. CandleProvider seam (data abstraction)
|
||||
|
||||
Confluence evaluators resolve candles through a `CandleProvider` interface,
|
||||
not `cache.get` inline. The cache-backed implementation folds in the freshest
|
||||
live quote as a partial daily bar so mid-session evaluations see the current
|
||||
price, not just the last EOD close. Weekly slots (50/200 cross, trend
|
||||
alignment) use the weekly cache key. This seam is pluggable for future
|
||||
replay/realtime sources.
|
||||
|
||||
### 4. Closed-loop signal history
|
||||
|
||||
Every slot fire is logged to `confluence_signal_history` with the as-of date,
|
||||
rack, and picture quality at the time. A resolver later checks whether price
|
||||
moved the expected direction over 4 weeks (bull slots → price up, bear/exit
|
||||
slots → price down). A small dead-band (0.5%) treats flat outcomes as
|
||||
unresolved rather than false alarms. Per-slot reliability weights (0.5–1.25)
|
||||
allow the rack to self-tune over time.
|
||||
|
||||
### 5. Picture-change alert producer
|
||||
|
||||
The `confluence_change` alert type fires when the picture quality tier
|
||||
changes (improved / deteriorated) or net evidence shifts beyond 0.35.
|
||||
Throttled to 5 per hour per type. ADR-0007 framing: "the picture has
|
||||
changed," never "act now."
|
||||
|
||||
### 6. COT data adapter
|
||||
|
||||
The CFTC Traders-in-Financial-Futures report (leveraged-funds long/short +
|
||||
open interest) is fetched from CFTC's annual zip files and parsed. The
|
||||
`cotPositioning` slot consumes this data. Registered under the `cftc`
|
||||
vendor family with 1.5s min-interval pacing.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The tRPC `confluence.*` router exposes slots, racks, evaluation, backtest,
|
||||
scorecard, and saveRack. The frontend `/confluence` page shows picture
|
||||
quality, per-slot evidence, and reliability scorecard.
|
||||
- Three system rack presets are seeded on startup: Full Confluence (all 34),
|
||||
Technical Momentum (15), Macro + Flows + Sentiment (14).
|
||||
- The 15-symbol confluence universe (PLTR, NVDA, AMD, AAPL, MSFT, SMH, XOM,
|
||||
JPM, UNH, COST, AMZN, CAT, LMT, LIN, NEE) + SPY benchmark are pinned
|
||||
into the demand set on startup.
|
||||
- No advisory output is produced. All picture-quality labels describe
|
||||
evidence; they never recommend action.
|
||||
- The closed loop is not self-executing: the `confluence.eval` procedure
|
||||
must be triggered to produce evaluations. The backtest procedure is
|
||||
read-only and query-driven. Future work can schedule periodic evaluation.
|
||||
Reference in New Issue
Block a user