mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
@@ -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
11
api/bootstrap.js
vendored
@@ -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
@@ -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.
|
||||
|
||||
16
proto/worldmonitor/market/v1/list_ai_tokens.proto
Normal file
16
proto/worldmonitor/market/v1/list_ai_tokens.proto
Normal 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;
|
||||
}
|
||||
25
proto/worldmonitor/market/v1/list_crypto_sectors.proto
Normal file
25
proto/worldmonitor/market/v1/list_crypto_sectors.proto
Normal 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;
|
||||
}
|
||||
16
proto/worldmonitor/market/v1/list_defi_tokens.proto
Normal file
16
proto/worldmonitor/market/v1/list_defi_tokens.proto
Normal 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;
|
||||
}
|
||||
16
proto/worldmonitor/market/v1/list_other_tokens.proto
Normal file
16
proto/worldmonitor/market/v1/list_other_tokens.proto
Normal 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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
67
scripts/seed-crypto-sectors.mjs
Normal file
67
scripts/seed-crypto-sectors.mjs
Normal 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);
|
||||
});
|
||||
87
scripts/seed-token-panels.mjs
Normal file
87
scripts/seed-token-panels.mjs
Normal 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);
|
||||
});
|
||||
22
scripts/shared/ai-tokens.json
Normal file
22
scripts/shared/ai-tokens.json
Normal 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"
|
||||
}
|
||||
}
|
||||
12
scripts/shared/crypto-sectors.json
Normal file
12
scripts/shared/crypto-sectors.json
Normal 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
scripts/shared/defi-tokens.json
Normal file
24
scripts/shared/defi-tokens.json
Normal 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
scripts/shared/other-tokens.json
Normal file
21
scripts/shared/other-tokens.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
36
server/worldmonitor/market/v1/list-ai-tokens.ts
Normal file
36
server/worldmonitor/market/v1/list-ai-tokens.ts
Normal 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: [] };
|
||||
}
|
||||
}
|
||||
25
server/worldmonitor/market/v1/list-crypto-sectors.ts
Normal file
25
server/worldmonitor/market/v1/list-crypto-sectors.ts
Normal 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: [] };
|
||||
}
|
||||
}
|
||||
36
server/worldmonitor/market/v1/list-defi-tokens.ts
Normal file
36
server/worldmonitor/market/v1/list-defi-tokens.ts
Normal 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: [] };
|
||||
}
|
||||
}
|
||||
36
server/worldmonitor/market/v1/list-other-tokens.ts
Normal file
36
server/worldmonitor/market/v1/list-other-tokens.ts
Normal 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: [] };
|
||||
}
|
||||
}
|
||||
@@ -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
22
shared/ai-tokens.json
Normal 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"
|
||||
}
|
||||
}
|
||||
12
shared/crypto-sectors.json
Normal file
12
shared/crypto-sectors.json
Normal 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
24
shared/defi-tokens.json
Normal 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
21
shared/other-tokens.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user