feat(breadth): add market breadth history chart (#2932)

This commit is contained in:
Elie Habib
2026-04-11 17:54:26 +04:00
committed by GitHub
parent d33ac8bd01
commit 46c35e6073
24 changed files with 892 additions and 1 deletions

2
api/bootstrap.js vendored
View File

@@ -97,6 +97,7 @@ const BOOTSTRAP_CACHE_KEYS = {
lngVulnerability: 'energy:lng-vulnerability:v1',
sprPolicies: 'energy:spr-policies:v1',
aaiiSentiment: 'market:aaii-sentiment:v1',
breadthHistory: 'market:breadth-history:v1',
};
const SLOW_KEYS = new Set([
@@ -137,6 +138,7 @@ const SLOW_KEYS = new Set([
'lngVulnerability',
'sprPolicies',
'aaiiSentiment',
'breadthHistory',
]);
const FAST_KEYS = new Set([
'earthquakes', 'outages', 'serviceStatuses', 'ddosAttacks', 'trafficAnomalies', 'macroSignals', 'chokepoints',

View File

@@ -66,6 +66,7 @@ const BOOTSTRAP_KEYS = {
ecbEuribor6m: 'economic:fred:v1:EURIBOR6M:0',
ecbEuribor1y: 'economic:fred:v1:EURIBOR1Y:0',
fearGreedIndex: 'market:fear-greed:v1',
breadthHistory: 'market:breadth-history:v1',
euYieldCurve: 'economic:yield-curve-eu:v1',
earningsCalendar: 'market:earnings-calendar:v1',
econCalendar: 'economic:econ-calendar:v1',
@@ -250,6 +251,7 @@ const SEED_META = {
ecbEuribor1y: { key: 'seed-meta:economic:ecb-short-rates', maxStaleMin: 4320 }, // shared meta key with ecbEstr
gscpi: { key: 'seed-meta:economic:gscpi', maxStaleMin: 2880 }, // 24h interval; 2880min = 48h = 2x interval
fearGreedIndex: { key: 'seed-meta:market:fear-greed', maxStaleMin: 720 }, // 6h cron; 720min = 12h = 2x interval
breadthHistory: { key: 'seed-meta:market:breadth-history', maxStaleMin: 2880 }, // daily cron at 21:00 ET; 2880min = 48h = 2x interval
hormuzTracker: { key: 'seed-meta:supply_chain:hormuz_tracker', maxStaleMin: 2880 }, // daily cron; 2880min = 48h = 2x interval
earningsCalendar: { key: 'seed-meta:market:earnings-calendar', maxStaleMin: 1440 }, // 12h cron; 1440min = 24h = 2x interval
econCalendar: { key: 'seed-meta:economic:econ-calendar', maxStaleMin: 1440 }, // 12h cron; 1440min = 24h = 2x interval

File diff suppressed because one or more lines are too long

View File

@@ -644,6 +644,32 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/market/v1/get-market-breadth-history:
get:
tags:
- MarketService
summary: GetMarketBreadthHistory
description: GetMarketBreadthHistory retrieves historical % of S&P 500 stocks above 20/50/200-day SMAs.
operationId: GetMarketBreadthHistory
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/GetMarketBreadthHistoryResponse'
"400":
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
default:
description: Error response
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
Error:
@@ -1806,3 +1832,43 @@ components:
type: string
transactionDate:
type: string
GetMarketBreadthHistoryRequest:
type: object
GetMarketBreadthHistoryResponse:
type: object
properties:
currentPctAbove20d:
type: number
format: double
currentPctAbove50d:
type: number
format: double
currentPctAbove200d:
type: number
format: double
updatedAt:
type: string
history:
type: array
items:
$ref: '#/components/schemas/BreadthSnapshot'
unavailable:
type: boolean
BreadthSnapshot:
type: object
properties:
date:
type: string
pctAbove20d:
type: number
format: double
description: |-
Optional so a missing/failed Barchart reading serializes as JSON null
instead of collapsing to 0, which would render identically to a real 0%
reading (severe market dislocation with no S&P stocks above SMA).
pctAbove50d:
type: number
format: double
pctAbove200d:
type: number
format: double

View File

@@ -0,0 +1,26 @@
syntax = "proto3";
package worldmonitor.market.v1;
import "sebuf/http/annotations.proto";
message GetMarketBreadthHistoryRequest {}
message BreadthSnapshot {
string date = 1;
// Optional so a missing/failed Barchart reading serializes as JSON null
// instead of collapsing to 0, which would render identically to a real 0%
// reading (severe market dislocation with no S&P stocks above SMA).
optional double pct_above_20d = 2;
optional double pct_above_50d = 3;
optional double pct_above_200d = 4;
}
message GetMarketBreadthHistoryResponse {
optional double current_pct_above_20d = 1;
optional double current_pct_above_50d = 2;
optional double current_pct_above_200d = 3;
string updated_at = 4;
repeated BreadthSnapshot history = 5;
bool unavailable = 6;
}

View File

@@ -23,6 +23,7 @@ import "worldmonitor/market/v1/get_fear_greed_index.proto";
import "worldmonitor/market/v1/list_earnings_calendar.proto";
import "worldmonitor/market/v1/get_cot_positioning.proto";
import "worldmonitor/market/v1/get_insider_transactions.proto";
import "worldmonitor/market/v1/get_market_breadth_history.proto";
// MarketService provides APIs for financial market data from Finnhub, Yahoo Finance, and CoinGecko.
service MarketService {
@@ -127,4 +128,9 @@ service MarketService {
rpc GetInsiderTransactions(GetInsiderTransactionsRequest) returns (GetInsiderTransactionsResponse) {
option (sebuf.http.config) = {path: "/get-insider-transactions", method: HTTP_METHOD_GET};
}
// GetMarketBreadthHistory retrieves historical % of S&P 500 stocks above 20/50/200-day SMAs.
rpc GetMarketBreadthHistory(GetMarketBreadthHistoryRequest) returns (GetMarketBreadthHistoryResponse) {
option (sebuf.http.config) = {path: "/get-market-breadth-history", method: HTTP_METHOD_GET};
}
}

View File

@@ -0,0 +1,127 @@
#!/usr/bin/env node
import { loadEnvFile, CHROME_UA, runSeed, sleep } from './_seed-utils.mjs';
loadEnvFile(import.meta.url);
const BREADTH_KEY = 'market:breadth-history:v1';
const BREADTH_TTL = 2592000; // 30 days
const HISTORY_LENGTH = 252; // trading days (~1 year)
// Barchart breadth symbols:
// $S5TH = % of S&P 500 above 200-day SMA
// $S5FI = % of S&P 500 above 50-day SMA
// $S5TW = % of S&P 500 above 20-day SMA
const BARCHART_SYMBOLS = [
{ symbol: '%24S5TW', label: '20d', field: 'pctAbove20d' },
{ symbol: '%24S5FI', label: '50d', field: 'pctAbove50d' },
{ symbol: '%24S5TH', label: '200d', field: 'pctAbove200d' },
];
async function fetchBarchartPrice(encodedSymbol, label) {
try {
const resp = await fetch(`https://www.barchart.com/stocks/quotes/${encodedSymbol}`, {
headers: { 'User-Agent': CHROME_UA, Accept: 'text/html,application/xhtml+xml' },
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) {
console.warn(` Barchart ${label}: HTTP ${resp.status}`);
return null;
}
const html = await resp.text();
const block = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/)?.[1] ?? html;
const m = block.match(/"lastPrice"\s*:\s*"?([\d.]+)"?/);
const val = m ? parseFloat(m[1]) : NaN;
return Number.isFinite(val) ? val : null;
} catch (e) {
console.warn(` Barchart ${label}: ${e.message}`);
return null;
}
}
async function readExistingHistory() {
const url = process.env.UPSTASH_REDIS_REST_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
if (!url || !token) return null;
try {
const resp = await fetch(`${url}/get/${encodeURIComponent(BREADTH_KEY)}`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(5_000),
});
if (!resp.ok) return null;
const { result } = await resp.json();
return result ? JSON.parse(result) : null;
} catch {
return null;
}
}
async function fetchAll() {
const readings = {};
let successCount = 0;
for (const { symbol, label, field } of BARCHART_SYMBOLS) {
const val = await fetchBarchartPrice(symbol, label);
readings[field] = val;
if (val != null) successCount++;
await sleep(500);
}
console.log(` Barchart: ${successCount}/${BARCHART_SYMBOLS.length} readings`);
console.log(` 20d=${readings.pctAbove20d ?? 'null'} | 50d=${readings.pctAbove50d ?? 'null'} | 200d=${readings.pctAbove200d ?? 'null'}`);
if (successCount === 0) {
throw new Error('All Barchart breadth fetches failed');
}
const existing = await readExistingHistory();
const history = existing?.history ?? [];
// ET trading day: Railway cron fires at 9 PM ET which is 01:00-02:00 UTC on
// the NEXT calendar day, so UTC date would stamp today's session with
// tomorrow's date. en-CA locale returns ISO YYYY-MM-DD; America/New_York
// handles DST automatically.
const today = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York' }).format(new Date());
const lastEntry = history.at(-1);
if (lastEntry?.date === today) {
lastEntry.pctAbove20d = readings.pctAbove20d ?? lastEntry.pctAbove20d;
lastEntry.pctAbove50d = readings.pctAbove50d ?? lastEntry.pctAbove50d;
lastEntry.pctAbove200d = readings.pctAbove200d ?? lastEntry.pctAbove200d;
console.log(` Updated existing entry for ${today}`);
} else {
history.push({
date: today,
pctAbove20d: readings.pctAbove20d,
pctAbove50d: readings.pctAbove50d,
pctAbove200d: readings.pctAbove200d,
});
console.log(` Appended new entry for ${today} (history: ${history.length} days)`);
}
while (history.length > HISTORY_LENGTH) history.shift();
return {
updatedAt: new Date().toISOString(),
current: {
pctAbove20d: readings.pctAbove20d,
pctAbove50d: readings.pctAbove50d,
pctAbove200d: readings.pctAbove200d,
},
history,
};
}
function validate(data) {
return (
data?.current != null &&
Array.isArray(data?.history) &&
data.history.length > 0
);
}
runSeed('market', 'breadth-history', BREADTH_KEY, fetchAll, {
validateFn: validate,
ttlSeconds: BREADTH_TTL,
}).catch((err) => {
console.error('FATAL:', err.message || err);
process.exit(1);
});

View File

@@ -182,6 +182,7 @@ export const BOOTSTRAP_CACHE_KEYS: Record<string, string> = {
lngVulnerability: 'energy:lng-vulnerability:v1',
sprPolicies: 'energy:spr-policies:v1',
aaiiSentiment: 'market:aaii-sentiment:v1',
breadthHistory: 'market:breadth-history:v1',
};
export const PORTWATCH_PORT_ACTIVITY_KEY_PREFIX = 'supply_chain:portwatch-ports:v1:';
@@ -239,6 +240,7 @@ export const BOOTSTRAP_TIERS: Record<string, 'slow' | 'fast'> = {
lngVulnerability: 'slow',
sprPolicies: 'slow',
aaiiSentiment: 'slow',
breadthHistory: 'slow',
};
export const PORTWATCH_CHOKEPOINTS_REF_KEY = 'portwatch:chokepoints:ref:v1';

View File

@@ -67,6 +67,7 @@ const RPC_CACHE_TIER: Record<string, CacheTier> = {
'/api/market/v1/list-stablecoin-markets': 'medium',
'/api/market/v1/get-sector-summary': 'medium',
'/api/market/v1/get-fear-greed-index': 'slow',
'/api/market/v1/get-market-breadth-history': 'daily',
'/api/market/v1/list-gulf-quotes': 'medium',
'/api/market/v1/analyze-stock': 'slow',
'/api/market/v1/get-stock-analysis-history': 'medium',

View File

@@ -0,0 +1,71 @@
import type {
ServerContext,
GetMarketBreadthHistoryRequest,
GetMarketBreadthHistoryResponse,
BreadthSnapshot,
} from '../../../../src/generated/server/worldmonitor/market/v1/service_server';
import { getCachedJson } from '../../../_shared/redis';
const SEED_CACHE_KEY = 'market:breadth-history:v1';
interface SeedEntry {
date: string;
pctAbove20d: number | null;
pctAbove50d: number | null;
pctAbove200d: number | null;
}
interface SeedPayload {
updatedAt: string;
current: {
pctAbove20d: number | null;
pctAbove50d: number | null;
pctAbove200d: number | null;
};
history: SeedEntry[];
}
function emptyUnavailable(): GetMarketBreadthHistoryResponse {
return {
updatedAt: '',
history: [],
unavailable: true,
};
}
function nullToUndefined(v: number | null | undefined): number | undefined {
return v == null ? undefined : v;
}
export async function getMarketBreadthHistory(
_ctx: ServerContext,
_req: GetMarketBreadthHistoryRequest,
): Promise<GetMarketBreadthHistoryResponse> {
try {
const raw = await getCachedJson(SEED_CACHE_KEY, true) as SeedPayload | null;
if (!raw?.current || !Array.isArray(raw.history) || raw.history.length === 0) {
return emptyUnavailable();
}
// Preserve missing readings as undefined (proto `optional` → JSON omits
// the field) so a partial seed failure can be distinguished from a real
// 0% breadth reading in the UI. Panel treats undefined as "missing".
const history: BreadthSnapshot[] = raw.history.map((e) => ({
date: e.date,
pctAbove20d: nullToUndefined(e.pctAbove20d),
pctAbove50d: nullToUndefined(e.pctAbove50d),
pctAbove200d: nullToUndefined(e.pctAbove200d),
}));
return {
currentPctAbove20d: nullToUndefined(raw.current.pctAbove20d),
currentPctAbove50d: nullToUndefined(raw.current.pctAbove50d),
currentPctAbove200d: nullToUndefined(raw.current.pctAbove200d),
updatedAt: raw.updatedAt ?? '',
history,
unavailable: false,
};
} catch {
return emptyUnavailable();
}
}

View File

@@ -33,6 +33,7 @@ import { getFearGreedIndex } from './get-fear-greed-index';
import { listEarningsCalendar } from './list-earnings-calendar';
import { getCotPositioning } from './get-cot-positioning';
import { getInsiderTransactions } from './get-insider-transactions';
import { getMarketBreadthHistory } from './get-market-breadth-history';
export const marketHandler: MarketServiceHandler = {
listMarketQuotes,
@@ -55,4 +56,5 @@ export const marketHandler: MarketServiceHandler = {
listEarningsCalendar,
getCotPositioning,
getInsiderTransactions,
getMarketBreadthHistory,
};

View File

@@ -332,6 +332,9 @@ export class App {
if (shouldPrime('aaii-sentiment')) {
primeTask('aaiiSentiment', () => this.dataLoader.loadAaiiSentiment());
}
if (shouldPrime('market-breadth')) {
primeTask('marketBreadth', () => this.dataLoader.loadMarketBreadth());
}
if (shouldPrimeAny(['markets', 'heatmap', 'commodities', 'crypto', 'energy-complex'])) {
primeTask('markets', () => this.dataLoader.loadMarkets());
}
@@ -1403,6 +1406,12 @@ export class App {
REFRESH_INTERVALS.aaiiSentiment,
() => this.isPanelNearViewport('aaii-sentiment')
);
this.refreshScheduler.scheduleRefresh(
'market-breadth',
() => this.dataLoader.loadMarketBreadth(),
REFRESH_INTERVALS.marketBreadth,
() => this.isPanelNearViewport('market-breadth')
);
// Refresh intelligence signals for CII (geopolitical variant only)
if (SITE_VARIANT === 'full') {

View File

@@ -162,6 +162,7 @@ import {
SocialVelocityPanel,
WsbTickerScannerPanel,
AAIISentimentPanel,
MarketBreadthPanel,
} from '@/components';
import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel';
import { classifyNewsItem } from '@/services/positive-classifier';
@@ -3332,6 +3333,16 @@ export class DataLoaderManager implements AppModule {
}
}
async loadMarketBreadth(): Promise<void> {
const panel = this.ctx.panels['market-breadth'] as MarketBreadthPanel | undefined;
if (!panel) return;
try {
await panel.fetchData();
} catch (e) {
console.error('[App] Market breadth load failed:', e);
}
}
async loadCrossSourceSignals(): Promise<void> {
try {
const result = await fetchCrossSourceSignals();

View File

@@ -40,6 +40,7 @@ import {
InsightsPanel,
MacroSignalsPanel,
FearGreedPanel,
MarketBreadthPanel,
ETFFlowsPanel,
StablecoinPanel,
UcdpEventsPanel,
@@ -1080,6 +1081,7 @@ export class PanelLayoutManager implements AppModule {
this.createPanel('macro-signals', () => new MacroSignalsPanel());
this.createPanel('fear-greed', () => new FearGreedPanel());
this.createPanel('aaii-sentiment', () => new AAIISentimentPanel());
this.createPanel('market-breadth', () => new MarketBreadthPanel());
this.createPanel('macro-tiles', () => new MacroTilesPanel());
this.createPanel('fsi', () => new FSIPanel());
this.createPanel('yield-curve', () => new YieldCurvePanel());

View File

@@ -0,0 +1,265 @@
import { Panel } from './Panel';
import { t } from '@/services/i18n';
import { escapeHtml } from '@/utils/sanitize';
import { getHydratedData } from '@/services/bootstrap';
interface BreadthSnapshot {
date: string;
// null = reading was missing (partial seed failure); 0 = legitimate 0%.
pctAbove20d: number | null;
pctAbove50d: number | null;
pctAbove200d: number | null;
}
interface BreadthData {
currentPctAbove20d: number | null;
currentPctAbove50d: number | null;
currentPctAbove200d: number | null;
updatedAt: string;
history: BreadthSnapshot[];
unavailable?: boolean;
}
interface RawHistoryEntry {
date: string;
pctAbove20d?: number | null;
pctAbove50d?: number | null;
pctAbove200d?: number | null;
}
interface RawSeedPayload {
current?: { pctAbove20d?: number | null; pctAbove50d?: number | null; pctAbove200d?: number | null };
currentPctAbove20d?: number | null;
currentPctAbove50d?: number | null;
currentPctAbove200d?: number | null;
updatedAt?: string;
history?: RawHistoryEntry[];
unavailable?: boolean;
}
function toNullable(v: number | null | undefined): number | null {
if (v === null || v === undefined) return null;
return Number.isFinite(v) ? v : null;
}
function normalizeBreadthData(raw: RawSeedPayload): BreadthData {
const current = raw.current;
const history: BreadthSnapshot[] = (raw.history ?? []).map((e) => ({
date: e.date,
pctAbove20d: toNullable(e.pctAbove20d),
pctAbove50d: toNullable(e.pctAbove50d),
pctAbove200d: toNullable(e.pctAbove200d),
}));
return {
currentPctAbove20d: toNullable(raw.currentPctAbove20d ?? current?.pctAbove20d),
currentPctAbove50d: toNullable(raw.currentPctAbove50d ?? current?.pctAbove50d),
currentPctAbove200d: toNullable(raw.currentPctAbove200d ?? current?.pctAbove200d),
updatedAt: raw.updatedAt ?? '',
history,
unavailable: raw.unavailable,
};
}
const SVG_W = 480;
const SVG_H = 160;
const ML = 32;
const MR = 12;
const MT = 10;
const MB = 22;
const CW = SVG_W - ML - MR;
const CH = SVG_H - MT - MB;
type NumericSeriesKey = 'pctAbove20d' | 'pctAbove50d' | 'pctAbove200d';
type SeriesRun = Array<{ x: number; y: number }>;
const SERIES: { key: NumericSeriesKey; color: string; label: string; fillOpacity: number }[] = [
{ key: 'pctAbove20d', color: '#3b82f6', label: '20-day SMA', fillOpacity: 0.08 },
{ key: 'pctAbove50d', color: '#f59e0b', label: '50-day SMA', fillOpacity: 0.06 },
{ key: 'pctAbove200d', color: '#22c55e', label: '200-day SMA', fillOpacity: 0.04 },
];
function xPos(i: number, total: number): number {
if (total <= 1) return ML + CW / 2;
return ML + (i / (total - 1)) * CW;
}
function yPos(v: number): number {
return MT + CH - (v / 100) * CH;
}
/**
* Split a series into contiguous runs of valid points. Any null/non-finite
* reading breaks the run so the chart renders visible gaps at missing days
* instead of smoothing over them with a continuous line. Without this, a
* seed partial failure would look like real uninterrupted trend data.
*/
function splitSeriesByNulls(points: BreadthSnapshot[], key: NumericSeriesKey): SeriesRun[] {
const runs: SeriesRun[] = [];
let current: SeriesRun = [];
for (let i = 0; i < points.length; i++) {
const v = points[i]![key];
if (v === null || v === undefined || !Number.isFinite(v)) {
if (current.length > 0) {
runs.push(current);
current = [];
}
continue;
}
current.push({ x: xPos(i, points.length), y: yPos(v as number) });
}
if (current.length > 0) runs.push(current);
return runs;
}
function runToAreaPath(run: SeriesRun): string {
if (run.length < 2) return '';
const baseline = yPos(0).toFixed(1);
const first = run[0]!.x.toFixed(1);
const last = run[run.length - 1]!.x.toFixed(1);
const coords = run.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`);
return `M${first},${baseline} L${coords.join(' L')} L${last},${baseline} Z`;
}
function runToPolylinePoints(run: SeriesRun): string {
if (run.length === 0) return '';
return run.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
}
function buildChart(points: BreadthSnapshot[]): string {
if (points.length < 2) return '<div style="text-align:center;color:var(--text-dim);padding:20px;font-size:11px">Collecting data. Chart appears after 2+ days.</div>';
const yAxis = [0, 25, 50, 75, 100].map(v => {
const y = yPos(v);
return `
<line x1="${ML}" y1="${y.toFixed(1)}" x2="${SVG_W - MR}" y2="${y.toFixed(1)}" stroke="rgba(255,255,255,0.06)" stroke-width="1"/>
<text x="${(ML - 3).toFixed(0)}" y="${y.toFixed(1)}" text-anchor="end" fill="rgba(255,255,255,0.35)" font-size="8" dominant-baseline="middle">${v}%</text>`;
}).join('');
const step = Math.max(1, Math.floor(points.length / 6));
const xAxis = points.map((p, i) => {
if (i % step !== 0 && i !== points.length - 1) return '';
const x = xPos(i, points.length);
const label = p.date.slice(5);
return `<text x="${x.toFixed(1)}" y="${SVG_H - MB + 13}" text-anchor="middle" fill="rgba(255,255,255,0.4)" font-size="7">${escapeHtml(label)}</text>`;
}).join('');
// Render each contiguous run separately so null/missing days leave visible
// gaps instead of being bridged by a continuous polyline.
const areas = SERIES.map(s => {
const runs = splitSeriesByNulls(points, s.key as NumericSeriesKey);
return runs
.map((run) => {
const d = runToAreaPath(run);
if (!d) return '';
return `<path d="${d}" fill="${s.color}" opacity="${s.fillOpacity}"/>`;
})
.join('');
}).join('');
const lines = SERIES.map(s => {
const runs = splitSeriesByNulls(points, s.key as NumericSeriesKey);
return runs
.map((run) => {
if (run.length < 2) return '';
const coords = runToPolylinePoints(run);
return `<polyline points="${coords}" fill="none" stroke="${s.color}" stroke-width="1.5" opacity="0.9"/>`;
})
.join('');
}).join('');
const midLine = yPos(50);
const mid = `<line x1="${ML}" y1="${midLine.toFixed(1)}" x2="${SVG_W - MR}" y2="${midLine.toFixed(1)}" stroke="rgba(255,255,255,0.12)" stroke-width="1" stroke-dasharray="4 3"/>`;
return `<svg viewBox="0 0 ${SVG_W} ${SVG_H}" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;display:block">${yAxis}${mid}${xAxis}${areas}${lines}</svg>`;
}
function readingBadge(val: number, color: string): string {
const bg = val >= 60 ? 'rgba(34,197,94,0.12)' : val >= 40 ? 'rgba(245,158,11,0.12)' : 'rgba(239,68,68,0.12)';
const fg = val >= 60 ? '#22c55e' : val >= 40 ? '#f59e0b' : '#ef4444';
return `<span style="display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:4px;background:${bg}">
<span style="width:6px;height:6px;border-radius:50%;background:${color}"></span>
<span style="font-size:14px;font-weight:600;color:${fg}">${val.toFixed(1)}%</span>
</span>`;
}
export class MarketBreadthPanel extends Panel {
private data: BreadthData | null = null;
constructor() {
super({ id: 'market-breadth', title: t('panels.marketBreadth'), showCount: false, infoTooltip: 'Percentage of S&P 500 stocks trading above their 20, 50, and 200-day simple moving averages. A measure of market participation and internal strength.' });
}
public async fetchData(): Promise<boolean> {
const hydrated = getHydratedData('breadthHistory') as RawSeedPayload | undefined;
if (hydrated && !hydrated.unavailable && hydrated.history?.length) {
this.data = normalizeBreadthData(hydrated);
this.renderPanel();
void this.refreshFromRpc();
return true;
}
this.showLoading();
return this.refreshFromRpc();
}
private async refreshFromRpc(): Promise<boolean> {
try {
const { MarketServiceClient } = await import('@/generated/client/worldmonitor/market/v1/service_client');
const { getRpcBaseUrl } = await import('@/services/rpc-client');
const client = new MarketServiceClient(getRpcBaseUrl(), { fetch: (...args: Parameters<typeof fetch>) => globalThis.fetch(...args) });
const resp = await client.getMarketBreadthHistory({});
if (resp.unavailable) {
if (!this.data) this.showError(t('common.noDataShort'), () => void this.fetchData());
return false;
}
// The RPC interface types these as `number` but the JSON wire preserves
// null for missing readings — normalize through the same path as the
// hydrated payload so partial failures become `null` not `0`.
this.data = normalizeBreadthData(resp as unknown as RawSeedPayload);
this.renderPanel();
return true;
} catch (e) {
if (!this.data) this.showError(e instanceof Error ? e.message : t('common.failedToLoad'), () => void this.fetchData());
return false;
}
}
private renderPanel(): void {
if (!this.data?.history?.length) {
this.showError(t('common.noDataShort'), () => void this.fetchData());
return;
}
const d = this.data;
const chart = buildChart(d.history);
const currentMap: Record<NumericSeriesKey, number | null> = {
pctAbove20d: d.currentPctAbove20d,
pctAbove50d: d.currentPctAbove50d,
pctAbove200d: d.currentPctAbove200d,
};
const legend = SERIES.map(s => {
const val = currentMap[s.key];
// Distinguish missing (null) from a real 0% reading — a seed partial
// failure shows "—", a legit zero renders a badge at 0.0%.
const hasCurrent = typeof val === 'number' && Number.isFinite(val) && val >= 0;
return `<div style="display:flex;align-items:center;justify-content:space-between;padding:4px 0">
<span style="display:flex;align-items:center;gap:6px;font-size:11px;color:var(--text-dim)">
<span style="width:8px;height:3px;border-radius:1px;background:${s.color}"></span>
% Above ${escapeHtml(s.label)}
</span>
${hasCurrent ? readingBadge(val as number, s.color) : '<span style="font-size:11px;color:var(--text-dim)">\u2014</span>'}
</div>`;
}).join('');
const html = `
<div style="padding:12px 14px">
<div style="margin-bottom:8px">${legend}</div>
<div style="border-radius:6px;background:rgba(255,255,255,0.02);padding:4px 0">${chart}</div>
${d.updatedAt ? `<div style="text-align:right;font-size:9px;color:var(--text-dim);margin-top:4px">${escapeHtml(new Date(d.updatedAt).toLocaleString())}</div>` : ''}
</div>`;
this.setContent(html);
}
}

View File

@@ -74,6 +74,7 @@ export * from './ConsumerPricesPanel';
export { NationalDebtPanel } from './NationalDebtPanel';
export * from './FearGreedPanel';
export * from './AAIISentimentPanel';
export * from './MarketBreadthPanel';
export * from './MacroTilesPanel';
export * from './FSIPanel';
export * from './YieldCurvePanel';

View File

@@ -126,6 +126,7 @@ export const COMMANDS: Command[] = [
{ id: 'panel:macro-signals', keywords: ['macro', 'macro signals', 'liquidity'], label: 'Panel: Market Radar', icon: '\u{1F4C9}', category: 'panels' },
{ id: 'panel:fear-greed', keywords: ['fear', 'greed', 'fear and greed', 'sentiment', 'fear greed index'], label: 'Panel: Fear & Greed', icon: '\u{1F4CA}', category: 'panels' },
{ id: 'panel:aaii-sentiment', keywords: ['aaii', 'investor sentiment', 'bull bear', 'sentiment survey', 'aaii survey', 'contrarian'], label: 'Panel: AAII Investor Sentiment', icon: '\u{1F4CA}', category: 'panels' },
{ id: 'panel:market-breadth', keywords: ['breadth', 'market breadth', 'advance decline', 'above sma', 'stocks above moving average', '200 day', '50 day'], label: 'Panel: Market Breadth', icon: '\u{1F4CA}', category: 'panels' },
{ id: 'panel:hormuz-tracker', keywords: ['hormuz', 'strait of hormuz', 'shipping', 'crude oil', 'lng', 'fertilizer', 'tanker', 'wto'], label: 'Panel: Hormuz Trade Tracker', icon: '\u{1F6A2}', category: 'panels' },
{ id: 'panel:etf-flows', keywords: ['etf', 'etf flows', 'fund flows'], label: 'Panel: BTC ETF Tracker', icon: '\u{1F4B9}', category: 'panels' },
{ id: 'panel:stablecoins', keywords: ['stablecoins', 'usdt', 'usdc'], label: 'Panel: Stablecoins', icon: '\u{1FA99}', category: 'panels' },

View File

@@ -63,6 +63,7 @@ const FULL_PANELS: Record<string, PanelConfig> = {
'macro-signals': { name: 'Market Regime', enabled: true, priority: 2 },
'fear-greed': { name: 'Fear & Greed', enabled: true, priority: 2 },
'aaii-sentiment': { name: 'AAII Sentiment', enabled: false, priority: 2 },
'market-breadth': { name: 'Market Breadth', enabled: true, priority: 2 },
'macro-tiles': { name: 'Macro Indicators', enabled: false, priority: 2 },
'fsi': { name: 'Financial Stress', enabled: false, priority: 2 },
'yield-curve': { name: 'Yield Curve', enabled: false, priority: 2 },
@@ -437,6 +438,7 @@ const FINANCE_PANELS: Record<string, PanelConfig> = {
'macro-tiles': { name: 'Macro Indicators', enabled: true, priority: 1 },
'fear-greed': { name: 'Fear & Greed', enabled: true, priority: 1 },
'aaii-sentiment': { name: 'AAII Sentiment', enabled: true, priority: 2 },
'market-breadth': { name: 'Market Breadth', enabled: true, priority: 1 },
'fsi': { name: 'Financial Stress', enabled: true, priority: 1 },
'yield-curve': { name: 'Yield Curve', enabled: true, priority: 1 },
'earnings-calendar': { name: 'Earnings Calendar', enabled: true, priority: 1 },

View File

@@ -62,6 +62,7 @@ export const REFRESH_INTERVALS = {
economicCalendar: 60 * 60 * 1000,
cotPositioning: 60 * 60 * 1000,
aaiiSentiment: 60 * 60 * 1000, // weekly data; hourly refresh is sufficient
marketBreadth: 60 * 60 * 1000, // seeded daily; hourly refresh is sufficient
};
// Monitor colors - shared

View File

@@ -162,6 +162,7 @@ export const DEFAULT_PANELS: Record<string, PanelConfig> = {
heatmap: { name: 'Sector Heatmap', enabled: true, priority: 1 },
'macro-signals': { name: 'Market Radar', enabled: true, priority: 1 },
'fear-greed': { name: 'Fear & Greed', enabled: true, priority: 1 },
'market-breadth': { name: 'Market Breadth', enabled: true, priority: 1 },
derivatives: { name: 'Derivatives & Options', enabled: true, priority: 2 },
fintech: { name: 'Fintech & Trading Tech', enabled: true, priority: 2 },
regulation: { name: 'Financial Regulation', enabled: true, priority: 2 },

View File

@@ -484,6 +484,25 @@ export interface InsiderTransaction {
transactionDate: string;
}
export interface GetMarketBreadthHistoryRequest {
}
export interface GetMarketBreadthHistoryResponse {
currentPctAbove20d?: number;
currentPctAbove50d?: number;
currentPctAbove200d?: number;
updatedAt: string;
history: BreadthSnapshot[];
unavailable: boolean;
}
export interface BreadthSnapshot {
date: string;
pctAbove20d?: number;
pctAbove50d?: number;
pctAbove200d?: number;
}
export interface FieldViolation {
field: string;
description: string;
@@ -1024,6 +1043,29 @@ export class MarketServiceClient {
return await resp.json() as GetInsiderTransactionsResponse;
}
async getMarketBreadthHistory(req: GetMarketBreadthHistoryRequest, options?: MarketServiceCallOptions): Promise<GetMarketBreadthHistoryResponse> {
let path = "/api/market/v1/get-market-breadth-history";
const url = this.baseURL + path;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...this.defaultHeaders,
...options?.headers,
};
const resp = await this.fetchFn(url, {
method: "GET",
headers,
signal: options?.signal,
});
if (!resp.ok) {
return this.handleError(resp);
}
return await resp.json() as GetMarketBreadthHistoryResponse;
}
private async handleError(resp: Response): Promise<never> {
const body = await resp.text();
if (resp.status === 400) {

View File

@@ -484,6 +484,25 @@ export interface InsiderTransaction {
transactionDate: string;
}
export interface GetMarketBreadthHistoryRequest {
}
export interface GetMarketBreadthHistoryResponse {
currentPctAbove20d?: number;
currentPctAbove50d?: number;
currentPctAbove200d?: number;
updatedAt: string;
history: BreadthSnapshot[];
unavailable: boolean;
}
export interface BreadthSnapshot {
date: string;
pctAbove20d?: number;
pctAbove50d?: number;
pctAbove200d?: number;
}
export interface FieldViolation {
field: string;
description: string;
@@ -549,6 +568,7 @@ export interface MarketServiceHandler {
listEarningsCalendar(ctx: ServerContext, req: ListEarningsCalendarRequest): Promise<ListEarningsCalendarResponse>;
getCotPositioning(ctx: ServerContext, req: GetCotPositioningRequest): Promise<GetCotPositioningResponse>;
getInsiderTransactions(ctx: ServerContext, req: GetInsiderTransactionsRequest): Promise<GetInsiderTransactionsResponse>;
getMarketBreadthHistory(ctx: ServerContext, req: GetMarketBreadthHistoryRequest): Promise<GetMarketBreadthHistoryResponse>;
}
export function createMarketServiceRoutes(
@@ -1424,6 +1444,43 @@ export function createMarketServiceRoutes(
}
},
},
{
method: "GET",
path: "/api/market/v1/get-market-breadth-history",
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const body = {} as GetMarketBreadthHistoryRequest;
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.getMarketBreadthHistory(ctx, body);
return new Response(JSON.stringify(result as GetMarketBreadthHistoryResponse), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (err: unknown) {
if (err instanceof ValidationError) {
return new Response(JSON.stringify({ violations: err.violations }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (options?.onError) {
return options.onError(err, req);
}
const message = err instanceof Error ? err.message : String(err);
return new Response(JSON.stringify({ message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
},
},
];
}

View File

@@ -381,6 +381,7 @@
"airlineIntel": "✈️ Airline Intelligence",
"consumerPrices": "Consumer Prices",
"fearGreed": "Fear & Greed",
"marketBreadth": "Market Breadth",
"climateNews": "Climate News"
},
"commands": {

View File

@@ -0,0 +1,193 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
describe('Market breadth bootstrap registration', () => {
const cacheKeysSrc = readFileSync(join(root, 'server', '_shared', 'cache-keys.ts'), 'utf-8');
const bootstrapSrc = readFileSync(join(root, 'api', 'bootstrap.js'), 'utf-8');
const healthSrc = readFileSync(join(root, 'api', 'health.js'), 'utf-8');
const gatewaySrc = readFileSync(join(root, 'server', 'gateway.ts'), 'utf-8');
it('cache-keys.ts has breadthHistory in BOOTSTRAP_CACHE_KEYS', () => {
assert.match(cacheKeysSrc, /breadthHistory:\s+'market:breadth-history:v1'/);
});
it('cache-keys.ts has breadthHistory in BOOTSTRAP_TIERS', () => {
assert.match(cacheKeysSrc, /breadthHistory:\s+'slow'/);
});
it('bootstrap.js has breadthHistory key', () => {
assert.match(bootstrapSrc, /breadthHistory:\s+'market:breadth-history:v1'/);
});
it('bootstrap.js has breadthHistory in SLOW_KEYS', () => {
assert.match(bootstrapSrc, /'breadthHistory'/);
});
it('health.js has breadthHistory data key', () => {
assert.match(healthSrc, /breadthHistory:\s+'market:breadth-history:v1'/);
});
it('health.js has breadthHistory seed-meta config', () => {
assert.match(healthSrc, /breadthHistory:\s+\{\s*key:\s+'seed-meta:market:breadth-history'/);
});
it('gateway.ts has market breadth history cache tier', () => {
assert.match(gatewaySrc, /\/api\/market\/v1\/get-market-breadth-history/);
});
});
describe('Market breadth seed script', () => {
const seedSrc = readFileSync(join(root, 'scripts', 'seed-market-breadth.mjs'), 'utf-8');
it('uses correct Redis key', () => {
assert.match(seedSrc, /market:breadth-history:v1/);
});
it('has a 30-day TTL', () => {
assert.match(seedSrc, /2592000/);
});
it('fetches all three Barchart breadth symbols', () => {
assert.match(seedSrc, /S5TW/);
assert.match(seedSrc, /S5FI/);
assert.match(seedSrc, /S5TH/);
});
it('maintains rolling 252-day history', () => {
assert.match(seedSrc, /HISTORY_LENGTH\s*=\s*252/);
});
it('calls runSeed with validation', () => {
assert.match(seedSrc, /runSeed\(/);
assert.match(seedSrc, /validateFn/);
});
});
describe('Market breadth RPC handler', () => {
const handlerSrc = readFileSync(join(root, 'server', 'worldmonitor', 'market', 'v1', 'get-market-breadth-history.ts'), 'utf-8');
it('reads from correct cache key', () => {
assert.match(handlerSrc, /market:breadth-history:v1/);
});
it('returns unavailable=true on empty data', () => {
assert.match(handlerSrc, /unavailable:\s*true/);
});
it('maps history entries to BreadthSnapshot', () => {
assert.match(handlerSrc, /BreadthSnapshot/);
});
});
describe('Market breadth proto', () => {
const protoSrc = readFileSync(join(root, 'proto', 'worldmonitor', 'market', 'v1', 'get_market_breadth_history.proto'), 'utf-8');
const serviceSrc = readFileSync(join(root, 'proto', 'worldmonitor', 'market', 'v1', 'service.proto'), 'utf-8');
it('defines GetMarketBreadthHistoryRequest and Response', () => {
assert.match(protoSrc, /GetMarketBreadthHistoryRequest/);
assert.match(protoSrc, /GetMarketBreadthHistoryResponse/);
});
it('defines BreadthSnapshot message', () => {
assert.match(protoSrc, /message BreadthSnapshot/);
});
it('marks pct_above_* fields optional so null != 0 at the wire level', () => {
assert.match(protoSrc, /optional double pct_above_20d/);
assert.match(protoSrc, /optional double pct_above_50d/);
assert.match(protoSrc, /optional double pct_above_200d/);
assert.match(protoSrc, /optional double current_pct_above_20d/);
assert.match(protoSrc, /optional double current_pct_above_50d/);
assert.match(protoSrc, /optional double current_pct_above_200d/);
});
it('is imported in service.proto', () => {
assert.match(serviceSrc, /get_market_breadth_history\.proto/);
});
it('has RPC registered in MarketService', () => {
assert.match(serviceSrc, /rpc GetMarketBreadthHistory/);
});
});
describe('Market breadth panel', () => {
const panelSrc = readFileSync(join(root, 'src', 'components', 'MarketBreadthPanel.ts'), 'utf-8');
it('is registered in handler.ts', () => {
const handlerTs = readFileSync(join(root, 'server', 'worldmonitor', 'market', 'v1', 'handler.ts'), 'utf-8');
assert.match(handlerTs, /getMarketBreadthHistory/);
});
it('builds SVG area chart', () => {
assert.match(panelSrc, /<svg viewBox/);
assert.match(panelSrc, /polyline/);
assert.match(panelSrc, /<path/);
});
it('shows 3 series with correct colors', () => {
assert.match(panelSrc, /#3b82f6/); // blue for 20d
assert.match(panelSrc, /#f59e0b/); // orange for 50d
assert.match(panelSrc, /#22c55e/); // green for 200d
});
it('fetches from bootstrap and RPC', () => {
assert.match(panelSrc, /getHydratedData/);
assert.match(panelSrc, /getMarketBreadthHistory/);
});
});
describe('Market breadth null-vs-zero handling', () => {
const panelSrc = readFileSync(join(root, 'src', 'components', 'MarketBreadthPanel.ts'), 'utf-8');
const handlerSrc = readFileSync(join(root, 'server', 'worldmonitor', 'market', 'v1', 'get-market-breadth-history.ts'), 'utf-8');
const seedSrc = readFileSync(join(root, 'scripts', 'seed-market-breadth.mjs'), 'utf-8');
it('seed preserves null for failed Barchart fetches', () => {
// readings[field] = val where val can be null; must NOT coerce to 0
assert.match(seedSrc, /readings\[field\]\s*=\s*val/);
assert.doesNotMatch(seedSrc, /pctAbove20d:\s*readings\.pctAbove20d\s*\|\|\s*0/);
});
it('handler returns nullable currents (no ?? 0 coercion)', () => {
// Ensure the handler no longer coerces raw.current.pctAbove* ?? 0
assert.doesNotMatch(handlerSrc, /raw\.current\.pctAbove20d\s*\?\?\s*0/);
assert.doesNotMatch(handlerSrc, /raw\.current\.pctAbove50d\s*\?\?\s*0/);
assert.doesNotMatch(handlerSrc, /raw\.current\.pctAbove200d\s*\?\?\s*0/);
// Missing readings flow through nullToUndefined so proto `optional`
// serializes as JSON undefined (field omitted), not 0.
assert.match(handlerSrc, /nullToUndefined\(raw\.current\.pctAbove20d\)/);
assert.match(handlerSrc, /nullToUndefined\(raw\.current\.pctAbove50d\)/);
assert.match(handlerSrc, /nullToUndefined\(raw\.current\.pctAbove200d\)/);
});
it('panel type distinguishes null from number for current readings', () => {
assert.match(panelSrc, /currentPctAbove20d:\s*number\s*\|\s*null/);
assert.match(panelSrc, /currentPctAbove50d:\s*number\s*\|\s*null/);
assert.match(panelSrc, /currentPctAbove200d:\s*number\s*\|\s*null/);
});
it('panel legend treats null as missing, 0 as a valid reading', () => {
// hasCurrent check must accept 0 but reject null
assert.match(panelSrc, /Number\.isFinite\(val\)\s*&&\s*val\s*>=\s*0/);
// Uses "—" (em dash, \u2014) for missing readings, not "N/A"
assert.match(panelSrc, /\\u2014/);
});
it('history chart splits polylines at null points', () => {
assert.match(panelSrc, /splitSeriesByNulls/);
// run-based helpers (one polyline per contiguous run, not one per series)
assert.match(panelSrc, /runToAreaPath/);
assert.match(panelSrc, /runToPolylinePoints/);
});
it('splitSeriesByNulls breaks on null/undefined/non-finite values', () => {
assert.match(panelSrc, /v\s*===\s*null/);
assert.match(panelSrc, /v\s*===\s*undefined/);
assert.match(panelSrc, /!Number\.isFinite\(v\)/);
});
});