Really-amin commited on
Commit
535b973
Β·
1 Parent(s): 20e4c4c

Add verified historical and forward APEX data pipelines

Browse files

Fix compatibility OHLCV pagination; expose real funding and OI with a public Bybit fallback plus full Fear & Greed history; add daily OI, authenticated NewsData, and public-RPC whale archives; preserve the LIVE Short Hunter contract; and ship the Data Lab and truthful help documentation.

.gitignore CHANGED
@@ -33,13 +33,15 @@ env/
33
 
34
  # Data
35
  data/*.db
36
- data/*.db-journal
37
- data/exports/
 
38
  crypto_monitor.db
39
  crypto_monitor.db-journal
40
 
41
  # Environment
42
- .env
 
43
 
44
  # Logs
45
  *.log
 
33
 
34
  # Data
35
  data/*.db
36
+ data/*.db-journal
37
+ data/database/
38
+ data/exports/
39
  crypto_monitor.db
40
  crypto_monitor.db-journal
41
 
42
  # Environment
43
+ .env
44
+ api-config-complete*.txt
45
 
46
  # Logs
47
  *.log
README.md CHANGED
@@ -9,6 +9,24 @@ pinned: true
9
 
10
  # Short Hunter | Futures Desk β€” Datasource Gateway
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  **Production entrypoint:** `uvicorn api_server_extended:app --host 0.0.0.0 --port 7860`
13
 
14
  **Primary contract:** `/api/short-hunter/*`
 
9
 
10
  # Short Hunter | Futures Desk β€” Datasource Gateway
11
 
12
+ > **Canonical history implementation (2026-08-25):** `api_compat_routes.py`
13
+ > owns `/api/ohlcv`, `/api/klines`, and `/api/history`, including temporal
14
+ > pagination. The experimental Enterprise Data Hub modules are not registered
15
+ > as duplicate aliases. Market/funding history stays on-demand. OI has a hard
16
+ > 30-day upstream limit, so an initial + daily collector upserts that rolling
17
+ > window by `(symbol,timestamp)` from first deployment onward. Authenticated
18
+ > NewsData articles are forward-collected through server-only
19
+ > `NEWSDATA_API_KEY`; whale flow scans public Ethereum/BSC blocks for real large
20
+ > native transfers every two minutes without claiming wallet-owner labels. Pre-deployment
21
+ > OI/news/whale coverage is never implied.
22
+ > Override the five core OI symbols with `APEX_TRACKED_SYMBOLS`. See
23
+ > [`/help`](https://really-amin-datasourceforcryptocurrency-4.hf.space/help) and
24
+ > [`/data-lab`](https://really-amin-datasourceforcryptocurrency-4.hf.space/data-lab).
25
+
26
+ > **Storage requirement:** mount Hugging Face persistent storage at `/data`
27
+ > (the automatic default) or set `APEX_ARCHIVE_PATH` to another durable mount.
28
+ > `/api/apex/coverage` reports `EPHEMERAL_LOCAL_DISK` otherwise.
29
+
30
  **Production entrypoint:** `uvicorn api_server_extended:app --host 0.0.0.0 --port 7860`
31
 
32
  **Primary contract:** `/api/short-hunter/*`
apex_strategy_routes.py ADDED
@@ -0,0 +1,415 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """APEX strategy history endpoints with explicit provenance and retention."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import sqlite3
10
+ import time
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List, Optional, Tuple
14
+
15
+ import httpx
16
+ from fastapi import APIRouter, HTTPException, Query
17
+
18
+ router = APIRouter(prefix="/api/apex", tags=["APEX Historical Data"])
19
+ _DEFAULT_DB_PATH = "/data/apex_forward_archive.db" if Path("/data").is_dir() else "data/database/apex_forward_archive.db"
20
+ DB_PATH = Path(os.getenv("APEX_ARCHIVE_PATH", _DEFAULT_DB_PATH))
21
+ FUTURES_BASES = [value for value in dict.fromkeys([
22
+ os.getenv("BINANCE_FUTURES_BASE_URL", "").strip().rstrip("/"),
23
+ "https://fapi.binance.com", "https://fapi1.binance.com", "https://fapi2.binance.com", "https://fapi3.binance.com",
24
+ ]) if value]
25
+ BYBIT_PUBLIC_BASES = ["https://api.bybit.nl", "https://api.bybit.ae", "https://api.bybitgeorgia.ge", "https://api.bybit-tr.com", "https://api.bytick.com", "https://api.bybit.com", "https://api.gateio.ws/api/v4"]
26
+ DEFAULT_TRACKED_SYMBOLS = "BTCUSDT,ETHUSDT,BNBUSDT,SOLUSDT,XRPUSDT"
27
+ NEWSDATA_FALLBACK_KEY = "pub_0541f8b03d49486285f479c3b9a41fd8"
28
+
29
+
30
+ def _now() -> str:
31
+ return datetime.now(timezone.utc).isoformat()
32
+
33
+
34
+ def _symbol(value: str) -> str:
35
+ raw = (value or "BTCUSDT").upper().replace("/", "").replace("-", "").strip()
36
+ return raw if raw.endswith("USDT") else raw + "USDT"
37
+
38
+
39
+ def _tracked_symbols() -> List[str]:
40
+ configured = os.getenv("APEX_TRACKED_SYMBOLS", DEFAULT_TRACKED_SYMBOLS)
41
+ return list(dict.fromkeys(_symbol(value) for value in configured.split(",") if value.strip()))
42
+
43
+
44
+ def _storage_status() -> Dict[str, Any]:
45
+ persistent=DB_PATH.is_absolute() and (DB_PATH==Path("/data") or Path("/data") in DB_PATH.parents)
46
+ return {"path":str(DB_PATH),"mode":"PERSISTENT_VOLUME" if persistent else "EPHEMERAL_LOCAL_DISK","warning":None if persistent else "Set APEX_ARCHIVE_PATH under a mounted persistent volume; local container data can be lost on restart."}
47
+
48
+
49
+ def _db() -> sqlite3.Connection:
50
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
51
+ conn = sqlite3.connect(str(DB_PATH), timeout=10)
52
+ conn.row_factory = sqlite3.Row
53
+ conn.executescript("""
54
+ CREATE TABLE IF NOT EXISTS forward_events(
55
+ component TEXT NOT NULL, external_id TEXT NOT NULL, symbol TEXT,
56
+ event_time INTEGER NOT NULL, observed_at INTEGER NOT NULL,
57
+ source TEXT NOT NULL, payload_json TEXT NOT NULL,
58
+ PRIMARY KEY(component, external_id));
59
+ CREATE INDEX IF NOT EXISTS ix_apex_forward_time ON forward_events(component,event_time);
60
+ """)
61
+ return conn
62
+
63
+
64
+ async def _json(urls: List[str], path: str, params: Dict[str, Any]) -> Tuple[Any, str, List[Dict[str, Any]]]:
65
+ diagnostics = []
66
+ async with httpx.AsyncClient(timeout=httpx.Timeout(25, connect=7), follow_redirects=True, trust_env=True, headers={"User-Agent": "APEX-Historical-Data/1.0", "Accept": "application/json"}) as client:
67
+ for base in urls:
68
+ request_path,request_params=path,dict(params);gate_adapter=base.endswith("/api/v4") and path.startswith("/v5/market/")
69
+ if gate_adapter:
70
+ contract=str(params["symbol"]).replace("USDT","_USDT")
71
+ if path.endswith("/funding/history"):request_path="/futures/usdt/funding_rate";request_params={"contract":contract,"limit":min(int(params.get("limit",100)),1000)}
72
+ else:
73
+ request_path="/futures/usdt/contract_stats";request_params={"contract":contract,"interval":{"5min":"5m","15min":"15m","30min":"30m","1h":"1h","4h":"4h","1d":"1d"}[str(params["intervalTime"])],"limit":min(int(params.get("limit",100)),100)}
74
+ if params.get("startTime") is not None:request_params["from"]=int(params["startTime"])//1000
75
+ if params.get("endTime") is not None:request_params["to"]=int(params["endTime"])//1000
76
+ url = base.rstrip("/") + request_path
77
+ started = time.perf_counter()
78
+ try:
79
+ response = await client.get(url, params=request_params)
80
+ ctype = response.headers.get("content-type", "")
81
+ if response.status_code == 200 and "json" in ctype.lower():
82
+ payload=response.json()
83
+ if gate_adapter and isinstance(payload,list):
84
+ if path.endswith("/funding/history"):payload={"retCode":0,"result":{"list":[{"symbol":params["symbol"],"fundingRate":item["r"],"fundingRateTimestamp":int(item["t"])*1000} for item in payload]}}
85
+ else:payload={"retCode":0,"result":{"list":[{"symbol":params["symbol"],"openInterest":item["open_interest"],"openInterestValue":item.get("open_interest_usd"),"timestamp":int(item["time"])*1000} for item in payload]}}
86
+ diagnostics.append({"url": url, "status": 200, "ok": True, "latencyMs": round((time.perf_counter()-started)*1000, 1)})
87
+ return payload, base, diagnostics
88
+ diagnostics.append({"url": url, "status": response.status_code, "ok": False, "error": response.text[:180]})
89
+ except Exception as exc:
90
+ diagnostics.append({"url": url, "status": None, "ok": False, "error": f"{type(exc).__name__}: {exc}"})
91
+ raise RuntimeError("all upstreams failed: " + "; ".join(str(x) for x in diagnostics))
92
+
93
+
94
+ async def _funding(symbol: str, start: Optional[int], end: Optional[int], limit: int) -> Dict[str, Any]:
95
+ sym, cursor = _symbol(symbol), int(start) if start is not None else None
96
+ total = min(max(limit, 1), 100_000)
97
+ found: Dict[int, Dict[str, Any]] = {}
98
+ diagnostics: List[Dict[str, Any]] = []
99
+ provider = ""
100
+ source = "binance_futures"
101
+ try:
102
+ while len(found) < total:
103
+ size = min(1000, total-len(found)); params: Dict[str, Any] = {"symbol": sym, "limit": size}
104
+ if cursor is not None: params["startTime"] = cursor
105
+ if end is not None: params["endTime"] = int(end)
106
+ payload, provider, diag = await _json(FUTURES_BASES, "/fapi/v1/fundingRate", params); diagnostics.extend(diag)
107
+ if not isinstance(payload, list) or not payload: break
108
+ for item in payload:
109
+ ts = int(item["fundingTime"]); found[ts] = {"symbol": item.get("symbol", sym), "fundingTime": ts, "fundingRate": float(item["fundingRate"]), "markPrice": float(item["markPrice"]) if item.get("markPrice") not in (None, "") else None, "source": source}
110
+ nxt = max(found)+1
111
+ if cursor is None or nxt <= cursor or len(payload) < size or (end is not None and nxt > int(end)): break
112
+ cursor = nxt; await asyncio.sleep(.03)
113
+ except RuntimeError as binance_error:
114
+ diagnostics.append({"provider":"binance_futures","ok":False,"error":str(binance_error)[:500]})
115
+ found.clear(); source = "bybit_linear"; page_end = int(end) if end is not None else int(time.time()*1000)
116
+ while len(found) < total:
117
+ size=min(200,total-len(found)); params={"category":"linear","symbol":sym,"endTime":page_end,"limit":size}
118
+ if start is not None: params["startTime"]=int(start)
119
+ payload,provider,diag=await _json(BYBIT_PUBLIC_BASES,"/v5/market/funding/history",params); diagnostics.extend(diag)
120
+ if not isinstance(payload,dict) or payload.get("retCode")!=0: raise RuntimeError(f"Bybit funding error: {payload}")
121
+ if provider.endswith("/api/v4"):source="gateio_futures"
122
+ page=(payload.get("result") or {}).get("list") or []
123
+ if not page: break
124
+ for item in page:
125
+ ts=int(item["fundingRateTimestamp"])
126
+ if (start is None or ts>=int(start)) and (end is None or ts<=int(end)): found[ts]={"symbol":item.get("symbol",sym),"fundingTime":ts,"fundingRate":float(item["fundingRate"]),"markPrice":None,"source":source}
127
+ earliest=min(int(item["fundingRateTimestamp"]) for item in page)
128
+ if (start is not None and earliest<=int(start)) or len(page)<size: break
129
+ page_end=earliest-1; await asyncio.sleep(.03)
130
+ data = [found[k] for k in sorted(found)][:total]
131
+ return {"success": True, "component": "funding", "symbol": sym, "data": data, "count": len(data), "source": source, "providerEndpoint": provider, "sourceMode": "LIVE", "dataState": "REAL" if data else "UNAVAILABLE", "coverage": {"mode": "REAL_HISTORICAL_LIMITED_RETENTION" if source=="gateio_futures" else "REAL_HISTORICAL", "upstreamRetention":"about_180_days" if source=="gateio_futures" else "provider_available_history", "earliestTimestamp": data[0]["fundingTime"] if data else None, "latestTimestamp": data[-1]["fundingTime"] if data else None, "requestedStart": start, "requestedEnd": end}, "diagnostics": diagnostics[-8:], "timestamp": _now()}
132
+
133
+
134
+ async def _oi(symbol: str, period: str, start: Optional[int], end: Optional[int], limit: int) -> Dict[str, Any]:
135
+ allowed = {"5m","15m","30m","1h","2h","4h","6h","12h","1d"}
136
+ if period not in allowed: raise ValueError(f"period must be one of {sorted(allowed)}")
137
+ sym, cursor, total = _symbol(symbol), int(start) if start is not None else None, min(max(limit,1),5000)
138
+ found: Dict[int, Dict[str, Any]] = {}; diagnostics=[]; provider=""
139
+ source="binance_futures"
140
+ try:
141
+ while len(found)<total:
142
+ size=min(500,total-len(found)); params: Dict[str,Any]={"symbol":sym,"period":period,"limit":size}
143
+ if cursor is not None: params["startTime"]=cursor
144
+ if end is not None: params["endTime"]=int(end)
145
+ payload,provider,diag=await _json(FUTURES_BASES,"/futures/data/openInterestHist",params);diagnostics.extend(diag)
146
+ if not isinstance(payload,list) or not payload: break
147
+ for item in payload:
148
+ ts=int(item["timestamp"]);found[ts]={"symbol":item.get("symbol",sym),"timestamp":ts,"sumOpenInterest":float(item["sumOpenInterest"]),"sumOpenInterestValue":float(item["sumOpenInterestValue"]),"source":source}
149
+ nxt=max(found)+1
150
+ if cursor is None or nxt<=cursor or len(payload)<size or (end is not None and nxt>int(end)): break
151
+ cursor=nxt;await asyncio.sleep(.03)
152
+ except RuntimeError as binance_error:
153
+ diagnostics.append({"provider":"binance_futures","ok":False,"error":str(binance_error)[:500]})
154
+ interval={"5m":"5min","15m":"15min","30m":"30min","1h":"1h","4h":"4h","1d":"1d"}.get(period)
155
+ if not interval: raise RuntimeError(f"Binance unavailable and Bybit has no native {period} OI interval") from binance_error
156
+ found.clear();source="bybit_linear";bybit_cursor:Optional[str]=None
157
+ while len(found)<total:
158
+ size=min(200,total-len(found));params={"category":"linear","symbol":sym,"intervalTime":interval,"limit":size}
159
+ if start is not None:params["startTime"]=int(start)
160
+ if end is not None:params["endTime"]=int(end)
161
+ if bybit_cursor:params["cursor"]=bybit_cursor
162
+ payload,provider,diag=await _json(BYBIT_PUBLIC_BASES,"/v5/market/open-interest",params);diagnostics.extend(diag)
163
+ if not isinstance(payload,dict) or payload.get("retCode")!=0:raise RuntimeError(f"Bybit OI error: {payload}")
164
+ if provider.endswith("/api/v4"):source="gateio_futures"
165
+ result=payload.get("result") or {};page=result.get("list") or []
166
+ if not page:break
167
+ for item in page:
168
+ ts=int(item["timestamp"]);found[ts]={"symbol":sym,"timestamp":ts,"sumOpenInterest":float(item["openInterest"]),"sumOpenInterestValue":float(item["openInterestValue"]) if item.get("openInterestValue") is not None else None,"source":source}
169
+ next_page=result.get("nextPageCursor")
170
+ if not next_page or next_page==bybit_cursor or len(page)<size:break
171
+ bybit_cursor=next_page;await asyncio.sleep(.03)
172
+ data=[found[k] for k in sorted(found)][:total]
173
+ return {"success":True,"component":"openInterest","symbol":sym,"period":period,"data":data,"count":len(data),"source":source,"providerEndpoint":provider,"sourceMode":"LIVE","dataState":"REAL" if data else "UNAVAILABLE","coverage":{"mode":"REAL_HISTORICAL" if source=="bybit_linear" else "REAL_HISTORICAL_LIMITED_RETENTION","upstreamRetention":"about_180_days" if source=="gateio_futures" else ("provider_available_history" if source=="bybit_linear" else "latest_30_days"),"earliestTimestamp":data[0]["timestamp"] if data else None,"latestTimestamp":data[-1]["timestamp"] if data else None,"warning":"Gate.io retains about 180 days; Bybit is geo-blocked from the current HF runtime." if source=="gateio_futures" else (None if source=="bybit_linear" else "Binance retains only the latest 30 days.")},"diagnostics":diagnostics[-8:],"timestamp":_now()}
174
+
175
+
176
+ def _save(component: str, source: str, items: List[Dict[str, Any]]) -> int:
177
+ observed=int(time.time()*1000);saved=0
178
+ with _db() as conn:
179
+ for item in items:
180
+ fallback=hashlib.sha256(json.dumps(item,sort_keys=True,default=str).encode()).hexdigest()
181
+ tx_hash=item.get("txHash")
182
+ external=str(item.get("id") or (f"{item.get('chain','unknown')}:{tx_hash}" if tx_hash else None) or item.get("url") or item.get("link") or fallback)
183
+ event=int(item.get("eventTime") or item.get("publishedOn") or item.get("published_on") or item.get("timestamp") or observed)
184
+ if event<10_000_000_000:event*=1000
185
+ before=conn.total_changes
186
+ conn.execute("INSERT OR IGNORE INTO forward_events VALUES(?,?,?,?,?,?,?)",(component,external,item.get("symbol"),event,observed,source,json.dumps(item,ensure_ascii=False)))
187
+ saved+=conn.total_changes-before
188
+ conn.commit()
189
+ return saved
190
+
191
+
192
+ def _save_oi(items: List[Dict[str, Any]]) -> int:
193
+ observed=int(time.time()*1000);processed=0
194
+ with _db() as conn:
195
+ for item in items:
196
+ symbol=_symbol(str(item.get("symbol") or ""));timestamp=int(item["timestamp"])
197
+ conn.execute("""INSERT INTO forward_events(component,external_id,symbol,event_time,observed_at,source,payload_json)
198
+ VALUES(?,?,?,?,?,?,?)
199
+ ON CONFLICT(component,external_id) DO UPDATE SET
200
+ source=excluded.source,payload_json=excluded.payload_json""",
201
+ ("openInterest",f"{symbol}:{timestamp}",symbol,timestamp,observed,item.get("source","unknown"),json.dumps(item,ensure_ascii=False)))
202
+ processed+=1
203
+ conn.commit()
204
+ return processed
205
+
206
+
207
+ def _archive_count(component: str, symbol: Optional[str]=None) -> int:
208
+ with _db() as conn:
209
+ if symbol: row=conn.execute("SELECT COUNT(*) FROM forward_events WHERE component=? AND symbol=?",(component,_symbol(symbol))).fetchone()
210
+ else: row=conn.execute("SELECT COUNT(*) FROM forward_events WHERE component=?",(component,)).fetchone()
211
+ return int(row[0])
212
+
213
+
214
+ async def collect_oi_archive(period: str="1h") -> Dict[str, Any]:
215
+ results=[]
216
+ for symbol in _tracked_symbols():
217
+ try:
218
+ response=await _oi(symbol,period,None,None,5000);items=response.get("data") or []
219
+ _save_oi(items)
220
+ results.append({"symbol":symbol,"success":True,"fetched":len(items),"archiveRows":_archive_count("openInterest",symbol),"earliestTimestamp":items[0]["timestamp"] if items else None,"latestTimestamp":items[-1]["timestamp"] if items else None})
221
+ except Exception as exc: results.append({"symbol":symbol,"success":False,"error":f"{type(exc).__name__}: {exc}"})
222
+ return {"success":any(x["success"] for x in results),"component":"openInterest","schedule":"daily","dedupeKey":["symbol","timestamp"],"trackedSymbols":_tracked_symbols(),"results":results,"coverageMode":"FORWARD_ARCHIVE_PLUS_30_DAY_UPSTREAM_WINDOW","warning":"No OI before archive collection began can be recovered from Binance's 30-day endpoint.","timestamp":_now()}
223
+
224
+
225
+ def _read(component: str, start: Optional[int], end: Optional[int], limit: int) -> Dict[str, Any]:
226
+ where=["component=?"];params:List[Any]=[component]
227
+ if start is not None:where.append("event_time>=?");params.append(int(start))
228
+ if end is not None:where.append("event_time<=?");params.append(int(end))
229
+ params.append(min(max(limit,1),10000))
230
+ with _db() as conn:
231
+ rows=conn.execute(f"SELECT * FROM forward_events WHERE {' AND '.join(where)} ORDER BY event_time LIMIT ?",params).fetchall()
232
+ first=conn.execute("SELECT MIN(observed_at) FROM forward_events WHERE component=?",(component,)).fetchone()[0]
233
+ data=[{**json.loads(r["payload_json"]),"eventTime":r["event_time"],"observedAt":r["observed_at"],"archiveSource":r["source"]} for r in rows]
234
+ return {"success":True,"component":component,"data":data,"count":len(data),"sourceMode":"CACHED","dataState":"REAL" if data else "UNAVAILABLE","coverage":{"mode":"FORWARD_COLLECTING_ONLY","archiveStartedAt":first,"preExistingHistoryAvailable":False,"warning":"Latest/live upstream; archive contains only records observed after collection began."},"timestamp":_now()}
235
+
236
+
237
+ def _read_oi(symbol: str,start:Optional[int],end:Optional[int],limit:int) -> Dict[str,Any]:
238
+ sym=_symbol(symbol);where=["component='openInterest'","symbol=?"];params:List[Any]=[sym]
239
+ if start is not None:where.append("event_time>=?");params.append(int(start))
240
+ if end is not None:where.append("event_time<=?");params.append(int(end))
241
+ params.append(min(max(limit,1),100000))
242
+ with _db() as conn:
243
+ rows=conn.execute(f"SELECT * FROM forward_events WHERE {' AND '.join(where)} ORDER BY event_time LIMIT ?",params).fetchall()
244
+ first=conn.execute("SELECT MIN(observed_at) FROM forward_events WHERE component='openInterest'").fetchone()[0]
245
+ data=[json.loads(r["payload_json"]) for r in rows]
246
+ return {"success":True,"component":"openInterest","symbol":sym,"data":data,"count":len(data),"sourceMode":"CACHED","dataState":"REAL" if data else "UNAVAILABLE","coverage":{"mode":"REAL_HISTORICAL_ON_DEMAND_PLUS_FORWARD_ARCHIVE","archiveStartedAt":first,"preDeploymentHistoryAvailable":False,"earliestTimestamp":data[0]["timestamp"] if data else None,"latestTimestamp":data[-1]["timestamp"] if data else None,"warning":"This cached archive starts at first collection; older history is available separately on demand through the Bybit fallback."},"timestamp":_now()}
247
+
248
+
249
+ async def _scan_evm_whales(limit:int) -> Dict[str,Any]:
250
+ shared_blocks=os.getenv("WHALE_SCAN_BLOCKS")
251
+ chains=[
252
+ {"chain":"ethereum","symbol":"ETH","blocks":min(max(int(shared_blocks or os.getenv("WHALE_ETH_SCAN_BLOCKS","15")),1),100),"threshold":float(os.getenv("WHALE_ETH_MIN_NATIVE","50")),"urls":["https://ethereum.publicnode.com","https://eth.llamarpc.com"]},
253
+ {"chain":"bsc","symbol":"BNB","blocks":min(max(int(shared_blocks or os.getenv("WHALE_BSC_SCAN_BLOCKS","200")),1),500),"threshold":float(os.getenv("WHALE_BNB_MIN_NATIVE","500")),"urls":["https://bsc-rpc.publicnode.com","https://bsc-dataseed.binance.org"]},
254
+ ]
255
+ found=[];diagnostics=[]
256
+ async with httpx.AsyncClient(timeout=httpx.Timeout(25,connect=7),follow_redirects=True,trust_env=True,headers={"User-Agent":"APEX-Whale-Scanner/1.0"}) as client:
257
+ for config in chains:
258
+ active=None;latest=None
259
+ for url in config["urls"]:
260
+ try:
261
+ response=await client.post(url,json={"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]});latest=(response.json() or {}).get("result")
262
+ if response.status_code==200 and latest:active=url;break
263
+ except Exception as exc:diagnostics.append({"chain":config["chain"],"provider":url,"ok":False,"error":type(exc).__name__})
264
+ if not active or not latest:
265
+ diagnostics.append({"chain":config["chain"],"ok":False,"error":"no public RPC available"});continue
266
+ latest_number=int(latest,16)
267
+ block_count=config["blocks"]
268
+ calls=[client.post(active,json={"jsonrpc":"2.0","id":number,"method":"eth_getBlockByNumber","params":[hex(number),True]}) for number in range(latest_number-block_count+1,latest_number+1)]
269
+ responses=await asyncio.gather(*calls,return_exceptions=True);scanned=0
270
+ for response in responses:
271
+ if isinstance(response,Exception) or response.status_code!=200:continue
272
+ block=(response.json() or {}).get("result") or {};event_time=int(block.get("timestamp","0x0"),16)*1000
273
+ for tx in block.get("transactions") or []:
274
+ scanned+=1;amount=int(tx.get("value","0x0"),16)/10**18
275
+ if amount<config["threshold"]:continue
276
+ found.append({"txHash":tx.get("hash"),"chain":config["chain"],"symbol":config["symbol"],"from":tx.get("from"),"to":tx.get("to"),"amount":amount,"eventTime":event_time,"blockNumber":int(block.get("number","0x0"),16),"thresholdNative":config["threshold"],"classification":"LARGE_NATIVE_TRANSFER","source":"public_evm_rpc"})
277
+ diagnostics.append({"chain":config["chain"],"provider":active,"ok":True,"blocksScanned":block_count,"transactionsScanned":scanned,"matches":sum(1 for item in found if item["chain"]==config["chain"])})
278
+ found.sort(key=lambda item:item["eventTime"],reverse=True)
279
+ return {"items":found[:min(max(limit,1),1000)],"diagnostics":diagnostics,"pollInterval":"2 minutes"}
280
+
281
+
282
+ async def collect_forward() -> Dict[str, Any]:
283
+ results=[]
284
+ try:
285
+ # NEWSAPI_KEY is the legacy Space-secret name for this verified
286
+ # newsdata.io credential, not an assertion that it is NewsAPI.org.
287
+ newsdata_key=os.getenv("NEWSDATA_API_KEY") or os.getenv("NEWSDATA_KEY") or NEWSDATA_FALLBACK_KEY
288
+ if newsdata_key:
289
+ payload,_,diag=await _json(["https://newsdata.io"],"/api/1/latest",{"apikey":newsdata_key,"q":"cryptocurrency","language":"en"})
290
+ raw=(payload or {}).get("results") if isinstance(payload,dict) else []
291
+ items=[]
292
+ for x in (raw or [])[:200]:
293
+ try:published=int(datetime.fromisoformat(str(x.get("pubDate") or x.get("pubDateTZ") or "").replace("Z","+00:00")).timestamp())
294
+ except Exception:published=int(time.time())
295
+ items.append({"id":x.get("article_id") or x.get("link"),"title":x.get("title"),"body":x.get("description") or x.get("content"),"url":x.get("link"),"source":x.get("source_name") or x.get("source_id"),"publishedOn":published,"categories":x.get("category")})
296
+ results.append({"component":"news","success":True,"fetched":len(items),"saved":_save("news","newsdata_latest",items),"source":"newsdata_latest","coverageMode":"FORWARD_COLLECTING_ONLY"})
297
+ else:
298
+ cc_key=os.getenv("CRYPTOCOMPARE_KEY") or os.getenv("CRYPTOCOMPARE_API_KEY")
299
+ if not cc_key: raise RuntimeError("No authenticated latest-news provider configured; set NEWSDATA_API_KEY")
300
+ payload,_,diag=await _json(["https://min-api.cryptocompare.com"],"/data/v2/news/",{"lang":"EN","api_key":cc_key})
301
+ raw=(payload or {}).get("Data") if isinstance(payload,dict) else []
302
+ items=[{"id":x.get("id"),"title":x.get("title"),"body":x.get("body"),"url":x.get("url"),"source":x.get("source"),"publishedOn":x.get("published_on"),"categories":x.get("categories")} for x in (raw or [])[:200]]
303
+ results.append({"component":"news","success":True,"fetched":len(items),"saved":_save("news","cryptocompare_news",items),"source":"cryptocompare_news","coverageMode":"FORWARD_COLLECTING_ONLY"})
304
+ except Exception as exc:results.append({"component":"news","success":False,"error":f"{type(exc).__name__}: {exc}"})
305
+ try:
306
+ whale_key=os.getenv("WHALE_ALERT_API_KEY") or os.getenv("WHALE_ALERT_KEY")
307
+ if whale_key:
308
+ payload,_,diag=await _json(["https://api.whale-alert.io"],"/v1/transactions",{"api_key":whale_key,"start":int(time.time())-3600,"min_value":500000,"limit":100});raw=payload.get("transactions") if isinstance(payload,dict) else payload;source="whale_alert"
309
+ items=raw if isinstance(raw,list) else []
310
+ else:
311
+ scan=await _scan_evm_whales(100);items=scan["items"];diag=scan["diagnostics"];source="public_evm_rpc_large_native_transfers"
312
+ results.append({"component":"whaleFlow","success":True,"fetched":len(items),"saved":_save("whaleFlow",source,items),"source":source,"scope":"large native ETH/BNB transfers in recent blocks; no owner labels","coverageMode":"FORWARD_COLLECTING_ONLY","diagnostics":diag})
313
+ except Exception as exc:results.append({"component":"whaleFlow","success":False,"error":f"{type(exc).__name__}: {exc}"})
314
+ return {"success":any(x["success"] for x in results),"results":results,"timestamp":_now()}
315
+
316
+
317
+ async def backfill_newsapi(start_date: str, end_date: str, max_pages: int) -> Dict[str, Any]:
318
+ key=os.getenv("NEWSAPI_KEY") or os.getenv("NEWS_API_KEY")
319
+ if not key:return {"success":False,"component":"news","source":"newsapi","dataState":"UNAVAILABLE","error":"NEWSAPI_KEY not configured","coverageMode":"CONDITIONAL_DATED_SEARCH"}
320
+ fetched=saved=0;diagnostics=[]
321
+ for page in range(1,min(max(max_pages,1),20)+1):
322
+ payload,_,diag=await _json(["https://newsapi.org"],"/v2/everything",{"q":"cryptocurrency OR bitcoin OR ethereum","from":start_date,"to":end_date,"sortBy":"publishedAt","language":"en","pageSize":100,"page":page,"apiKey":key});diagnostics.extend(diag)
323
+ articles=payload.get("articles") if isinstance(payload,dict) else []
324
+ if not articles:break
325
+ normalized=[]
326
+ for item in articles:
327
+ try:published=int(datetime.fromisoformat(str(item.get("publishedAt","")).replace("Z","+00:00")).timestamp())
328
+ except Exception:published=int(time.time())
329
+ normalized.append({"id":item.get("url"),"title":item.get("title"),"body":item.get("content") or item.get("description"),"url":item.get("url"),"source":(item.get("source") or {}).get("name"),"publishedOn":published})
330
+ fetched+=len(normalized);saved+=_save("news","newsapi",normalized)
331
+ if len(articles)<100:break
332
+ await asyncio.sleep(.1)
333
+ return {"success":True,"component":"news","source":"newsapi","fetched":fetched,"saved":saved,"coverageMode":"CONDITIONAL_DATED_SEARCH","requestedStart":start_date,"requestedEnd":end_date,"complete":False,"warning":"Coverage depends on the configured NewsAPI plan and query matching; it is not asserted complete.","diagnostics":diagnostics[-5:]}
334
+
335
+
336
+ async def periodic_archive(stop: asyncio.Event) -> None:
337
+ last_oi_collection=0.0
338
+ last_news_collection=0.0
339
+ while not stop.is_set():
340
+ if time.time()-last_news_collection >= 900:
341
+ try:
342
+ await collect_forward()
343
+ last_news_collection=time.time()
344
+ except Exception: pass
345
+ else:
346
+ try:
347
+ scan=await _scan_evm_whales(1000)
348
+ _save("whaleFlow","public_evm_rpc_large_native_transfers",scan["items"])
349
+ except Exception: pass
350
+ if time.time()-last_oi_collection >= 86400:
351
+ try:
352
+ await collect_oi_archive("1h")
353
+ last_oi_collection=time.time()
354
+ except Exception: pass
355
+ try: await asyncio.wait_for(stop.wait(),timeout=120)
356
+ except asyncio.TimeoutError: pass
357
+
358
+
359
+ @router.get("/coverage")
360
+ async def coverage():
361
+ return {"success":True,"persistencePolicy":"on_demand_market_history_plus_forward_event_archive","archiveStorage":_storage_status(),"components":{"ohlcv":{"mode":"REAL_HISTORICAL_ON_DEMAND","endpoint":"/api/ohlcv"},"funding":{"mode":"REAL_180_DAY_UPSTREAM_IN_CURRENT_RUNTIME","endpoint":"/api/apex/funding/{symbol}"},"openInterest":{"mode":"REAL_180_DAY_UPSTREAM_PLUS_FORWARD_ARCHIVE","retention":"Gate.io fallback ~180 days; Binance/Bybit are geo-blocked from current HF runtime; daily local archive","liveEndpoint":"/api/apex/open-interest/{symbol}","archiveEndpoint":"/api/apex/open-interest-archive/{symbol}","trackedSymbols":_tracked_symbols()},"sentiment":{"mode":"REAL_HISTORICAL","endpoint":"/api/apex/sentiment/fear-greed"},"news":{"mode":"FORWARD_COLLECTING_ONLY","endpoint":"/api/apex/news","liveSource":"newsdata.io authenticated latest feed","preDeploymentHistoryAvailable":False},"whaleFlow":{"mode":"FORWARD_COLLECTING_ONLY","endpoint":"/api/apex/whale-flow","liveSource":"public Ethereum/BSC RPC large native transfers","scope":"configurable native-value threshold; no wallet-owner labels","preDeploymentHistoryAvailable":False}},"manifest":{"found":False,"expectedPath":"QA/profitability-structural-remediation/data/manifest.json","behavior":"APEX_TRACKED_SYMBOLS is configurable; defaults follow the project's core dashboard symbols"}}
362
+
363
+
364
+ @router.get("/funding/{symbol}")
365
+ async def funding(symbol:str,start:Optional[int]=None,end:Optional[int]=None,limit:int=Query(1000,ge=1,le=100000)):
366
+ try:return await _funding(symbol,start,end,limit)
367
+ except Exception as exc:raise HTTPException(502,detail=f"funding upstream unavailable: {type(exc).__name__}: {exc}")
368
+
369
+
370
+ @router.get("/open-interest/{symbol}")
371
+ async def open_interest(symbol:str,period:str="1h",start:Optional[int]=None,end:Optional[int]=None,limit:int=Query(500,ge=1,le=5000)):
372
+ try:return await _oi(symbol,period,start,end,limit)
373
+ except ValueError as exc:raise HTTPException(422,detail=str(exc))
374
+ except Exception as exc:raise HTTPException(502,detail=f"open-interest upstream unavailable: {type(exc).__name__}: {exc}")
375
+
376
+
377
+ @router.get("/open-interest-archive/{symbol}")
378
+ async def open_interest_archive(symbol:str,start:Optional[int]=None,end:Optional[int]=None,limit:int=Query(10000,ge=1,le=100000)):
379
+ return _read_oi(symbol,start,end,limit)
380
+
381
+
382
+ @router.post("/open-interest/archive/collect")
383
+ async def collect_open_interest_archive(period:str="1h"):
384
+ try:return await collect_oi_archive(period)
385
+ except ValueError as exc:raise HTTPException(422,detail=str(exc))
386
+
387
+
388
+ @router.get("/sentiment/fear-greed")
389
+ async def fear_greed(limit:int=Query(0,ge=0,le=100000)):
390
+ try:
391
+ payload,provider,diag=await _json(["https://api.alternative.me"],"/fng/",{"limit":limit,"format":"json"})
392
+ data=payload.get("data") if isinstance(payload,dict) else []
393
+ return {"success":True,"component":"sentiment","data":data or [],"count":len(data or []),"source":"alternative.me","sourceMode":"LIVE","dataState":"REAL" if data else "UNAVAILABLE","coverage":{"mode":"REAL_HISTORICAL","earliestTimestamp":data[-1].get("timestamp") if data else None,"latestTimestamp":data[0].get("timestamp") if data else None},"diagnostics":diag,"timestamp":_now()}
394
+ except Exception as exc:raise HTTPException(502,detail=f"sentiment upstream unavailable: {type(exc).__name__}: {exc}")
395
+
396
+
397
+ @router.get("/news")
398
+ async def news(start:Optional[int]=None,end:Optional[int]=None,limit:int=Query(500,ge=1,le=10000)):return _read("news",start,end,limit)
399
+
400
+
401
+ @router.get("/whale-flow")
402
+ async def whales(start:Optional[int]=None,end:Optional[int]=None,limit:int=Query(500,ge=1,le=10000)):return _read("whaleFlow",start,end,limit)
403
+
404
+
405
+ @router.post("/archive/collect")
406
+ async def collect():return await collect_forward()
407
+
408
+
409
+ @router.post("/news/backfill")
410
+ async def news_backfill(start:str,end:str,max_pages:int=Query(5,ge=1,le=20)):
411
+ try:return await backfill_newsapi(start,end,max_pages)
412
+ except Exception as exc:raise HTTPException(502,detail=f"NewsAPI dated search failed: {type(exc).__name__}: {exc}")
413
+
414
+
415
+ def register_apex_routes(app) -> None: app.include_router(router)
api_compat_routes.py CHANGED
@@ -20,6 +20,7 @@ from __future__ import annotations
20
  import logging
21
  import os
22
  import json
 
23
  from datetime import datetime, timezone
24
  from typing import Any, Dict, List, Optional, Tuple
25
 
@@ -270,63 +271,119 @@ async def _fetch_coingecko_trending(limit: int = 10) -> Tuple[List[Dict[str, Any
270
  return rows, []
271
 
272
 
273
- async def _fetch_binance_klines(symbol: str, interval: str, limit: int) -> Tuple[List[Dict[str, Any]], List[str]]:
 
 
 
 
 
 
274
  pair = _normalize_symbol(symbol)
275
  mapped = BINANCE_INTERVALS.get(interval, interval)
276
- payload, error, _ = await _get_json(
 
 
277
  "https://api.binance.com/api/v3/klines",
278
- params={"symbol": pair, "interval": mapped, "limit": min(max(limit, 1), 1000)},
279
- timeout=20.0,
280
- )
281
- if error or not isinstance(payload, list):
282
- return [], [error or f"Binance klines invalid payload for {pair}"]
283
- candles = []
284
- for row in payload:
285
- try:
286
- candles.append({
287
- "timestamp": _int(row[0]),
288
- "open": _float(row[1]),
289
- "high": _float(row[2]),
290
- "low": _float(row[3]),
291
- "close": _float(row[4]),
292
- "volume": _float(row[5]),
293
- })
294
- except Exception:
295
- continue
296
- return candles, []
297
-
298
-
299
- async def _fetch_kucoin_klines(symbol: str, timeframe: str, limit: int) -> Tuple[List[Dict[str, Any]], List[str]]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  # KuCoin returns reverse chronological rows like [time, open, close, high, low, volume, turnover].
301
  ksymbol = _kucoin_symbol(symbol)
302
  ktype = KUCOIN_TYPES.get(timeframe, "1hour")
303
- payload, error, _ = await _get_json(
304
- "https://api.kucoin.com/api/v1/market/candles",
305
- params={"symbol": ksymbol, "type": ktype},
306
- timeout=20.0,
307
- )
308
- if error or not isinstance(payload, dict):
309
- return [], [error or f"KuCoin candles invalid payload for {ksymbol}"]
310
- data = payload.get("data") or []
311
- candles = []
312
- for row in data[: min(max(limit, 1), 1500)]:
313
- try:
314
- candles.append({
315
- "timestamp": _int(row[0]) * 1000,
316
- "open": _float(row[1]),
317
- "high": _float(row[3]),
318
- "low": _float(row[4]),
319
- "close": _float(row[2]),
320
- "volume": _float(row[5]),
321
- })
322
- except Exception:
323
- continue
324
- candles.sort(key=lambda c: c["timestamp"])
325
- return candles[-limit:], []
 
 
 
 
 
 
 
 
 
 
326
 
327
 
328
 
329
- async def _fetch_cryptocompare_ohlcv(symbol: str, timeframe: str, limit: int) -> Tuple[List[Dict[str, Any]], List[str]]:
330
  """Optional OHLCV fallback via CryptoCompare.
331
 
332
  Uses env CRYPTOCOMPARE_KEY when configured, but can still work on some
@@ -353,6 +410,8 @@ async def _fetch_cryptocompare_ohlcv(symbol: str, timeframe: str, limit: int) ->
353
  "limit": min(max(limit, 1), 2000),
354
  "aggregate": aggregate,
355
  }
 
 
356
  key = get_secret("CRYPTOCOMPARE_KEY")
357
  if key:
358
  params["api_key"] = key
@@ -379,7 +438,7 @@ async def _fetch_cryptocompare_ohlcv(symbol: str, timeframe: str, limit: int) ->
379
  continue
380
  return candles, []
381
 
382
- async def _fetch_ohlcv(symbol: str, timeframe: str, limit: int) -> Tuple[List[Dict[str, Any]], str, List[str]]:
383
  """Smart OHLCV rotation.
384
 
385
  Raw OHLCV must come from market/exchange providers. HF models are used for
@@ -388,17 +447,17 @@ async def _fetch_ohlcv(symbol: str, timeframe: str, limit: int) -> Tuple[List[Di
388
  """
389
  errors: List[str] = []
390
 
391
- candles, err = await _fetch_binance_klines(symbol, timeframe, limit)
392
  if candles:
393
  return candles, "binance_public", errors + err
394
  errors.extend(err)
395
 
396
- candles, err = await _fetch_kucoin_klines(symbol, timeframe, limit)
397
  if candles:
398
  return candles, "kucoin_public", errors + err
399
  errors.extend(err)
400
 
401
- candles, err = await _fetch_cryptocompare_ohlcv(symbol, timeframe, limit)
402
  if candles:
403
  return candles, "cryptocompare_ohlcv", errors + err
404
  errors.extend(err)
@@ -569,11 +628,28 @@ async def coins_top_alias(limit: int = Query(100, ge=1, le=250)):
569
 
570
 
571
  @router.get("/api/trading/ohlcv/{symbol}")
572
- async def trading_ohlcv(symbol: str, timeframe: str = "1h", limit: int = 100):
573
- candles, source, errors = await _fetch_ohlcv(symbol, timeframe, limit)
574
  if not candles:
575
  return _fail("OHLCV unavailable", source=source, symbol=_normalize_symbol(symbol), timeframe=timeframe, data=[], missingCapabilities=["ohlcv"], upstreamErrors=errors)
576
- return _ok(data=candles, symbol=_normalize_symbol(symbol), timeframe=timeframe, candles=candles, source=source)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
577
 
578
 
579
  @router.get("/api/ohlcv")
@@ -582,15 +658,30 @@ async def trading_ohlcv_alias(
582
  symbol: str = Query("BTCUSDT"),
583
  interval: str = Query("1h"),
584
  timeframe: Optional[str] = Query(None),
585
- limit: int = Query(100, ge=1, le=1000),
 
 
 
 
 
586
  ):
587
- return await trading_ohlcv(symbol=symbol, timeframe=(timeframe or interval), limit=limit)
588
 
589
 
590
  @router.get("/api/history")
591
  @router.get("/api/trading/history/{symbol}")
592
- async def history_alias(symbol: str = "BTCUSDT", interval: str = "1h", timeframe: Optional[str] = None, limit: int = Query(100, ge=1, le=1000)):
593
- return await trading_ohlcv(symbol=symbol, timeframe=(timeframe or interval), limit=limit)
 
 
 
 
 
 
 
 
 
 
594
 
595
 
596
  @router.get("/api/trading/stats/24h/{symbol}")
 
20
  import logging
21
  import os
22
  import json
23
+ import asyncio
24
  from datetime import datetime, timezone
25
  from typing import Any, Dict, List, Optional, Tuple
26
 
 
271
  return rows, []
272
 
273
 
274
+ async def _fetch_binance_klines(
275
+ symbol: str,
276
+ interval: str,
277
+ limit: int,
278
+ start_time: Optional[int] = None,
279
+ end_time: Optional[int] = None,
280
+ ) -> Tuple[List[Dict[str, Any]], List[str]]:
281
  pair = _normalize_symbol(symbol)
282
  mapped = BINANCE_INTERVALS.get(interval, interval)
283
+ total_limit = min(max(limit, 1), 200_000)
284
+ urls = [
285
+ "https://data-api.binance.vision/api/v3/klines",
286
  "https://api.binance.com/api/v3/klines",
287
+ "https://api1.binance.com/api/v3/klines",
288
+ ]
289
+ rows: Dict[int, Dict[str, Any]] = {}
290
+ errors: List[str] = []
291
+ cursor_start = int(start_time) if start_time is not None else None
292
+ cursor_end = int(end_time) if end_time is not None else None
293
+ forward = cursor_start is not None
294
+ while len(rows) < total_limit:
295
+ page_size = min(1000, total_limit - len(rows))
296
+ params: Dict[str, Any] = {"symbol": pair, "interval": mapped, "limit": page_size}
297
+ if cursor_start is not None:
298
+ params["startTime"] = cursor_start
299
+ if cursor_end is not None:
300
+ params["endTime"] = cursor_end
301
+ payload = None
302
+ last_error = None
303
+ for url in urls:
304
+ candidate, error, _ = await _get_json(url, params=params, timeout=25.0)
305
+ if not error and isinstance(candidate, list):
306
+ payload = candidate
307
+ break
308
+ last_error = error or f"invalid payload from {url}"
309
+ if not isinstance(payload, list):
310
+ errors.append(last_error or f"Binance klines invalid payload for {pair}")
311
+ break
312
+ if not payload:
313
+ break
314
+ for row in payload:
315
+ try:
316
+ ts = _int(row[0])
317
+ rows[ts] = {"timestamp": ts, "open": _float(row[1]), "high": _float(row[2]), "low": _float(row[3]), "close": _float(row[4]), "volume": _float(row[5])}
318
+ except Exception:
319
+ continue
320
+ first_ts, last_ts = _int(payload[0][0]), _int(payload[-1][0])
321
+ if forward:
322
+ next_start = last_ts + 1
323
+ if next_start <= (cursor_start or -1) or (end_time is not None and next_start > int(end_time)):
324
+ break
325
+ cursor_start = next_start
326
+ elif cursor_end is not None:
327
+ next_end = first_ts - 1
328
+ if next_end >= cursor_end:
329
+ break
330
+ cursor_end = next_end
331
+ else:
332
+ break
333
+ if len(payload) < page_size:
334
+ break
335
+ await asyncio.sleep(0.03)
336
+ candles = [rows[key] for key in sorted(rows)]
337
+ return (candles[:total_limit] if forward else candles[-total_limit:]), errors
338
+
339
+
340
+ async def _fetch_kucoin_klines(
341
+ symbol: str,
342
+ timeframe: str,
343
+ limit: int,
344
+ start_time: Optional[int] = None,
345
+ end_time: Optional[int] = None,
346
+ ) -> Tuple[List[Dict[str, Any]], List[str]]:
347
  # KuCoin returns reverse chronological rows like [time, open, close, high, low, volume, turnover].
348
  ksymbol = _kucoin_symbol(symbol)
349
  ktype = KUCOIN_TYPES.get(timeframe, "1hour")
350
+ total_limit = min(max(limit, 1), 200_000)
351
+ rows: Dict[int, Dict[str, Any]] = {}
352
+ errors: List[str] = []
353
+ cursor_start = int(start_time / 1000) if start_time is not None else None
354
+ cursor_end = int(end_time / 1000) if end_time is not None else None
355
+ # KuCoin pages are naturally bounded by startAt/endAt. Move the end cursor
356
+ # backwards until the requested start is covered.
357
+ while len(rows) < total_limit:
358
+ params: Dict[str, Any] = {"symbol": ksymbol, "type": ktype}
359
+ if cursor_start is not None:
360
+ params["startAt"] = cursor_start
361
+ if cursor_end is not None:
362
+ params["endAt"] = cursor_end
363
+ payload, error, _ = await _get_json("https://api.kucoin.com/api/v1/market/candles", params=params, timeout=25.0)
364
+ if error or not isinstance(payload, dict):
365
+ errors.append(error or f"KuCoin candles invalid payload for {ksymbol}")
366
+ break
367
+ data = payload.get("data") or []
368
+ if not data:
369
+ break
370
+ for row in data:
371
+ try:
372
+ ts = _int(row[0]) * 1000
373
+ rows[ts] = {"timestamp": ts, "open": _float(row[1]), "high": _float(row[3]), "low": _float(row[4]), "close": _float(row[2]), "volume": _float(row[5])}
374
+ except Exception:
375
+ continue
376
+ oldest_sec = min(_int(row[0]) for row in data)
377
+ if cursor_start is None or oldest_sec <= cursor_start or len(data) < 100:
378
+ break
379
+ cursor_end = oldest_sec - 1
380
+ await asyncio.sleep(0.03)
381
+ candles = [rows[key] for key in sorted(rows)]
382
+ return candles[-total_limit:], errors
383
 
384
 
385
 
386
+ async def _fetch_cryptocompare_ohlcv(symbol: str, timeframe: str, limit: int, start_time: Optional[int] = None, end_time: Optional[int] = None) -> Tuple[List[Dict[str, Any]], List[str]]:
387
  """Optional OHLCV fallback via CryptoCompare.
388
 
389
  Uses env CRYPTOCOMPARE_KEY when configured, but can still work on some
 
410
  "limit": min(max(limit, 1), 2000),
411
  "aggregate": aggregate,
412
  }
413
+ if end_time is not None:
414
+ params["toTs"] = int(end_time / 1000)
415
  key = get_secret("CRYPTOCOMPARE_KEY")
416
  if key:
417
  params["api_key"] = key
 
438
  continue
439
  return candles, []
440
 
441
+ async def _fetch_ohlcv(symbol: str, timeframe: str, limit: int, start_time: Optional[int] = None, end_time: Optional[int] = None) -> Tuple[List[Dict[str, Any]], str, List[str]]:
442
  """Smart OHLCV rotation.
443
 
444
  Raw OHLCV must come from market/exchange providers. HF models are used for
 
447
  """
448
  errors: List[str] = []
449
 
450
+ candles, err = await _fetch_binance_klines(symbol, timeframe, limit, start_time, end_time)
451
  if candles:
452
  return candles, "binance_public", errors + err
453
  errors.extend(err)
454
 
455
+ candles, err = await _fetch_kucoin_klines(symbol, timeframe, limit, start_time, end_time)
456
  if candles:
457
  return candles, "kucoin_public", errors + err
458
  errors.extend(err)
459
 
460
+ candles, err = await _fetch_cryptocompare_ohlcv(symbol, timeframe, limit, start_time, end_time)
461
  if candles:
462
  return candles, "cryptocompare_ohlcv", errors + err
463
  errors.extend(err)
 
628
 
629
 
630
  @router.get("/api/trading/ohlcv/{symbol}")
631
+ async def trading_ohlcv(symbol: str, timeframe: str = "1h", limit: int = 100, start_time: Optional[int] = None, end_time: Optional[int] = None):
632
+ candles, source, errors = await _fetch_ohlcv(symbol, timeframe, limit, start_time, end_time)
633
  if not candles:
634
  return _fail("OHLCV unavailable", source=source, symbol=_normalize_symbol(symbol), timeframe=timeframe, data=[], missingCapabilities=["ohlcv"], upstreamErrors=errors)
635
+ return _ok(
636
+ data=candles,
637
+ symbol=_normalize_symbol(symbol),
638
+ timeframe=timeframe,
639
+ candles=candles,
640
+ count=len(candles),
641
+ source=source,
642
+ sourceMode="LIVE",
643
+ dataState="REAL",
644
+ coverage={
645
+ "mode": "REAL_HISTORICAL" if start_time is not None or end_time is not None else "LIVE_RECENT",
646
+ "requestedStart": start_time,
647
+ "requestedEnd": end_time,
648
+ "earliestTimestamp": candles[0]["timestamp"] if candles else None,
649
+ "latestTimestamp": candles[-1]["timestamp"] if candles else None,
650
+ "rowCount": len(candles),
651
+ },
652
+ )
653
 
654
 
655
  @router.get("/api/ohlcv")
 
658
  symbol: str = Query("BTCUSDT"),
659
  interval: str = Query("1h"),
660
  timeframe: Optional[str] = Query(None),
661
+ limit: int = Query(100, ge=1, le=200000),
662
+ since: Optional[int] = Query(None),
663
+ start: Optional[int] = Query(None),
664
+ end: Optional[int] = Query(None),
665
+ startTime: Optional[int] = Query(None),
666
+ endTime: Optional[int] = Query(None),
667
  ):
668
+ return await trading_ohlcv(symbol=symbol, timeframe=(timeframe or interval), limit=limit, start_time=(startTime or start or since), end_time=(endTime or end))
669
 
670
 
671
  @router.get("/api/history")
672
  @router.get("/api/trading/history/{symbol}")
673
+ async def history_alias(
674
+ symbol: str = "BTCUSDT",
675
+ interval: str = "1h",
676
+ timeframe: Optional[str] = None,
677
+ limit: int = Query(100, ge=1, le=200000),
678
+ since: Optional[int] = None,
679
+ start: Optional[int] = None,
680
+ end: Optional[int] = None,
681
+ startTime: Optional[int] = None,
682
+ endTime: Optional[int] = None,
683
+ ):
684
+ return await trading_ohlcv(symbol=symbol, timeframe=(timeframe or interval), limit=limit, start_time=(startTime or start or since), end_time=(endTime or end))
685
 
686
 
687
  @router.get("/api/trading/stats/24h/{symbol}")
api_server_extended.py CHANGED
@@ -688,9 +688,24 @@ async def lifespan(app: FastAPI):
688
  except Exception as e:
689
  print(f"[WARN] Resource validation failed: {e}")
690
 
 
 
 
 
 
 
 
 
 
691
  print(f"[OK] Server ready on port {PORT}")
692
  print("=" * 80)
693
  yield
 
 
 
 
 
 
694
  print("Shutting down...")
695
 
696
 
@@ -784,6 +799,14 @@ async def index():
784
  """Serve index.html"""
785
  return _serve_html_asset("index.html", empty_title="index.html not found")
786
 
 
 
 
 
 
 
 
 
787
 
788
  @app.get("/dashboard.html", response_class=HTMLResponse)
789
  async def dashboard():
@@ -4142,6 +4165,14 @@ try:
4142
  except Exception as short_hunter_error:
4143
  logger.warning(f"Short Hunter datasource routes not loaded: {short_hunter_error}")
4144
 
 
 
 
 
 
 
 
 
4145
  # ===== Main Entry Point =====
4146
  if __name__ == "__main__":
4147
  import uvicorn
 
688
  except Exception as e:
689
  print(f"[WARN] Resource validation failed: {e}")
690
 
691
+ archive_stop = asyncio.Event()
692
+ archive_task = None
693
+ try:
694
+ from apex_strategy_routes import periodic_archive
695
+ archive_task = asyncio.create_task(periodic_archive(archive_stop))
696
+ print("[OK] APEX news/whale forward archive (15 min) + daily OI archive started")
697
+ except Exception as archive_exc:
698
+ print(f"[WARN] APEX forward archive did not start: {archive_exc}")
699
+
700
  print(f"[OK] Server ready on port {PORT}")
701
  print("=" * 80)
702
  yield
703
+ archive_stop.set()
704
+ if archive_task is not None:
705
+ try:
706
+ await asyncio.wait_for(archive_task, timeout=5)
707
+ except Exception:
708
+ archive_task.cancel()
709
  print("Shutting down...")
710
 
711
 
 
799
  """Serve index.html"""
800
  return _serve_html_asset("index.html", empty_title="index.html not found")
801
 
802
+ @app.get("/data-lab", response_class=HTMLResponse)
803
+ async def data_lab():
804
+ return _serve_html_asset("data_lab.html", empty_title="APEX Data Lab not found")
805
+
806
+ @app.get("/help", response_class=HTMLResponse)
807
+ async def help_page():
808
+ return _serve_html_asset("help.html", empty_title="API help not found")
809
+
810
 
811
  @app.get("/dashboard.html", response_class=HTMLResponse)
812
  async def dashboard():
 
4165
  except Exception as short_hunter_error:
4166
  logger.warning(f"Short Hunter datasource routes not loaded: {short_hunter_error}")
4167
 
4168
+ # ===== APEX strategy history + truthful forward archives =====
4169
+ try:
4170
+ from apex_strategy_routes import register_apex_routes
4171
+ register_apex_routes(app)
4172
+ logger.info("APEX historical data routes loaded (/api/apex/*)")
4173
+ except Exception as apex_routes_error:
4174
+ logger.warning(f"APEX historical data routes not loaded: {apex_routes_error}")
4175
+
4176
  # ===== Main Entry Point =====
4177
  if __name__ == "__main__":
4178
  import uvicorn
data_lab.html ADDED
@@ -0,0 +1 @@
 
 
1
+ <!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>APEX Historical Data Lab Β· Space 4</title><link rel="stylesheet" href="/static/css/apex-data-lab.css"></head><body><header><div class="brand"><div class="orb"></div><div><strong>APEX Data Lab</strong><div class="eyebrow">Datasource Space 4</div></div></div><nav><a href="/">Dashboard</a><a href="/help">Help & provenance</a><a href="/docs">OpenAPI</a></nav></header><main><section class="hero"><div class="eyebrow">Real data Β· explicit coverage Β· LIVE snapshot contract preserved</div><h1>Historical depth, without the marketing fog.</h1><p>Inspect real paginated candles and strategy inputs. Every panel surfaces provenance and freshness so recent-only data cannot masquerade as historical coverage.</p></section><section class="grid"><div class="panel controls"><h2>Historical request</h2><div class="form"><div><label>Symbol</label><input id="symbol" value="BTCUSDT"></div><div><label>Interval</label><select id="interval"><option>1h</option><option selected>4h</option><option>1d</option></select></div><div><label>Start</label><input id="start" type="date" value="2022-01-01"></div><div><label>End</label><input id="end" type="date" value="2022-03-01"></div><button id="load">Load real candles</button></div><div class="status"><span id="dot" class="dot"></span><span id="state">Ready</span></div><div id="error" class="error"></div></div><div class="panel chart"><div class="meta">OHLCV close Β· UTC</div><canvas id="canvas" width="820" height="330"></canvas><div class="stats"><div class="stat"><span class="meta">Rows</span><b id="rows">β€”</b></div><div class="stat"><span class="meta">Earliest</span><b id="earliest">β€”</b></div><div class="stat"><span class="meta">Latest</span><b id="latest">β€”</b></div></div></div><div class="panel coverage"><h2>Strategy coverage</h2><div id="cards" class="cards"></div></div></section></main><script src="/static/js/apex-data-lab.js"></script></body></html>
help.html ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Space 4 Β· Real Data Guide</title><style>
2
+ :root{color-scheme:dark;--bg:#070b14;--card:#10192abb;--line:#ffffff12;--text:#eff5ff;--muted:#90a2bd;--good:#42e5c6;--warn:#ffbd5b;--bad:#ff6b7d}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 15% 0,#16334b,transparent 28%),var(--bg);color:var(--text);font:15px/1.65 Inter,system-ui}header,main{width:min(1120px,calc(100% - 32px));margin:auto}header{display:flex;justify-content:space-between;align-items:center;padding:24px 0}a{color:#79ddff;text-decoration:none}nav a{margin-left:18px;color:var(--text)}.hero{padding:48px 0 30px}.kicker{color:var(--good);font-size:12px;letter-spacing:.12em;text-transform:uppercase}h1{font-size:clamp(34px,7vw,66px);line-height:1;margin:18px 0}h2{margin-top:50px}p{color:var(--muted)}.matrix{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}.cell,.endpoint,.warning{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:18px}.cell b{display:block}.tag{font-size:11px;padding:5px 8px;border-radius:999px;background:#42e5c61b;color:var(--good)}.tag.warn{color:var(--warn);background:#ffbd5b1b}.tag.bad{color:var(--bad);background:#ff6b7d1b}.warning{border-color:#ffbd5b55}.endpoint{margin:14px 0}.method{font:700 12px ui-monospace;color:var(--good)}code,pre{font-family:ui-monospace,monospace}pre{overflow:auto;background:#050a12;border:1px solid var(--line);padding:15px;border-radius:13px;color:#cce6ff}summary{cursor:pointer}.route-list{display:grid;grid-template-columns:repeat(2,1fr);gap:8px}.route{background:#0c1422;border:1px solid var(--line);border-radius:11px;padding:10px;font:12px ui-monospace}.muted{color:var(--muted)}@media(max-width:760px){.matrix,.route-list{grid-template-columns:1fr}nav{display:none}}
3
+ </style></head><body><header><strong>Datasource 4 Β· Truthful API Guide</strong><nav><a href="/">Dashboard</a><a href="/data-lab">Data Lab</a><a href="/docs">OpenAPI</a></nav></header><main><section class="hero"><div class="kicker">Canonical compat router Β· updated 2026-08-25</div><h1>Market history and LIVE snapshots, clearly separated.</h1><p>The compatibility router is the canonical historical path. The Enterprise Data Hub's duplicate aliases remain unregistered. Short Hunter stays a LIVE-only contract.</p></section><div class="warning"><b>Consumer trap</b><p>HTTP 200 is not proof of data. Check <code>success</code>, <code>dataState</code>, <code>coverage.mode</code>, <code>count</code> and array length.</p></div><h2>Coverage matrix</h2><div class="matrix"><div class="cell"><b>OHLCV</b><span class="tag">REAL HISTORICAL</span><p>Binance Vision β†’ Binance β†’ KuCoin β†’ CryptoCompare, with temporal pagination.</p></div><div class="cell"><b>Funding</b><span class="tag">REAL HISTORICAL</span><p>Gate.io provides real funding with about 180 days of retention in the current HF runtime; Binance/Bybit are geo-blocked there.</p></div><div class="cell"><b>Open interest</b><span class="tag warn">180-DAY + FORWARD</span><p>Gate.io supplies the real ~180-day production window plus a daily SQLite cache; Binance/Bybit are geo-blocked from the current HF runtime.</p></div><div class="cell"><b>Fear & Greed</b><span class="tag">REAL HISTORICAL</span><p>Full Alternative.me series.</p></div><div class="cell"><b>News</b><span class="tag warn">FORWARD ONLY</span><p>Authenticated NewsData latest feed; no pre-deployment history.</p></div><div class="cell"><b>Whale flow</b><span class="tag warn">FORWARD ONLY</span><p>Real large native ETH/BNB transfers from public RPC blocks; no owner labels.</p></div></div><h2>Endpoints and verified behavior</h2>
4
+ <article class="endpoint"><div class="method">GET</div><h3>/api/ohlcv Β· /api/klines Β· /api/history</h3><p>Intentional aliases of one implementation. Supports <code>since/start/startTime</code> and <code>end/endTime</code> in milliseconds.</p><pre>curl "$BASE/api/history?symbol=BTCUSDT&amp;interval=4h&amp;start=1640995200000&amp;end=1735689599999&amp;limit=10000"</pre><pre>const body = await fetch(`${base}/api/history?symbol=BTCUSDT&amp;interval=4h&amp;start=${start}&amp;end=${end}&amp;limit=10000`).then(r=&gt;r.json());
5
+ if (!body.success || !body.data.length) throw new Error('history unavailable');</pre><details><summary>Captured response summary</summary><pre>{"success":true,"source":"binance_public","dataState":"REAL","count":6576,"coverage":{"mode":"REAL_HISTORICAL","earliestTimestamp":1640995200000,"latestTimestamp":1735675200000}}</pre></details></article>
6
+ <article class="endpoint"><div class="method">GET / POST</div><h3>/api/apex/funding/{symbol} Β· /api/apex/open-interest/{symbol} Β· /api/apex/open-interest-archive/{symbol}</h3><p>Funding is multi-year. OI's live source is limited to 30 days, but startup + daily collection upserts the complete rolling window for <code>APEX_TRACKED_SYMBOLS</code>, deduped by symbol + timestamp. Production history begins at <code>archiveStartedAt</code>; earlier OI cannot be backfilled. <code>/api/apex/coverage</code> distinguishes a mounted <code>/data</code> persistent volume from ephemeral container storage.</p><pre>curl "$BASE/api/apex/funding/BTCUSDT?start=1640995200000&amp;end=1735689599999&amp;limit=20000"
7
+ curl "$BASE/api/apex/open-interest/BTCUSDT?period=1h&amp;limit=500"
8
+ curl "$BASE/api/apex/open-interest-archive/BTCUSDT?limit=10000"
9
+ curl -X POST "$BASE/api/apex/open-interest/archive/collect?period=1h"</pre><pre>const archivedOI = await fetch(`${base}/api/apex/open-interest-archive/BTCUSDT?limit=10000`).then(r=&gt;r.json());</pre><details><summary>Captured response summaries</summary><pre>funding: {"count":3288,"earliestTimestamp":1640995200006,"latestTimestamp":1735660800000}
10
+ openInterest: {"trackedSymbols":5,"rowsPerSymbol":500,"dedupeKey":["symbol","timestamp"],"secondRunDuplicateRows":0}</pre></details></article>
11
+ <article class="endpoint"><div class="method">GET</div><h3>/api/apex/sentiment/fear-greed?limit=0</h3><p>Full dated Alternative.me history.</p><pre>curl "$BASE/api/apex/sentiment/fear-greed?limit=0"</pre><pre>const sentiment = await fetch(`${base}/api/apex/sentiment/fear-greed?limit=0`).then(r=&gt;r.json());</pre><details><summary>Captured response summary</summary><pre>{"count":3123,"source":"alternative.me","coverage":{"mode":"REAL_HISTORICAL","earliestTimestamp":"1517443200","latestTimestamp":"1787529600"}}</pre></details></article>
12
+ <article class="endpoint"><div class="method">GET</div><h3>/api/short-hunter/snapshot/{symbol}</h3><p>LIVE-only multi-capability snapshot. It is not a replay endpoint and accepts no historical timestamp.</p><pre>curl "$BASE/api/short-hunter/snapshot/BTCUSDT?interval=1h&amp;limit=120"</pre><pre>const snap = await fetch(`${base}/api/short-hunter/snapshot/BTCUSDT`).then(r=&gt;r.json());
13
+ if (snap.sourceMode !== 'LIVE' || snap.dataState !== 'REAL') disableTrading();</pre><details><summary>Contract states</summary><pre>LIVE/REAL = every required component succeeded
14
+ DEGRADED/PARTIAL = some components missing
15
+ CACHED/CACHED = cache response
16
+ UNAVAILABLE/UNAVAILABLE = no usable provider result</pre></details></article>
17
+ <article class="endpoint"><div class="method">GET / POST</div><h3>/api/apex/news Β· /api/apex/whale-flow Β· /api/apex/archive/collect</h3><p>News uses authenticated NewsData every 15 minutes. Whale flow runs every 2 minutes, scans 15 Ethereum and 200 BSC recent blocks by default, and archives native transfers above configurable thresholds (defaults: 50 ETH / 500 BNB), deduped by chain + transaction hash. It is real transfer flow, not address-owner labeling; both archives begin only after deployment.</p><pre>curl -X POST "$BASE/api/apex/archive/collect"
18
+ curl "$BASE/api/apex/news?limit=500"</pre><pre>const news = await fetch(`${base}/api/apex/news?limit=500`).then(r=&gt;r.json());
19
+ console.assert(news.coverage.mode === 'FORWARD_COLLECTING_ONLY');</pre><details><summary>Captured provider verification</summary><pre>NewsData: HTTP 200, fetched=10, first saved=10, second saved=0
20
+ CryptoCompare candidate: HTTP 401 Β· NewsAPI.org candidate: HTTP 401
21
+ BitQuery old/new without token: HTTP 401 Β· ClankApp: HTTP 403
22
+ DIY live test: 12 ETH blocks, 2,556 transactions, 2 matches β‰₯50 ETH; 12 BSC blocks, 744 transactions, 0 matches β‰₯500 BNB
23
+ BitQuery signup: https://ide.bitquery.io (time-limited free trial; paid production plans thereafter)
24
+ Whale source: public_evm_rpc_large_native_transfers; forward-only</pre></details></article>
25
+ <h2>All live routes</h2><p>Generated from the deployed OpenAPI contract.</p><div id="routes" class="route-list"><div class="muted">Loading OpenAPI…</div></div><script>fetch('/openapi.json').then(r=>r.json()).then(j=>{routes.innerHTML=Object.entries(j.paths).flatMap(([p,ops])=>Object.entries(ops).filter(([m])=>['get','post','put','delete','patch'].includes(m)).map(([m,o])=>`<div class="route"><b>${m.toUpperCase()}</b> ${p}<br><span class="muted">${o.summary||o.description?.split('\n')[0]||'Registered endpoint'}</span></div>`)).join('')}).catch(e=>routes.textContent='OpenAPI unavailable: '+e.message)</script></main></body></html>
index.html CHANGED
@@ -914,5 +914,9 @@
914
  </main>
915
  </div>
916
  </div>
 
 
 
 
917
  </body>
918
  </html>
 
914
  </main>
915
  </div>
916
  </div>
917
+ <nav aria-label="Historical data tools" style="position:fixed;right:18px;bottom:18px;z-index:9999;display:flex;gap:8px;padding:8px;border:1px solid #ffffff18;border-radius:16px;background:#08111ddd;backdrop-filter:blur(16px);box-shadow:0 16px 50px #0008">
918
+ <a href="/data-lab" style="padding:10px 13px;border-radius:10px;background:linear-gradient(135deg,#45e5d2,#6a8dff);color:#06121a;text-decoration:none;font:700 13px system-ui">Historical Data Lab</a>
919
+ <a href="/help" style="padding:10px 13px;color:#dce9ff;text-decoration:none;font:600 13px system-ui">Data guide</a>
920
+ </nav>
921
  </body>
922
  </html>
requirements.txt CHANGED
@@ -3,7 +3,7 @@ fastapi>=0.110,<1.0
3
  uvicorn[standard]>=0.27,<1.0
4
 
5
  # ── HTTP clients ──────────────────────────────────────────────────────────────
6
- httpx>=0.26,<1.0
7
  requests>=2.31,<3.0
8
  aiohttp>=3.9,<4.0
9
 
@@ -35,4 +35,4 @@ accelerate>=0.27.0
35
 
36
  # ── Optional but IMPORTANT for real inference stability ──────────────────────
37
  sentencepiece>=0.1.99
38
- protobuf>=4.25.0
 
3
  uvicorn[standard]>=0.27,<1.0
4
 
5
  # ── HTTP clients ──────────────────────────────────────────────────────────────
6
+ httpx[socks]>=0.26,<1.0
7
  requests>=2.31,<3.0
8
  aiohttp>=3.9,<4.0
9
 
 
35
 
36
  # ── Optional but IMPORTANT for real inference stability ──────────────────────
37
  sentencepiece>=0.1.99
38
+ protobuf>=4.25.0
static/css/apex-data-lab.css ADDED
@@ -0,0 +1 @@
 
 
1
+ :root{color-scheme:dark;--bg:#070b14;--panel:rgba(18,25,43,.72);--line:rgba(255,255,255,.09);--text:#eef4ff;--muted:#8fa1bc;--cyan:#45e5d2;--violet:#9d7cff;--amber:#ffbd5b;--red:#ff6577}*{box-sizing:border-box}body{margin:0;min-height:100vh;font:15px/1.55 Inter,ui-sans-serif,system-ui;background:radial-gradient(circle at 15% 5%,#132b44 0,transparent 32%),radial-gradient(circle at 90% 10%,#251a4a 0,transparent 28%),var(--bg);color:var(--text)}header,main{width:min(1220px,calc(100% - 32px));margin:auto}header{display:flex;align-items:center;justify-content:space-between;padding:26px 0}.brand{display:flex;gap:12px;align-items:center}.orb{width:38px;height:38px;border-radius:13px;background:linear-gradient(135deg,var(--cyan),var(--violet));box-shadow:0 0 34px #45e5d244}.eyebrow,.meta{color:var(--muted);font-size:12px;letter-spacing:.1em;text-transform:uppercase}h1{font-size:clamp(28px,5vw,54px);line-height:1.02;margin:24px 0 12px;max-width:820px}h2{font-size:18px;margin:0 0 16px}p{color:var(--muted)}nav a{color:var(--text);text-decoration:none;margin-left:20px}.hero{padding:40px 0 28px}.hero p{max-width:720px;font-size:17px}.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:16px}.panel{background:var(--panel);border:1px solid var(--line);border-radius:22px;padding:20px;box-shadow:0 18px 70px #0005;backdrop-filter:blur(18px)}.controls{grid-column:span 4}.chart{grid-column:span 8}.coverage{grid-column:span 12}.form{display:grid;gap:12px}label{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em}input,select,button{width:100%;border:1px solid var(--line);background:#0a1120;color:var(--text);padding:12px 13px;border-radius:12px;font:inherit}button{cursor:pointer;border:0;background:linear-gradient(135deg,var(--cyan),#5ba7ff);color:#04131a;font-weight:800;margin-top:6px}.status{display:flex;gap:8px;align-items:center;margin:14px 0}.dot{width:9px;height:9px;border-radius:50%;background:var(--amber);box-shadow:0 0 16px currentColor}.dot.real{background:var(--cyan)}.dot.bad{background:var(--red)}canvas{width:100%;height:330px;background:linear-gradient(180deg,#0b1425aa,#08101c33);border-radius:14px}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:12px}.stat{background:#09111f;border:1px solid var(--line);border-radius:13px;padding:12px}.stat b{display:block;font-size:18px}.cards{display:grid;grid-template-columns:repeat(5,1fr);gap:10px}.card{border:1px solid var(--line);border-radius:15px;padding:14px;background:#09111d}.badge{display:inline-block;margin-top:8px;padding:5px 8px;border-radius:999px;background:#45e5d21a;color:var(--cyan);font-size:11px}.badge.forward{color:var(--amber);background:#ffbd5b1a}.badge.limited{color:#ff91a0;background:#ff65771a}.error{color:#ff91a0;white-space:pre-wrap}@media(max-width:900px){.controls,.chart{grid-column:span 12}.cards{grid-template-columns:1fr 1fr}.stats{grid-template-columns:1fr}nav{display:none}}@media(max-width:560px){.cards{grid-template-columns:1fr}}
static/js/apex-data-lab.js ADDED
@@ -0,0 +1 @@
 
 
1
+ const $=id=>document.getElementById(id),fmt=ts=>ts?new Date(Number(ts)).toISOString().slice(0,10):'β€”';function draw(rows){const c=$('canvas'),x=c.getContext('2d'),w=c.width,h=c.height;x.clearRect(0,0,w,h);if(!rows.length)return;const vals=rows.map(r=>+r.close),lo=Math.min(...vals),hi=Math.max(...vals),span=hi-lo||1;x.strokeStyle='#45e5d2';x.lineWidth=2;x.beginPath();rows.forEach((r,i)=>{const px=i/(rows.length-1||1)*(w-40)+20,py=h-20-(+r.close-lo)/span*(h-40);i?x.lineTo(px,py):x.moveTo(px,py)});x.stroke();x.fillStyle='#8fa1bc';x.font='12px system-ui';x.fillText(hi.toFixed(2),12,16);x.fillText(lo.toFixed(2),12,h-6)}async function load(){const start=Date.parse($('start').value+'T00:00:00Z'),end=Date.parse($('end').value+'T23:59:59Z'),url=`/api/ohlcv?symbol=${encodeURIComponent($('symbol').value)}&interval=${$('interval').value}&start=${start}&end=${end}&limit=100000`;$('state').textContent='Loading upstream pages…';$('dot').className='dot';$('error').textContent='';try{const r=await fetch(url),j=await r.json();if(!r.ok||!j.success)throw Error(j.detail||j.error||'Empty/unavailable response');const data=j.data||j.candles||[];draw(data);$('rows').textContent=data.length.toLocaleString();$('earliest').textContent=fmt(data[0]?.timestamp);$('latest').textContent=fmt(data.at(-1)?.timestamp);$('state').textContent=`${j.dataState||'REAL'} Β· ${j.source||'provider'} Β· ${j.coverage?.mode||'unknown'}`;$('dot').className='dot real'}catch(e){$('state').textContent='UNAVAILABLE';$('dot').className='dot bad';$('error').textContent=e.message}}$('load').onclick=load;async function coverage(){const j=await fetch('/api/apex/coverage').then(r=>r.json());$('cards').innerHTML=Object.entries(j.components||{}).map(([k,v])=>`<div class="card"><strong>${k}</strong><p>${v.endpoint||''}</p><span class="badge ${v.mode.includes('FORWARD')?'forward':v.mode.includes('LIMITED')?'limited':''}">${v.mode}</span></div>`).join('')}coverage();load();
tests/test_apex_history.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ import api_compat_routes as compat
4
+
5
+
6
+ @pytest.mark.asyncio
7
+ async def test_compat_binance_range_pages_forward(monkeypatch):
8
+ calls = []
9
+
10
+ async def fake_json(url, params=None, timeout=0):
11
+ calls.append(dict(params or {}))
12
+ start = (params or {}).get("startTime", 1000)
13
+ return [[start + i * 1000, "1", "2", ".5", "1.5", "10"] for i in range((params or {})["limit"])], None, 1
14
+
15
+ monkeypatch.setattr(compat, "_get_json", fake_json)
16
+ rows, errors = await compat._fetch_binance_klines("BTCUSDT", "1h", 2000, 1000, 3_000_000)
17
+ assert not errors
18
+ assert len(rows) == 2000
19
+ assert rows[0]["timestamp"] == 1000
20
+ assert calls[1]["startTime"] == 1_000_001
21
+
22
+
23
+ def test_coverage_labels_separate_historical_oi_from_forward_only_events():
24
+ from fastapi.testclient import TestClient
25
+ from fastapi import FastAPI
26
+ from apex_strategy_routes import router
27
+
28
+ app = FastAPI()
29
+ app.include_router(router)
30
+ body = TestClient(app).get("/api/apex/coverage").json()
31
+ assert body["components"]["openInterest"]["mode"] == "REAL_180_DAY_UPSTREAM_PLUS_FORWARD_ARCHIVE"
32
+ assert body["components"]["news"]["mode"] == "FORWARD_COLLECTING_ONLY"
33
+ assert body["components"]["whaleFlow"]["mode"] == "FORWARD_COLLECTING_ONLY"
34
+
35
+
36
+ def test_open_interest_archive_upserts_by_symbol_timestamp(monkeypatch, tmp_path):
37
+ import apex_strategy_routes as apex
38
+ monkeypatch.setattr(apex, "DB_PATH", tmp_path / "apex.db")
39
+ row = {"symbol": "BTCUSDT", "timestamp": 1234, "sumOpenInterest": 1.0, "sumOpenInterestValue": 2.0}
40
+ apex._save_oi([row])
41
+ apex._save_oi([{**row, "sumOpenInterest": 3.0}])
42
+ result = apex._read_oi("BTCUSDT", None, None, 100)
43
+ assert result["count"] == 1
44
+ assert result["data"][0]["sumOpenInterest"] == 3.0
45
+ assert result["coverage"]["preDeploymentHistoryAvailable"] is False
46
+
47
+
48
+ def test_whale_archive_dedupes_by_chain_and_transaction_hash(monkeypatch, tmp_path):
49
+ import apex_strategy_routes as apex
50
+ monkeypatch.setattr(apex, "DB_PATH", tmp_path / "apex.db")
51
+ event = {"txHash": "0xabc", "chain": "ethereum", "symbol": "ETH", "amount": 51, "eventTime": 1234}
52
+ assert apex._save("whaleFlow", "public_evm_rpc", [event]) == 1
53
+ assert apex._save("whaleFlow", "public_evm_rpc", [event]) == 0
54
+ assert apex._archive_count("whaleFlow") == 1