mirror of
https://github.com/Mihirkansara/nexus-quant-terminal.git
synced 2026-11-02 09:36:11 +00:00
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:
co-authored by
Claude Opus 5.5
parent
fa6154884f
commit
7a9ae88241
+2
-2
@@ -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("/")
|
||||
|
||||
@@ -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]}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 |
Reference in New Issue
Block a user