mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat(breadth): add market breadth history chart (#2932)
This commit is contained in:
2
api/bootstrap.js
vendored
2
api/bootstrap.js
vendored
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
|
||||
127
scripts/seed-market-breadth.mjs
Normal file
127
scripts/seed-market-breadth.mjs
Normal 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);
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
71
server/worldmonitor/market/v1/get-market-breadth-history.ts
Normal file
71
server/worldmonitor/market/v1/get-market-breadth-history.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
265
src/components/MarketBreadthPanel.ts
Normal file
265
src/components/MarketBreadthPanel.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -381,6 +381,7 @@
|
||||
"airlineIntel": "✈️ Airline Intelligence",
|
||||
"consumerPrices": "Consumer Prices",
|
||||
"fearGreed": "Fear & Greed",
|
||||
"marketBreadth": "Market Breadth",
|
||||
"climateNews": "Climate News"
|
||||
},
|
||||
"commands": {
|
||||
|
||||
193
tests/market-breadth.test.mjs
Normal file
193
tests/market-breadth.test.mjs
Normal 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\)/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user