Skip to main content

Signal Engine

Location: src/data/signals/ The signal engine is the fact layer between raw on-chain data and AI narratives. It has zero external dependencies.
[Dune scheduled/cached baseline]  ─┐
                                    ├─▶  detectSignals()  ─▶  WalletSignal[]  ─▶  AI narrative
[Live wallet events (Alchemy/Etherscan)] ─┘
AI narratives must consume these deterministic signals — they must not invent facts. Every number, address, and time window in a narrative must trace back to a WalletSignal.evidence field and its sources provenance chain.

Files

FileExports
src/data/signals/engine.jsdetectSignals(), individual detectors, buildBaselineStats(), DEFAULT_OPTIONS
src/data/signals/calculations.jsPure math helpers: usualDailyVolumeUSD, baselineDeviation, deriveConfidence, etc.
src/data/signals/known-labels.jsCEX/bridge label patterns: isCexLabel, isBridgeLabel, normaliseCexName
src/data/signals/index.jsRe-exports all three modules

API

import { detectSignals } from './src/data/signals/index.js';

const signals = detectSignals({
  walletAddress: '0x4838…5f97',
  chain:         'ethereum',
  baseline:      historicalWalletBaseline,   // from Dune — may be null
  events:        liveWalletEvents,            // from Alchemy/Etherscan
  windowStart:   '2026-04-01T00:00:00.000Z',
  windowEnd:     '2026-05-01T00:00:00.000Z',
}, options);
// returns WalletSignal[]
All outputs conform to the WalletSignal shape from src/data/models/signals.js.

Signal taxonomy

Signal typeTrigger conditionRequires baseline?
accumulationNet token inflows ≥ 60% of total baseline volumeYes
distributionNet token outflows ≥ 60% of total baseline volumeYes
bridgeLive eventType === 'bridge' OR bridge-labelled counterparty OR baseline bridge counterpartyNo (low confidence without)
cex_depositCEX-labelled counterparty + net outflow directionNo (low confidence without)
cex_withdrawalCEX-labelled counterparty + net inflow directionNo (low confidence without)
unusual_activityPeriod volume ≥ 3× expected OR period tx count ≥ 3× expectedYes
new_counterpartyEvent counterparty not in baseline top list, value ≥ $50kYes
protocol_rotationNew protocol handles ≥ 20% of live event volumeYes
large_move_vs_baselineSingle event ≥ 5× usual daily volumeYes

Default thresholds

export const DEFAULT_OPTIONS = {
  largeMovThresholdMultiplier:  5,     // event ≥ 5× usualDailyVolumeUSD
  unusualVolumeMultiplier:      3,     // period volume ≥ 3× expected
  unusualTxMultiplier:          3,     // period tx count ≥ 3× expected
  minSignalValueUSD:            10_000,
  newCounterpartyMinUSD:        50_000,
  accumulationNetInflowRatio:   0.6,
  distributionNetOutflowRatio:  0.6,
  protocolRotationVolumeShare:  0.2,
};
All thresholds can be overridden by passing a Partial<SignalEngineOptions> as the second argument to detectSignals().

Confidence rules

Confidence is derived from data completeness, not signal magnitude. A large move detected from a partial dataset is medium, not high.
ConditionResulting confidence
baseline === null'low'
baseline.dataQuality.confidence === 'low''low'
baseline.dataQuality.isPartial === truemax 'medium'
baseline.dataQuality.isEstimated === truemax 'medium'
baseline.totalVolumeEstimated === truemax 'medium'
Any event.valueUSD === nullmax 'medium'
baseline.dataQuality.confidence === 'medium''medium'
All data complete and exact'high'
Rules are evaluated in order; the first match wins. Use deriveConfidence(baseline, events) from calculations.js directly when building custom detectors.

Deterministic calculations

All math is in src/data/signals/calculations.js:
FunctionDescription
baselineWindowDays(baseline)Days in the Dune query window (min 1)
usualDailyVolumeUSD(baseline)totalVolumeUSD / days — average daily volume
usualDailyTxCount(baseline)txCount / days — average daily tx count
baselineDeviation(valueUSD, baseline)valueUSD / usualDailyVolumeUSD — how many daily-avgs this event represents
txCountDeviation(count, baseline)Tx count ratio vs baseline daily average
sumEventValues(events)Sum of non-null valueUSD; null if all unknown
deriveConfidence(baseline, events)See confidence rules above
strengthFromDeviation(deviation, medThresh, highThresh)Maps a ratio to 'low'/'medium'/'high'

CEX and bridge detection

CEX and bridge classification uses label matching on counterpartyLabel, not address lookup. This keeps the engine decoupled from the API layer’s PROTOCOL_MAP and avoids hardcoding addresses that change after upgrades.
import { isCexLabel, isBridgeLabel, normaliseCexName } from './src/data/signals/known-labels.js';

isCexLabel('Coinbase Prime')  // → true
isBridgeLabel('Hop Protocol') // → true
normaliseCexName('Binance 14') // → 'Binance'
Known patterns live in CEX_LABEL_PATTERNS and BRIDGE_LABEL_PATTERNS. Add new entries there when a new exchange or bridge needs to be covered — no engine logic changes required.

Missing-data behaviour

The engine never throws on missing or partial inputs. It degrades gracefully:
  • baseline === null — detectors that require baseline return null and are filtered out. Only label-based detectors (bridge, cex) can still fire, at confidence: 'low'.
  • Partial baseline (isPartial: true) — all emitted signals cap at confidence: 'medium' and carry a caveat noting the incomplete dataset.
  • event.valueUSD === null — the event is included in tx-count comparisons but excluded from volume sums. If this causes a relevant deviation, the signal is emitted at max 'medium' confidence.
  • Empty event array — only baseline-derived signals are considered.
  • Empty event array AND baseline === nulldetectSignals() returns [].

Source provenance

Every signal includes:
  • sources: SourceMetadata[] — the baseline source, each contributing event source, and an engineSource entry (sourceType: 'computed').
  • dataQuality.sources — same list, inside the DataQuality object.
The engineSource entry has sourceId: 'signal-engine-v1' so consumers can filter engine-derived fields from raw data fields.

How the narrative engine uses signals

  1. Pass signals[] as the primary context to NarrativeInput.signals — not the raw baseline or events.
  2. Every narrative claim must trace to a signal’s evidence object. If a number can’t be backed by evidence, it must not appear in the narrative.
  3. Respect signal.confidence. Narratives for confidence: 'low' signals must use hedged language (“limited data suggests…”) and explicitly surface the relevant caveats.
  4. Never override evidence fields. The AI may phrase and contextualise the evidence but must not change the numbers.
  5. Attach signal.sources to the NarrativeCard.sources array so the UI can show data-freshness badges for every claim.
The adversarialSignals namespace (src/lib/adversarial-heuristics.js) provides a separate set of behavioral exposure heuristics consumed by QuantumExposureCard. These are not part of the detectSignals() pipeline and are not WalletSignal objects. See Quantum Intelligence for the full specification.

Running tests

node --test test/signal-engine.test.mjs     # 35 signal engine tests
node --test                                  # 101 total (all suites)
npm run check                                # lint + test + build + audit