feat(finance): crypto sectors heatmap + DeFi/AI/Alt token panels + expanded crypto news (#1900)

* feat(market): add crypto sectors heatmap and token panels (DeFi, AI, Other) backend

- Add shared/crypto-sectors.json, defi-tokens.json, ai-tokens.json, other-tokens.json configs
- Add scripts/seed-crypto-sectors.mjs and seed-token-panels.mjs seed scripts
- Add proto messages for ListCryptoSectors, ListDefiTokens, ListAiTokens, ListOtherTokens
- Add change7d field (field 6) to CryptoQuote proto message
- Run buf generate to produce updated TypeScript bindings
- Add server handlers for all 4 new RPCs reading from seeded Redis cache
- Wire handlers into marketHandler and register cache keys with BOOTSTRAP_TIERS=slow
- Wire seedCryptoSectors and seedTokenPanels into ais-relay.cjs seedAllMarketData loop

* feat(panels): add crypto sectors heatmap and token panels (DeFi, AI, Other)

- Add TokenData interface to src/types/index.ts
- Wire ListCryptoSectorsResponse/ListDefiTokensResponse/ListAiTokensResponse/ListOtherTokensResponse into market service with circuit breakers and hydration fallbacks
- Add CryptoHeatmapPanel, TokenListPanel, DefiTokensPanel, AiTokensPanel, OtherTokensPanel to MarketPanel.ts
- Register 4 new panels in panels.ts FINANCE_PANELS and cryptoDigital category
- Instantiate new panels in panel-layout.ts
- Load data in data-loader.ts loadMarkets() alongside existing crypto fetch

* fix(crypto-panels): resolve test failures and type errors post-review

- Add @ts-nocheck to regenerated market service_server/client (matches repo convention)
- Add 4 new RPC routes to RPC_CACHE_TIER in gateway.ts (route-cache-tier test)
- Sync scripts/shared/ with shared/ for new token/sector JSON configs
- Restore non-market generated files to origin/main state (avoid buf version diff)

* fix(crypto-panels): address code review findings (P1-P3)

- ais-relay seedTokenPanels: add empty-guard before Redis write to
  prevent overwriting cached data when all IDs are unresolvable
- server _feeds.ts: sync 4 missing crypto feeds (Wu Blockchain, Messari,
  NFT News, Stablecoin Policy) with client-side feeds.ts
- data-loader: expose panel refs outside try block so catch can call
  showRetrying(); log error instead of swallowing silently
- MarketPanel: replace hardcoded English error strings with t() calls
  (failedSectorData / failedCryptoData) to honour user locale
- seed-token-panels.mjs: remove unused getRedisCredentials import
- cache-keys.ts: one BOOTSTRAP_TIERS entry per line for consistency

* fix(crypto-panels): three correctness fixes for RSS proxy, refresh, and Redis write visibility

- api/_rss-allowed-domains.js: add 7 new crypto domains (decrypt.co,
  blockworks.co, thedefiant.io, bitcoinmagazine.com, www.dlnews.com,
  cryptoslate.com, unchainedcrypto.com) so rss-proxy.js accepts the
  new finance feeds instead of rejecting them as disallowed hosts
- src/App.ts: add crypto-heatmap/defi-tokens/ai-tokens/other-tokens to
  the periodic markets refresh viewport condition so panels on screen
  continue receiving live updates, not just the initial load
- ais-relay seedTokenPanels: capture upstashSet return values and log
  PARTIAL if any Redis write fails, matching seedCryptoSectors pattern
This commit is contained in:
Elie Habib
2026-03-20 10:34:20 +04:00
committed by GitHub
parent b9f39bb8f3
commit c0bf784d21
42 changed files with 1439 additions and 13 deletions

View File

@@ -288,5 +288,12 @@ export default [
"www.mining-technology.com",
"www.australianmining.com.au",
"news.goldseek.com",
"news.silverseek.com"
"news.silverseek.com",
"decrypt.co",
"blockworks.co",
"thedefiant.io",
"bitcoinmagazine.com",
"www.dlnews.com",
"cryptoslate.com",
"unchainedcrypto.com"
];

11
api/bootstrap.js vendored
View File

@@ -36,8 +36,12 @@ const BOOTSTRAP_CACHE_KEYS = {
flightDelays: 'aviation:delays-bootstrap:v1',
insights: 'news:insights:v1',
predictions: 'prediction:markets-bootstrap:v1',
cryptoQuotes: 'market:crypto:v1',
gulfQuotes: 'market:gulf-quotes:v1',
cryptoQuotes: 'market:crypto:v1',
cryptoSectors: 'market:crypto-sectors:v1',
defiTokens: 'market:defi-tokens:v1',
aiTokens: 'market:ai-tokens:v1',
otherTokens: 'market:other-tokens:v1',
gulfQuotes: 'market:gulf-quotes:v1',
stablecoinMarkets: 'market:stablecoins:v1',
unrestEvents: 'unrest:events:v1',
iranEvents: 'conflict:iran-events:v1',
@@ -60,7 +64,8 @@ const SLOW_KEYS = new Set([
'radiationWatch', 'thermalEscalation',
'cyberThreats', 'techReadiness', 'progressData', 'renewableEnergy',
'naturalEvents',
'cryptoQuotes', 'gulfQuotes', 'stablecoinMarkets', 'unrestEvents', 'ucdpEvents',
'cryptoQuotes', 'cryptoSectors', 'defiTokens', 'aiTokens', 'otherTokens',
'gulfQuotes', 'stablecoinMarkets', 'unrestEvents', 'ucdpEvents',
'techEvents',
'securityAdvisories',
'customsRevenue',

File diff suppressed because one or more lines are too long

View File

@@ -419,6 +419,110 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/market/v1/list-crypto-sectors:
get:
tags:
- MarketService
summary: ListCryptoSectors
description: ListCryptoSectors retrieves crypto sector performance averages.
operationId: ListCryptoSectors
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/ListCryptoSectorsResponse'
"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/list-defi-tokens:
get:
tags:
- MarketService
summary: ListDefiTokens
description: ListDefiTokens retrieves DeFi token prices and changes.
operationId: ListDefiTokens
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/ListDefiTokensResponse'
"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/list-ai-tokens:
get:
tags:
- MarketService
summary: ListAiTokens
description: ListAiTokens retrieves AI-focused crypto token prices and changes.
operationId: ListAiTokens
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/ListAiTokensResponse'
"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/list-other-tokens:
get:
tags:
- MarketService
summary: ListOtherTokens
description: ListOtherTokens retrieves other/trending crypto token prices and changes.
operationId: ListOtherTokens
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/ListOtherTokensResponse'
"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:
@@ -549,6 +653,10 @@ components:
type: number
format: double
description: Sparkline data points (recent price history).
change7d:
type: number
format: double
description: 7-day percentage change.
required:
- symbol
description: CryptoQuote represents a cryptocurrency quote from CoinGecko.
@@ -1188,3 +1296,61 @@ components:
type: array
items:
$ref: '#/components/schemas/BacktestStockResponse'
ListCryptoSectorsRequest:
type: object
description: ListCryptoSectorsRequest retrieves crypto sector performance.
ListCryptoSectorsResponse:
type: object
properties:
sectors:
type: array
items:
$ref: '#/components/schemas/CryptoSector'
description: ListCryptoSectorsResponse contains crypto sector performance data.
CryptoSector:
type: object
properties:
id:
type: string
description: Sector identifier.
name:
type: string
description: Sector display name.
change:
type: number
format: double
description: Average 24h percentage change across sector tokens.
description: CryptoSector represents performance data for a crypto market sector.
ListDefiTokensRequest:
type: object
description: ListDefiTokensRequest retrieves DeFi token prices.
ListDefiTokensResponse:
type: object
properties:
tokens:
type: array
items:
$ref: '#/components/schemas/CryptoQuote'
description: ListDefiTokensResponse contains DeFi token price data.
ListAiTokensRequest:
type: object
description: ListAiTokensRequest retrieves AI crypto token prices.
ListAiTokensResponse:
type: object
properties:
tokens:
type: array
items:
$ref: '#/components/schemas/CryptoQuote'
description: ListAiTokensResponse contains AI token price data.
ListOtherTokensRequest:
type: object
description: ListOtherTokensRequest retrieves other/trending crypto token prices.
ListOtherTokensResponse:
type: object
properties:
tokens:
type: array
items:
$ref: '#/components/schemas/CryptoQuote'
description: ListOtherTokensResponse contains other token price data.

View File

@@ -0,0 +1,16 @@
syntax = "proto3";
package worldmonitor.market.v1;
import "sebuf/http/annotations.proto";
import "worldmonitor/market/v1/market_quote.proto";
// ListAiTokensRequest retrieves AI crypto token prices.
message ListAiTokensRequest {
}
// ListAiTokensResponse contains AI token price data.
message ListAiTokensResponse {
// The list of AI token quotes.
repeated CryptoQuote tokens = 1;
}

View File

@@ -0,0 +1,25 @@
syntax = "proto3";
package worldmonitor.market.v1;
import "sebuf/http/annotations.proto";
// CryptoSector represents performance data for a crypto market sector.
message CryptoSector {
// Sector identifier.
string id = 1;
// Sector display name.
string name = 2;
// Average 24h percentage change across sector tokens.
double change = 3;
}
// ListCryptoSectorsRequest retrieves crypto sector performance.
message ListCryptoSectorsRequest {
}
// ListCryptoSectorsResponse contains crypto sector performance data.
message ListCryptoSectorsResponse {
// The list of crypto sectors with their performance.
repeated CryptoSector sectors = 1;
}

View File

@@ -0,0 +1,16 @@
syntax = "proto3";
package worldmonitor.market.v1;
import "sebuf/http/annotations.proto";
import "worldmonitor/market/v1/market_quote.proto";
// ListDefiTokensRequest retrieves DeFi token prices.
message ListDefiTokensRequest {
}
// ListDefiTokensResponse contains DeFi token price data.
message ListDefiTokensResponse {
// The list of DeFi token quotes.
repeated CryptoQuote tokens = 1;
}

View File

@@ -0,0 +1,16 @@
syntax = "proto3";
package worldmonitor.market.v1;
import "sebuf/http/annotations.proto";
import "worldmonitor/market/v1/market_quote.proto";
// ListOtherTokensRequest retrieves other/trending crypto token prices.
message ListOtherTokensRequest {
}
// ListOtherTokensResponse contains other token price data.
message ListOtherTokensResponse {
// The list of other token quotes.
repeated CryptoQuote tokens = 1;
}

View File

@@ -38,6 +38,8 @@ message CryptoQuote {
double change = 4;
// Sparkline data points (recent price history).
repeated double sparkline = 5;
// 7-day percentage change.
double change7d = 6;
}
// CommodityQuote represents a commodity price quote from Yahoo Finance.

View File

@@ -15,6 +15,10 @@ import "worldmonitor/market/v1/analyze_stock.proto";
import "worldmonitor/market/v1/backtest_stock.proto";
import "worldmonitor/market/v1/get_stock_analysis_history.proto";
import "worldmonitor/market/v1/list_stored_stock_backtests.proto";
import "worldmonitor/market/v1/list_crypto_sectors.proto";
import "worldmonitor/market/v1/list_defi_tokens.proto";
import "worldmonitor/market/v1/list_ai_tokens.proto";
import "worldmonitor/market/v1/list_other_tokens.proto";
// MarketService provides APIs for financial market data from Finnhub, Yahoo Finance, and CoinGecko.
service MarketService {
@@ -79,4 +83,24 @@ service MarketService {
rpc ListStoredStockBacktests(ListStoredStockBacktestsRequest) returns (ListStoredStockBacktestsResponse) {
option (sebuf.http.config) = {path: "/list-stored-stock-backtests", method: HTTP_METHOD_GET};
}
// ListCryptoSectors retrieves crypto sector performance averages.
rpc ListCryptoSectors(ListCryptoSectorsRequest) returns (ListCryptoSectorsResponse) {
option (sebuf.http.config) = {path: "/list-crypto-sectors", method: HTTP_METHOD_GET};
}
// ListDefiTokens retrieves DeFi token prices and changes.
rpc ListDefiTokens(ListDefiTokensRequest) returns (ListDefiTokensResponse) {
option (sebuf.http.config) = {path: "/list-defi-tokens", method: HTTP_METHOD_GET};
}
// ListAiTokens retrieves AI-focused crypto token prices and changes.
rpc ListAiTokens(ListAiTokensRequest) returns (ListAiTokensResponse) {
option (sebuf.http.config) = {path: "/list-ai-tokens", method: HTTP_METHOD_GET};
}
// ListOtherTokens retrieves other/trending crypto token prices and changes.
rpc ListOtherTokens(ListOtherTokensRequest) returns (ListOtherTokensResponse) {
option (sebuf.http.config) = {path: "/list-other-tokens", method: HTTP_METHOD_GET};
}
}

View File

@@ -1659,6 +1659,94 @@ async function seedStablecoinMarkets() {
return stablecoins.length;
}
// Crypto Sectors Heatmap — CoinGecko sector averages
const _sectorsCfg = requireShared('crypto-sectors.json');
const SECTORS_LIST = _sectorsCfg.sectors;
const SECTORS_SEED_TTL = 3600; // 1h
async function seedCryptoSectors() {
const allIds = [...new Set(SECTORS_LIST.flatMap((s) => s.tokens))];
let data;
try {
const apiKey = process.env.COINGECKO_API_KEY;
const base = apiKey ? 'https://pro-api.coingecko.com/api/v3' : 'https://api.coingecko.com/api/v3';
const headers = { Accept: 'application/json' };
if (apiKey) headers['x-cg-pro-api-key'] = apiKey;
const url = `${base}/coins/markets?vs_currency=usd&ids=${allIds.join(',')}&order=market_cap_desc&sparkline=false&price_change_percentage=24h`;
data = await cyberHttpGetJson(url, headers, 15000);
if (!Array.isArray(data) || data.length === 0) throw new Error('CoinGecko returned no data');
} catch (err) {
console.warn(`[CryptoSectors] CoinGecko failed: ${err.message} — skipping`);
return 0;
}
const byId = new Map(data.map((c) => [c.id, c.price_change_percentage_24h]));
const sectors = SECTORS_LIST.map((sector) => {
const changes = sector.tokens.map((id) => byId.get(id)).filter((v) => typeof v === 'number' && isFinite(v));
const change = changes.length > 0 ? changes.reduce((a, b) => a + b, 0) / changes.length : 0;
return { id: sector.id, name: sector.name, change };
});
const ok1 = await upstashSet('market:crypto-sectors:v1', { sectors }, SECTORS_SEED_TTL);
const ok2 = await upstashSet('seed-meta:market:crypto-sectors', { fetchedAt: Date.now(), recordCount: sectors.length }, 604800);
console.log(`[CryptoSectors] Seeded ${sectors.length} sectors (redis: ${ok1 && ok2 ? 'OK' : 'PARTIAL'})`);
return sectors.length;
}
// Token Panels — DeFi, AI, Other — single CoinGecko call writing 3 Redis keys
const _defiCfg = requireShared('defi-tokens.json');
const _aiCfg = requireShared('ai-tokens.json');
const _otherCfg = requireShared('other-tokens.json');
const TOKEN_PANELS_SEED_TTL = 3600; // 1h
function _mapTokens(ids, meta, byId) {
const tokens = [];
for (const id of ids) {
const coin = byId.get(id);
if (!coin) continue;
const m = meta[id];
tokens.push({
name: m?.name || coin.name || id,
symbol: m?.symbol || (coin.symbol || id).toUpperCase(),
price: coin.current_price ?? 0,
change24h: coin.price_change_percentage_24h ?? 0,
change7d: coin.price_change_percentage_7d_in_currency ?? 0,
});
}
return tokens;
}
async function seedTokenPanels() {
const allIds = [...new Set([..._defiCfg.ids, ..._aiCfg.ids, ..._otherCfg.ids])];
let data;
try {
const apiKey = process.env.COINGECKO_API_KEY;
const base = apiKey ? 'https://pro-api.coingecko.com/api/v3' : 'https://api.coingecko.com/api/v3';
const headers = { Accept: 'application/json' };
if (apiKey) headers['x-cg-pro-api-key'] = apiKey;
const url = `${base}/coins/markets?vs_currency=usd&ids=${allIds.join(',')}&order=market_cap_desc&sparkline=false&price_change_percentage=24h,7d`;
data = await cyberHttpGetJson(url, headers, 15000);
if (!Array.isArray(data) || data.length === 0) throw new Error('CoinGecko returned no data');
} catch (err) {
console.warn(`[TokenPanels] CoinGecko failed: ${err.message} — skipping`);
return 0;
}
const byId = new Map(data.map((c) => [c.id, c]));
const defi = { tokens: _mapTokens(_defiCfg.ids, _defiCfg.meta, byId) };
const ai = { tokens: _mapTokens(_aiCfg.ids, _aiCfg.meta, byId) };
const other = { tokens: _mapTokens(_otherCfg.ids, _otherCfg.meta, byId) };
if (defi.tokens.length === 0 && ai.tokens.length === 0 && other.tokens.length === 0) {
console.warn('[TokenPanels] All panels empty after mapping — skipping Redis write to preserve cached data');
return 0;
}
const ok1 = await upstashSet('market:defi-tokens:v1', defi, TOKEN_PANELS_SEED_TTL);
const ok2 = await upstashSet('market:ai-tokens:v1', ai, TOKEN_PANELS_SEED_TTL);
const ok3 = await upstashSet('market:other-tokens:v1', other, TOKEN_PANELS_SEED_TTL);
await upstashSet('seed-meta:market:token-panels', { fetchedAt: Date.now(), recordCount: defi.tokens.length + ai.tokens.length + other.tokens.length }, 604800);
const total = defi.tokens.length + ai.tokens.length + other.tokens.length;
const allOk = ok1 && ok2 && ok3;
console.log(`[TokenPanels] Seeded ${defi.tokens.length} DeFi, ${ai.tokens.length} AI, ${other.tokens.length} Other (${total} total, redis: ${allOk ? 'OK' : 'PARTIAL'})`);
return total;
}
async function seedAllMarketData() {
const t0 = Date.now();
const q = await seedMarketQuotes();
@@ -1668,7 +1756,9 @@ async function seedAllMarketData() {
const e = await seedEtfFlows();
const cr = await seedCryptoQuotes();
const sc = await seedStablecoinMarkets();
console.log(`[Market] Seed complete: ${q} quotes, ${c} commodities, ${s} sectors, ${g} gulf, ${e} etf, ${cr} crypto, ${sc} stablecoins (${((Date.now() - t0) / 1000).toFixed(1)}s)`);
const cs = await seedCryptoSectors();
const tp = await seedTokenPanels();
console.log(`[Market] Seed complete: ${q} quotes, ${c} commodities, ${s} sectors, ${g} gulf, ${e} etf, ${cr} crypto, ${sc} stablecoins, ${cs} crypto-sectors, ${tp} token-panels (${((Date.now() - t0) / 1000).toFixed(1)}s)`);
}
async function startMarketDataSeedLoop() {

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env node
import { loadEnvFile, loadSharedConfig, CHROME_UA, runSeed, sleep } from './_seed-utils.mjs';
const sectorsConfig = loadSharedConfig('crypto-sectors.json');
loadEnvFile(import.meta.url);
const CANONICAL_KEY = 'market:crypto-sectors:v1';
const CACHE_TTL = 3600;
const SECTORS = sectorsConfig.sectors;
async function fetchWithRateLimitRetry(url, maxAttempts = 5, headers = { Accept: 'application/json', 'User-Agent': CHROME_UA }) {
for (let i = 0; i < maxAttempts; i++) {
const resp = await fetch(url, { headers, signal: AbortSignal.timeout(15_000) });
if (resp.status === 429) {
const wait = Math.min(10_000 * (i + 1), 60_000);
console.warn(` CoinGecko 429 — waiting ${wait / 1000}s (attempt ${i + 1}/${maxAttempts})`);
await sleep(wait);
continue;
}
if (!resp.ok) throw new Error(`CoinGecko HTTP ${resp.status}`);
return resp;
}
throw new Error('CoinGecko rate limit exceeded after retries');
}
async function fetchSectorData() {
const allIds = [...new Set(SECTORS.flatMap(s => s.tokens))];
const apiKey = process.env.COINGECKO_API_KEY;
const baseUrl = apiKey ? 'https://pro-api.coingecko.com/api/v3' : 'https://api.coingecko.com/api/v3';
const url = `${baseUrl}/coins/markets?vs_currency=usd&ids=${allIds.join(',')}&order=market_cap_desc&sparkline=false&price_change_percentage=24h`;
const headers = { Accept: 'application/json', 'User-Agent': CHROME_UA };
if (apiKey) headers['x-cg-pro-api-key'] = apiKey;
const resp = await fetchWithRateLimitRetry(url, 5, headers);
const data = await resp.json();
if (!Array.isArray(data) || data.length === 0) throw new Error('CoinGecko returned no data');
const byId = new Map(data.map(c => [c.id, c.price_change_percentage_24h]));
const sectors = SECTORS.map(sector => {
const changes = sector.tokens
.map(id => byId.get(id))
.filter(v => typeof v === 'number' && isFinite(v));
const change = changes.length > 0 ? changes.reduce((a, b) => a + b, 0) / changes.length : 0;
return { id: sector.id, name: sector.name, change };
});
return { sectors };
}
function validate(data) {
return Array.isArray(data?.sectors) && data.sectors.length > 0;
}
runSeed('market', 'crypto-sectors', CANONICAL_KEY, fetchSectorData, {
validateFn: validate,
ttlSeconds: CACHE_TTL,
sourceVersion: 'coingecko-sectors',
}).catch(err => {
const _cause = err.cause ? ` (cause: ${err.cause.message || err.cause.code || err.cause})` : '';
console.error('FATAL:', (err.message || err) + _cause);
process.exit(1);
});

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env node
import { loadEnvFile, loadSharedConfig, CHROME_UA, writeExtraKey, sleep } from './_seed-utils.mjs';
const defiConfig = loadSharedConfig('defi-tokens.json');
const aiConfig = loadSharedConfig('ai-tokens.json');
const otherConfig = loadSharedConfig('other-tokens.json');
loadEnvFile(import.meta.url);
const DEFI_KEY = 'market:defi-tokens:v1';
const AI_KEY = 'market:ai-tokens:v1';
const OTHER_KEY = 'market:other-tokens:v1';
const CACHE_TTL = 3600;
async function fetchWithRateLimitRetry(url, maxAttempts = 5, headers = { Accept: 'application/json', 'User-Agent': CHROME_UA }) {
for (let i = 0; i < maxAttempts; i++) {
const resp = await fetch(url, { headers, signal: AbortSignal.timeout(15_000) });
if (resp.status === 429) {
const wait = Math.min(10_000 * (i + 1), 60_000);
console.warn(` CoinGecko 429 — waiting ${wait / 1000}s (attempt ${i + 1}/${maxAttempts})`);
await sleep(wait);
continue;
}
if (!resp.ok) throw new Error(`CoinGecko HTTP ${resp.status}`);
return resp;
}
throw new Error('CoinGecko rate limit exceeded after retries');
}
function mapTokens(ids, meta, byId) {
const tokens = [];
for (const id of ids) {
const coin = byId.get(id);
if (!coin) continue;
const m = meta[id];
tokens.push({
name: m?.name || coin.name || id,
symbol: m?.symbol || (coin.symbol || id).toUpperCase(),
price: coin.current_price ?? 0,
change24h: coin.price_change_percentage_24h ?? 0,
change7d: coin.price_change_percentage_7d_in_currency ?? 0,
});
}
return tokens;
}
async function main() {
const allIds = [...new Set([...defiConfig.ids, ...aiConfig.ids, ...otherConfig.ids])];
const apiKey = process.env.COINGECKO_API_KEY;
const baseUrl = apiKey ? 'https://pro-api.coingecko.com/api/v3' : 'https://api.coingecko.com/api/v3';
const url = `${baseUrl}/coins/markets?vs_currency=usd&ids=${allIds.join(',')}&order=market_cap_desc&sparkline=false&price_change_percentage=24h,7d`;
const headers = { Accept: 'application/json', 'User-Agent': CHROME_UA };
if (apiKey) headers['x-cg-pro-api-key'] = apiKey;
console.log('=== market:token-panels Seed ===');
console.log(` Keys: ${DEFI_KEY}, ${AI_KEY}, ${OTHER_KEY}`);
const resp = await fetchWithRateLimitRetry(url, 5, headers);
const data = await resp.json();
if (!Array.isArray(data) || data.length === 0) throw new Error('CoinGecko returned no data');
const byId = new Map(data.map(c => [c.id, c]));
const defi = { tokens: mapTokens(defiConfig.ids, defiConfig.meta, byId) };
const ai = { tokens: mapTokens(aiConfig.ids, aiConfig.meta, byId) };
const other = { tokens: mapTokens(otherConfig.ids, otherConfig.meta, byId) };
if (defi.tokens.length === 0 && ai.tokens.length === 0 && other.tokens.length === 0) {
throw new Error('All token panels returned empty — refusing to overwrite cache');
}
await writeExtraKey(DEFI_KEY, defi, CACHE_TTL);
await writeExtraKey(AI_KEY, ai, CACHE_TTL);
await writeExtraKey(OTHER_KEY, other, CACHE_TTL);
const total = defi.tokens.length + ai.tokens.length + other.tokens.length;
console.log(` Seeded: ${defi.tokens.length} DeFi, ${ai.tokens.length} AI, ${other.tokens.length} Other (${total} total)`);
console.log('\n=== Done ===');
}
main().catch(err => {
const _cause = err.cause ? ` (cause: ${err.cause.message || err.cause.code || err.cause})` : '';
console.error('FATAL:', (err.message || err) + _cause);
process.exit(1);
});

View File

@@ -0,0 +1,22 @@
{
"ids": ["bittensor","render-token","fetch-ai","akash-network","ocean-protocol","singularitynet","grass","virtual-protocol","ai16z","griffain"],
"meta": {
"bittensor": { "name": "Bittensor", "symbol": "TAO" },
"render-token": { "name": "Render", "symbol": "RENDER" },
"fetch-ai": { "name": "Fetch.ai", "symbol": "FET" },
"akash-network": { "name": "Akash Network", "symbol": "AKT" },
"ocean-protocol": { "name": "Ocean Protocol", "symbol": "OCEAN" },
"singularitynet": { "name": "SingularityNET", "symbol": "AGIX" },
"grass": { "name": "Grass", "symbol": "GRASS" },
"virtual-protocol": { "name": "Virtuals Protocol", "symbol": "VIRTUAL" },
"ai16z": { "name": "ai16z", "symbol": "AI16Z" },
"griffain": { "name": "Griffain", "symbol": "GRIFFAIN" }
},
"coinpaprika": {
"bittensor": "tao-bittensor",
"fetch-ai": "fet-fetch-ai",
"akash-network": "akt-akash-network",
"ocean-protocol": "ocean-ocean-protocol",
"singularitynet": "agix-singularitynet"
}
}

View File

@@ -0,0 +1,12 @@
{
"sectors": [
{ "id": "layer-1", "name": "Layer 1", "tokens": ["bitcoin","ethereum","solana","avalanche-2","cardano"] },
{ "id": "defi", "name": "DeFi", "tokens": ["aave","uniswap","jupiter-exchange-solana","pendle","maker"] },
{ "id": "layer-2", "name": "Layer 2", "tokens": ["matic-network","arbitrum","optimism","starknet","mantle"] },
{ "id": "ai", "name": "AI", "tokens": ["bittensor","render-token","fetch-ai","ocean-protocol","akash-network"] },
{ "id": "memes", "name": "Memes", "tokens": ["dogecoin","shiba-inu","pepe","bonk","floki"] },
{ "id": "gaming", "name": "Gaming", "tokens": ["immutable-x","gala","the-sandbox","axie-infinity","illuvium"] },
{ "id": "privacy", "name": "Privacy", "tokens": ["monero","zcash","secret","oasis-network","iron-fish"] },
{ "id": "infra", "name": "Infra", "tokens": ["chainlink","the-graph","filecoin","arweave","helium"] }
]
}

View File

@@ -0,0 +1,24 @@
{
"ids": ["aave","uniswap","jupiter-exchange-solana","pendle","maker","lido-dao","hyperliquid","raydium","aerodrome-finance","curve-dao-token"],
"meta": {
"aave": { "name": "Aave", "symbol": "AAVE" },
"uniswap": { "name": "Uniswap", "symbol": "UNI" },
"jupiter-exchange-solana": { "name": "Jupiter", "symbol": "JUP" },
"pendle": { "name": "Pendle", "symbol": "PENDLE" },
"maker": { "name": "Maker", "symbol": "MKR" },
"lido-dao": { "name": "Lido DAO", "symbol": "LDO" },
"hyperliquid": { "name": "Hyperliquid", "symbol": "HYPE" },
"raydium": { "name": "Raydium", "symbol": "RAY" },
"aerodrome-finance": { "name": "Aerodrome", "symbol": "AERO" },
"curve-dao-token": { "name": "Curve DAO", "symbol": "CRV" }
},
"coinpaprika": {
"aave": "aave-aave",
"uniswap": "uni-uniswap",
"pendle": "pendle-pendle",
"maker": "mkr-maker",
"lido-dao": "ldo-lido-dao",
"raydium": "ray-raydium",
"curve-dao-token": "crv-curve-dao-token"
}
}

View File

@@ -0,0 +1,21 @@
{
"ids": ["aptos","sui","sei-network","injective-protocol","celestia","pyth-network","jito-governance-token","movement","wormhole","ondo-finance"],
"meta": {
"aptos": { "name": "Aptos", "symbol": "APT" },
"sui": { "name": "Sui", "symbol": "SUI" },
"sei-network": { "name": "Sei", "symbol": "SEI" },
"injective-protocol": { "name": "Injective", "symbol": "INJ" },
"celestia": { "name": "Celestia", "symbol": "TIA" },
"pyth-network": { "name": "Pyth Network", "symbol": "PYTH" },
"jito-governance-token": { "name": "Jito", "symbol": "JTO" },
"movement": { "name": "Movement", "symbol": "MOVE" },
"wormhole": { "name": "Wormhole", "symbol": "W" },
"ondo-finance": { "name": "Ondo Finance", "symbol": "ONDO" }
},
"coinpaprika": {
"aptos": "apt-aptos",
"sui": "sui-sui",
"injective-protocol": "inj-injective-protocol",
"celestia": "tia-celestia"
}
}

View File

@@ -285,5 +285,12 @@
"www.mining-technology.com",
"www.australianmining.com.au",
"news.goldseek.com",
"news.silverseek.com"
"news.silverseek.com",
"decrypt.co",
"blockworks.co",
"thedefiant.io",
"bitcoinmagazine.com",
"www.dlnews.com",
"cryptoslate.com",
"unchainedcrypto.com"
]

View File

@@ -50,6 +50,10 @@ export const BOOTSTRAP_CACHE_KEYS: Record<string, string> = {
forecasts: 'forecast:predictions:v2',
customsRevenue: 'trade:customs-revenue:v1',
sanctionsPressure: 'sanctions:pressure:v1',
cryptoSectors: 'market:crypto-sectors:v1',
defiTokens: 'market:defi-tokens:v1',
aiTokens: 'market:ai-tokens:v1',
otherTokens: 'market:other-tokens:v1',
};
export const BOOTSTRAP_TIERS: Record<string, 'slow' | 'fast'> = {
@@ -70,4 +74,8 @@ export const BOOTSTRAP_TIERS: Record<string, 'slow' | 'fast'> = {
securityAdvisories: 'slow',
forecasts: 'fast',
customsRevenue: 'slow',
cryptoSectors: 'slow',
defiTokens: 'slow',
aiTokens: 'slow',
otherTokens: 'slow',
};

View File

@@ -58,6 +58,10 @@ const RPC_CACHE_TIER: Record<string, CacheTier> = {
'/api/market/v1/list-market-quotes': 'medium',
'/api/market/v1/list-crypto-quotes': 'medium',
'/api/market/v1/list-crypto-sectors': 'slow',
'/api/market/v1/list-defi-tokens': 'slow',
'/api/market/v1/list-ai-tokens': 'slow',
'/api/market/v1/list-other-tokens': 'slow',
'/api/market/v1/list-commodity-quotes': 'medium',
'/api/market/v1/list-stablecoin-markets': 'medium',
'/api/market/v1/get-sector-summary': 'medium',

View File

@@ -25,6 +25,10 @@ import { analyzeStock } from './analyze-stock';
import { getStockAnalysisHistory } from './get-stock-analysis-history';
import { backtestStock } from './backtest-stock';
import { listStoredStockBacktests } from './list-stored-stock-backtests';
import { listCryptoSectors } from './list-crypto-sectors';
import { listDefiTokens } from './list-defi-tokens';
import { listAiTokens } from './list-ai-tokens';
import { listOtherTokens } from './list-other-tokens';
export const marketHandler: MarketServiceHandler = {
listMarketQuotes,
@@ -39,4 +43,8 @@ export const marketHandler: MarketServiceHandler = {
getStockAnalysisHistory,
backtestStock,
listStoredStockBacktests,
listCryptoSectors,
listDefiTokens,
listAiTokens,
listOtherTokens,
};

View File

@@ -0,0 +1,36 @@
/**
* RPC: ListAiTokens -- reads seeded AI token data from Railway seed cache.
*/
import type {
ServerContext,
ListAiTokensRequest,
ListAiTokensResponse,
CryptoQuote,
} from '../../../../src/generated/server/worldmonitor/market/v1/service_server';
import { getCachedJson } from '../../../_shared/redis';
const SEED_CACHE_KEY = 'market:ai-tokens:v1';
type TokenSeedEntry = { name: string; symbol: string; price: number; change24h: number; change7d: number };
export async function listAiTokens(
_ctx: ServerContext,
_req: ListAiTokensRequest,
): Promise<ListAiTokensResponse> {
try {
const seedData = await getCachedJson(SEED_CACHE_KEY, true) as { tokens: TokenSeedEntry[] } | null;
if (!seedData?.tokens?.length) return { tokens: [] };
const tokens: CryptoQuote[] = seedData.tokens.map(t => ({
name: t.name,
symbol: t.symbol,
price: t.price,
change: t.change24h,
change7d: t.change7d,
sparkline: [],
}));
return { tokens };
} catch {
return { tokens: [] };
}
}

View File

@@ -0,0 +1,25 @@
/**
* RPC: ListCryptoSectors -- reads seeded crypto sector data from Railway seed cache.
*/
import type {
ServerContext,
ListCryptoSectorsRequest,
ListCryptoSectorsResponse,
} from '../../../../src/generated/server/worldmonitor/market/v1/service_server';
import { getCachedJson } from '../../../_shared/redis';
const SEED_CACHE_KEY = 'market:crypto-sectors:v1';
export async function listCryptoSectors(
_ctx: ServerContext,
_req: ListCryptoSectorsRequest,
): Promise<ListCryptoSectorsResponse> {
try {
const seedData = await getCachedJson(SEED_CACHE_KEY, true) as { sectors: Array<{ id: string; name: string; change: number }> } | null;
if (!seedData?.sectors?.length) return { sectors: [] };
return { sectors: seedData.sectors };
} catch {
return { sectors: [] };
}
}

View File

@@ -0,0 +1,36 @@
/**
* RPC: ListDefiTokens -- reads seeded DeFi token data from Railway seed cache.
*/
import type {
ServerContext,
ListDefiTokensRequest,
ListDefiTokensResponse,
CryptoQuote,
} from '../../../../src/generated/server/worldmonitor/market/v1/service_server';
import { getCachedJson } from '../../../_shared/redis';
const SEED_CACHE_KEY = 'market:defi-tokens:v1';
type TokenSeedEntry = { name: string; symbol: string; price: number; change24h: number; change7d: number };
export async function listDefiTokens(
_ctx: ServerContext,
_req: ListDefiTokensRequest,
): Promise<ListDefiTokensResponse> {
try {
const seedData = await getCachedJson(SEED_CACHE_KEY, true) as { tokens: TokenSeedEntry[] } | null;
if (!seedData?.tokens?.length) return { tokens: [] };
const tokens: CryptoQuote[] = seedData.tokens.map(t => ({
name: t.name,
symbol: t.symbol,
price: t.price,
change: t.change24h,
change7d: t.change7d,
sparkline: [],
}));
return { tokens };
} catch {
return { tokens: [] };
}
}

View File

@@ -0,0 +1,36 @@
/**
* RPC: ListOtherTokens -- reads seeded other/trending token data from Railway seed cache.
*/
import type {
ServerContext,
ListOtherTokensRequest,
ListOtherTokensResponse,
CryptoQuote,
} from '../../../../src/generated/server/worldmonitor/market/v1/service_server';
import { getCachedJson } from '../../../_shared/redis';
const SEED_CACHE_KEY = 'market:other-tokens:v1';
type TokenSeedEntry = { name: string; symbol: string; price: number; change24h: number; change7d: number };
export async function listOtherTokens(
_ctx: ServerContext,
_req: ListOtherTokensRequest,
): Promise<ListOtherTokensResponse> {
try {
const seedData = await getCachedJson(SEED_CACHE_KEY, true) as { tokens: TokenSeedEntry[] } | null;
if (!seedData?.tokens?.length) return { tokens: [] };
const tokens: CryptoQuote[] = seedData.tokens.map(t => ({
name: t.name,
symbol: t.symbol,
price: t.price,
change: t.change24h,
change7d: t.change7d,
sparkline: [],
}));
return { tokens };
} catch {
return { tokens: [] };
}
}

View File

@@ -239,6 +239,21 @@ export const VARIANT_FEEDS: Record<string, Record<string, ServerFeed[]>> = {
crypto: [
{ name: 'CoinDesk', url: 'https://www.coindesk.com/arc/outboundfeeds/rss/' },
{ name: 'Cointelegraph', url: 'https://cointelegraph.com/rss' },
{ name: 'The Block', url: 'https://news.google.com/rss/search?q=site:theblock.co+when:1d&hl=en-US&gl=US&ceid=US:en' },
{ name: 'Decrypt', url: 'https://decrypt.co/feed' },
{ name: 'Blockworks', url: 'https://blockworks.co/feed' },
{ name: 'The Defiant', url: 'https://thedefiant.io/feed' },
{ name: 'Bitcoin Magazine', url: 'https://bitcoinmagazine.com/feed' },
{ name: 'DL News', url: 'https://www.dlnews.com/feed/' },
{ name: 'CryptoSlate', url: 'https://cryptoslate.com/feed/' },
{ name: 'Unchained', url: 'https://unchainedcrypto.com/feed/' },
{ name: 'DeFi News', url: 'https://news.google.com/rss/search?q=(DeFi+OR+"decentralized+finance")+when:3d&hl=en-US&gl=US&ceid=US:en' },
{ name: 'Bloomberg Crypto', url: 'https://news.google.com/rss/search?q=bloomberg+crypto+when:1d&hl=en-US&gl=US&ceid=US:en' },
{ name: 'Reuters Crypto', url: 'https://news.google.com/rss/search?q=reuters+crypto+when:1d&hl=en-US&gl=US&ceid=US:en' },
{ name: 'Wu Blockchain', url: 'https://news.google.com/rss/search?q=site:wublockchain.com+when:7d&hl=en-US&gl=US&ceid=US:en' },
{ name: 'Messari', url: 'https://news.google.com/rss/search?q=site:messari.io+when:3d&hl=en-US&gl=US&ceid=US:en' },
{ name: 'NFT News', url: 'https://news.google.com/rss/search?q=(NFT+OR+"non-fungible")+when:3d&hl=en-US&gl=US&ceid=US:en' },
{ name: 'Stablecoin Policy', url: 'https://news.google.com/rss/search?q=(stablecoin+regulation+OR+"stablecoin+bill")+when:7d&hl=en-US&gl=US&ceid=US:en' },
],
centralbanks: [
{ name: 'Federal Reserve', url: 'https://www.federalreserve.gov/feeds/press_all.xml' },

22
shared/ai-tokens.json Normal file
View File

@@ -0,0 +1,22 @@
{
"ids": ["bittensor","render-token","fetch-ai","akash-network","ocean-protocol","singularitynet","grass","virtual-protocol","ai16z","griffain"],
"meta": {
"bittensor": { "name": "Bittensor", "symbol": "TAO" },
"render-token": { "name": "Render", "symbol": "RENDER" },
"fetch-ai": { "name": "Fetch.ai", "symbol": "FET" },
"akash-network": { "name": "Akash Network", "symbol": "AKT" },
"ocean-protocol": { "name": "Ocean Protocol", "symbol": "OCEAN" },
"singularitynet": { "name": "SingularityNET", "symbol": "AGIX" },
"grass": { "name": "Grass", "symbol": "GRASS" },
"virtual-protocol": { "name": "Virtuals Protocol", "symbol": "VIRTUAL" },
"ai16z": { "name": "ai16z", "symbol": "AI16Z" },
"griffain": { "name": "Griffain", "symbol": "GRIFFAIN" }
},
"coinpaprika": {
"bittensor": "tao-bittensor",
"fetch-ai": "fet-fetch-ai",
"akash-network": "akt-akash-network",
"ocean-protocol": "ocean-ocean-protocol",
"singularitynet": "agix-singularitynet"
}
}

View File

@@ -0,0 +1,12 @@
{
"sectors": [
{ "id": "layer-1", "name": "Layer 1", "tokens": ["bitcoin","ethereum","solana","avalanche-2","cardano"] },
{ "id": "defi", "name": "DeFi", "tokens": ["aave","uniswap","jupiter-exchange-solana","pendle","maker"] },
{ "id": "layer-2", "name": "Layer 2", "tokens": ["matic-network","arbitrum","optimism","starknet","mantle"] },
{ "id": "ai", "name": "AI", "tokens": ["bittensor","render-token","fetch-ai","ocean-protocol","akash-network"] },
{ "id": "memes", "name": "Memes", "tokens": ["dogecoin","shiba-inu","pepe","bonk","floki"] },
{ "id": "gaming", "name": "Gaming", "tokens": ["immutable-x","gala","the-sandbox","axie-infinity","illuvium"] },
{ "id": "privacy", "name": "Privacy", "tokens": ["monero","zcash","secret","oasis-network","iron-fish"] },
{ "id": "infra", "name": "Infra", "tokens": ["chainlink","the-graph","filecoin","arweave","helium"] }
]
}

24
shared/defi-tokens.json Normal file
View File

@@ -0,0 +1,24 @@
{
"ids": ["aave","uniswap","jupiter-exchange-solana","pendle","maker","lido-dao","hyperliquid","raydium","aerodrome-finance","curve-dao-token"],
"meta": {
"aave": { "name": "Aave", "symbol": "AAVE" },
"uniswap": { "name": "Uniswap", "symbol": "UNI" },
"jupiter-exchange-solana": { "name": "Jupiter", "symbol": "JUP" },
"pendle": { "name": "Pendle", "symbol": "PENDLE" },
"maker": { "name": "Maker", "symbol": "MKR" },
"lido-dao": { "name": "Lido DAO", "symbol": "LDO" },
"hyperliquid": { "name": "Hyperliquid", "symbol": "HYPE" },
"raydium": { "name": "Raydium", "symbol": "RAY" },
"aerodrome-finance": { "name": "Aerodrome", "symbol": "AERO" },
"curve-dao-token": { "name": "Curve DAO", "symbol": "CRV" }
},
"coinpaprika": {
"aave": "aave-aave",
"uniswap": "uni-uniswap",
"pendle": "pendle-pendle",
"maker": "mkr-maker",
"lido-dao": "ldo-lido-dao",
"raydium": "ray-raydium",
"curve-dao-token": "crv-curve-dao-token"
}
}

21
shared/other-tokens.json Normal file
View File

@@ -0,0 +1,21 @@
{
"ids": ["aptos","sui","sei-network","injective-protocol","celestia","pyth-network","jito-governance-token","movement","wormhole","ondo-finance"],
"meta": {
"aptos": { "name": "Aptos", "symbol": "APT" },
"sui": { "name": "Sui", "symbol": "SUI" },
"sei-network": { "name": "Sei", "symbol": "SEI" },
"injective-protocol": { "name": "Injective", "symbol": "INJ" },
"celestia": { "name": "Celestia", "symbol": "TIA" },
"pyth-network": { "name": "Pyth Network", "symbol": "PYTH" },
"jito-governance-token": { "name": "Jito", "symbol": "JTO" },
"movement": { "name": "Movement", "symbol": "MOVE" },
"wormhole": { "name": "Wormhole", "symbol": "W" },
"ondo-finance": { "name": "Ondo Finance", "symbol": "ONDO" }
},
"coinpaprika": {
"aptos": "apt-aptos",
"sui": "sui-sui",
"injective-protocol": "inj-injective-protocol",
"celestia": "tia-celestia"
}
}

View File

@@ -285,5 +285,12 @@
"www.mining-technology.com",
"www.australianmining.com.au",
"news.goldseek.com",
"news.silverseek.com"
"news.silverseek.com",
"decrypt.co",
"blockworks.co",
"thedefiant.io",
"bitcoinmagazine.com",
"www.dlnews.com",
"cryptoslate.com",
"unchainedcrypto.com"
]

View File

@@ -763,7 +763,7 @@ export class App {
name: 'markets',
fn: () => this.dataLoader.loadMarkets(),
intervalMs: REFRESH_INTERVALS.markets,
condition: () => this.isAnyPanelNearViewport(['markets', 'heatmap', 'commodities', 'crypto']),
condition: () => this.isAnyPanelNearViewport(['markets', 'heatmap', 'commodities', 'crypto', 'crypto-heatmap', 'defi-tokens', 'ai-tokens', 'other-tokens']),
},
{
name: 'predictions',

View File

@@ -21,6 +21,10 @@ import {
getFeedFailures,
fetchMultipleStocks,
fetchCrypto,
fetchCryptoSectors,
fetchDefiTokens,
fetchAiTokens,
fetchOtherTokens,
fetchPredictions,
fetchEarthquakes,
fetchWeatherAlerts,
@@ -118,6 +122,10 @@ import {
HeatmapPanel,
CommoditiesPanel,
CryptoPanel,
CryptoHeatmapPanel,
DefiTokensPanel,
AiTokensPanel,
OtherTokensPanel,
PredictionPanel,
MonitorPanel,
InsightsPanel,
@@ -363,7 +371,7 @@ export class DataLoaderManager implements AppModule {
// Happy variant only loads news data -- skip all geopolitical/financial/military data
if (SITE_VARIANT !== 'happy') {
if (shouldLoadAny(['markets', 'heatmap', 'commodities', 'crypto', 'energy-complex'])) {
if (shouldLoadAny(['markets', 'heatmap', 'commodities', 'crypto', 'energy-complex', 'crypto-heatmap', 'defi-tokens', 'ai-tokens', 'other-tokens'])) {
tasks.push({ name: 'markets', task: runGuarded('markets', () => this.loadMarkets()) });
}
if (SITE_VARIANT === 'finance' && getSecretState('WORLDMONITOR_API_KEY').present && shouldLoad('stock-analysis')) {
@@ -1326,6 +1334,32 @@ export class DataLoaderManager implements AppModule {
} catch {
this.ctx.statusPanel?.updateApi('CoinGecko', { status: 'error' });
}
const cryptoHeatmapPanel = this.ctx.panels['crypto-heatmap'] as CryptoHeatmapPanel | undefined;
const defiPanel = this.ctx.panels['defi-tokens'] as DefiTokensPanel | undefined;
const aiPanel = this.ctx.panels['ai-tokens'] as AiTokensPanel | undefined;
const otherPanel = this.ctx.panels['other-tokens'] as OtherTokensPanel | undefined;
if (cryptoHeatmapPanel || defiPanel || aiPanel || otherPanel) {
try {
const [sectors, defi, ai, other] = await Promise.all([
cryptoHeatmapPanel ? fetchCryptoSectors() : Promise.resolve([]),
defiPanel ? fetchDefiTokens() : Promise.resolve([]),
aiPanel ? fetchAiTokens() : Promise.resolve([]),
otherPanel ? fetchOtherTokens() : Promise.resolve([]),
]);
cryptoHeatmapPanel?.renderSectors(sectors);
defiPanel?.renderTokens(defi);
aiPanel?.renderTokens(ai);
otherPanel?.renderTokens(other);
} catch (err) {
console.warn('[DataLoader] Token panel load failed:', err);
cryptoHeatmapPanel?.showRetrying(t('common.failedCryptoData'));
defiPanel?.showRetrying(t('common.failedCryptoData'));
aiPanel?.showRetrying(t('common.failedCryptoData'));
otherPanel?.showRetrying(t('common.failedCryptoData'));
}
}
}
async loadDailyMarketBrief(force = false): Promise<void> {

View File

@@ -11,6 +11,10 @@ import {
HeatmapPanel,
CommoditiesPanel,
CryptoPanel,
CryptoHeatmapPanel,
DefiTokensPanel,
AiTokensPanel,
OtherTokensPanel,
PredictionPanel,
MonitorPanel,
EconomicPanel,
@@ -551,6 +555,10 @@ export class PanelLayoutManager implements AppModule {
this.createNewsPanel('intel', 'panels.intel');
this.createPanel('crypto', () => new CryptoPanel());
this.createPanel('crypto-heatmap', () => new CryptoHeatmapPanel());
this.createPanel('defi-tokens', () => new DefiTokensPanel());
this.createPanel('ai-tokens', () => new AiTokensPanel());
this.createPanel('other-tokens', () => new OtherTokensPanel());
this.createNewsPanel('middleeast', 'panels.middleeast');
this.createNewsPanel('layoffs', 'panels.layoffs');
this.createNewsPanel('ai', 'panels.ai');

View File

@@ -1,6 +1,6 @@
import { Panel } from './Panel';
import { t } from '@/services/i18n';
import type { MarketData, CryptoData } from '@/types';
import type { MarketData, CryptoData, TokenData } from '@/types';
import { formatPrice, formatChange, getChangeClass, getHeatmapClass } from '@/utils';
import { escapeHtml } from '@/utils/sanitize';
import { miniSparkline } from '@/utils/sparkline';
@@ -230,3 +230,80 @@ export class CryptoPanel extends Panel {
this.setContent(html);
}
}
export class CryptoHeatmapPanel extends Panel {
constructor() {
super({ id: 'crypto-heatmap', title: 'Crypto Sectors' });
}
public renderSectors(data: Array<{ id: string; name: string; change: number }>): void {
if (data.length === 0) {
this.showRetrying(t('common.failedSectorData'));
return;
}
const html =
'<div class="heatmap">' +
data
.map((sector) => {
const change = sector.change ?? 0;
return `
<div class="heatmap-cell ${getHeatmapClass(change)}">
<div class="sector-name">${escapeHtml(sector.name)}</div>
<div class="sector-change ${getChangeClass(change)}">${formatChange(change)}</div>
</div>
`;
})
.join('') +
'</div>';
this.setContent(html);
}
}
export class TokenListPanel extends Panel {
public renderTokens(data: TokenData[]): void {
if (data.length === 0) {
this.showRetrying(t('common.failedCryptoData'));
return;
}
const rows = data
.map(
(tok) => `
<div class="market-item">
<div class="market-info">
<span class="market-name">${escapeHtml(tok.name)}</span>
<span class="market-symbol">${escapeHtml(tok.symbol)}</span>
</div>
<div class="market-data">
<span class="market-price">$${tok.price.toLocaleString(undefined, { maximumFractionDigits: tok.price < 1 ? 6 : 2 })}</span>
<span class="market-change ${getChangeClass(tok.change24h)}">${formatChange(tok.change24h)}</span>
<span class="market-change market-change--7d ${getChangeClass(tok.change7d)}">${formatChange(tok.change7d)}W</span>
</div>
</div>
`
)
.join('');
this.setContent(rows);
}
}
export class DefiTokensPanel extends TokenListPanel {
constructor() {
super({ id: 'defi-tokens', title: 'DeFi Tokens' });
}
}
export class AiTokensPanel extends TokenListPanel {
constructor() {
super({ id: 'ai-tokens', title: 'AI Tokens' });
}
}
export class OtherTokensPanel extends TokenListPanel {
constructor() {
super({ id: 'other-tokens', title: 'Alt Tokens' });
}
}

View File

@@ -984,6 +984,19 @@ const FINANCE_FEEDS: Record<string, Feed[]> = {
{ name: 'The Block', url: rss('https://news.google.com/rss/search?q=site:theblock.co+when:1d&hl=en-US&gl=US&ceid=US:en') },
{ name: 'Crypto News', url: rss('https://news.google.com/rss/search?q=(bitcoin+OR+ethereum+OR+crypto+OR+"digital+assets")+when:1d&hl=en-US&gl=US&ceid=US:en') },
{ name: 'DeFi News', url: rss('https://news.google.com/rss/search?q=(DeFi+OR+"decentralized+finance"+OR+DEX+OR+"yield+farming")+when:3d&hl=en-US&gl=US&ceid=US:en') },
{ name: 'Decrypt', url: rss('https://decrypt.co/feed') },
{ name: 'Blockworks', url: rss('https://blockworks.co/feed') },
{ name: 'The Defiant', url: rss('https://thedefiant.io/feed') },
{ name: 'Bitcoin Magazine', url: rss('https://bitcoinmagazine.com/feed') },
{ name: 'DL News', url: rss('https://www.dlnews.com/feed/') },
{ name: 'CryptoSlate', url: rss('https://cryptoslate.com/feed/') },
{ name: 'Unchained', url: rss('https://unchainedcrypto.com/feed/') },
{ name: 'Wu Blockchain', url: rss('https://news.google.com/rss/search?q=site:wublockchain.com+when:7d&hl=en-US&gl=US&ceid=US:en') },
{ name: 'Messari', url: rss('https://news.google.com/rss/search?q=site:messari.io+when:3d&hl=en-US&gl=US&ceid=US:en') },
{ name: 'Bloomberg Crypto', url: rss('https://news.google.com/rss/search?q=bloomberg+crypto+when:1d&hl=en-US&gl=US&ceid=US:en') },
{ name: 'Reuters Crypto', url: rss('https://news.google.com/rss/search?q=reuters+crypto+when:1d&hl=en-US&gl=US&ceid=US:en') },
{ name: 'NFT News', url: rss('https://news.google.com/rss/search?q=(NFT+OR+"non-fungible")+when:3d&hl=en-US&gl=US&ceid=US:en') },
{ name: 'Stablecoin Policy', url: rss('https://news.google.com/rss/search?q=(stablecoin+regulation+OR+"stablecoin+bill")+when:7d&hl=en-US&gl=US&ceid=US:en') },
],
centralbanks: [
{ name: 'Federal Reserve', url: rss('https://www.federalreserve.gov/feeds/press_all.xml') },

View File

@@ -382,6 +382,10 @@ const FINANCE_PANELS: Record<string, PanelConfig> = {
'commodities-news': { name: 'Commodities News', enabled: true, priority: 2 },
crypto: { name: 'Crypto & Digital Assets', enabled: true, priority: 1 },
'crypto-news': { name: 'Crypto News', enabled: true, priority: 2 },
'crypto-heatmap': { name: 'Crypto Sectors', enabled: true, priority: 1 },
'defi-tokens': { name: 'DeFi Tokens', enabled: true, priority: 2 },
'ai-tokens': { name: 'AI Tokens', enabled: true, priority: 2 },
'other-tokens': { name: 'Alt Tokens', enabled: true, priority: 2 },
centralbanks: { name: 'Central Bank Watch', enabled: true, priority: 1 },
economic: { name: 'Macro Stress', enabled: true, priority: 1 },
'trade-policy': { name: 'Trade Policy', enabled: true, priority: 1 },
@@ -956,7 +960,7 @@ export const PANEL_CATEGORY_MAP: Record<string, { labelKey: string; panelKeys: s
},
cryptoDigital: {
labelKey: 'header.panelCatCryptoDigital',
panelKeys: ['crypto', 'crypto-news', 'etf-flows', 'stablecoins', 'fintech'],
panelKeys: ['crypto', 'crypto-heatmap', 'defi-tokens', 'ai-tokens', 'other-tokens', 'crypto-news', 'etf-flows', 'stablecoins', 'fintech'],
variants: ['finance'],
},
centralBanksEcon: {

View File

@@ -36,6 +36,7 @@ export interface CryptoQuote {
price: number;
change: number;
sparkline: number[];
change7d: number;
}
export interface ListCommodityQuotesRequest {
@@ -293,6 +294,40 @@ export interface ListStoredStockBacktestsResponse {
items: BacktestStockResponse[];
}
export interface ListCryptoSectorsRequest {
}
export interface ListCryptoSectorsResponse {
sectors: CryptoSector[];
}
export interface CryptoSector {
id: string;
name: string;
change: number;
}
export interface ListDefiTokensRequest {
}
export interface ListDefiTokensResponse {
tokens: CryptoQuote[];
}
export interface ListAiTokensRequest {
}
export interface ListAiTokensResponse {
tokens: CryptoQuote[];
}
export interface ListOtherTokensRequest {
}
export interface ListOtherTokensResponse {
tokens: CryptoQuote[];
}
export interface FieldViolation {
field: string;
description: string;
@@ -644,6 +679,98 @@ export class MarketServiceClient {
return await resp.json() as ListStoredStockBacktestsResponse;
}
async listCryptoSectors(req: ListCryptoSectorsRequest, options?: MarketServiceCallOptions): Promise<ListCryptoSectorsResponse> {
let path = "/api/market/v1/list-crypto-sectors";
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 ListCryptoSectorsResponse;
}
async listDefiTokens(req: ListDefiTokensRequest, options?: MarketServiceCallOptions): Promise<ListDefiTokensResponse> {
let path = "/api/market/v1/list-defi-tokens";
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 ListDefiTokensResponse;
}
async listAiTokens(req: ListAiTokensRequest, options?: MarketServiceCallOptions): Promise<ListAiTokensResponse> {
let path = "/api/market/v1/list-ai-tokens";
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 ListAiTokensResponse;
}
async listOtherTokens(req: ListOtherTokensRequest, options?: MarketServiceCallOptions): Promise<ListOtherTokensResponse> {
let path = "/api/market/v1/list-other-tokens";
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 ListOtherTokensResponse;
}
private async handleError(resp: Response): Promise<never> {
const body = await resp.text();
if (resp.status === 400) {

View File

@@ -36,6 +36,7 @@ export interface CryptoQuote {
price: number;
change: number;
sparkline: number[];
change7d: number;
}
export interface ListCommodityQuotesRequest {
@@ -293,6 +294,40 @@ export interface ListStoredStockBacktestsResponse {
items: BacktestStockResponse[];
}
export interface ListCryptoSectorsRequest {
}
export interface ListCryptoSectorsResponse {
sectors: CryptoSector[];
}
export interface CryptoSector {
id: string;
name: string;
change: number;
}
export interface ListDefiTokensRequest {
}
export interface ListDefiTokensResponse {
tokens: CryptoQuote[];
}
export interface ListAiTokensRequest {
}
export interface ListAiTokensResponse {
tokens: CryptoQuote[];
}
export interface ListOtherTokensRequest {
}
export interface ListOtherTokensResponse {
tokens: CryptoQuote[];
}
export interface FieldViolation {
field: string;
description: string;
@@ -350,6 +385,10 @@ export interface MarketServiceHandler {
getStockAnalysisHistory(ctx: ServerContext, req: GetStockAnalysisHistoryRequest): Promise<GetStockAnalysisHistoryResponse>;
backtestStock(ctx: ServerContext, req: BacktestStockRequest): Promise<BacktestStockResponse>;
listStoredStockBacktests(ctx: ServerContext, req: ListStoredStockBacktestsRequest): Promise<ListStoredStockBacktestsResponse>;
listCryptoSectors(ctx: ServerContext, req: ListCryptoSectorsRequest): Promise<ListCryptoSectorsResponse>;
listDefiTokens(ctx: ServerContext, req: ListDefiTokensRequest): Promise<ListDefiTokensResponse>;
listAiTokens(ctx: ServerContext, req: ListAiTokensRequest): Promise<ListAiTokensResponse>;
listOtherTokens(ctx: ServerContext, req: ListOtherTokensRequest): Promise<ListOtherTokensResponse>;
}
export function createMarketServiceRoutes(
@@ -908,6 +947,154 @@ export function createMarketServiceRoutes(
}
},
},
{
method: "GET",
path: "/api/market/v1/list-crypto-sectors",
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const body = {} as ListCryptoSectorsRequest;
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.listCryptoSectors(ctx, body);
return new Response(JSON.stringify(result as ListCryptoSectorsResponse), {
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/list-defi-tokens",
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const body = {} as ListDefiTokensRequest;
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.listDefiTokens(ctx, body);
return new Response(JSON.stringify(result as ListDefiTokensResponse), {
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/list-ai-tokens",
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const body = {} as ListAiTokensRequest;
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.listAiTokens(ctx, body);
return new Response(JSON.stringify(result as ListAiTokensResponse), {
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/list-other-tokens",
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const body = {} as ListOtherTokensRequest;
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.listOtherTokens(ctx, body);
return new Response(JSON.stringify(result as ListOtherTokensResponse), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (err: unknown) {
if (err instanceof ValidationError) {
return new Response(JSON.stringify({ violations: err.violations }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (options?.onError) {
return options.onError(err, req);
}
const message = err instanceof Error ? err.message : String(err);
return new Response(JSON.stringify({ message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
},
},
];
}

View File

@@ -10,10 +10,15 @@ import {
MarketServiceClient,
type ListMarketQuotesResponse,
type ListCryptoQuotesResponse,
type ListCryptoSectorsResponse,
type CryptoSector,
type ListDefiTokensResponse,
type ListAiTokensResponse,
type ListOtherTokensResponse,
type MarketQuote as ProtoMarketQuote,
type CryptoQuote as ProtoCryptoQuote,
} from '@/generated/client/worldmonitor/market/v1/service_client';
import type { MarketData, CryptoData } from '@/types';
import type { MarketData, CryptoData, TokenData } from '@/types';
import { createCircuitBreaker } from '@/utils/circuit-breaker';
import { getHydratedData } from '@/services/bootstrap';
@@ -24,9 +29,17 @@ const MARKET_QUOTES_CACHE_TTL_MS = 5 * 60 * 1000;
const stockBreaker = createCircuitBreaker<ListMarketQuotesResponse>({ name: 'Market Quotes', cacheTtlMs: MARKET_QUOTES_CACHE_TTL_MS, persistCache: true });
const commodityBreaker = createCircuitBreaker<ListMarketQuotesResponse>({ name: 'Commodity Quotes', cacheTtlMs: MARKET_QUOTES_CACHE_TTL_MS, persistCache: true });
const cryptoBreaker = createCircuitBreaker<ListCryptoQuotesResponse>({ name: 'Crypto Quotes', persistCache: true });
const cryptoSectorsBreaker = createCircuitBreaker<ListCryptoSectorsResponse>({ name: 'Crypto Sectors', persistCache: true });
const defiBreaker = createCircuitBreaker<ListDefiTokensResponse>({ name: 'DeFi Tokens', persistCache: true });
const aiBreaker = createCircuitBreaker<ListAiTokensResponse>({ name: 'AI Tokens', persistCache: true });
const otherBreaker = createCircuitBreaker<ListOtherTokensResponse>({ name: 'Other Tokens', persistCache: true });
const emptyStockFallback: ListMarketQuotesResponse = { quotes: [], finnhubSkipped: false, skipReason: '', rateLimited: false };
const emptyCryptoFallback: ListCryptoQuotesResponse = { quotes: [] };
const emptyCryptoSectorsFallback: ListCryptoSectorsResponse = { sectors: [] };
const emptyDefiTokensFallback: ListDefiTokensResponse = { tokens: [] };
const emptyAiTokensFallback: ListAiTokensResponse = { tokens: [] };
const emptyOtherTokensFallback: ListOtherTokensResponse = { tokens: [] };
// ---- Proto -> legacy adapters ----
@@ -166,3 +179,93 @@ export async function fetchCrypto(): Promise<CryptoData[]> {
return lastSuccessfulCrypto;
}
// ========================================================================
// Crypto Sectors
// ========================================================================
let lastSuccessfulSectors: CryptoSector[] = [];
export async function fetchCryptoSectors(): Promise<CryptoSector[]> {
const hydrated = getHydratedData('cryptoSectors') as ListCryptoSectorsResponse | undefined;
if (hydrated?.sectors?.length) {
lastSuccessfulSectors = hydrated.sectors;
return hydrated.sectors;
}
const resp = await cryptoSectorsBreaker.execute(async () => {
return client.listCryptoSectors({});
}, emptyCryptoSectorsFallback);
if (resp.sectors.length > 0) {
lastSuccessfulSectors = resp.sectors;
return resp.sectors;
}
return lastSuccessfulSectors;
}
// ========================================================================
// Token Panels (DeFi, AI, Other)
// ========================================================================
function toTokenData(q: ProtoCryptoQuote): TokenData {
return {
name: q.name,
symbol: q.symbol,
price: q.price,
change24h: q.change,
change7d: q.change7d ?? 0,
};
}
let lastSuccessfulDefi: TokenData[] = [];
let lastSuccessfulAi: TokenData[] = [];
let lastSuccessfulOther: TokenData[] = [];
export async function fetchDefiTokens(): Promise<TokenData[]> {
const hydrated = getHydratedData('defiTokens') as ListDefiTokensResponse | undefined;
if (hydrated?.tokens?.length) {
const mapped = hydrated.tokens.map(toTokenData).filter(t => t.price > 0);
if (mapped.length > 0) { lastSuccessfulDefi = mapped; return mapped; }
}
const resp = await defiBreaker.execute(async () => {
return client.listDefiTokens({});
}, emptyDefiTokensFallback);
const results = resp.tokens.map(toTokenData).filter(t => t.price > 0);
if (results.length > 0) { lastSuccessfulDefi = results; return results; }
return lastSuccessfulDefi;
}
export async function fetchAiTokens(): Promise<TokenData[]> {
const hydrated = getHydratedData('aiTokens') as ListAiTokensResponse | undefined;
if (hydrated?.tokens?.length) {
const mapped = hydrated.tokens.map(toTokenData).filter(t => t.price > 0);
if (mapped.length > 0) { lastSuccessfulAi = mapped; return mapped; }
}
const resp = await aiBreaker.execute(async () => {
return client.listAiTokens({});
}, emptyAiTokensFallback);
const results = resp.tokens.map(toTokenData).filter(t => t.price > 0);
if (results.length > 0) { lastSuccessfulAi = results; return results; }
return lastSuccessfulAi;
}
export async function fetchOtherTokens(): Promise<TokenData[]> {
const hydrated = getHydratedData('otherTokens') as ListOtherTokensResponse | undefined;
if (hydrated?.tokens?.length) {
const mapped = hydrated.tokens.map(toTokenData).filter(t => t.price > 0);
if (mapped.length > 0) { lastSuccessfulOther = mapped; return mapped; }
}
const resp = await otherBreaker.execute(async () => {
return client.listOtherTokens({});
}, emptyOtherTokensFallback);
const results = resp.tokens.map(toTokenData).filter(t => t.price > 0);
if (results.length > 0) { lastSuccessfulOther = results; return results; }
return lastSuccessfulOther;
}

View File

@@ -5846,6 +5846,10 @@ body.playback-mode .status-dot {
color: var(--red);
}
.market-change--7d {
opacity: 0.7;
}
/* Gulf Economies */
.gulf-section {
margin-bottom: 8px;

View File

@@ -186,6 +186,14 @@ export interface CryptoData {
sparkline?: number[];
}
export interface TokenData {
name: string;
symbol: string;
price: number;
change24h: number;
change7d: number;
}
export type EscalationTrend = 'escalating' | 'stable' | 'de-escalating';
export interface DynamicEscalationScore {