ferrox-py 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- examples/data_platform/main.py +47 -0
- examples/data_platform/modules/analytics/quant_controller.py +75 -0
- examples/data_platform/modules/analytics/quant_service.py +86 -0
- examples/data_platform/modules/catalog/catalog_controller.py +40 -0
- examples/data_platform/modules/etl/etl_controller.py +51 -0
- examples/data_platform/modules/ingestion/ingestion_controller.py +30 -0
- examples/data_platform/modules/ingestion/ingestion_service.py +47 -0
- examples/data_platform/modules/live_data/live_controller.py +23 -0
- examples/data_platform/modules/live_data/live_service.py +70 -0
- examples/data_platform/modules/streaming/binance_ingestor.py +86 -0
- examples/data_platform/modules/streaming/data_contracts.py +42 -0
- examples/data_platform/static/index.html +394 -0
- ferrox_py/__init__.py +1 -0
- ferrox_py/abstractions/__init__.py +1 -0
- ferrox_py/abstractions/crud_generator.py +30 -0
- ferrox_py/abstractions/guards_advanced.py +9 -0
- ferrox_py/abstractions/interceptors.py +23 -0
- ferrox_py/abstractions/pipes.py +21 -0
- ferrox_py/architectures/__init__.py +1 -0
- ferrox_py/architectures/api_gateway.py +34 -0
- ferrox_py/architectures/events.py +16 -0
- ferrox_py/architectures/queues_jobs.py +45 -0
- ferrox_py/architectures/sagas.py +38 -0
- ferrox_py/architectures/scheduling.py +21 -0
- ferrox_py/cli/__init__.py +1 -0
- ferrox_py/cli/code_factory.py +2 -0
- ferrox_py/cli/commands.py +33 -0
- ferrox_py/core/__init__.py +1 -0
- ferrox_py/core/app.py +24 -0
- ferrox_py/core/config.py +19 -0
- ferrox_py/core/container.py +51 -0
- ferrox_py/core/controllers.py +16 -0
- ferrox_py/core/errors.py +16 -0
- ferrox_py/core/module.py +17 -0
- ferrox_py/core/provider.py +19 -0
- ferrox_py/core/testing.py +19 -0
- ferrox_py/cqrs/__init__.py +1 -0
- ferrox_py/cqrs/bus.py +29 -0
- ferrox_py/databases/__init__.py +1 -0
- ferrox_py/databases/migrations.py +7 -0
- ferrox_py/databases/mongodb.py +14 -0
- ferrox_py/databases/redis.py +24 -0
- ferrox_py/databases/sqlalchemy.py +17 -0
- ferrox_py/integrations/__init__.py +1 -0
- ferrox_py/integrations/cloud.py +10 -0
- ferrox_py/integrations/feature_flags.py +9 -0
- ferrox_py/integrations/i18n.py +11 -0
- ferrox_py/integrations/mailer.py +33 -0
- ferrox_py/integrations/notifications.py +9 -0
- ferrox_py/integrations/payments.py +31 -0
- ferrox_py/integrations/search.py +9 -0
- ferrox_py/integrations/webhooks.py +12 -0
- ferrox_py/observability/__init__.py +1 -0
- ferrox_py/observability/health.py +14 -0
- ferrox_py/observability/logging.py +20 -0
- ferrox_py/observability/metrics.py +20 -0
- ferrox_py/observability/tracing.py +22 -0
- ferrox_py/pipelines/__init__.py +4 -0
- ferrox_py/pipelines/dag.py +72 -0
- ferrox_py/pipelines/node.py +31 -0
- ferrox_py/resilience/__init__.py +1 -0
- ferrox_py/resilience/circuit_breaker.py +37 -0
- ferrox_py/security/__init__.py +1 -0
- ferrox_py/security/advanced_auth.py +25 -0
- ferrox_py/security/distributed_locks.py +40 -0
- ferrox_py/security/headers.py +20 -0
- ferrox_py/security/jwt.py +51 -0
- ferrox_py/security/mtd.py +18 -0
- ferrox_py/security/paseto.py +17 -0
- ferrox_py/security/rate_limiting.py +38 -0
- ferrox_py/security/selftest.py +6 -0
- ferrox_py/security/sentinel.py +21 -0
- ferrox_py/transports/__init__.py +1 -0
- ferrox_py/transports/datagrid.py +33 -0
- ferrox_py/transports/file_storage.py +24 -0
- ferrox_py/transports/graphql.py +11 -0
- ferrox_py/transports/sse.py +17 -0
- ferrox_py/web/__init__.py +1 -0
- ferrox_py/web/decorators.py +29 -0
- ferrox_py/web/guards.py +14 -0
- ferrox_py-1.0.0.dist-info/METADATA +95 -0
- ferrox_py-1.0.0.dist-info/RECORD +90 -0
- ferrox_py-1.0.0.dist-info/WHEEL +4 -0
- ferrox_py-1.0.0.dist-info/entry_points.txt +2 -0
- infrastructure/README.md +69 -0
- infrastructure/terraform/aws/main.tf +89 -0
- infrastructure/terraform/aws/variables.tf +14 -0
- infrastructure/terraform/gcp/main.tf +64 -0
- infrastructure/terraform/gcp/variables.tf +8 -0
- infrastructure/terraform/vps/main.tf +117 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import uvicorn
|
|
2
|
+
from fastapi import FastAPI
|
|
3
|
+
from fastapi.staticfiles import StaticFiles
|
|
4
|
+
from fastapi.responses import FileResponse
|
|
5
|
+
from ferrox_py.core.container import Container
|
|
6
|
+
from modules.ingestion.ingestion_controller import IngestionController
|
|
7
|
+
from modules.catalog.catalog_controller import CatalogController
|
|
8
|
+
from modules.live_data.live_controller import LiveController
|
|
9
|
+
from modules.etl.etl_controller import EtlController
|
|
10
|
+
from modules.analytics.quant_controller import QuantController
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
def create_app() -> FastAPI:
|
|
14
|
+
app = FastAPI(title="Ferrox Data Platform")
|
|
15
|
+
|
|
16
|
+
# Mount Static Files for the Dashboard
|
|
17
|
+
static_dir = os.path.join(os.path.dirname(__file__), "static")
|
|
18
|
+
if os.path.exists(static_dir):
|
|
19
|
+
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
|
20
|
+
|
|
21
|
+
@app.get("/")
|
|
22
|
+
async def serve_dashboard():
|
|
23
|
+
return FileResponse(os.path.join(static_dir, "index.html"))
|
|
24
|
+
|
|
25
|
+
# 1. Boot IoC Container
|
|
26
|
+
container = Container()
|
|
27
|
+
|
|
28
|
+
# 2. Register Controllers
|
|
29
|
+
ingest = IngestionController(container)
|
|
30
|
+
catalog = CatalogController(container)
|
|
31
|
+
live = LiveController(container)
|
|
32
|
+
etl = EtlController(container)
|
|
33
|
+
quant = QuantController(container)
|
|
34
|
+
|
|
35
|
+
# 3. Mount Routes
|
|
36
|
+
app.include_router(ingest.router)
|
|
37
|
+
app.include_router(catalog.router)
|
|
38
|
+
app.include_router(live.router)
|
|
39
|
+
app.include_router(etl.router)
|
|
40
|
+
app.include_router(quant.router)
|
|
41
|
+
|
|
42
|
+
return app
|
|
43
|
+
|
|
44
|
+
app = create_app()
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from fastapi import APIRouter
|
|
2
|
+
from ferrox_py.pipelines.node import PipelineNode
|
|
3
|
+
from ferrox_py.pipelines.dag import DAG
|
|
4
|
+
from .quant_service import QuantitativeAnalyticsService
|
|
5
|
+
import httpx
|
|
6
|
+
import random
|
|
7
|
+
|
|
8
|
+
class QuantController:
|
|
9
|
+
def __init__(self, container):
|
|
10
|
+
self.router = APIRouter(prefix="/analytics", tags=["Quantitative Analysis"])
|
|
11
|
+
self.container = container
|
|
12
|
+
|
|
13
|
+
if QuantitativeAnalyticsService not in self.container._providers:
|
|
14
|
+
self.container._providers[QuantitativeAnalyticsService] = QuantitativeAnalyticsService
|
|
15
|
+
|
|
16
|
+
self.setup_routes()
|
|
17
|
+
|
|
18
|
+
def setup_routes(self):
|
|
19
|
+
@self.router.get("/signals")
|
|
20
|
+
async def get_market_signals():
|
|
21
|
+
"""
|
|
22
|
+
Uses the DAG Orchestrator to build a real-time signal report.
|
|
23
|
+
1. Extract BTC/ETH data
|
|
24
|
+
2. Compute Z-Scores and Correlats
|
|
25
|
+
3. Compute Order Book Imbalance
|
|
26
|
+
"""
|
|
27
|
+
service: QuantitativeAnalyticsService = self.container.resolve(QuantitativeAnalyticsService)
|
|
28
|
+
|
|
29
|
+
dag = DAG(name="Quant-Signals-Pipeline")
|
|
30
|
+
|
|
31
|
+
# --- NODE 1: Fetch Live Market Context ---
|
|
32
|
+
async def fetch_context():
|
|
33
|
+
async with httpx.AsyncClient() as client:
|
|
34
|
+
res = await client.get("https://api.binance.com/api/v3/ticker/bookTicker?symbol=BTCUSDT")
|
|
35
|
+
data = res.json()
|
|
36
|
+
# Convert to depth format: [[price, qty]]
|
|
37
|
+
bids = [[float(data["bidPrice"]), float(data["bidQty"])]]
|
|
38
|
+
asks = [[float(data["askPrice"]), float(data["askQty"])]]
|
|
39
|
+
|
|
40
|
+
# Randomize a massive trade volume for demo purposes sometimes
|
|
41
|
+
sim_trade_vol = random.choice([0.1, 0.5, 1.2, 5.0, 50.0, 150.0])
|
|
42
|
+
return {"bids": bids, "asks": asks, "last_trade_vol": sim_trade_vol, "price": float(data["bidPrice"])}
|
|
43
|
+
|
|
44
|
+
extract_node = PipelineNode("extract_market", fetch_context)
|
|
45
|
+
|
|
46
|
+
# --- NODE 2: OBI Analysis ---
|
|
47
|
+
def compute_obi(extract_res):
|
|
48
|
+
data = extract_res["extract_market"]
|
|
49
|
+
return service.compute_order_book_imbalance(data["bids"], data["asks"])
|
|
50
|
+
|
|
51
|
+
obi_node = PipelineNode("obi_analysis", lambda: None) # We will inject logic manually or just use closure
|
|
52
|
+
|
|
53
|
+
# Since our simple DAG engine doesn't inject results into kwargs yet, we use a shared state or closure
|
|
54
|
+
# For this demo, we can just run it inline
|
|
55
|
+
|
|
56
|
+
async def run_analysis():
|
|
57
|
+
context = await fetch_context()
|
|
58
|
+
|
|
59
|
+
obi = service.compute_order_book_imbalance(context["bids"], context["asks"])
|
|
60
|
+
whale = service.detect_whale_anomalies(context["price"], context["last_trade_vol"])
|
|
61
|
+
|
|
62
|
+
# Simulate BTC/ETH prices over last 10 ticks for correlation
|
|
63
|
+
btc_prices = [context["price"] * (1 + random.uniform(-0.01, 0.01)) for _ in range(10)]
|
|
64
|
+
eth_prices = [3000 * (1 + random.uniform(-0.01, 0.01)) for _ in range(10)]
|
|
65
|
+
corr = service.compute_cross_asset_correlation(btc_prices, eth_prices)
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
"asset": "BTC",
|
|
69
|
+
"order_book_imbalance": obi,
|
|
70
|
+
"whale_detection": whale,
|
|
71
|
+
"btc_eth_correlation": corr
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# Execute directly for the API response
|
|
75
|
+
return await run_analysis()
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import numpy as np
|
|
3
|
+
import random
|
|
4
|
+
from typing import Dict, Any, List
|
|
5
|
+
from structlog import get_logger
|
|
6
|
+
|
|
7
|
+
logger = get_logger()
|
|
8
|
+
|
|
9
|
+
class QuantitativeAnalyticsService:
|
|
10
|
+
"""
|
|
11
|
+
Computes statistical anomalies and order book imbalances.
|
|
12
|
+
In a real scenario, this extracts the last N minutes of data from Redis.
|
|
13
|
+
For this demo, we simulate the historical distribution based on current data.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def compute_order_book_imbalance(self, bids: List[List[float]], asks: List[List[float]]) -> Dict[str, Any]:
|
|
17
|
+
"""
|
|
18
|
+
OBI = (BidVolume - AskVolume) / (BidVolume + AskVolume)
|
|
19
|
+
Values near +1 indicate heavy buy-side spoofing/pressure.
|
|
20
|
+
Values near -1 indicate heavy sell-side spoofing/pressure.
|
|
21
|
+
"""
|
|
22
|
+
if not bids or not asks:
|
|
23
|
+
return {"obi": 0.0, "status": "Neutral", "spoofing_detected": False}
|
|
24
|
+
|
|
25
|
+
bid_vol = sum(price * qty for price, qty in bids)
|
|
26
|
+
ask_vol = sum(price * qty for price, qty in asks)
|
|
27
|
+
|
|
28
|
+
total = bid_vol + ask_vol
|
|
29
|
+
if total == 0:
|
|
30
|
+
return {"obi": 0.0, "status": "Neutral", "spoofing_detected": False}
|
|
31
|
+
|
|
32
|
+
obi = (bid_vol - ask_vol) / total
|
|
33
|
+
|
|
34
|
+
status = "Neutral"
|
|
35
|
+
spoofing = False
|
|
36
|
+
if obi > 0.7:
|
|
37
|
+
status = "Extreme Buy Pressure"
|
|
38
|
+
spoofing = obi > 0.9
|
|
39
|
+
elif obi < -0.7:
|
|
40
|
+
status = "Extreme Sell Pressure"
|
|
41
|
+
spoofing = obi < -0.9
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
"obi": round(obi, 4),
|
|
45
|
+
"status": status,
|
|
46
|
+
"spoofing_detected": spoofing,
|
|
47
|
+
"bid_volume": round(bid_vol, 2),
|
|
48
|
+
"ask_volume": round(ask_vol, 2)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
def detect_whale_anomalies(self, current_price: float, current_volume: float) -> Dict[str, Any]:
|
|
52
|
+
"""
|
|
53
|
+
Computes Z-Score against a simulated historical rolling window.
|
|
54
|
+
Z = (x - mean) / std_dev
|
|
55
|
+
If Z > 3, it's a massive anomaly (Whale).
|
|
56
|
+
"""
|
|
57
|
+
# Simulate last 100 trades volume distribution (Log-normal distribution typical for crypto volumes)
|
|
58
|
+
simulated_history = np.random.lognormal(mean=np.log(current_volume*0.2), sigma=0.8, size=100)
|
|
59
|
+
df = pd.DataFrame(simulated_history, columns=['volume'])
|
|
60
|
+
|
|
61
|
+
mean_vol = df['volume'].mean()
|
|
62
|
+
std_vol = df['volume'].std()
|
|
63
|
+
|
|
64
|
+
# Inject the current volume to see how it compares
|
|
65
|
+
z_score = (current_volume - mean_vol) / (std_vol if std_vol > 0 else 1)
|
|
66
|
+
|
|
67
|
+
is_whale = z_score > 3.0
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
"z_score": round(z_score, 2),
|
|
71
|
+
"mean_volume": round(mean_vol, 2),
|
|
72
|
+
"is_whale_anomaly": bool(is_whale),
|
|
73
|
+
"severity": "CRITICAL" if z_score > 5 else ("HIGH" if is_whale else "NORMAL")
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
def compute_cross_asset_correlation(self, asset1_prices: List[float], asset2_prices: List[float]) -> float:
|
|
77
|
+
"""
|
|
78
|
+
Pearson correlation coefficient between two assets.
|
|
79
|
+
"""
|
|
80
|
+
if len(asset1_prices) != len(asset2_prices) or len(asset1_prices) < 2:
|
|
81
|
+
return 0.0
|
|
82
|
+
|
|
83
|
+
df = pd.DataFrame({'a1': asset1_prices, 'a2': asset2_prices})
|
|
84
|
+
corr = df['a1'].corr(df['a2'])
|
|
85
|
+
|
|
86
|
+
return round(corr, 4) if not pd.isna(corr) else 0.0
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from fastapi import APIRouter
|
|
2
|
+
from ferrox_py.core.controllers import BaseController
|
|
3
|
+
from ferrox_py.core.container import Container
|
|
4
|
+
from ferrox_py.transports.datagrid import DataGridHelper
|
|
5
|
+
from ferrox_py.databases.mongodb import MongoService
|
|
6
|
+
|
|
7
|
+
class CatalogController(BaseController):
|
|
8
|
+
def __init__(self, container: Container):
|
|
9
|
+
super().__init__(prefix="/catalog", tags=["Catalog"])
|
|
10
|
+
self.container = container
|
|
11
|
+
|
|
12
|
+
# Lazy dependency injection
|
|
13
|
+
# (In a real app, container resolves these automatically via @module)
|
|
14
|
+
self.datagrid = DataGridHelper()
|
|
15
|
+
self.mongo = MongoService()
|
|
16
|
+
|
|
17
|
+
@self.router.get("/datasets")
|
|
18
|
+
async def list_datasets(skip: int = 0, take: int = 10, sort: str = None, filter: str = None):
|
|
19
|
+
"""
|
|
20
|
+
Queries the Data Catalog metadata using DataGrid abstractions.
|
|
21
|
+
"""
|
|
22
|
+
query_params = {"skip": skip, "take": take}
|
|
23
|
+
if sort: query_params["sort"] = sort
|
|
24
|
+
if filter: query_params["filter"] = filter
|
|
25
|
+
|
|
26
|
+
ast = self.datagrid.parse_query(query_params)
|
|
27
|
+
|
|
28
|
+
# Simulated Mongo cursor execution
|
|
29
|
+
# db = self.mongo.get_database("data_platform")
|
|
30
|
+
# cursor = db.catalog.find(ast.filters).skip(ast.skip).limit(ast.take)
|
|
31
|
+
|
|
32
|
+
return self.ok({
|
|
33
|
+
"query_ast": {
|
|
34
|
+
"skip": ast.skip,
|
|
35
|
+
"take": ast.take,
|
|
36
|
+
"filters": ast.filters,
|
|
37
|
+
"sort": ast.sort
|
|
38
|
+
},
|
|
39
|
+
"results": ["dataset_weather", "dataset_finance"]
|
|
40
|
+
}, "Catalog queried successfully")
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from fastapi import APIRouter
|
|
3
|
+
from ferrox_py.pipelines.node import PipelineNode
|
|
4
|
+
from ferrox_py.pipelines.dag import DAG
|
|
5
|
+
|
|
6
|
+
class EtlController:
|
|
7
|
+
def __init__(self, container):
|
|
8
|
+
self.router = APIRouter(prefix="/etl", tags=["Data Pipelines (DAGs)"])
|
|
9
|
+
self.container = container
|
|
10
|
+
self.setup_routes()
|
|
11
|
+
|
|
12
|
+
def setup_routes(self):
|
|
13
|
+
@self.router.get("/trigger")
|
|
14
|
+
async def trigger_pipeline():
|
|
15
|
+
"""
|
|
16
|
+
Demonstrates Ferrox-Py's internal DAG engine for ETL workloads.
|
|
17
|
+
"""
|
|
18
|
+
dag = DAG(name="Daily-Aggregations")
|
|
19
|
+
|
|
20
|
+
# Create nodes (Steps in ETL)
|
|
21
|
+
extract_users = PipelineNode("extract_users", lambda: {"users": 5000})
|
|
22
|
+
extract_sales = PipelineNode("extract_sales", lambda: {"sales": 120000})
|
|
23
|
+
|
|
24
|
+
async def transform_data():
|
|
25
|
+
await asyncio.sleep(0.1) # simulate heavy transformation
|
|
26
|
+
return {"transformed": True}
|
|
27
|
+
|
|
28
|
+
transform = PipelineNode("transform", transform_data)
|
|
29
|
+
transform.depends_on("extract_users", "extract_sales")
|
|
30
|
+
|
|
31
|
+
async def load_to_warehouse():
|
|
32
|
+
await asyncio.sleep(0.1) # simulate db insert
|
|
33
|
+
return {"loaded": True}
|
|
34
|
+
|
|
35
|
+
load = PipelineNode("load", load_to_warehouse)
|
|
36
|
+
load.depends_on("transform")
|
|
37
|
+
|
|
38
|
+
# Register in DAG
|
|
39
|
+
dag.add_node(extract_users)
|
|
40
|
+
dag.add_node(extract_sales)
|
|
41
|
+
dag.add_node(transform)
|
|
42
|
+
dag.add_node(load)
|
|
43
|
+
|
|
44
|
+
# Execute the graph
|
|
45
|
+
results = await dag.execute()
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
"message": "ETL Pipeline Executed Successfully",
|
|
49
|
+
"dag_name": dag.name,
|
|
50
|
+
"execution_graph": results
|
|
51
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from fastapi import Request
|
|
2
|
+
from ferrox_py.core.controllers import BaseController
|
|
3
|
+
from ferrox_py.core.container import Container
|
|
4
|
+
from ferrox_py.security.rate_limiting import RateLimiter
|
|
5
|
+
from .ingestion_service import IngestionService
|
|
6
|
+
|
|
7
|
+
class IngestionController(BaseController):
|
|
8
|
+
def __init__(self, container: Container):
|
|
9
|
+
super().__init__(prefix="/ingest", tags=["Ingestion"])
|
|
10
|
+
|
|
11
|
+
self.service = IngestionService()
|
|
12
|
+
self.rate_limiter = RateLimiter()
|
|
13
|
+
|
|
14
|
+
@self.router.post("/trigger/weather")
|
|
15
|
+
async def trigger_weather_etl(request: Request):
|
|
16
|
+
"""
|
|
17
|
+
Triggers a data pipeline to fetch weather data.
|
|
18
|
+
Protected by Token Bucket Rate Limiting.
|
|
19
|
+
"""
|
|
20
|
+
client_ip = request.client.host if request.client else "unknown"
|
|
21
|
+
|
|
22
|
+
# Rate limit: 2 requests per second
|
|
23
|
+
allowed = await self.rate_limiter.is_allowed(f"etl_{client_ip}", capacity=2, refill_rate=1.0)
|
|
24
|
+
if not allowed:
|
|
25
|
+
return {"status": "error", "message": "Too Many Requests", "code": 429}
|
|
26
|
+
|
|
27
|
+
# Execute Pipeline
|
|
28
|
+
result = await self.service.run_weather_pipeline()
|
|
29
|
+
|
|
30
|
+
return self.ok(result, "Pipeline executed successfully")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from ferrox_py_utils.pipelines.orchestrator import PipelineOrchestrator, PipelineStep
|
|
2
|
+
from ferrox_py_utils.schemas.registry import SchemaRegistry
|
|
3
|
+
from ferrox_py.core.provider import injectable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
@injectable()
|
|
7
|
+
class IngestionService:
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self.orchestrator = PipelineOrchestrator()
|
|
10
|
+
self.registry = SchemaRegistry()
|
|
11
|
+
|
|
12
|
+
# Register dynamic schema for weather metrics
|
|
13
|
+
self.registry.register_schema("WeatherMetric", {
|
|
14
|
+
"city": (str, ...),
|
|
15
|
+
"temperature": (float, ...),
|
|
16
|
+
"humidity": (int, ...)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
async def _extract(self, payload: Any) -> Any:
|
|
20
|
+
# Simulate downloading data from HTTP Connector
|
|
21
|
+
print("Extracting data...")
|
|
22
|
+
return {"city": "Milan", "temperature": 22.5, "humidity": "60"} # humidity is string, should be casted to int
|
|
23
|
+
|
|
24
|
+
async def _transform(self, data: Any) -> Any:
|
|
25
|
+
print("Transforming and Validating data...")
|
|
26
|
+
# Validate against dynamic schema
|
|
27
|
+
clean_data = self.registry.validate("WeatherMetric", data)
|
|
28
|
+
return clean_data
|
|
29
|
+
|
|
30
|
+
async def _load(self, data: Any) -> Any:
|
|
31
|
+
print("Loading data into MongoDB...")
|
|
32
|
+
# Simulate Mongo load
|
|
33
|
+
return {"inserted_id": "5f1b2c3d", "data": data}
|
|
34
|
+
|
|
35
|
+
async def run_weather_pipeline(self) -> dict:
|
|
36
|
+
steps = [
|
|
37
|
+
PipelineStep("Extract", self._extract),
|
|
38
|
+
PipelineStep("Transform", self._transform),
|
|
39
|
+
PipelineStep("Load", self._load)
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
result = await self.orchestrator.execute_pipeline(
|
|
43
|
+
name="Weather_ETL",
|
|
44
|
+
initial_data=None,
|
|
45
|
+
steps=steps
|
|
46
|
+
)
|
|
47
|
+
return result
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends
|
|
2
|
+
from .live_service import LiveDataAggregatorService
|
|
3
|
+
|
|
4
|
+
class LiveController:
|
|
5
|
+
def __init__(self, container):
|
|
6
|
+
self.router = APIRouter(prefix="/live", tags=["Live Data Platform Demo"])
|
|
7
|
+
self.container = container
|
|
8
|
+
|
|
9
|
+
# Register the service in the IoC Container dynamically for the demo
|
|
10
|
+
if LiveDataAggregatorService not in self.container._providers:
|
|
11
|
+
self.container._providers[LiveDataAggregatorService] = LiveDataAggregatorService
|
|
12
|
+
|
|
13
|
+
self.setup_routes()
|
|
14
|
+
|
|
15
|
+
def setup_routes(self):
|
|
16
|
+
@self.router.get("/dashboard")
|
|
17
|
+
async def get_live_dashboard():
|
|
18
|
+
"""
|
|
19
|
+
Fetches real-time aggregated data from Binance, GitHub, and Open-Meteo.
|
|
20
|
+
Demonstrates Ferrox-Py's async I/O capabilities.
|
|
21
|
+
"""
|
|
22
|
+
service: LiveDataAggregatorService = self.container.resolve(LiveDataAggregatorService)
|
|
23
|
+
return await service.get_aggregated_dashboard()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import asyncio
|
|
3
|
+
from structlog import get_logger
|
|
4
|
+
|
|
5
|
+
logger = get_logger()
|
|
6
|
+
|
|
7
|
+
class LiveDataAggregatorService:
|
|
8
|
+
"""
|
|
9
|
+
Demonstrates Ferrox-Py's capability to act as a high-performance Data Platform Edge.
|
|
10
|
+
Fetches data concurrently from multiple public, free APIs.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
async def fetch_crypto_price(self, client: httpx.AsyncClient) -> dict:
|
|
17
|
+
try:
|
|
18
|
+
# Binance public API (no key required for ticker)
|
|
19
|
+
res = await client.get("https://api.binance.com/api/v3/ticker/price?symbol=BTCUSDT")
|
|
20
|
+
res.raise_for_status()
|
|
21
|
+
data = res.json()
|
|
22
|
+
return {"asset": "BTC", "price_usd": float(data["price"])}
|
|
23
|
+
except Exception as e:
|
|
24
|
+
logger.error("failed_fetch_crypto", error=str(e))
|
|
25
|
+
return {"asset": "BTC", "price_usd": None, "error": "API Unavailable"}
|
|
26
|
+
|
|
27
|
+
async def fetch_github_status(self, client: httpx.AsyncClient) -> dict:
|
|
28
|
+
try:
|
|
29
|
+
# GitHub public API
|
|
30
|
+
res = await client.get("https://www.githubstatus.com/api/v2/status.json")
|
|
31
|
+
res.raise_for_status()
|
|
32
|
+
data = res.json()
|
|
33
|
+
return {"system": "GitHub", "status": data.get("status", {}).get("description")}
|
|
34
|
+
except Exception as e:
|
|
35
|
+
logger.error("failed_fetch_github", error=str(e))
|
|
36
|
+
return {"system": "GitHub", "status": "Unknown"}
|
|
37
|
+
|
|
38
|
+
async def fetch_weather_ny(self, client: httpx.AsyncClient) -> dict:
|
|
39
|
+
try:
|
|
40
|
+
# Open-Meteo (Free, no key)
|
|
41
|
+
res = await client.get("https://api.open-meteo.com/v1/forecast?latitude=40.7143&longitude=-74.006¤t_weather=true")
|
|
42
|
+
res.raise_for_status()
|
|
43
|
+
data = res.json()
|
|
44
|
+
current = data.get("current_weather", {})
|
|
45
|
+
return {"city": "New York", "temperature_celsius": current.get("temperature"), "windspeed": current.get("windspeed")}
|
|
46
|
+
except Exception as e:
|
|
47
|
+
logger.error("failed_fetch_weather", error=str(e))
|
|
48
|
+
return {"city": "New York", "error": "Weather API Unavailable"}
|
|
49
|
+
|
|
50
|
+
async def get_aggregated_dashboard(self) -> dict:
|
|
51
|
+
"""
|
|
52
|
+
Aggregates data using asyncio.gather for lightning-fast concurrent I/O.
|
|
53
|
+
"""
|
|
54
|
+
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
55
|
+
crypto_task = self.fetch_crypto_price(client)
|
|
56
|
+
github_task = self.fetch_github_status(client)
|
|
57
|
+
weather_task = self.fetch_weather_ny(client)
|
|
58
|
+
|
|
59
|
+
# Execute all external calls concurrently (Scatter-Gather pattern)
|
|
60
|
+
results = await asyncio.gather(crypto_task, github_task, weather_task)
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
"platform": "Ferrox-Py Data Aggregator",
|
|
64
|
+
"status": "Operational",
|
|
65
|
+
"metrics": {
|
|
66
|
+
"markets": results[0],
|
|
67
|
+
"dev_ops": results[1],
|
|
68
|
+
"environment": results[2]
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import websockets
|
|
4
|
+
import redis.asyncio as redis
|
|
5
|
+
from pydantic import ValidationError
|
|
6
|
+
from structlog import get_logger
|
|
7
|
+
from .data_contracts import BinanceTradeContract, BinanceDepthContract, DataLineage
|
|
8
|
+
|
|
9
|
+
logger = get_logger()
|
|
10
|
+
|
|
11
|
+
# Multiplexed Binance WebSocket for multi-asset Trades & OrderBooks
|
|
12
|
+
# We ingest Trades for BTC, ETH, SOL (for Correlation & Whales)
|
|
13
|
+
# We ingest Depth5 for BTC, ETH, SOL (for OrderBook Imbalance/Spoofing)
|
|
14
|
+
STREAMS = [
|
|
15
|
+
"btcusdt@trade", "ethusdt@trade", "solusdt@trade",
|
|
16
|
+
"btcusdt@depth5@100ms", "ethusdt@depth5@100ms", "solusdt@depth5@100ms"
|
|
17
|
+
]
|
|
18
|
+
BINANCE_WS_URL = f"wss://stream.binance.com:9443/stream?streams={'/'.join(STREAMS)}"
|
|
19
|
+
|
|
20
|
+
REDIS_STREAM_TRADES = "ferrox:ingestion:crypto_trades"
|
|
21
|
+
REDIS_STREAM_DEPTH = "ferrox:ingestion:crypto_depth"
|
|
22
|
+
|
|
23
|
+
async def run_ingestion_engine():
|
|
24
|
+
logger.info("Starting Multi-Asset High-Throughput Ingestion Engine...")
|
|
25
|
+
|
|
26
|
+
r = redis.Redis(host='localhost', port=6379, decode_responses=True, socket_connect_timeout=1.0)
|
|
27
|
+
use_redis = True
|
|
28
|
+
try:
|
|
29
|
+
await asyncio.wait_for(r.ping(), timeout=2.0)
|
|
30
|
+
logger.info("Connected to Redis for Stream Backpressure.")
|
|
31
|
+
except Exception:
|
|
32
|
+
logger.warning("Redis not found or timed out. Running in Simulation/Print mode.")
|
|
33
|
+
use_redis = False
|
|
34
|
+
|
|
35
|
+
async for websocket in websockets.connect(BINANCE_WS_URL):
|
|
36
|
+
logger.info("Connected to Binance Multiplexed WebSocket", streams=len(STREAMS))
|
|
37
|
+
try:
|
|
38
|
+
async for message in websocket:
|
|
39
|
+
payload = json.loads(message)
|
|
40
|
+
stream_name = payload.get("stream")
|
|
41
|
+
raw_data = payload.get("data")
|
|
42
|
+
|
|
43
|
+
if not stream_name or not raw_data:
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
lineage = DataLineage(source_system="Binance_WS_API")
|
|
47
|
+
|
|
48
|
+
# Route based on stream type
|
|
49
|
+
try:
|
|
50
|
+
if "@trade" in stream_name:
|
|
51
|
+
contract = BinanceTradeContract(**raw_data)
|
|
52
|
+
contract.lineage = lineage
|
|
53
|
+
|
|
54
|
+
if use_redis:
|
|
55
|
+
await r.xadd(REDIS_STREAM_TRADES, {"payload": contract.model_dump_json()}, maxlen=100000)
|
|
56
|
+
else:
|
|
57
|
+
logger.info("trade_ingested", symbol=contract.symbol, price=contract.price)
|
|
58
|
+
|
|
59
|
+
elif "@depth5" in stream_name:
|
|
60
|
+
# Depth payload doesn't contain symbol, we extract it from stream name (e.g. btcusdt@depth5)
|
|
61
|
+
symbol = stream_name.split('@')[0].upper()
|
|
62
|
+
raw_data['symbol'] = symbol
|
|
63
|
+
contract = BinanceDepthContract(**raw_data)
|
|
64
|
+
contract.lineage = lineage
|
|
65
|
+
|
|
66
|
+
if use_redis:
|
|
67
|
+
await r.xadd(REDIS_STREAM_DEPTH, {"payload": contract.model_dump_json()}, maxlen=50000)
|
|
68
|
+
else:
|
|
69
|
+
# Log just top bid/ask to avoid console spam
|
|
70
|
+
top_bid = contract.bids[0][0] if contract.bids else 0
|
|
71
|
+
top_ask = contract.asks[0][0] if contract.asks else 0
|
|
72
|
+
logger.info("depth_ingested", symbol=contract.symbol, top_bid=top_bid, top_ask=top_ask)
|
|
73
|
+
|
|
74
|
+
except ValidationError as e:
|
|
75
|
+
logger.error("schema_drift_detected", stream=stream_name, error=str(e))
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
except websockets.ConnectionClosed:
|
|
79
|
+
logger.warning("WebSocket Connection Closed. Reconnecting...")
|
|
80
|
+
continue
|
|
81
|
+
except Exception as e:
|
|
82
|
+
logger.error("fatal_ingestion_error", error=str(e))
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
asyncio.run(run_ingestion_engine())
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field, field_validator
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Optional, List
|
|
4
|
+
|
|
5
|
+
class DataLineage(BaseModel):
|
|
6
|
+
"""
|
|
7
|
+
Solves Point 3 (Lineage & Governance):
|
|
8
|
+
Every event MUST carry its origin, ingestion timestamp, and processor version.
|
|
9
|
+
"""
|
|
10
|
+
source_system: str
|
|
11
|
+
ingested_at: datetime = Field(default_factory=datetime.utcnow)
|
|
12
|
+
processor_version: str = "v1.1.0"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BinanceTradeContract(BaseModel):
|
|
16
|
+
"""
|
|
17
|
+
Validates real-time Trade executions.
|
|
18
|
+
"""
|
|
19
|
+
event_type: str = Field(alias="e")
|
|
20
|
+
event_time: int = Field(alias="E")
|
|
21
|
+
symbol: str = Field(alias="s")
|
|
22
|
+
trade_id: int = Field(alias="t")
|
|
23
|
+
price: float = Field(alias="p")
|
|
24
|
+
quantity: float = Field(alias="q")
|
|
25
|
+
is_buyer_maker: bool = Field(alias="m")
|
|
26
|
+
|
|
27
|
+
lineage: Optional[DataLineage] = None
|
|
28
|
+
|
|
29
|
+
@field_validator("price", "quantity", mode="before")
|
|
30
|
+
def parse_floats(cls, v):
|
|
31
|
+
return float(v)
|
|
32
|
+
|
|
33
|
+
class BinanceDepthContract(BaseModel):
|
|
34
|
+
"""
|
|
35
|
+
Validates Order Book Depth (Top 5 Bids/Asks) for detecting Spoofing and Imbalances.
|
|
36
|
+
"""
|
|
37
|
+
last_update_id: int = Field(alias="lastUpdateId")
|
|
38
|
+
bids: List[List[float]]
|
|
39
|
+
asks: List[List[float]]
|
|
40
|
+
symbol: str = "UNKNOWN" # Injected by the multiplexer
|
|
41
|
+
|
|
42
|
+
lineage: Optional[DataLineage] = None
|