mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
22
compound-engineering.local.md
Normal file
22
compound-engineering.local.md
Normal 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
@@ -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
@@ -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
|
||||
|
||||
29
proto/worldmonitor/economic/v1/get_economic_calendar.proto
Normal file
29
proto/worldmonitor/economic/v1/get_economic_calendar.proto
Normal 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;
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
|
||||
26
proto/worldmonitor/market/v1/get_cot_positioning.proto
Normal file
26
proto/worldmonitor/market/v1/get_cot_positioning.proto
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
31
proto/worldmonitor/market/v1/list_earnings_calendar.proto
Normal file
31
proto/worldmonitor/market/v1/list_earnings_calendar.proto
Normal 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;
|
||||
}
|
||||
@@ -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
148
scripts/seed-cot.mjs
Normal 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); });
|
||||
}
|
||||
82
scripts/seed-earnings-calendar.mjs
Normal file
82
scripts/seed-earnings-calendar.mjs
Normal 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); });
|
||||
}
|
||||
135
scripts/seed-economic-calendar.mjs
Normal file
135
scripts/seed-economic-calendar.mjs
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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) ───
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
40
server/worldmonitor/economic/v1/get-economic-calendar.ts
Normal file
40
server/worldmonitor/economic/v1/get-economic-calendar.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
55
server/worldmonitor/market/v1/get-cot-positioning.ts
Normal file
55
server/worldmonitor/market/v1/get-cot-positioning.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
42
server/worldmonitor/market/v1/list-earnings-calendar.ts
Normal file
42
server/worldmonitor/market/v1/list-earnings-calendar.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
105
src/components/CotPositioningPanel.ts
Normal file
105
src/components/CotPositioningPanel.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
138
src/components/EarningsCalendarPanel.ts
Normal file
138
src/components/EarningsCalendarPanel.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
142
src/components/EconomicCalendarPanel.ts
Normal file
142
src/components/EconomicCalendarPanel.ts
Normal 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(' ');
|
||||
|
||||
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
113
src/components/FSIPanel.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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">⚠ ${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>
|
||||
|
||||
133
src/components/MacroTilesPanel.ts
Normal file
133
src/components/MacroTilesPanel.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
198
src/components/YieldCurvePanel.ts
Normal file
198
src/components/YieldCurvePanel.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
51
todos/030-pending-p3-macro-tiles-fragile-delta-formatter.md
Normal file
51
todos/030-pending-p3-macro-tiles-fragile-delta-formatter.md
Normal 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
|
||||
47
todos/031-pending-p3-fsi-panel-no-hydrated-data-fast-path.md
Normal file
47
todos/031-pending-p3-fsi-panel-no-hydrated-data-fast-path.md
Normal 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
|
||||
58
todos/032-pending-p3-finance-panels-rpc-client-singletons.md
Normal file
58
todos/032-pending-p3-finance-panels-rpc-client-singletons.md
Normal 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
|
||||
43
todos/033-pending-p3-allowed-series-missing-bamlc-sofr.md
Normal file
43
todos/033-pending-p3-allowed-series-missing-bamlc-sofr.md
Normal 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)
|
||||
Reference in New Issue
Block a user