feat: add Volume Profile and News Center tabs

Add volumeprofile and newscenter API routers, wire them into the app,
and add matching dashboard tabs with API client helpers and screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kansaram
2026-10-25 16:06:59 +05:30
co-authored by Claude Opus 5.5
parent fa6154884f
commit 7a9ae88241
10 changed files with 1067 additions and 2 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps, exotics, eventvol, risk, tarf, deephedge, positioning, smiledyn, margin, rvscan
from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps, exotics, eventvol, risk, tarf, deephedge, positioning, smiledyn, margin, rvscan, volumeprofile, newscenter
app = FastAPI(title="QuantRisk FX Terminal API",
description="Forex options risk analytics — Garman-Kohlhagen model.",
@@ -9,7 +9,7 @@ app = FastAPI(title="QuantRisk FX Terminal API",
app.add_middleware(CORSMiddleware, allow_origins=["*"],
allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
for router in [greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps, exotics, eventvol, risk, tarf, deephedge, positioning, smiledyn, margin, rvscan]:
for router in [greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata, smile, vol, hedge, xasset, jumps, exotics, eventvol, risk, tarf, deephedge, positioning, smiledyn, margin, rvscan, volumeprofile, newscenter]:
app.include_router(router.router, prefix="/api")
@app.get("/")
+290
View File
@@ -0,0 +1,290 @@
"""
newscenter.py — ForexFactory-style news center.
• Aggregates free RSS feeds (FXStreet, Investing.com, MarketWatch,
CNBC, Google News finance queries) — no API keys.
• AI analysis per article: sentiment, impact, confidence,
affected currencies/pairs, trading bias (rule-based NLP engine —
deterministic, zero-latency, no external LLM dependency).
• Economic event detection: FED/FOMC/NFP/CPI/ECB/… keyword engine.
• Trading signal engine: high-impact + directional sentiment → signal card.
Storage: in-memory ring buffer (max 200 articles), deduped by title hash.
Upstream refresh at most every 60s; clients can poll every 10s for free.
"""
import re
import time
import html
import hashlib
import xml.etree.ElementTree as ET
from email.utils import parsedate_to_datetime
import httpx
from fastapi import APIRouter
router = APIRouter(prefix="/newscenter", tags=["newscenter"])
UA = {"User-Agent": "Mozilla/5.0 (compatible; NexusTerminal/1.0)"}
FEEDS = [
("FXStreet", "https://www.fxstreet.com/rss/news"),
("Investing.com", "https://www.investing.com/rss/news_1.rss"),
("MarketWatch", "https://feeds.content.dowjones.io/public/rss/mw_topstories"),
("CNBC", "https://search.cnbc.com/rs/search/combinedcms/view.xml?partnerId=wrss01&id=20910258"),
("Google News", "https://news.google.com/rss/search?q=forex%20OR%20%22federal%20reserve%22%20OR%20%22interest%20rate%22&hl=en-US&gl=US&ceid=US:en"),
]
# ── Economic event keyword engine ─────────────────────────────────────────────
HIGH_IMPACT = [
"fed ", "fomc", "nfp", "nonfarm", "non-farm", "cpi", "ppi", "gdp",
"ecb", "boe ", "boj ", "snb ", "rba ", "rbnz", "rate decision",
"rate cut", "rate hike", "interest rate", "inflation", "unemployment",
"payrolls", "powell", "lagarde", "central bank", "monetary policy",
"recession", "intervention", "emergency",
]
MED_IMPACT = [
"retail sales", "pmi", "consumer confidence", "trade balance",
"housing", "jobless claims", "durable goods", "treasury", "yields",
"earnings", "manufacturing", "sentiment", "ism ", "tariff",
]
CCY_TERMS = {
"USD": ["usd", "dollar", "fed ", "fomc", "powell", "treasury", "u.s.", "us economy", "american"],
"EUR": ["eur", "euro", "ecb", "lagarde", "eurozone", "germany", "german"],
"GBP": ["gbp", "pound", "sterling", "boe ", "bank of england", "uk ", "britain"],
"JPY": ["jpy", "yen", "boj ", "bank of japan", "japan"],
"CHF": ["chf", "franc", "snb ", "swiss"],
"AUD": ["aud", "aussie", "rba ", "australia"],
"CAD": ["cad", "loonie", "bank of canada", "canada"],
"NZD": ["nzd", "kiwi", "rbnz", "new zealand"],
"XAU": ["gold", "xau", "bullion"],
"BTC": ["bitcoin", "btc", "crypto"],
}
BULLISH = ["hike", "raise", "strong", "beat", "beats", "surge", "rally", "soar",
"jump", "gain", "growth", "robust", "hawkish", "record high", "upbeat",
"optimis", "recover", "boost", "tops", "exceed"]
BEARISH = ["cut", "weak", "miss", "misses", "fall", "drop", "plunge", "slump",
"decline", "recession", "dovish", "fear", "concern", "warn", "crisis",
"tumble", "slide", "low", "downgrade", "risk-off", "selloff"]
PAIR_MAP = {
"USD": ["EURUSD", "GBPUSD", "USDJPY", "AUDUSD", "USDCAD", "USDCHF"],
"EUR": ["EURUSD", "EURJPY", "EURGBP"],
"GBP": ["GBPUSD", "GBPJPY", "EURGBP"],
"JPY": ["USDJPY", "EURJPY", "GBPJPY"],
"CHF": ["USDCHF"],
"AUD": ["AUDUSD"],
"CAD": ["USDCAD"],
"NZD": ["NZDUSD"],
"XAU": ["XAUUSD"],
"BTC": ["BTCUSD"],
}
_store: dict = {"articles": [], "ts": 0.0}
_REFRESH_TTL = 60 # upstream refresh interval (s)
_MAX_ARTICLES = 200
# ── RSS parsing (stdlib only) ─────────────────────────────────────────────────
def _text(el, tag):
node = el.find(tag)
return (node.text or "").strip() if node is not None and node.text else ""
def _strip_html(s: str) -> str:
return re.sub(r"<[^>]+>", "", html.unescape(s or "")).strip()
def _find_image(item) -> str:
ns = {"media": "http://search.yahoo.com/mrss/"}
for path in ("media:content", "media:thumbnail"):
node = item.find(path, ns)
if node is not None and node.get("url"):
return node.get("url")
enc = item.find("enclosure")
if enc is not None and (enc.get("type") or "").startswith("image"):
return enc.get("url", "")
desc = _text(item, "description")
m = re.search(r'<img[^>]+src="([^"]+)"', desc or "")
return m.group(1) if m else ""
def _fetch_feed(source: str, url: str) -> list[dict]:
try:
resp = httpx.get(url, timeout=12, follow_redirects=True, headers=UA)
if resp.status_code != 200:
return []
root = ET.fromstring(resp.content)
out = []
for item in root.iter("item"):
title = _strip_html(_text(item, "title"))
if not title:
continue
pub = _text(item, "pubDate")
try:
published = parsedate_to_datetime(pub).timestamp() if pub else time.time()
except Exception:
published = time.time()
out.append({
"id": hashlib.md5(title.lower().encode()).hexdigest()[:12],
"title": title,
"summary": _strip_html(_text(item, "description"))[:280],
"image": _find_image(item),
"source": source,
"url": _text(item, "link"),
"publishedAt": published,
})
return out[:40]
except Exception:
return []
# ── AI analysis engine ────────────────────────────────────────────────────────
def _detect_impact(text: str) -> str:
t = " " + text.lower() + " "
if any(k in t for k in HIGH_IMPACT):
return "High"
if any(k in t for k in MED_IMPACT):
return "Medium"
return "Low"
def _analyze(article: dict) -> dict:
text = " " + (article["title"] + " " + article["summary"]).lower() + " "
impact = _detect_impact(text)
# Currency detection
currencies = [ccy for ccy, terms in CCY_TERMS.items()
if any(term in text for term in terms)]
bull = sum(text.count(w) for w in BULLISH)
bear = sum(text.count(w) for w in BEARISH)
if bull > bear:
sentiment, score = "Bullish", bull - bear
elif bear > bull:
sentiment, score = "Bearish", bear - bull
else:
sentiment, score = "Neutral", 0
confidence = min(95, 50 + score * 12 + (15 if impact == "High" else 5 if impact == "Medium" else 0))
primary = currencies[0] if currencies else None
pairs = []
for ccy in currencies[:3]:
pairs += PAIR_MAP.get(ccy, [])
pairs = list(dict.fromkeys(pairs))[:5]
if sentiment == "Neutral" or not primary:
bias = "No directional bias — monitor for confirmation."
else:
bias = f"{sentiment} {primary} — watch {', '.join(pairs[:3]) if pairs else 'majors'} for {('continuation' if impact != 'High' else 'volatility expansion')}."
return {
"sentiment": sentiment if not primary else f"{sentiment} {primary}",
"impact": impact,
"confidence": confidence,
"affectedCurrencies": currencies[:4],
"affectedPairs": pairs,
"tradingBias": bias,
}
def _make_signal(article: dict) -> dict | None:
ai = article["ai"]
if ai["impact"] != "High" or "Neutral" in ai["sentiment"] or not ai["affectedPairs"]:
return None
pair = ai["affectedPairs"][0]
bullish = ai["sentiment"].startswith("Bullish")
ccy = ai["affectedCurrencies"][0] if ai["affectedCurrencies"] else ""
# Bullish base currency → BUY the pair if ccy is the base, SELL if quote
side = "BUY" if (bullish == pair.startswith(ccy)) else "SELL"
return {
"signal": side,
"pair": pair,
"strength": "Strong" if ai["confidence"] >= 80 else "Moderate",
"confidence": ai["confidence"],
"reason": article["title"][:90],
"source": article["source"],
"publishedAt": article["publishedAt"],
}
# ── Aggregation ───────────────────────────────────────────────────────────────
def _refresh():
now = time.time()
if _store["articles"] and (now - _store["ts"]) < _REFRESH_TTL:
return
existing = {a["id"] for a in _store["articles"]}
fresh = []
for source, url in FEEDS:
for art in _fetch_feed(source, url):
if art["id"] not in existing:
art["ai"] = _analyze(art)
fresh.append(art)
existing.add(art["id"])
merged = fresh + _store["articles"]
merged.sort(key=lambda a: -a["publishedAt"])
_store["articles"] = merged[:_MAX_ARTICLES]
_store["ts"] = now
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("/feed")
def news_feed(limit: int = 30, offset: int = 0):
"""Paginated news feed with embedded AI analysis (infinite-scroll friendly)."""
_refresh()
arts = _store["articles"]
return {
"articles": arts[offset:offset + max(1, min(limit, 100))],
"total": len(arts),
"offset": offset,
"refreshed_at": _store["ts"],
}
@router.get("/hot")
def hot_story():
"""Top story: most recent High-impact article (prefers one with an image)."""
_refresh()
arts = _store["articles"]
high = [a for a in arts if a["ai"]["impact"] == "High"]
with_img = [a for a in high if a["image"]] or [a for a in arts if a["image"]]
pick = (with_img or high or arts or [None])[0]
return {"story": pick}
@router.get("/breaking")
def breaking():
"""High-impact stories from the last 20 minutes."""
_refresh()
cutoff = time.time() - 20 * 60
items = [a for a in _store["articles"]
if a["ai"]["impact"] == "High" and a["publishedAt"] >= cutoff]
return {"breaking": items[:5]}
@router.get("/signals")
def news_signals():
"""Trading signal cards generated from recent high-impact news."""
_refresh()
cutoff = time.time() - 6 * 3600
sigs = []
seen_pairs = set()
for a in _store["articles"]:
if a["publishedAt"] < cutoff:
break
s = _make_signal(a)
if s and s["pair"] not in seen_pairs:
seen_pairs.add(s["pair"])
sigs.append(s)
return {"signals": sigs[:8]}
+215
View File
@@ -0,0 +1,215 @@
"""
volumeprofile.py — Live Volume Profile engine (TradingView-style).
Computes volume-at-price distributions from intraday OHLCV bars:
• POC (Point of Control) — price level with max traded volume
• VAH / VAL (Value Area) — 70% volume band around POC
• HVN / LVN — high / low volume nodes (local extrema)
• Buy / Sell / Delta volume — up-bar vs down-bar classification
• Session / Daily / Weekly profiles
FX pairs on Yahoo report tick volume (count of price updates), which is
the industry-standard FX proxy (same as MT4/MT5). When volume is zero,
each bar contributes weight 1 distributed across its high-low range.
No database, no Redis — in-memory TTL cache (same pattern as livedata.py).
"""
import time
import numpy as np
import yfinance as yf
from fastapi import APIRouter, HTTPException
from .forex import PAIRS as FX_PAIRS
router = APIRouter(prefix="/volume", tags=["volume"])
# Extra symbols beyond the forex router's pair list
EXTRA_SYMBOLS = {
"BTCUSD": "BTC-USD",
"ETHUSD": "ETH-USD",
}
# profile_type → (yfinance period, interval, cache TTL seconds)
PROFILES = {
"session": ("1d", "1m", 15),
"daily": ("5d", "5m", 60),
"weekly": ("1mo", "30m", 300),
}
N_BUCKETS = 48 # price levels in the profile
VALUE_AREA_PCT = 0.70 # standard 70% value area
_cache: dict = {} # key: (pair, profile) → {"data":…, "ts":…}
def _symbol_for(pair: str) -> str | None:
if pair in FX_PAIRS:
return FX_PAIRS[pair]["sym"]
return EXTRA_SYMBOLS.get(pair)
def _fetch_bars(sym: str, period: str, interval: str):
hist = yf.download(sym, period=period, interval=interval,
progress=False, auto_adjust=True)
if hist.empty:
return None
hist = hist.dropna()
if hasattr(hist.columns, "droplevel"):
try:
hist.columns = hist.columns.droplevel(1)
except Exception:
pass
return hist
def _build_profile(hist) -> dict:
o = hist["Open"].to_numpy(dtype=float)
h = hist["High"].to_numpy(dtype=float)
l = hist["Low"].to_numpy(dtype=float)
c = hist["Close"].to_numpy(dtype=float)
v = hist["Volume"].to_numpy(dtype=float) if "Volume" in hist else np.zeros(len(o))
# FX tick-volume proxy: bars with zero volume each count as 1 tick unit
if v.sum() <= 0:
v = np.ones(len(o))
lo, hi = float(l.min()), float(h.max())
if hi <= lo:
hi = lo * 1.0001 + 1e-9
edges = np.linspace(lo, hi, N_BUCKETS + 1)
mids = (edges[:-1] + edges[1:]) / 2
buy = np.zeros(N_BUCKETS)
sell = np.zeros(N_BUCKETS)
# Distribute each bar's volume uniformly across the buckets its range spans.
# Direction: close vs previous close (FX 1m bars often have open == close,
# which would misclassify everything if compared intra-bar). Flat bars 50/50.
for i in range(len(o)):
i0 = int(np.searchsorted(edges, l[i], side="right") - 1)
i1 = int(np.searchsorted(edges, h[i], side="right") - 1)
i0 = max(0, min(i0, N_BUCKETS - 1))
i1 = max(0, min(i1, N_BUCKETS - 1))
span = i1 - i0 + 1
per = v[i] / span
ref = c[i - 1] if i > 0 else o[i]
if c[i] > ref:
buy[i0:i1 + 1] += per
elif c[i] < ref:
sell[i0:i1 + 1] += per
else:
buy[i0:i1 + 1] += per / 2
sell[i0:i1 + 1] += per / 2
total = buy + sell
grand = float(total.sum())
if grand <= 0:
raise HTTPException(503, "No volume data available.")
# POC
poc_i = int(total.argmax())
# Value area: expand from POC, greedily take the larger neighbor
va_vol = total[poc_i]
lo_i = hi_i = poc_i
target = grand * VALUE_AREA_PCT
while va_vol < target and (lo_i > 0 or hi_i < N_BUCKETS - 1):
below = total[lo_i - 1] if lo_i > 0 else -1.0
above = total[hi_i + 1] if hi_i < N_BUCKETS - 1 else -1.0
if above >= below:
hi_i += 1
va_vol += total[hi_i]
else:
lo_i -= 1
va_vol += total[lo_i]
# HVN / LVN: local maxima / minima of the smoothed profile
kernel = np.array([0.25, 0.5, 0.25])
smooth = np.convolve(total, kernel, mode="same")
mean = smooth.mean()
hvn, lvn = [], []
for i in range(1, N_BUCKETS - 1):
if smooth[i] > smooth[i - 1] and smooth[i] >= smooth[i + 1] and smooth[i] > mean * 1.15:
hvn.append(i)
if smooth[i] < smooth[i - 1] and smooth[i] <= smooth[i + 1] and smooth[i] < mean * 0.6:
lvn.append(i)
hvn = sorted(hvn, key=lambda i: -total[i])[:4]
lvn = sorted(lvn, key=lambda i: total[i])[:4]
buy_total, sell_total = float(buy.sum()), float(sell.sum())
decimals = 5 if mids[-1] < 100 else 3
return {
"buckets": [
{
"price": round(float(mids[i]), decimals),
"vol": round(float(total[i]), 2),
"buy": round(float(buy[i]), 2),
"sell": round(float(sell[i]), 2),
"delta": round(float(buy[i] - sell[i]), 2),
"in_va": lo_i <= i <= hi_i,
"is_poc": i == poc_i,
}
for i in range(N_BUCKETS)
],
"poc": round(float(mids[poc_i]), decimals),
"vah": round(float(mids[hi_i]), decimals),
"val": round(float(mids[lo_i]), decimals),
"hvn": [round(float(mids[i]), decimals) for i in hvn],
"lvn": [round(float(mids[i]), decimals) for i in lvn],
"session_volume": round(grand, 2),
"buy_volume": round(buy_total, 2),
"sell_volume": round(sell_total, 2),
"delta_volume": round(buy_total - sell_total, 2),
"aggressive_buy_pct": round(buy_total / grand * 100, 1),
"last_price": round(float(c[-1]), decimals),
"bars": len(o),
"volume_kind": "exchange" if "Volume" in hist and float(hist["Volume"].sum()) > 0 else "tick",
}
@router.get("/pairs")
def volume_pairs():
"""Pairs supported by the volume profile engine."""
return sorted(list(FX_PAIRS.keys()) + list(EXTRA_SYMBOLS.keys()))
@router.get("/profile/{pair}")
def volume_profile(pair: str, profile: str = "session"):
"""
Volume profile for a pair.
profile: session (1m bars, today) | daily (5m, 5d) | weekly (30m, 1mo)
Cached 15s / 60s / 300s respectively.
"""
pair = pair.upper()
sym = _symbol_for(pair)
if not sym:
raise HTTPException(404, f"Unknown pair '{pair}'.")
if profile not in PROFILES:
profile = "session"
period, interval, ttl = PROFILES[profile]
key = (pair, profile)
now = time.time()
hit = _cache.get(key)
if hit and (now - hit["ts"]) < ttl:
return {**hit["data"], "cached": True}
hist = _fetch_bars(sym, period, interval)
if hist is None or len(hist) < 5:
# serve stale cache rather than failing if provider hiccups
if hit:
return {**hit["data"], "cached": True, "stale": True}
raise HTTPException(503, "No intraday data returned.")
data = {
"pair": pair,
"profile": profile,
"interval": interval,
**_build_profile(hist),
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"cached": False,
}
_cache[key] = {"data": data, "ts": now}
return data
+12
View File
@@ -80,3 +80,15 @@ export const fetchMarginBook = (p) => api.post('/margin/book', p, { timeout: 600
// Carry & RV Scanner
export const fetchRvScan = (ccy) => api.get(`/rvscan/${ccy}`).then(r=>r.data)
// Volume profile
export const fetchVolumePairs = () => api.get('/volume/pairs').then(r => r.data)
export const fetchVolumeProfile = (pair, profile) =>
api.get(`/volume/profile/${pair}`, { params: { profile } }).then(r => r.data)
// News center
export const fetchNewsFeed = (limit, offset) =>
api.get('/newscenter/feed', { params: { limit, offset } }).then(r => r.data)
export const fetchHotStory = () => api.get('/newscenter/hot').then(r => r.data)
export const fetchNewsSignals = () => api.get('/newscenter/signals').then(r => r.data)
export const fetchBreakingNews = () => api.get('/newscenter/breaking').then(r => r.data)
+319
View File
@@ -0,0 +1,319 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { fetchNewsFeed, fetchHotStory, fetchNewsSignals, fetchBreakingNews } from '../api/client'
const SOURCE_COLORS = {
'FXStreet': 'var(--cyan)', 'Investing.com': 'var(--amber)',
'MarketWatch': 'var(--green)', 'CNBC': 'var(--purple)', 'Google News': 'var(--red)',
}
const IMPACT = {
High: { dot: '🔴', cls: 'tbadge-high', color: 'var(--red)' },
Medium: { dot: '🟡', cls: 'tbadge-medium', color: 'var(--amber)' },
Low: { dot: '🟢', cls: 'tbadge-green', color: 'var(--green)' },
}
const ago = (ts) => {
const s = Math.max(0, (Date.now() / 1000) - ts)
if (s < 60) return 'just now'
if (s < 3600) return `${Math.floor(s / 60)}m ago`
if (s < 86400) return `${Math.floor(s / 3600)}h ago`
return `${Math.floor(s / 86400)}d ago`
}
const SourceChip = ({ source }) => (
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 8, fontWeight: 700, letterSpacing: '0.06em',
padding: '2px 6px', borderRadius: 3,
color: SOURCE_COLORS[source] || 'var(--text-muted)',
background: `color-mix(in srgb, ${SOURCE_COLORS[source] || 'var(--text-muted)'} 10%, transparent)`,
border: `1px solid color-mix(in srgb, ${SOURCE_COLORS[source] || 'var(--text-muted)'} 25%, transparent)`,
}}>{source.toUpperCase()}</span>
)
const SentimentChip = ({ ai }) => {
const bull = ai.sentiment.startsWith('Bullish')
const bear = ai.sentiment.startsWith('Bearish')
const color = bull ? 'var(--green)' : bear ? 'var(--red)' : 'var(--text-muted)'
return (
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 8.5, fontWeight: 700,
padding: '2px 7px', borderRadius: 3, color,
background: `color-mix(in srgb, ${color} 10%, transparent)`,
}}>
{bull ? '▲ ' : bear ? '▼ ' : '— '}{ai.sentiment.toUpperCase()} · {ai.confidence}%
</span>
)
}
function StoryRow({ a, onSelect, active }) {
const imp = IMPACT[a.ai.impact] || IMPACT.Low
return (
<div onClick={() => onSelect(a)} style={{
padding: '10px 12px', cursor: 'pointer',
borderBottom: '1px solid var(--border)',
borderLeft: active ? '3px solid var(--cyan)' : '3px solid transparent',
background: active ? 'color-mix(in srgb, var(--cyan) 5%, transparent)' : 'transparent',
transition: 'var(--t-fast)',
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'color-mix(in srgb, var(--cyan) 3%, transparent)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5, flexWrap: 'wrap' }}>
<span className={`tbadge ${imp.cls}`} style={{ fontSize: 7.5 }}>{a.ai.impact.toUpperCase()}</span>
<SourceChip source={a.source} />
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 8.5, color: 'var(--text-faint)',
marginLeft: 'auto' }}>{ago(a.publishedAt)}</span>
</div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text)', lineHeight: 1.45 }}>
{a.title}
</div>
{a.ai.affectedPairs.length > 0 && (
<div style={{ display: 'flex', gap: 4, marginTop: 5, flexWrap: 'wrap' }}>
{a.ai.affectedPairs.slice(0, 4).map(p => (
<span key={p} style={{ fontFamily: 'var(--font-mono)', fontSize: 8,
color: 'var(--cyan)', padding: '1px 5px', borderRadius: 2,
background: 'color-mix(in srgb, var(--cyan) 7%, transparent)' }}>{p}</span>
))}
</div>
)}
</div>
)
}
function SignalCard({ s }) {
const buy = s.signal === 'BUY'
const color = buy ? 'var(--green)' : 'var(--red)'
return (
<div className="signal-panel" style={{
borderLeft: `3px solid ${color}`, padding: '10px 14px', minWidth: 190, flexShrink: 0,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 15, fontWeight: 800, color }}>
{buy ? '▲' : '▼'} {s.signal}
</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 700,
color: 'var(--text)' }}>{s.pair}</span>
<span className={`tbadge ${s.strength === 'Strong' ? 'tbadge-high' : 'tbadge-medium'}`}
style={{ marginLeft: 'auto', fontSize: 7.5 }}>{s.strength.toUpperCase()}</span>
</div>
<div style={{ fontSize: 9.5, color: 'var(--text-muted)', lineHeight: 1.4, marginBottom: 6 }}>
{s.reason}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center',
fontFamily: 'var(--font-mono)', fontSize: 8.5 }}>
<span style={{ color: 'var(--text-faint)' }}>{s.source} · {ago(s.publishedAt)}</span>
<span style={{ color, fontWeight: 700 }}>CONF {s.confidence}%</span>
</div>
</div>
)
}
export default function NewsCenter() {
const [articles, setArticles] = useState([])
const [total, setTotal] = useState(0)
const [hot, setHot] = useState(null)
const [selected, setSelected] = useState(null)
const [signals, setSignals] = useState([])
const [breaking, setBreaking] = useState([])
const [limit, setLimit] = useState(30)
const [loading, setLoading] = useState(true)
const listRef = useRef(null)
const load = useCallback(async (lim) => {
try {
const [feed, hotRes, sigRes, brkRes] = await Promise.all([
fetchNewsFeed(lim, 0), fetchHotStory(), fetchNewsSignals(), fetchBreakingNews(),
])
setArticles(feed.articles)
setTotal(feed.total)
setHot(hotRes.story)
setSignals(sigRes.signals)
setBreaking(brkRes.breaking)
} catch { /* keep previous data on transient failures */ }
finally { setLoading(false) }
}, [])
useEffect(() => {
load(limit)
const t = setInterval(() => load(limit), 10000) // auto-refresh every 10s
return () => clearInterval(t)
}, [load, limit])
// Infinite scroll on the left panel
const onScroll = () => {
const el = listRef.current
if (el && el.scrollTop + el.clientHeight >= el.scrollHeight - 80
&& articles.length < total) {
setLimit(l => Math.min(l + 20, 100))
}
}
const story = selected || hot
return (
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 12,
height: '100%', boxSizing: 'border-box' }}>
{/* Breaking news banner */}
{breaking.length > 0 && (
<div style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 14px',
borderRadius: 'var(--r-sm)',
background: 'color-mix(in srgb, var(--red) 10%, transparent)',
border: '1px solid color-mix(in srgb, var(--red) 35%, transparent)',
animation: 'lp-reveal 0.3s ease',
}}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 9, fontWeight: 800,
letterSpacing: '0.15em', color: 'var(--red)', flexShrink: 0,
animation: 'nx-flicker 2s ease-in-out infinite' }}>● BREAKING</span>
<span style={{ fontSize: 11.5, fontWeight: 600, color: 'var(--text)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{breaking[0].title}
</span>
</div>
)}
{/* Header */}
<div className="term-header" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div className="live-dot" />
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 700,
letterSpacing: '0.1em', color: 'var(--text)' }}>
NEWS CENTER · {total} STORIES · AI ANALYSIS · AUTO-REFRESH 10S
</span>
</div>
{/* Signal cards strip */}
{signals.length > 0 && (
<div style={{ display: 'flex', gap: 10, overflowX: 'auto', paddingBottom: 4 }}
className="term-scroll">
{signals.map((s, i) => <SignalCard key={i} s={s} />)}
</div>
)}
{loading && (
<div>{[...Array(4)].map((_, i) =>
<div key={i} className="skeleton" style={{ height: 56, marginBottom: 8, borderRadius: 8 }} />)}
</div>
)}
{!loading && (
<div style={{ display: 'flex', gap: 12, flex: 1, minHeight: 0,
flexWrap: 'wrap' }}>
{/* LEFT: Latest stories */}
<div style={{ flex: '1 1 340px', minWidth: 300, display: 'flex',
flexDirection: 'column', background: 'var(--bg2)',
border: '1px solid var(--border)', borderRadius: 'var(--r-sm)',
overflow: 'hidden', maxHeight: 640 }}>
<div style={{ padding: '9px 12px', borderBottom: '1px solid var(--border)',
fontFamily: 'var(--font-mono)', fontSize: 9.5, fontWeight: 700,
letterSpacing: '0.12em', color: 'var(--text-muted)', background: 'var(--bg3)' }}>
LATEST STORIES
</div>
<div ref={listRef} onScroll={onScroll} className="term-scroll"
style={{ overflowY: 'auto', flex: 1 }}>
{articles.map(a => (
<StoryRow key={a.id} a={a} onSelect={setSelected}
active={story && story.id === a.id} />
))}
{articles.length < total && (
<div style={{ padding: 12, textAlign: 'center', fontSize: 9.5,
fontFamily: 'var(--font-mono)', color: 'var(--text-faint)' }}>
scroll for more…
</div>
)}
</div>
</div>
{/* RIGHT: Hot story */}
<div style={{ flex: '1.4 1 420px', minWidth: 320, display: 'flex',
flexDirection: 'column', background: 'var(--bg2)',
border: '1px solid var(--border)', borderRadius: 'var(--r-sm)',
overflow: 'hidden', maxHeight: 640 }}>
<div style={{ padding: '9px 12px', borderBottom: '1px solid var(--border)',
fontFamily: 'var(--font-mono)', fontSize: 9.5, fontWeight: 700,
letterSpacing: '0.12em', color: 'var(--amber)', background: 'var(--bg3)' }}>
🔥 HOT STORY
</div>
{!story && (
<div style={{ padding: 24, fontSize: 12, color: 'var(--text-muted)' }}>
No stories loaded yet.
</div>
)}
{story && (
<div className="term-scroll" style={{ overflowY: 'auto', flex: 1 }}>
{/* Image */}
{story.image ? (
<img src={story.image} alt="" loading="lazy"
style={{ width: '100%', height: 220, objectFit: 'cover', display: 'block' }}
onError={e => { e.currentTarget.style.display = 'none' }} />
) : (
<div style={{ width: '100%', height: 120,
background: 'linear-gradient(135deg, color-mix(in srgb, var(--cyan) 12%, var(--bg3)), color-mix(in srgb, var(--purple) 12%, var(--bg3)))',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: 'var(--font-mono)', fontSize: 11, letterSpacing: '0.2em',
color: 'var(--text-muted)' }}>
NEXUS NEWS
</div>
)}
<div style={{ padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8,
marginBottom: 10, flexWrap: 'wrap' }}>
<span className={`tbadge ${(IMPACT[story.ai.impact] || IMPACT.Low).cls}`}>
{story.ai.impact.toUpperCase()} IMPACT
</span>
<SourceChip source={story.source} />
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 9,
color: 'var(--text-faint)' }}>{ago(story.publishedAt)}</span>
</div>
<h2 style={{ fontSize: 17, fontWeight: 700, color: 'var(--text)',
lineHeight: 1.4, margin: '0 0 10px' }}>{story.title}</h2>
{story.summary && (
<p style={{ fontSize: 12.5, color: 'var(--text-muted)', lineHeight: 1.65,
margin: '0 0 14px' }}>{story.summary}</p>
)}
{/* AI Analysis panel */}
<div className="signal-panel" style={{ borderLeft: '3px solid var(--purple)',
padding: '12px 14px', marginBottom: 14 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 9, fontWeight: 700,
letterSpacing: '0.12em', color: 'var(--purple)', marginBottom: 8 }}>
⚡ AI ANALYSIS
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 8 }}>
<SentimentChip ai={story.ai} />
{story.ai.affectedPairs.map(p => (
<span key={p} style={{ fontFamily: 'var(--font-mono)', fontSize: 8.5,
color: 'var(--cyan)', padding: '2px 7px', borderRadius: 3,
background: 'color-mix(in srgb, var(--cyan) 8%, transparent)',
border: '1px solid color-mix(in srgb, var(--cyan) 20%, transparent)' }}>
{p}
</span>
))}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.5,
fontFamily: 'var(--font-mono)' }}>
{story.ai.tradingBias}
</div>
</div>
{story.url && (
<a href={story.url} target="_blank" rel="noopener noreferrer"
className="btn-ghost" style={{ fontSize: 10.5, display: 'inline-flex',
alignItems: 'center', gap: 6, textDecoration: 'none' }}>
READ FULL STORY ↗
</a>
)}
</div>
</div>
)}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,225 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { usePortfolioStore } from '../store/portfolio'
import { fetchVolumeProfile, fetchVolumePairs } from '../api/client'
const PROFILE_TYPES = [
{ id: 'session', label: 'SESSION', refresh: 15000 },
{ id: 'daily', label: 'DAILY', refresh: 60000 },
{ id: 'weekly', label: 'WEEKLY', refresh: 300000 },
]
const fmt = (n) => n == null ? '—'
: Math.abs(n) >= 1e6 ? (n / 1e6).toFixed(2) + 'M'
: Math.abs(n) >= 1e3 ? (n / 1e3).toFixed(1) + 'K'
: Number(n).toFixed(Math.abs(n) < 10 ? 2 : 0)
const Metric = ({ label, value, color }) => (
<div style={{
background: 'var(--bg2)', borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)', padding: '8px 12px',
borderLeft: `3px solid ${color || 'var(--cyan)'}`, minWidth: 96,
}}>
<div style={{ fontSize: 8.5, color: 'var(--text-muted)', letterSpacing: '0.12em',
fontFamily: 'var(--font-mono)', marginBottom: 3 }}>{label}</div>
<div style={{ fontSize: 14, fontWeight: 700, color: color || 'var(--text)',
fontFamily: 'var(--font-mono)', fontVariantNumeric: 'tabular-nums' }}>{value}</div>
</div>
)
export default function VolumeProfileView() {
const { pair: storePair } = usePortfolioStore()
const [pair, setPair] = useState(storePair || 'EURUSD')
const [pairs, setPairs] = useState([])
const [profileType, setProfileType] = useState('session')
const [data, setData] = useState(null)
const [err, setErr] = useState(null)
const [loading, setLoading] = useState(true)
const timerRef = useRef(null)
useEffect(() => { fetchVolumePairs().then(setPairs).catch(() => {}) }, [])
useEffect(() => { if (storePair) setPair(storePair) }, [storePair])
const load = useCallback(async (silent = false) => {
if (!silent) setLoading(true)
try {
const d = await fetchVolumeProfile(pair, profileType)
setData(d)
setErr(null)
} catch (e) {
setErr(e?.response?.data?.detail || 'Volume data unavailable')
} finally {
setLoading(false)
}
}, [pair, profileType])
useEffect(() => {
load()
const interval = PROFILE_TYPES.find(p => p.id === profileType)?.refresh || 15000
timerRef.current = setInterval(() => load(true), interval)
return () => clearInterval(timerRef.current)
}, [load, profileType])
const maxVol = data ? Math.max(...data.buckets.map(b => b.vol), 1) : 1
const buckets = data ? [...data.buckets].reverse() : [] // high price on top
const deltaPos = data && data.delta_volume >= 0
return (
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Terminal header */}
<div className="term-header" style={{ display: 'flex', alignItems: 'center',
justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div className="live-dot" />
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 700,
letterSpacing: '0.1em', color: 'var(--text)' }}>
VOLUME PROFILE · {pair} · {data?.volume_kind === 'tick' ? 'TICK VOLUME' : 'EXCHANGE VOLUME'}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<select className="input-field" value={pair}
style={{ width: 100, fontSize: 11, fontWeight: 600, padding: '4px 8px' }}
onChange={e => setPair(e.target.value)}>
{(pairs.length ? pairs : [pair]).map(p => <option key={p} value={p}>{p}</option>)}
</select>
<div style={{ display: 'flex', gap: 2 }}>
{PROFILE_TYPES.map(pt => (
<button key={pt.id} className="btn-ghost" onClick={() => setProfileType(pt.id)}
style={{
fontSize: 9, padding: '4px 10px', letterSpacing: '0.08em',
background: profileType === pt.id
? 'color-mix(in srgb, var(--cyan) 12%, transparent)' : 'transparent',
color: profileType === pt.id ? 'var(--cyan)' : 'var(--text-muted)',
border: profileType === pt.id
? '1px solid color-mix(in srgb, var(--cyan) 30%, transparent)'
: '1px solid transparent',
}}>{pt.label}</button>
))}
</div>
</div>
</div>
{err && (
<div style={{ padding: 12, borderRadius: 'var(--r-sm)', fontSize: 12,
background: 'color-mix(in srgb, var(--red) 8%, transparent)',
border: '1px solid color-mix(in srgb, var(--red) 25%, transparent)',
color: 'var(--red)', fontFamily: 'var(--font-mono)' }}>
⚠ {err}
</div>
)}
{loading && !data && (
<div>{[...Array(5)].map((_, i) =>
<div key={i} className="skeleton" style={{ height: 40, marginBottom: 8, borderRadius: 8 }} />)}
</div>
)}
{data && (
<>
{/* Metrics strip */}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Metric label="POC" value={data.poc} color="var(--amber)" />
<Metric label="VAH" value={data.vah} color="var(--cyan)" />
<Metric label="VAL" value={data.val} color="var(--cyan)" />
<Metric label="LAST" value={data.last_price} color="var(--text)" />
<Metric label="SESSION VOL" value={fmt(data.session_volume)} color="var(--purple)" />
<Metric label="BUY VOL" value={fmt(data.buy_volume)} color="var(--green)" />
<Metric label="SELL VOL" value={fmt(data.sell_volume)} color="var(--red)" />
<Metric label="DELTA"
value={(deltaPos ? '+' : '') + fmt(data.delta_volume)}
color={deltaPos ? 'var(--green)' : 'var(--red)'} />
</div>
{/* Aggressive buyers vs sellers gauge */}
<div style={{ background: 'var(--bg2)', border: '1px solid var(--border)',
borderRadius: 'var(--r-sm)', padding: '10px 14px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between',
fontFamily: 'var(--font-mono)', fontSize: 9.5, marginBottom: 6 }}>
<span style={{ color: 'var(--green)' }}>AGGRESSIVE BUYERS {data.aggressive_buy_pct}%</span>
<span style={{ color: 'var(--red)' }}>{(100 - data.aggressive_buy_pct).toFixed(1)}% AGGRESSIVE SELLERS</span>
</div>
<div style={{ height: 8, borderRadius: 4, overflow: 'hidden', display: 'flex',
background: 'var(--bg3)' }}>
<div style={{ width: `${data.aggressive_buy_pct}%`,
background: 'linear-gradient(90deg, var(--green), color-mix(in srgb, var(--green) 60%, transparent))',
transition: 'width 0.6s ease' }} />
<div style={{ flex: 1, background:
'linear-gradient(90deg, color-mix(in srgb, var(--red) 60%, transparent), var(--red))' }} />
</div>
</div>
{/* Profile bars */}
<div className="signal-panel" style={{ borderLeft: '3px solid var(--cyan)',
padding: '14px 16px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{buckets.map((b) => {
const isLast = data.last_price >= b.price - (buckets[0]?.price - buckets[1]?.price || 0) / 2
&& data.last_price < b.price + (buckets[0]?.price - buckets[1]?.price || 0) / 2
const isHVN = data.hvn.includes(b.price)
const isLVN = data.lvn.includes(b.price)
return (
<div key={b.price} style={{ display: 'flex', alignItems: 'center', gap: 8,
height: 11, fontFamily: 'var(--font-mono)' }}>
{/* Price label */}
<div style={{
width: 74, textAlign: 'right', fontSize: 8.5,
fontVariantNumeric: 'tabular-nums',
color: b.is_poc ? 'var(--amber)' : isLast ? 'var(--text)' : 'var(--text-muted)',
fontWeight: b.is_poc || isLast ? 700 : 400,
}}>{b.price}</div>
{/* Bar track */}
<div style={{ flex: 1, height: '100%', display: 'flex', alignItems: 'center',
background: b.in_va ? 'color-mix(in srgb, var(--cyan) 4%, transparent)' : 'transparent',
borderLeft: b.is_poc
? '2px solid var(--amber)'
: '2px solid transparent',
position: 'relative' }}>
<div style={{ display: 'flex', height: 7,
width: `${(b.vol / maxVol) * 100}%`,
minWidth: b.vol > 0 ? 2 : 0,
borderRadius: 1, overflow: 'hidden',
boxShadow: b.is_poc ? '0 0 8px color-mix(in srgb, var(--amber) 50%, transparent)' : 'none',
}}>
<div style={{ width: `${b.vol > 0 ? (b.buy / b.vol) * 100 : 0}%`,
background: b.is_poc ? 'var(--amber)' : 'var(--green)', opacity: 0.85 }} />
<div style={{ flex: 1,
background: b.is_poc ? 'color-mix(in srgb, var(--amber) 70%, transparent)' : 'var(--red)',
opacity: 0.7 }} />
</div>
{(isHVN || isLVN) && (
<span style={{ fontSize: 7.5, marginLeft: 6, letterSpacing: '0.08em',
color: isHVN ? 'var(--purple)' : 'var(--text-faint)' }}>
{isHVN ? 'HVN' : 'LVN'}
</span>
)}
{isLast && (
<div style={{ position: 'absolute', left: 0, right: 0, top: '50%',
borderTop: '1px dashed color-mix(in srgb, var(--text) 50%, transparent)' }} />
)}
</div>
</div>
)
})}
</div>
{/* Legend */}
<div style={{ display: 'flex', gap: 16, marginTop: 12, paddingTop: 10,
borderTop: '1px solid var(--border)', fontFamily: 'var(--font-mono)',
fontSize: 8.5, color: 'var(--text-muted)', flexWrap: 'wrap' }}>
<span><span style={{ color: 'var(--green)' }}>■</span> Buy</span>
<span><span style={{ color: 'var(--red)' }}>■</span> Sell</span>
<span><span style={{ color: 'var(--amber)' }}>■</span> POC</span>
<span><span style={{ color: 'var(--cyan)' }}>▒</span> Value Area 70%</span>
<span><span style={{ color: 'var(--purple)' }}>HVN</span> High Vol Node</span>
<span>LVN Low Vol Node</span>
<span style={{ marginLeft: 'auto' }}>
{data.bars} bars · {data.interval} · auto-refresh {PROFILE_TYPES.find(p => p.id === profileType).refresh / 1000}s
</span>
</div>
</div>
</>
)}
</div>
)
}
+4
View File
@@ -27,6 +27,8 @@ import RvScanner from '../components/RvScanner'
const IVSurface = lazy(() => import('../components/IVSurface'))
const WorldMap = lazy(() => import('../components/WorldMap'))
const LiveFeeds = lazy(() => import('../components/LiveFeeds'))
const VolumeProfileView = lazy(() => import('../components/VolumeProfileView'))
const NewsCenter = lazy(() => import('../components/NewsCenter'))
class TabErrorBoundary extends Component {
state = { error: null }
@@ -76,6 +78,8 @@ const TABS = [
{ id:'calendar', icon:'📅', label:'Calendar', component: EconomicCalendar },
{ id:'worldmap', icon:'🌍', label:'Live Map', component: WorldMap },
{ id:'livefeeds', icon:'📡', label:'Live Feeds', component: LiveFeeds },
{ id:'volume', icon:'📊', label:'Volume Profile', component: VolumeProfileView },
{ id:'newscenter', icon:'📰', label:'News Center', component: NewsCenter },
]
const LoadingFallback = () => (
Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB