Skip to content

Add explainable Signal strength scoring model (SIGNAL-SCORE-001) - #60

Merged
jamalfrnk merged 2 commits into
mainfrom
feat/signal-strength-score
Aug 2, 2026
Merged

Add explainable Signal strength scoring model (SIGNAL-SCORE-001)#60
jamalfrnk merged 2 commits into
mainfrom
feat/signal-strength-score

Conversation

@jamalfrnk

Copy link
Copy Markdown
Owner

Summary

Closes #37 (SIGNAL-SCORE-001). technical-analysis.ts already computes real indicators (EMA/MACD/RSI/ADX/Fisher/Keltner/ATR), but there was no unified, explainable 0-100 "Signal strength" score composing them into a documented, versioned model -- ruleAlignmentScore is a raw confirmation count, not that.

  • signals/signalScore.ts -- pure, DB-free computeSignalScore(), same testability discipline as analytics/metrics.ts. Six weighted components (trend agreement 25, momentum agreement 20, trend-strength confirmation 15, volatility suitability 15, data freshness 15, indicator availability 10). Full weighting rationale in docs/product/signal-strength.md. Reuses technical-analysis.ts's own EMA-separation and ADX thresholds as normalization references rather than inventing new arbitrary constants.
  • Conflict handling: trend/momentum disagreement (or an undeterminable trend) forces direction to NEUTRAL and the score to exactly 0 -- mirrors evaluateSignal()'s own choice to fire no signal at all in that case.
  • Invalidation handling: RSI/Fisher pointing opposite the thesis, or price already breaking the Keltner band against it, caps the score at 15 without flipping direction -- the call hasn't reversed, only weakened.
  • schemas/signalScore.ts -- versioned Zod contract (SCORE_MODEL_VERSION), mirrored on the client (no shared package exists between client/server in this repo, matching its existing convention).
  • Wired into technical-analysis.ts's generateSignals(); persisted in a new nullable signals.signal_score jsonb column -- nullable because signals generated before this shipped never had a score computed, and backfilling a synthetic value for them would fabricate evidence that doesn't exist. Verified this decision was necessary: the shared dev DB had 2,431 pre-existing signal rows, and a NOT NULL migration would have been a real (blocked) data-loss operation.
  • Client: SignalStrength.tsx replaces the old raw "Rule Alignment" bar with the new Signal strength score, a Strong/Moderate/Weak/None label (never color alone, per the mission's accessibility requirement), and a native <details> disclosure for the component breakdown -- no new UI dependency added.
  • Never labeled "confidence"/"probability"/"expected return" anywhere, matching the ruleAlignmentScore precedent (GH audit finding F-5).

Verification

  • 8 new unit tests cover every fixture category from the issue's test plan: full agreement (100), full disagreement (exactly 0), undeterminable trend (exactly 0), missing-indicator handling, stale-data handling, invalidation-condition capping, version stability, and the always-null candleInterval.
  • Full suite: server 199/199 passing, client 33/33 passing. Both npm run build clean. Client lint/format clean.
  • Verified against real market data end-to-end, not just unit tests: ran the real server against live Hyperliquid mainnet data, confirmed generated signals persist a correctly-computed score in Postgres -- including a real invalidation-condition case (RSI oversold correctly capping a live SHORT signal at 15/100) and a real non-invalidated case (component math cross-checked by hand against the persisted jsonb: (2*25+0*20+100*15+50*15+100*15+100*10)/100 = 48, matching exactly) -- and confirmed the API round-trips the jsonb value intact to the client-facing shape.
  • Checked for CRLF pollution before committing (git diff --stat vs git diff -w --stat -- identical).

Test plan

  • Independent reviewer reproduces the score computation against real generated signals (not just the diff), including the conflict-zero and invalidation-cap cases
  • Confirm the nullable-column migration applies cleanly against a DB with pre-existing signal rows
  • Confirm no regression in existing signal generation / API behavior

Issue #37: technical-analysis.ts computes real indicators (EMA/MACD/RSI/
ADX/Fisher/Keltner/ATR) but had no unified, explainable 0-100 score
composing them -- ruleAlignmentScore is a raw confirmation count, not a
documented, versioned model.

- signals/signalScore.ts: pure, DB-free computeSignalScore(), mirroring
  analytics/metrics.ts's testability discipline. Six weighted components
  (trend agreement 25, momentum agreement 20, trend-strength confirmation
  15, volatility suitability 15, data freshness 15, indicator availability
  10 -- documented rationale in docs/product/signal-strength.md), reusing
  technical-analysis.ts's own EMA-separation and ADX thresholds as
  normalization references rather than inventing new arbitrary constants.
- Conflict handling: trend/momentum disagreement (or an undeterminable
  trend) forces direction to NEUTRAL and the score to exactly 0, mirroring
  evaluateSignal()'s own choice to fire no signal at all in that case.
- Invalidation handling: RSI/Fisher pointing opposite the thesis, or price
  already breaking the Keltner band against it, caps the score at 15
  without flipping direction -- the call hasn't reversed, only weakened.
- schemas/signalScore.ts: versioned Zod contract (SCORE_MODEL_VERSION),
  mirrored on the client (no shared package exists between them, matching
  this repo's existing convention).
- Wired into technical-analysis.ts's generateSignals(); persisted in a new
  nullable signals.signal_score jsonb column -- nullable because signals
  generated before this shipped never had a score computed, and backfilling
  a synthetic value would fabricate evidence that doesn't exist.
- client: SignalStrength.tsx replaces the old raw "Rule Alignment" bar with
  the new Signal strength score, a Strong/Moderate/Weak/None label (never
  color alone, per the mission's accessibility requirement), and a native
  <details> disclosure for the component breakdown -- no new UI dependency.
- Never labeled "confidence"/"probability"/"expected return" anywhere,
  matching the ruleAlignmentScore precedent (GH F-5).

Verified against real market data end-to-end, not just unit tests: ran the
real server against live Hyperliquid data, confirmed generated signals
persist a correctly-computed score (including a real invalidation-condition
case -- RSI oversold capping a SHORT signal at 15/100), and confirmed the
API round-trips the jsonb value intact.

8 new unit tests cover every fixture category from the test plan: full
agreement (100), full disagreement (exactly 0), undeterminable trend
(exactly 0), missing-indicator handling, stale-data handling,
invalidation-condition capping, version stability, and the always-null
candleInterval. Full suite: server 199/199, client 33/33. Both builds clean.

Closes #37.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a8a7a809d2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread client/src/features/signals/SignalCard.tsx
…rework)

Independent review of PR #60 found a confirmed High-severity regression:
signals.signal_score is a nullable column by design (any signal generated
before this feature shipped never had a score computed, and the shared
dev DB alone had 6,728 of 6,731 rows genuinely null), but the client's
Signal.signalScore type declared it non-nullable and SignalStrength
dereferenced it directly -- rendering any un-scored signal threw
"Cannot read properties of null (reading 'totalScore')", which the app's
top-level ErrorBoundary caught by blanking the *entire* Signals page, not
degrading a single card. Reviewer reproduced this with a throwaway test
rendering <SignalStrength score={null} />.

- types.ts: Signal.signalScore is now SignalScore | null, matching the
  real DB column and API response shape.
- SignalStrength.tsx: renders an explicit "Not available" state for a
  null score (signal generated before this feature existed) instead of
  crashing -- consistent with this PR's own "don't fabricate evidence
  that doesn't exist" principle already applied to candleInterval and the
  nullable column itself.
- SignalStrength.test.tsx (new): regression test for the null case, plus
  a real-score render test.

Also addresses the review's two non-blocking Low findings:
- signalScore.ts: volatilitySuitability now guards keltnerUpper/
  keltnerLower against zero, matching the existing ema200 !== 0 guard
  (the outer clamp already prevented a NaN/Infinity leak, but the
  reviewer correctly noted the inconsistency).
- signalScore.test.ts: two more fixtures -- momentum missing while trend
  is still determinable (direction preserved, score reduced), and every
  indicator null simultaneously (NEUTRAL, exactly 0, no crash).

Full suite: server 201/201, client 35/35. Both builds clean, client
lint/format clean.
@jamalfrnk

Copy link
Copy Markdown
Owner Author

Rework applied (independent review: FAIL -> addressed)

Independent review found a confirmed High regression: `signals.signal_score` is a nullable column by design (this DB alone had 6,728 of 6,731 rows genuinely null), but the client's `Signal.signalScore` type was non-nullable and `SignalStrength` dereferenced it directly -- any un-scored signal crashed with `Cannot read properties of null (reading 'totalScore')`, taking down the entire Signals page via the app's ErrorBoundary. Reviewer reproduced this directly with a throwaway test.

Fixed:

  • `types.ts`: `Signal.signalScore` is now `SignalScore | null`, matching the real DB/API shape.
  • `SignalStrength.tsx`: renders an explicit "Not available" state for a null score instead of crashing.
  • New regression test (`SignalStrength.test.tsx`) covers both the null case and a real-score render.

Also addressed the two non-blocking Low findings:

  • `signalScore.ts`: added an explicit zero-guard on `keltnerUpper`/`keltnerLower` in `volatilitySuitability`, matching the existing `ema200 !== 0` pattern.
  • Two more test fixtures: momentum missing while trend is still determinable, and every indicator null simultaneously.

Full suite: server 201/201, client 35/35. Both builds clean, client lint/format clean.

@jamalfrnk
jamalfrnk merged commit 6c7c18b into main Aug 2, 2026
3 checks passed
@jamalfrnk

Copy link
Copy Markdown
Owner Author

Independent re-review: PASS

The confirmed High-severity crash (un-scored signal -> SignalStrength dereferencing null -> whole Signals page blanked via ErrorBoundary) is genuinely fixed, verified by reproduction: the reviewer ran the exact same <SignalStrength score={null} /> case the prior review used and confirmed it no longer throws, while the real-score path still renders correctly (no regression). Grepped the whole client for other unguarded signalScore consumers -- none found; SignalCard.tsx is the only other call site and passes through safely.

Both Low findings also confirmed genuinely addressed: the keltnerUpper/keltnerLower zero-guard is correctly placed, and the two new test fixtures (missing momentum, all-indicators-null) assert exactly what they claim, not just matching test names.

Full suite: server 201/201, client 35/35. Both builds clean. git diff between the reviewed commits is scoped to exactly the 5 files claimed -- no unrelated changes.

Ready for merge at your convenience.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SIGNAL-SCORE-001: Implement explainable signal-strength scoring

1 participant