nimazasinich Cursor Agent bxsfy712 commited on
Commit
19eb917
·
1 Parent(s): ca2386d

Cryptocurrency data source functionality (#124)

Browse files

* Refactor: Disable looping animations and update coverage reports

This commit disables all looping animations globally to prevent visual glitches. It also updates the coverage reports to reflect the current state of code coverage.

Co-authored-by: bxsfy712 <[email protected]>

* feat: Add model selection and health checks to models page

Co-authored-by: bxsfy712 <[email protected]>

* feat: Add support endpoints for fualt.txt and realendpoint.txt

Co-authored-by: bxsfy712 <[email protected]>

* feat: Add DeFi, Fear & Greed, and OHLCV endpoints

This commit introduces new API endpoints for DeFi data from DefiLlama, the Fear & Greed Index, and various OHLCV query styles. It also updates the frontend configuration helper to include these new endpoints and improve code snippets.

Co-authored-by: bxsfy712 <[email protected]>

* Refactor: Move toast defaults to config

Co-authored-by: bxsfy712 <[email protected]>

* Refactor: Improve sentiment display and styling

Enhance the visual presentation of sentiment analysis results with updated CSS and dynamic styling.

Co-authored-by: bxsfy712 <[email protected]>

* feat: Add API key status to dashboard and resource summary

Co-authored-by: bxsfy712 <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: bxsfy712 <[email protected]>

coverage.xml ADDED
The diff for this file is too large to render. See raw diff
 
hf_unified_server.py CHANGED
@@ -7,7 +7,7 @@ Multi-page architecture with HTTP polling and WebSocket support.
7
 
8
  from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
9
  from fastapi.middleware.cors import CORSMiddleware
10
- from fastapi.responses import JSONResponse, FileResponse, HTMLResponse, RedirectResponse
11
  from fastapi.staticfiles import StaticFiles
12
  from contextlib import asynccontextmanager
13
  from pathlib import Path
@@ -18,6 +18,7 @@ import json
18
  import asyncio
19
  import sys
20
  import os
 
21
  from typing import List, Dict, Any, Optional, Tuple
22
  from pydantic import BaseModel
23
  from dotenv import load_dotenv
@@ -61,6 +62,9 @@ logger = logging.getLogger(__name__)
61
  WORKSPACE_ROOT = Path(__file__).resolve().parent
62
  RESOURCES_FILE = WORKSPACE_ROOT / "crypto_resources_unified_2025-11-11.json"
63
  OHLCV_VERIFICATION_FILE = WORKSPACE_ROOT / "ohlcv_verification_results_20251127_003016.json"
 
 
 
64
 
65
 
66
  def _load_json_file(path: Path) -> Optional[Dict[str, Any]]:
@@ -74,6 +78,89 @@ def _load_json_file(path: Path) -> Optional[Dict[str, Any]]:
74
  return None
75
 
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  _RESOURCES_CACHE: Optional[Dict[str, Any]] = _load_json_file(RESOURCES_FILE)
78
  _OHLCV_VERIFICATION_CACHE: Optional[Dict[str, Any]] = _load_json_file(OHLCV_VERIFICATION_FILE)
79
 
@@ -463,6 +550,84 @@ async def get_all_endpoints():
463
  "timestamp": datetime.utcnow().isoformat() + "Z"
464
  }
465
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
466
  # ============================================================================
467
  # STATIC FILES
468
  # ============================================================================
@@ -663,6 +828,7 @@ async def api_resources_summary() -> Dict[str, Any]:
663
  """Resources summary endpoint for dashboard (compatible with frontend)."""
664
  try:
665
  summary, categories = _summarize_resources()
 
666
 
667
  # Format for frontend compatibility
668
  return {
@@ -672,6 +838,10 @@ async def api_resources_summary() -> Dict[str, Any]:
672
  "free_resources": summary.get("free", 0),
673
  "premium_resources": summary.get("premium", 0),
674
  "models_available": summary.get("models_available", 0),
 
 
 
 
675
  "local_routes_count": summary.get("local_routes_count", 0),
676
  "categories": {
677
  cat["name"].lower().replace(" ", "_"): {
@@ -695,6 +865,9 @@ async def api_resources_summary() -> Dict[str, Any]:
695
  "free_resources": 180,
696
  "premium_resources": 68,
697
  "models_available": 8,
 
 
 
698
  "local_routes_count": 24,
699
  "categories": {
700
  "market_data": {"count": 15, "type": "external"},
@@ -913,6 +1086,46 @@ async def api_sentiment_global(timeframe: str = "1D"):
913
  }
914
 
915
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
916
  @app.get("/api/sentiment/asset/{symbol}")
917
  async def api_sentiment_asset(symbol: str):
918
  """Get sentiment analysis for a specific asset"""
@@ -1161,6 +1374,66 @@ async def api_news_latest(limit: int = 50) -> Dict[str, Any]:
1161
  "error": str(e)
1162
  }
1163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1164
  @app.get("/api/market")
1165
  async def api_market(limit: Optional[int] = None):
1166
  """Market overview data - REAL DATA from CoinGecko"""
@@ -1345,6 +1618,11 @@ async def api_sentiment_analyze(payload: Dict[str, Any]):
1345
  try:
1346
  text = payload.get("text", "")
1347
  mode = payload.get("mode", "crypto")
 
 
 
 
 
1348
 
1349
  if not text:
1350
  return {
@@ -1356,15 +1634,41 @@ async def api_sentiment_analyze(payload: Dict[str, Any]):
1356
  # Use AI service for sentiment analysis
1357
  try:
1358
  from backend.services.ai_service_unified import ai_service
1359
- result = await ai_service.analyze_sentiment(text, mode=mode)
1360
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1361
  return {
1362
  "success": True,
1363
- "sentiment": result.get("sentiment", "neutral"),
1364
- "score": result.get("score", 0.5),
1365
- "confidence": result.get("confidence", 0.5),
1366
  "model": result.get("model", "unified"),
1367
- "timestamp": datetime.utcnow().isoformat() + "Z"
1368
  }
1369
  except Exception as e:
1370
  logger.warning(f"AI sentiment analysis failed: {e}, using fallback")
@@ -1413,6 +1717,53 @@ async def api_sentiment_analyze(payload: Dict[str, Any]):
1413
  # OHLCV DATA ENDPOINTS
1414
  # ============================================================================
1415
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1416
  @app.get("/api/ohlcv/{symbol}")
1417
  async def api_ohlcv_symbol(symbol: str, timeframe: str = "1h", limit: int = 100):
1418
  """Get OHLCV data for a symbol - fallback endpoint"""
@@ -1480,11 +1831,11 @@ async def api_ohlcv_multi(symbols: str, timeframe: str = "1h", limit: int = 100)
1480
  "timestamp": datetime.utcnow().isoformat() + "Z"
1481
  }
1482
 
1483
- # Root endpoint - Serve Dashboard as home page
1484
- @app.get("/", response_class=HTMLResponse)
1485
- async def root():
1486
- """Root endpoint - serves the dashboard page"""
1487
- return serve_page("dashboard")
1488
 
1489
  # API Root endpoint - Keep for backwards compatibility
1490
  @app.get("/api")
 
7
 
8
  from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
9
  from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.responses import JSONResponse, FileResponse, HTMLResponse, RedirectResponse, PlainTextResponse
11
  from fastapi.staticfiles import StaticFiles
12
  from contextlib import asynccontextmanager
13
  from pathlib import Path
 
18
  import asyncio
19
  import sys
20
  import os
21
+ import re
22
  from typing import List, Dict, Any, Optional, Tuple
23
  from pydantic import BaseModel
24
  from dotenv import load_dotenv
 
62
  WORKSPACE_ROOT = Path(__file__).resolve().parent
63
  RESOURCES_FILE = WORKSPACE_ROOT / "crypto_resources_unified_2025-11-11.json"
64
  OHLCV_VERIFICATION_FILE = WORKSPACE_ROOT / "ohlcv_verification_results_20251127_003016.json"
65
+ FAULT_LOG_FILE = WORKSPACE_ROOT / "fualt.txt"
66
+ REAL_ENDPOINTS_FILE = WORKSPACE_ROOT / "realendpoint.txt"
67
+ API_KEYS_CONFIG_FILE = WORKSPACE_ROOT / "config" / "api_keys.json"
68
 
69
 
70
  def _load_json_file(path: Path) -> Optional[Dict[str, Any]]:
 
78
  return None
79
 
80
 
81
+ def _read_text_file_tail(path: Path, tail: Optional[int] = None) -> Dict[str, Any]:
82
+ """
83
+ Read a text file safely with optional tail (last N lines).
84
+ Returns structured data for client consumption.
85
+ """
86
+ if not path.exists():
87
+ return {"exists": False, "path": str(path), "tail": tail, "lines": [], "content": ""}
88
+
89
+ try:
90
+ text = path.read_text(encoding="utf-8", errors="replace")
91
+ except Exception as exc: # pragma: no cover
92
+ logger.error("Failed to read %s: %s", path, exc)
93
+ return {"exists": True, "path": str(path), "tail": tail, "lines": [], "content": "", "error": str(exc)}
94
+
95
+ lines = text.splitlines()
96
+ if isinstance(tail, int) and tail > 0:
97
+ lines = lines[-tail:]
98
+ text = "\n".join(lines)
99
+
100
+ return {
101
+ "exists": True,
102
+ "path": str(path),
103
+ "tail": tail,
104
+ "line_count": len(lines),
105
+ "content": text,
106
+ "lines": lines,
107
+ }
108
+
109
+
110
+ def _count_configured_api_keys() -> Dict[str, Any]:
111
+ """
112
+ Count API keys configured via environment variables referenced in config/api_keys.json.
113
+ Values in the config are typically placeholders like "${ETHERSCAN_KEY}".
114
+ """
115
+ try:
116
+ if not API_KEYS_CONFIG_FILE.exists():
117
+ return {
118
+ "config_exists": False,
119
+ "total_key_refs": 0,
120
+ "configured_keys": 0,
121
+ "missing_keys": [],
122
+ }
123
+
124
+ raw = API_KEYS_CONFIG_FILE.read_text(encoding="utf-8", errors="replace")
125
+ cfg = json.loads(raw) if raw.strip() else {}
126
+
127
+ pattern = re.compile(r"\$\{([A-Z0-9_]+)\}")
128
+ referenced: List[str] = []
129
+
130
+ def walk(obj: Any) -> None:
131
+ if isinstance(obj, dict):
132
+ for v in obj.values():
133
+ walk(v)
134
+ elif isinstance(obj, list):
135
+ for v in obj:
136
+ walk(v)
137
+ elif isinstance(obj, str):
138
+ for m in pattern.finditer(obj):
139
+ referenced.append(m.group(1))
140
+
141
+ walk(cfg)
142
+ # Unique + stable order
143
+ refs = sorted(set(referenced))
144
+ configured = [k for k in refs if (os.getenv(k) or "").strip() not in ("", "null", "None")]
145
+ missing = [k for k in refs if k not in configured]
146
+
147
+ return {
148
+ "config_exists": True,
149
+ "total_key_refs": len(refs),
150
+ "configured_keys": len(configured),
151
+ "missing_keys": missing,
152
+ }
153
+ except Exception as e:
154
+ logger.error(f"Failed to count configured API keys: {e}")
155
+ return {
156
+ "config_exists": bool(API_KEYS_CONFIG_FILE.exists()),
157
+ "total_key_refs": 0,
158
+ "configured_keys": 0,
159
+ "missing_keys": [],
160
+ "error": str(e),
161
+ }
162
+
163
+
164
  _RESOURCES_CACHE: Optional[Dict[str, Any]] = _load_json_file(RESOURCES_FILE)
165
  _OHLCV_VERIFICATION_CACHE: Optional[Dict[str, Any]] = _load_json_file(OHLCV_VERIFICATION_FILE)
166
 
 
550
  "timestamp": datetime.utcnow().isoformat() + "Z"
551
  }
552
 
553
+ # ============================================================================
554
+ # SUPPORT FILES (fualt.txt, realendpoint.txt) FOR CLIENTS
555
+ # ============================================================================
556
+
557
+ @app.get("/api/support/fualt")
558
+ async def api_support_fualt(tail: Optional[int] = 500) -> Dict[str, Any]:
559
+ """
560
+ Expose `fualt.txt` to clients (debug/support).
561
+ Default returns last 500 lines to keep payload small.
562
+ """
563
+ data = _read_text_file_tail(FAULT_LOG_FILE, tail=tail)
564
+ data["timestamp"] = datetime.utcnow().isoformat() + "Z"
565
+ return data
566
+
567
+
568
+ @app.get("/fualt.txt")
569
+ async def download_fualt_txt():
570
+ """Download the raw `fualt.txt` file if present."""
571
+ if not FAULT_LOG_FILE.exists():
572
+ return PlainTextResponse("fualt.txt not found", status_code=404)
573
+ return FileResponse(FAULT_LOG_FILE)
574
+
575
+
576
+ def _build_real_endpoints_snapshot() -> List[Dict[str, Any]]:
577
+ """Build a minimal endpoint snapshot from the current FastAPI routing table."""
578
+ snapshot: List[Dict[str, Any]] = []
579
+ for route in app.routes:
580
+ if not hasattr(route, "path") or not hasattr(route, "methods"):
581
+ continue
582
+ if route.path.startswith("/openapi") or route.path == "/docs":
583
+ continue
584
+ methods = sorted([m for m in (route.methods or []) if m not in {"HEAD", "OPTIONS"}])
585
+ snapshot.append({"path": route.path, "methods": methods, "name": getattr(route, "name", "")})
586
+
587
+ # Sort stable for clients/diffs
588
+ snapshot.sort(key=lambda r: (r["path"], ",".join(r["methods"])))
589
+ return snapshot
590
+
591
+
592
+ @app.get("/api/support/realendpoints")
593
+ async def api_support_realendpoints(format: str = "json") -> Any:
594
+ """
595
+ Provide a "real endpoints" list for clients.
596
+ - format=json (default): structured list
597
+ - format=txt: plain text similar to a `realendpoint.txt` file
598
+ """
599
+ endpoints_snapshot = _build_real_endpoints_snapshot()
600
+ if format.lower() == "txt":
601
+ lines = []
602
+ for e in endpoints_snapshot:
603
+ methods = ",".join(e["methods"]) if e["methods"] else ""
604
+ lines.append(f"{methods:10} {e['path']}")
605
+ return PlainTextResponse("\n".join(lines) + "\n")
606
+
607
+ return {
608
+ "success": True,
609
+ "timestamp": datetime.utcnow().isoformat() + "Z",
610
+ "count": len(endpoints_snapshot),
611
+ "endpoints": endpoints_snapshot,
612
+ }
613
+
614
+
615
+ @app.get("/realendpoint.txt")
616
+ async def download_realendpoint_txt():
617
+ """
618
+ Download `realendpoint.txt` if present, otherwise generate it on the fly from
619
+ the live routing table (so it is always "supported").
620
+ """
621
+ if REAL_ENDPOINTS_FILE.exists():
622
+ return FileResponse(REAL_ENDPOINTS_FILE)
623
+ # Generate on the fly
624
+ endpoints_snapshot = _build_real_endpoints_snapshot()
625
+ lines = []
626
+ for e in endpoints_snapshot:
627
+ methods = ",".join(e["methods"]) if e["methods"] else ""
628
+ lines.append(f"{methods:10} {e['path']}")
629
+ return PlainTextResponse("\n".join(lines) + "\n")
630
+
631
  # ============================================================================
632
  # STATIC FILES
633
  # ============================================================================
 
828
  """Resources summary endpoint for dashboard (compatible with frontend)."""
829
  try:
830
  summary, categories = _summarize_resources()
831
+ keys_info = _count_configured_api_keys()
832
 
833
  # Format for frontend compatibility
834
  return {
 
838
  "free_resources": summary.get("free", 0),
839
  "premium_resources": summary.get("premium", 0),
840
  "models_available": summary.get("models_available", 0),
841
+ # API key status (for dashboard)
842
+ "total_api_keys": keys_info.get("total_key_refs", 0),
843
+ "configured_api_keys": keys_info.get("configured_keys", 0),
844
+ "api_keys_config_loaded": keys_info.get("config_exists", False),
845
  "local_routes_count": summary.get("local_routes_count", 0),
846
  "categories": {
847
  cat["name"].lower().replace(" ", "_"): {
 
865
  "free_resources": 180,
866
  "premium_resources": 68,
867
  "models_available": 8,
868
+ "total_api_keys": 0,
869
+ "configured_api_keys": 0,
870
+ "api_keys_config_loaded": False,
871
  "local_routes_count": 24,
872
  "categories": {
873
  "market_data": {"count": 15, "type": "external"},
 
1086
  }
1087
 
1088
 
1089
+ @app.get("/api/fear-greed")
1090
+ async def api_fear_greed(limit: int = 1) -> Dict[str, Any]:
1091
+ """
1092
+ Convenience endpoint for Fear & Greed Index (Alternative.me).
1093
+ This keeps client integrations simple and is safe to call from the dashboard.
1094
+ """
1095
+ try:
1096
+ import httpx
1097
+
1098
+ async with httpx.AsyncClient(timeout=10.0) as client:
1099
+ response = await client.get("https://api.alternative.me/fng/", params={"limit": str(limit)})
1100
+ response.raise_for_status()
1101
+ payload = response.json()
1102
+
1103
+ data = payload.get("data") or []
1104
+ latest = data[0] if isinstance(data, list) and data else {}
1105
+
1106
+ value = int(latest.get("value", 50))
1107
+ classification = latest.get("value_classification", "Neutral")
1108
+
1109
+ return {
1110
+ "success": True,
1111
+ "value": value,
1112
+ "classification": classification,
1113
+ "timestamp": datetime.utcnow().isoformat() + "Z",
1114
+ "source": "alternative.me",
1115
+ "data": data[: min(len(data), 30)],
1116
+ }
1117
+ except Exception as e:
1118
+ logger.error(f"Failed to fetch Fear & Greed Index: {e}")
1119
+ return {
1120
+ "success": False,
1121
+ "value": 50,
1122
+ "classification": "Neutral",
1123
+ "timestamp": datetime.utcnow().isoformat() + "Z",
1124
+ "source": "unavailable",
1125
+ "error": str(e),
1126
+ }
1127
+
1128
+
1129
  @app.get("/api/sentiment/asset/{symbol}")
1130
  async def api_sentiment_asset(symbol: str):
1131
  """Get sentiment analysis for a specific asset"""
 
1374
  "error": str(e)
1375
  }
1376
 
1377
+
1378
+ # ============================================================================
1379
+ # DeFi (public, no keys) - DefiLlama
1380
+ # ============================================================================
1381
+
1382
+ @app.get("/api/defi/tvl")
1383
+ async def api_defi_tvl() -> Dict[str, Any]:
1384
+ """Total Value Locked (TVL) using DefiLlama public API."""
1385
+ try:
1386
+ import httpx
1387
+ async with httpx.AsyncClient(timeout=10.0) as client:
1388
+ resp = await client.get("https://api.llama.fi/tvl")
1389
+ resp.raise_for_status()
1390
+ tvl = resp.json()
1391
+ return {"success": True, "tvl": tvl, "source": "defillama", "timestamp": datetime.utcnow().isoformat() + "Z"}
1392
+ except Exception as e:
1393
+ logger.error(f"DeFi TVL failed: {e}")
1394
+ return {"success": False, "tvl": None, "source": "unavailable", "error": str(e), "timestamp": datetime.utcnow().isoformat() + "Z"}
1395
+
1396
+
1397
+ @app.get("/api/defi/protocols")
1398
+ async def api_defi_protocols(limit: int = 20) -> Dict[str, Any]:
1399
+ """Top DeFi protocols by TVL using DefiLlama public API."""
1400
+ try:
1401
+ import httpx
1402
+ async with httpx.AsyncClient(timeout=15.0) as client:
1403
+ resp = await client.get("https://api.llama.fi/protocols")
1404
+ resp.raise_for_status()
1405
+ protocols = resp.json()
1406
+
1407
+ if isinstance(protocols, list):
1408
+ protocols = sorted(protocols, key=lambda p: float(p.get("tvl") or 0), reverse=True)
1409
+ protocols = protocols[: max(1, min(int(limit), 100))]
1410
+
1411
+ return {"success": True, "protocols": protocols, "source": "defillama", "timestamp": datetime.utcnow().isoformat() + "Z"}
1412
+ except Exception as e:
1413
+ logger.error(f"DeFi protocols failed: {e}")
1414
+ return {"success": False, "protocols": [], "source": "unavailable", "error": str(e), "timestamp": datetime.utcnow().isoformat() + "Z"}
1415
+
1416
+
1417
+ @app.get("/api/defi/yields")
1418
+ async def api_defi_yields(limit: int = 20) -> Dict[str, Any]:
1419
+ """Yield pools snapshot using DefiLlama public API."""
1420
+ try:
1421
+ import httpx
1422
+ async with httpx.AsyncClient(timeout=15.0) as client:
1423
+ resp = await client.get("https://yields.llama.fi/pools")
1424
+ resp.raise_for_status()
1425
+ payload = resp.json()
1426
+
1427
+ pools = payload.get("data") if isinstance(payload, dict) else []
1428
+ if isinstance(pools, list):
1429
+ pools = sorted(pools, key=lambda p: float(p.get("tvlUsd") or 0), reverse=True)
1430
+ pools = pools[: max(1, min(int(limit), 100))]
1431
+
1432
+ return {"success": True, "pools": pools, "source": "defillama", "timestamp": datetime.utcnow().isoformat() + "Z"}
1433
+ except Exception as e:
1434
+ logger.error(f"DeFi yields failed: {e}")
1435
+ return {"success": False, "pools": [], "source": "unavailable", "error": str(e), "timestamp": datetime.utcnow().isoformat() + "Z"}
1436
+
1437
  @app.get("/api/market")
1438
  async def api_market(limit: Optional[int] = None):
1439
  """Market overview data - REAL DATA from CoinGecko"""
 
1618
  try:
1619
  text = payload.get("text", "")
1620
  mode = payload.get("mode", "crypto")
1621
+ # Optional: allow explicit HF model selection from the UI
1622
+ # - `model_key`: key from the server/client registry (preferred)
1623
+ # - `model`: backwards-compatible alias used by some pages
1624
+ model_key = payload.get("model_key") or payload.get("model")
1625
+ use_ensemble = bool(payload.get("use_ensemble", True))
1626
 
1627
  if not text:
1628
  return {
 
1634
  # Use AI service for sentiment analysis
1635
  try:
1636
  from backend.services.ai_service_unified import ai_service
1637
+
1638
+ # If the UI requested a specific model_key and HF client is available,
1639
+ # call it directly so the Models page "Test Model" works.
1640
+ if model_key and getattr(ai_service, "hf_client", None) is not None:
1641
+ hf_result = await ai_service.hf_client.analyze_sentiment(
1642
+ text=text,
1643
+ model_key=str(model_key),
1644
+ use_cache=True,
1645
+ )
1646
+
1647
+ # Normalize HF API client response into the UI-friendly shape.
1648
+ if hf_result.get("status") == "success":
1649
+ return {
1650
+ "success": True,
1651
+ "sentiment": hf_result.get("label", "neutral"),
1652
+ "score": hf_result.get("score", hf_result.get("confidence", 0.5)),
1653
+ "confidence": hf_result.get("confidence", hf_result.get("score", 0.5)),
1654
+ "model": hf_result.get("model", "hf_inference_api"),
1655
+ "model_key": hf_result.get("model_key", model_key),
1656
+ "engine": hf_result.get("engine", "hf_inference_api"),
1657
+ "timestamp": datetime.utcnow().isoformat() + "Z",
1658
+ }
1659
+
1660
+ # If the selected model isn't available, fall back to auto mode.
1661
+ # (Still return success=False only if everything fails.)
1662
+
1663
+ result = await ai_service.analyze_sentiment(text, category=mode, use_ensemble=use_ensemble)
1664
+
1665
  return {
1666
  "success": True,
1667
+ "sentiment": result.get("sentiment", result.get("label", "neutral")),
1668
+ "score": result.get("score", result.get("confidence", 0.5)),
1669
+ "confidence": result.get("confidence", result.get("score", 0.5)),
1670
  "model": result.get("model", "unified"),
1671
+ "timestamp": datetime.utcnow().isoformat() + "Z",
1672
  }
1673
  except Exception as e:
1674
  logger.warning(f"AI sentiment analysis failed: {e}, using fallback")
 
1717
  # OHLCV DATA ENDPOINTS
1718
  # ============================================================================
1719
 
1720
+ @app.get("/api/ohlcv")
1721
+ async def api_ohlcv_query(symbol: str, timeframe: str = "1h", limit: int = 100):
1722
+ """Query-style OHLCV endpoint for frontend compatibility."""
1723
+ return await api_ohlcv_symbol(symbol=symbol, timeframe=timeframe, limit=limit)
1724
+
1725
+
1726
+ @app.get("/api/klines")
1727
+ async def api_klines(symbol: str, interval: str = "1h", limit: int = 100):
1728
+ """Binance-style klines alias for frontend compatibility."""
1729
+ sym = symbol.upper().strip()
1730
+ m = re.match(r"^([A-Z0-9]+?)(USDT|USD|USDC|BUSD)$", sym)
1731
+ base = m.group(1) if m else sym
1732
+ return await api_ohlcv_symbol(symbol=base, timeframe=interval, limit=limit)
1733
+
1734
+
1735
+ @app.get("/api/historical")
1736
+ async def api_historical(symbol: str, days: int = 30):
1737
+ """Simple historical alias (daily candles)."""
1738
+ try:
1739
+ from backend.services.binance_client import BinanceClient
1740
+
1741
+ binance = BinanceClient()
1742
+ fetch_days = min(max(int(days), 1), 365)
1743
+ data = await binance.get_ohlcv(symbol.upper(), "1d", fetch_days)
1744
+ return {
1745
+ "success": True,
1746
+ "symbol": symbol.upper(),
1747
+ "days": fetch_days,
1748
+ "data": data,
1749
+ "count": len(data),
1750
+ "timestamp": datetime.utcnow().isoformat() + "Z",
1751
+ "source": "binance",
1752
+ }
1753
+ except Exception as e:
1754
+ logger.warning(f"Historical fetch failed for {symbol}: {e}")
1755
+ return {
1756
+ "success": False,
1757
+ "symbol": symbol.upper(),
1758
+ "days": days,
1759
+ "data": [],
1760
+ "count": 0,
1761
+ "timestamp": datetime.utcnow().isoformat() + "Z",
1762
+ "source": "unavailable",
1763
+ "error": str(e),
1764
+ }
1765
+
1766
+
1767
  @app.get("/api/ohlcv/{symbol}")
1768
  async def api_ohlcv_symbol(symbol: str, timeframe: str = "1h", limit: int = 100):
1769
  """Get OHLCV data for a symbol - fallback endpoint"""
 
1831
  "timestamp": datetime.utcnow().isoformat() + "Z"
1832
  }
1833
 
1834
+ #
1835
+ # NOTE:
1836
+ # `/` is already handled earlier (redirects to dashboard). Keep a single handler
1837
+ # for a stable routing table (avoids duplicate route definitions).
1838
+ #
1839
 
1840
  # API Root endpoint - Keep for backwards compatibility
1841
  @app.get("/api")
static/pages/dashboard/dashboard.js CHANGED
@@ -541,22 +541,26 @@ class DashboardPage {
541
 
542
  async fetchStats() {
543
  try {
544
- const [res1, res2] = await Promise.allSettled([
545
  apiClient.fetch('/api/resources/summary', {}, 15000).then(r => r.ok ? r.json() : null),
546
- apiClient.fetch('/api/models/status', {}, 10000).then(r => r.ok ? r.json() : null)
 
547
  ]);
548
 
549
  const data = res1.value?.summary || res1.value || {};
550
  const models = res2.value || {};
 
551
 
552
- // FIX: Calculate actual provider count correctly
553
- const providerCount = data.by_category ?
554
- Object.keys(data.by_category || {}).length :
555
- (data.available_providers || data.total_providers || 0);
 
556
 
557
  return {
558
  total_resources: data.total_resources || 0,
559
- api_keys: data.total_api_keys || 0,
 
560
  models_loaded: models.models_loaded || data.models_available || 0,
561
  active_providers: providerCount // FIX: Use actual provider count, not total_resources
562
  };
 
541
 
542
  async fetchStats() {
543
  try {
544
+ const [res1, res2, res3] = await Promise.allSettled([
545
  apiClient.fetch('/api/resources/summary', {}, 15000).then(r => r.ok ? r.json() : null),
546
+ apiClient.fetch('/api/models/status', {}, 10000).then(r => r.ok ? r.json() : null),
547
+ apiClient.fetch('/api/providers', {}, 10000).then(r => r.ok ? r.json() : null)
548
  ]);
549
 
550
  const data = res1.value?.summary || res1.value || {};
551
  const models = res2.value || {};
552
+ const providers = res3.value || {};
553
 
554
+ // Providers: prefer backend providers endpoint; fallback to categories length if needed
555
+ const providerCount = Number.isFinite(providers?.online) ? providers.online
556
+ : Number.isFinite(providers?.total) ? providers.total
557
+ : Array.isArray(data.by_category) ? data.by_category.length
558
+ : 0;
559
 
560
  return {
561
  total_resources: data.total_resources || 0,
562
+ // Show configured keys (real usefulness), fallback to total refs
563
+ api_keys: data.configured_api_keys ?? data.total_api_keys ?? 0,
564
  models_loaded: models.models_loaded || data.models_available || 0,
565
  active_providers: providerCount // FIX: Use actual provider count, not total_resources
566
  };
static/pages/models/models.css CHANGED
@@ -853,12 +853,25 @@
853
  .test-result {
854
  margin-top: var(--space-6);
855
  padding: var(--space-6);
856
- background: rgba(0, 0, 0, 0.3);
857
- border: 1px solid rgba(255, 255, 255, 0.1);
858
  border-radius: var(--radius-xl);
 
 
 
859
  animation: fadeIn 0.4s ease;
860
  }
861
 
 
 
 
 
 
 
 
 
 
 
862
  .test-result.hidden {
863
  display: none;
864
  }
@@ -868,11 +881,13 @@
868
  justify-content: space-between;
869
  align-items: center;
870
  margin-bottom: var(--space-4);
 
 
871
  }
872
 
873
  .result-header h3 {
874
  font-size: var(--font-size-lg);
875
- font-weight: 600;
876
  color: var(--text-strong);
877
  margin: 0;
878
  }
@@ -880,40 +895,107 @@
880
  .result-time {
881
  font-size: var(--font-size-xs);
882
  color: var(--text-muted);
 
 
 
 
 
 
 
 
 
 
 
 
883
  }
884
 
885
  .sentiment-display {
886
- text-align: center;
 
887
  padding: var(--space-6);
888
- background: linear-gradient(135deg, rgba(139, 92, 246, 0.1) 0%, rgba(59, 130, 246, 0.1) 100%);
 
889
  border-radius: var(--radius-xl);
890
- margin-bottom: var(--space-5);
 
 
 
 
891
  }
892
 
 
 
 
 
 
 
 
 
 
 
 
 
 
893
  .sentiment-emoji {
894
- font-size: 64px;
895
- margin-bottom: var(--space-3);
 
 
 
 
 
 
 
 
 
896
  }
897
 
898
  .sentiment-label {
 
 
899
  font-family: 'Space Grotesk', sans-serif;
900
- font-size: var(--font-size-2xl);
901
  font-weight: 700;
902
  text-transform: uppercase;
903
- margin-bottom: var(--space-2);
 
 
 
904
  }
905
 
906
- .sentiment-label.bullish { color: #4ade80; }
907
- .sentiment-label.bearish { color: #f87171; }
908
- .sentiment-label.neutral { color: #60a5fa; }
 
 
 
 
 
 
 
 
 
909
 
910
  .sentiment-confidence {
911
- font-size: var(--font-size-lg);
912
- color: var(--text-muted);
 
 
 
 
 
 
 
 
 
 
 
 
913
  }
914
 
915
  .result-details {
916
- background: rgba(0, 0, 0, 0.4);
 
917
  border-radius: var(--radius-md);
918
  padding: var(--space-4);
919
  overflow: auto;
@@ -922,10 +1004,27 @@
922
 
923
  .result-json {
924
  font-family: 'JetBrains Mono', monospace;
925
- font-size: var(--font-size-xs);
926
- color: #22d3ee;
927
  white-space: pre-wrap;
928
  margin: 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
929
  }
930
 
931
  /* =========================================================================
 
853
  .test-result {
854
  margin-top: var(--space-6);
855
  padding: var(--space-6);
856
+ background: linear-gradient(180deg, rgba(2, 6, 23, 0.55), rgba(0, 0, 0, 0.35));
857
+ border: 1px solid rgba(255, 255, 255, 0.12);
858
  border-radius: var(--radius-xl);
859
+ box-shadow: 0 18px 60px rgba(0, 0, 0, 0.35);
860
+ position: relative;
861
+ overflow: hidden;
862
  animation: fadeIn 0.4s ease;
863
  }
864
 
865
+ .test-result::before {
866
+ content: '';
867
+ position: absolute;
868
+ inset: 0;
869
+ pointer-events: none;
870
+ background: radial-gradient(900px 220px at 20% 0%, rgba(139, 92, 246, 0.18), transparent 60%),
871
+ radial-gradient(900px 220px at 80% 0%, rgba(34, 211, 238, 0.14), transparent 60%);
872
+ opacity: 0.9;
873
+ }
874
+
875
  .test-result.hidden {
876
  display: none;
877
  }
 
881
  justify-content: space-between;
882
  align-items: center;
883
  margin-bottom: var(--space-4);
884
+ position: relative;
885
+ z-index: 1;
886
  }
887
 
888
  .result-header h3 {
889
  font-size: var(--font-size-lg);
890
+ font-weight: 700;
891
  color: var(--text-strong);
892
  margin: 0;
893
  }
 
895
  .result-time {
896
  font-size: var(--font-size-xs);
897
  color: var(--text-muted);
898
+ padding: 6px 10px;
899
+ border-radius: var(--radius-full);
900
+ background: rgba(255, 255, 255, 0.06);
901
+ border: 1px solid rgba(255, 255, 255, 0.10);
902
+ }
903
+
904
+ .result-body {
905
+ display: grid;
906
+ grid-template-columns: minmax(260px, 360px) 1fr;
907
+ gap: var(--space-5);
908
+ position: relative;
909
+ z-index: 1;
910
  }
911
 
912
  .sentiment-display {
913
+ --accent-rgb: 96 165 250; /* default */
914
+ text-align: left;
915
  padding: var(--space-6);
916
+ background: linear-gradient(135deg, rgba(var(--accent-rgb) / 0.14) 0%, rgba(255, 255, 255, 0.04) 65%);
917
+ border: 1px solid rgba(255, 255, 255, 0.10);
918
  border-radius: var(--radius-xl);
919
+ display: grid;
920
+ grid-template-columns: 76px 1fr;
921
+ grid-template-rows: auto auto;
922
+ column-gap: var(--space-4);
923
+ row-gap: var(--space-2);
924
  }
925
 
926
+ .sentiment-display[data-sentiment="bullish"],
927
+ .sentiment-display[data-sentiment="positive"],
928
+ .sentiment-display[data-sentiment="buy"] { --accent-rgb: 34 197 94; }
929
+
930
+ .sentiment-display[data-sentiment="bearish"],
931
+ .sentiment-display[data-sentiment="negative"],
932
+ .sentiment-display[data-sentiment="sell"] { --accent-rgb: 239 68 68; }
933
+
934
+ .sentiment-display[data-sentiment="neutral"],
935
+ .sentiment-display[data-sentiment="hold"] { --accent-rgb: 59 130 246; }
936
+
937
+ .sentiment-display[data-sentiment="unknown"] { --accent-rgb: 148 163 184; }
938
+
939
  .sentiment-emoji {
940
+ grid-row: 1 / span 2;
941
+ grid-column: 1;
942
+ width: 76px;
943
+ height: 76px;
944
+ display: grid;
945
+ place-items: center;
946
+ font-size: 42px;
947
+ border-radius: 18px;
948
+ background: rgba(var(--accent-rgb) / 0.16);
949
+ border: 1px solid rgba(var(--accent-rgb) / 0.28);
950
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
951
  }
952
 
953
  .sentiment-label {
954
+ grid-column: 2;
955
+ grid-row: 1;
956
  font-family: 'Space Grotesk', sans-serif;
957
+ font-size: clamp(22px, 2.2vw, 30px);
958
  font-weight: 700;
959
  text-transform: uppercase;
960
+ letter-spacing: 0.08em;
961
+ margin: 0;
962
+ line-height: 1.1;
963
+ color: rgb(var(--accent-rgb));
964
  }
965
 
966
+ .sentiment-label.bullish,
967
+ .sentiment-label.positive,
968
+ .sentiment-label.buy { color: rgb(34, 197, 94); }
969
+
970
+ .sentiment-label.bearish,
971
+ .sentiment-label.negative,
972
+ .sentiment-label.sell { color: rgb(239, 68, 68); }
973
+
974
+ .sentiment-label.neutral,
975
+ .sentiment-label.hold { color: rgb(59, 130, 246); }
976
+
977
+ .sentiment-label.unknown { color: rgb(148, 163, 184); }
978
 
979
  .sentiment-confidence {
980
+ grid-column: 2;
981
+ grid-row: 2;
982
+ font-size: var(--font-size-sm);
983
+ color: rgba(226, 232, 240, 0.85);
984
+ padding: 10px 12px;
985
+ border-radius: var(--radius-lg);
986
+ border: 1px solid rgba(255, 255, 255, 0.10);
987
+ background:
988
+ linear-gradient(
989
+ 90deg,
990
+ rgba(var(--accent-rgb) / 0.25) 0 var(--confidence, 0%),
991
+ rgba(255, 255, 255, 0.06) var(--confidence, 0%) 100%
992
+ );
993
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
994
  }
995
 
996
  .result-details {
997
+ background: rgba(0, 0, 0, 0.38);
998
+ border: 1px solid rgba(255, 255, 255, 0.10);
999
  border-radius: var(--radius-md);
1000
  padding: var(--space-4);
1001
  overflow: auto;
 
1004
 
1005
  .result-json {
1006
  font-family: 'JetBrains Mono', monospace;
1007
+ font-size: 12px;
1008
+ color: rgba(226, 232, 240, 0.92);
1009
  white-space: pre-wrap;
1010
  margin: 0;
1011
+ line-height: 1.55;
1012
+ }
1013
+
1014
+ @media (max-width: 900px) {
1015
+ .result-body {
1016
+ grid-template-columns: 1fr;
1017
+ }
1018
+ .sentiment-display {
1019
+ grid-template-columns: 64px 1fr;
1020
+ padding: var(--space-5);
1021
+ }
1022
+ .sentiment-emoji {
1023
+ width: 64px;
1024
+ height: 64px;
1025
+ font-size: 36px;
1026
+ border-radius: 16px;
1027
+ }
1028
  }
1029
 
1030
  /* =========================================================================
static/pages/models/models.js CHANGED
@@ -11,6 +11,8 @@ import logger from '../../shared/js/utils/logger.js';
11
  class ModelsPage {
12
  constructor() {
13
  this.models = [];
 
 
14
  this.refreshInterval = null;
15
  }
16
 
@@ -20,6 +22,7 @@ class ModelsPage {
20
 
21
  this.bindEvents();
22
  await this.loadModels();
 
23
 
24
  this.refreshInterval = setInterval(() => this.loadModels(), 60000);
25
 
@@ -30,6 +33,16 @@ class ModelsPage {
30
  }
31
  }
32
 
 
 
 
 
 
 
 
 
 
 
33
  bindEvents() {
34
  // Refresh button
35
  const refreshBtn = document.getElementById('refresh-btn');
@@ -81,6 +94,23 @@ class ModelsPage {
81
  this.reinitializeAll();
82
  });
83
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  }
85
 
86
  switchTab(tabId) {
@@ -131,7 +161,7 @@ class ModelsPage {
131
  const response = await fetch('/api/models/list', {
132
  method: 'GET',
133
  headers: { 'Content-Type': 'application/json' },
134
- signal: AbortSignal.timeout(10000)
135
  });
136
 
137
  if (response.ok) {
@@ -154,7 +184,7 @@ class ModelsPage {
154
  const response = await fetch('/api/models/status', {
155
  method: 'GET',
156
  headers: { 'Content-Type': 'application/json' },
157
- signal: AbortSignal.timeout(10000)
158
  });
159
 
160
  if (response.ok) {
@@ -179,7 +209,7 @@ class ModelsPage {
179
  const response = await fetch('/api/models/summary', {
180
  method: 'GET',
181
  headers: { 'Content-Type': 'application/json' },
182
- signal: AbortSignal.timeout(10000)
183
  });
184
 
185
  if (response.ok) {
@@ -232,7 +262,9 @@ class ModelsPage {
232
  this.models = this.getFallbackModels();
233
  }
234
 
235
- this.renderModels();
 
 
236
 
237
  // Update stats from payload or calculate from models
238
  const stats = {
@@ -258,7 +290,9 @@ class ModelsPage {
258
 
259
  // Fallback to demo data
260
  this.models = this.getFallbackModels();
261
- this.renderModels();
 
 
262
  this.renderStats({
263
  total_models: this.models.length,
264
  models_loaded: 0,
@@ -274,19 +308,38 @@ class ModelsPage {
274
  populateTestModelSelect() {
275
  const testModelSelect = document.getElementById('test-model-select');
276
  if (testModelSelect && this.models.length > 0) {
277
- testModelSelect.innerHTML = '<option value="">Select a model...</option>';
 
278
 
279
- this.models.forEach(model => {
280
- if (model.loaded) {
281
- const option = document.createElement('option');
282
- option.value = model.key;
283
- option.textContent = `${model.name} (${model.category})`;
284
- testModelSelect.appendChild(option);
285
- }
286
  });
287
  }
288
  }
289
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
  /**
291
  * Extract models array from various payload structures
292
  */
@@ -458,9 +511,12 @@ class ModelsPage {
458
  method: 'POST',
459
  headers: { 'Content-Type': 'application/json' },
460
  body: JSON.stringify({
461
- text: 'Bitcoin is going to the moon! 🚀'
 
 
 
462
  }),
463
- signal: AbortSignal.timeout(10000)
464
  });
465
 
466
  if (response.ok) {
@@ -501,7 +557,7 @@ class ModelsPage {
501
  }
502
 
503
  const text = input.value.trim();
504
- const modelId = modelSelect?.value || 'sentiment';
505
 
506
  this.showToast('Analyzing...', 'info');
507
 
@@ -509,8 +565,13 @@ class ModelsPage {
509
  const response = await fetch('/api/sentiment/analyze', {
510
  method: 'POST',
511
  headers: { 'Content-Type': 'application/json' },
512
- body: JSON.stringify({ text, model: modelId }),
513
- signal: AbortSignal.timeout(10000)
 
 
 
 
 
514
  });
515
 
516
  if (!response.ok) {
@@ -529,13 +590,26 @@ class ModelsPage {
529
  const emojiEl = document.getElementById('sentiment-emoji');
530
  const labelEl = document.getElementById('sentiment-label');
531
  const confidenceEl = document.getElementById('sentiment-confidence');
 
532
  const timeEl = document.getElementById('result-time');
533
  const jsonPre = document.querySelector('.result-json');
534
 
535
  if (emojiEl) emojiEl.textContent = emoji;
536
- if (labelEl) labelEl.textContent = result.sentiment || 'Unknown';
 
 
 
 
 
 
 
 
 
 
 
537
  if (confidenceEl) {
538
- confidenceEl.textContent = result.score ? `Confidence: ${(result.score * 100).toFixed(1)}%` : '';
 
539
  }
540
  if (timeEl) timeEl.textContent = new Date().toLocaleTimeString();
541
  if (jsonPre) jsonPre.textContent = JSON.stringify(result, null, 2);
@@ -547,6 +621,111 @@ class ModelsPage {
547
  }
548
  }
549
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
550
  getSentimentEmoji(sentiment) {
551
  const emojiMap = {
552
  'positive': '😊',
@@ -581,7 +760,7 @@ class ModelsPage {
581
  const response = await fetch('/api/models/reinitialize', {
582
  method: 'POST',
583
  headers: { 'Content-Type': 'application/json' },
584
- signal: AbortSignal.timeout(30000)
585
  });
586
 
587
  if (response.ok) {
 
11
  class ModelsPage {
12
  constructor() {
13
  this.models = [];
14
+ this.allModels = [];
15
+ this.activeFilters = { category: 'all', status: 'all' };
16
  this.refreshInterval = null;
17
  }
18
 
 
22
 
23
  this.bindEvents();
24
  await this.loadModels();
25
+ await this.loadHealth();
26
 
27
  this.refreshInterval = setInterval(() => this.loadModels(), 60000);
28
 
 
33
  }
34
  }
35
 
36
+ createTimeoutSignal(ms = 10000) {
37
+ // Prefer AbortSignal.timeout when available, fallback to AbortController.
38
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
39
+ return AbortSignal.timeout(ms);
40
+ }
41
+ const controller = new AbortController();
42
+ setTimeout(() => controller.abort(), ms);
43
+ return controller.signal;
44
+ }
45
+
46
  bindEvents() {
47
  // Refresh button
48
  const refreshBtn = document.getElementById('refresh-btn');
 
94
  this.reinitializeAll();
95
  });
96
  }
97
+
98
+ // Filters
99
+ const categoryFilter = document.getElementById('category-filter');
100
+ if (categoryFilter) {
101
+ categoryFilter.addEventListener('change', (e) => {
102
+ this.activeFilters.category = e.target.value || 'all';
103
+ this.applyFilters();
104
+ });
105
+ }
106
+
107
+ const statusFilter = document.getElementById('status-filter');
108
+ if (statusFilter) {
109
+ statusFilter.addEventListener('change', (e) => {
110
+ this.activeFilters.status = e.target.value || 'all';
111
+ this.applyFilters();
112
+ });
113
+ }
114
  }
115
 
116
  switchTab(tabId) {
 
161
  const response = await fetch('/api/models/list', {
162
  method: 'GET',
163
  headers: { 'Content-Type': 'application/json' },
164
+ signal: this.createTimeoutSignal(10000)
165
  });
166
 
167
  if (response.ok) {
 
184
  const response = await fetch('/api/models/status', {
185
  method: 'GET',
186
  headers: { 'Content-Type': 'application/json' },
187
+ signal: this.createTimeoutSignal(10000)
188
  });
189
 
190
  if (response.ok) {
 
209
  const response = await fetch('/api/models/summary', {
210
  method: 'GET',
211
  headers: { 'Content-Type': 'application/json' },
212
+ signal: this.createTimeoutSignal(10000)
213
  });
214
 
215
  if (response.ok) {
 
262
  this.models = this.getFallbackModels();
263
  }
264
 
265
+ this.allModels = [...this.models];
266
+ this.applyFilters(false);
267
+ this.renderCatalog();
268
 
269
  // Update stats from payload or calculate from models
270
  const stats = {
 
290
 
291
  // Fallback to demo data
292
  this.models = this.getFallbackModels();
293
+ this.allModels = [...this.models];
294
+ this.applyFilters(false);
295
+ this.renderCatalog();
296
  this.renderStats({
297
  total_models: this.models.length,
298
  models_loaded: 0,
 
308
  populateTestModelSelect() {
309
  const testModelSelect = document.getElementById('test-model-select');
310
  if (testModelSelect && this.models.length > 0) {
311
+ // Allow testing any model key via backend (auto-fallback if unavailable)
312
+ testModelSelect.innerHTML = '<option value="">Auto (best available)</option>';
313
 
314
+ const sorted = [...this.models].sort((a, b) => (a.category || '').localeCompare(b.category || '') || (a.name || '').localeCompare(b.name || ''));
315
+ sorted.forEach(model => {
316
+ const option = document.createElement('option');
317
+ option.value = model.key;
318
+ option.textContent = `${model.name} (${model.category})`;
319
+ testModelSelect.appendChild(option);
 
320
  });
321
  }
322
  }
323
 
324
+ applyFilters(shouldRerender = true) {
325
+ const category = this.activeFilters.category;
326
+ const status = this.activeFilters.status;
327
+
328
+ const filtered = (this.allModels || []).filter((m) => {
329
+ const catOk = category === 'all' ? true : (m.category === category || (m.category || '').toLowerCase() === category.toLowerCase());
330
+ const statusOk = status === 'all' ? true : (m.status === status || (status === 'available' && !m.loaded && !m.failed));
331
+ return catOk && statusOk;
332
+ });
333
+
334
+ this.models = filtered;
335
+ if (shouldRerender) {
336
+ this.renderModels();
337
+ } else {
338
+ // For initial load path we still need to render once.
339
+ this.renderModels();
340
+ }
341
+ }
342
+
343
  /**
344
  * Extract models array from various payload structures
345
  */
 
511
  method: 'POST',
512
  headers: { 'Content-Type': 'application/json' },
513
  body: JSON.stringify({
514
+ text: 'Bitcoin is going to the moon! 🚀',
515
+ mode: 'crypto',
516
+ model_key: modelId,
517
+ use_ensemble: false
518
  }),
519
+ signal: this.createTimeoutSignal(10000)
520
  });
521
 
522
  if (response.ok) {
 
557
  }
558
 
559
  const text = input.value.trim();
560
+ const modelKey = modelSelect?.value || '';
561
 
562
  this.showToast('Analyzing...', 'info');
563
 
 
565
  const response = await fetch('/api/sentiment/analyze', {
566
  method: 'POST',
567
  headers: { 'Content-Type': 'application/json' },
568
+ body: JSON.stringify({
569
+ text,
570
+ mode: 'crypto',
571
+ model_key: modelKey || undefined,
572
+ use_ensemble: !modelKey
573
+ }),
574
+ signal: this.createTimeoutSignal(10000)
575
  });
576
 
577
  if (!response.ok) {
 
590
  const emojiEl = document.getElementById('sentiment-emoji');
591
  const labelEl = document.getElementById('sentiment-label');
592
  const confidenceEl = document.getElementById('sentiment-confidence');
593
+ const displayEl = document.getElementById('sentiment-display');
594
  const timeEl = document.getElementById('result-time');
595
  const jsonPre = document.querySelector('.result-json');
596
 
597
  if (emojiEl) emojiEl.textContent = emoji;
598
+ const sentimentKey = (result.sentiment || 'unknown').toString().toLowerCase();
599
+ if (displayEl) {
600
+ displayEl.setAttribute('data-sentiment', sentimentKey);
601
+ const pct = (typeof result.score === 'number' ? result.score : 0) * 100;
602
+ displayEl.style.setProperty('--confidence', `${Math.max(0, Math.min(100, pct)).toFixed(1)}%`);
603
+ }
604
+ if (labelEl) {
605
+ labelEl.textContent = result.sentiment || 'Unknown';
606
+ // Ensure CSS sentiment variants can apply reliably
607
+ labelEl.classList.remove('bullish', 'bearish', 'neutral', 'positive', 'negative', 'buy', 'sell', 'hold', 'unknown');
608
+ labelEl.classList.add(sentimentKey);
609
+ }
610
  if (confidenceEl) {
611
+ const pct = (typeof result.score === 'number' ? result.score : 0) * 100;
612
+ confidenceEl.textContent = `Confidence: ${Math.max(0, Math.min(100, pct)).toFixed(1)}%`;
613
  }
614
  if (timeEl) timeEl.textContent = new Date().toLocaleTimeString();
615
  if (jsonPre) jsonPre.textContent = JSON.stringify(result, null, 2);
 
621
  }
622
  }
623
 
624
+ async loadHealth() {
625
+ const container = document.getElementById('health-grid');
626
+ if (!container) return;
627
+
628
+ container.innerHTML = `
629
+ <div class="loading-state">
630
+ <div class="loading-spinner"></div>
631
+ <p class="loading-text">Loading health data...</p>
632
+ </div>
633
+ `;
634
+
635
+ try {
636
+ const res = await fetch('/api/models/health', {
637
+ method: 'GET',
638
+ headers: { 'Content-Type': 'application/json' },
639
+ signal: this.createTimeoutSignal(10000)
640
+ });
641
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
642
+ const data = await res.json();
643
+
644
+ const health = Array.isArray(data.health) ? data.health : (data.health ? Object.values(data.health) : []);
645
+ if (!health.length) {
646
+ container.innerHTML = `
647
+ <div class="empty-state glass-card" style="grid-column: 1 / -1;">
648
+ <div class="empty-icon">🏥</div>
649
+ <h3>No health data</h3>
650
+ <p>Health registry is empty (models may be running in fallback mode).</p>
651
+ </div>
652
+ `;
653
+ return;
654
+ }
655
+
656
+ container.innerHTML = health.map((h) => {
657
+ const status = h.status || 'unknown';
658
+ const statusClass = status === 'healthy' ? 'loaded' : status === 'unavailable' ? 'failed' : 'available';
659
+ const name = h.name || h.key || 'model';
660
+ return `
661
+ <div class="model-card ${statusClass}">
662
+ <div class="model-header">
663
+ <div class="model-info">
664
+ <h3 class="model-name">${name}</h3>
665
+ <p class="model-type">Health: ${status}</p>
666
+ </div>
667
+ <div class="model-status ${statusClass}">${status}</div>
668
+ </div>
669
+ <div class="model-body">
670
+ <div class="model-meta">
671
+ <span class="meta-badge">✅ ${Number(h.success_count || 0)} success</span>
672
+ <span class="meta-badge">⚠️ ${Number(h.error_count || 0)} errors</span>
673
+ </div>
674
+ </div>
675
+ </div>
676
+ `;
677
+ }).join('');
678
+ } catch (e) {
679
+ container.innerHTML = `
680
+ <div class="empty-state glass-card" style="grid-column: 1 / -1;">
681
+ <div class="empty-icon">⚠️</div>
682
+ <h3>Health data unavailable</h3>
683
+ <p>${e?.message || 'Unable to fetch /api/models/health'}</p>
684
+ </div>
685
+ `;
686
+ }
687
+ }
688
+
689
+ renderCatalog() {
690
+ // Best-effort catalog fill; only runs if the catalog containers exist on this page.
691
+ const buckets = {
692
+ crypto: document.getElementById('catalog-crypto'),
693
+ financial: document.getElementById('catalog-financial'),
694
+ social: document.getElementById('catalog-social'),
695
+ trading: document.getElementById('catalog-trading'),
696
+ generation: document.getElementById('catalog-generation'),
697
+ summarization: document.getElementById('catalog-summarization')
698
+ };
699
+
700
+ const hasAny = Object.values(buckets).some(Boolean);
701
+ if (!hasAny) return;
702
+
703
+ const byBucket = { crypto: [], financial: [], social: [], trading: [], generation: [], summarization: [] };
704
+ (this.allModels || []).forEach((m) => {
705
+ const cat = (m.category || '').toLowerCase();
706
+ if (cat.includes('crypto')) byBucket.crypto.push(m);
707
+ else if (cat.includes('financial')) byBucket.financial.push(m);
708
+ else if (cat.includes('social')) byBucket.social.push(m);
709
+ else if (cat.includes('trading')) byBucket.trading.push(m);
710
+ else if (cat.includes('generation') || cat.includes('gen')) byBucket.generation.push(m);
711
+ else if (cat.includes('summar')) byBucket.summarization.push(m);
712
+ });
713
+
714
+ const renderList = (list) => list.map((m) => `
715
+ <div class="catalog-model-item">
716
+ <div class="catalog-model-name">${m.name}</div>
717
+ <div class="catalog-model-id">${m.model_id}</div>
718
+ </div>
719
+ `).join('') || '<div class="empty-state"><p>No models in this category.</p></div>';
720
+
721
+ if (buckets.crypto) buckets.crypto.innerHTML = renderList(byBucket.crypto);
722
+ if (buckets.financial) buckets.financial.innerHTML = renderList(byBucket.financial);
723
+ if (buckets.social) buckets.social.innerHTML = renderList(byBucket.social);
724
+ if (buckets.trading) buckets.trading.innerHTML = renderList(byBucket.trading);
725
+ if (buckets.generation) buckets.generation.innerHTML = renderList(byBucket.generation);
726
+ if (buckets.summarization) buckets.summarization.innerHTML = renderList(byBucket.summarization);
727
+ }
728
+
729
  getSentimentEmoji(sentiment) {
730
  const emojiMap = {
731
  'positive': '😊',
 
760
  const response = await fetch('/api/models/reinitialize', {
761
  method: 'POST',
762
  headers: { 'Content-Type': 'application/json' },
763
+ signal: this.createTimeoutSignal(30000)
764
  });
765
 
766
  if (response.ok) {
static/shared/components/config-helper-modal.js CHANGED
@@ -23,6 +23,29 @@ export class ConfigHelperModal {
23
  const baseUrl = window.location.origin;
24
 
25
  return [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  // ===== UNIFIED SERVICE API =====
27
  {
28
  name: 'Unified Service API',
@@ -31,11 +54,12 @@ export class ConfigHelperModal {
31
  endpoints: [
32
  { method: 'GET', path: '/api/service/rate?pair=BTC/USDT', desc: 'Get exchange rate' },
33
  { method: 'GET', path: '/api/service/rate/batch?pairs=BTC/USDT,ETH/USDT', desc: 'Multiple rates' },
 
34
  { method: 'GET', path: '/api/service/market-status', desc: 'Market overview' },
35
  { method: 'GET', path: '/api/service/top?n=10', desc: 'Top cryptocurrencies' },
36
  { method: 'GET', path: '/api/service/sentiment?symbol=BTC', desc: 'Get sentiment' },
37
- { method: 'GET', path: '/api/service/whales?chain=ethereum&min_amount_usd=1000000', desc: 'Whale transactions' },
38
- { method: 'GET', path: '/api/service/onchain?address=0x...&chain=ethereum', desc: 'On-chain data' },
39
  { method: 'POST', path: '/api/service/query', desc: 'Universal query endpoint' }
40
  ],
41
  example: `// Get BTC price
@@ -53,21 +77,22 @@ fetch('${baseUrl}/api/service/rate/batch?pairs=BTC/USDT,ETH/USDT,BNB/USDT')
53
  {
54
  name: 'Market Data API',
55
  category: 'Market Data',
56
- description: 'Real-time prices, OHLCV, and market statistics from 8+ providers',
57
  endpoints: [
58
  { method: 'GET', path: '/api/market?limit=100', desc: 'Market data with prices' },
59
- { method: 'GET', path: '/api/ohlcv?symbol=BTC&timeframe=1h&limit=500', desc: 'OHLCV candlestick data' },
60
- { method: 'GET', path: '/api/klines?symbol=BTCUSDT&interval=1h', desc: 'Klines (alias for OHLCV)' },
61
- { method: 'GET', path: '/api/historical?symbol=BTC&days=30', desc: 'Historical price data' },
62
  { method: 'GET', path: '/api/coins/top?limit=50', desc: 'Top coins by market cap' },
63
- { method: 'GET', path: '/api/trending', desc: 'Trending cryptocurrencies' }
 
 
 
 
64
  ],
65
  example: `// Get OHLCV data for charting
66
  fetch('${baseUrl}/api/ohlcv?symbol=BTC&timeframe=1h&limit=100')
67
  .then(res => res.json())
68
  .then(data => {
69
  console.log('OHLCV data:', data.data);
70
- // Each candle: { t, o, h, l, c, v }
71
  });`
72
  },
73
 
@@ -77,12 +102,11 @@ fetch('${baseUrl}/api/ohlcv?symbol=BTC&timeframe=1h&limit=100')
77
  category: 'News & Media',
78
  description: 'Crypto news from 9+ sources including RSS feeds',
79
  endpoints: [
80
- { method: 'GET', path: '/api/news?limit=20', desc: 'Latest crypto news' },
81
- { method: 'GET', path: '/api/news/latest?symbol=BTC&limit=10', desc: 'News filtered by symbol' },
82
- { method: 'GET', path: '/api/news?source=decrypt', desc: 'News from specific source' }
83
  ],
84
  example: `// Get latest news
85
- fetch('${baseUrl}/api/news?limit=10')
86
  .then(res => res.json())
87
  .then(data => {
88
  data.articles.forEach(article => {
@@ -98,7 +122,7 @@ fetch('${baseUrl}/api/news?limit=10')
98
  description: 'Fear & Greed Index, social sentiment, and AI-powered analysis',
99
  endpoints: [
100
  { method: 'GET', path: '/api/sentiment/global', desc: 'Global market sentiment' },
101
- { method: 'GET', path: '/api/fear-greed', desc: 'Fear & Greed Index' },
102
  { method: 'GET', path: '/api/sentiment/asset/{symbol}', desc: 'Asset-specific sentiment' },
103
  { method: 'POST', path: '/api/sentiment/analyze', desc: 'Analyze custom text' }
104
  ],
@@ -113,10 +137,10 @@ fetch('${baseUrl}/api/fear-greed')
113
  fetch('${baseUrl}/api/sentiment/analyze', {
114
  method: 'POST',
115
  headers: { 'Content-Type': 'application/json' },
116
- body: JSON.stringify({ text: 'Bitcoin is going to the moon!' })
117
  })
118
  .then(res => res.json())
119
- .then(data => console.log('Sentiment:', data.label, data.score));`
120
  },
121
 
122
  // ===== ON-CHAIN ANALYTICS =====
@@ -125,19 +149,13 @@ fetch('${baseUrl}/api/sentiment/analyze', {
125
  category: 'Analytics',
126
  description: 'Blockchain data, whale tracking, and network statistics',
127
  endpoints: [
128
- { method: 'GET', path: '/api/whale', desc: 'Whale transactions' },
129
- { method: 'GET', path: '/api/whales/transactions?limit=50', desc: 'Recent whale moves' },
130
- { method: 'GET', path: '/api/whales/stats?hours=24', desc: 'Whale activity statistics' },
131
- { method: 'GET', path: '/api/blockchain/gas?chain=ethereum', desc: 'Gas prices' }
132
  ],
133
  example: `// Get whale transactions
134
  fetch('${baseUrl}/api/service/whales?chain=ethereum&min_amount_usd=1000000&limit=20')
135
  .then(res => res.json())
136
- .then(data => {
137
- data.data.forEach(tx => {
138
- console.log('Whale:', tx.amount_usd, 'USD', tx.chain);
139
- });
140
- });`
141
  },
142
 
143
  // ===== TECHNICAL ANALYSIS =====
@@ -172,6 +190,28 @@ fetch('${baseUrl}/api/technical/ta-quick', {
172
  console.log('Entry Range:', data.entry_range);
173
  });`
174
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
 
176
  // ===== AI MODELS =====
177
  {
@@ -181,15 +221,16 @@ fetch('${baseUrl}/api/technical/ta-quick', {
181
  endpoints: [
182
  { method: 'GET', path: '/api/models/status', desc: 'Models status' },
183
  { method: 'GET', path: '/api/models/list', desc: 'List all models' },
 
184
  { method: 'GET', path: '/api/models/health', desc: 'Model health check' },
185
- { method: 'POST', path: '/api/models/reinit-all', desc: 'Reinitialize models' },
186
  { method: 'POST', path: '/api/ai/decision', desc: 'AI trading decision' }
187
  ],
188
  example: `// Get AI trading decision
189
  fetch('${baseUrl}/api/ai/decision', {
190
  method: 'POST',
191
  headers: { 'Content-Type': 'application/json' },
192
- body: JSON.stringify({ symbol: 'BTC', timeframe: '1h' })
193
  })
194
  .then(res => res.json())
195
  .then(data => {
@@ -203,21 +244,36 @@ fetch('${baseUrl}/api/ai/decision', {
203
  {
204
  name: 'DeFi Data API',
205
  category: 'DeFi Services',
206
- description: 'DefiLlama TVL, protocols, yields, and stablecoins',
207
  endpoints: [
208
  { method: 'GET', path: '/api/defi/tvl', desc: 'Total Value Locked' },
209
  { method: 'GET', path: '/api/defi/protocols?limit=20', desc: 'Top DeFi protocols' },
210
- { method: 'GET', path: '/api/defi/yields', desc: 'DeFi yields' }
211
  ],
212
  example: `// Get DeFi TVL data
213
  fetch('${baseUrl}/api/defi/protocols?limit=10')
214
  .then(res => res.json())
215
  .then(data => {
216
- data.protocols.forEach(p => {
217
  console.log(p.name, '- TVL:', p.tvl);
218
  });
219
  });`
220
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
 
222
  // ===== RESOURCES & MONITORING =====
223
  {
@@ -248,39 +304,23 @@ fetch('${baseUrl}/api/resources/stats')
248
  console.log('Success Rate:', data.success_rate + '%');
249
  });`
250
  },
251
-
252
- // ===== WEBSOCKET =====
253
  {
254
- name: 'WebSocket API (Optional)',
255
- category: 'Real-time Services',
256
- description: 'Optional real-time streaming via WebSocket (HTTP polling recommended)',
257
  endpoints: [
258
- { method: 'WS', path: '/ws/master', desc: 'Master endpoint (all services)' },
259
- { method: 'WS', path: '/ws/live', desc: 'Live market data' },
260
- { method: 'WS', path: '/ws/ai/data', desc: 'AI model updates' },
261
- { method: 'WS', path: '/ws/monitoring', desc: 'System monitoring' }
 
262
  ],
263
- example: `// WebSocket connection (optional - HTTP works fine)
264
- const ws = new WebSocket('wss://${window.location.host}/ws/master');
265
-
266
- ws.onopen = () => {
267
- ws.send(JSON.stringify({
268
- action: 'subscribe',
269
- service: 'market_data'
270
- }));
271
- };
272
-
273
- ws.onmessage = (event) => {
274
- const data = JSON.parse(event.data);
275
- console.log('Real-time update:', data);
276
- };
277
-
278
- // Alternative: HTTP polling (recommended)
279
- setInterval(async () => {
280
- const data = await fetch('${baseUrl}/api/market?limit=100')
281
- .then(r => r.json());
282
- console.log('Market data:', data);
283
- }, 30000);`
284
  }
285
  ];
286
  }
@@ -324,7 +364,7 @@ setInterval(async () => {
324
 
325
  <div class="config-helper-body">
326
  <div class="config-helper-intro">
327
- <p>Access <strong>40+ data providers</strong> through our unified API. Copy and paste these examples to get started.</p>
328
  <div class="config-helper-base-url">
329
  <strong>Base URL:</strong>
330
  <code>${window.location.origin}</code>
@@ -336,10 +376,44 @@ setInterval(async () => {
336
  </button>
337
  </div>
338
  <div class="config-helper-stats">
339
- <span>📊 8+ Market Providers</span>
340
- <span>📰 9+ News Sources</span>
341
- <span>🎭 4+ Sentiment APIs</span>
342
- <span>🔗 4+ On-Chain APIs</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  </div>
344
  </div>
345
 
@@ -358,7 +432,12 @@ setInterval(async () => {
358
  modal.querySelectorAll('.copy-btn').forEach(btn => {
359
  btn.addEventListener('click', (e) => {
360
  e.stopPropagation();
361
- const text = btn.getAttribute('data-copy');
 
 
 
 
 
362
  this.copyToClipboard(text, btn);
363
  });
364
  });
@@ -450,7 +529,20 @@ setInterval(async () => {
450
 
451
  async copyToClipboard(text, button) {
452
  try {
453
- await navigator.clipboard.writeText(text);
 
 
 
 
 
 
 
 
 
 
 
 
 
454
 
455
  // Visual feedback
456
  const originalHTML = button.innerHTML;
@@ -609,6 +701,42 @@ style.textContent = `
609
  border-radius: 4px;
610
  }
611
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
612
  .service-category {
613
  margin-bottom: 24px;
614
  }
@@ -806,6 +934,10 @@ style.textContent = `
806
  .config-helper-stats {
807
  flex-direction: column;
808
  }
 
 
 
 
809
  }
810
  `;
811
  document.head.appendChild(style);
 
23
  const baseUrl = window.location.origin;
24
 
25
  return [
26
+ // ===== QUICK DISCOVERY =====
27
+ {
28
+ name: 'Discovery & Health',
29
+ category: 'Getting Started',
30
+ description: 'Verify the server is online and discover all available endpoints',
31
+ endpoints: [
32
+ { method: 'GET', path: '/api/health', desc: 'Health check' },
33
+ { method: 'GET', path: '/api/status', desc: 'System status' },
34
+ { method: 'GET', path: '/api/routers', desc: 'Loaded routers status' },
35
+ { method: 'GET', path: '/api/endpoints', desc: 'Full endpoints list (grouped)' },
36
+ { method: 'GET', path: '/docs', desc: 'Swagger UI documentation' }
37
+ ],
38
+ example: `// Health check
39
+ fetch('${baseUrl}/api/health')
40
+ .then(res => res.json())
41
+ .then(console.log);
42
+
43
+ // Get full endpoints list
44
+ fetch('${baseUrl}/api/endpoints')
45
+ .then(res => res.json())
46
+ .then(data => console.log('Total:', data.total_endpoints));`
47
+ },
48
+
49
  // ===== UNIFIED SERVICE API =====
50
  {
51
  name: 'Unified Service API',
 
54
  endpoints: [
55
  { method: 'GET', path: '/api/service/rate?pair=BTC/USDT', desc: 'Get exchange rate' },
56
  { method: 'GET', path: '/api/service/rate/batch?pairs=BTC/USDT,ETH/USDT', desc: 'Multiple rates' },
57
+ { method: 'GET', path: '/api/service/history?symbol=BTC&interval=60&limit=200', desc: 'Historical OHLC (minutes interval)' },
58
  { method: 'GET', path: '/api/service/market-status', desc: 'Market overview' },
59
  { method: 'GET', path: '/api/service/top?n=10', desc: 'Top cryptocurrencies' },
60
  { method: 'GET', path: '/api/service/sentiment?symbol=BTC', desc: 'Get sentiment' },
61
+ { method: 'GET', path: '/api/service/whales?chain=ethereum&min_amount_usd=1000000', desc: 'Whale transactions (service)' },
62
+ { method: 'GET', path: '/api/service/onchain?address=0x...&chain=ethereum', desc: 'On-chain data (service)' },
63
  { method: 'POST', path: '/api/service/query', desc: 'Universal query endpoint' }
64
  ],
65
  example: `// Get BTC price
 
77
  {
78
  name: 'Market Data API',
79
  category: 'Market Data',
80
+ description: 'Real-time prices, OHLC/OHLCV, and market statistics',
81
  endpoints: [
82
  { method: 'GET', path: '/api/market?limit=100', desc: 'Market data with prices' },
 
 
 
83
  { method: 'GET', path: '/api/coins/top?limit=50', desc: 'Top coins by market cap' },
84
+ { method: 'GET', path: '/api/trending', desc: 'Trending cryptocurrencies' },
85
+ { method: 'GET', path: '/api/market/ohlc?symbol=BTC&timeframe=1h', desc: 'OHLC (multi-source, recommended)' },
86
+ { method: 'GET', path: '/api/ohlcv?symbol=BTC&timeframe=1h&limit=100', desc: 'OHLCV (query-style)' },
87
+ { method: 'GET', path: '/api/klines?symbol=BTCUSDT&interval=1h&limit=100', desc: 'Klines alias (Binance style)' },
88
+ { method: 'GET', path: '/api/historical?symbol=BTC&days=30', desc: 'Daily historical candles (alias)' }
89
  ],
90
  example: `// Get OHLCV data for charting
91
  fetch('${baseUrl}/api/ohlcv?symbol=BTC&timeframe=1h&limit=100')
92
  .then(res => res.json())
93
  .then(data => {
94
  console.log('OHLCV data:', data.data);
95
+ // Each candle: { timestamp, open, high, low, close, volume }
96
  });`
97
  },
98
 
 
102
  category: 'News & Media',
103
  description: 'Crypto news from 9+ sources including RSS feeds',
104
  endpoints: [
105
+ { method: 'GET', path: '/api/news/latest?limit=20', desc: 'Latest crypto news' },
106
+ { method: 'GET', path: '/api/news?limit=20', desc: 'Alias for latest (compat)' }
 
107
  ],
108
  example: `// Get latest news
109
+ fetch('${baseUrl}/api/news/latest?limit=10')
110
  .then(res => res.json())
111
  .then(data => {
112
  data.articles.forEach(article => {
 
122
  description: 'Fear & Greed Index, social sentiment, and AI-powered analysis',
123
  endpoints: [
124
  { method: 'GET', path: '/api/sentiment/global', desc: 'Global market sentiment' },
125
+ { method: 'GET', path: '/api/fear-greed', desc: 'Fear & Greed Index (alias)' },
126
  { method: 'GET', path: '/api/sentiment/asset/{symbol}', desc: 'Asset-specific sentiment' },
127
  { method: 'POST', path: '/api/sentiment/analyze', desc: 'Analyze custom text' }
128
  ],
 
137
  fetch('${baseUrl}/api/sentiment/analyze', {
138
  method: 'POST',
139
  headers: { 'Content-Type': 'application/json' },
140
+ body: JSON.stringify({ text: 'Bitcoin is going to the moon!', mode: 'crypto' })
141
  })
142
  .then(res => res.json())
143
+ .then(data => console.log('Sentiment:', data.sentiment, data.score));`
144
  },
145
 
146
  // ===== ON-CHAIN ANALYTICS =====
 
149
  category: 'Analytics',
150
  description: 'Blockchain data, whale tracking, and network statistics',
151
  endpoints: [
152
+ { method: 'GET', path: '/api/service/whales?chain=ethereum&min_amount_usd=1000000&limit=20', desc: 'Whale transactions (service)' },
153
+ { method: 'GET', path: '/api/service/onchain?address=0x...&chain=ethereum', desc: 'On-chain snapshot (service)' }
 
 
154
  ],
155
  example: `// Get whale transactions
156
  fetch('${baseUrl}/api/service/whales?chain=ethereum&min_amount_usd=1000000&limit=20')
157
  .then(res => res.json())
158
+ .then(data => console.log(data));`
 
 
 
 
159
  },
160
 
161
  // ===== TECHNICAL ANALYSIS =====
 
190
  console.log('Entry Range:', data.entry_range);
191
  });`
192
  },
193
+
194
+ // ===== INDICATORS =====
195
+ {
196
+ name: 'Indicator Services API',
197
+ category: 'Analysis Services',
198
+ description: 'Technical indicators (RSI, MACD, SMA/EMA, BB, ATR, StochRSI) + comprehensive signals',
199
+ endpoints: [
200
+ { method: 'GET', path: '/api/indicators/services', desc: 'List available indicator services' },
201
+ { method: 'GET', path: '/api/indicators/rsi?symbol=BTC&timeframe=1h&period=14', desc: 'RSI' },
202
+ { method: 'GET', path: '/api/indicators/macd?symbol=BTC&timeframe=1h', desc: 'MACD' },
203
+ { method: 'GET', path: '/api/indicators/comprehensive?symbol=BTC&timeframe=1h', desc: 'Comprehensive indicator analysis' }
204
+ ],
205
+ example: `// List indicator services
206
+ fetch('${baseUrl}/api/indicators/services')
207
+ .then(r => r.json())
208
+ .then(console.log);
209
+
210
+ // RSI
211
+ fetch('${baseUrl}/api/indicators/rsi?symbol=BTC&timeframe=1h&period=14')
212
+ .then(r => r.json())
213
+ .then(console.log);`
214
+ },
215
 
216
  // ===== AI MODELS =====
217
  {
 
221
  endpoints: [
222
  { method: 'GET', path: '/api/models/status', desc: 'Models status' },
223
  { method: 'GET', path: '/api/models/list', desc: 'List all models' },
224
+ { method: 'GET', path: '/api/models/summary', desc: 'Models grouped by category (frontend-ready)' },
225
  { method: 'GET', path: '/api/models/health', desc: 'Model health check' },
226
+ { method: 'POST', path: '/api/models/reinitialize', desc: 'Reinitialize models (UI button)' },
227
  { method: 'POST', path: '/api/ai/decision', desc: 'AI trading decision' }
228
  ],
229
  example: `// Get AI trading decision
230
  fetch('${baseUrl}/api/ai/decision', {
231
  method: 'POST',
232
  headers: { 'Content-Type': 'application/json' },
233
+ body: JSON.stringify({ symbol: 'BTC', horizon: 'swing', risk_tolerance: 'moderate' })
234
  })
235
  .then(res => res.json())
236
  .then(data => {
 
244
  {
245
  name: 'DeFi Data API',
246
  category: 'DeFi Services',
247
+ description: 'DefiLlama public endpoints (no API key): TVL, protocols, yields',
248
  endpoints: [
249
  { method: 'GET', path: '/api/defi/tvl', desc: 'Total Value Locked' },
250
  { method: 'GET', path: '/api/defi/protocols?limit=20', desc: 'Top DeFi protocols' },
251
+ { method: 'GET', path: '/api/defi/yields?limit=20', desc: 'DeFi yield pools' }
252
  ],
253
  example: `// Get DeFi TVL data
254
  fetch('${baseUrl}/api/defi/protocols?limit=10')
255
  .then(res => res.json())
256
  .then(data => {
257
+ (data.protocols || []).forEach(p => {
258
  console.log(p.name, '- TVL:', p.tvl);
259
  });
260
  });`
261
  },
262
+
263
+ // ===== TRADING & BACKTESTING =====
264
+ {
265
+ name: 'Trading & Backtesting API',
266
+ category: 'Trading',
267
+ description: 'Historical backtests and strategy runs (uses exchange/data fallbacks)',
268
+ endpoints: [
269
+ { method: 'GET', path: '/api/trading/backtest/historical/BTCUSDT?timeframe=1h&days=30', desc: 'Historical candles for backtest' },
270
+ { method: 'GET', path: '/api/trading/backtest/run/BTCUSDT?strategy=sma_crossover&days=30&initial_capital=10000', desc: 'Run a backtest strategy' }
271
+ ],
272
+ example: `// Run SMA crossover backtest
273
+ fetch('${baseUrl}/api/trading/backtest/run/BTCUSDT?strategy=sma_crossover&days=30&initial_capital=10000')
274
+ .then(r => r.json())
275
+ .then(console.log);`
276
+ },
277
 
278
  // ===== RESOURCES & MONITORING =====
279
  {
 
304
  console.log('Success Rate:', data.success_rate + '%');
305
  });`
306
  },
307
+
308
+ // ===== SUPPORT / DEBUG =====
309
  {
310
+ name: 'Support & Debug API',
311
+ category: 'System Services',
312
+ description: 'Client-accessible support files and real endpoint list',
313
  endpoints: [
314
+ { method: 'GET', path: '/api/support/realendpoints', desc: 'Endpoints list (JSON)' },
315
+ { method: 'GET', path: '/api/support/realendpoints?format=txt', desc: 'Endpoints list (TXT)' },
316
+ { method: 'GET', path: '/realendpoint.txt', desc: 'Download endpoints list (TXT)' },
317
+ { method: 'GET', path: '/api/support/fualt?tail=200', desc: 'Tail of fault log (JSON)' },
318
+ { method: 'GET', path: '/fualt.txt', desc: 'Download full fault log (TXT)' }
319
  ],
320
+ example: `// Fetch tail of fualt.txt
321
+ fetch('${baseUrl}/api/support/fualt?tail=200')
322
+ .then(r => r.json())
323
+ .then(data => console.log(data.content));`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  }
325
  ];
326
  }
 
364
 
365
  <div class="config-helper-body">
366
  <div class="config-helper-intro">
367
+ <p>Use this guide to integrate quickly. Every endpoint listed below exists in the server and is safe to copy.</p>
368
  <div class="config-helper-base-url">
369
  <strong>Base URL:</strong>
370
  <code>${window.location.origin}</code>
 
376
  </button>
377
  </div>
378
  <div class="config-helper-stats">
379
+ <span>✅ Copy-ready URLs</span>
380
+ <span>✅ Copy-ready code snippets</span>
381
+ <span>✅ `/api/endpoints` for discovery</span>
382
+ <span>✅ `/docs` for Swagger</span>
383
+ </div>
384
+
385
+ <div class="config-helper-snippets">
386
+ <div class="snippet-card">
387
+ <div class="snippet-head">
388
+ <span>cURL</span>
389
+ <button class="copy-btn">Copy</button>
390
+ </div>
391
+ <pre><code>curl -s '${window.location.origin}/api/health' | jq .</code></pre>
392
+ </div>
393
+ <div class="snippet-card">
394
+ <div class="snippet-head">
395
+ <span>JavaScript (fetch)</span>
396
+ <button class="copy-btn">Copy</button>
397
+ </div>
398
+ <pre><code>fetch('${window.location.origin}/api/market?limit=10')
399
+ .then(r => r.json())
400
+ .then(console.log);</code></pre>
401
+ </div>
402
+ <div class="snippet-card">
403
+ <div class="snippet-head">
404
+ <span>Python (requests)</span>
405
+ <button class="copy-btn">Copy</button>
406
+ </div>
407
+ <pre><code>import requests
408
+ print(requests.get('${window.location.origin}/api/market?limit=10', timeout=10).json())</code></pre>
409
+ </div>
410
+ <div class="snippet-card">
411
+ <div class="snippet-head">
412
+ <span>Optional HF Token</span>
413
+ <button class="copy-btn">Copy</button>
414
+ </div>
415
+ <pre><code>export HF_TOKEN='YOUR_TOKEN_HERE'</code></pre>
416
+ </div>
417
  </div>
418
  </div>
419
 
 
432
  modal.querySelectorAll('.copy-btn').forEach(btn => {
433
  btn.addEventListener('click', (e) => {
434
  e.stopPropagation();
435
+ let text = btn.getAttribute('data-copy');
436
+ if (!text) {
437
+ // Snippet cards copy their visible code block
438
+ const codeEl = btn.closest('.snippet-card')?.querySelector('pre code');
439
+ text = codeEl?.textContent || '';
440
+ }
441
  this.copyToClipboard(text, btn);
442
  });
443
  });
 
529
 
530
  async copyToClipboard(text, button) {
531
  try {
532
+ if (navigator.clipboard?.writeText) {
533
+ await navigator.clipboard.writeText(text);
534
+ } else {
535
+ // Fallback for older browsers / restricted contexts
536
+ const ta = document.createElement('textarea');
537
+ ta.value = text;
538
+ ta.setAttribute('readonly', '');
539
+ ta.style.position = 'fixed';
540
+ ta.style.left = '-9999px';
541
+ document.body.appendChild(ta);
542
+ ta.select();
543
+ document.execCommand('copy');
544
+ ta.remove();
545
+ }
546
 
547
  // Visual feedback
548
  const originalHTML = button.innerHTML;
 
701
  border-radius: 4px;
702
  }
703
 
704
+ .config-helper-snippets {
705
+ display: grid;
706
+ grid-template-columns: repeat(2, minmax(0, 1fr));
707
+ gap: 12px;
708
+ margin-top: 12px;
709
+ }
710
+
711
+ .snippet-card {
712
+ background: var(--bg-secondary, #f8fdfc);
713
+ border: 1px solid var(--border-light, #e5e7eb);
714
+ border-radius: 12px;
715
+ padding: 12px;
716
+ overflow: hidden;
717
+ }
718
+
719
+ .snippet-head {
720
+ display: flex;
721
+ align-items: center;
722
+ justify-content: space-between;
723
+ font-size: 12px;
724
+ font-weight: 700;
725
+ color: var(--text-secondary, #6b7280);
726
+ margin-bottom: 8px;
727
+ }
728
+
729
+ .snippet-card pre {
730
+ margin: 0;
731
+ background: #0b1220;
732
+ color: #e2e8f0;
733
+ padding: 10px;
734
+ border-radius: 10px;
735
+ overflow-x: auto;
736
+ font-size: 12px;
737
+ line-height: 1.5;
738
+ }
739
+
740
  .service-category {
741
  margin-bottom: 24px;
742
  }
 
934
  .config-helper-stats {
935
  flex-direction: column;
936
  }
937
+
938
+ .config-helper-snippets {
939
+ grid-template-columns: 1fr;
940
+ }
941
  }
942
  `;
943
  document.head.appendChild(style);
static/shared/css/global.css CHANGED
@@ -231,3 +231,42 @@ input::placeholder {
231
  clip: rect(0, 0, 0, 0);
232
  border: 0;
233
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  clip: rect(0, 0, 0, 0);
232
  border: 0;
233
  }
234
+
235
+ /* ==========================================================================
236
+ Disable "blinking" (looping) animations globally
237
+ --------------------------------------------------------------------------
238
+ Many pages/components use infinite pulse/shimmer/gradient animations that can
239
+ look like blinking. We stop ALL looping animations across the app, but keep
240
+ loading spinners rotating so users still get feedback during async loads.
241
+ ========================================================================== */
242
+
243
+ /* Stop all looping animations (including on pseudo-elements) */
244
+ html body *,
245
+ html body *::before,
246
+ html body *::after {
247
+ animation-iteration-count: 1 !important;
248
+ }
249
+
250
+ /* Keep true loading spinners rotating */
251
+ html body .spinner,
252
+ html body .loading-spinner,
253
+ html body .spinner-icon,
254
+ html body .spinner::before,
255
+ html body .spinner::after,
256
+ html body .loading-spinner::before,
257
+ html body .loading-spinner::after,
258
+ html body .spinner-icon::before,
259
+ html body .spinner-icon::after {
260
+ animation-iteration-count: infinite !important;
261
+ }
262
+
263
+ /* Explicitly disable LIVE badge blinking (header/sidebar). Some pages load
264
+ global.css async; this hard-stop prevents any repeated pulsing. */
265
+ html body .live-badge,
266
+ html body .live-dot,
267
+ html body .live-pulse,
268
+ html body .live-badge-enhanced,
269
+ html body .live-badge-enhanced .live-pulse,
270
+ html body .live-badge-enhanced .live-text {
271
+ animation: none !important;
272
+ }
static/shared/js/components/toast.js CHANGED
@@ -5,10 +5,22 @@
5
 
6
  import { CONFIG } from '../core/config.js';
7
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  export class Toast {
9
  static container = null;
10
  static toasts = [];
11
- static maxToasts = CONFIG.TOAST.MAX_VISIBLE;
12
 
13
  /**
14
  * Initialize toast container
@@ -35,7 +47,9 @@ export class Toast {
35
  id: Date.now() + Math.random(),
36
  message,
37
  type,
38
- duration: options.duration || (type === 'error' ? CONFIG.TOAST.ERROR_DURATION : CONFIG.TOAST.DEFAULT_DURATION),
 
 
39
  dismissible: options.dismissible !== false,
40
  action: options.action || null,
41
  };
 
5
 
6
  import { CONFIG } from '../core/config.js';
7
 
8
+ const TOAST_DEFAULTS = {
9
+ MAX_VISIBLE: 3,
10
+ DEFAULT_DURATION: 3500,
11
+ ERROR_DURATION: 6000,
12
+ };
13
+
14
+ // CONFIG.TOAST is optional in some builds/pages; keep Toast resilient.
15
+ const TOAST_CONFIG = {
16
+ ...TOAST_DEFAULTS,
17
+ ...(CONFIG?.TOAST || {}),
18
+ };
19
+
20
  export class Toast {
21
  static container = null;
22
  static toasts = [];
23
+ static maxToasts = TOAST_CONFIG.MAX_VISIBLE;
24
 
25
  /**
26
  * Initialize toast container
 
47
  id: Date.now() + Math.random(),
48
  message,
49
  type,
50
+ duration:
51
+ options.duration ??
52
+ (type === 'error' ? TOAST_CONFIG.ERROR_DURATION : TOAST_CONFIG.DEFAULT_DURATION),
53
  dismissible: options.dismissible !== false,
54
  action: options.action || null,
55
  };
static/shared/js/core/config.js CHANGED
@@ -77,6 +77,10 @@ export const API_ENDPOINTS = {
77
  resourcesCategory: '/api/resources/category',
78
  resourcesApis: '/api/resources/apis',
79
  providers: '/api/providers',
 
 
 
 
80
 
81
  // Advanced
82
  multiSourceData: '/api/multi-source/data',
@@ -204,6 +208,12 @@ export const CONFIG = {
204
  MAX_RETRIES: 3,
205
  RETRY_DELAY: 1000,
206
  RETRIES: 3,
 
 
 
 
 
 
207
  IS_HUGGINGFACE: IS_HUGGINGFACE,
208
  IS_LOCALHOST: IS_LOCALHOST,
209
  ENVIRONMENT: IS_HUGGINGFACE ? 'huggingface' : IS_LOCALHOST ? 'local' : 'production'
 
77
  resourcesCategory: '/api/resources/category',
78
  resourcesApis: '/api/resources/apis',
79
  providers: '/api/providers',
80
+
81
+ // Support / Debug (client-consumable)
82
+ supportFualt: '/api/support/fualt',
83
+ supportRealEndpoints: '/api/support/realendpoints',
84
 
85
  // Advanced
86
  multiSourceData: '/api/multi-source/data',
 
208
  MAX_RETRIES: 3,
209
  RETRY_DELAY: 1000,
210
  RETRIES: 3,
211
+ // UI defaults (used by shared components like toast)
212
+ TOAST: {
213
+ MAX_VISIBLE: 3,
214
+ DEFAULT_DURATION: 3500,
215
+ ERROR_DURATION: 6000
216
+ },
217
  IS_HUGGINGFACE: IS_HUGGINGFACE,
218
  IS_LOCALHOST: IS_LOCALHOST,
219
  ENVIRONMENT: IS_HUGGINGFACE ? 'huggingface' : IS_LOCALHOST ? 'local' : 'production'
static/shared/js/core/support-client.js ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Support Client
3
+ * Client-facing helpers for fualt.txt + realendpoint.txt support endpoints.
4
+ */
5
+
6
+ import { API_ENDPOINTS } from './config.js';
7
+
8
+ export class SupportClient {
9
+ static async getFualt({ tail = 500 } = {}) {
10
+ const url = `${API_ENDPOINTS.supportFualt}?tail=${encodeURIComponent(tail)}`;
11
+ const res = await fetch(url, { headers: { 'Content-Type': 'application/json' } });
12
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
13
+ return await res.json();
14
+ }
15
+
16
+ static async getRealEndpoints({ format = 'json' } = {}) {
17
+ const url = `${API_ENDPOINTS.supportRealEndpoints}?format=${encodeURIComponent(format)}`;
18
+ const res = await fetch(url, { headers: { 'Content-Type': 'application/json' } });
19
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
20
+ if (format === 'txt') return await res.text();
21
+ return await res.json();
22
+ }
23
+ }
24
+
25
+ export default SupportClient;
26
+