feat(finance-panels): add 7 macro/market panels + Daily Brief context (issues #2245-#2253) (#2258)

* feat(fear-greed): add regime state label, action stance badge, divergence warnings

Closes #2245

* feat(finance-panels): add 7 new finance panels + Daily Brief macro context

Implements issues #2245 (F&G Regime), #2246 (Sector Heatmap bars),
#2247 (MacroTiles), #2248 (FSI), #2249 (Yield Curve), #2250 (Earnings
Calendar), #2251 (Economic Calendar), #2252 (COT Positioning),
#2253 (Daily Brief prompt extension).

New panels:
- MacroTilesPanel: CPI YoY, Unemployment, GDP, Fed Rate tiles via FRED
- FSIPanel: Financial Stress Indicator gauge (HYG/TLT/VIX/HY-spread)
- YieldCurvePanel: SVG yield curve chart with inverted/normal badge
- EarningsCalendarPanel: Finnhub earnings calendar with BMO/AMC/BEAT/MISS
- EconomicCalendarPanel: FOMC/CPI/NFP events with impact badges
- CotPositioningPanel: CFTC disaggregated COT positioning bars
- MarketPanel: adds sorted bar chart view above sector heatmap grid

New RPCs:
- ListEarningsCalendar (market/v1)
- GetCotPositioning (market/v1)
- GetEconomicCalendar (economic/v1)

Seed scripts:
- seed-earnings-calendar.mjs (Finnhub, 14-day window, TTL 12h)
- seed-economic-calendar.mjs (Finnhub, 30-day window, TTL 12h)
- seed-cot.mjs (CFTC disaggregated text file, TTL 7d)
- seed-economy.mjs: adds yield curve tenors DGS1MO/3MO/6MO/1/2/5/30
- seed-fear-greed.mjs: adds FSI computation + sector performance

Daily Brief: extends buildDailyMarketBrief with optional regime,
yield curve, and sector context fed to the LLM summarization prompt.

All panels default enabled in FINANCE_PANELS, disabled in FULL_PANELS.

🤖 Generated with Claude Sonnet 4.6 via Claude Code (https://claude.ai/claude-code) + Compound Engineering v2.40.0

Co-Authored-By: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>

* fix(finance-panels): address code review P1/P2 findings

P1 - Security/Correctness:
- EconomicCalendarPanel: add escapeHtml on all 7 Finnhub-sourced fields
- EconomicCalendarPanel: fix panel contract (public fetchData():boolean,
  remove constructor self-init, add retry callbacks to all showError calls)
- YieldCurvePanel: fix NaN in xPos() when count <= 1 (divide-by-zero)
- seed-earnings-calendar: move Finnhub API key from URL to X-Finnhub-Token header
- seed-economic-calendar: move Finnhub API key from URL to X-Finnhub-Token header
- seed-earnings-calendar: add isMain guard around runSeed() call
- health.js + bootstrap.js: register earningsCalendar, econCalendar, cotPositioning keys
- health.js dataSize(): add earnings + instruments to property name list

P2 - Quality:
- FSIPanel: change !resp.fsiValue → resp.fsiValue <= 0 (rejects valid zero)
- data-loader: fix Promise.allSettled type inference via indexed destructure
- seed-fear-greed: allowlist cnnLabel against known values before writing to Redis
- seed-economic-calendar: remove unused sleep import
- seed-earnings-calendar + econ-calendar: increase TTL 43200 → 129600 (36h = 3x interval)
- YieldCurvePanel: use SERIES_IDS const in RPC call (single source of truth)

* fix(bootstrap): remove on-demand panel keys from bootstrap.js

earningsCalendar, econCalendar, cotPositioning panels fetch via RPC
on demand — they have no getHydratedData consumer in src/ and must
not be in api/bootstrap.js. They remain in api/health.js BOOTSTRAP_KEYS
for staleness monitoring.

* fix(compound-engineering): fix markdown lint error in local settings

* fix(finance-panels): resolve all P3 code-review findings

- 030: MacroTilesPanel: add `deltaFormat?` field to MacroTile interface,
  define per-tile delta formatters (CPI pp, GDP localeString+B),
  replace fragile tile.id switch in tileHtml with fmt = deltaFormat ?? format
- 031: FSIPanel: check getHydratedData('fearGreedIndex') at top of
  fetchData(); extract fsi/vix/hySpread from headerMetrics and render
  synchronously; fall back to live RPC only when bootstrap absent
- 032: All 6 finance panels: extract lazy module-level client singletons
  (EconomicServiceClient or MarketServiceClient) so the client is
  constructed at most once per panel module lifetime, not on every fetchData
- 033: get-fred-series-batch: add BAMLC0A0CM and SOFR to ALLOWED_SERIES
  (both seeded by seed-economy.mjs but previously unreachable via RPC)

* fix(finance-panels): health.js SEED_META, FSI calibration, seed-cot catch handler

- health.js: add SEED_META entries for earningsCalendar (1440min), econCalendar
  (1440min), cotPositioning (14400min) — without these, stopped seeds only alarm
  CRIT:EMPTY after TTL expiry instead of earlier WARN:STALE_SEED
- seed-cot.mjs: replace bare await with .catch() handler consistent with other seeds
- seed-fear-greed.mjs: recalibrate FSI thresholds to match formula output range
  (Low>=1.5, Moderate>=0.8, Elevated>=0.3; old values >=0.08/0.05/0.03 were
  calibrated for [0,0.15] but formula yields ~1-2 in normal conditions)
- FSIPanel.ts: fix gauge fillPct range to [0, 2.5] matching recalibrated thresholds
- todos: fix MD022/MD032 markdown lint errors in P3 review files

---------

Co-authored-by: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>
This commit is contained in:
Elie Habib
2026-03-26 08:03:09 +04:00
committed by GitHub
parent 5a24e8d60c
commit 2939b1f4a1
45 changed files with 2567 additions and 7 deletions

View File

@@ -55,6 +55,9 @@ const BOOTSTRAP_KEYS = {
otherTokens: 'market:other-tokens:v1',
fredBatch: 'economic:fred:v1:FEDFUNDS:0',
fearGreedIndex: 'market:fear-greed:v1',
earningsCalendar: 'market:earnings-calendar:v1',
econCalendar: 'economic:econ-calendar:v1',
cotPositioning: 'market:cot:v1',
};
const STANDALONE_KEYS = {
@@ -180,6 +183,9 @@ const SEED_META = {
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
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
cotPositioning: { key: 'seed-meta:market:cot', maxStaleMin: 14400 }, // weekly CFTC release; 14400min = 10d = 1.4x interval (weekend + delay buffer)
};
// Standalone keys that are populated on-demand by RPC handlers (not seeds).
@@ -246,6 +252,7 @@ function dataSize(parsed) {
'airports', 'closedIcaos', 'categories', 'regions', 'entries', 'satellites',
'sectors', 'statuses', 'scores', 'topics', 'advisories', 'months',
'observations', 'datapoints', 'clusters',
'earnings', 'instruments',
'charts']) {
if (Array.isArray(parsed[k])) return parsed[k].length;
}

View File

@@ -0,0 +1,22 @@
---
review_agents:
- compound-engineering:review:kieran-typescript-reviewer
- compound-engineering:review:security-sentinel
- compound-engineering:review:performance-oracle
- compound-engineering:review:architecture-strategist
- compound-engineering:review:code-simplicity-reviewer
---
# WorldMonitor Review Context
TypeScript monorepo: Vanilla TS panels (no React), sebuf proto RPCs, Redis-cached seed data,
Vercel edge functions, Railway cron seeds.
Key patterns:
- Panels extend `Panel` base class with `fetchData()` returning boolean, `setContent(html)`, `showError(msg, retry)`
- Private `_hasData` guard prevents overwriting good data with error on retry
- Seed scripts use `runSeed(domain, name, key, fetchFn, options)` with TTL ≥ 3× seed interval
- RPC handlers read from Redis via `getCachedJson(key, true)`, return typed proto response
- `cachedFetchJson` coalesces concurrent cache misses — use it for on-demand fetches
- All panels registered in `src/config/panels.ts` (FINANCE_PANELS + FULL_PANELS) and `src/app/panel-layout.ts`

File diff suppressed because one or more lines are too long

View File

@@ -457,6 +457,43 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/economic/v1/get-economic-calendar:
get:
tags:
- EconomicService
summary: GetEconomicCalendar
description: GetEconomicCalendar retrieves upcoming major economic events (FOMC, CPI, NFP, etc).
operationId: GetEconomicCalendar
parameters:
- name: fromDate
in: query
required: false
schema:
type: string
- name: toDate
in: query
required: false
schema:
type: string
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/GetEconomicCalendarResponse'
"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:
@@ -1320,3 +1357,45 @@ components:
type: string
description: Observed value.
description: BlsObservation is a single BLS data point.
GetEconomicCalendarRequest:
type: object
properties:
fromDate:
type: string
toDate:
type: string
GetEconomicCalendarResponse:
type: object
properties:
events:
type: array
items:
$ref: '#/components/schemas/EconomicEvent'
fromDate:
type: string
toDate:
type: string
total:
type: integer
format: int32
unavailable:
type: boolean
EconomicEvent:
type: object
properties:
event:
type: string
country:
type: string
date:
type: string
impact:
type: string
actual:
type: string
estimate:
type: string
previous:
type: string
unit:
type: string

File diff suppressed because one or more lines are too long

View File

@@ -549,6 +549,69 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/market/v1/list-earnings-calendar:
get:
tags:
- MarketService
summary: ListEarningsCalendar
description: ListEarningsCalendar retrieves upcoming and recent earnings releases.
operationId: ListEarningsCalendar
parameters:
- name: fromDate
in: query
required: false
schema:
type: string
- name: toDate
in: query
required: false
schema:
type: string
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/ListEarningsCalendarResponse'
"400":
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
default:
description: Error response
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/market/v1/get-cot-positioning:
get:
tags:
- MarketService
summary: GetCotPositioning
description: GetCotPositioning retrieves CFTC COT institutional positioning data.
operationId: GetCotPositioning
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/GetCotPositioningResponse'
"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:
@@ -1445,6 +1508,17 @@ components:
type: string
unavailable:
type: boolean
fsiValue:
type: number
format: double
fsiLabel:
type: string
hygPrice:
type: number
format: double
tltPrice:
type: number
format: double
FearGreedCategory:
type: object
properties:
@@ -1461,3 +1535,96 @@ components:
type: boolean
inputsJson:
type: string
ListEarningsCalendarRequest:
type: object
properties:
fromDate:
type: string
toDate:
type: string
ListEarningsCalendarResponse:
type: object
properties:
earnings:
type: array
items:
$ref: '#/components/schemas/EarningsEntry'
fromDate:
type: string
toDate:
type: string
total:
type: integer
format: int32
unavailable:
type: boolean
EarningsEntry:
type: object
properties:
symbol:
type: string
company:
type: string
date:
type: string
hour:
type: string
epsEstimate:
type: number
format: double
revenueEstimate:
type: number
format: double
epsActual:
type: number
format: double
revenueActual:
type: number
format: double
hasActuals:
type: boolean
surpriseDirection:
type: string
GetCotPositioningRequest:
type: object
GetCotPositioningResponse:
type: object
properties:
instruments:
type: array
items:
$ref: '#/components/schemas/CotInstrument'
reportDate:
type: string
unavailable:
type: boolean
CotInstrument:
type: object
properties:
name:
type: string
code:
type: string
reportDate:
type: string
assetManagerLong:
type: string
format: int64
assetManagerShort:
type: string
format: int64
leveragedFundsLong:
type: string
format: int64
leveragedFundsShort:
type: string
format: int64
dealerLong:
type: string
format: int64
dealerShort:
type: string
format: int64
netPct:
type: number
format: double

View File

@@ -0,0 +1,29 @@
syntax = "proto3";
package worldmonitor.economic.v1;
import "sebuf/http/annotations.proto";
message GetEconomicCalendarRequest {
string from_date = 1 [(sebuf.http.query) = {name: "fromDate"}];
string to_date = 2 [(sebuf.http.query) = {name: "toDate"}];
}
message EconomicEvent {
string event = 1;
string country = 2;
string date = 3;
string impact = 4;
string actual = 5;
string estimate = 6;
string previous = 7;
string unit = 8;
}
message GetEconomicCalendarResponse {
repeated EconomicEvent events = 1;
string from_date = 2;
string to_date = 3;
int32 total = 4;
bool unavailable = 5;
}

View File

@@ -17,6 +17,7 @@ import "worldmonitor/economic/v1/list_bigmac_prices.proto";
import "worldmonitor/economic/v1/get_national_debt.proto";
import "worldmonitor/economic/v1/list_fuel_prices.proto";
import "worldmonitor/economic/v1/get_bls_series.proto";
import "worldmonitor/economic/v1/get_economic_calendar.proto";
// EconomicService provides APIs for macroeconomic data from FRED, World Bank, and EIA.
service EconomicService {
@@ -91,4 +92,9 @@ service EconomicService {
rpc GetBlsSeries(GetBlsSeriesRequest) returns (GetBlsSeriesResponse) {
option (sebuf.http.config) = {path: "/get-bls-series", method: HTTP_METHOD_GET};
}
// GetEconomicCalendar retrieves upcoming major economic events (FOMC, CPI, NFP, etc).
rpc GetEconomicCalendar(GetEconomicCalendarRequest) returns (GetEconomicCalendarResponse) {
option (sebuf.http.config) = {path: "/get-economic-calendar", method: HTTP_METHOD_GET};
}
}

View File

@@ -0,0 +1,26 @@
syntax = "proto3";
package worldmonitor.market.v1;
import "sebuf/http/annotations.proto";
message GetCotPositioningRequest {}
message CotInstrument {
string name = 1;
string code = 2;
string report_date = 3;
int64 asset_manager_long = 4;
int64 asset_manager_short = 5;
int64 leveraged_funds_long = 6;
int64 leveraged_funds_short = 7;
int64 dealer_long = 8;
int64 dealer_short = 9;
double net_pct = 10;
}
message GetCotPositioningResponse {
repeated CotInstrument instruments = 1;
string report_date = 2;
bool unavailable = 3;
}

View File

@@ -40,4 +40,8 @@ message GetFearGreedIndexResponse {
double aaii_bear = 23;
string fed_rate = 24;
bool unavailable = 25;
double fsi_value = 26;
string fsi_label = 27;
double hyg_price = 28;
double tlt_price = 29;
}

View File

@@ -0,0 +1,31 @@
syntax = "proto3";
package worldmonitor.market.v1;
import "sebuf/http/annotations.proto";
message ListEarningsCalendarRequest {
string from_date = 1 [(sebuf.http.query) = {name: "fromDate"}];
string to_date = 2 [(sebuf.http.query) = {name: "toDate"}];
}
message EarningsEntry {
string symbol = 1;
string company = 2;
string date = 3;
string hour = 4;
double eps_estimate = 5;
double revenue_estimate = 6;
double eps_actual = 7;
double revenue_actual = 8;
bool has_actuals = 9;
string surprise_direction = 10;
}
message ListEarningsCalendarResponse {
repeated EarningsEntry earnings = 1;
string from_date = 2;
string to_date = 3;
int32 total = 4;
bool unavailable = 5;
}

View File

@@ -20,6 +20,8 @@ import "worldmonitor/market/v1/list_defi_tokens.proto";
import "worldmonitor/market/v1/list_ai_tokens.proto";
import "worldmonitor/market/v1/list_other_tokens.proto";
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";
// MarketService provides APIs for financial market data from Finnhub, Yahoo Finance, and CoinGecko.
service MarketService {
@@ -109,4 +111,14 @@ service MarketService {
rpc GetFearGreedIndex(GetFearGreedIndexRequest) returns (GetFearGreedIndexResponse) {
option (sebuf.http.config) = {path: "/get-fear-greed-index", method: HTTP_METHOD_GET};
}
// ListEarningsCalendar retrieves upcoming and recent earnings releases.
rpc ListEarningsCalendar(ListEarningsCalendarRequest) returns (ListEarningsCalendarResponse) {
option (sebuf.http.config) = {path: "/list-earnings-calendar", method: HTTP_METHOD_GET};
}
// GetCotPositioning retrieves CFTC COT institutional positioning data.
rpc GetCotPositioning(GetCotPositioningRequest) returns (GetCotPositioningResponse) {
option (sebuf.http.config) = {path: "/get-cot-positioning", method: HTTP_METHOD_GET};
}
}

148
scripts/seed-cot.mjs Normal file
View File

@@ -0,0 +1,148 @@
#!/usr/bin/env node
import { loadEnvFile, CHROME_UA, runSeed } from './_seed-utils.mjs';
loadEnvFile(import.meta.url);
const COT_KEY = 'market:cot:v1';
const COT_TTL = 604800;
const TARGET_INSTRUMENTS = [
{ name: 'S&P 500 E-Mini', code: 'ES', pattern: /E-MINI S&P 500/i },
{ name: 'Nasdaq 100 E-Mini', code: 'NQ', pattern: /E-MINI NASDAQ-100/i },
{ name: '10-Year T-Note', code: 'ZN', pattern: /10-YEAR U.S. TREASURY NOTE/i },
{ name: '2-Year T-Note', code: 'ZT', pattern: /2-YEAR U.S. TREASURY NOTE/i },
{ name: 'Gold', code: 'GC', pattern: /GOLD - COMMODITY EXCHANGE/i },
{ name: 'Crude Oil (WTI)', code: 'CL', pattern: /CRUDE OIL, LIGHT SWEET/i },
{ name: 'EUR/USD', code: 'EC', pattern: /EURO FX/i },
{ name: 'USD/JPY', code: 'JY', pattern: /JAPANESE YEN/i },
];
function parseDate(raw) {
if (!raw) return '';
const s = String(raw).trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
if (/^\d{6}$/.test(s)) {
const yy = s.slice(0, 2);
const mm = s.slice(2, 4);
const dd = s.slice(4, 6);
const year = parseInt(yy, 10) >= 50 ? `19${yy}` : `20${yy}`;
return `${year}-${mm}-${dd}`;
}
return s;
}
async function fetchCotData() {
const url = 'https://www.cftc.gov/dea/newcot/c_disaggrt.txt';
let text;
try {
const resp = await fetch(url, {
headers: { 'User-Agent': CHROME_UA },
signal: AbortSignal.timeout(30_000),
});
if (!resp.ok) {
console.warn(` CFTC fetch failed: HTTP ${resp.status}`);
return { instruments: [], reportDate: '' };
}
text = await resp.text();
} catch (e) {
console.warn(` CFTC fetch error: ${e.message}`);
return { instruments: [], reportDate: '' };
}
const lines = text.split('\n').map(l => l.trimEnd());
if (lines.length < 2) {
console.warn(' CFTC: empty file');
return { instruments: [], reportDate: '' };
}
const headerLine = lines[0];
const headers = headerLine.split('|').map(h => h.trim());
const colIdx = name => {
const idx = headers.indexOf(name);
return idx;
};
const nameCol = colIdx('Market_and_Exchange_Names');
const dateCol1 = colIdx('Report_Date_as_YYYY-MM-DD');
const dateCol2 = colIdx('As_of_Date_In_Form_YYMMDD');
const dealerLongCol = colIdx('Dealer_Positions_Long_All');
const dealerShortCol = colIdx('Dealer_Positions_Short_All');
const amLongCol = colIdx('Asset_Mgr_Positions_Long_All');
const amShortCol = colIdx('Asset_Mgr_Positions_Short_All');
const levLongCol = colIdx('Lev_Money_Positions_Long_All');
const levShortCol = colIdx('Lev_Money_Positions_Short_All');
if (nameCol === -1) {
console.warn(' CFTC: Market_and_Exchange_Names column not found');
return { instruments: [], reportDate: '' };
}
const dataLines = lines.slice(1).filter(l => l.trim().length > 0);
const instruments = [];
let latestReportDate = '';
for (const target of TARGET_INSTRUMENTS) {
const matchingLines = dataLines.filter(line => {
const fields = line.split('|');
const marketName = fields[nameCol] ?? '';
return target.pattern.test(marketName);
});
if (matchingLines.length === 0) {
console.warn(` CFTC: no rows found for ${target.name}`);
continue;
}
const row = matchingLines[0];
const fields = row.split('|');
const rawDate = (dateCol1 !== -1 && fields[dateCol1]?.trim())
? fields[dateCol1].trim()
: (dateCol2 !== -1 ? fields[dateCol2]?.trim() ?? '' : '');
const reportDate = parseDate(rawDate);
if (reportDate && !latestReportDate) latestReportDate = reportDate;
const toNum = idx => {
if (idx === -1) return 0;
const v = parseInt((fields[idx] ?? '').replace(/,/g, '').trim(), 10);
return isNaN(v) ? 0 : v;
};
const dealerLong = toNum(dealerLongCol);
const dealerShort = toNum(dealerShortCol);
const amLong = toNum(amLongCol);
const amShort = toNum(amShortCol);
const levLong = toNum(levLongCol);
const levShort = toNum(levShortCol);
const netPct = ((amLong - amShort) / Math.max(amLong + amShort, 1)) * 100;
instruments.push({
name: target.name,
code: target.code,
reportDate,
assetManagerLong: amLong,
assetManagerShort: amShort,
leveragedFundsLong: levLong,
leveragedFundsShort: levShort,
dealerLong,
dealerShort,
netPct: parseFloat(netPct.toFixed(2)),
});
console.log(` ${target.code}: AM net ${netPct.toFixed(1)}% (${amLong}L / ${amShort}S), date=${reportDate}`);
}
return { instruments, reportDate: latestReportDate };
}
if (process.argv[1] && process.argv[1].endsWith('seed-cot.mjs')) {
runSeed('market', 'cot', COT_KEY, fetchCotData, {
ttlSeconds: COT_TTL,
validateFn: data => Array.isArray(data?.instruments) && data.instruments.length > 0,
recordCount: data => data?.instruments?.length ?? 0,
}).catch(err => { console.error('FATAL:', err.message || err); process.exit(1); });
}

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env node
import { loadEnvFile, CHROME_UA, runSeed } from './_seed-utils.mjs';
loadEnvFile(import.meta.url);
const KEY = 'market:earnings-calendar:v1';
const TTL = 129600; // 36h — 3× a 12h cron interval
function toDateStr(d) {
return d.toISOString().slice(0, 10);
}
async function fetchAll() {
const apiKey = process.env.FINNHUB_API_KEY;
if (!apiKey) {
console.warn(' FINNHUB_API_KEY not set — skipping');
return { earnings: [], unavailable: true };
}
const from = new Date();
const to = new Date();
to.setDate(to.getDate() + 14);
const url = `https://finnhub.io/api/v1/calendar/earnings?from=${toDateStr(from)}&to=${toDateStr(to)}`;
const resp = await fetch(url, {
headers: { 'User-Agent': CHROME_UA, 'X-Finnhub-Token': apiKey },
signal: AbortSignal.timeout(15_000),
});
if (!resp.ok) {
throw new Error(`Finnhub earnings calendar HTTP ${resp.status}`);
}
const data = await resp.json();
const raw = Array.isArray(data?.earningsCalendar) ? data.earningsCalendar : [];
const earnings = raw
.filter(e => e.symbol)
.map(e => {
const epsEst = e.epsEstimate != null ? Number(e.epsEstimate) : null;
const epsAct = e.epsActual != null ? Number(e.epsActual) : null;
const revEst = e.revenueEstimate != null ? Number(e.revenueEstimate) : null;
const revAct = e.revenueActual != null ? Number(e.revenueActual) : null;
const hasActuals = epsAct != null;
let surpriseDirection = '';
if (hasActuals && epsEst != null) {
if (epsAct > epsEst) surpriseDirection = 'beat';
else if (epsAct < epsEst) surpriseDirection = 'miss';
}
return {
symbol: String(e.symbol),
company: e.name ? String(e.name) : String(e.symbol),
date: e.date ? String(e.date) : '',
hour: e.hour ? String(e.hour) : '',
epsEstimate: epsEst,
revenueEstimate: revEst,
epsActual: epsAct,
revenueActual: revAct,
hasActuals,
surpriseDirection,
};
})
.sort((a, b) => a.date.localeCompare(b.date))
.slice(0, 100);
console.log(` Fetched ${earnings.length} earnings entries`);
return { earnings, unavailable: false };
}
function validate(data) {
return Array.isArray(data?.earnings) && data.earnings.length > 0;
}
if (process.argv[1]?.endsWith('seed-earnings-calendar.mjs')) {
runSeed('market', 'earnings-calendar', KEY, fetchAll, {
validateFn: validate,
ttlSeconds: TTL,
sourceVersion: 'finnhub-v1',
}).catch(err => { console.error('FATAL:', err.message || err); process.exit(1); });
}

View File

@@ -0,0 +1,135 @@
#!/usr/bin/env node
import { loadEnvFile, CHROME_UA, runSeed } from './_seed-utils.mjs';
loadEnvFile(import.meta.url);
const CANONICAL_KEY = 'economic:econ-calendar:v1';
const CACHE_TTL = 129600; // 36h — 3× a 12h cron interval
const HIGH_PRIORITY_TERMS = [
'fomc', 'fed funds', 'federal funds', 'nonfarm', 'non-farm',
'cpi', 'pce', 'gdp', 'unemployment', 'payroll', 'retail sales', 'pmi', 'ism',
];
const ALLOWED_COUNTRIES = new Set(['US', 'UK', 'EUR', 'EU', 'DE', 'FR', 'JP', 'CN']);
function isHighPriority(eventName) {
const lower = (eventName || '').toLowerCase();
return HIGH_PRIORITY_TERMS.some((term) => lower.includes(term));
}
function normalizeImpact(raw) {
if (raw === null || raw === undefined) return 'low';
const s = String(raw).toLowerCase();
if (s === '3' || s === 'high') return 'high';
if (s === '2' || s === 'medium' || s === 'moderate') return 'medium';
return 'low';
}
function toDateString(timeStr) {
if (!timeStr) return '';
const d = new Date(timeStr);
if (!Number.isNaN(d.getTime())) {
return d.toISOString().slice(0, 10);
}
if (/^\d{4}-\d{2}-\d{2}/.test(timeStr)) return timeStr.slice(0, 10);
return '';
}
function formatValue(v) {
if (v === null || v === undefined) return '';
return String(v);
}
function buildFallbackEvents() {
const year = new Date().getFullYear();
return [
{ event: 'FOMC Rate Decision', country: 'US', date: `${year}-01-29`, impact: 'high', actual: '', estimate: '', previous: '', unit: '' },
{ event: 'FOMC Rate Decision', country: 'US', date: `${year}-03-19`, impact: 'high', actual: '', estimate: '', previous: '', unit: '' },
{ event: 'FOMC Rate Decision', country: 'US', date: `${year}-05-07`, impact: 'high', actual: '', estimate: '', previous: '', unit: '' },
{ event: 'FOMC Rate Decision', country: 'US', date: `${year}-06-18`, impact: 'high', actual: '', estimate: '', previous: '', unit: '' },
{ event: 'FOMC Rate Decision', country: 'US', date: `${year}-07-30`, impact: 'high', actual: '', estimate: '', previous: '', unit: '' },
{ event: 'FOMC Rate Decision', country: 'US', date: `${year}-09-17`, impact: 'high', actual: '', estimate: '', previous: '', unit: '' },
{ event: 'FOMC Rate Decision', country: 'US', date: `${year}-11-05`, impact: 'high', actual: '', estimate: '', previous: '', unit: '' },
{ event: 'FOMC Rate Decision', country: 'US', date: `${year}-12-17`, impact: 'high', actual: '', estimate: '', previous: '', unit: '' },
].filter((e) => e.date >= new Date().toISOString().slice(0, 10));
}
async function fetchEconomicCalendar() {
const apiKey = process.env.FINNHUB_API_KEY;
if (!apiKey) {
console.warn(' FINNHUB_API_KEY missing — returning hardcoded FOMC dates');
const events = buildFallbackEvents();
const today = new Date().toISOString().slice(0, 10);
const future = new Date(Date.now() + 30 * 86400_000).toISOString().slice(0, 10);
return { events, fromDate: today, toDate: future, total: events.length };
}
const today = new Date();
const from = today.toISOString().slice(0, 10);
const to = new Date(today.getTime() + 30 * 86400_000).toISOString().slice(0, 10);
const url = `https://finnhub.io/api/v1/calendar/economic?from=${from}&to=${to}`;
console.log(` Fetching Finnhub economic calendar ${from}${to}`);
const resp = await fetch(url, {
headers: { 'User-Agent': CHROME_UA, 'X-Finnhub-Token': apiKey },
signal: AbortSignal.timeout(20_000),
});
if (!resp.ok) {
throw new Error(`Finnhub HTTP ${resp.status}`);
}
const data = await resp.json();
const raw = data?.economicCalendar ?? [];
console.log(` Raw events from Finnhub: ${raw.length}`);
const filtered = raw.filter((item) => {
const country = (item.country || '').toUpperCase();
if (!ALLOWED_COUNTRIES.has(country)) return false;
const impact = normalizeImpact(item.impact);
if (impact === 'high') return true;
if (isHighPriority(item.event)) return true;
return false;
});
const transformed = filtered.map((item) => ({
event: item.event || '',
country: (item.country || '').toUpperCase(),
date: toDateString(item.time || item.date || ''),
impact: normalizeImpact(item.impact),
actual: formatValue(item.actual),
estimate: formatValue(item.estimate),
previous: formatValue(item.prev),
unit: formatValue(item.unit),
}));
transformed.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
const events = transformed.slice(0, 60);
console.log(` Filtered to ${events.length} events`);
return { events, fromDate: from, toDate: to, total: events.length };
}
function validate(data) {
return Array.isArray(data?.events) && data.events.length > 0;
}
if (process.argv[1]?.endsWith('seed-economic-calendar.mjs')) {
runSeed('economic', 'econ-calendar', CANONICAL_KEY, fetchEconomicCalendar, {
validateFn: validate,
ttlSeconds: CACHE_TTL,
sourceVersion: 'finnhub-v1',
}).catch((err) => {
const _cause = err.cause ? ` (cause: ${err.cause.message || err.cause.code || err.cause})` : '';
console.error('FATAL:', (err.message || err) + _cause);
process.exit(1);
});
}

View File

@@ -17,7 +17,7 @@ const ENERGY_TTL = 3600;
const CAPACITY_TTL = 86400;
const MACRO_TTL = 21600; // 6h — survive extended Yahoo outages
const FRED_SERIES = ['WALCL', 'FEDFUNDS', 'T10Y2Y', 'UNRATE', 'CPIAUCSL', 'DGS10', 'VIXCLS', 'GDP', 'M2SL', 'DCOILWTICO', 'BAMLH0A0HYM2', 'ICSA', 'MORTGAGE30US', 'BAMLC0A0CM', 'SOFR'];
const FRED_SERIES = ['WALCL', 'FEDFUNDS', 'T10Y2Y', 'UNRATE', 'CPIAUCSL', 'DGS10', 'VIXCLS', 'GDP', 'M2SL', 'DCOILWTICO', 'BAMLH0A0HYM2', 'ICSA', 'MORTGAGE30US', 'BAMLC0A0CM', 'SOFR', 'DGS1MO', 'DGS3MO', 'DGS6MO', 'DGS1', 'DGS2', 'DGS5', 'DGS30'];
// ─── EIA Energy Prices (WTI + Brent) ───

View File

@@ -41,7 +41,7 @@ const FEAR_GREED_TTL = 64800; // 18h = 3x 6h interval
const FRED_PREFIX = 'economic:fred:v1';
// --- Yahoo Finance fetching (15 symbols, 150ms gaps) ---
const YAHOO_SYMBOLS = ['^GSPC','^VIX','^VIX9D','^VIX3M','^SKEW','GLD','TLT','SPY','RSP','DX-Y.NYB','XLK','XLF','XLE','XLV','XLY','XLP','XLI','XLB','XLU','XLRE','XLC'];
const YAHOO_SYMBOLS = ['^GSPC','^VIX','^VIX9D','^VIX3M','^SKEW','GLD','TLT','HYG','SPY','RSP','DX-Y.NYB','XLK','XLF','XLE','XLV','XLY','XLP','XLI','XLB','XLU','XLRE','XLC'];
async function fetchYahooSymbol(symbol) {
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?interval=1d&range=1y`;
@@ -126,7 +126,9 @@ async function fetchCNN() {
if (!resp.ok) { console.warn(` CNN F&G: HTTP ${resp.status}`); return null; }
const data = await resp.json();
const score = data?.score ?? data?.fear_and_greed?.score;
const rating = data?.rating ?? data?.fear_and_greed?.rating;
const rawRating = data?.rating ?? data?.fear_and_greed?.rating;
const VALID_CNN_LABELS = new Set(['Extreme Fear', 'Fear', 'Neutral', 'Greed', 'Extreme Greed']);
const rating = (typeof rawRating === 'string' && VALID_CNN_LABELS.has(rawRating)) ? rawRating : null;
return score != null ? { score: Math.round(score), label: rating ?? labelFromScore(Math.round(score)) } : null;
} catch (e) { console.warn(` CNN F&G: ${e.message}`); return null; }
}
@@ -410,7 +412,7 @@ async function fetchAll() {
const vix9d = yahoo['^VIX9D'];
const vix3m = yahoo['^VIX3M'];
const skew = yahoo['^SKEW'];
const gld = yahoo['GLD'], tlt = yahoo['TLT'], spy = yahoo['SPY'], rsp = yahoo['RSP'];
const gld = yahoo['GLD'], tlt = yahoo['TLT'], hyg = yahoo['HYG'], spy = yahoo['SPY'], rsp = yahoo['RSP'];
const dxy = yahoo['DX-Y.NYB'];
const xlk = yahoo['XLK'], xlf = yahoo['XLF'], xle = yahoo['XLE'], xlv = yahoo['XLV'];
const xly = yahoo['XLY'], xlp = yahoo['XLP'], xli = yahoo['XLI'], xlb = yahoo['XLB'];
@@ -450,6 +452,27 @@ async function fetchAll() {
const fedRateStr = fedRate != null ? `${fedRate.toFixed(2)}%` : null;
const hySpreadVal = fredLatest(hyObs);
const hygPrice = hyg?.price ?? null;
const tltPrice = tlt?.price ?? null;
let fsiValue = null;
let fsiLabel = 'Unknown';
if (hygPrice != null && tltPrice != null && tltPrice > 0 && vixLive != null && vixLive > 0 && hySpreadVal != null && hySpreadVal > 0) {
fsiValue = Math.round(((hygPrice / tltPrice) / (vixLive * hySpreadVal / 100)) * 10000) / 10000;
if (fsiValue >= 1.5) fsiLabel = 'Low Stress';
else if (fsiValue >= 0.8) fsiLabel = 'Moderate Stress';
else if (fsiValue >= 0.3) fsiLabel = 'Elevated Stress';
else fsiLabel = 'High Stress';
}
const SECTOR_ETF_NAMES = { XLK: 'Technology', XLF: 'Financials', XLE: 'Energy', XLV: 'Health Care', XLY: 'Consumer Discr.', XLP: 'Consumer Staples', XLI: 'Industrials', XLB: 'Materials', XLU: 'Utilities', XLRE: 'Real Estate', XLC: 'Comm. Services' };
const sectorPerformance = Object.entries(SECTOR_ETF_NAMES).map(([sym, name]) => {
const d = yahoo[sym];
if (!d?.closes || d.closes.length < 2) return null;
const prev = d.closes.at(-2), curr = d.closes.at(-1);
const change1d = (prev && prev > 0) ? Math.round(((curr - prev) / prev) * 10000) / 100 : null;
return change1d != null ? { symbol: sym, name, change1d } : null;
}).filter(Boolean);
const payload = {
timestamp: new Date().toISOString(),
composite: { score: compositeScore, label: compositeLabel, previous: previousScore },
@@ -475,7 +498,9 @@ async function fetchAll() {
pctAbove200d: pctAbove200d != null ? { value: pctAbove200d } : null,
yield10y: fredLatest(dgs10Obs) != null ? { value: fredLatest(dgs10Obs) } : null,
fedRate: fedRateStr ? { value: fedRateStr } : null,
fsi: fsiValue != null ? { value: fsiValue, label: fsiLabel, hygPrice, tltPrice } : null,
},
sectorPerformance,
unavailable: false,
};

View File

@@ -183,6 +183,10 @@ const RPC_CACHE_TIER: Record<string, CacheTier> = {
'/api/consumer-prices/v1/get-consumer-price-freshness': 'slow',
'/api/aviation/v1/get-youtube-live-stream-info': 'fast',
'/api/market/v1/list-earnings-calendar': 'slow',
'/api/market/v1/get-cot-positioning': 'slow',
'/api/economic/v1/get-economic-calendar': 'slow',
};
// TODO(payment-pr): PREMIUM_RPC_PATHS is intentionally empty until the payment/pro-user

View File

@@ -0,0 +1,40 @@
import type {
ServerContext,
GetEconomicCalendarRequest,
GetEconomicCalendarResponse,
EconomicEvent,
} from '../../../../src/generated/server/worldmonitor/economic/v1/service_server';
import { getCachedJson } from '../../../_shared/redis';
const SEED_CACHE_KEY = 'economic:econ-calendar:v1';
function buildFallbackResult(): GetEconomicCalendarResponse {
return {
events: [],
fromDate: '',
toDate: '',
total: 0,
unavailable: true,
};
}
export async function getEconomicCalendar(
_ctx: ServerContext,
_req: GetEconomicCalendarRequest,
): Promise<GetEconomicCalendarResponse> {
try {
const result = await getCachedJson(SEED_CACHE_KEY, true) as GetEconomicCalendarResponse | null;
if (result && !result.unavailable && Array.isArray(result.events) && result.events.length > 0) {
return {
events: result.events as EconomicEvent[],
fromDate: result.fromDate ?? '',
toDate: result.toDate ?? '',
total: result.total ?? result.events.length,
unavailable: false,
};
}
return buildFallbackResult();
} catch {
return buildFallbackResult();
}
}

View File

@@ -18,6 +18,8 @@ const ALLOWED_SERIES = new Set<string>([
'WALCL', 'FEDFUNDS', 'T10Y2Y', 'UNRATE', 'CPIAUCSL', 'DGS10', 'VIXCLS',
'GDP', 'M2SL', 'DCOILWTICO', 'BAMLH0A0HYM2', 'ICSA', 'MORTGAGE30US',
'GSCPI', // NY Fed Global Supply Chain Pressure Index (seeded by ais-relay, not FRED API)
'DGS1MO', 'DGS3MO', 'DGS6MO', 'DGS1', 'DGS2', 'DGS5', 'DGS30', // yield curve tenors
'BAMLC0A0CM', 'SOFR', // IG OAS spread + Secured Overnight Financing Rate (seeded by seed-economy.mjs)
]);
export async function getFredSeriesBatch(

View File

@@ -14,6 +14,7 @@ import { listBigMacPrices } from './list-bigmac-prices';
import { getNationalDebt } from './get-national-debt';
import { listFuelPrices } from './list-fuel-prices';
import { getBlsSeries } from './get-bls-series';
import { getEconomicCalendar } from './get-economic-calendar';
export const economicHandler: EconomicServiceHandler = {
getFredSeries,
@@ -30,4 +31,5 @@ export const economicHandler: EconomicServiceHandler = {
getNationalDebt,
listFuelPrices,
getBlsSeries,
getEconomicCalendar,
};

View File

@@ -0,0 +1,55 @@
import type {
ServerContext,
GetCotPositioningRequest,
GetCotPositioningResponse,
CotInstrument,
} from '../../../../src/generated/server/worldmonitor/market/v1/service_server';
import { getCachedJson } from '../../../_shared/redis';
const SEED_CACHE_KEY = 'market:cot:v1';
interface RawInstrument {
name: string;
code: string;
reportDate: string;
assetManagerLong: number;
assetManagerShort: number;
leveragedFundsLong: number;
leveragedFundsShort: number;
dealerLong: number;
dealerShort: number;
netPct: number;
}
export async function getCotPositioning(
_ctx: ServerContext,
_req: GetCotPositioningRequest,
): Promise<GetCotPositioningResponse> {
try {
const raw = await getCachedJson(SEED_CACHE_KEY, true) as { instruments?: RawInstrument[]; reportDate?: string } | null;
if (!raw?.instruments || raw.instruments.length === 0) {
return { instruments: [], reportDate: '', unavailable: true };
}
const instruments: CotInstrument[] = raw.instruments.map(item => ({
name: String(item.name ?? ''),
code: String(item.code ?? ''),
reportDate: String(item.reportDate ?? ''),
assetManagerLong: String(item.assetManagerLong ?? 0),
assetManagerShort: String(item.assetManagerShort ?? 0),
leveragedFundsLong: String(item.leveragedFundsLong ?? 0),
leveragedFundsShort: String(item.leveragedFundsShort ?? 0),
dealerLong: String(item.dealerLong ?? 0),
dealerShort: String(item.dealerShort ?? 0),
netPct: Number(item.netPct ?? 0),
}));
return {
instruments,
reportDate: String(raw.reportDate ?? ''),
unavailable: false,
};
} catch {
return { instruments: [], reportDate: '', unavailable: true };
}
}

View File

@@ -53,6 +53,10 @@ export async function getFearGreedIndex(
aaiiBull: Number(hdr?.aaiBull?.value ?? 0),
aaiiBear: Number(hdr?.aaiBear?.value ?? 0),
fedRate: String(hdr?.fedRate?.value ?? ''),
fsiValue: Number(hdr?.fsi?.value ?? 0),
fsiLabel: String(hdr?.fsi?.label ?? ''),
hygPrice: Number(hdr?.fsi?.hygPrice ?? 0),
tltPrice: Number(hdr?.fsi?.tltPrice ?? 0),
unavailable: false,
};
} catch {

View File

@@ -30,6 +30,8 @@ import { listDefiTokens } from './list-defi-tokens';
import { listAiTokens } from './list-ai-tokens';
import { listOtherTokens } from './list-other-tokens';
import { getFearGreedIndex } from './get-fear-greed-index';
import { listEarningsCalendar } from './list-earnings-calendar';
import { getCotPositioning } from './get-cot-positioning';
export const marketHandler: MarketServiceHandler = {
listMarketQuotes,
@@ -49,4 +51,6 @@ export const marketHandler: MarketServiceHandler = {
listAiTokens,
listOtherTokens,
getFearGreedIndex,
listEarningsCalendar,
getCotPositioning,
};

View File

@@ -0,0 +1,42 @@
import type {
ServerContext,
ListEarningsCalendarRequest,
ListEarningsCalendarResponse,
EarningsEntry,
} from '../../../../src/generated/server/worldmonitor/market/v1/service_server';
import { getCachedJson } from '../../../_shared/redis';
const SEED_CACHE_KEY = 'market:earnings-calendar:v1';
export async function listEarningsCalendar(
_ctx: ServerContext,
_req: ListEarningsCalendarRequest,
): Promise<ListEarningsCalendarResponse> {
try {
const cached = await getCachedJson(SEED_CACHE_KEY, true) as { earnings?: EarningsEntry[]; unavailable?: boolean } | null;
if (!cached?.earnings?.length) {
return { earnings: [], fromDate: '', toDate: '', total: 0, unavailable: true };
}
const entries: EarningsEntry[] = cached.earnings.map(e => ({
symbol: e.symbol ?? '',
company: e.company ?? '',
date: e.date ?? '',
hour: e.hour ?? '',
epsEstimate: e.epsEstimate ?? 0,
revenueEstimate: e.revenueEstimate ?? 0,
epsActual: e.epsActual ?? 0,
revenueActual: e.revenueActual ?? 0,
hasActuals: e.hasActuals ?? false,
surpriseDirection: e.surpriseDirection ?? '',
}));
const dates = entries.map(e => e.date).filter(Boolean).sort();
const fromDate = dates[0] ?? '';
const toDate = dates[dates.length - 1] ?? '';
return { earnings: entries, fromDate, toDate, total: entries.length, unavailable: false };
} catch {
return { earnings: [], fromDate: '', toDate: '', total: 0, unavailable: true };
}
}

View File

@@ -168,6 +168,9 @@ import {
cacheDailyMarketBrief,
getCachedDailyMarketBrief,
shouldRefreshDailyBrief,
type RegimeMacroContext,
type YieldCurveContext,
type SectorBriefContext,
} from '@/services/daily-market-brief';
import { fetchCachedRiskScores } from '@/services/cached-risk-scores';
import type { ThreatLevel as ClientThreatLevel } from '@/types';
@@ -1404,10 +1407,22 @@ export class DataLoaderManager implements AppModule {
this.callPanel('daily-market-brief', 'showLoading', 'Building daily market brief...');
}
const [r0, r1, r2] = await Promise.allSettled([
this._collectRegimeContext(),
this._collectYieldCurveContext(),
this._collectSectorContext(),
]);
const regimeContext = r0.status === 'fulfilled' ? r0.value : undefined;
const yieldCurveContext = r1.status === 'fulfilled' ? r1.value : undefined;
const sectorContext = r2.status === 'fulfilled' ? r2.value : undefined;
const brief = await buildDailyMarketBrief({
markets: this.ctx.latestMarkets,
newsByCategory: this.ctx.newsByCategory,
timezone,
regimeContext,
yieldCurveContext,
sectorContext,
});
if (!brief.available) {
@@ -1433,6 +1448,93 @@ export class DataLoaderManager implements AppModule {
}
}
private async _collectRegimeContext(): Promise<RegimeMacroContext | undefined> {
try {
const hydrated = getHydratedData('fearGreedIndex') as Record<string, unknown> | undefined;
if (hydrated && !hydrated.unavailable && Number(hydrated.compositeScore) > 0) {
const comp = hydrated.composite as Record<string, unknown> | undefined;
const cats = (hydrated.categories ?? {}) as Record<string, Record<string, unknown>>;
const hdr = (hydrated.headerMetrics ?? {}) as Record<string, Record<string, unknown> | null>;
return {
compositeScore: Number(comp?.score ?? hydrated.compositeScore ?? 0),
compositeLabel: String(comp?.label ?? hydrated.compositeLabel ?? ''),
fsiValue: Number(hdr?.fsi?.value ?? 0),
fsiLabel: String(hdr?.fsi?.label ?? ''),
vix: Number(hdr?.vix?.value ?? 0),
hySpread: Number(hdr?.hySpread?.value ?? 0),
cnnFearGreed: Number(hdr?.cnnFearGreed?.value ?? 0),
cnnLabel: String(hdr?.cnnFearGreed?.label ?? ''),
momentum: cats.momentum ? { score: Number(cats.momentum.score ?? 0) } : undefined,
sentiment: cats.sentiment ? { score: Number(cats.sentiment.score ?? 0) } : undefined,
};
}
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.getFearGreedIndex({});
if (resp.unavailable || resp.compositeScore <= 0) return undefined;
return {
compositeScore: resp.compositeScore,
compositeLabel: resp.compositeLabel,
fsiValue: resp.fsiValue ?? 0,
fsiLabel: resp.fsiLabel ?? '',
vix: resp.vix ?? 0,
hySpread: resp.hySpread ?? 0,
cnnFearGreed: resp.cnnFearGreed ?? 0,
cnnLabel: resp.cnnLabel ?? '',
momentum: resp.momentum ? { score: resp.momentum.score } : undefined,
sentiment: resp.sentiment ? { score: resp.sentiment.score } : undefined,
};
} catch {
return undefined;
}
}
private async _collectYieldCurveContext(): Promise<YieldCurveContext | undefined> {
try {
const { EconomicServiceClient } = await import('@/generated/client/worldmonitor/economic/v1/service_client');
const { getRpcBaseUrl } = await import('@/services/rpc-client');
const client = new EconomicServiceClient(getRpcBaseUrl(), { fetch: (...args: Parameters<typeof fetch>) => globalThis.fetch(...args) });
const resp = await client.getFredSeriesBatch({ seriesIds: ['DGS2', 'DGS10', 'DGS30'], limit: 1 });
const lastVal = (id: string): number => {
const obs = resp.results[id]?.observations;
if (!obs?.length) return 0;
return obs[obs.length - 1]?.value ?? 0;
};
const rate2y = lastVal('DGS2');
const rate10y = lastVal('DGS10');
const rate30y = lastVal('DGS30');
if (!rate10y) return undefined;
const spread2s10s = rate2y > 0 ? Math.round((rate10y - rate2y) * 100) : 0;
return { inverted: spread2s10s < 0, spread2s10s, rate2y, rate10y, rate30y };
} catch {
return undefined;
}
}
private _collectSectorContext(): SectorBriefContext | undefined {
try {
const hydratedSectors = getHydratedData('sectors') as GetSectorSummaryResponse | undefined;
const sectors = hydratedSectors?.sectors;
if (!sectors?.length) return undefined;
const sorted = [...sectors].sort((a, b) => b.change - a.change);
const countPositive = sorted.filter(s => s.change > 0).length;
const top = sorted[0];
const worst = sorted[sorted.length - 1];
if (!top || !worst) return undefined;
return {
topName: top.name,
topChange: top.change,
worstName: worst.name,
worstChange: worst.change,
countPositive,
total: sorted.length,
};
} catch {
return undefined;
}
}
async loadMarketImplications(): Promise<void> {
if (!getSecretState('WORLDMONITOR_API_KEY').present && !isProUser()) return;
if (this.ctx.isDestroyed || this.ctx.inFlight.has('marketImplications')) return;

View File

@@ -56,6 +56,12 @@ import {
EconomicCorrelationPanel,
DisasterCorrelationPanel,
HormuzPanel,
MacroTilesPanel,
FSIPanel,
YieldCurvePanel,
EarningsCalendarPanel,
EconomicCalendarPanel,
CotPositioningPanel,
} from '@/components';
import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel';
import { focusInvestmentOnMap } from '@/services/investments-focus';
@@ -891,6 +897,12 @@ export class PanelLayoutManager implements AppModule {
this.createPanel('macro-signals', () => new MacroSignalsPanel());
this.createPanel('fear-greed', () => new FearGreedPanel());
this.createPanel('macro-tiles', () => new MacroTilesPanel());
this.createPanel('fsi', () => new FSIPanel());
this.createPanel('yield-curve', () => new YieldCurvePanel());
this.createPanel('earnings-calendar', () => new EarningsCalendarPanel());
this.createPanel('economic-calendar', () => new EconomicCalendarPanel());
this.createPanel('cot-positioning', () => new CotPositioningPanel());
this.createPanel('hormuz-tracker', () => new HormuzPanel());
this.createPanel('etf-flows', () => new ETFFlowsPanel());
this.createPanel('stablecoins', () => new StablecoinPanel());

View File

@@ -0,0 +1,105 @@
import type { MarketServiceClient } from '@/generated/client/worldmonitor/market/v1/service_client';
import { Panel } from './Panel';
import { escapeHtml } from '@/utils/sanitize';
let _client: MarketServiceClient | null = null;
async function getMarketClient(): Promise<MarketServiceClient> {
if (!_client) {
const { MarketServiceClient } = await import('@/generated/client/worldmonitor/market/v1/service_client');
const { getRpcBaseUrl } = await import('@/services/rpc-client');
_client = new MarketServiceClient(getRpcBaseUrl(), { fetch: (...args: Parameters<typeof fetch>) => globalThis.fetch(...args) });
}
return _client;
}
interface CotInstrumentData {
name: string;
code: string;
reportDate: string;
assetManagerLong: string;
assetManagerShort: string;
leveragedFundsLong: string;
leveragedFundsShort: string;
dealerLong: string;
dealerShort: string;
netPct: number;
}
function toNum(v: string | number): number {
return typeof v === 'number' ? v : parseInt(String(v), 10) || 0;
}
function renderPositionBar(netPct: number, label: string): string {
const clamped = Math.max(-100, Math.min(100, netPct));
const halfWidth = Math.abs(clamped) / 100 * 50;
const color = clamped >= 0 ? '#2ecc71' : '#e74c3c';
const leftPct = clamped >= 0 ? 50 : 50 - halfWidth;
const sign = clamped >= 0 ? '+' : '';
return `
<div style="margin:3px 0">
<div style="display:flex;justify-content:space-between;font-size:9px;color:var(--text-dim);margin-bottom:2px">
<span>${escapeHtml(label)}</span>
<span style="color:${color};font-weight:600">${sign}${clamped.toFixed(1)}%</span>
</div>
<div style="position:relative;height:8px;background:rgba(255,255,255,0.06);border-radius:2px">
<div style="position:absolute;top:0;bottom:0;left:50%;width:1px;background:rgba(255,255,255,0.15)"></div>
<div style="position:absolute;top:0;bottom:0;left:${leftPct.toFixed(2)}%;width:${halfWidth.toFixed(2)}%;background:${color};border-radius:1px"></div>
</div>
</div>`;
}
function renderInstrument(item: CotInstrumentData): string {
const levLong = toNum(item.leveragedFundsLong);
const levShort = toNum(item.leveragedFundsShort);
const amNetPct = item.netPct;
const levNetPct = ((levLong - levShort) / Math.max(levLong + levShort, 1)) * 100;
return `
<div style="padding:8px 0;border-bottom:1px solid rgba(255,255,255,0.06)">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<span style="font-size:12px;font-weight:600">${escapeHtml(item.name)}</span>
<span style="font-size:9px;color:var(--text-dim)">${escapeHtml(item.code)}</span>
</div>
${renderPositionBar(amNetPct, 'Asset Managers')}
${renderPositionBar(levNetPct, 'Leveraged Funds')}
</div>`;
}
export class CotPositioningPanel extends Panel {
private _hasData = false;
constructor() {
super({ id: 'cot-positioning', title: 'CFTC COT Positioning', showCount: false });
}
public async fetchData(): Promise<boolean> {
this.showLoading();
try {
const client = await getMarketClient();
const resp = await client.getCotPositioning({});
if (resp.unavailable || !resp.instruments || resp.instruments.length === 0) {
if (!this._hasData) this.showError('COT data unavailable', () => void this.fetchData());
return false;
}
this._hasData = true;
this.render(resp.instruments as CotInstrumentData[], resp.reportDate ?? '');
return true;
} catch (e) {
if (!this._hasData) this.showError(e instanceof Error ? e.message : 'Failed to load', () => void this.fetchData());
return false;
}
}
private render(instruments: CotInstrumentData[], reportDate: string): void {
const rows = instruments.map(renderInstrument).join('');
const dateFooter = reportDate
? `<div style="font-size:9px;color:var(--text-dim);margin-top:8px;text-align:right">Report date: ${escapeHtml(reportDate)}</div>`
: '';
const html = `
<div style="padding:10px 14px">
${rows}
${dateFooter}
</div>`;
this.setContent(html);
}
}

View File

@@ -0,0 +1,138 @@
import type { MarketServiceClient } from '@/generated/client/worldmonitor/market/v1/service_client';
import { Panel } from './Panel';
import { escapeHtml } from '@/utils/sanitize';
let _client: MarketServiceClient | null = null;
async function getMarketClient(): Promise<MarketServiceClient> {
if (!_client) {
const { MarketServiceClient } = await import('@/generated/client/worldmonitor/market/v1/service_client');
const { getRpcBaseUrl } = await import('@/services/rpc-client');
_client = new MarketServiceClient(getRpcBaseUrl(), { fetch: (...args: Parameters<typeof fetch>) => globalThis.fetch(...args) });
}
return _client;
}
interface EarningsEntry {
symbol: string;
company: string;
date: string;
hour: string;
epsEstimate: number | null;
revenueEstimate: number | null;
epsActual: number | null;
revenueActual: number | null;
hasActuals: boolean;
surpriseDirection: string;
}
function fmtDate(dateStr: string): string {
try {
const d = new Date(dateStr + 'T12:00:00Z');
return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' });
} catch {
return dateStr;
}
}
function fmtEps(v: number | null): string {
if (v == null) return '';
return v.toFixed(2);
}
function renderEntry(e: EarningsEntry): string {
const hourLabel = e.hour === 'bmo' ? 'BMO' : e.hour === 'amc' ? 'AMC' : e.hour.toUpperCase();
const hourColor = e.hour === 'bmo'
? 'background:rgba(46,204,113,0.15);color:#2ecc71'
: 'background:rgba(52,152,219,0.15);color:#3498db';
const epsEst = fmtEps(e.epsEstimate);
const epsAct = fmtEps(e.epsActual);
let rightSection = '';
if (e.hasActuals && epsAct) {
const badgeColor = e.surpriseDirection === 'beat'
? 'background:rgba(46,204,113,0.2);color:#2ecc71'
: e.surpriseDirection === 'miss'
? 'background:rgba(231,76,60,0.2);color:#e74c3c'
: 'background:rgba(255,255,255,0.08);color:var(--text-dim)';
const badgeLabel = e.surpriseDirection === 'beat' ? 'BEAT' : e.surpriseDirection === 'miss' ? 'MISS' : '';
rightSection = `
<span style="font-size:11px;color:var(--text-dim)">EPS ${escapeHtml(epsAct)}</span>
${badgeLabel ? `<span style="font-size:9px;font-weight:600;padding:2px 5px;border-radius:3px;${badgeColor}">${escapeHtml(badgeLabel)}</span>` : ''}`;
} else if (epsEst) {
rightSection = `<span style="font-size:11px;color:var(--text-dim)">est ${escapeHtml(epsEst)}</span>`;
}
return `
<div style="display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid rgba(255,255,255,0.04)">
<span style="font-size:9px;font-weight:600;padding:2px 5px;border-radius:3px;${hourColor};flex-shrink:0">${escapeHtml(hourLabel)}</span>
<div style="flex:1;min-width:0">
<div style="font-size:12px;font-weight:600;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escapeHtml(e.symbol)}</div>
<div style="font-size:10px;color:var(--text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escapeHtml(e.company)}</div>
</div>
<div style="display:flex;align-items:center;gap:6px;flex-shrink:0">${rightSection}</div>
</div>`;
}
function renderGroup(date: string, entries: EarningsEntry[]): string {
return `
<div style="font-size:10px;font-weight:600;color:var(--text-dim);text-transform:uppercase;padding:10px 0 4px;border-top:1px solid rgba(255,255,255,0.06)">${escapeHtml(fmtDate(date))}</div>
${entries.map(renderEntry).join('')}`;
}
export class EarningsCalendarPanel extends Panel {
private _hasData = false;
constructor() {
super({ id: 'earnings-calendar', title: 'Earnings Calendar', showCount: false });
}
public async fetchData(): Promise<boolean> {
this.showLoading();
return this.refreshFromRpc();
}
private async refreshFromRpc(): Promise<boolean> {
try {
const client = await getMarketClient();
const today = new Date();
const future = new Date();
future.setDate(future.getDate() + 14);
const fromDate = today.toISOString().slice(0, 10);
const toDate = future.toISOString().slice(0, 10);
const resp = await client.listEarningsCalendar({ fromDate, toDate });
if (resp.unavailable || !resp.earnings?.length) {
if (!this._hasData) this.showError('No earnings data', () => void this.fetchData());
return false;
}
this.render(resp.earnings as EarningsEntry[]);
return true;
} catch (e) {
if (!this._hasData) this.showError(e instanceof Error ? e.message : 'Failed to load', () => void this.fetchData());
return false;
}
}
private render(earnings: EarningsEntry[]): void {
this._hasData = true;
const grouped = new Map<string, EarningsEntry[]>();
for (const e of earnings) {
const key = e.date || 'Unknown';
const arr = grouped.get(key);
if (arr) arr.push(e);
else grouped.set(key, [e]);
}
const sortedDates = [...grouped.keys()].sort();
const html = `
<div style="padding:0 14px 12px;max-height:480px;overflow-y:auto">
${sortedDates.map(d => renderGroup(d, grouped.get(d)!)).join('')}
</div>`;
this.setContent(html);
}
}

View File

@@ -0,0 +1,142 @@
import type { EconomicServiceClient } from '@/generated/client/worldmonitor/economic/v1/service_client';
import { Panel } from './Panel';
import { escapeHtml } from '@/utils/sanitize';
let _client: EconomicServiceClient | null = null;
async function getEconomicClient(): Promise<EconomicServiceClient> {
if (!_client) {
const { EconomicServiceClient } = await import('@/generated/client/worldmonitor/economic/v1/service_client');
const { getRpcBaseUrl } = await import('@/services/rpc-client');
_client = new EconomicServiceClient(getRpcBaseUrl(), { fetch: (...args: Parameters<typeof fetch>) => globalThis.fetch(...args) });
}
return _client;
}
const COUNTRY_FLAGS: Record<string, string> = {
US: '🇺🇸',
GB: '🇬🇧',
UK: '🇬🇧',
EU: '🇪🇺',
EUR: '🇪🇺',
DE: '🇩🇪',
FR: '🇫🇷',
JP: '🇯🇵',
CN: '🇨🇳',
CA: '🇨🇦',
AU: '🇦🇺',
};
const IMPACT_COLORS: Record<string, string> = {
high: '#e74c3c',
medium: '#f39c12',
low: 'rgba(255,255,255,0.3)',
};
interface EconomicEvent {
event: string;
country: string;
date: string;
impact: string;
actual: string;
estimate: string;
previous: string;
unit: string;
}
function groupByDate(events: EconomicEvent[]): Map<string, EconomicEvent[]> {
const map = new Map<string, EconomicEvent[]>();
for (const ev of events) {
const key = ev.date || 'Unknown';
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(ev);
}
return map;
}
function formatDate(dateStr: string): string {
if (!dateStr || dateStr === 'Unknown') return 'Unknown Date';
const d = new Date(`${dateStr}T00:00:00`);
if (Number.isNaN(d.getTime())) return dateStr;
return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' });
}
function formatMetaValue(val: string, unit: string): string {
if (!val) return '—';
return unit ? `${val} ${unit}` : val;
}
export class EconomicCalendarPanel extends Panel {
private _hasData = false;
private _events: EconomicEvent[] = [];
constructor() {
super({ id: 'economic-calendar', title: 'Economic Calendar', showCount: false });
}
public async fetchData(): Promise<boolean> {
this.showLoading('Loading economic calendar...');
try {
const client = await getEconomicClient();
const today = new Date();
const fromDate = today.toISOString().slice(0, 10);
const toDate = new Date(today.getTime() + 30 * 86400_000).toISOString().slice(0, 10);
const resp = await client.getEconomicCalendar({ fromDate, toDate });
if (resp.unavailable || !resp.events || resp.events.length === 0) {
if (!this._hasData) this.showError('Economic calendar data unavailable.', () => void this.fetchData());
return false;
}
this._events = resp.events as EconomicEvent[];
this._hasData = true;
this._render();
return true;
} catch (err) {
if (this.isAbortError(err)) return false;
if (!this._hasData) this.showError('Failed to load economic calendar.', () => void this.fetchData());
return false;
}
}
private _render(): void {
if (!this._hasData || this._events.length === 0) {
if (!this._hasData) this.showError('No upcoming economic events.', () => void this.fetchData());
return;
}
const grouped = groupByDate(this._events);
const sections: string[] = [];
for (const [date, events] of grouped) {
const dateHeader = `<div class="econ-cal-date-header">${escapeHtml(formatDate(date))}</div>`;
const rows = events.map((ev) => {
const impact = (ev.impact || 'low').toLowerCase();
const color = IMPACT_COLORS[impact] ?? IMPACT_COLORS.low;
const flag = COUNTRY_FLAGS[ev.country] ?? escapeHtml(ev.country);
const isHigh = impact === 'high';
const badge = `<span class="econ-cal-badge" style="background:${color};color:#fff;padding:1px 5px;border-radius:3px;font-size:0.7em;font-weight:700;text-transform:uppercase;">${escapeHtml(impact)}</span>`;
const name = isHigh
? `<strong>${escapeHtml(ev.event)}</strong>`
: escapeHtml(ev.event);
const meta = [
ev.actual ? `<span>Actual: ${escapeHtml(formatMetaValue(ev.actual, ev.unit))}</span>` : '',
ev.estimate ? `<span>Est: ${escapeHtml(formatMetaValue(ev.estimate, ev.unit))}</span>` : '',
ev.previous ? `<span>Prev: ${escapeHtml(formatMetaValue(ev.previous, ev.unit))}</span>` : '',
].filter(Boolean).join(' &nbsp;');
return `<div class="econ-cal-event">
<div class="econ-cal-event-main">
<span class="econ-cal-flag">${flag}</span>
<span class="econ-cal-name">${name}</span>
${badge}
</div>
${meta ? `<div class="econ-cal-meta">${meta}</div>` : ''}
</div>`;
}).join('');
sections.push(`<div class="econ-cal-group">${dateHeader}${rows}</div>`);
}
this.setContent(`<div class="econ-cal-panel">${sections.join('')}</div>`);
}
}

113
src/components/FSIPanel.ts Normal file
View File

@@ -0,0 +1,113 @@
import type { MarketServiceClient } from '@/generated/client/worldmonitor/market/v1/service_client';
import { Panel } from './Panel';
import { escapeHtml } from '@/utils/sanitize';
import { getHydratedData } from '@/services/bootstrap';
let _client: MarketServiceClient | null = null;
async function getMarketClient(): Promise<MarketServiceClient> {
if (!_client) {
const { MarketServiceClient } = await import('@/generated/client/worldmonitor/market/v1/service_client');
const { getRpcBaseUrl } = await import('@/services/rpc-client');
_client = new MarketServiceClient(getRpcBaseUrl(), { fetch: (...args: Parameters<typeof fetch>) => globalThis.fetch(...args) });
}
return _client;
}
function fsiLabelColor(label: string): string {
if (label === 'Low Stress') return '#27ae60';
if (label === 'Moderate Stress') return '#f39c12';
if (label === 'Elevated Stress') return '#e67e22';
return '#c0392b';
}
function fsiInterpretation(label: string): string {
if (label === 'Low Stress') return 'Credit markets functioning normally, equity/bond ratio healthy.';
if (label === 'Moderate Stress') return 'Some deterioration in credit conditions, monitor closely.';
if (label === 'Elevated Stress') return 'Significant credit market stress, defensive positioning warranted.';
return 'Severe financial stress, systemic risk elevated.';
}
function metricCard(label: string, value: string): string {
return `<div style="background:rgba(255,255,255,0.04);border-radius:6px;padding:8px 10px;border:1px solid rgba(255,255,255,0.07)">
<div style="font-size:9px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:4px">${escapeHtml(label)}</div>
<div style="font-size:16px;font-weight:600;color:var(--text)">${escapeHtml(value)}</div>
</div>`;
}
export class FSIPanel extends Panel {
private _hasData = false;
constructor() {
super({ id: 'fsi', title: 'Financial Stress Indicator', showCount: false });
}
public async fetchData(): Promise<boolean> {
this.showLoading();
try {
const hydrated = getHydratedData('fearGreedIndex') as Record<string, unknown> | undefined;
if (hydrated && !hydrated.unavailable) {
const hdr = (hydrated.headerMetrics ?? {}) as Record<string, Record<string, unknown> | null>;
const fsiValue = Number(hdr?.fsi?.value ?? 0);
const fsiLabel = String(hdr?.fsi?.label ?? '');
if (fsiValue > 0) {
this._hasData = true;
this.render({
fsiValue,
fsiLabel,
hygPrice: 0,
tltPrice: 0,
vix: Number(hdr?.vix?.value ?? 0),
hySpread: Number(hdr?.hySpread?.value ?? 0),
});
return true;
}
}
const client = await getMarketClient();
const resp = await client.getFearGreedIndex({});
if (resp.unavailable || resp.fsiValue <= 0) {
if (!this._hasData) this.showError('FSI data unavailable', () => void this.fetchData());
return false;
}
this._hasData = true;
this.render(resp);
return true;
} catch (e) {
if (!this._hasData) this.showError(e instanceof Error ? e.message : 'Failed to load', () => void this.fetchData());
return false;
}
}
private render(resp: { fsiValue: number; fsiLabel: string; hygPrice: number; tltPrice: number; vix: number; hySpread: number }): void {
const { fsiValue, fsiLabel, hygPrice, tltPrice, vix, hySpread } = resp;
const labelColor = fsiLabelColor(fsiLabel);
const fillPct = Math.min(Math.max((fsiValue / 2.5) * 100, 0), 100);
const interpretation = fsiInterpretation(fsiLabel);
const html = `<div style="padding:12px 14px">
<div style="text-align:center;margin-bottom:16px">
<div style="font-size:11px;color:var(--text-dim);margin-bottom:4px">FSI VALUE</div>
<div style="font-size:36px;font-weight:700;color:${labelColor};line-height:1">${fsiValue.toFixed(4)}</div>
<div style="font-size:13px;font-weight:600;color:${labelColor};margin-top:4px">${escapeHtml(fsiLabel)}</div>
</div>
<div style="margin:0 0 12px">
<div style="display:flex;justify-content:space-between;font-size:9px;color:var(--text-dim);margin-bottom:3px">
<span>High Stress</span><span>Low Stress</span>
</div>
<div style="background:rgba(255,255,255,0.07);border-radius:4px;height:8px;overflow:hidden">
<div style="height:100%;width:${fillPct.toFixed(1)}%;background:linear-gradient(90deg,#c0392b,#f39c12,#27ae60);border-radius:4px"></div>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:8px;margin-bottom:12px">
${metricCard('VIX', vix > 0 ? vix.toFixed(2) : 'N/A')}
${metricCard('HY Spread', hySpread > 0 ? hySpread.toFixed(2) + '%' : 'N/A')}
${metricCard('HYG Price', hygPrice > 0 ? '$' + hygPrice.toFixed(2) : 'N/A')}
${metricCard('TLT Price', tltPrice > 0 ? '$' + tltPrice.toFixed(2) : 'N/A')}
</div>
<div style="font-size:11px;color:var(--text-dim);background:rgba(255,255,255,0.03);border-radius:6px;padding:8px 10px;border-left:3px solid ${labelColor}">
${escapeHtml(interpretation)}
</div>
</div>`;
this.setContent(html);
}
}

View File

@@ -52,6 +52,28 @@ function fmt(v: number | null | undefined, digits = 2): string {
return v.toFixed(digits);
}
function getRegimeState(score: number): { state: string; stance: string; color: string } {
if (score <= 20) return { state: 'Crisis / Risk-Off', stance: 'CASH', color: '#c0392b' };
if (score <= 35) return { state: 'Stressed / Defensive', stance: 'DEFENSIVE', color: '#e67e22' };
if (score <= 50) return { state: 'Fragile / Hedged', stance: 'HEDGED', color: '#f1c40f' };
if (score <= 65) return { state: 'Stable / Normal', stance: 'NORMAL', color: '#2ecc71' };
return { state: 'Strong / Risk-On', stance: 'AGGRESSIVE', color: '#27ae60' };
}
function getDivergenceWarnings(d: FearGreedData): string[] {
const warnings: string[] = [];
const mom = d.momentum?.score ?? 50;
const sent = d.sentiment?.score ?? 50;
const cnn = d.cnnFearGreed;
const comp = d.compositeScore;
const trend = d.trend?.score ?? 50;
if (mom < 10) warnings.push('Momentum at extreme low — broad equity selling pressure');
if (sent < 15) warnings.push('Sentiment in extreme fear zone');
if (cnn > 0 && Math.abs(comp - cnn) > 20) warnings.push(`CNN F&G ${Math.round(cnn)} diverges ${Math.abs(Math.round(comp - cnn))}pts from composite — sentiment/structural disconnect`);
if (trend < 20) warnings.push('Trend in breakdown — price structure deteriorating');
return warnings;
}
function renderGauge(score: number, label: string, delta: number | null, color: string): string {
const cx = 100, cy = 100, R = 88, r = 60;
@@ -205,6 +227,8 @@ export class FearGreedPanel extends Panel {
const prev = d.previousScore;
const delta = prev > 0 ? score - prev : null;
const color = scoreColor(score);
const regime = getRegimeState(score);
const warnings = getDivergenceWarnings(d);
const catRows = CAT_NAMES.map(name => {
const c = d[name] as CategoryData | undefined;
@@ -246,11 +270,22 @@ export class FearGreedPanel extends Panel {
hdrMetric('Fed Rate', d.fedRate || 'N/A'),
].join('');
const warningsHtml = warnings.length > 0
? `<div style="margin-bottom:10px">
${warnings.map(w => `<div style="display:flex;align-items:center;gap:6px;padding:5px 8px;margin-bottom:4px;border-radius:4px;border:1px solid #e67e22;background:rgba(230,126,34,0.08);font-size:10px;color:#e67e22">&#9888; ${escapeHtml(w)}</div>`).join('')}
</div>`
: '';
const html = `
<div style="padding:12px 14px">
<div style="text-align:center;margin-bottom:12px">
<div style="text-align:center;font-size:11px;font-weight:600;color:${regime.color};letter-spacing:0.06em;text-transform:uppercase;margin-bottom:4px">${escapeHtml(regime.state)}</div>
${renderGauge(score, label, delta, color)}
<div style="text-align:center;margin-top:6px;margin-bottom:8px">
<span style="display:inline-block;padding:3px 12px;border-radius:999px;font-size:10px;font-weight:700;color:#fff;background:${regime.color};letter-spacing:0.08em">${escapeHtml(regime.stance)}</span>
</div>
</div>
${warningsHtml}
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:2px;background:rgba(255,255,255,0.04);border-radius:8px;padding:4px;margin-bottom:12px">
${hdr}
</div>

View File

@@ -0,0 +1,133 @@
import type { EconomicServiceClient } from '@/generated/client/worldmonitor/economic/v1/service_client';
import { Panel } from './Panel';
import { escapeHtml } from '@/utils/sanitize';
let _client: EconomicServiceClient | null = null;
async function getEconomicClient(): Promise<EconomicServiceClient> {
if (!_client) {
const { EconomicServiceClient } = await import('@/generated/client/worldmonitor/economic/v1/service_client');
const { getRpcBaseUrl } = await import('@/services/rpc-client');
_client = new EconomicServiceClient(getRpcBaseUrl(), { fetch: (...args: Parameters<typeof fetch>) => globalThis.fetch(...args) });
}
return _client;
}
interface MacroTile {
id: string;
label: string;
unit: string;
value: number | null;
prior: number | null;
date: string;
lowerIsBetter: boolean;
neutral?: boolean;
format: (v: number) => string;
deltaFormat?: (v: number) => string;
}
function pctFmt(v: number): string {
return `${v.toFixed(1)}%`;
}
function gdpFmt(v: number): string {
return `$${v.toLocaleString(undefined, { maximumFractionDigits: 0 })}B`;
}
function cpiYoY(obs: { date: string; value: number }[]): { value: number | null; prior: number | null; date: string } {
if (obs.length < 13) return { value: null, prior: null, date: '' };
const latest = obs[obs.length - 1];
const yearAgo = obs[obs.length - 13];
const priorMonth = obs[obs.length - 2];
const priorYearAgo = obs[obs.length - 14] ?? obs[obs.length - 13];
if (!latest || !yearAgo) return { value: null, prior: null, date: '' };
const yoy = yearAgo.value > 0 ? ((latest.value - yearAgo.value) / yearAgo.value) * 100 : null;
const priorYoy = (priorYearAgo && priorMonth && priorYearAgo.value > 0)
? ((priorMonth.value - priorYearAgo.value) / priorYearAgo.value) * 100
: null;
return { value: yoy, prior: priorYoy, date: latest.date };
}
function lastTwo(obs: { date: string; value: number }[]): { value: number | null; prior: number | null; date: string } {
const last = obs[obs.length - 1];
if (!obs.length || !last) return { value: null, prior: null, date: '' };
const prev = obs[obs.length - 2];
return {
value: last.value,
prior: prev?.value ?? null,
date: last.date,
};
}
function deltaColor(delta: number, lowerIsBetter: boolean, neutral: boolean): string {
if (neutral) return 'var(--text-dim)';
if (delta === 0) return 'var(--text-dim)';
const improved = lowerIsBetter ? delta < 0 : delta > 0;
return improved ? '#27ae60' : '#e74c3c';
}
function tileHtml(tile: MacroTile): string {
const val = tile.value !== null ? escapeHtml(tile.format(tile.value)) : 'N/A';
const delta = tile.value !== null && tile.prior !== null ? tile.value - tile.prior : null;
const fmt = tile.deltaFormat ?? tile.format;
const deltaStr = delta !== null
? `${delta >= 0 ? '+' : ''}${fmt(delta)} vs prior`
: '';
const deltaColor_ = delta !== null ? deltaColor(delta, tile.lowerIsBetter, tile.neutral ?? false) : 'var(--text-dim)';
return `<div style="background:rgba(255,255,255,0.03);border:1px solid var(--border);border-radius:6px;padding:14px 12px;display:flex;flex-direction:column;gap:4px">
<div style="font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.07em">${escapeHtml(tile.label)}</div>
<div style="font-size:28px;font-weight:700;color:var(--text);line-height:1.1;font-variant-numeric:tabular-nums">${val}</div>
${deltaStr ? `<div style="font-size:11px;color:${deltaColor_}">${escapeHtml(deltaStr)}</div>` : ''}
<div style="font-size:10px;color:var(--text-dim)">${escapeHtml(tile.date)}</div>
</div>`;
}
export class MacroTilesPanel extends Panel {
private _hasData = false;
constructor() {
super({ id: 'macro-tiles', title: 'Macro Indicators', showCount: false });
}
public async fetchData(): Promise<boolean> {
this.showLoading();
try {
const client = await getEconomicClient();
const resp = await client.getFredSeriesBatch({
seriesIds: ['CPIAUCSL', 'UNRATE', 'GDP', 'FEDFUNDS'],
limit: 14,
});
const cpiObs = resp.results['CPIAUCSL']?.observations ?? [];
const unrateObs = resp.results['UNRATE']?.observations ?? [];
const gdpObs = resp.results['GDP']?.observations ?? [];
const fedObs = resp.results['FEDFUNDS']?.observations ?? [];
const cpi = cpiYoY(cpiObs);
const unrate = lastTwo(unrateObs);
const gdp = lastTwo(gdpObs);
const fed = lastTwo(fedObs);
const tiles: MacroTile[] = [
{ id: 'cpi', label: 'CPI (YoY)', unit: '%', ...cpi, lowerIsBetter: true, format: pctFmt, deltaFormat: (v) => v.toFixed(2) },
{ id: 'unrate', label: 'Unemployment', unit: '%', ...unrate, lowerIsBetter: true, format: pctFmt },
{ id: 'gdp', label: 'GDP (Billions)', unit: '$B', ...gdp, lowerIsBetter: false, format: gdpFmt, deltaFormat: (v) => `${v.toLocaleString(undefined, { maximumFractionDigits: 0 })}B` },
{ id: 'fed', label: 'Fed Funds Rate', unit: '%', ...fed, lowerIsBetter: false, neutral: true, format: pctFmt },
];
const hasAny = tiles.some(t => t.value !== null);
if (!hasAny) {
if (!this._hasData) this.showError('Macro data unavailable', () => void this.fetchData());
return false;
}
this._hasData = true;
const html = `<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:10px">${tiles.map(tileHtml).join('')}</div>`;
this.setContent(html);
return true;
} catch (e) {
if (!this._hasData) this.showError(e instanceof Error ? e.message : 'Failed to load', () => void this.fetchData());
return false;
}
}
}

View File

@@ -0,0 +1,198 @@
import type { EconomicServiceClient } from '@/generated/client/worldmonitor/economic/v1/service_client';
import { Panel } from './Panel';
import { escapeHtml } from '@/utils/sanitize';
let _client: EconomicServiceClient | null = null;
async function getEconomicClient(): Promise<EconomicServiceClient> {
if (!_client) {
const { EconomicServiceClient } = await import('@/generated/client/worldmonitor/economic/v1/service_client');
const { getRpcBaseUrl } = await import('@/services/rpc-client');
_client = new EconomicServiceClient(getRpcBaseUrl(), { fetch: (...args: Parameters<typeof fetch>) => globalThis.fetch(...args) });
}
return _client;
}
const SERIES_IDS = ['DGS1MO', 'DGS3MO', 'DGS6MO', 'DGS1', 'DGS2', 'DGS5', 'DGS10', 'DGS30'] as const;
const TENOR_LABELS = ['1M', '3M', '6M', '1Y', '2Y', '5Y', '10Y', '30Y'];
const SVG_W = 480;
const SVG_H = 180;
const MARGIN_L = 40;
const MARGIN_R = 20;
const MARGIN_T = 16;
const MARGIN_B = 24;
const CHART_W = SVG_W - MARGIN_L - MARGIN_R;
const CHART_H = SVG_H - MARGIN_T - MARGIN_B;
interface YieldPoint {
tenor: string;
value: number | null;
}
function xPos(index: number, count: number): number {
if (count <= 1) return MARGIN_L + CHART_W / 2;
return MARGIN_L + (index / (count - 1)) * CHART_W;
}
function yPos(value: number, yMin: number, yMax: number): number {
const range = yMax - yMin || 1;
const scale = (value - yMin) / range;
return MARGIN_T + CHART_H - scale * CHART_H;
}
function buildPolylinePoints(points: YieldPoint[], yMin: number, yMax: number): string {
return points
.map((p, i) => {
if (p.value === null) return null;
const x = xPos(i, points.length);
const y = yPos(p.value, yMin, yMax);
return `${x.toFixed(2)},${y.toFixed(2)}`;
})
.filter(Boolean)
.join(' ');
}
function buildYAxisLabels(yMin: number, yMax: number): string {
const step = (yMax - yMin) / 3;
const labels: string[] = [];
for (let i = 0; i <= 3; i++) {
const val = yMin + step * i;
const y = yPos(val, yMin, yMax);
labels.push(
`<text x="${(MARGIN_L - 4).toFixed(0)}" y="${y.toFixed(2)}" text-anchor="end" fill="rgba(255,255,255,0.35)" font-size="8" alignment-baseline="middle">${val.toFixed(1)}%</text>`
);
labels.push(
`<line x1="${MARGIN_L}" y1="${y.toFixed(2)}" x2="${SVG_W - MARGIN_R}" y2="${y.toFixed(2)}" stroke="rgba(255,255,255,0.06)" stroke-width="1"/>`
);
}
return labels.join('');
}
function buildXAxisLabels(count: number): string {
return TENOR_LABELS.slice(0, count).map((label, i) => {
const x = xPos(i, count);
const y = SVG_H - MARGIN_B + 12;
return `<text x="${x.toFixed(2)}" y="${y}" text-anchor="middle" fill="rgba(255,255,255,0.5)" font-size="8">${escapeHtml(label)}</text>`;
}).join('');
}
function buildCircles(points: YieldPoint[], yMin: number, yMax: number): string {
return points.map((p, i) => {
if (p.value === null) return '';
const x = xPos(i, points.length);
const y = yPos(p.value, yMin, yMax);
return `<circle cx="${x.toFixed(2)}" cy="${y.toFixed(2)}" r="3" fill="#3498db" stroke="rgba(0,0,0,0.4)" stroke-width="1"/>`;
}).join('');
}
function renderChart(current: YieldPoint[], prior: YieldPoint[]): string {
const validValues = current.map(p => p.value).filter((v): v is number => v !== null);
if (validValues.length === 0) return '<div style="padding:16px;color:var(--text-dim);font-size:12px">No yield data available.</div>';
const yMin = Math.max(0, Math.min(...validValues) - 0.25);
const yMax = Math.max(...validValues) + 0.5;
const curPoints = buildPolylinePoints(current, yMin, yMax);
const priorPoints = buildPolylinePoints(prior, yMin, yMax);
const priorLine = priorPoints.length > 0
? `<polyline points="${priorPoints}" fill="none" stroke="rgba(255,255,255,0.3)" stroke-width="1.5" stroke-dasharray="4,3" stroke-linecap="round" stroke-linejoin="round"/>`
: '';
return `
<svg viewBox="0 0 ${SVG_W} ${SVG_H}" width="100%" style="display:block;overflow:visible">
${buildYAxisLabels(yMin, yMax)}
${buildXAxisLabels(current.length)}
${priorLine}
<polyline points="${curPoints}" fill="none" stroke="#3498db" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
${buildCircles(current, yMin, yMax)}
</svg>`;
}
function renderTable(points: YieldPoint[]): string {
const headers = points.map(p => `<th style="font-size:9px;font-weight:600;color:var(--text-dim);padding:4px 6px;text-align:center">${escapeHtml(p.tenor)}</th>`).join('');
const cells = points.map(p => {
const val = p.value !== null ? `${p.value.toFixed(2)}%` : 'N/A';
return `<td style="font-size:11px;color:var(--text);padding:4px 6px;text-align:center">${escapeHtml(val)}</td>`;
}).join('');
return `
<div style="overflow-x:auto;margin-top:8px">
<table style="width:100%;border-collapse:collapse">
<thead><tr>${headers}</tr></thead>
<tbody><tr>${cells}</tr></tbody>
</table>
</div>`;
}
export class YieldCurvePanel extends Panel {
private _hasData = false;
constructor() {
super({ id: 'yield-curve', title: 'US Treasury Yield Curve', showCount: false });
}
public async fetchData(): Promise<boolean> {
this.showLoading();
try {
const client = await getEconomicClient();
const resp = await client.getFredSeriesBatch({ seriesIds: [...SERIES_IDS], limit: 2 });
const results = resp.results ?? {};
const current: YieldPoint[] = SERIES_IDS.map((id, i) => {
const obs = results[id]?.observations ?? [];
return { tenor: TENOR_LABELS[i] ?? id, value: obs.length > 0 ? (obs[obs.length - 1]?.value ?? null) : null };
});
const prior: YieldPoint[] = SERIES_IDS.map((id, i) => {
const obs = results[id]?.observations ?? [];
return { tenor: TENOR_LABELS[i] ?? id, value: obs.length > 1 ? (obs[obs.length - 2]?.value ?? null) : null };
});
const validCount = current.filter(p => p.value !== null).length;
if (validCount === 0) {
if (!this._hasData) this.showError('No yield data available', () => void this.fetchData());
return false;
}
this.render(current, prior);
return true;
} catch (e) {
if (!this._hasData) this.showError(e instanceof Error ? e.message : 'Failed to load yield curve', () => void this.fetchData());
return false;
}
}
private render(current: YieldPoint[], prior: YieldPoint[]): void {
this._hasData = true;
const y2 = current.find(p => p.tenor === '2Y')?.value ?? null;
const y10 = current.find(p => p.tenor === '10Y')?.value ?? null;
const isInverted = y2 !== null && y10 !== null && y2 > y10;
const spreadBps = y2 !== null && y10 !== null ? ((y10 - y2) * 100).toFixed(0) : null;
const spreadSign = spreadBps !== null ? (Number(spreadBps) >= 0 ? '+' : '') : '';
const statusBadge = isInverted
? `<span style="background:#e74c3c;color:#fff;font-size:9px;font-weight:700;padding:2px 6px;border-radius:4px;letter-spacing:0.08em">INVERTED</span>`
: `<span style="background:#2ecc71;color:#000;font-size:9px;font-weight:700;padding:2px 6px;border-radius:4px;letter-spacing:0.08em">NORMAL</span>`;
const spreadHtml = spreadBps !== null
? `<span style="font-size:11px;color:var(--text-dim);margin-left:10px">2Y-10Y Spread: <span style="color:${isInverted ? '#e74c3c' : '#2ecc71'}">${escapeHtml(spreadSign + spreadBps)}bps</span></span>`
: '';
const html = `
<div style="padding:10px 14px 6px">
<div style="display:flex;align-items:center;margin-bottom:10px;gap:4px">
${statusBadge}${spreadHtml}
</div>
<div style="margin:0 -4px">${renderChart(current, prior)}</div>
${renderTable(current)}
<div style="margin-top:8px;font-size:9px;color:var(--text-dim);display:flex;gap:12px;align-items:center">
<span><svg width="20" height="4" style="vertical-align:middle"><line x1="0" y1="2" x2="20" y2="2" stroke="#3498db" stroke-width="2"/></svg> Current</span>
<span><svg width="20" height="4" style="vertical-align:middle"><line x1="0" y1="2" x2="20" y2="2" stroke="rgba(255,255,255,0.3)" stroke-width="1.5" stroke-dasharray="4,3"/></svg> Prior</span>
<span style="margin-left:auto">Source: FRED</span>
</div>
</div>`;
this.setContent(html);
}
}

View File

@@ -71,4 +71,10 @@ export * from './DisasterCorrelationPanel';
export * from './ConsumerPricesPanel';
export { NationalDebtPanel } from './NationalDebtPanel';
export * from './FearGreedPanel';
export * from './MacroTilesPanel';
export * from './FSIPanel';
export * from './YieldCurvePanel';
export * from './EarningsCalendarPanel';
export * from './EconomicCalendarPanel';
export * from './CotPositioningPanel';
export { HormuzPanel } from './HormuzPanel';

View File

@@ -56,6 +56,12 @@ const FULL_PANELS: Record<string, PanelConfig> = {
'satellite-fires': { name: 'Fires', enabled: true, priority: 2 },
'macro-signals': { name: 'Market Regime', enabled: true, priority: 2 },
'fear-greed': { name: 'Fear & Greed', 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 },
'earnings-calendar': { name: 'Earnings Calendar', enabled: false, priority: 2 },
'economic-calendar': { name: 'Economic Calendar', enabled: false, priority: 2 },
'cot-positioning': { name: 'COT Positioning', enabled: false, priority: 2 },
'hormuz-tracker': { name: 'Hormuz Trade Tracker', enabled: true, priority: 2 },
'gulf-economies': { name: 'Gulf Economies', enabled: false, priority: 2 },
'consumer-prices': { name: 'Consumer Prices', enabled: false, priority: 2 },
@@ -407,6 +413,13 @@ const FINANCE_PANELS: Record<string, PanelConfig> = {
ipo: { name: 'IPOs, Earnings & M&A', enabled: true, priority: 1 },
heatmap: { name: 'Sector Heatmap', enabled: true, priority: 1 },
'macro-signals': { name: 'Market Regime', enabled: true, priority: 1 },
'macro-tiles': { name: 'Macro Indicators', enabled: true, priority: 1 },
'fear-greed': { name: 'Fear & Greed', 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 },
'economic-calendar': { name: 'Economic Calendar', enabled: true, priority: 1 },
'cot-positioning': { name: 'COT Positioning', enabled: true, priority: 2 },
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

@@ -363,6 +363,30 @@ export interface BlsObservation {
value: string;
}
export interface GetEconomicCalendarRequest {
fromDate: string;
toDate: string;
}
export interface GetEconomicCalendarResponse {
events: EconomicEvent[];
fromDate: string;
toDate: string;
total: number;
unavailable: boolean;
}
export interface EconomicEvent {
event: string;
country: string;
date: string;
impact: string;
actual: string;
estimate: string;
previous: string;
unit: string;
}
export interface FieldViolation {
field: string;
description: string;
@@ -751,6 +775,32 @@ export class EconomicServiceClient {
return await resp.json() as GetBlsSeriesResponse;
}
async getEconomicCalendar(req: GetEconomicCalendarRequest, options?: EconomicServiceCallOptions): Promise<GetEconomicCalendarResponse> {
let path = "/api/economic/v1/get-economic-calendar";
const params = new URLSearchParams();
if (req.fromDate != null && req.fromDate !== "") params.set("fromDate", String(req.fromDate));
if (req.toDate != null && req.toDate !== "") params.set("toDate", String(req.toDate));
const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : "");
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 GetEconomicCalendarResponse;
}
private async handleError(resp: Response): Promise<never> {
const body = await resp.text();
if (resp.status === 400) {

View File

@@ -357,6 +357,10 @@ export interface GetFearGreedIndexResponse {
aaiiBear: number;
fedRate: string;
unavailable: boolean;
fsiValue: number;
fsiLabel: string;
hygPrice: number;
tltPrice: number;
}
export interface FearGreedCategory {
@@ -367,6 +371,54 @@ export interface FearGreedCategory {
inputsJson: string;
}
export interface ListEarningsCalendarRequest {
fromDate: string;
toDate: string;
}
export interface ListEarningsCalendarResponse {
earnings: EarningsEntry[];
fromDate: string;
toDate: string;
total: number;
unavailable: boolean;
}
export interface EarningsEntry {
symbol: string;
company: string;
date: string;
hour: string;
epsEstimate: number;
revenueEstimate: number;
epsActual: number;
revenueActual: number;
hasActuals: boolean;
surpriseDirection: string;
}
export interface GetCotPositioningRequest {
}
export interface GetCotPositioningResponse {
instruments: CotInstrument[];
reportDate: string;
unavailable: boolean;
}
export interface CotInstrument {
name: string;
code: string;
reportDate: string;
assetManagerLong: string;
assetManagerShort: string;
leveragedFundsLong: string;
leveragedFundsShort: string;
dealerLong: string;
dealerShort: string;
netPct: number;
}
export interface FieldViolation {
field: string;
description: string;
@@ -833,6 +885,55 @@ export class MarketServiceClient {
return await resp.json() as GetFearGreedIndexResponse;
}
async listEarningsCalendar(req: ListEarningsCalendarRequest, options?: MarketServiceCallOptions): Promise<ListEarningsCalendarResponse> {
let path = "/api/market/v1/list-earnings-calendar";
const params = new URLSearchParams();
if (req.fromDate != null && req.fromDate !== "") params.set("fromDate", String(req.fromDate));
if (req.toDate != null && req.toDate !== "") params.set("toDate", String(req.toDate));
const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : "");
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 ListEarningsCalendarResponse;
}
async getCotPositioning(req: GetCotPositioningRequest, options?: MarketServiceCallOptions): Promise<GetCotPositioningResponse> {
let path = "/api/market/v1/get-cot-positioning";
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 GetCotPositioningResponse;
}
private async handleError(resp: Response): Promise<never> {
const body = await resp.text();
if (resp.status === 400) {

View File

@@ -363,6 +363,30 @@ export interface BlsObservation {
value: string;
}
export interface GetEconomicCalendarRequest {
fromDate: string;
toDate: string;
}
export interface GetEconomicCalendarResponse {
events: EconomicEvent[];
fromDate: string;
toDate: string;
total: number;
unavailable: boolean;
}
export interface EconomicEvent {
event: string;
country: string;
date: string;
impact: string;
actual: string;
estimate: string;
previous: string;
unit: string;
}
export interface FieldViolation {
field: string;
description: string;
@@ -422,6 +446,7 @@ export interface EconomicServiceHandler {
getNationalDebt(ctx: ServerContext, req: GetNationalDebtRequest): Promise<GetNationalDebtResponse>;
listFuelPrices(ctx: ServerContext, req: ListFuelPricesRequest): Promise<ListFuelPricesResponse>;
getBlsSeries(ctx: ServerContext, req: GetBlsSeriesRequest): Promise<GetBlsSeriesResponse>;
getEconomicCalendar(ctx: ServerContext, req: GetEconomicCalendarRequest): Promise<GetEconomicCalendarResponse>;
}
export function createEconomicServiceRoutes(
@@ -1010,6 +1035,54 @@ export function createEconomicServiceRoutes(
}
},
},
{
method: "GET",
path: "/api/economic/v1/get-economic-calendar",
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const url = new URL(req.url, "http://localhost");
const params = url.searchParams;
const body: GetEconomicCalendarRequest = {
fromDate: params.get("fromDate") ?? "",
toDate: params.get("toDate") ?? "",
};
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("getEconomicCalendar", body);
if (bodyViolations) {
throw new ValidationError(bodyViolations);
}
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.getEconomicCalendar(ctx, body);
return new Response(JSON.stringify(result as GetEconomicCalendarResponse), {
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

@@ -357,6 +357,10 @@ export interface GetFearGreedIndexResponse {
aaiiBear: number;
fedRate: string;
unavailable: boolean;
fsiValue: number;
fsiLabel: string;
hygPrice: number;
tltPrice: number;
}
export interface FearGreedCategory {
@@ -367,6 +371,54 @@ export interface FearGreedCategory {
inputsJson: string;
}
export interface ListEarningsCalendarRequest {
fromDate: string;
toDate: string;
}
export interface ListEarningsCalendarResponse {
earnings: EarningsEntry[];
fromDate: string;
toDate: string;
total: number;
unavailable: boolean;
}
export interface EarningsEntry {
symbol: string;
company: string;
date: string;
hour: string;
epsEstimate: number;
revenueEstimate: number;
epsActual: number;
revenueActual: number;
hasActuals: boolean;
surpriseDirection: string;
}
export interface GetCotPositioningRequest {
}
export interface GetCotPositioningResponse {
instruments: CotInstrument[];
reportDate: string;
unavailable: boolean;
}
export interface CotInstrument {
name: string;
code: string;
reportDate: string;
assetManagerLong: string;
assetManagerShort: string;
leveragedFundsLong: string;
leveragedFundsShort: string;
dealerLong: string;
dealerShort: string;
netPct: number;
}
export interface FieldViolation {
field: string;
description: string;
@@ -429,6 +481,8 @@ export interface MarketServiceHandler {
listAiTokens(ctx: ServerContext, req: ListAiTokensRequest): Promise<ListAiTokensResponse>;
listOtherTokens(ctx: ServerContext, req: ListOtherTokensRequest): Promise<ListOtherTokensResponse>;
getFearGreedIndex(ctx: ServerContext, req: GetFearGreedIndexRequest): Promise<GetFearGreedIndexResponse>;
listEarningsCalendar(ctx: ServerContext, req: ListEarningsCalendarRequest): Promise<ListEarningsCalendarResponse>;
getCotPositioning(ctx: ServerContext, req: GetCotPositioningRequest): Promise<GetCotPositioningResponse>;
}
export function createMarketServiceRoutes(
@@ -1172,6 +1226,91 @@ export function createMarketServiceRoutes(
}
},
},
{
method: "GET",
path: "/api/market/v1/list-earnings-calendar",
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const url = new URL(req.url, "http://localhost");
const params = url.searchParams;
const body: ListEarningsCalendarRequest = {
fromDate: params.get("fromDate") ?? "",
toDate: params.get("toDate") ?? "",
};
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("listEarningsCalendar", body);
if (bodyViolations) {
throw new ValidationError(bodyViolations);
}
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.listEarningsCalendar(ctx, body);
return new Response(JSON.stringify(result as ListEarningsCalendarResponse), {
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" },
});
}
},
},
{
method: "GET",
path: "/api/market/v1/get-cot-positioning",
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const body = {} as GetCotPositioningRequest;
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.getCotPositioning(ctx, body);
return new Response(JSON.stringify(result as GetCotPositioningResponse), {
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

@@ -30,12 +30,45 @@ export interface DailyMarketBrief {
headlineCount: number;
}
export interface RegimeMacroContext {
compositeScore: number;
compositeLabel: string;
fsiValue: number;
fsiLabel: string;
vix: number;
hySpread: number;
cnnFearGreed: number;
cnnLabel: string;
momentum?: { score: number };
sentiment?: { score: number };
}
export interface YieldCurveContext {
inverted: boolean;
spread2s10s: number;
rate2y: number;
rate10y: number;
rate30y: number;
}
export interface SectorBriefContext {
topName: string;
topChange: number;
worstName: string;
worstChange: number;
countPositive: number;
total: number;
}
export interface BuildDailyMarketBriefOptions {
markets: MarketData[];
newsByCategory: Record<string, NewsItem[]>;
timezone?: string;
now?: Date;
targets?: MarketWatchlistEntry[];
regimeContext?: RegimeMacroContext;
yieldCurveContext?: YieldCurveContext;
sectorContext?: SectorBriefContext;
summarize?: (
headlines: string[],
onProgress?: undefined,
@@ -273,6 +306,47 @@ function buildSummaryInputs(items: DailyMarketBriefItem[], headlines: NewsItem[]
return { headlines: headlineLines, marketContext };
}
function buildExtendedMarketContext(
baseContext: string,
regime?: RegimeMacroContext,
yieldCurve?: YieldCurveContext,
sector?: SectorBriefContext,
): string {
const parts: string[] = [`Markets: ${baseContext}`];
if (regime && regime.compositeScore > 0) {
const lines = [
`Fear & Greed: ${regime.compositeScore.toFixed(0)} (${regime.compositeLabel})`,
];
if (regime.fsiValue > 0) lines.push(`FSI: ${regime.fsiValue.toFixed(2)} (${regime.fsiLabel})`);
if (regime.vix > 0) lines.push(`VIX: ${regime.vix.toFixed(1)}`);
if (regime.hySpread > 0) lines.push(`HY Spread: ${regime.hySpread.toFixed(0)}bps`);
if (regime.cnnFearGreed > 0) lines.push(`CNN F&G: ${regime.cnnFearGreed.toFixed(0)} (${regime.cnnLabel})`);
if (regime.momentum) lines.push(`Momentum: ${regime.momentum.score.toFixed(0)}/100`);
if (regime.sentiment) lines.push(`Sentiment: ${regime.sentiment.score.toFixed(0)}/100`);
parts.push(`Market Stress Indicators:\n${lines.join('\n')}`);
}
if (yieldCurve && yieldCurve.rate10y > 0) {
const spreadStr = (yieldCurve.spread2s10s >= 0 ? '+' : '') + yieldCurve.spread2s10s.toFixed(0);
parts.push([
`Yield Curve: ${yieldCurve.inverted ? 'INVERTED' : 'NORMAL'} (2s/10s ${spreadStr}bps)`,
`2Y: ${yieldCurve.rate2y.toFixed(2)}% 10Y: ${yieldCurve.rate10y.toFixed(2)}% 30Y: ${yieldCurve.rate30y.toFixed(2)}%`,
].join('\n'));
}
if (sector && sector.total > 0) {
const topSign = sector.topChange >= 0 ? '+' : '';
const worstSign = sector.worstChange >= 0 ? '+' : '';
parts.push([
`Sectors: ${sector.countPositive}/${sector.total} positive`,
`Top: ${sector.topName} ${topSign}${sector.topChange.toFixed(1)}% Worst: ${sector.worstName} ${worstSign}${sector.worstChange.toFixed(1)}%`,
].join('\n'));
}
return parts.join('\n\n');
}
export function shouldRefreshDailyBrief(
brief: DailyMarketBrief | null | undefined,
timezone = 'UTC',
@@ -337,6 +411,7 @@ export async function buildDailyMarketBrief(options: BuildDailyMarketBriefOption
}
const { headlines: summaryHeadlines, marketContext } = buildSummaryInputs(items, relevantHeadlines);
const extendedContext = buildExtendedMarketContext(marketContext, options.regimeContext, options.yieldCurveContext, options.sectorContext);
let summary = buildRuleSummary(items, relevantHeadlines.length);
let provider = 'rules';
let model = '';
@@ -348,7 +423,7 @@ export async function buildDailyMarketBrief(options: BuildDailyMarketBriefOption
const generated = await summaryProvider(
summaryHeadlines,
undefined,
`Market context: ${marketContext}`,
extendedContext,
'en',
);
if (generated?.summary) {

View File

@@ -0,0 +1,51 @@
---
status: complete
priority: p3
issue_id: "030"
tags: [code-review, quality, finance-panels]
dependencies: []
---
# MacroTilesPanel: Fragile Delta Formatter Switching on tile.id
## Problem Statement
`MacroTilesPanel.ts:59-61` uses a single expression that switches formatter behavior by `tile.id` string comparison and strips characters from formatted output. Breaks silently if a tile id or format function changes.
## Findings
```typescript
const deltaStr = delta !== null
? `${delta >= 0 ? '+' : ''}${tile.id === 'cpi' ? delta.toFixed(2) : tile.format(delta).replace('$', '').replace('B', '')}${tile.id === 'cpi' ? '' : tile.id === 'gdp' ? 'B' : ''} vs prior`
: '';
```
- Switches on `tile.id` string comparison
- Strips `$` and `B` from formatted output then re-appends `B` for GDP
- Adding a new tile with a different format will silently produce wrong output
- The `MacroTile` interface already has a `format` field — a `deltaFormat?: (v: number) => string` field would be the correct extension point
## Proposed Solutions
### Option A: Add deltaFormat field to MacroTile interface
Add `deltaFormat?: (v: number) => string` to `MacroTile`, define it per-tile in the tiles array. Clean, self-contained, extensible.
- **Effort**: Small
- **Risk**: Low
### Option B: Keep as-is with a comment
Add an explanatory comment documenting the intent. Low effort but keeps fragility.
- **Effort**: Minimal
- **Risk**: Low
## Acceptance Criteria
- [ ] Adding a new tile type to MacroTilesPanel does not require updating a switch expression
- [ ] Delta formatting logic is co-located with tile definition
## Work Log
- 2026-03-26: Identified by code review of PR #2258

View File

@@ -0,0 +1,47 @@
---
status: complete
priority: p3
issue_id: "031"
tags: [code-review, performance, finance-panels]
dependencies: []
---
# FSIPanel: Missing getHydratedData Fast Path
## Problem Statement
`FSIPanel.fetchData()` always fires a live `getFearGreedIndex` RPC call, even though `getHydratedData('fearGreedIndex')` already contains the FSI fields from bootstrap. This causes a redundant Redis round-trip on every panel open.
## Findings
The `fearGreedIndex` bootstrap key contains `hdr.fsi.value`, `hdr.fsi.label`, `hdr.vix.value`, `hdr.hySpread.value` — all the fields FSIPanel needs. The `_collectRegimeContext()` method in `data-loader.ts` already demonstrates the correct pattern: check `getHydratedData('fearGreedIndex')` first, fall back to RPC if absent.
FSIPanel skips this optimization entirely. On sessions with a bootstrap payload, every FSI panel open costs an extra RPC call to Redis.
## Proposed Solutions
### Option A: Mirror _collectRegimeContext pattern
Check `getHydratedData('fearGreedIndex')` at the top of `fetchData()`. Extract FSI fields from `hdr.fsi`. Fall back to RPC only if hydrated data is absent or `unavailable`.
```typescript
const hydrated = getHydratedData('fearGreedIndex') as Record<string, unknown> | undefined;
if (hydrated && !hydrated.unavailable) {
// extract hdr.fsi fields and render
return true;
}
// fall back to RPC
```
- **Effort**: Small
- **Risk**: Low — hydrated data read is synchronous and always stale by definition
## Acceptance Criteria
- [ ] FSIPanel reads from bootstrap hydration on sessions where fearGreedIndex is loaded
- [ ] Falls back to RPC when hydrated data is absent
- [ ] No visible change in rendered output
## Work Log
- 2026-03-26: Identified by performance review of PR #2258

View File

@@ -0,0 +1,58 @@
---
status: complete
priority: p3
issue_id: "032"
tags: [code-review, performance, finance-panels]
dependencies: []
---
# Finance Panels: RPC Client Constructed on Every fetchData() Call
## Problem Statement
All 6 new finance panels (MacroTilesPanel, YieldCurvePanel, FSIPanel, EarningsCalendarPanel, EconomicCalendarPanel, CotPositioningPanel) plus `_collectRegimeContext` and `_collectYieldCurveContext` in data-loader.ts construct a new `EconomicServiceClient` or `MarketServiceClient` on every `fetchData()` call via dynamic imports.
## Findings
```typescript
// Re-runs on every fetchData() call:
const { EconomicServiceClient } = await import('@/generated/client/...');
const { getRpcBaseUrl } = await import('@/services/rpc-client');
const client = new EconomicServiceClient(getRpcBaseUrl(), { fetch: ... });
```
While Vite caches module resolution, a new client object is constructed each call. The fetch lambda `(...args) => globalThis.fetch(...args)` is also recreated each time. On retry or multiple panel opens in the same session, this is unnecessary work.
## Proposed Solutions
### Option A: Module-level lazy singleton per panel
```typescript
let _client: EconomicServiceClient | null = null;
function getClient(): EconomicServiceClient {
if (!_client) {
const { getRpcBaseUrl } = require('@/services/rpc-client'); // sync after module load
_client = new EconomicServiceClient(getRpcBaseUrl(), { fetch: (...args) => globalThis.fetch(...args) });
}
return _client;
}
```
- **Effort**: Small per panel
- **Risk**: Low — clients are stateless
### Option B: Shared RPC client factory in a separate module
A `getRpcClients()` helper that lazily initializes and caches both service clients.
- **Effort**: Medium
- **Risk**: Low
## Acceptance Criteria
- [ ] Each panel's service client is created at most once per panel instance
- [ ] fetch lambda still uses deferred `globalThis.fetch` (not bound at construction)
## Work Log
- 2026-03-26: Identified by performance review of PR #2258

View File

@@ -0,0 +1,43 @@
---
status: complete
priority: p3
issue_id: "033"
tags: [code-review, bug, finance-panels]
dependencies: []
---
# ALLOWED_SERIES Missing BAMLC0A0CM and SOFR (Pre-existing)
## Problem Statement
`scripts/seed-economy.mjs` seeds `BAMLC0A0CM` (IG OAS) and `SOFR` into Redis, but neither series appears in `ALLOWED_SERIES` in `server/worldmonitor/economic/v1/get-fred-series-batch.ts`. Any RPC request for these series silently returns empty data.
## Findings
- `seed-economy.mjs` line 20: includes `'BAMLC0A0CM', 'SOFR'` in FRED_SERIES
- `get-fred-series-batch.ts` ALLOWED_SERIES: does NOT include these two series
- Result: data is written to Redis but unreachable via the public RPC
- Pre-existing bug, not introduced by PR #2258 but visible because the file was touched
## Proposed Solutions
### Option A: Add to ALLOWED_SERIES
```typescript
'BAMLC0A0CM', // IG OAS spread
'SOFR', // Secured Overnight Financing Rate
```
One-line fix. No other changes needed.
- **Effort**: Minimal
- **Risk**: None — just allowlisting existing seeded data
## Acceptance Criteria
- [ ] `getFredSeriesBatch({ seriesIds: ['BAMLC0A0CM', 'SOFR'] })` returns data
- [ ] No changes to seed scripts needed
## Work Log
- 2026-03-26: Identified by architecture review of PR #2258 (pre-existing gap)