mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat(market): Fear & Greed Index 2.0 — 10-category composite sentiment panel (#2181)
* Add Fear & Greed Index 2.0 reverse engineering brief Analyzes the 10-category weighted composite (Sentiment, Volatility, Positioning, Trend, Breadth, Momentum, Liquidity, Credit, Macro, Cross-Asset) with scoring formulas, data source audit, and implementation plan for building it as a worldmonitor panel. https://claude.ai/code/session_01HR69u6oF1VCMwsC2PHFL8i * Add seed script implementation plan to F&G brief Details exact endpoints, Yahoo symbols (17 calls), Redis key schema, computed metrics, FRED series to add (BAMLC0A0CM, SOFR), CNN/AAII sources, output JSON schema, and estimated runtime (~8s per seed run). https://claude.ai/code/session_01HR69u6oF1VCMwsC2PHFL8i * Update brief: all sources are free, zero paid APIs needed - CBOE CDN CSVs for put/call ratios (totalpc.csv, equitypc.csv) - CNN dataviz API for Fear & Greed (production.dataviz.cnn.io) - Yahoo Finance for VIX9D/VIX3M/SKEW/RSP/NYA (standard symbols) - FRED for IG spread (BAMLC0A0CM) and SOFR (add to existing array) - AAII scrape for bull/bear survey (only medium-effort source) - Breadth via RSP/SPY divergence + NYSE composite (no scraping) https://claude.ai/code/session_01HR69u6oF1VCMwsC2PHFL8i * Add verified Yahoo symbols for breadth + finalized source list New discoveries: - ^MMTH = % stocks above 200 DMA (direct Yahoo symbol!) - C:ISSU = NYSE advance/decline data - CNN endpoint accepts date param for historical data - CBOE CSVs have data back to 2003 - 33 total calls per seed run, ~6s runtime All 10 categories now have confirmed free sources. https://claude.ai/code/session_01HR69u6oF1VCMwsC2PHFL8i * Rewrite F&G brief as forward-looking design doc Remove all reverse-engineering language, screenshot references, and discovery notes. Clean structure: goal, scoring model, data sources, formulas, seed script plan, implementation phases, MVP path. https://claude.ai/code/session_01HR69u6oF1VCMwsC2PHFL8i * docs: apply gold standard corrections to fear-greed-index-2.0 brief * feat(market): add Fear & Greed Index 2.0 — 10-category composite sentiment panel Composite 0-100 index from 10 weighted categories: sentiment (CNN F&G, AAII, crypto F&G), volatility (VIX, term structure), positioning (P/C ratio, SKEW), trend (SPX vs MAs), breadth (% >200d, RSP/SPY divergence), momentum (sector RSI, ROC), liquidity (M2, Fed BS, SOFR), credit (HY/IG spreads), macro (Fed rate, yield curve, unemployment), cross-asset (gold/bonds/DXY vs equities). Data layer: - seed-fear-greed.mjs: 19 Yahoo symbols (150ms gaps), CBOE P/C CSVs, CNN F&G API, AAII scrape (degraded-safe), FRED Redis reads. TTL 64800s. - seed-economy.mjs: add BAMLC0A0CM (IG spread) and SOFR to FRED_SERIES. - Bootstrap 4-file checklist: cache-keys, bootstrap.js, health.js, handler. Proto + RPC: - get_fear_greed_index.proto with FearGreedCategory message. - get-fear-greed-index.ts handler reads seeded Redis data. Frontend: - FearGreedPanel with gauge, 9-metric header grid, 10-category breakdown. - Self-loading via bootstrap hydration + RPC fallback. - Registered in panel-layout, App.ts (prime + refresh), panel config, Cmd-K commands, finance variant, i18n (en/ar/zh/es). * fix(market): add RPC_CACHE_TIER entry for get-fear-greed-index * fix(docs): escape bare angle bracket in fear-greed brief for MDX * fix(docs): fix markdown lint errors in fear-greed brief (blank lines around headings/lists) * fix(market): fix seed-fear-greed bugs from code review - fredLatest/fredNMonthsAgo: guard parseFloat with Number.isFinite to handle FRED's "." missing-data sentinel (was returning NaN which propagated through scoring as a truthy non-null value) - Remove 3 unused Yahoo symbols (^NYA, HYG, LQD) that were fetched but not referenced in any scoring category (saves ~450ms per run) - fedRateStr: display effective rate directly instead of deriving target range via (fedRate - 0.25) which was incorrect * fix(market): address P2/P3 review findings in Fear & Greed - FearGreedPanel: add mapSeedPayload() to correctly map raw seed JSON to proto-shaped FearGreedData; bootstrap hydration was always falling through to RPC because seed shape (composite.score) differs from proto shape (compositeScore) - FearGreedPanel: fix fmt() — remove === 0 guard and add explicit > 0 checks on VIX and P/C Ratio display to handle proto default zeros without masking genuine zero values (e.g. pctAbove200d) - seed-fear-greed: remove broken history write — each run overwrote the key with a single-entry array (no read-then-append), making the 90-day TTL meaningless; no consumer exists yet so defer to later - seed-fear-greed: extract hySpreadVal const to avoid double fredLatest call - seed-fear-greed: fix stale comment (19 symbols → 16 after prior cleanup) --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
2
api/bootstrap.js
vendored
2
api/bootstrap.js
vendored
@@ -67,6 +67,7 @@ const BOOTSTRAP_CACHE_KEYS = {
|
||||
fuelPrices: 'economic:fuel-prices:v1',
|
||||
nationalDebt: 'economic:national-debt:v1',
|
||||
marketImplications: 'intelligence:market-implications:v1',
|
||||
fearGreedIndex: 'market:fear-greed:v1',
|
||||
};
|
||||
|
||||
const SLOW_KEYS = new Set([
|
||||
@@ -87,6 +88,7 @@ const SLOW_KEYS = new Set([
|
||||
'fuelPrices',
|
||||
'nationalDebt',
|
||||
'marketImplications',
|
||||
'fearGreedIndex',
|
||||
]);
|
||||
const FAST_KEYS = new Set([
|
||||
'earthquakes', 'outages', 'serviceStatuses', 'ddosAttacks', 'trafficAnomalies', 'macroSignals', 'chokepoints', 'chokepointTransits',
|
||||
|
||||
@@ -53,6 +53,7 @@ const BOOTSTRAP_KEYS = {
|
||||
aiTokens: 'market:ai-tokens:v1',
|
||||
otherTokens: 'market:other-tokens:v1',
|
||||
fredBatch: 'economic:fred:v1:FEDFUNDS:0',
|
||||
fearGreedIndex: 'market:fear-greed:v1',
|
||||
};
|
||||
|
||||
const STANDALONE_KEYS = {
|
||||
@@ -172,7 +173,7 @@ const SEED_META = {
|
||||
otherTokens: { key: 'seed-meta:market:token-panels', maxStaleMin: 90 },
|
||||
fredBatch: { key: 'seed-meta:economic:fred:v1:FEDFUNDS:0', maxStaleMin: 1500 }, // daily cron
|
||||
gscpi: { key: 'seed-meta:economic:gscpi', maxStaleMin: 2880 }, // 24h interval; 2880min = 48h = 2x interval
|
||||
marketImplications: { key: 'seed-meta:intelligence:market-implications', maxStaleMin: 150 }, // 75min TTL; 150min = 2x interval
|
||||
fearGreedIndex: { key: 'seed-meta:market:fear-greed', maxStaleMin: 720 }, // 6h cron; 720min = 12h = 2x interval
|
||||
};
|
||||
|
||||
// Standalone keys that are populated on-demand by RPC handlers (not seeds).
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -523,6 +523,32 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
/api/market/v1/get-fear-greed-index:
|
||||
get:
|
||||
tags:
|
||||
- MarketService
|
||||
summary: GetFearGreedIndex
|
||||
description: GetFearGreedIndex retrieves the composite Fear & Greed sentiment index.
|
||||
operationId: GetFearGreedIndex
|
||||
responses:
|
||||
"200":
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GetFearGreedIndexResponse'
|
||||
"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:
|
||||
@@ -1354,3 +1380,84 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/CryptoQuote'
|
||||
description: ListOtherTokensResponse contains other token price data.
|
||||
GetFearGreedIndexRequest:
|
||||
type: object
|
||||
GetFearGreedIndexResponse:
|
||||
type: object
|
||||
properties:
|
||||
compositeScore:
|
||||
type: number
|
||||
format: double
|
||||
compositeLabel:
|
||||
type: string
|
||||
previousScore:
|
||||
type: number
|
||||
format: double
|
||||
seededAt:
|
||||
type: string
|
||||
sentiment:
|
||||
$ref: '#/components/schemas/FearGreedCategory'
|
||||
volatility:
|
||||
$ref: '#/components/schemas/FearGreedCategory'
|
||||
positioning:
|
||||
$ref: '#/components/schemas/FearGreedCategory'
|
||||
trend:
|
||||
$ref: '#/components/schemas/FearGreedCategory'
|
||||
breadth:
|
||||
$ref: '#/components/schemas/FearGreedCategory'
|
||||
momentum:
|
||||
$ref: '#/components/schemas/FearGreedCategory'
|
||||
liquidity:
|
||||
$ref: '#/components/schemas/FearGreedCategory'
|
||||
credit:
|
||||
$ref: '#/components/schemas/FearGreedCategory'
|
||||
macro:
|
||||
$ref: '#/components/schemas/FearGreedCategory'
|
||||
crossAsset:
|
||||
$ref: '#/components/schemas/FearGreedCategory'
|
||||
vix:
|
||||
type: number
|
||||
format: double
|
||||
hySpread:
|
||||
type: number
|
||||
format: double
|
||||
yield10y:
|
||||
type: number
|
||||
format: double
|
||||
putCallRatio:
|
||||
type: number
|
||||
format: double
|
||||
pctAbove200d:
|
||||
type: number
|
||||
format: double
|
||||
cnnFearGreed:
|
||||
type: number
|
||||
format: double
|
||||
cnnLabel:
|
||||
type: string
|
||||
aaiiBull:
|
||||
type: number
|
||||
format: double
|
||||
aaiiBear:
|
||||
type: number
|
||||
format: double
|
||||
fedRate:
|
||||
type: string
|
||||
unavailable:
|
||||
type: boolean
|
||||
FearGreedCategory:
|
||||
type: object
|
||||
properties:
|
||||
score:
|
||||
type: number
|
||||
format: double
|
||||
weight:
|
||||
type: number
|
||||
format: double
|
||||
contribution:
|
||||
type: number
|
||||
format: double
|
||||
degraded:
|
||||
type: boolean
|
||||
inputsJson:
|
||||
type: string
|
||||
|
||||
376
docs/fear-greed-index-2.0-brief.md
Normal file
376
docs/fear-greed-index-2.0-brief.md
Normal file
@@ -0,0 +1,376 @@
|
||||
# Fear & Greed Index 2.0 — Design Brief
|
||||
|
||||
## Goal
|
||||
|
||||
Build a composite market sentiment gauge (0–100) combining **10 weighted categories** into a single score. Unlike CNN's Fear & Greed Index (~7 inputs, widely criticized for lagging and oversimplifying), this uses 10 granular categories with more inputs per category to produce a nuanced, institutional-quality reading.
|
||||
|
||||
---
|
||||
|
||||
## Composite Score
|
||||
|
||||
```
|
||||
Final Score = Σ (Category_Score × Category_Weight)
|
||||
```
|
||||
|
||||
Each category scores **0–100** (0 = Extreme Fear, 100 = Extreme Greed). The weighted sum produces the composite index.
|
||||
|
||||
### 10 Categories
|
||||
|
||||
| # | Category | Weight | What It Measures |
|
||||
|---|----------------|--------|------------------|
|
||||
| 1 | **Sentiment** | 10% | CNN F&G, AAII Bull/Bear surveys, crypto sentiment |
|
||||
| 2 | **Volatility** | 10% | VIX level, VIX term structure (contango/backwardation) |
|
||||
| 3 | **Positioning** | 15% | Put/Call ratios, options skew (CBOE SKEW) |
|
||||
| 4 | **Trend** | 10% | SPX vs 20d/50d/200d MAs, price momentum |
|
||||
| 5 | **Breadth** | 10% | % stocks > 200 DMA, advance/decline ratio, equal-weight divergence |
|
||||
| 6 | **Momentum** | 10% | Sector RSI spread, rate of change |
|
||||
| 7 | **Liquidity** | 15% | M2 growth, Fed balance sheet, SOFR rate |
|
||||
| 8 | **Credit** | 10% | HY spreads, IG spreads, credit ETF trends |
|
||||
| 9 | **Macro** | 5% | Fed rate, yield curve, unemployment |
|
||||
| 10| **Cross-Asset** | 5% | Gold/USD correlation, bonds vs equities |
|
||||
|
||||
### Score Labels
|
||||
|
||||
| Range | Label |
|
||||
|-------|-------|
|
||||
| 0–20 | Extreme Fear |
|
||||
| 20–40 | Fear |
|
||||
| 40–60 | Neutral |
|
||||
| 60–80 | Greed |
|
||||
| 80–100| Extreme Greed |
|
||||
|
||||
### Header Metrics (9 key stats)
|
||||
|
||||
| Metric | Source | Context |
|
||||
|--------|--------|---------|
|
||||
| CNN F&G | CNN dataviz API | 0–100 score + label |
|
||||
| AAII Bear % | AAII survey | vs historical average |
|
||||
| AAII Bull % | AAII survey | vs historical average |
|
||||
| Put/Call Ratio | CBOE CDN CSV | vs 1yr average |
|
||||
| VIX | Yahoo / FRED | % change |
|
||||
| HY Spread | FRED | vs long-term average |
|
||||
| % > 200 DMA | Yahoo `^MMTH` | vs recent peak |
|
||||
| 10Y Yield | FRED | level |
|
||||
| Fed Rate | FRED | current range |
|
||||
|
||||
---
|
||||
|
||||
## Data Sources
|
||||
|
||||
All sources are free with no paid API keys required.
|
||||
|
||||
### Already Available (read from Redis)
|
||||
|
||||
| Data Point | FRED Series | Used In |
|
||||
|-----------|------------|---------|
|
||||
| VIX | VIXCLS | Volatility |
|
||||
| HY Spread (OAS) | BAMLH0A0HYM2 | Credit |
|
||||
| 10Y Yield | DGS10 | Macro |
|
||||
| Fed Funds Rate | FEDFUNDS | Macro |
|
||||
| 10Y-2Y Spread | T10Y2Y | Macro |
|
||||
| M2 Money Supply | M2SL | Liquidity |
|
||||
| Fed Balance Sheet | WALCL | Liquidity |
|
||||
| Unemployment | UNRATE | Macro |
|
||||
| Crypto Fear & Greed | Alternative.me (macro-signals) | Sentiment |
|
||||
|
||||
### New FRED Series (add to `seed-economy.mjs`)
|
||||
|
||||
| Series | Name | Category |
|
||||
|--------|------|----------|
|
||||
| `BAMLC0A0CM` | ICE BofA US IG OAS | Credit |
|
||||
| `SOFR` | Secured Overnight Financing Rate | Liquidity |
|
||||
|
||||
### New External Sources
|
||||
|
||||
| Source | Endpoint | Format | Auth | Reliability |
|
||||
|--------|----------|--------|------|-------------|
|
||||
| **CNN Fear & Greed** | `production.dataviz.cnn.io/index/fearandgreed/graphdata/{date}` | JSON | User-Agent header | MEDIUM |
|
||||
| **AAII Sentiment** | `aaii.com/sentimentsurvey` (HTML scrape) | HTML | User-Agent header | LOW (blocks bots) |
|
||||
| **CBOE Total P/C** | `cdn.cboe.com/.../totalpc.csv` | CSV | None | HIGH |
|
||||
| **CBOE Equity P/C** | `cdn.cboe.com/.../equitypc.csv` | CSV | None | HIGH |
|
||||
|
||||
### Yahoo Finance Symbols (19 total)
|
||||
|
||||
Uses `query1.finance.yahoo.com/v8/finance/chart` — no API key, User-Agent header only.
|
||||
|
||||
| # | Symbol | Category | Purpose |
|
||||
|---|--------|----------|---------|
|
||||
| 1 | `^GSPC` | Trend, Momentum | SPX — compute 20/50/200 DMA, ROC |
|
||||
| 2 | `^VIX` | Volatility | Real-time VIX |
|
||||
| 3 | `^VIX9D` | Volatility | 9-day VIX for term structure |
|
||||
| 4 | `^VIX3M` | Volatility | 3-month VIX for term structure |
|
||||
| 5 | `^SKEW` | Positioning | CBOE SKEW index |
|
||||
| 6 | `^MMTH` | Breadth | % of stocks above 200 DMA |
|
||||
| 7 | `^NYA` | Breadth | NYSE Composite for breadth divergence |
|
||||
| 8 | `C:ISSU` | Breadth | NYSE advances/declines/unchanged |
|
||||
| 9 | `GLD` | Cross-Asset | Gold proxy |
|
||||
| 10 | `TLT` | Cross-Asset | Bonds proxy |
|
||||
| 11 | `SPY` | Cross-Asset, Breadth | Equity benchmark |
|
||||
| 12 | `RSP` | Breadth | Equal-weight S&P 500 (vs SPY divergence) |
|
||||
| 13 | `DX-Y.NYB` | Cross-Asset | USD Dollar Index |
|
||||
| 14 | `HYG` | Credit | HY bond ETF trend |
|
||||
| 15 | `LQD` | Credit | IG bond ETF trend |
|
||||
| 16 | `XLK` | Momentum | Tech sector |
|
||||
| 17 | `XLF` | Momentum | Financial sector |
|
||||
| 18 | `XLE` | Momentum | Energy sector |
|
||||
| 19 | `XLV` | Momentum | Healthcare sector |
|
||||
|
||||
**Notes:**
|
||||
|
||||
- `^MMTH` = % above **200-day** MA (not `^MMTW` which is 20-day)
|
||||
- `C:ISSU` = NYSE advance/decline/unchanged data. **Unvalidated via `/v8/finance/chart` endpoint** — must confirm it returns advance/decline figures before relying on it. If unavailable, Breadth drops `ad_score` and reweights: `breadth_score * 0.57 + rsp_score * 0.43`
|
||||
- Fallback: Finnhub candle API for ETF symbols; breadth symbols Yahoo-only
|
||||
|
||||
---
|
||||
|
||||
## Scoring Formulas
|
||||
|
||||
### 1. Sentiment (10%)
|
||||
|
||||
```
|
||||
inputs: CNN_FG, AAII_Bull, AAII_Bear (AAII is LOW reliability — blocks bots)
|
||||
|
||||
// Normal path (AAII available):
|
||||
score = (CNN_FG * 0.4) + (AAII_Bull_Percentile * 0.3) + ((100 - AAII_Bear_Percentile) * 0.3)
|
||||
|
||||
// Degraded path (AAII unavailable — store aaiBull/aaiBear as null, not 0):
|
||||
score = CNN_FG // 100% weight on CNN F&G; crypto F&G from Redis as secondary signal if CNN also fails
|
||||
// aaiBull and aaiBear fields: null (not 0 — zero skews score toward Extreme Fear)
|
||||
```
|
||||
|
||||
**Reliability notes:** CNN F&G is MEDIUM reliability. If both CNN and AAII fail, use `cryptoFearGreed` from Redis (already seeded via macro-signals) as a proxy — it is directionally correlated. Mark `unavailable: true` only if all three sentiment sources are absent.
|
||||
|
||||
### 2. Volatility (10%)
|
||||
|
||||
```
|
||||
inputs: VIX, VIX_Term_Structure
|
||||
vix_score = clamp(100 - ((VIX - 12) / 28) * 100, 0, 100) // VIX 12=100, VIX 40=0
|
||||
term_score = contango ? 70 : backwardation ? 30 : 50
|
||||
score = vix_score * 0.7 + term_score * 0.3
|
||||
```
|
||||
|
||||
### 3. Positioning (15%)
|
||||
|
||||
```
|
||||
inputs: Put_Call_Ratio, Options_Skew
|
||||
pc_score = clamp(100 - ((PC_Ratio - 0.7) / 0.6) * 100, 0, 100) // 0.7=greed, 1.3=fear
|
||||
skew_score = clamp(100 - ((SKEW - 100) / 50) * 100, 0, 100)
|
||||
score = pc_score * 0.6 + skew_score * 0.4
|
||||
```
|
||||
|
||||
### 4. Trend (10%)
|
||||
|
||||
```
|
||||
inputs: SPX_Price, SMA20, SMA50, SMA200
|
||||
above_count = count(price > SMA20, price > SMA50, price > SMA200)
|
||||
distance_200 = (price - SMA200) / SMA200
|
||||
score = (above_count / 3) * 50 + clamp(distance_200 * 500 + 50, 0, 100) * 0.5
|
||||
```
|
||||
|
||||
### 5. Breadth (10%)
|
||||
|
||||
```
|
||||
inputs: Pct_Above_200DMA, Advance_Decline, RSP_SPY_Divergence
|
||||
breadth_score = Pct_Above_200DMA // already 0-100
|
||||
ad_score = clamp((AD_Ratio - 0.5) / 1.5 * 100, 0, 100)
|
||||
rsp_score = clamp(RSP_SPY_30d_diff * 10 + 50, 0, 100)
|
||||
score = breadth_score * 0.4 + ad_score * 0.3 + rsp_score * 0.3
|
||||
```
|
||||
|
||||
### 6. Momentum (10%)
|
||||
|
||||
```
|
||||
inputs: Sector_RSI_Spread, SPX_ROC_20d
|
||||
rsi_score = clamp((avg_sector_rsi - 30) / 40 * 100, 0, 100)
|
||||
roc_score = clamp(SPX_ROC_20d * 10 + 50, 0, 100)
|
||||
score = rsi_score * 0.5 + roc_score * 0.5
|
||||
```
|
||||
|
||||
### 7. Liquidity (15%)
|
||||
|
||||
```
|
||||
inputs: M2_YoY_Change, Fed_Balance_Sheet_Change, SOFR_Rate
|
||||
m2_score = clamp(M2_YoY * 10 + 50, 0, 100)
|
||||
fed_score = clamp(Fed_BS_MoM * 20 + 50, 0, 100)
|
||||
sofr_score = clamp(100 - SOFR * 15, 0, 100)
|
||||
score = m2_score * 0.4 + fed_score * 0.3 + sofr_score * 0.3
|
||||
```
|
||||
|
||||
### 8. Credit (10%)
|
||||
|
||||
```
|
||||
inputs: HY_Spread, IG_Spread, HY_Spread_Change_30d
|
||||
hy_score = clamp(100 - ((HY_Spread - 3.0) / 5.0) * 100, 0, 100)
|
||||
ig_score = clamp(100 - ((IG_Spread - 0.8) / 2.0) * 100, 0, 100)
|
||||
trend_score = HY_narrowing ? 70 : HY_widening ? 30 : 50
|
||||
score = hy_score * 0.4 + ig_score * 0.3 + trend_score * 0.3
|
||||
```
|
||||
|
||||
### 9. Macro (5%)
|
||||
|
||||
```
|
||||
inputs: Fed_Rate, Yield_Curve_10Y2Y, Unemployment_Trend
|
||||
rate_score = clamp(100 - Fed_Rate * 15, 0, 100)
|
||||
curve_score = T10Y2Y > 0 ? 60 + T10Y2Y * 20 : 40 + T10Y2Y * 40
|
||||
unemp_score = clamp(100 - (UNRATE - 3.5) * 20, 0, 100)
|
||||
score = rate_score * 0.3 + curve_score * 0.4 + unemp_score * 0.3
|
||||
```
|
||||
|
||||
### 10. Cross-Asset (5%)
|
||||
|
||||
```
|
||||
inputs: Gold_vs_SPY_30d, TLT_vs_SPY_30d, DXY_30d_Change
|
||||
gold_signal = Gold_30d > SPY_30d ? fear : greed
|
||||
bond_signal = TLT_30d > SPY_30d ? fear : greed
|
||||
dxy_signal = DXY_rising ? slight_fear : slight_greed
|
||||
score = weighted combination with mean reversion
|
||||
```
|
||||
|
||||
### Computed Metrics (derived from fetched data, no extra API calls)
|
||||
|
||||
| Metric | Inputs | Formula | Category |
|
||||
|--------|--------|---------|----------|
|
||||
| SPX 20/50/200 DMA | ^GSPC closes | `smaCalc(prices, period)` | Trend |
|
||||
| SPX ROC 20d | ^GSPC closes | `rateOfChange(prices, 20)` | Momentum |
|
||||
| VIX Term Structure | ^VIX, ^VIX9D, ^VIX3M | `VIX/VIX3M` ratio (<1 = contango) | Volatility |
|
||||
| Sector RSI (14d) | XLK/XLF/XLE/XLV | Standard RSI formula | Momentum |
|
||||
| Cross-asset 30d returns | GLD, TLT, SPY, DXY | `rateOfChange(prices, 30)` | Cross-Asset |
|
||||
| M2 YoY change | M2SL | `(latest - 12mo_ago) / 12mo_ago` | Liquidity |
|
||||
| Fed BS MoM change | WALCL | `(latest - 4wk_ago) / 4wk_ago` | Liquidity |
|
||||
| HY spread trend | BAMLH0A0HYM2 | `30d change direction` | Credit |
|
||||
| RSP/SPY ratio | RSP, SPY | `RSP_return_30d - SPY_return_30d` | Breadth |
|
||||
|
||||
---
|
||||
|
||||
## Seed Script: `seed-fear-greed.mjs`
|
||||
|
||||
Follows the existing pattern: Railway cron → fetch external APIs → compute scores → atomic publish to Redis → server handler reads from Redis.
|
||||
|
||||
### Redis Keys
|
||||
|
||||
```
|
||||
market:fear-greed:v1 # Composite index + all category scores
|
||||
market:fear-greed:history:v1 # Sorted set — daily snapshots for sparklines (score UNIX score, member ISO date string)
|
||||
seed-meta:market:fear-greed # Metadata (fetchedAt, recordCount, sourceVersion)
|
||||
seed-lock:market:fear-greed # Concurrency lock
|
||||
```
|
||||
|
||||
**TTL**: 64800s (18h) — 3× the 6h cron interval. Required to survive 2 missed cron cycles (Railway downtime, deploy gaps). `runSeed()` extends this same TTL on both fetch-failure and empty-data paths.
|
||||
**Cron**: `0 0,6,12,18 * * *` (every 6h)
|
||||
**health.js `maxStaleMin`**: 720 (12h) — 2× interval. One missed cycle never fires a spurious WARN; the 20min self-heal from `runSeed()` retry covers transient failures.
|
||||
|
||||
**`composite.previous` requires a pre-write Redis GET.** Before calling `runSeed()`, read `market:fear-greed:v1` from Redis, extract `composite.score`, pass it into `publishTransform` as `previous`. `runSeed()` then overwrites the key atomically. Do NOT compute `previous` after the write — the key is already overwritten.
|
||||
|
||||
### API Call Budget
|
||||
|
||||
| Source | Calls | Rate Limited? | Auth |
|
||||
|--------|-------|--------------|------|
|
||||
| Yahoo Finance | 19 symbols | 150ms gaps | User-Agent only |
|
||||
| CBOE CDN | 2 CSVs | No | None |
|
||||
| CNN dataviz | 1 | No | User-Agent only |
|
||||
| AAII | 1 | Blocks bots | User-Agent + scrape |
|
||||
| Redis reads | ~10 FRED series | No | Bearer token |
|
||||
| **Total** | **~33** | — | — |
|
||||
|
||||
**Estimated runtime**: ~3s (Yahoo sequential) + ~2s (CBOE/CNN/AAII parallel) + ~1s (Redis) = **~6s per run**
|
||||
|
||||
**Timeouts**: Set `AbortSignal.timeout(8000)` on AAII scrape (frequently stalls). AAII failure must not block the entire seed run — wrap in `try/catch`, log warn, continue with degraded Sentiment scoring.
|
||||
|
||||
### Output Schema (stored in Redis)
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2026-03-24T12:00:00Z",
|
||||
"composite": {
|
||||
"score": 38.7,
|
||||
"label": "Fear",
|
||||
"previous": 41.2
|
||||
},
|
||||
"categories": {
|
||||
"sentiment": { "score": 19, "weight": 0.10, "contribution": 1.9, "inputs": { "cnnFearGreed": 16, "cnnLabel": "Extreme Fear", "aaiBull": 30.4, "aaiBear": 52.0 }, "degraded": false },
|
||||
// degraded: true when AAII unavailable; aaiBull/aaiBear: null (not 0) when AAII fetch fails
|
||||
"volatility": { "score": 47, "weight": 0.10, "contribution": 4.7, "inputs": { "vix": 26.78, "vixChange": 11.31, "vix9d": 28.1, "vix3m": 24.5, "termStructure": "backwardation" } },
|
||||
"positioning": { "score": 34, "weight": 0.15, "contribution": 5.1, "inputs": { "putCallRatio": 1.01, "putCallAvg": 0.87, "skew": 135 } },
|
||||
"trend": { "score": 52, "weight": 0.10, "contribution": 5.2, "inputs": { "spxPrice": 5667, "sma20": 5580, "sma50": 5520, "sma200": 5200, "aboveMaCount": 3 } },
|
||||
"breadth": { "score": 40, "weight": 0.10, "contribution": 4.0, "inputs": { "pctAbove200d": 43.93, "rspSpyRatio": -2.1, "advDecRatio": 0.85 } },
|
||||
"momentum": { "score": 13, "weight": 0.10, "contribution": 1.3, "inputs": { "spxRoc20d": -3.2, "sectorRsiAvg": 38, "leadersVsLaggards": -12.5 } },
|
||||
"liquidity": { "score": 26, "weight": 0.15, "contribution": 3.9, "inputs": { "m2Yoy": 1.2, "fedBsMom": -0.8, "sofr": 5.31 } },
|
||||
"credit": { "score": 68, "weight": 0.10, "contribution": 6.8, "inputs": { "hySpread": 3.27, "igSpread": 1.15, "hyTrend30d": "narrowing" } },
|
||||
"macro": { "score": 44, "weight": 0.05, "contribution": 2.2, "inputs": { "fedRate": 3.625, "t10y2y": 0.15, "unrate": 4.1 } },
|
||||
"crossAsset": { "score": 72, "weight": 0.05, "contribution": 3.6, "inputs": { "goldReturn30d": 4.2, "tltReturn30d": 1.8, "spyReturn30d": -2.1, "dxyChange30d": -1.5 } }
|
||||
},
|
||||
"headerMetrics": {
|
||||
"cnnFearGreed": { "value": 16, "label": "Extreme Fear" },
|
||||
"aaiBear": { "value": 52, "context": "6-wk high" },
|
||||
"aaiBull": { "value": 30.4, "context": "Below avg" },
|
||||
"putCall": { "value": 1.01, "context": "vs 0.87 yr avg" },
|
||||
"vix": { "value": 26.78, "context": "+11.31%" },
|
||||
"hySpread": { "value": 3.27, "context": "vs LT avg" },
|
||||
"pctAbove200d": { "value": 43.93, "context": "Down from 68.5%" },
|
||||
"yield10y": { "value": 4.25 },
|
||||
"fedRate": { "value": "3.50-3.75%" }
|
||||
},
|
||||
"unavailable": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Data Layer
|
||||
|
||||
1. Add `BAMLC0A0CM` and `SOFR` to `seed-economy.mjs` FRED_SERIES array
|
||||
- Note: SOFR is weekly cadence from FRED, not daily — Liquidity formula is stable between releases
|
||||
2. Validate `C:ISSU` symbol returns advance/decline data via Yahoo `/v8/finance/chart` — confirm before building Breadth formula around it
|
||||
3. Create `seed-fear-greed.mjs`:
|
||||
- TTL: **64800s** (18h = 3× interval)
|
||||
- AAII fetch: `AbortSignal.timeout(8000)`, wrapped in `try/catch` — failure uses degraded Sentiment scoring
|
||||
- Pre-write step: GET `market:fear-greed:v1` from Redis, extract `composite.score` as `previous`, pass via `publishTransform`
|
||||
- `runSeed()` calls `process.exit(0)` — all extra key writes (e.g. history key) must use the `extraKeys` option, NOT code after the `runSeed()` call
|
||||
4. Register with **bootstrap 4-file checklist**:
|
||||
- `cache-keys.ts` — add `market:fear-greed:v1`
|
||||
- `api/bootstrap.js` — register the key
|
||||
- `health.js` — classify as `BOOTSTRAP_KEYS` (seeded, CRIT if empty); set `maxStaleMin: 720` (12h = 2× interval)
|
||||
- `gateway.ts` — wire `GetFearGreedIndex` RPC
|
||||
|
||||
### Phase 2: Proto + RPC
|
||||
|
||||
5. New proto: `proto/worldmonitor/market/v1/fear_greed.proto`
|
||||
- `GetFearGreedIndex` RPC
|
||||
- Messages for composite score, category scores, and header metrics
|
||||
6. New handler: `server/worldmonitor/market/v1/get-fear-greed-index.ts`
|
||||
- Reads computed data from Redis, returns structured response
|
||||
|
||||
### Phase 3: Frontend Panel
|
||||
|
||||
7. New component: `src/components/FearGreedPanel.ts`
|
||||
- Gauge — semicircular 0–100 dial with color gradient (red→yellow→green)
|
||||
- Header grid — 9 key metrics with contextual annotations
|
||||
- Category breakdown — expandable cards per category (score, weight, contribution, bar)
|
||||
- Handle `degraded: true` on Sentiment card (show "AAII unavailable" note)
|
||||
8. Register in finance variant panel config
|
||||
|
||||
### Phase 4: Polish
|
||||
|
||||
9. Historical sparklines — append daily snapshot to `market:fear-greed:history:v1` (sorted set, score = UNIX timestamp, member = ISO date + composite score JSON). Write via `extraKeys` in Phase 1 seeder. TTL: 90 days (7776000s). Frontend reads this key for trend sparkline.
|
||||
10. Alerts on threshold crossings (e.g. score drops below 20)
|
||||
|
||||
---
|
||||
|
||||
## MVP Path
|
||||
|
||||
Build the initial version using only data we already have + easy additions:
|
||||
|
||||
1. **Volatility** — VIX from FRED
|
||||
2. **Credit** — HY + IG spread from FRED
|
||||
3. **Macro** — Fed rate + yield curve + unemployment from FRED
|
||||
4. **Trend** — SPX price vs computed MAs from Yahoo
|
||||
5. **Liquidity** — M2 + Fed balance sheet from FRED + SOFR
|
||||
6. **Sentiment** — CNN F&G endpoint + crypto F&G (already have)
|
||||
7. **Momentum** — Sector ETF returns from Yahoo
|
||||
8. **Cross-Asset** — GLD/TLT/SPY/DXY returns from Yahoo
|
||||
9. **Positioning** — CBOE put/call CSVs + SKEW from Yahoo
|
||||
10. **Breadth** — ^MMTH + RSP/SPY divergence + C:ISSU from Yahoo
|
||||
|
||||
All 10 categories covered from day one. No paid sources needed.
|
||||
43
proto/worldmonitor/market/v1/get_fear_greed_index.proto
Normal file
43
proto/worldmonitor/market/v1/get_fear_greed_index.proto
Normal file
@@ -0,0 +1,43 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.market.v1;
|
||||
|
||||
import "sebuf/http/annotations.proto";
|
||||
|
||||
message GetFearGreedIndexRequest {}
|
||||
|
||||
message FearGreedCategory {
|
||||
double score = 1;
|
||||
double weight = 2;
|
||||
double contribution = 3;
|
||||
bool degraded = 4;
|
||||
string inputs_json = 5;
|
||||
}
|
||||
|
||||
message GetFearGreedIndexResponse {
|
||||
double composite_score = 1;
|
||||
string composite_label = 2;
|
||||
double previous_score = 3;
|
||||
string seeded_at = 4;
|
||||
FearGreedCategory sentiment = 5;
|
||||
FearGreedCategory volatility = 6;
|
||||
FearGreedCategory positioning = 7;
|
||||
FearGreedCategory trend = 8;
|
||||
FearGreedCategory breadth = 9;
|
||||
FearGreedCategory momentum = 10;
|
||||
FearGreedCategory liquidity = 11;
|
||||
FearGreedCategory credit = 12;
|
||||
FearGreedCategory macro = 13;
|
||||
FearGreedCategory cross_asset = 14;
|
||||
double vix = 15;
|
||||
double hy_spread = 16;
|
||||
double yield_10y = 17;
|
||||
double put_call_ratio = 18;
|
||||
double pct_above_200d = 19;
|
||||
double cnn_fear_greed = 20;
|
||||
string cnn_label = 21;
|
||||
double aaii_bull = 22;
|
||||
double aaii_bear = 23;
|
||||
string fed_rate = 24;
|
||||
bool unavailable = 25;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ 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";
|
||||
import "worldmonitor/market/v1/get_fear_greed_index.proto";
|
||||
|
||||
// MarketService provides APIs for financial market data from Finnhub, Yahoo Finance, and CoinGecko.
|
||||
service MarketService {
|
||||
@@ -103,4 +104,9 @@ service MarketService {
|
||||
rpc ListOtherTokens(ListOtherTokensRequest) returns (ListOtherTokensResponse) {
|
||||
option (sebuf.http.config) = {path: "/list-other-tokens", method: HTTP_METHOD_GET};
|
||||
}
|
||||
|
||||
// GetFearGreedIndex retrieves the composite Fear & Greed sentiment index.
|
||||
rpc GetFearGreedIndex(GetFearGreedIndexRequest) returns (GetFearGreedIndexResponse) {
|
||||
option (sebuf.http.config) = {path: "/get-fear-greed-index", method: HTTP_METHOD_GET};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ const ENERGY_TTL = 3600;
|
||||
const CAPACITY_TTL = 86400;
|
||||
const MACRO_TTL = 21600; // 6h — survive extended Yahoo outages
|
||||
|
||||
const FRED_SERIES = ['WALCL', 'FEDFUNDS', 'T10Y2Y', 'UNRATE', 'CPIAUCSL', 'DGS10', 'VIXCLS', 'GDP', 'M2SL', 'DCOILWTICO', 'BAMLH0A0HYM2', 'ICSA', 'MORTGAGE30US'];
|
||||
const FRED_SERIES = ['WALCL', 'FEDFUNDS', 'T10Y2Y', 'UNRATE', 'CPIAUCSL', 'DGS10', 'VIXCLS', 'GDP', 'M2SL', 'DCOILWTICO', 'BAMLH0A0HYM2', 'ICSA', 'MORTGAGE30US', 'BAMLC0A0CM', 'SOFR'];
|
||||
|
||||
// ─── EIA Energy Prices (WTI + Brent) ───
|
||||
|
||||
|
||||
411
scripts/seed-fear-greed.mjs
Normal file
411
scripts/seed-fear-greed.mjs
Normal file
@@ -0,0 +1,411 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { loadEnvFile, CHROME_UA, runSeed, readSeedSnapshot, sleep } from './_seed-utils.mjs';
|
||||
|
||||
loadEnvFile(import.meta.url);
|
||||
|
||||
const FEAR_GREED_KEY = 'market:fear-greed:v1';
|
||||
const FEAR_GREED_TTL = 64800; // 18h = 3x 6h interval
|
||||
|
||||
const FRED_PREFIX = 'economic:fred:v1';
|
||||
|
||||
// --- Yahoo Finance fetching (16 symbols, 150ms gaps) ---
|
||||
const YAHOO_SYMBOLS = ['^GSPC','^VIX','^VIX9D','^VIX3M','^SKEW','^MMTH','C:ISSU','GLD','TLT','SPY','RSP','DX-Y.NYB','XLK','XLF','XLE','XLV'];
|
||||
|
||||
async function fetchYahooSymbol(symbol) {
|
||||
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?interval=1d&range=3mo`;
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
headers: { 'User-Agent': CHROME_UA, Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!resp.ok) { console.warn(` Yahoo ${symbol}: HTTP ${resp.status}`); return null; }
|
||||
const data = await resp.json();
|
||||
const result = data?.chart?.result?.[0];
|
||||
if (!result) return null;
|
||||
const closes = result.indicators?.quote?.[0]?.close ?? [];
|
||||
const validCloses = closes.filter(v => v != null);
|
||||
const price = result.meta?.regularMarketPrice ?? validCloses.at(-1) ?? null;
|
||||
return { symbol, price, closes: validCloses };
|
||||
} catch (e) {
|
||||
console.warn(` Yahoo ${symbol}: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllYahoo() {
|
||||
const results = {};
|
||||
for (const sym of YAHOO_SYMBOLS) {
|
||||
results[sym] = await fetchYahooSymbol(sym);
|
||||
await sleep(150);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// --- CBOE P/C ratios ---
|
||||
async function fetchCBOE() {
|
||||
const [totalResp, equityResp] = await Promise.allSettled([
|
||||
fetch('https://cdn.cboe.com/api/global/us_indices/daily_prices/totalpc.csv', { headers: { 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(10_000) }),
|
||||
fetch('https://cdn.cboe.com/api/global/us_indices/daily_prices/equitypc.csv', { headers: { 'User-Agent': CHROME_UA }, signal: AbortSignal.timeout(10_000) }),
|
||||
]);
|
||||
const parseLastValue = async (resp) => {
|
||||
if (resp.status !== 'fulfilled' || !resp.value.ok) return null;
|
||||
const text = await resp.value.text();
|
||||
const lines = text.trim().split('\n').filter(l => l.trim());
|
||||
const last = lines.at(-1)?.split(',');
|
||||
return last?.length >= 2 ? parseFloat(last[1]) : null;
|
||||
};
|
||||
const [totalPc, equityPc] = await Promise.all([parseLastValue(totalResp), parseLastValue(equityResp)]);
|
||||
return { totalPc, equityPc };
|
||||
}
|
||||
|
||||
// --- CNN Fear & Greed ---
|
||||
async function fetchCNN() {
|
||||
try {
|
||||
const date = new Date().toISOString().slice(0,10).replace(/-/g,'');
|
||||
const resp = await fetch(`https://production.dataviz.cnn.io/index/fearandgreed/graphdata/${date}`, {
|
||||
headers: { 'User-Agent': CHROME_UA, Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
const score = data?.fear_and_greed?.score;
|
||||
const rating = data?.fear_and_greed?.rating;
|
||||
return score != null ? { score: Math.round(score), label: rating ?? labelFromScore(Math.round(score)) } : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// --- AAII Sentiment (LOW reliability, always wrapped, non-blocking) ---
|
||||
async function fetchAAII() {
|
||||
try {
|
||||
const resp = await fetch('https://www.aaii.com/sentimentsurvey/sent_results', {
|
||||
headers: { 'User-Agent': CHROME_UA, Accept: 'text/html,application/xhtml+xml' },
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const html = await resp.text();
|
||||
const bullMatch = html.match(/Bullish[^%]*?([\d.]+)%/i);
|
||||
const bearMatch = html.match(/Bearish[^%]*?([\d.]+)%/i);
|
||||
if (!bullMatch || !bearMatch) return null;
|
||||
return { bull: parseFloat(bullMatch[1]), bear: parseFloat(bearMatch[1]) };
|
||||
} catch (e) {
|
||||
console.warn(' AAII: fetch failed:', e.message, '(using degraded Sentiment)');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- FRED Redis reads ---
|
||||
async function readFred(seriesId) {
|
||||
const url = process.env.UPSTASH_REDIS_REST_URL;
|
||||
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
|
||||
if (!url || !token) return null;
|
||||
try {
|
||||
const resp = await fetch(`${url}/get/${encodeURIComponent(`${FRED_PREFIX}:${seriesId}:0`)}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const { result } = await resp.json();
|
||||
if (!result) return null;
|
||||
const parsed = JSON.parse(result);
|
||||
const obs = parsed?.series?.observations;
|
||||
if (!obs?.length) return null;
|
||||
return obs;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function readMacroSignals() {
|
||||
const url = process.env.UPSTASH_REDIS_REST_URL;
|
||||
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
|
||||
if (!url || !token) return null;
|
||||
try {
|
||||
const resp = await fetch(`${url}/get/${encodeURIComponent('economic:macro-signals:v1')}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const { result } = await resp.json();
|
||||
return result ? JSON.parse(result) : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// --- Math helpers ---
|
||||
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
||||
function sma(prices, period) {
|
||||
if (prices.length < period) return null;
|
||||
return prices.slice(-period).reduce((a,b) => a+b, 0) / period;
|
||||
}
|
||||
function roc(prices, period) {
|
||||
if (prices.length < period+1) return null;
|
||||
const prev = prices[prices.length - period - 1];
|
||||
const curr = prices[prices.length - 1];
|
||||
return prev ? ((curr - prev) / prev) * 100 : null;
|
||||
}
|
||||
function rsi(prices, period=14) {
|
||||
if (prices.length < period+1) return 50;
|
||||
let gains=0, losses=0;
|
||||
for (let i=prices.length-period; i<prices.length; i++) {
|
||||
const d = prices[i]-prices[i-1];
|
||||
if (d>0) gains+=d; else losses+=Math.abs(d);
|
||||
}
|
||||
if (losses===0) return 100;
|
||||
const rs = (gains/period)/(losses/period);
|
||||
return 100 - (100/(1+rs));
|
||||
}
|
||||
function fredLatest(obs) {
|
||||
if (!obs) return null;
|
||||
const v = parseFloat(obs.at(-1)?.value ?? 'NaN');
|
||||
return Number.isFinite(v) ? v : null;
|
||||
}
|
||||
function fredNMonthsAgo(obs, months) {
|
||||
if (!obs) return null;
|
||||
const idx = obs.length - 1 - months;
|
||||
if (idx < 0) return null;
|
||||
const v = parseFloat(obs[idx]?.value ?? 'NaN');
|
||||
return Number.isFinite(v) ? v : null;
|
||||
}
|
||||
function labelFromScore(s) {
|
||||
if (s <= 20) return 'Extreme Fear';
|
||||
if (s <= 40) return 'Fear';
|
||||
if (s <= 60) return 'Neutral';
|
||||
if (s <= 80) return 'Greed';
|
||||
return 'Extreme Greed';
|
||||
}
|
||||
|
||||
// --- Scoring ---
|
||||
function scoreCategory(name, inputs) {
|
||||
switch(name) {
|
||||
case 'sentiment': {
|
||||
const { cnnFg, aaiBull, aaiBear, cryptoFg } = inputs;
|
||||
const degraded = aaiBull == null || aaiBear == null;
|
||||
let score;
|
||||
if (!degraded) {
|
||||
const bullPct = clamp(aaiBull, 0, 100);
|
||||
const bearPct = clamp(aaiBear, 0, 100);
|
||||
const bullPercentile = clamp((bullPct / 60) * 100, 0, 100);
|
||||
const bearPercentile = clamp((bearPct / 55) * 100, 0, 100);
|
||||
if (cnnFg != null) {
|
||||
score = (cnnFg * 0.4) + (bullPercentile * 0.3) + ((100 - bearPercentile) * 0.3);
|
||||
} else {
|
||||
score = (bullPercentile * 0.5) + ((100 - bearPercentile) * 0.5);
|
||||
}
|
||||
} else if (cnnFg != null) {
|
||||
score = cnnFg;
|
||||
} else if (cryptoFg != null) {
|
||||
score = cryptoFg;
|
||||
} else {
|
||||
score = 50;
|
||||
}
|
||||
return { score: clamp(Math.round(score), 0, 100), degraded, inputs: { cnnFearGreed: cnnFg, aaiBull: aaiBull ?? null, aaiBear: aaiBear ?? null, cryptoFg } };
|
||||
}
|
||||
case 'volatility': {
|
||||
const { vix, vix9d, vix3m } = inputs;
|
||||
if (vix == null) return { score: 50, inputs };
|
||||
const vixScore = clamp(100 - ((vix - 12) / 28) * 100, 0, 100);
|
||||
const termScore = (vix9d != null && vix3m != null) ? (vix / vix3m < 1 ? 70 : 30) : 50;
|
||||
const termStructure = (vix9d != null && vix3m != null) ? (vix / vix3m < 1 ? 'contango' : 'backwardation') : 'unknown';
|
||||
return { score: Math.round(vixScore * 0.7 + termScore * 0.3), inputs: { vix, vix9d, vix3m, termStructure } };
|
||||
}
|
||||
case 'positioning': {
|
||||
const { totalPc, equityPc, skew } = inputs;
|
||||
const pc = totalPc ?? equityPc;
|
||||
if (pc == null && skew == null) return { score: 50, inputs };
|
||||
const pcScore = pc != null ? clamp(100 - ((pc - 0.7) / 0.6) * 100, 0, 100) : 50;
|
||||
const skewScore = skew != null ? clamp(100 - ((skew - 100) / 50) * 100, 0, 100) : 50;
|
||||
const w = pc != null && skew != null ? [0.6, 0.4] : [1.0, 0.0];
|
||||
return { score: Math.round(pcScore * w[0] + skewScore * w[1]), inputs: { putCallRatio: pc, skew } };
|
||||
}
|
||||
case 'trend': {
|
||||
const { prices } = inputs;
|
||||
if (!prices?.length) return { score: 50, inputs: {} };
|
||||
const price = prices.at(-1);
|
||||
const s20 = sma(prices, 20), s50 = sma(prices, 50), s200 = sma(prices, 200);
|
||||
const aboveCount = [s20, s50, s200].filter(s => s != null && price > s).length;
|
||||
const dist200 = s200 ? (price - s200) / s200 : 0;
|
||||
const score = (aboveCount / 3) * 50 + clamp(dist200 * 500 + 50, 0, 100) * 0.5;
|
||||
return { score: Math.round(clamp(score, 0, 100)), inputs: { spxPrice: price, sma20: s20, sma50: s50, sma200: s200, aboveMaCount: aboveCount } };
|
||||
}
|
||||
case 'breadth': {
|
||||
const { mmthPrice, rspCloses, spyCloses, advDecRatio } = inputs;
|
||||
const breadthScore = mmthPrice != null ? clamp(mmthPrice, 0, 100) : 50;
|
||||
const rspRoc = (rspCloses?.length && spyCloses?.length) ? (roc(rspCloses, 30) ?? 0) - (roc(spyCloses, 30) ?? 0) : null;
|
||||
const rspScore = rspRoc != null ? clamp(rspRoc * 10 + 50, 0, 100) : 50;
|
||||
const adScore = advDecRatio != null ? clamp((advDecRatio - 0.5) / 1.5 * 100, 0, 100) : 50;
|
||||
const hasAd = advDecRatio != null;
|
||||
const w = hasAd ? [0.4, 0.3, 0.3] : [0.57, 0, 0.43];
|
||||
const score = breadthScore * w[0] + adScore * w[1] + rspScore * w[2];
|
||||
return { score: Math.round(clamp(score, 0, 100)), inputs: { pctAbove200d: mmthPrice, rspSpyRatio: rspRoc, advDecRatio: advDecRatio ?? null } };
|
||||
}
|
||||
case 'momentum': {
|
||||
const { spxCloses, sectorCloses } = inputs;
|
||||
const spxRoc = spxCloses?.length ? roc(spxCloses, 20) : null;
|
||||
const rocScore = spxRoc != null ? clamp(spxRoc * 10 + 50, 0, 100) : 50;
|
||||
const sectorRsiValues = sectorCloses ? Object.values(sectorCloses).filter(Boolean).map(c => rsi(c)) : [];
|
||||
const avgRsi = sectorRsiValues.length ? sectorRsiValues.reduce((a,b)=>a+b,0)/sectorRsiValues.length : 50;
|
||||
const rsiScore = clamp((avgRsi - 30) / 40 * 100, 0, 100);
|
||||
return { score: Math.round((rsiScore * 0.5 + rocScore * 0.5)), inputs: { spxRoc20d: spxRoc, sectorRsiAvg: Math.round(avgRsi) } };
|
||||
}
|
||||
case 'liquidity': {
|
||||
const { m2Obs, walclObs, sofr } = inputs;
|
||||
const m2Latest = fredLatest(m2Obs), m2Ago = fredNMonthsAgo(m2Obs, 12);
|
||||
const m2Yoy = (m2Latest && m2Ago && m2Ago !== 0) ? ((m2Latest - m2Ago) / m2Ago) * 100 : null;
|
||||
const walclLatest = fredLatest(walclObs), walclAgo = fredNMonthsAgo(walclObs, 1);
|
||||
const fedBsMom = (walclLatest && walclAgo && walclAgo !== 0) ? ((walclLatest - walclAgo) / walclAgo) * 100 : null;
|
||||
const m2Score = m2Yoy != null ? clamp(m2Yoy * 10 + 50, 0, 100) : 50;
|
||||
const fedScore = fedBsMom != null ? clamp(fedBsMom * 20 + 50, 0, 100) : 50;
|
||||
const sofrScore = sofr != null ? clamp(100 - sofr * 15, 0, 100) : 50;
|
||||
return { score: Math.round(m2Score * 0.4 + fedScore * 0.3 + sofrScore * 0.3), inputs: { m2Yoy, fedBsMom, sofr } };
|
||||
}
|
||||
case 'credit': {
|
||||
const { hyObs, igObs } = inputs;
|
||||
const hySpread = fredLatest(hyObs), igSpread = fredLatest(igObs);
|
||||
const hyScore = hySpread != null ? clamp(100 - ((hySpread - 3.0) / 5.0) * 100, 0, 100) : 50;
|
||||
const igScore = igSpread != null ? clamp(100 - ((igSpread - 0.8) / 2.0) * 100, 0, 100) : 50;
|
||||
const hyPrev = fredNMonthsAgo(hyObs, 1);
|
||||
const hyTrend = (hySpread != null && hyPrev != null) ? (hySpread < hyPrev ? 'narrowing' : hySpread > hyPrev ? 'widening' : 'stable') : 'stable';
|
||||
const trendScore = hyTrend === 'narrowing' ? 70 : hyTrend === 'widening' ? 30 : 50;
|
||||
return { score: Math.round(hyScore * 0.4 + igScore * 0.3 + trendScore * 0.3), inputs: { hySpread, igSpread, hyTrend30d: hyTrend } };
|
||||
}
|
||||
case 'macro': {
|
||||
const { fedObs, curveObs, unrateObs } = inputs;
|
||||
const fedRate = fredLatest(fedObs), t10y2y = fredLatest(curveObs), unrate = fredLatest(unrateObs);
|
||||
const rateScore = fedRate != null ? clamp(100 - fedRate * 15, 0, 100) : 50;
|
||||
const curveScore = t10y2y != null ? (t10y2y > 0 ? clamp(60 + t10y2y * 20, 0, 100) : clamp(40 + t10y2y * 40, 0, 100)) : 50;
|
||||
const unempScore = unrate != null ? clamp(100 - (unrate - 3.5) * 20, 0, 100) : 50;
|
||||
return { score: Math.round(rateScore * 0.3 + curveScore * 0.4 + unempScore * 0.3), inputs: { fedRate, t10y2y, unrate } };
|
||||
}
|
||||
case 'crossAsset': {
|
||||
const { gldCloses, tltCloses, spyCloses, dxyCloses } = inputs;
|
||||
const goldRoc = gldCloses?.length ? roc(gldCloses, 30) : null;
|
||||
const tltRoc = tltCloses?.length ? roc(tltCloses, 30) : null;
|
||||
const spyRoc = spyCloses?.length ? roc(spyCloses, 30) : null;
|
||||
const dxyRoc = dxyCloses?.length ? roc(dxyCloses, 30) : null;
|
||||
const goldSignal = (goldRoc != null && spyRoc != null) ? (goldRoc > spyRoc ? 30 : 70) : 50;
|
||||
const bondSignal = (tltRoc != null && spyRoc != null) ? (tltRoc > spyRoc ? 30 : 70) : 50;
|
||||
const dxySignal = dxyRoc != null ? (dxyRoc > 0 ? 40 : 60) : 50;
|
||||
return { score: Math.round((goldSignal + bondSignal + dxySignal) / 3), inputs: { goldReturn30d: goldRoc, tltReturn30d: tltRoc, spyReturn30d: spyRoc, dxyChange30d: dxyRoc } };
|
||||
}
|
||||
default: return { score: 50, inputs };
|
||||
}
|
||||
}
|
||||
|
||||
const WEIGHTS = { sentiment: 0.10, volatility: 0.10, positioning: 0.15, trend: 0.10, breadth: 0.10, momentum: 0.10, liquidity: 0.15, credit: 0.10, macro: 0.05, crossAsset: 0.05 };
|
||||
|
||||
async function fetchAll() {
|
||||
const prevSnapshot = await readSeedSnapshot(FEAR_GREED_KEY).catch(() => null);
|
||||
const previousScore = prevSnapshot?.composite?.score ?? null;
|
||||
|
||||
const [yahooResults, cboeResult, cnnResult, aaiiResult, macroSignals] = await Promise.allSettled([
|
||||
fetchAllYahoo(),
|
||||
fetchCBOE(),
|
||||
fetchCNN(),
|
||||
fetchAAII(),
|
||||
readMacroSignals(),
|
||||
]);
|
||||
|
||||
const yahoo = yahooResults.status === 'fulfilled' ? yahooResults.value : {};
|
||||
const cboe = cboeResult.status === 'fulfilled' ? cboeResult.value : {};
|
||||
const cnn = cnnResult.status === 'fulfilled' ? cnnResult.value : null;
|
||||
const aaii = aaiiResult.status === 'fulfilled' ? aaiiResult.value : null;
|
||||
const macro = macroSignals.status === 'fulfilled' ? macroSignals.value : null;
|
||||
|
||||
if (yahooResults.status === 'rejected') console.warn(' Yahoo batch failed:', yahooResults.reason?.message);
|
||||
if (cboeResult.status === 'rejected') console.warn(' CBOE failed:', cboeResult.reason?.message);
|
||||
if (cnnResult.status === 'rejected') console.warn(' CNN failed:', cnnResult.reason?.message);
|
||||
if (aaiiResult.status === 'rejected') console.warn(' AAII failed:', aaiiResult.reason?.message);
|
||||
|
||||
const [hyObs, igObs, m2Obs, walclObs, sofrObs, fedObs, curveObs, unrateObs, vixObs, dgs10Obs] = await Promise.all([
|
||||
readFred('BAMLH0A0HYM2'), readFred('BAMLC0A0CM'), readFred('M2SL'), readFred('WALCL'),
|
||||
readFred('SOFR'), readFred('FEDFUNDS'), readFred('T10Y2Y'), readFred('UNRATE'), readFred('VIXCLS'), readFred('DGS10'),
|
||||
]);
|
||||
|
||||
const gspc = yahoo['^GSPC'];
|
||||
const vixData = yahoo['^VIX'];
|
||||
const vix9d = yahoo['^VIX9D'];
|
||||
const vix3m = yahoo['^VIX3M'];
|
||||
const skew = yahoo['^SKEW'];
|
||||
const mmth = yahoo['^MMTH'];
|
||||
const cissu = yahoo['C:ISSU'];
|
||||
const gld = yahoo['GLD'], tlt = yahoo['TLT'], spy = yahoo['SPY'], rsp = yahoo['RSP'];
|
||||
const dxy = yahoo['DX-Y.NYB'];
|
||||
const xlk = yahoo['XLK'], xlf = yahoo['XLF'], xle = yahoo['XLE'], xlv = yahoo['XLV'];
|
||||
|
||||
const vixLive = vixData?.price ?? fredLatest(vixObs);
|
||||
const vix9dPrice = vix9d?.price ?? null;
|
||||
const vix3mPrice = vix3m?.price ?? null;
|
||||
const skewPrice = skew?.price ?? null;
|
||||
const mmthPrice = mmth?.price ?? null;
|
||||
const sofrRate = fredLatest(sofrObs);
|
||||
const cryptoFg = macro?.fearGreed?.score ?? macro?.signals?.fearGreed?.value ?? null;
|
||||
|
||||
let advDecRatio = null;
|
||||
if (cissu?.price != null) {
|
||||
advDecRatio = cissu.price > 0 ? Math.min(cissu.price / 100, 2.0) : null;
|
||||
}
|
||||
|
||||
const cats = {
|
||||
sentiment: scoreCategory('sentiment', { cnnFg: cnn?.score ?? null, aaiBull: aaii?.bull ?? null, aaiBear: aaii?.bear ?? null, cryptoFg }),
|
||||
volatility: scoreCategory('volatility', { vix: vixLive, vix9d: vix9dPrice, vix3m: vix3mPrice }),
|
||||
positioning: scoreCategory('positioning', { totalPc: cboe.totalPc, equityPc: cboe.equityPc, skew: skewPrice }),
|
||||
trend: scoreCategory('trend', { prices: gspc?.closes ?? [] }),
|
||||
breadth: scoreCategory('breadth', { mmthPrice, rspCloses: rsp?.closes, spyCloses: spy?.closes, advDecRatio }),
|
||||
momentum: scoreCategory('momentum', { spxCloses: gspc?.closes, sectorCloses: { XLK: xlk?.closes, XLF: xlf?.closes, XLE: xle?.closes, XLV: xlv?.closes } }),
|
||||
liquidity: scoreCategory('liquidity', { m2Obs, walclObs, sofr: sofrRate }),
|
||||
credit: scoreCategory('credit', { hyObs, igObs }),
|
||||
macro: scoreCategory('macro', { fedObs, curveObs, unrateObs }),
|
||||
crossAsset: scoreCategory('crossAsset', { gldCloses: gld?.closes, tltCloses: tlt?.closes, spyCloses: spy?.closes, dxyCloses: dxy?.closes }),
|
||||
};
|
||||
|
||||
const compositeScore = Math.round(
|
||||
Object.entries(cats).reduce((sum, [name, cat]) => sum + cat.score * WEIGHTS[name], 0) * 10
|
||||
) / 10;
|
||||
const compositeLabel = labelFromScore(compositeScore);
|
||||
|
||||
const fedRate = fredLatest(fedObs);
|
||||
const fedRateStr = fedRate != null ? `${fedRate.toFixed(2)}%` : null;
|
||||
const hySpreadVal = fredLatest(hyObs);
|
||||
|
||||
const payload = {
|
||||
timestamp: new Date().toISOString(),
|
||||
composite: { score: compositeScore, label: compositeLabel, previous: previousScore },
|
||||
categories: {
|
||||
sentiment: { score: cats.sentiment.score, weight: WEIGHTS.sentiment, contribution: Math.round(cats.sentiment.score * WEIGHTS.sentiment * 10)/10, inputs: cats.sentiment.inputs, degraded: cats.sentiment.degraded ?? false },
|
||||
volatility: { score: cats.volatility.score, weight: WEIGHTS.volatility, contribution: Math.round(cats.volatility.score * WEIGHTS.volatility * 10)/10, inputs: cats.volatility.inputs },
|
||||
positioning: { score: cats.positioning.score, weight: WEIGHTS.positioning, contribution: Math.round(cats.positioning.score * WEIGHTS.positioning * 10)/10, inputs: cats.positioning.inputs },
|
||||
trend: { score: cats.trend.score, weight: WEIGHTS.trend, contribution: Math.round(cats.trend.score * WEIGHTS.trend * 10)/10, inputs: cats.trend.inputs },
|
||||
breadth: { score: cats.breadth.score, weight: WEIGHTS.breadth, contribution: Math.round(cats.breadth.score * WEIGHTS.breadth * 10)/10, inputs: cats.breadth.inputs },
|
||||
momentum: { score: cats.momentum.score, weight: WEIGHTS.momentum, contribution: Math.round(cats.momentum.score * WEIGHTS.momentum * 10)/10, inputs: cats.momentum.inputs },
|
||||
liquidity: { score: cats.liquidity.score, weight: WEIGHTS.liquidity, contribution: Math.round(cats.liquidity.score * WEIGHTS.liquidity * 10)/10, inputs: cats.liquidity.inputs },
|
||||
credit: { score: cats.credit.score, weight: WEIGHTS.credit, contribution: Math.round(cats.credit.score * WEIGHTS.credit * 10)/10, inputs: cats.credit.inputs },
|
||||
macro: { score: cats.macro.score, weight: WEIGHTS.macro, contribution: Math.round(cats.macro.score * WEIGHTS.macro * 10)/10, inputs: cats.macro.inputs },
|
||||
crossAsset: { score: cats.crossAsset.score, weight: WEIGHTS.crossAsset, contribution: Math.round(cats.crossAsset.score * WEIGHTS.crossAsset * 10)/10, inputs: cats.crossAsset.inputs },
|
||||
},
|
||||
headerMetrics: {
|
||||
cnnFearGreed: cnn ? { value: cnn.score, label: cnn.label } : null,
|
||||
aaiBear: aaii ? { value: Math.round(aaii.bear), context: `${aaii.bear.toFixed(1)}%` } : null,
|
||||
aaiBull: aaii ? { value: Math.round(aaii.bull), context: `${aaii.bull.toFixed(1)}%` } : null,
|
||||
putCall: cboe.totalPc != null ? { value: cboe.totalPc } : null,
|
||||
vix: vixLive != null ? { value: vixLive } : null,
|
||||
hySpread: hySpreadVal != null ? { value: hySpreadVal } : null,
|
||||
pctAbove200d: mmthPrice != null ? { value: mmthPrice } : null,
|
||||
yield10y: fredLatest(dgs10Obs) != null ? { value: fredLatest(dgs10Obs) } : null,
|
||||
fedRate: fedRateStr ? { value: fedRateStr } : null,
|
||||
},
|
||||
unavailable: false,
|
||||
};
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function validate(data) {
|
||||
return data?.composite?.score != null && data.timestamp != null;
|
||||
}
|
||||
|
||||
runSeed('market', 'fear-greed', FEAR_GREED_KEY, fetchAll, {
|
||||
validateFn: validate,
|
||||
ttlSeconds: FEAR_GREED_TTL,
|
||||
sourceVersion: 'yahoo-cboe-cnn-fred-v1',
|
||||
}).catch((err) => {
|
||||
console.error('FATAL:', err.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -61,6 +61,7 @@ export const BOOTSTRAP_CACHE_KEYS: Record<string, string> = {
|
||||
otherTokens: 'market:other-tokens:v1',
|
||||
nationalDebt: 'economic:national-debt:v1',
|
||||
marketImplications: 'intelligence:market-implications:v1',
|
||||
fearGreedIndex: 'market:fear-greed:v1',
|
||||
};
|
||||
|
||||
export const BOOTSTRAP_TIERS: Record<string, 'slow' | 'fast'> = {
|
||||
@@ -92,4 +93,5 @@ export const BOOTSTRAP_TIERS: Record<string, 'slow' | 'fast'> = {
|
||||
otherTokens: 'slow',
|
||||
nationalDebt: 'slow',
|
||||
marketImplications: 'slow',
|
||||
fearGreedIndex: 'slow',
|
||||
};
|
||||
|
||||
@@ -65,6 +65,7 @@ const RPC_CACHE_TIER: Record<string, CacheTier> = {
|
||||
'/api/market/v1/list-commodity-quotes': 'medium',
|
||||
'/api/market/v1/list-stablecoin-markets': 'medium',
|
||||
'/api/market/v1/get-sector-summary': 'medium',
|
||||
'/api/market/v1/get-fear-greed-index': 'slow',
|
||||
'/api/market/v1/list-gulf-quotes': 'medium',
|
||||
'/api/market/v1/analyze-stock': 'slow',
|
||||
'/api/market/v1/get-stock-analysis-history': 'medium',
|
||||
|
||||
61
server/worldmonitor/market/v1/get-fear-greed-index.ts
Normal file
61
server/worldmonitor/market/v1/get-fear-greed-index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type {
|
||||
ServerContext,
|
||||
GetFearGreedIndexRequest,
|
||||
GetFearGreedIndexResponse,
|
||||
FearGreedCategory,
|
||||
} from '../../../../src/generated/server/worldmonitor/market/v1/service_server';
|
||||
import { getCachedJson } from '../../../_shared/redis';
|
||||
|
||||
const SEED_CACHE_KEY = 'market:fear-greed:v1';
|
||||
|
||||
export async function getFearGreedIndex(
|
||||
_ctx: ServerContext,
|
||||
_req: GetFearGreedIndexRequest,
|
||||
): Promise<GetFearGreedIndexResponse> {
|
||||
try {
|
||||
const raw = await getCachedJson(SEED_CACHE_KEY, true) as Record<string, unknown> | null;
|
||||
if (!raw?.composite) return { compositeScore: 0, compositeLabel: '', unavailable: true } as GetFearGreedIndexResponse;
|
||||
|
||||
const comp = raw.composite as Record<string, unknown>;
|
||||
const cats = (raw.categories ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const hdr = (raw.headerMetrics ?? {}) as Record<string, Record<string, unknown> | null>;
|
||||
|
||||
const mapCat = (c: Record<string, unknown> | undefined): FearGreedCategory => ({
|
||||
score: Number(c?.score ?? 50),
|
||||
weight: Number(c?.weight ?? 0),
|
||||
contribution: Number(c?.contribution ?? 0),
|
||||
degraded: Boolean(c?.degraded),
|
||||
inputsJson: JSON.stringify(c?.inputs ?? {}),
|
||||
});
|
||||
|
||||
return {
|
||||
compositeScore: Number(comp.score ?? 0),
|
||||
compositeLabel: String(comp.label ?? ''),
|
||||
previousScore: Number(comp.previous ?? 0),
|
||||
seededAt: String(raw.timestamp ?? ''),
|
||||
sentiment: mapCat(cats.sentiment),
|
||||
volatility: mapCat(cats.volatility),
|
||||
positioning: mapCat(cats.positioning),
|
||||
trend: mapCat(cats.trend),
|
||||
breadth: mapCat(cats.breadth),
|
||||
momentum: mapCat(cats.momentum),
|
||||
liquidity: mapCat(cats.liquidity),
|
||||
credit: mapCat(cats.credit),
|
||||
macro: mapCat(cats.macro),
|
||||
crossAsset: mapCat(cats.crossAsset),
|
||||
vix: Number(hdr?.vix?.value ?? 0),
|
||||
hySpread: Number(hdr?.hySpread?.value ?? 0),
|
||||
yield10y: Number(hdr?.yield10y?.value ?? 0),
|
||||
putCallRatio: Number(hdr?.putCall?.value ?? 0),
|
||||
pctAbove200d: Number(hdr?.pctAbove200d?.value ?? 0),
|
||||
cnnFearGreed: Number(hdr?.cnnFearGreed?.value ?? 0),
|
||||
cnnLabel: String(hdr?.cnnFearGreed?.label ?? ''),
|
||||
aaiiBull: Number(hdr?.aaiBull?.value ?? 0),
|
||||
aaiiBear: Number(hdr?.aaiBear?.value ?? 0),
|
||||
fedRate: String(hdr?.fedRate?.value ?? ''),
|
||||
unavailable: false,
|
||||
};
|
||||
} catch {
|
||||
return { compositeScore: 0, compositeLabel: '', unavailable: true } as GetFearGreedIndexResponse;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { listCryptoSectors } from './list-crypto-sectors';
|
||||
import { listDefiTokens } from './list-defi-tokens';
|
||||
import { listAiTokens } from './list-ai-tokens';
|
||||
import { listOtherTokens } from './list-other-tokens';
|
||||
import { getFearGreedIndex } from './get-fear-greed-index';
|
||||
|
||||
export const marketHandler: MarketServiceHandler = {
|
||||
listMarketQuotes,
|
||||
@@ -47,4 +48,5 @@ export const marketHandler: MarketServiceHandler = {
|
||||
listDefiTokens,
|
||||
listAiTokens,
|
||||
listOtherTokens,
|
||||
getFearGreedIndex,
|
||||
};
|
||||
|
||||
11
src/App.ts
11
src/App.ts
@@ -27,6 +27,7 @@ import type { ServiceStatusPanel } from '@/components/ServiceStatusPanel';
|
||||
import type { StablecoinPanel } from '@/components/StablecoinPanel';
|
||||
import type { ETFFlowsPanel } from '@/components/ETFFlowsPanel';
|
||||
import type { MacroSignalsPanel } from '@/components/MacroSignalsPanel';
|
||||
import type { FearGreedPanel } from '@/components/FearGreedPanel';
|
||||
import type { StrategicPosturePanel } from '@/components/StrategicPosturePanel';
|
||||
import type { StrategicRiskPanel } from '@/components/StrategicRiskPanel';
|
||||
import type { GulfEconomiesPanel } from '@/components/GulfEconomiesPanel';
|
||||
@@ -235,6 +236,10 @@ export class App {
|
||||
const panel = this.state.panels['macro-signals'] as MacroSignalsPanel | undefined;
|
||||
if (panel) primeTask('macro-signals', () => panel.fetchData());
|
||||
}
|
||||
if (shouldPrime('fear-greed')) {
|
||||
const panel = this.state.panels['fear-greed'] as FearGreedPanel | undefined;
|
||||
if (panel) primeTask('fear-greed', () => panel.fetchData());
|
||||
}
|
||||
if (shouldPrime('etf-flows')) {
|
||||
const panel = this.state.panels['etf-flows'] as ETFFlowsPanel | undefined;
|
||||
if (panel) primeTask('etf-flows', () => panel.fetchData());
|
||||
@@ -1068,6 +1073,12 @@ export class App {
|
||||
REFRESH_INTERVALS.macroSignals,
|
||||
() => this.isPanelNearViewport('macro-signals')
|
||||
);
|
||||
this.refreshScheduler.scheduleRefresh(
|
||||
'fear-greed',
|
||||
() => (this.state.panels['fear-greed'] as FearGreedPanel).fetchData(),
|
||||
REFRESH_INTERVALS.fearGreed,
|
||||
() => this.isPanelNearViewport('fear-greed')
|
||||
);
|
||||
this.refreshScheduler.scheduleRefresh(
|
||||
'strategic-posture',
|
||||
() => (this.state.panels['strategic-posture'] as StrategicPosturePanel).refresh(),
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
RuntimeConfigPanel,
|
||||
InsightsPanel,
|
||||
MacroSignalsPanel,
|
||||
FearGreedPanel,
|
||||
ETFFlowsPanel,
|
||||
StablecoinPanel,
|
||||
UcdpEventsPanel,
|
||||
@@ -866,6 +867,7 @@ export class PanelLayoutManager implements AppModule {
|
||||
);
|
||||
|
||||
this.createPanel('macro-signals', () => new MacroSignalsPanel());
|
||||
this.createPanel('fear-greed', () => new FearGreedPanel());
|
||||
this.createPanel('etf-flows', () => new ETFFlowsPanel());
|
||||
this.createPanel('stablecoins', () => new StablecoinPanel());
|
||||
|
||||
|
||||
234
src/components/FearGreedPanel.ts
Normal file
234
src/components/FearGreedPanel.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { Panel } from './Panel';
|
||||
import { t } from '@/services/i18n';
|
||||
import { escapeHtml } from '@/utils/sanitize';
|
||||
import { getHydratedData } from '@/services/bootstrap';
|
||||
|
||||
interface FearGreedData {
|
||||
compositeScore: number;
|
||||
compositeLabel: string;
|
||||
previousScore: number;
|
||||
seededAt: string;
|
||||
sentiment?: CategoryData;
|
||||
volatility?: CategoryData;
|
||||
positioning?: CategoryData;
|
||||
trend?: CategoryData;
|
||||
breadth?: CategoryData;
|
||||
momentum?: CategoryData;
|
||||
liquidity?: CategoryData;
|
||||
credit?: CategoryData;
|
||||
macro?: CategoryData;
|
||||
crossAsset?: CategoryData;
|
||||
vix: number;
|
||||
hySpread: number;
|
||||
yield10y: number;
|
||||
putCallRatio: number;
|
||||
pctAbove200d: number;
|
||||
cnnFearGreed: number;
|
||||
cnnLabel: string;
|
||||
aaiiBull: number;
|
||||
aaiiBear: number;
|
||||
fedRate: string;
|
||||
unavailable?: boolean;
|
||||
}
|
||||
|
||||
interface CategoryData {
|
||||
score: number;
|
||||
weight: number;
|
||||
contribution: number;
|
||||
degraded?: boolean;
|
||||
inputsJson?: string;
|
||||
}
|
||||
|
||||
function scoreColor(score: number): string {
|
||||
if (score <= 20) return '#e74c3c';
|
||||
if (score <= 40) return '#e67e22';
|
||||
if (score <= 60) return '#f1c40f';
|
||||
if (score <= 80) return '#2ecc71';
|
||||
return '#27ae60';
|
||||
}
|
||||
|
||||
function fmt(v: number | null | undefined, digits = 2): string {
|
||||
if (v == null) return 'N/A';
|
||||
return v.toFixed(digits);
|
||||
}
|
||||
|
||||
function mapSeedPayload(raw: Record<string, unknown>): FearGreedData | null {
|
||||
const comp = raw.composite as Record<string, unknown> | undefined;
|
||||
if (!comp?.score) return null;
|
||||
const cats = (raw.categories ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const hdr = (raw.headerMetrics ?? {}) as Record<string, Record<string, unknown> | null>;
|
||||
const mapCat = (c: Record<string, unknown> | undefined): CategoryData | undefined => c ? {
|
||||
score: Number(c.score ?? 50),
|
||||
weight: Number(c.weight ?? 0),
|
||||
contribution: Number(c.contribution ?? 0),
|
||||
degraded: Boolean(c.degraded),
|
||||
inputsJson: JSON.stringify(c.inputs ?? {}),
|
||||
} : undefined;
|
||||
return {
|
||||
compositeScore: Number(comp.score),
|
||||
compositeLabel: String(comp.label ?? ''),
|
||||
previousScore: Number(comp.previous ?? 0),
|
||||
seededAt: String(raw.timestamp ?? ''),
|
||||
sentiment: mapCat(cats.sentiment),
|
||||
volatility: mapCat(cats.volatility),
|
||||
positioning: mapCat(cats.positioning),
|
||||
trend: mapCat(cats.trend),
|
||||
breadth: mapCat(cats.breadth),
|
||||
momentum: mapCat(cats.momentum),
|
||||
liquidity: mapCat(cats.liquidity),
|
||||
credit: mapCat(cats.credit),
|
||||
macro: mapCat(cats.macro),
|
||||
crossAsset: mapCat(cats.crossAsset),
|
||||
vix: Number(hdr?.vix?.value ?? 0),
|
||||
hySpread: Number(hdr?.hySpread?.value ?? 0),
|
||||
yield10y: Number(hdr?.yield10y?.value ?? 0),
|
||||
putCallRatio: Number(hdr?.putCall?.value ?? 0),
|
||||
pctAbove200d: Number(hdr?.pctAbove200d?.value ?? 0),
|
||||
cnnFearGreed: Number(hdr?.cnnFearGreed?.value ?? 0),
|
||||
cnnLabel: String(hdr?.cnnFearGreed?.label ?? ''),
|
||||
aaiiBull: Number(hdr?.aaiBull?.value ?? 0),
|
||||
aaiiBear: Number(hdr?.aaiBear?.value ?? 0),
|
||||
fedRate: String(hdr?.fedRate?.value ?? ''),
|
||||
unavailable: false,
|
||||
};
|
||||
}
|
||||
|
||||
const CAT_NAMES = ['sentiment','volatility','positioning','trend','breadth','momentum','liquidity','credit','macro','crossAsset'] as const;
|
||||
|
||||
const CAT_DISPLAY: Record<string, string> = {
|
||||
sentiment: 'Sentiment',
|
||||
volatility: 'Volatility',
|
||||
positioning: 'Positioning',
|
||||
trend: 'Trend',
|
||||
breadth: 'Breadth',
|
||||
momentum: 'Momentum',
|
||||
liquidity: 'Liquidity',
|
||||
credit: 'Credit',
|
||||
macro: 'Macro',
|
||||
crossAsset: 'Cross-Asset',
|
||||
};
|
||||
|
||||
export class FearGreedPanel extends Panel {
|
||||
private data: FearGreedData | null = null;
|
||||
private loading = true;
|
||||
private error: string | null = null;
|
||||
|
||||
constructor() {
|
||||
super({ id: 'fear-greed', title: t('panels.fearGreed'), showCount: false, infoTooltip: 'Composite sentiment index: 10 weighted categories (volatility, positioning, breadth, momentum, liquidity, credit, macro, cross-asset, sentiment, trend).' });
|
||||
}
|
||||
|
||||
public async fetchData(): Promise<boolean> {
|
||||
const hydrated = getHydratedData('fearGreedIndex') as Record<string, unknown> | undefined;
|
||||
if (hydrated && !hydrated.unavailable) {
|
||||
const mapped = mapSeedPayload(hydrated);
|
||||
if (mapped && mapped.compositeScore > 0) {
|
||||
this.data = mapped;
|
||||
this.loading = false;
|
||||
this.error = null;
|
||||
this.renderPanel();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { MarketServiceClient } = await import('@/generated/client/worldmonitor/market/v1/service_client');
|
||||
const { getRpcBaseUrl } = await import('@/services/rpc-client');
|
||||
const client = new MarketServiceClient(getRpcBaseUrl(), { fetch: (...args: Parameters<typeof fetch>) => globalThis.fetch(...args) });
|
||||
const resp = await client.getFearGreedIndex({});
|
||||
if (resp.unavailable) {
|
||||
this.error = 'Fear & Greed index unavailable';
|
||||
this.loading = false;
|
||||
this.renderPanel();
|
||||
return false;
|
||||
}
|
||||
this.data = resp as FearGreedData;
|
||||
this.loading = false;
|
||||
this.error = null;
|
||||
this.renderPanel();
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.error = e instanceof Error ? e.message : 'Failed to load';
|
||||
this.loading = false;
|
||||
this.renderPanel();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private renderPanel(): void {
|
||||
if (this.loading) {
|
||||
this.setContent('<div class="panel-empty">Loading...</div>');
|
||||
return;
|
||||
}
|
||||
if (this.error || !this.data) {
|
||||
this.setContent(`<div class="panel-empty">${escapeHtml(this.error ?? 'Fear & Greed index unavailable')}</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const d = this.data;
|
||||
const score = d.compositeScore;
|
||||
const label = escapeHtml(d.compositeLabel);
|
||||
const prev = d.previousScore;
|
||||
const delta = prev > 0 ? score - prev : null;
|
||||
const color = scoreColor(score);
|
||||
|
||||
const catRows = CAT_NAMES.map(name => {
|
||||
const c = d[name] as CategoryData | undefined;
|
||||
if (!c) return '';
|
||||
const s = Math.round(c.score ?? 50);
|
||||
const w = Math.round((c.weight ?? 0) * 100);
|
||||
const contrib = (c.contribution ?? 0).toFixed(1);
|
||||
const deg = c.degraded ? ' <span style="color:#e67e22;font-size:10px">degraded</span>' : '';
|
||||
const barColor = scoreColor(s);
|
||||
const displayName = CAT_DISPLAY[name] ?? name;
|
||||
return `
|
||||
<div style="margin:4px 0">
|
||||
<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--text-dim)">
|
||||
<span>${escapeHtml(displayName)}${deg}</span>
|
||||
<span style="color:${barColor};font-weight:600">${s}</span>
|
||||
</div>
|
||||
<div style="height:4px;background:rgba(255,255,255,0.1);border-radius:2px;margin:2px 0">
|
||||
<div style="width:${s}%;height:100%;background:${barColor};border-radius:2px;transition:width 0.3s"></div>
|
||||
</div>
|
||||
<div style="font-size:10px;color:var(--text-dim)">${w}% weight · +${contrib} pts</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const deltaHtml = delta != null
|
||||
? `<span style="font-size:13px;color:${delta >= 0 ? '#2ecc71' : '#e74c3c'}">${delta >= 0 ? '+' : ''}${delta.toFixed(1)} vs prev</span>`
|
||||
: '';
|
||||
|
||||
const hdrMetric = (lbl: string, val: string) =>
|
||||
`<div style="text-align:center;padding:6px 4px">
|
||||
<div style="font-size:18px;font-weight:600;color:var(--text)">${escapeHtml(val)}</div>
|
||||
<div style="font-size:10px;color:var(--text-dim);margin-top:2px">${escapeHtml(lbl)}</div>
|
||||
</div>`;
|
||||
|
||||
const hdr = [
|
||||
hdrMetric('VIX', d.vix > 0 ? fmt(d.vix, 2) : 'N/A'),
|
||||
hdrMetric('HY Spread', d.hySpread > 0 ? `${fmt(d.hySpread, 2)}%` : 'N/A'),
|
||||
hdrMetric('10Y Yield', d.yield10y > 0 ? `${fmt(d.yield10y, 2)}%` : 'N/A'),
|
||||
hdrMetric('P/C Ratio', d.putCallRatio > 0 ? fmt(d.putCallRatio, 2) : 'N/A'),
|
||||
hdrMetric('% > 200d', d.pctAbove200d ? `${fmt(d.pctAbove200d, 1)}%` : 'N/A'),
|
||||
hdrMetric('CNN F&G', d.cnnFearGreed ? `${Math.round(d.cnnFearGreed)}` : 'N/A'),
|
||||
hdrMetric('AAII Bull', d.aaiiBull ? `${fmt(d.aaiiBull, 1)}%` : 'N/A'),
|
||||
hdrMetric('AAII Bear', d.aaiiBear ? `${fmt(d.aaiiBear, 1)}%` : 'N/A'),
|
||||
hdrMetric('Fed Rate', d.fedRate || 'N/A'),
|
||||
].join('');
|
||||
|
||||
const html = `
|
||||
<div style="padding:12px 14px">
|
||||
<div style="text-align:center;margin-bottom:12px">
|
||||
<div style="font-size:56px;font-weight:700;line-height:1;color:${color}">${score}</div>
|
||||
<div style="font-size:16px;font-weight:600;color:${color};margin:4px 0">${label}</div>
|
||||
${deltaHtml}
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:2px;background:rgba(255,255,255,0.04);border-radius:8px;padding:4px;margin-bottom:12px">
|
||||
${hdr}
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:6px">Category Breakdown</div>
|
||||
${catRows}
|
||||
</div>`;
|
||||
|
||||
this.setContent(html);
|
||||
}
|
||||
}
|
||||
@@ -70,3 +70,4 @@ export * from './EconomicCorrelationPanel';
|
||||
export * from './DisasterCorrelationPanel';
|
||||
export * from './ConsumerPricesPanel';
|
||||
export { NationalDebtPanel } from './NationalDebtPanel';
|
||||
export * from './FearGreedPanel';
|
||||
|
||||
@@ -121,6 +121,7 @@ export const COMMANDS: Command[] = [
|
||||
{ id: 'panel:heatmap', keywords: ['heatmap', 'sector heatmap'], label: 'Panel: Sector Heatmap', icon: '\u{1F5FA}\uFE0F', category: 'panels' },
|
||||
{ id: 'panel:ai', keywords: ['ai', 'ml', 'artificial intelligence'], label: 'Panel: AI/ML', icon: '\u{1F916}', category: 'panels' },
|
||||
{ id: 'panel:macro-signals', keywords: ['macro', 'macro signals', 'liquidity'], label: 'Panel: Market Radar', icon: '\u{1F4C9}', category: 'panels' },
|
||||
{ id: 'panel:fear-greed', keywords: ['fear', 'greed', 'fear and greed', 'sentiment', 'fear greed index'], label: 'Panel: Fear & Greed', icon: '\u{1F4CA}', category: 'panels' },
|
||||
{ id: 'panel:etf-flows', keywords: ['etf', 'etf flows', 'fund flows'], label: 'Panel: BTC ETF Tracker', icon: '\u{1F4B9}', category: 'panels' },
|
||||
{ id: 'panel:stablecoins', keywords: ['stablecoins', 'usdt', 'usdc'], label: 'Panel: Stablecoins', icon: '\u{1FA99}', category: 'panels' },
|
||||
{ id: 'panel:monitors', keywords: ['monitors', 'my monitors', 'watchlist'], label: 'Panel: My Monitors', icon: '\u{1F4CB}', category: 'panels' },
|
||||
|
||||
@@ -55,6 +55,7 @@ const FULL_PANELS: Record<string, PanelConfig> = {
|
||||
monitors: { name: 'My Monitors', enabled: true, priority: 2 },
|
||||
'satellite-fires': { name: 'Fires', enabled: true, priority: 2 },
|
||||
'macro-signals': { name: 'Market Regime', enabled: true, priority: 2 },
|
||||
'fear-greed': { name: 'Fear & Greed', enabled: true, priority: 2 },
|
||||
'gulf-economies': { name: 'Gulf Economies', enabled: false, priority: 2 },
|
||||
'consumer-prices': { name: 'Consumer Prices', enabled: false, priority: 2 },
|
||||
'grocery-basket': { name: 'Grocery Index', enabled: false, priority: 2 },
|
||||
|
||||
@@ -36,6 +36,7 @@ export const REFRESH_INTERVALS = {
|
||||
stablecoins: 15 * 60 * 1000,
|
||||
etfFlows: 15 * 60 * 1000,
|
||||
macroSignals: 15 * 60 * 1000,
|
||||
fearGreed: 30 * 60 * 1000,
|
||||
strategicPosture: 15 * 60 * 1000,
|
||||
strategicRisk: 5 * 60 * 1000,
|
||||
temporalBaseline: 10 * 60 * 1000,
|
||||
|
||||
@@ -158,6 +158,7 @@ export const DEFAULT_PANELS: Record<string, PanelConfig> = {
|
||||
ipo: { name: 'IPOs, Earnings & M&A', enabled: true, priority: 1 },
|
||||
heatmap: { name: 'Sector Heatmap', enabled: true, priority: 1 },
|
||||
'macro-signals': { name: 'Market Radar', enabled: true, priority: 1 },
|
||||
'fear-greed': { name: 'Fear & Greed', enabled: true, priority: 1 },
|
||||
derivatives: { name: 'Derivatives & Options', enabled: true, priority: 2 },
|
||||
fintech: { name: 'Fintech & Trading Tech', enabled: true, priority: 2 },
|
||||
regulation: { name: 'Financial Regulation', enabled: true, priority: 2 },
|
||||
|
||||
@@ -328,6 +328,45 @@ export interface ListOtherTokensResponse {
|
||||
tokens: CryptoQuote[];
|
||||
}
|
||||
|
||||
export interface GetFearGreedIndexRequest {
|
||||
}
|
||||
|
||||
export interface GetFearGreedIndexResponse {
|
||||
compositeScore: number;
|
||||
compositeLabel: string;
|
||||
previousScore: number;
|
||||
seededAt: string;
|
||||
sentiment?: FearGreedCategory;
|
||||
volatility?: FearGreedCategory;
|
||||
positioning?: FearGreedCategory;
|
||||
trend?: FearGreedCategory;
|
||||
breadth?: FearGreedCategory;
|
||||
momentum?: FearGreedCategory;
|
||||
liquidity?: FearGreedCategory;
|
||||
credit?: FearGreedCategory;
|
||||
macro?: FearGreedCategory;
|
||||
crossAsset?: FearGreedCategory;
|
||||
vix: number;
|
||||
hySpread: number;
|
||||
yield10y: number;
|
||||
putCallRatio: number;
|
||||
pctAbove200d: number;
|
||||
cnnFearGreed: number;
|
||||
cnnLabel: string;
|
||||
aaiiBull: number;
|
||||
aaiiBear: number;
|
||||
fedRate: string;
|
||||
unavailable: boolean;
|
||||
}
|
||||
|
||||
export interface FearGreedCategory {
|
||||
score: number;
|
||||
weight: number;
|
||||
contribution: number;
|
||||
degraded: boolean;
|
||||
inputsJson: string;
|
||||
}
|
||||
|
||||
export interface FieldViolation {
|
||||
field: string;
|
||||
description: string;
|
||||
@@ -771,6 +810,29 @@ export class MarketServiceClient {
|
||||
return await resp.json() as ListOtherTokensResponse;
|
||||
}
|
||||
|
||||
async getFearGreedIndex(req: GetFearGreedIndexRequest, options?: MarketServiceCallOptions): Promise<GetFearGreedIndexResponse> {
|
||||
let path = "/api/market/v1/get-fear-greed-index";
|
||||
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 GetFearGreedIndexResponse;
|
||||
}
|
||||
|
||||
private async handleError(resp: Response): Promise<never> {
|
||||
const body = await resp.text();
|
||||
if (resp.status === 400) {
|
||||
|
||||
@@ -328,6 +328,45 @@ export interface ListOtherTokensResponse {
|
||||
tokens: CryptoQuote[];
|
||||
}
|
||||
|
||||
export interface GetFearGreedIndexRequest {
|
||||
}
|
||||
|
||||
export interface GetFearGreedIndexResponse {
|
||||
compositeScore: number;
|
||||
compositeLabel: string;
|
||||
previousScore: number;
|
||||
seededAt: string;
|
||||
sentiment?: FearGreedCategory;
|
||||
volatility?: FearGreedCategory;
|
||||
positioning?: FearGreedCategory;
|
||||
trend?: FearGreedCategory;
|
||||
breadth?: FearGreedCategory;
|
||||
momentum?: FearGreedCategory;
|
||||
liquidity?: FearGreedCategory;
|
||||
credit?: FearGreedCategory;
|
||||
macro?: FearGreedCategory;
|
||||
crossAsset?: FearGreedCategory;
|
||||
vix: number;
|
||||
hySpread: number;
|
||||
yield10y: number;
|
||||
putCallRatio: number;
|
||||
pctAbove200d: number;
|
||||
cnnFearGreed: number;
|
||||
cnnLabel: string;
|
||||
aaiiBull: number;
|
||||
aaiiBear: number;
|
||||
fedRate: string;
|
||||
unavailable: boolean;
|
||||
}
|
||||
|
||||
export interface FearGreedCategory {
|
||||
score: number;
|
||||
weight: number;
|
||||
contribution: number;
|
||||
degraded: boolean;
|
||||
inputsJson: string;
|
||||
}
|
||||
|
||||
export interface FieldViolation {
|
||||
field: string;
|
||||
description: string;
|
||||
@@ -389,6 +428,7 @@ export interface MarketServiceHandler {
|
||||
listDefiTokens(ctx: ServerContext, req: ListDefiTokensRequest): Promise<ListDefiTokensResponse>;
|
||||
listAiTokens(ctx: ServerContext, req: ListAiTokensRequest): Promise<ListAiTokensResponse>;
|
||||
listOtherTokens(ctx: ServerContext, req: ListOtherTokensRequest): Promise<ListOtherTokensResponse>;
|
||||
getFearGreedIndex(ctx: ServerContext, req: GetFearGreedIndexRequest): Promise<GetFearGreedIndexResponse>;
|
||||
}
|
||||
|
||||
export function createMarketServiceRoutes(
|
||||
@@ -1095,6 +1135,43 @@ export function createMarketServiceRoutes(
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/market/v1/get-fear-greed-index",
|
||||
handler: async (req: Request): Promise<Response> => {
|
||||
try {
|
||||
const pathParams: Record<string, string> = {};
|
||||
const body = {} as GetFearGreedIndexRequest;
|
||||
|
||||
const ctx: ServerContext = {
|
||||
request: req,
|
||||
pathParams,
|
||||
headers: Object.fromEntries(req.headers.entries()),
|
||||
};
|
||||
|
||||
const result = await handler.getFearGreedIndex(ctx, body);
|
||||
return new Response(JSON.stringify(result as GetFearGreedIndexResponse), {
|
||||
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" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -281,7 +281,9 @@
|
||||
"gulfIndices": "مؤشرات الخليج",
|
||||
"gulfCurrencies": "عملات الخليج",
|
||||
"gulfOil": "نفط الخليج",
|
||||
"bigmac": "Real Big Mac Index"
|
||||
|
||||
"bigmac": "Real Big Mac Index",
|
||||
"fearGreed": "Fear & Greed"
|
||||
},
|
||||
"commands": {
|
||||
"prefixes": {
|
||||
|
||||
@@ -373,7 +373,8 @@
|
||||
"gulfCurrencies": "Gulf Currencies",
|
||||
"gulfOil": "Gulf Oil",
|
||||
"airlineIntel": "✈️ Airline Intelligence",
|
||||
"consumerPrices": "Consumer Prices"
|
||||
"consumerPrices": "Consumer Prices",
|
||||
"fearGreed": "Fear & Greed"
|
||||
},
|
||||
"commands": {
|
||||
"prefixes": {
|
||||
|
||||
@@ -281,7 +281,9 @@
|
||||
"gulfIndices": "Índices del Golfo",
|
||||
"gulfCurrencies": "Monedas del Golfo",
|
||||
"gulfOil": "Petróleo del Golfo",
|
||||
"bigmac": "Real Big Mac Index"
|
||||
|
||||
"bigmac": "Real Big Mac Index",
|
||||
"fearGreed": "Fear & Greed"
|
||||
},
|
||||
"commands": {
|
||||
"prefixes": {
|
||||
|
||||
@@ -281,7 +281,9 @@
|
||||
"gulfIndices": "海湾指数",
|
||||
"gulfCurrencies": "海湾货币",
|
||||
"gulfOil": "海湾石油",
|
||||
"bigmac": "Real Big Mac Index"
|
||||
|
||||
"bigmac": "Real Big Mac Index",
|
||||
"fearGreed": "Fear & Greed"
|
||||
},
|
||||
"commands": {
|
||||
"prefixes": {
|
||||
|
||||
Reference in New Issue
Block a user