Dmitry Beresnev commited on
Commit
abd065f
·
1 Parent(s): 180f1d8

add core logic

Browse files
.gitignore ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ *.manifest
31
+ *.spec
32
+
33
+ # Installer logs
34
+ pip-log.txt
35
+ pip-delete-this-directory.txt
36
+
37
+ # Unit test / coverage reports
38
+ htmlcov/
39
+ .tox/
40
+ .nox/
41
+ .coverage
42
+ .coverage.*
43
+ .cache
44
+ nosetests.xml
45
+ coverage.xml
46
+ *.cover
47
+ *.py,cover
48
+ .hypothesis/
49
+ .pytest_cache/
50
+ cover/
51
+
52
+ # Translations
53
+ *.mo
54
+ *.pot
55
+
56
+ # Django stuff:
57
+ *.log
58
+ local_settings.py
59
+ db.sqlite3
60
+ db.sqlite3-journal
61
+
62
+ # Flask stuff:
63
+ instance/
64
+ .webassets-cache
65
+
66
+ # Scrapy stuff:
67
+ .scrapy
68
+
69
+ # Sphinx documentation
70
+ _docs/_build/
71
+
72
+ # PyBuilder
73
+ .pybuilder/
74
+
75
+ # Jupyter Notebook
76
+ .ipynb_checkpoints
77
+
78
+ # IPython
79
+ profile_default/
80
+ ipython_config.py
81
+
82
+ # PyCharm
83
+ .idea/
84
+
85
+ # VS Code
86
+ .vscode/
87
+
88
+ # Rope
89
+ .ropeproject
90
+
91
+ # mkdocs documentation
92
+ /site
93
+
94
+ # mypy
95
+ .mypy_cache/
96
+ .dmypy.json
97
+ dmypy.json
98
+
99
+ # Pyre
100
+ .pyre/
101
+
102
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow
103
+ __pypackages__/
104
+
105
+ # Celery stuff
106
+ celerybeat-schedule
107
+ celerybeat.pid
108
+
109
+ # SageMath parsed files
110
+ *.sage.py
111
+
112
+ # Environments
113
+ .env
114
+ .env.*
115
+ .venv
116
+ venv/
117
+ ENV/
118
+ env/
119
+
120
+ # Spyder project settings
121
+ .spyderproject
122
+ .spyproject
123
+
124
+ # Rope project settings
125
+ .ropeproject
126
+
127
+ # mrc
128
+ .mypy_cache/
129
+
130
+ # PyInstaller
131
+ *.manifest
132
+ *.spec
133
+
134
+ # Logs
135
+ logs/
136
+
137
+ # OS files
138
+ .DS_Store
139
+ Thumbs.db
140
+
141
+ # Temporary files
142
+ *.tmp
143
+ *.temp
144
+ *.swp
145
+
146
+ # Caches
147
+ .cache/
148
+
149
+ # Poetry
150
+ poetry.lock
151
+
152
+ # Ruff
153
+ .ruff_cache/
154
+
155
+ # Pyright
156
+ .pyright/
157
+
158
+ # uv
159
+ .uv/
160
+
161
+ # Data files
162
+ *.csv
163
+ *.parquet
164
+ *.feather
165
+
166
+ # Model checkpoints
167
+ *.ckpt
README.md CHANGED
@@ -9,4 +9,40 @@ license: apache-2.0
9
  short_description: Market Analyzing Platform - Notifications, Tracking, etc
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  short_description: Market Analyzing Platform - Notifications, Tracking, etc
10
  ---
11
 
12
+ ## POC Overview
13
+ This POC implements a market-wide risk radar using:
14
+ - Universe loader for tickers (unofficial lists supported)
15
+ - Price ingestion via `yfinance`
16
+ - Price Delta Calculator (drop by default)
17
+ - Adaptive thresholds, event detection, and alerting
18
+ - In-memory pub/sub topics
19
+ - Process pool for price loading
20
+
21
+ ## Topics
22
+ - `universe.updated`
23
+ - `prices.snapshot`
24
+ - `price.delta.calculated`
25
+ - `event.detected`
26
+ - `alert.ready`
27
+
28
+ ## Run
29
+ ```bash
30
+ python -m src.main
31
+ ```
32
+
33
+ ## Notes
34
+ - `yfinance` is an unofficial data source and can break or rate-limit.
35
+ - Process pool parallelizes price fetching to speed up ingestion.
36
+ - Universe loader pulls:
37
+ - US equities from `rreichel3/US-Stock-Symbols` (unofficial)
38
+ - EU equities from Wikipedia index constituents (FTSE 100, DAX, CAC 40)
39
+ - Crypto from Binance `exchangeInfo`
40
+ - Commodities from a curated Yahoo Finance futures list
41
+
42
+ ## Telegram Bot
43
+ Set environment variables:
44
+ ```bash
45
+ export TELEGRAM_BOT_TOKEN=...
46
+ export TELEGRAM_CHAT_ID=...
47
+ ```
48
+ ```
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ yfinance
2
+ pandas
3
+ numpy
4
+ requests
5
+ lxml
src/core/__init__.py ADDED
File without changes
src/core/alerting.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections import defaultdict
2
+ from typing import Dict
3
+ import time
4
+ import uuid
5
+ import os
6
+
7
+ import requests
8
+
9
+ from .config import MIN_ALERT_GAP_MIN, TELEGRAM_BOT_TOKEN_ENV, TELEGRAM_CHAT_ID_ENV
10
+
11
+
12
+ class AlertDeduplicator:
13
+ def __init__(self) -> None:
14
+ self._last_ts = defaultdict(lambda: defaultdict(int))
15
+
16
+ def allow(self, ticker: str, timeframe: str) -> bool:
17
+ now = int(time.time())
18
+ last = self._last_ts[ticker][timeframe]
19
+ if now - last < MIN_ALERT_GAP_MIN * 60:
20
+ return False
21
+ self._last_ts[ticker][timeframe] = now
22
+ return True
23
+
24
+
25
+ def classify(timeframe_minutes: int) -> str:
26
+ return "flash" if timeframe_minutes <= 30 else "slow"
27
+
28
+
29
+ def build_alert(
30
+ ticker: str,
31
+ asset_class: str,
32
+ venue: str,
33
+ timeframe: str,
34
+ alert_type: str,
35
+ delta_pct: float,
36
+ threshold: float,
37
+ ) -> Dict:
38
+ return {
39
+ "event_id": str(uuid.uuid4()),
40
+ "ts": int(time.time() * 1000),
41
+ "ticker": ticker,
42
+ "asset_class": asset_class,
43
+ "venue": venue,
44
+ "timeframe": timeframe,
45
+ "alert_type": alert_type,
46
+ "delta_pct": round(delta_pct, 4),
47
+ "threshold": round(threshold, 4),
48
+ "cooldown_sec": MIN_ALERT_GAP_MIN * 60,
49
+ "destination": "telegram",
50
+ "message": (
51
+ f"[{alert_type.upper()}] {ticker} {timeframe} "
52
+ f"drop {delta_pct:.2f}% (thr {threshold:.2f}%)"
53
+ ),
54
+ }
55
+
56
+
57
+ class TelegramNotifier:
58
+ def __init__(self) -> None:
59
+ self._token = os.getenv(TELEGRAM_BOT_TOKEN_ENV)
60
+ self._chat_id = os.getenv(TELEGRAM_CHAT_ID_ENV)
61
+
62
+ def send(self, alert: Dict) -> None:
63
+ if not self._token or not self._chat_id:
64
+ print(alert["message"])
65
+ return
66
+ url = f"https://api.telegram.org/bot{self._token}/sendMessage"
67
+ payload = {"chat_id": self._chat_id, "text": alert["message"]}
68
+ try:
69
+ requests.post(url, json=payload, timeout=10)
70
+ except Exception:
71
+ print(alert["message"])
src/core/config.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ TIMEFRAMES_MIN = {
2
+ "10m": 10,
3
+ "30m": 30,
4
+ "1h": 60,
5
+ "2h": 120,
6
+ "3h": 180,
7
+ "6h": 360,
8
+ "10h": 600,
9
+ }
10
+
11
+ K_SIGMA = 3.0
12
+ MIN_ALERT_GAP_MIN = 20
13
+
14
+ POLL_INTERVAL_SEC = 60
15
+
16
+ # Process pool for price loading
17
+ PROCESS_POOL_WORKERS = 4
18
+ BATCH_SIZE = 200
19
+
20
+ # Universe settings
21
+ ASSET_CLASSES = ["equity", "crypto", "commodity"]
22
+
23
+ # Adaptive threshold parameters
24
+ MIN_WINDOW_POINTS = 5
25
+
26
+ # Universe sources (no-auth)
27
+ US_TICKER_SOURCES = {
28
+ "us.nasdaq": "https://raw.githubusercontent.com/rreichel3/US-Stock-Symbols/main/nasdaq/nasdaq_tickers.txt",
29
+ "us.nyse": "https://raw.githubusercontent.com/rreichel3/US-Stock-Symbols/main/nyse/nyse_tickers.txt",
30
+ "us.amex": "https://raw.githubusercontent.com/rreichel3/US-Stock-Symbols/main/amex/amex_tickers.txt",
31
+ }
32
+
33
+ EU_WIKI_INDEX_SOURCES = {
34
+ "eu.ftse100": {
35
+ "url": "https://en.wikipedia.org/wiki/FTSE_100_Index",
36
+ "column": "EPIC",
37
+ "suffix": ".L",
38
+ },
39
+ "eu.dax": {
40
+ "url": "https://en.wikipedia.org/wiki/DAX",
41
+ "column": "Ticker symbol",
42
+ "suffix": ".DE",
43
+ },
44
+ "eu.cac40": {
45
+ "url": "https://en.wikipedia.org/wiki/CAC_40",
46
+ "column": "Ticker",
47
+ "suffix": ".PA",
48
+ },
49
+ }
50
+
51
+ BINANCE_EXCHANGE_INFO = "https://api.binance.com/api/v3/exchangeInfo"
52
+
53
+ COMMODITY_TICKERS = [
54
+ "GC=F", # Gold
55
+ "SI=F", # Silver
56
+ "CL=F", # WTI Crude
57
+ "BZ=F", # Brent Crude
58
+ "NG=F", # Natural Gas
59
+ "HG=F", # Copper
60
+ "ZC=F", # Corn
61
+ "ZW=F", # Wheat
62
+ "ZS=F", # Soybeans
63
+ ]
64
+
65
+ # Telegram bot (no auth on load; uses env vars at runtime)
66
+ TELEGRAM_BOT_TOKEN_ENV = "TELEGRAM_BOT_TOKEN"
67
+ TELEGRAM_CHAT_ID_ENV = "TELEGRAM_CHAT_ID"
src/core/event_detector.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Optional
2
+ import numpy as np
3
+
4
+ from .config import K_SIGMA, MIN_WINDOW_POINTS
5
+
6
+
7
+ def adaptive_threshold(window) -> Optional[float]:
8
+ if len(window) < MIN_WINDOW_POINTS:
9
+ return None
10
+ prices = np.array(window, dtype=float)
11
+ returns = np.diff(prices) / prices[:-1]
12
+ if returns.size == 0:
13
+ return None
14
+ sigma = float(np.std(returns))
15
+ return -K_SIGMA * sigma * 100.0
16
+
17
+
18
+ def detect_event(delta_pct: float, threshold: float) -> bool:
19
+ # Both are negative for drops
20
+ return delta_pct < threshold
21
+
22
+
23
+ def severity(delta_pct: float, threshold: float) -> str:
24
+ ratio = abs(delta_pct) / max(abs(threshold), 1e-9)
25
+ if ratio >= 2.0:
26
+ return "high"
27
+ if ratio >= 1.3:
28
+ return "med"
29
+ return "low"
src/core/main.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import uuid
3
+
4
+ from .config import TIMEFRAMES_MIN
5
+ from .pubsub import PubSub
6
+ from .universe_loader import UniverseLoader
7
+ from .price_ingestor import PriceIngestor
8
+ from .price_buffer import PriceBuffer
9
+ from .price_delta import price_delta, velocity_pct_per_min
10
+ from .event_detector import adaptive_threshold, detect_event, severity
11
+ from .alerting import AlertDeduplicator, classify, build_alert, TelegramNotifier
12
+
13
+
14
+ TOPICS = {
15
+ "universe": "universe.updated",
16
+ "prices": "prices.snapshot",
17
+ "delta": "price.delta.calculated",
18
+ "event": "event.detected",
19
+ "alert": "alert.ready",
20
+ }
21
+
22
+
23
+ def run_once():
24
+ pubsub = PubSub()
25
+ buffer = PriceBuffer()
26
+ dedupe = AlertDeduplicator()
27
+ notifier = TelegramNotifier()
28
+
29
+ pubsub.subscribe(TOPICS["alert"], notifier.send)
30
+
31
+ loader = UniverseLoader()
32
+ universe = loader.load()
33
+
34
+ for venue_key, tickers in universe.items():
35
+ if not tickers:
36
+ continue
37
+ asset_class = _infer_asset_class(venue_key)
38
+ venue = venue_key
39
+ pubsub.publish(TOPICS["universe"], loader.build_event(asset_class, venue, tickers))
40
+
41
+ ingestor = PriceIngestor(venue=venue, asset_class=asset_class)
42
+ price_event = ingestor.fetch_prices(tickers)
43
+ pubsub.publish(TOPICS["prices"], price_event)
44
+
45
+ for p in price_event["prices"]:
46
+ buffer.update(p["ticker"], p["price"])
47
+
48
+ for ticker in tickers:
49
+ for tf, minutes in TIMEFRAMES_MIN.items():
50
+ window = buffer.window(ticker, tf)
51
+ delta = price_delta(window)
52
+ if not delta:
53
+ continue
54
+ delta_pct = delta["delta_pct"]
55
+ vel = velocity_pct_per_min(delta_pct, minutes)
56
+
57
+ delta_event = {
58
+ "event_id": str(uuid.uuid4()),
59
+ "ts": int(time.time() * 1000),
60
+ "ticker": ticker,
61
+ "asset_class": asset_class,
62
+ "venue": venue,
63
+ "timeframe": tf,
64
+ **delta,
65
+ "velocity_pct_per_min": vel,
66
+ "window_points": len(window),
67
+ }
68
+ pubsub.publish(TOPICS["delta"], delta_event)
69
+
70
+ thr = adaptive_threshold(window)
71
+ if thr is None:
72
+ continue
73
+ if detect_event(delta_pct, thr):
74
+ if not dedupe.allow(ticker, tf):
75
+ continue
76
+ event = {
77
+ "event_id": str(uuid.uuid4()),
78
+ "ts": int(time.time() * 1000),
79
+ "ticker": ticker,
80
+ "asset_class": asset_class,
81
+ "venue": venue,
82
+ "timeframe": tf,
83
+ "delta_pct": delta_pct,
84
+ "threshold": thr,
85
+ "threshold_type": "adaptive",
86
+ "severity": severity(delta_pct, thr),
87
+ }
88
+ pubsub.publish(TOPICS["event"], event)
89
+
90
+ alert_type = classify(minutes)
91
+ alert = build_alert(
92
+ ticker,
93
+ asset_class,
94
+ venue,
95
+ tf,
96
+ alert_type,
97
+ delta_pct,
98
+ thr,
99
+ )
100
+ pubsub.publish(TOPICS["alert"], alert)
101
+
102
+
103
+
104
+ def _infer_asset_class(venue_key: str) -> str:
105
+ if "crypto" in venue_key:
106
+ return "crypto"
107
+ if "commodities" in venue_key:
108
+ return "commodity"
109
+ return "equity"
110
+
111
+
112
+ if __name__ == "__main__":
113
+ while True:
114
+ run_once()
115
+ time.sleep(60)
src/core/price_buffer.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections import deque
2
+ from typing import Dict, Deque, List
3
+
4
+ from .config import TIMEFRAMES_MIN
5
+
6
+
7
+ class PriceBuffer:
8
+ def __init__(self) -> None:
9
+ self._data: Dict[str, Dict[str, Deque[float]]] = {}
10
+
11
+ def update(self, ticker: str, price: float) -> None:
12
+ if ticker not in self._data:
13
+ self._data[ticker] = {
14
+ tf: deque(maxlen=window)
15
+ for tf, window in TIMEFRAMES_MIN.items()
16
+ }
17
+ for tf in self._data[ticker]:
18
+ self._data[ticker][tf].append(price)
19
+
20
+ def window(self, ticker: str, tf: str) -> List[float]:
21
+ if ticker not in self._data or tf not in self._data[ticker]:
22
+ return []
23
+ return list(self._data[ticker][tf])
src/core/price_delta.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Optional
2
+
3
+
4
+ def price_delta(window) -> Optional[Dict[str, float]]:
5
+ if len(window) < 2:
6
+ return None
7
+ start = window[0]
8
+ now = window[-1]
9
+ if start == 0:
10
+ return None
11
+ delta_abs = now - start
12
+ delta_pct = (delta_abs / start) * 100.0
13
+ return {
14
+ "price_start": float(start),
15
+ "price_now": float(now),
16
+ "delta_abs": float(delta_abs),
17
+ "delta_pct": float(delta_pct),
18
+ }
19
+
20
+
21
+ def velocity_pct_per_min(delta_pct: float, minutes: int) -> float:
22
+ if minutes <= 0:
23
+ return 0.0
24
+ return delta_pct / float(minutes)
src/core/price_ingestor.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from concurrent.futures import ProcessPoolExecutor, as_completed
2
+ from typing import Dict, List
3
+ import time
4
+ import uuid
5
+
6
+ from .config import BATCH_SIZE, PROCESS_POOL_WORKERS
7
+
8
+
9
+ def _fetch_chunk(tickers: List[str]) -> Dict[str, Dict]:
10
+ import yfinance as yf
11
+ import pandas as pd
12
+
13
+ if not tickers:
14
+ return {}
15
+
16
+ data = yf.download(
17
+ tickers=" ".join(tickers),
18
+ period="1d",
19
+ interval="1m",
20
+ group_by="ticker",
21
+ progress=False,
22
+ threads=False,
23
+ )
24
+
25
+ result: Dict[str, Dict] = {}
26
+
27
+ if isinstance(data.columns, pd.MultiIndex):
28
+ for ticker in tickers:
29
+ if ticker not in data.columns.get_level_values(0):
30
+ continue
31
+ df = data[ticker].dropna()
32
+ if df.empty:
33
+ continue
34
+ price = float(df["Close"].iloc[-1])
35
+ ts_price = int(df.index[-1].timestamp() * 1000)
36
+ result[ticker] = {"price": price, "ts_price": ts_price}
37
+ else:
38
+ df = data.dropna()
39
+ if not df.empty:
40
+ price = float(df["Close"].iloc[-1])
41
+ ts_price = int(df.index[-1].timestamp() * 1000)
42
+ result[tickers[0]] = {"price": price, "ts_price": ts_price}
43
+
44
+ return result
45
+
46
+
47
+ def _chunk_list(items: List[str], size: int) -> List[List[str]]:
48
+ return [items[i : i + size] for i in range(0, len(items), size)]
49
+
50
+
51
+ class PriceIngestor:
52
+ def __init__(self, venue: str, asset_class: str) -> None:
53
+ self.venue = venue
54
+ self.asset_class = asset_class
55
+
56
+ def fetch_prices(self, tickers: List[str]) -> Dict:
57
+ chunks = _chunk_list(tickers, BATCH_SIZE)
58
+ prices: Dict[str, Dict] = {}
59
+
60
+ if PROCESS_POOL_WORKERS <= 1 or len(chunks) <= 1:
61
+ for chunk in chunks:
62
+ prices.update(_fetch_chunk(chunk))
63
+ else:
64
+ with ProcessPoolExecutor(max_workers=PROCESS_POOL_WORKERS) as pool:
65
+ futures = [pool.submit(_fetch_chunk, chunk) for chunk in chunks]
66
+ for f in as_completed(futures):
67
+ prices.update(f.result())
68
+
69
+ return {
70
+ "event_id": str(uuid.uuid4()),
71
+ "ts": int(time.time() * 1000),
72
+ "asset_class": self.asset_class,
73
+ "venue": self.venue,
74
+ "is_delayed": False,
75
+ "prices": [
76
+ {
77
+ "ticker": t,
78
+ "price": p["price"],
79
+ "ts_price": p["ts_price"],
80
+ }
81
+ for t, p in prices.items()
82
+ ],
83
+ }
src/core/pubsub.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections import defaultdict
2
+ from typing import Callable, Dict, List, Any
3
+
4
+
5
+ class PubSub:
6
+ def __init__(self) -> None:
7
+ self._subs: Dict[str, List[Callable[[Any], None]]] = defaultdict(list)
8
+
9
+ def subscribe(self, topic: str, handler: Callable[[Any], None]) -> None:
10
+ self._subs[topic].append(handler)
11
+
12
+ def publish(self, topic: str, message: Any) -> None:
13
+ for handler in self._subs.get(topic, []):
14
+ handler(message)
src/core/universe_loader.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, List, Set
2
+ import time
3
+ import uuid
4
+
5
+ import pandas as pd
6
+ import requests
7
+
8
+ from .config import (
9
+ US_TICKER_SOURCES,
10
+ EU_WIKI_INDEX_SOURCES,
11
+ BINANCE_EXCHANGE_INFO,
12
+ COMMODITY_TICKERS,
13
+ )
14
+
15
+
16
+ class UniverseLoader:
17
+ def __init__(self, timeout_sec: int = 20) -> None:
18
+ self._version = "v1"
19
+ self._timeout = timeout_sec
20
+
21
+ def load(self) -> Dict[str, List[str]]:
22
+ us = self._load_us_equities()
23
+ eu = self._load_eu_equities()
24
+ crypto = self._load_crypto()
25
+ commodities = sorted(set(COMMODITY_TICKERS))
26
+
27
+ return {
28
+ "us.equities": sorted(us),
29
+ "eu.equities": sorted(eu),
30
+ "crypto": sorted(crypto),
31
+ "commodities": commodities,
32
+ }
33
+
34
+ def build_event(self, asset_class: str, venue: str, tickers: List[str]) -> Dict:
35
+ return {
36
+ "event_id": str(uuid.uuid4()),
37
+ "ts": int(time.time() * 1000),
38
+ "asset_class": asset_class,
39
+ "venue": venue,
40
+ "universe_version": self._version,
41
+ "tickers": tickers,
42
+ "changes": {"added": tickers, "removed": []},
43
+ }
44
+
45
+ def _load_us_equities(self) -> Set[str]:
46
+ tickers: Set[str] = set()
47
+ for _, url in US_TICKER_SOURCES.items():
48
+ try:
49
+ resp = requests.get(url, timeout=self._timeout)
50
+ resp.raise_for_status()
51
+ for line in resp.text.splitlines():
52
+ t = line.strip().upper()
53
+ if not t or t.startswith("#"):
54
+ continue
55
+ tickers.add(t)
56
+ except Exception:
57
+ continue
58
+ return tickers
59
+
60
+ def _load_eu_equities(self) -> Set[str]:
61
+ tickers: Set[str] = set()
62
+ for _, cfg in EU_WIKI_INDEX_SOURCES.items():
63
+ url = cfg["url"]
64
+ column = cfg["column"]
65
+ suffix = cfg["suffix"]
66
+ try:
67
+ tables = pd.read_html(url)
68
+ except Exception:
69
+ continue
70
+ for table in tables:
71
+ if column not in table.columns:
72
+ continue
73
+ series = table[column].astype(str).str.strip()
74
+ for raw in series.tolist():
75
+ if not raw or raw == "nan":
76
+ continue
77
+ sym = raw.split(" ")[0].strip()
78
+ if not sym:
79
+ continue
80
+ if not sym.endswith(suffix):
81
+ sym = f"{sym}{suffix}"
82
+ tickers.add(sym)
83
+ break
84
+ return tickers
85
+
86
+ def _load_crypto(self) -> Set[str]:
87
+ tickers: Set[str] = set()
88
+ try:
89
+ resp = requests.get(BINANCE_EXCHANGE_INFO, timeout=self._timeout)
90
+ resp.raise_for_status()
91
+ data = resp.json()
92
+ for item in data.get("symbols", []):
93
+ if item.get("status") != "TRADING":
94
+ continue
95
+ quote = item.get("quoteAsset")
96
+ base = item.get("baseAsset")
97
+ if quote in {"USD", "USDT", "USDC", "BUSD"} and base:
98
+ tickers.add(f"{base}-USD")
99
+ except Exception:
100
+ return tickers
101
+ return tickers