mirror of
https://github.com/QuantEngines/fx_quant_engine.git
synced 2026-07-27 18:37:47 +00:00
Initial commit: fx_quant_engine and options_quant_engine scaffold
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
name: fx-quant-engine-ci
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "fx_quant_engine/**"
|
||||
- "config/**"
|
||||
- "tests/**"
|
||||
- "scripts/**"
|
||||
- "pyproject.toml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "fx_quant_engine/**"
|
||||
- "config/**"
|
||||
- "tests/**"
|
||||
- "scripts/**"
|
||||
- "pyproject.toml"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
lint-test-build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e '.[dev]'
|
||||
|
||||
- name: Lint
|
||||
run: ruff check .
|
||||
|
||||
- name: Test
|
||||
run: pytest
|
||||
|
||||
- name: Generate sample run artifact
|
||||
run: python scripts/run_engine.py
|
||||
|
||||
- name: Upload run artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fx-engine-runs
|
||||
path: examples/runs/*.json
|
||||
if-no-files-found: warn
|
||||
retention-days: 30
|
||||
@@ -0,0 +1,44 @@
|
||||
name: fx-quant-engine-release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-release-artifacts:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install build tools
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install build twine
|
||||
|
||||
- name: Build wheel and sdist
|
||||
run: python -m build
|
||||
|
||||
- name: Verify artifacts
|
||||
run: twine check dist/*
|
||||
|
||||
- name: Upload release distribution artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fx-quant-engine-dist
|
||||
path: dist/*
|
||||
retention-days: 90
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: dist/*
|
||||
generate_release_notes: true
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.so
|
||||
*.egg-info/
|
||||
*.egg
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Local run artifacts
|
||||
examples/runs/
|
||||
options_quant_engine/examples/runs/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -0,0 +1,193 @@
|
||||
# fx_quant_engine
|
||||
|
||||
A professional, modular, explainable, and production-ready starter repository for an FX quant signal engine focused on India-first constraints with global extensibility.
|
||||
|
||||
## What This Engine Does
|
||||
|
||||
- Ingests FX/macro/rate data from multiple pluggable adapters
|
||||
- Uses configurable source priority and fallback logic per asset
|
||||
- Engineers economically meaningful features through a registry-based pipeline
|
||||
- Detects interpretable market regimes
|
||||
- Generates directional and relative-value FX signals
|
||||
- Separates signal strength from confidence
|
||||
- Applies risk overlays and outputs action recommendations
|
||||
- Supports evaluation and realistic backtest scaffolding
|
||||
- Exports integration hooks for cross-engine multi-asset platforms
|
||||
|
||||
## Asset Universe
|
||||
|
||||
Primary INR pairs:
|
||||
|
||||
- USDINR
|
||||
- EURINR
|
||||
- GBPINR
|
||||
- JPYINR
|
||||
|
||||
Secondary G10 pairs:
|
||||
|
||||
- EURUSD
|
||||
- GBPUSD
|
||||
- USDJPY
|
||||
- AUDUSD
|
||||
- USDCAD
|
||||
- USDCHF
|
||||
|
||||
## Repository Structure
|
||||
|
||||
- fx_quant_engine/ingestion: adapters + fallback router
|
||||
- fx_quant_engine/preprocessing: data cleaning/alignment
|
||||
- fx_quant_engine/features: registry + modular feature pipeline
|
||||
- fx_quant_engine/regime: explainable regime detection
|
||||
- fx_quant_engine/models: model scoring and extension points
|
||||
- fx_quant_engine/signals: directional/RV signal + confidence engine
|
||||
- fx_quant_engine/risk: risk overlays and action mapping
|
||||
- fx_quant_engine/evaluation: signal quality diagnostics
|
||||
- fx_quant_engine/backtest: no-lookahead pair/portfolio simulation
|
||||
- fx_quant_engine/outputs: machine-readable + trader-readable payloads
|
||||
- fx_quant_engine/integration: cross-engine score exports
|
||||
- config: YAML-driven behavior
|
||||
- scripts: runnable examples
|
||||
- tests: unit tests
|
||||
- examples: sample payloads and run artifacts
|
||||
|
||||
## Data Adapter Architecture
|
||||
|
||||
Base interface:
|
||||
|
||||
```python
|
||||
class BaseDataAdapter:
|
||||
def fetch_price_data(self, asset, start, end):
|
||||
pass
|
||||
|
||||
def fetch_macro_data(self, key, start, end):
|
||||
pass
|
||||
|
||||
def fetch_rate_data(self, asset, start, end):
|
||||
pass
|
||||
|
||||
def health_check(self):
|
||||
pass
|
||||
```
|
||||
|
||||
Included adapters:
|
||||
|
||||
- BreezeAdapter (ICICI Breeze)
|
||||
- ZerodhaAdapter (optional via config)
|
||||
- NSEAdapter
|
||||
- RBIAdapter
|
||||
- FreeFXAdapter
|
||||
- MockAdapter
|
||||
|
||||
Live API wiring uses strict vendor-specific parsers with schema validation and supports:
|
||||
|
||||
- FXE_HTTP_TIMEOUT_SEC
|
||||
- FXE_HTTP_MAX_ATTEMPTS
|
||||
- FXE_HTTP_BACKOFF_SEC
|
||||
- BREEZE_LIVE_ENABLED, BREEZE_BASE_URL, BREEZE_API_KEY, BREEZE_API_SECRET
|
||||
- ZERODHA_LIVE_ENABLED, ZERODHA_BASE_URL, ZERODHA_API_KEY, ZERODHA_ACCESS_TOKEN
|
||||
- NSE_LIVE_ENABLED, NSE_BASE_URL
|
||||
- RBI_LIVE_ENABLED, RBI_BASE_URL
|
||||
|
||||
Router safeguards include adapter-level circuit breakers and source cooldown windows.
|
||||
|
||||
## Config-Driven Files
|
||||
|
||||
- config/universe.yaml
|
||||
- config/data_sources.yaml
|
||||
- config/features.yaml
|
||||
- config/regimes.yaml
|
||||
- config/models.yaml
|
||||
- config/risk.yaml
|
||||
- config/output.yaml
|
||||
|
||||
## Quickstart
|
||||
|
||||
1. Create and activate a Python 3.10+ environment.
|
||||
1. Install package and dev dependencies:
|
||||
|
||||
```bash
|
||||
pip install -e '.[dev]'
|
||||
```
|
||||
|
||||
1. Run tests:
|
||||
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
|
||||
1. Run engine example:
|
||||
|
||||
```bash
|
||||
python scripts/run_engine.py
|
||||
```
|
||||
|
||||
The script saves artifacts to examples/runs.
|
||||
|
||||
## Signal Output Schema
|
||||
|
||||
Each signal includes:
|
||||
|
||||
- direction
|
||||
- signal strength
|
||||
- confidence (separate module)
|
||||
- regime context
|
||||
- expected volatility
|
||||
- key drivers
|
||||
- risk flags
|
||||
- recommended action
|
||||
- position sizing multiplier
|
||||
|
||||
See examples/sample_signal.json.
|
||||
|
||||
## Relative Value Spread Model
|
||||
|
||||
Relative-value generation uses:
|
||||
|
||||
- explicit hedge ratio estimation from aligned log prices
|
||||
- spread z-score component
|
||||
- pair-level momentum and carry differentials
|
||||
- risk-overlayed RV signal output with `hedge_ratio` and `spread_zscore`
|
||||
|
||||
## Release Workflow
|
||||
|
||||
Automated release workflow is available in `.github/workflows/release.yml`.
|
||||
|
||||
- triggers on version tags like `v0.1.0`
|
||||
- builds wheel + source distribution
|
||||
- validates artifacts via twine
|
||||
- publishes GitHub release assets with retention policy
|
||||
|
||||
## Build Phases Coverage
|
||||
|
||||
Phase 1:
|
||||
|
||||
- repo structure
|
||||
- config system
|
||||
- ingestion + preprocessing
|
||||
- basic features
|
||||
|
||||
Phase 2:
|
||||
|
||||
- regime detection
|
||||
- signal engine
|
||||
- confidence engine
|
||||
- outputs
|
||||
|
||||
Phase 3:
|
||||
|
||||
- risk layer
|
||||
- evaluation framework
|
||||
- backtesting scaffolding
|
||||
|
||||
Phase 4 starter:
|
||||
|
||||
- model extension hooks
|
||||
- integration hooks
|
||||
- tests
|
||||
- docs
|
||||
|
||||
## Notes
|
||||
|
||||
- No credentials are hardcoded.
|
||||
- Live API integration can be added by replacing adapter internals while preserving interfaces.
|
||||
- Mock pathways are included for deterministic testing and offline development.
|
||||
@@ -0,0 +1,54 @@
|
||||
enabled_sources:
|
||||
breeze: true
|
||||
zerodha: false
|
||||
nse: true
|
||||
rbi: true
|
||||
freefx: true
|
||||
mock: true
|
||||
|
||||
source_priority:
|
||||
default: [nse, rbi, freefx, mock]
|
||||
g10: [freefx, mock]
|
||||
|
||||
asset_source_map:
|
||||
USDINR: [nse, breeze, rbi, mock]
|
||||
EURINR: [nse, breeze, freefx, mock]
|
||||
GBPINR: [nse, freefx, mock]
|
||||
JPYINR: [nse, freefx, mock]
|
||||
EURUSD: [freefx, mock]
|
||||
GBPUSD: [freefx, mock]
|
||||
USDJPY: [freefx, mock]
|
||||
AUDUSD: [freefx, mock]
|
||||
USDCAD: [freefx, mock]
|
||||
USDCHF: [freefx, mock]
|
||||
|
||||
reliability_tags:
|
||||
breeze: medium
|
||||
zerodha: medium
|
||||
nse: high
|
||||
rbi: high
|
||||
freefx: medium
|
||||
mock: low
|
||||
|
||||
latency_tags_ms:
|
||||
breeze: 350
|
||||
zerodha: 250
|
||||
nse: 500
|
||||
rbi: 800
|
||||
freefx: 700
|
||||
mock: 1
|
||||
|
||||
live_api:
|
||||
breeze_live_enabled: false
|
||||
zerodha_live_enabled: false
|
||||
nse_live_enabled: false
|
||||
rbi_live_enabled: false
|
||||
|
||||
http_controls:
|
||||
timeout_sec: 5
|
||||
max_attempts: 3
|
||||
backoff_sec: 0.5
|
||||
|
||||
circuit_breaker:
|
||||
failure_threshold: 3
|
||||
cooldown_seconds: 120
|
||||
@@ -0,0 +1,28 @@
|
||||
features:
|
||||
returns: true
|
||||
moving_averages: true
|
||||
breakouts: true
|
||||
trend_strength: true
|
||||
momentum_multi_horizon: true
|
||||
momentum_persistence: true
|
||||
momentum_acceleration: true
|
||||
mean_reversion_zscore: true
|
||||
bollinger_position: true
|
||||
realized_vol: true
|
||||
atr_proxy: true
|
||||
vol_regime_proxy: true
|
||||
carry_proxy: true
|
||||
usd_strength_score: true
|
||||
risk_on_off_proxy: true
|
||||
inr_crude_proxy: true
|
||||
em_stress_proxy: true
|
||||
inr_vol_clustering: true
|
||||
rbi_intervention_proxy: true
|
||||
cross_asset_bond_proxy: true
|
||||
cross_asset_commodity_proxy: true
|
||||
cross_asset_equity_proxy: true
|
||||
|
||||
windows:
|
||||
short: 5
|
||||
medium: 20
|
||||
long: 60
|
||||
@@ -0,0 +1,22 @@
|
||||
directional:
|
||||
model_weights:
|
||||
trend: 0.35
|
||||
momentum: 0.25
|
||||
mean_reversion: 0.15
|
||||
carry: 0.10
|
||||
macro: 0.15
|
||||
|
||||
relative_value:
|
||||
lookback: 60
|
||||
spread_zscore_threshold: 1.0
|
||||
model_weights:
|
||||
spread: 0.5
|
||||
momentum_diff: 0.3
|
||||
carry_diff: 0.2
|
||||
|
||||
confidence:
|
||||
agreement_weight: 0.25
|
||||
regime_stability_weight: 0.20
|
||||
data_quality_weight: 0.20
|
||||
volatility_penalty_weight: 0.20
|
||||
event_penalty_weight: 0.15
|
||||
@@ -0,0 +1,5 @@
|
||||
output:
|
||||
include_dashboard_payload: true
|
||||
include_trader_summary: true
|
||||
precision: 4
|
||||
engine_name: fx_quant_engine
|
||||
@@ -0,0 +1,15 @@
|
||||
trend:
|
||||
slope_threshold: 0.0005
|
||||
mr_band: 0.003
|
||||
|
||||
volatility:
|
||||
low_quantile: 0.3
|
||||
high_quantile: 0.7
|
||||
stress_quantile: 0.9
|
||||
|
||||
dollar:
|
||||
neutral_band: 0.2
|
||||
|
||||
risk:
|
||||
stress_vol_threshold: 0.75
|
||||
risk_off_equity_threshold: -0.01
|
||||
@@ -0,0 +1,13 @@
|
||||
risk:
|
||||
target_vol: 0.10
|
||||
max_gross_exposure: 1.0
|
||||
max_single_asset_exposure: 0.25
|
||||
stressed_regime_multiplier: 0.5
|
||||
illiquid_market_multiplier: 0.6
|
||||
data_reliability_penalty:
|
||||
high: 1.0
|
||||
medium: 0.85
|
||||
low: 0.6
|
||||
market_hours:
|
||||
inr_derivatives_open_utc: "03:45"
|
||||
inr_derivatives_close_utc: "10:00"
|
||||
@@ -0,0 +1,12 @@
|
||||
primary_inr_pairs:
|
||||
- USDINR
|
||||
- EURINR
|
||||
- GBPINR
|
||||
- JPYINR
|
||||
secondary_g10_pairs:
|
||||
- EURUSD
|
||||
- GBPUSD
|
||||
- USDJPY
|
||||
- AUDUSD
|
||||
- USDCAD
|
||||
- USDCHF
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"engine": "fx_quant_engine",
|
||||
"timestamp": "2026-03-30T10:00:00",
|
||||
"asset": "USDINR",
|
||||
"signal_type": "directional",
|
||||
"signal_direction": "long_usd",
|
||||
"signal_strength": 0.74,
|
||||
"confidence": 0.68,
|
||||
"regime": {
|
||||
"trend": "trend",
|
||||
"volatility": "normal",
|
||||
"dollar": "strengthening",
|
||||
"risk": "risk_off",
|
||||
"combined": "usd_dominant",
|
||||
"stability": 0.71
|
||||
},
|
||||
"expected_volatility": 0.089,
|
||||
"risk_flags": ["outside_primary_market_hours"],
|
||||
"drivers": {
|
||||
"trend": 0.51,
|
||||
"momentum": 0.36,
|
||||
"mean_reversion": -0.12,
|
||||
"carry": 0.09,
|
||||
"macro": -0.07
|
||||
},
|
||||
"recommended_action": "enter_small",
|
||||
"position_sizing_multiplier": 0.65
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
"""FX Quant Engine package."""
|
||||
|
||||
from fx_quant_engine.engine import FXQuantEngine
|
||||
|
||||
__all__ = ["FXQuantEngine"]
|
||||
@@ -0,0 +1,3 @@
|
||||
from fx_quant_engine.backtest.engine import BacktestEngine
|
||||
|
||||
__all__ = ["BacktestEngine"]
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from fx_quant_engine.schemas import BacktestResult
|
||||
|
||||
|
||||
class BacktestEngine:
|
||||
def __init__(self, transaction_cost_bps: float = 1.5, slippage_bps: float = 1.0) -> None:
|
||||
self.tc = transaction_cost_bps / 10000.0
|
||||
self.slippage = slippage_bps / 10000.0
|
||||
|
||||
def simulate_pair(self, prices: pd.Series, signal_strength: pd.Series) -> pd.Series:
|
||||
returns = prices.pct_change().fillna(0.0)
|
||||
position = signal_strength.shift(1).fillna(0.0) # no lookahead
|
||||
traded = position.diff().abs().fillna(0.0)
|
||||
net = position * returns - traded * (self.tc + self.slippage)
|
||||
return net
|
||||
|
||||
def simulate_portfolio(self, pair_returns: dict[str, pd.Series]) -> BacktestResult:
|
||||
if not pair_returns:
|
||||
return BacktestResult({}, 0.0, 0.0, 0.0, {})
|
||||
|
||||
df = pd.DataFrame(pair_returns).fillna(0.0)
|
||||
port = df.mean(axis=1)
|
||||
equity = (1.0 + port).cumprod()
|
||||
dd = equity / equity.cummax() - 1.0
|
||||
|
||||
pair_level = {k: float(v.mean() * 252.0) for k, v in pair_returns.items()}
|
||||
metrics = {
|
||||
"annualized_return": float(port.mean() * 252.0),
|
||||
"annualized_vol": float(port.std() * np.sqrt(252.0)),
|
||||
"sharpe": float((port.mean() / (port.std() + 1e-9)) * np.sqrt(252.0)),
|
||||
}
|
||||
|
||||
return BacktestResult(
|
||||
pair_level_returns=pair_level,
|
||||
portfolio_return=float((equity.iloc[-1] - 1.0) if len(equity) else 0.0),
|
||||
max_drawdown=float(dd.min() if len(dd) else 0.0),
|
||||
turnover=float(df.diff().abs().sum().sum()),
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
def walk_forward(self, prices: pd.Series, signal_strength: pd.Series, train_window: int = 120, test_window: int = 20) -> pd.Series:
|
||||
out = []
|
||||
idx = prices.index
|
||||
i = train_window
|
||||
while i < len(idx):
|
||||
end = min(i + test_window, len(idx))
|
||||
segment = self.simulate_pair(prices.iloc[:end], signal_strength.iloc[:end])
|
||||
out.append(segment.iloc[i:end])
|
||||
i += test_window
|
||||
return pd.concat(out).sort_index() if out else pd.Series(dtype=float)
|
||||
@@ -0,0 +1,214 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from fx_quant_engine.features.pipeline import FeaturePipeline
|
||||
from fx_quant_engine.ingestion.adapters import (
|
||||
BreezeAdapter,
|
||||
FreeFXAdapter,
|
||||
MockAdapter,
|
||||
NSEAdapter,
|
||||
RBIAdapter,
|
||||
ZerodhaAdapter,
|
||||
)
|
||||
from fx_quant_engine.ingestion.router import DataSourceRouter
|
||||
from fx_quant_engine.integration.hooks import IntegrationHooks
|
||||
from fx_quant_engine.models.ensemble import EnsembleModel
|
||||
from fx_quant_engine.models.relative_value import RelativeValueModel
|
||||
from fx_quant_engine.outputs.formatter import OutputFormatter
|
||||
from fx_quant_engine.preprocessing.cleaning import preprocess_market_data
|
||||
from fx_quant_engine.regime.engine import RegimeEngine
|
||||
from fx_quant_engine.risk.engine import RiskEngine
|
||||
from fx_quant_engine.schemas import SignalPayload
|
||||
from fx_quant_engine.signals.confidence import ConfidenceEngine
|
||||
from fx_quant_engine.signals.engine import SignalEngine
|
||||
from fx_quant_engine.utils.config import load_all_configs
|
||||
|
||||
|
||||
class FXQuantEngine:
|
||||
def __init__(self, config_dir: str | Path = "config") -> None:
|
||||
self.config = load_all_configs(config_dir)
|
||||
self.adapters = {
|
||||
"breeze": BreezeAdapter(),
|
||||
"zerodha": ZerodhaAdapter(),
|
||||
"nse": NSEAdapter(),
|
||||
"rbi": RBIAdapter(),
|
||||
"freefx": FreeFXAdapter(),
|
||||
"mock": MockAdapter(),
|
||||
}
|
||||
self.router = DataSourceRouter(self.adapters, self.config["data_sources"])
|
||||
self.features = FeaturePipeline(self.config["features"])
|
||||
self.regimes = RegimeEngine(self.config["regimes"])
|
||||
self.models = EnsembleModel(self.config["models"])
|
||||
self.rv_model = RelativeValueModel(
|
||||
lookback=int(self.config["models"].get("relative_value", {}).get("lookback", 60))
|
||||
)
|
||||
self.confidence = ConfidenceEngine(self.config["models"])
|
||||
self.signals = SignalEngine(
|
||||
engine_name=self.config["output"]["output"].get("engine_name", "fx_quant_engine")
|
||||
)
|
||||
self.risk = RiskEngine(self.config["risk"])
|
||||
self.output = OutputFormatter(self.config["output"])
|
||||
self.integration = IntegrationHooks()
|
||||
|
||||
def _asset_context(self, asset: str, start: datetime, end: datetime) -> dict[str, Any]:
|
||||
px_res = self.router.fetch_price_data(asset, start, end)
|
||||
macro_res = self.router.fetch_macro_data("macro_proxy", start, end)
|
||||
rate_res = self.router.fetch_rate_data(asset, start, end)
|
||||
|
||||
if not px_res.success or px_res.data.empty:
|
||||
raise RuntimeError(f"No price data available for {asset}: {px_res.error}")
|
||||
|
||||
df = px_res.data.join(macro_res.data, how="left").join(rate_res.data, how="left")
|
||||
if f"{asset}_rate" in df.columns:
|
||||
df["short_rate"] = df[f"{asset}_rate"]
|
||||
df["usd_rate"] = 0.045
|
||||
df["equity_proxy"] = df["close"].pct_change().rolling(5).mean().fillna(0.0)
|
||||
df["crude_proxy"] = (1.0 + df["close"].pct_change().fillna(0.0)).cumprod()
|
||||
df["emfx_proxy"] = df["close"].pct_change().fillna(0.0)
|
||||
|
||||
clean = preprocess_market_data(df)
|
||||
feat = self.features.transform(clean)
|
||||
regime = self.regimes.detect(feat)
|
||||
|
||||
return {
|
||||
"price": px_res,
|
||||
"macro": macro_res,
|
||||
"rate": rate_res,
|
||||
"clean": clean,
|
||||
"features": feat,
|
||||
"regime": regime,
|
||||
}
|
||||
|
||||
def run_asset(self, asset: str, start: datetime, end: datetime) -> dict[str, Any]:
|
||||
ctx = self._asset_context(asset, start, end)
|
||||
px_res = ctx["price"]
|
||||
feat = ctx["features"]
|
||||
regime = ctx["regime"]
|
||||
row = feat.iloc[-1]
|
||||
|
||||
score, model_drivers, model_agreement = self.models.directional_score(row)
|
||||
feature_agreement = float((row > 0).mean())
|
||||
data_quality = 1.0 if px_res.reliability == "high" else 0.8 if px_res.reliability == "medium" else 0.6
|
||||
volatility = float(min(1.0, row.get("realized_vol", 0.0)))
|
||||
event_risk = float(min(1.0, row.get("rbi_intervention_proxy", 0.0)))
|
||||
|
||||
conf = self.confidence.compute(
|
||||
feature_agreement=feature_agreement,
|
||||
model_agreement=model_agreement,
|
||||
regime_stability=regime.stability,
|
||||
data_quality=data_quality,
|
||||
volatility=volatility,
|
||||
event_risk=event_risk,
|
||||
)
|
||||
|
||||
sig: SignalPayload = self.signals.directional_signal(
|
||||
asset=asset,
|
||||
feature_row=row,
|
||||
regime=regime,
|
||||
score=score,
|
||||
confidence=conf,
|
||||
drivers=model_drivers,
|
||||
expected_volatility=float(row.get("realized_vol", 0.0)),
|
||||
)
|
||||
sig = self.risk.apply(
|
||||
signal=sig,
|
||||
regime=regime,
|
||||
reliability=px_res.reliability,
|
||||
liquidity_ok=True,
|
||||
event_risk=event_risk,
|
||||
)
|
||||
|
||||
payload = self.output.to_json_payload(sig)
|
||||
summary = self.output.trader_summary(sig)
|
||||
dashboard = self.output.dashboard_payload(sig)
|
||||
hooks = self.integration.export_scores(feat, regime)
|
||||
|
||||
return {
|
||||
"signal": payload,
|
||||
"summary": summary,
|
||||
"dashboard": dashboard,
|
||||
"integration_hooks": hooks,
|
||||
"source_usage": [u.__dict__ for u in self.router.usage_log],
|
||||
}
|
||||
|
||||
def run_relative_value(self, asset_a: str, asset_b: str, start: datetime, end: datetime) -> dict[str, Any]:
|
||||
ctx_a = self._asset_context(asset_a, start, end)
|
||||
ctx_b = self._asset_context(asset_b, start, end)
|
||||
feat_a = ctx_a["features"]
|
||||
feat_b = ctx_b["features"]
|
||||
|
||||
rv = self.rv_model.generate(
|
||||
asset_a=asset_a,
|
||||
asset_b=asset_b,
|
||||
prices_a=ctx_a["clean"]["close"],
|
||||
prices_b=ctx_b["clean"]["close"],
|
||||
features_a=feat_a,
|
||||
features_b=feat_b,
|
||||
)
|
||||
|
||||
regime = ctx_a["regime"] if ctx_a["regime"].stability >= ctx_b["regime"].stability else ctx_b["regime"]
|
||||
driver_vals = [v for k, v in rv.drivers.items() if k != "hedge_ratio"]
|
||||
signs = [np.sign(v) for v in driver_vals if abs(v) > 1e-12]
|
||||
model_agreement = float(abs(sum(signs)) / len(signs)) if signs else 0.0
|
||||
feature_agreement = float((feat_a.iloc[-1] - feat_b.iloc[-1] > 0).mean())
|
||||
|
||||
rel_map = {"high": 1.0, "medium": 0.8, "low": 0.6}
|
||||
q_a = rel_map.get(ctx_a["price"].reliability, 0.6)
|
||||
q_b = rel_map.get(ctx_b["price"].reliability, 0.6)
|
||||
data_quality = float((q_a + q_b) / 2.0)
|
||||
|
||||
volatility = float(min(1.0, rv.expected_volatility))
|
||||
event_risk = float(
|
||||
min(
|
||||
1.0,
|
||||
(
|
||||
float(feat_a.iloc[-1].get("rbi_intervention_proxy", 0.0))
|
||||
+ float(feat_b.iloc[-1].get("rbi_intervention_proxy", 0.0))
|
||||
)
|
||||
/ 2.0,
|
||||
)
|
||||
)
|
||||
|
||||
conf = self.confidence.compute(
|
||||
feature_agreement=feature_agreement,
|
||||
model_agreement=model_agreement,
|
||||
regime_stability=regime.stability,
|
||||
data_quality=data_quality,
|
||||
volatility=volatility,
|
||||
event_risk=event_risk,
|
||||
)
|
||||
|
||||
sig = self.signals.relative_value_signal(
|
||||
long_asset=rv.long_asset,
|
||||
short_asset=rv.short_asset,
|
||||
regime=regime,
|
||||
score=rv.score,
|
||||
confidence=conf,
|
||||
drivers=rv.drivers,
|
||||
expected_volatility=rv.expected_volatility,
|
||||
)
|
||||
reliability = "high" if min(q_a, q_b) >= 1.0 else "medium" if min(q_a, q_b) >= 0.8 else "low"
|
||||
sig = self.risk.apply(
|
||||
signal=sig,
|
||||
regime=regime,
|
||||
reliability=reliability,
|
||||
liquidity_ok=True,
|
||||
event_risk=event_risk,
|
||||
)
|
||||
|
||||
return {
|
||||
"relative_value_pair": f"{asset_a}:{asset_b}",
|
||||
"long": rv.long_asset,
|
||||
"short": rv.short_asset,
|
||||
"spread_score": rv.score,
|
||||
"hedge_ratio": rv.hedge_ratio,
|
||||
"spread_zscore": rv.spread_zscore,
|
||||
"signal": self.output.to_json_payload(sig),
|
||||
"summary": self.output.trader_summary(sig),
|
||||
"dashboard": self.output.dashboard_payload(sig),
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
from fx_quant_engine.evaluation.engine import Evaluator
|
||||
|
||||
__all__ = ["Evaluator"]
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from fx_quant_engine.schemas import EvaluationReport
|
||||
|
||||
|
||||
class Evaluator:
|
||||
def evaluate(
|
||||
self,
|
||||
signals: pd.DataFrame,
|
||||
forward_returns: pd.Series,
|
||||
regimes: pd.Series,
|
||||
feature_importance: dict[str, float],
|
||||
) -> EvaluationReport:
|
||||
if signals.empty or forward_returns.empty:
|
||||
return EvaluationReport(
|
||||
generated_at=datetime.now(timezone.utc).isoformat(),
|
||||
hit_rate=0.0,
|
||||
avg_forward_return=0.0,
|
||||
regime_performance={},
|
||||
signal_decay={},
|
||||
feature_importance=feature_importance,
|
||||
drift_flags=["insufficient_data"],
|
||||
calibration_score=0.0,
|
||||
)
|
||||
|
||||
aligned = signals.join(forward_returns.rename("fwd"), how="inner")
|
||||
aligned = aligned.join(regimes.rename("regime"), how="left").fillna("unknown")
|
||||
pred = np.sign(aligned["signal_strength"] * aligned.get("signal_sign", 1.0))
|
||||
realized = np.sign(aligned["fwd"])
|
||||
hit_rate = float((pred == realized).mean())
|
||||
avg_ret = float(aligned["fwd"].mean())
|
||||
|
||||
regime_perf = {
|
||||
r: float(v)
|
||||
for r, v in aligned.groupby("regime")["fwd"].mean().to_dict().items()
|
||||
}
|
||||
|
||||
signal_decay = {
|
||||
"1d": float(aligned["fwd"].mean()),
|
||||
"5d": float(aligned["fwd"].rolling(5).mean().dropna().mean() if len(aligned) >= 5 else 0.0),
|
||||
"20d": float(aligned["fwd"].rolling(20).mean().dropna().mean() if len(aligned) >= 20 else 0.0),
|
||||
}
|
||||
|
||||
drift = []
|
||||
if abs(avg_ret) < 1e-4:
|
||||
drift.append("low_signal_edge")
|
||||
if hit_rate < 0.48:
|
||||
drift.append("hit_rate_deterioration")
|
||||
|
||||
conf = aligned.get("confidence", pd.Series(0.5, index=aligned.index))
|
||||
calibration_score = float(np.clip(1.0 - abs(conf.mean() - hit_rate), 0.0, 1.0))
|
||||
|
||||
return EvaluationReport(
|
||||
generated_at=datetime.now(timezone.utc).isoformat(),
|
||||
hit_rate=hit_rate,
|
||||
avg_forward_return=avg_ret,
|
||||
regime_performance=regime_perf,
|
||||
signal_decay=signal_decay,
|
||||
feature_importance=feature_importance,
|
||||
drift_flags=drift,
|
||||
calibration_score=calibration_score,
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
from fx_quant_engine.features.pipeline import FeaturePipeline
|
||||
|
||||
__all__ = ["FeaturePipeline"]
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
FeatureFn = Callable[[pd.DataFrame, dict[str, Any]], pd.Series]
|
||||
|
||||
|
||||
class FeatureRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._features: dict[str, FeatureFn] = {}
|
||||
|
||||
def register(self, name: str, fn: FeatureFn) -> None:
|
||||
if name in self._features:
|
||||
raise ValueError(f"Feature already registered: {name}")
|
||||
self._features[name] = fn
|
||||
|
||||
def get(self, name: str) -> FeatureFn:
|
||||
return self._features[name]
|
||||
|
||||
def names(self) -> list[str]:
|
||||
return list(self._features.keys())
|
||||
|
||||
|
||||
registry = FeatureRegistry()
|
||||
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from fx_quant_engine.features.base import registry
|
||||
|
||||
|
||||
def _close(df: pd.DataFrame) -> pd.Series:
|
||||
if "close" in df.columns:
|
||||
return df["close"]
|
||||
numeric = df.select_dtypes(include="number")
|
||||
if numeric.empty:
|
||||
return pd.Series(0.0, index=df.index)
|
||||
return numeric.iloc[:, 0]
|
||||
|
||||
|
||||
def returns(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
return _close(df).pct_change().fillna(0.0)
|
||||
|
||||
|
||||
def moving_averages(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
w = cfg["windows"]["medium"]
|
||||
c = _close(df)
|
||||
return (c / c.rolling(w).mean() - 1.0).fillna(0.0)
|
||||
|
||||
|
||||
def breakouts(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
w = cfg["windows"]["long"]
|
||||
c = _close(df)
|
||||
high = c.rolling(w).max()
|
||||
low = c.rolling(w).min()
|
||||
rng = (high - low).replace(0.0, np.nan)
|
||||
return ((c - low) / rng).fillna(0.5)
|
||||
|
||||
|
||||
def trend_strength(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
w = cfg["windows"]["medium"]
|
||||
r = returns(df, cfg)
|
||||
return (r.rolling(w).mean() / (r.rolling(w).std() + 1e-9)).fillna(0.0)
|
||||
|
||||
|
||||
def momentum_multi_horizon(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
c = _close(df)
|
||||
s = cfg["windows"]["short"]
|
||||
m = cfg["windows"]["medium"]
|
||||
l = cfg["windows"]["long"]
|
||||
return ((c.pct_change(s) + c.pct_change(m) + c.pct_change(l)) / 3.0).fillna(0.0)
|
||||
|
||||
|
||||
def momentum_persistence(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
r = returns(df, cfg)
|
||||
return r.rolling(cfg["windows"]["short"]).apply(lambda x: float((x > 0).mean()), raw=False).fillna(0.5)
|
||||
|
||||
|
||||
def momentum_acceleration(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
m = momentum_multi_horizon(df, cfg)
|
||||
return m.diff().fillna(0.0)
|
||||
|
||||
|
||||
def mean_reversion_zscore(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
c = _close(df)
|
||||
w = cfg["windows"]["medium"]
|
||||
m = c.rolling(w).mean()
|
||||
s = c.rolling(w).std()
|
||||
return ((c - m) / (s + 1e-9)).fillna(0.0)
|
||||
|
||||
|
||||
def bollinger_position(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
z = mean_reversion_zscore(df, cfg)
|
||||
return (z / 2.0).clip(-1, 1)
|
||||
|
||||
|
||||
def realized_vol(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
r = returns(df, cfg)
|
||||
return (r.rolling(cfg["windows"]["medium"]).std() * np.sqrt(252)).fillna(0.0)
|
||||
|
||||
|
||||
def atr_proxy(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
c = _close(df)
|
||||
return c.diff().abs().rolling(cfg["windows"]["short"]).mean().fillna(0.0)
|
||||
|
||||
|
||||
def vol_regime_proxy(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
vol = realized_vol(df, cfg)
|
||||
return (vol / (vol.rolling(cfg["windows"]["long"]).mean() + 1e-9)).fillna(1.0)
|
||||
|
||||
|
||||
def carry_proxy(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
short_r = df.get("short_rate", pd.Series(0.05, index=df.index))
|
||||
usd_r = df.get("usd_rate", pd.Series(0.045, index=df.index))
|
||||
return (short_r - usd_r).fillna(0.0)
|
||||
|
||||
|
||||
def usd_strength_score(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
r = returns(df, cfg)
|
||||
return (-r.rolling(cfg["windows"]["medium"]).mean()).fillna(0.0)
|
||||
|
||||
|
||||
def risk_on_off_proxy(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
eq = df.get("equity_proxy", returns(df, cfg)).fillna(0.0)
|
||||
vix = df.get("vix_proxy", realized_vol(df, cfg)).fillna(0.0)
|
||||
return (eq - vix).fillna(0.0)
|
||||
|
||||
|
||||
def inr_crude_proxy(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
crude = df.get("crude_proxy", pd.Series(0.0, index=df.index))
|
||||
return crude.pct_change().fillna(0.0)
|
||||
|
||||
|
||||
def em_stress_proxy(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
em = df.get("emfx_proxy", returns(df, cfg)).fillna(0.0)
|
||||
return (-em.rolling(cfg["windows"]["medium"]).mean()).fillna(0.0)
|
||||
|
||||
|
||||
def inr_vol_clustering(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
r = returns(df, cfg)
|
||||
return r.abs().rolling(cfg["windows"]["short"]).mean().fillna(0.0)
|
||||
|
||||
|
||||
def rbi_intervention_proxy(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
z = mean_reversion_zscore(df, cfg)
|
||||
return (z.abs() > 2.0).astype(float)
|
||||
|
||||
|
||||
def cross_asset_bond_proxy(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
y = df.get("bond_yield_proxy", pd.Series(0.0, index=df.index))
|
||||
return y.diff().fillna(0.0)
|
||||
|
||||
|
||||
def cross_asset_commodity_proxy(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
g = df.get("gold_proxy", pd.Series(0.0, index=df.index))
|
||||
o = df.get("oil_proxy", pd.Series(0.0, index=df.index))
|
||||
return (g.pct_change().fillna(0.0) + o.pct_change().fillna(0.0)) / 2.0
|
||||
|
||||
|
||||
def cross_asset_equity_proxy(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
e = df.get("equity_proxy", pd.Series(0.0, index=df.index))
|
||||
return e.pct_change().fillna(0.0)
|
||||
|
||||
|
||||
def register_builtin_features() -> None:
|
||||
registry.register("returns", returns)
|
||||
registry.register("moving_averages", moving_averages)
|
||||
registry.register("breakouts", breakouts)
|
||||
registry.register("trend_strength", trend_strength)
|
||||
registry.register("momentum_multi_horizon", momentum_multi_horizon)
|
||||
registry.register("momentum_persistence", momentum_persistence)
|
||||
registry.register("momentum_acceleration", momentum_acceleration)
|
||||
registry.register("mean_reversion_zscore", mean_reversion_zscore)
|
||||
registry.register("bollinger_position", bollinger_position)
|
||||
registry.register("realized_vol", realized_vol)
|
||||
registry.register("atr_proxy", atr_proxy)
|
||||
registry.register("vol_regime_proxy", vol_regime_proxy)
|
||||
registry.register("carry_proxy", carry_proxy)
|
||||
registry.register("usd_strength_score", usd_strength_score)
|
||||
registry.register("risk_on_off_proxy", risk_on_off_proxy)
|
||||
registry.register("inr_crude_proxy", inr_crude_proxy)
|
||||
registry.register("em_stress_proxy", em_stress_proxy)
|
||||
registry.register("inr_vol_clustering", inr_vol_clustering)
|
||||
registry.register("rbi_intervention_proxy", rbi_intervention_proxy)
|
||||
registry.register("cross_asset_bond_proxy", cross_asset_bond_proxy)
|
||||
registry.register("cross_asset_commodity_proxy", cross_asset_commodity_proxy)
|
||||
registry.register("cross_asset_equity_proxy", cross_asset_equity_proxy)
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from fx_quant_engine.features.base import registry
|
||||
from fx_quant_engine.features.builtins import register_builtin_features
|
||||
|
||||
|
||||
class FeaturePipeline:
|
||||
def __init__(self, config: dict[str, Any]) -> None:
|
||||
self.config = config
|
||||
if not registry.names():
|
||||
register_builtin_features()
|
||||
|
||||
def transform(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
feature_cfg = self.config.get("features", {})
|
||||
out = pd.DataFrame(index=df.index)
|
||||
for feature_name, enabled in feature_cfg.items():
|
||||
if not enabled:
|
||||
continue
|
||||
if feature_name not in registry.names():
|
||||
continue
|
||||
out[feature_name] = registry.get(feature_name)(df, self.config)
|
||||
return out.fillna(0.0)
|
||||
@@ -0,0 +1,27 @@
|
||||
from fx_quant_engine.ingestion.adapters import (
|
||||
BreezeAdapter,
|
||||
FreeFXAdapter,
|
||||
MockAdapter,
|
||||
NSEAdapter,
|
||||
RBIAdapter,
|
||||
ZerodhaAdapter,
|
||||
)
|
||||
from fx_quant_engine.ingestion.base import BaseDataAdapter
|
||||
from fx_quant_engine.ingestion.credentials import APICredentials, load_credentials
|
||||
from fx_quant_engine.ingestion.http_client import HttpClient, RetryConfig
|
||||
from fx_quant_engine.ingestion.router import DataSourceRouter
|
||||
|
||||
__all__ = [
|
||||
"BaseDataAdapter",
|
||||
"BreezeAdapter",
|
||||
"ZerodhaAdapter",
|
||||
"NSEAdapter",
|
||||
"RBIAdapter",
|
||||
"FreeFXAdapter",
|
||||
"MockAdapter",
|
||||
"DataSourceRouter",
|
||||
"APICredentials",
|
||||
"load_credentials",
|
||||
"HttpClient",
|
||||
"RetryConfig",
|
||||
]
|
||||
@@ -0,0 +1,264 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from fx_quant_engine.ingestion.base import BaseDataAdapter
|
||||
from fx_quant_engine.ingestion.credentials import APICredentials, load_credentials
|
||||
from fx_quant_engine.ingestion.http_client import HttpClient, RetryConfig
|
||||
from fx_quant_engine.ingestion.parsers import (
|
||||
BreezeResponseParser,
|
||||
NSEResponseParser,
|
||||
RBIResponseParser,
|
||||
ZerodhaResponseParser,
|
||||
)
|
||||
|
||||
|
||||
def _mock_price_series(asset: str, start: datetime, end: datetime, seed: int) -> pd.DataFrame:
|
||||
idx = pd.date_range(start=start, end=end, freq="B")
|
||||
rng = np.random.default_rng(seed)
|
||||
steps = rng.normal(loc=0.0, scale=0.002, size=len(idx))
|
||||
base = 80.0 if asset.endswith("INR") else 1.1
|
||||
px = base * np.exp(np.cumsum(steps))
|
||||
return pd.DataFrame({"close": px}, index=idx)
|
||||
|
||||
|
||||
def _mock_macro_series(key: str, start: datetime, end: datetime, seed: int) -> pd.DataFrame:
|
||||
idx = pd.date_range(start=start, end=end, freq="B")
|
||||
rng = np.random.default_rng(seed)
|
||||
values = rng.normal(loc=0.0, scale=1.0, size=len(idx))
|
||||
return pd.DataFrame({key: values}, index=idx)
|
||||
|
||||
|
||||
def _mock_rate_series(asset: str, start: datetime, end: datetime, seed: int) -> pd.DataFrame:
|
||||
idx = pd.date_range(start=start, end=end, freq="B")
|
||||
rng = np.random.default_rng(seed)
|
||||
rate = 0.05 + rng.normal(loc=0.0, scale=0.002, size=len(idx))
|
||||
return pd.DataFrame({f"{asset}_rate": rate}, index=idx)
|
||||
|
||||
|
||||
def _retry_config_from_env() -> RetryConfig:
|
||||
return RetryConfig(
|
||||
timeout_sec=float(os.getenv("FXE_HTTP_TIMEOUT_SEC", "5.0")),
|
||||
max_attempts=int(os.getenv("FXE_HTTP_MAX_ATTEMPTS", "3")),
|
||||
backoff_sec=float(os.getenv("FXE_HTTP_BACKOFF_SEC", "0.5")),
|
||||
)
|
||||
|
||||
|
||||
def _live_enabled(env_key: str) -> bool:
|
||||
return os.getenv(env_key, "false").lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BreezeAdapter(BaseDataAdapter):
|
||||
use_live: bool = False
|
||||
base_url: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.use_live = self.use_live or _live_enabled("BREEZE_LIVE_ENABLED")
|
||||
self.base_url = self.base_url or os.getenv("BREEZE_BASE_URL")
|
||||
self.client = HttpClient(_retry_config_from_env())
|
||||
self.parser = BreezeResponseParser()
|
||||
|
||||
def _creds(self) -> APICredentials:
|
||||
return load_credentials("BREEZE", required=["API_KEY", "API_SECRET"], optional=["SESSION_TOKEN"])
|
||||
|
||||
def fetch_price_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
if self.use_live:
|
||||
if not self.base_url:
|
||||
raise RuntimeError("Breeze live enabled but BREEZE_BASE_URL not configured")
|
||||
creds = self._creds()
|
||||
payload = self.client.get_json(
|
||||
f"{self.base_url.rstrip('/')}/api/v1/price",
|
||||
params={"symbol": asset, "start": start.isoformat(), "end": end.isoformat()},
|
||||
headers={"X-API-KEY": str(creds.get("api_key") or "")},
|
||||
)
|
||||
return self.parser.parse_price(payload, start, end)
|
||||
return _mock_price_series(asset, start, end, seed=1)
|
||||
|
||||
def fetch_macro_data(self, key: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
if self.use_live:
|
||||
if not self.base_url:
|
||||
raise RuntimeError("Breeze live enabled but BREEZE_BASE_URL not configured")
|
||||
creds = self._creds()
|
||||
payload = self.client.get_json(
|
||||
f"{self.base_url.rstrip('/')}/api/v1/macro",
|
||||
params={"key": key, "start": start.isoformat(), "end": end.isoformat()},
|
||||
headers={"X-API-KEY": str(creds.get("api_key") or "")},
|
||||
)
|
||||
return self.parser.parse_macro(payload, key, start, end)
|
||||
return _mock_macro_series(key, start, end, seed=2)
|
||||
|
||||
def fetch_rate_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
if self.use_live:
|
||||
if not self.base_url:
|
||||
raise RuntimeError("Breeze live enabled but BREEZE_BASE_URL not configured")
|
||||
creds = self._creds()
|
||||
col = f"{asset}_rate"
|
||||
payload = self.client.get_json(
|
||||
f"{self.base_url.rstrip('/')}/api/v1/rates",
|
||||
params={"symbol": asset, "start": start.isoformat(), "end": end.isoformat()},
|
||||
headers={"X-API-KEY": str(creds.get("api_key") or "")},
|
||||
)
|
||||
return self.parser.parse_rate(payload, col, start, end)
|
||||
return _mock_rate_series(asset, start, end, seed=3)
|
||||
|
||||
def health_check(self) -> bool:
|
||||
if not self.use_live:
|
||||
return True
|
||||
return bool(self.base_url)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZerodhaAdapter(BaseDataAdapter):
|
||||
use_live: bool = False
|
||||
base_url: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.use_live = self.use_live or _live_enabled("ZERODHA_LIVE_ENABLED")
|
||||
self.base_url = self.base_url or os.getenv("ZERODHA_BASE_URL")
|
||||
self.client = HttpClient(_retry_config_from_env())
|
||||
self.parser = ZerodhaResponseParser()
|
||||
|
||||
def _creds(self) -> APICredentials:
|
||||
return load_credentials("ZERODHA", required=["API_KEY", "ACCESS_TOKEN"])
|
||||
|
||||
def fetch_price_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
if self.use_live:
|
||||
if not self.base_url:
|
||||
raise RuntimeError("Zerodha live enabled but ZERODHA_BASE_URL not configured")
|
||||
creds = self._creds()
|
||||
payload = self.client.get_json(
|
||||
f"{self.base_url.rstrip('/')}/instruments/historical",
|
||||
params={"symbol": asset, "start": start.isoformat(), "end": end.isoformat()},
|
||||
headers={
|
||||
"X-Kite-Version": "3",
|
||||
"Authorization": (
|
||||
f"token {str(creds.get('api_key') or '')}:{str(creds.get('access_token') or '')}"
|
||||
),
|
||||
},
|
||||
)
|
||||
return self.parser.parse_price(payload, start, end)
|
||||
return _mock_price_series(asset, start, end, seed=4)
|
||||
|
||||
def fetch_macro_data(self, key: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
raise NotImplementedError("Zerodha macro data not supported")
|
||||
|
||||
def fetch_rate_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
raise NotImplementedError("Zerodha rates not supported")
|
||||
|
||||
def health_check(self) -> bool:
|
||||
if not self.use_live:
|
||||
return True
|
||||
return bool(self.base_url)
|
||||
|
||||
|
||||
class NSEAdapter(BaseDataAdapter):
|
||||
def __init__(self, use_live: bool = False, base_url: str | None = None) -> None:
|
||||
self.use_live = use_live or _live_enabled("NSE_LIVE_ENABLED")
|
||||
self.base_url = base_url or os.getenv("NSE_BASE_URL", "https://www.nseindia.com")
|
||||
self.client = HttpClient(_retry_config_from_env())
|
||||
self.parser = NSEResponseParser()
|
||||
|
||||
def fetch_price_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
if self.use_live:
|
||||
payload = self.client.get_json(
|
||||
f"{self.base_url.rstrip('/')}/api/historical/foCPV",
|
||||
params={"symbol": asset, "from": start.strftime("%d-%m-%Y"), "to": end.strftime("%d-%m-%Y")},
|
||||
headers={"User-Agent": "fx-quant-engine/0.1"},
|
||||
)
|
||||
return self.parser.parse_price(payload, start, end)
|
||||
return _mock_price_series(asset, start, end, seed=5)
|
||||
|
||||
def fetch_macro_data(self, key: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
if self.use_live:
|
||||
payload = self.client.get_json(
|
||||
f"{self.base_url.rstrip('/')}/api/allIndices",
|
||||
headers={"User-Agent": "fx-quant-engine/0.1"},
|
||||
)
|
||||
return self.parser.parse_macro(payload, key, start, end)
|
||||
return _mock_macro_series(key, start, end, seed=6)
|
||||
|
||||
def fetch_rate_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
if self.use_live:
|
||||
col = f"{asset}_rate"
|
||||
payload = self.client.get_json(
|
||||
f"{self.base_url.rstrip('/')}/api/live-analysis-oi-spurts-underlyings",
|
||||
headers={"User-Agent": "fx-quant-engine/0.1"},
|
||||
)
|
||||
return self.parser.parse_rate(payload, col, start, end)
|
||||
return _mock_rate_series(asset, start, end, seed=7)
|
||||
|
||||
def health_check(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class RBIAdapter(BaseDataAdapter):
|
||||
def __init__(self, use_live: bool = False, base_url: str | None = None) -> None:
|
||||
self.use_live = use_live or _live_enabled("RBI_LIVE_ENABLED")
|
||||
self.base_url = base_url or os.getenv("RBI_BASE_URL", "https://data.rbi.org.in")
|
||||
self.client = HttpClient(_retry_config_from_env())
|
||||
self.parser = RBIResponseParser()
|
||||
|
||||
def fetch_price_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
if self.use_live:
|
||||
payload = self.client.get_json(
|
||||
f"{self.base_url.rstrip('/')}/api/exchange-rate",
|
||||
params={"pair": asset, "from": start.isoformat(), "to": end.isoformat()},
|
||||
)
|
||||
return self.parser.parse_price(payload, start, end)
|
||||
return _mock_price_series(asset, start, end, seed=8)
|
||||
|
||||
def fetch_macro_data(self, key: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
if self.use_live:
|
||||
payload = self.client.get_json(
|
||||
f"{self.base_url.rstrip('/')}/api/macro",
|
||||
params={"series": key, "from": start.isoformat(), "to": end.isoformat()},
|
||||
)
|
||||
return self.parser.parse_macro(payload, key, start, end)
|
||||
return _mock_macro_series(key, start, end, seed=9)
|
||||
|
||||
def fetch_rate_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
if self.use_live:
|
||||
col = f"{asset}_rate"
|
||||
payload = self.client.get_json(
|
||||
f"{self.base_url.rstrip('/')}/api/policy-rates",
|
||||
params={"asset": asset, "from": start.isoformat(), "to": end.isoformat()},
|
||||
)
|
||||
return self.parser.parse_rate(payload, col, start, end)
|
||||
return _mock_rate_series(asset, start, end, seed=10)
|
||||
|
||||
def health_check(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class FreeFXAdapter(BaseDataAdapter):
|
||||
def fetch_price_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
return _mock_price_series(asset, start, end, seed=11)
|
||||
|
||||
def fetch_macro_data(self, key: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
return _mock_macro_series(key, start, end, seed=12)
|
||||
|
||||
def fetch_rate_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
return _mock_rate_series(asset, start, end, seed=13)
|
||||
|
||||
def health_check(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class MockAdapter(BaseDataAdapter):
|
||||
def fetch_price_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
return _mock_price_series(asset, start, end, seed=42)
|
||||
|
||||
def fetch_macro_data(self, key: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
return _mock_macro_series(key, start, end, seed=43)
|
||||
|
||||
def fetch_rate_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
return _mock_rate_series(asset, start, end, seed=44)
|
||||
|
||||
def health_check(self) -> bool:
|
||||
return True
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class BaseDataAdapter(ABC):
|
||||
@abstractmethod
|
||||
def fetch_price_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def fetch_macro_data(self, key: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def fetch_rate_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def health_check(self) -> bool:
|
||||
pass
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class APICredentials:
|
||||
values: dict[str, str]
|
||||
|
||||
def get(self, key: str, default: str | None = None) -> str | None:
|
||||
return self.values.get(key, default)
|
||||
|
||||
|
||||
def load_credentials(prefix: str, required: list[str], optional: list[str] | None = None) -> APICredentials:
|
||||
optional = optional or []
|
||||
payload: dict[str, str] = {}
|
||||
|
||||
missing: list[str] = []
|
||||
for key in required:
|
||||
env_key = f"{prefix}_{key}"
|
||||
value = os.getenv(env_key)
|
||||
if not value:
|
||||
missing.append(env_key)
|
||||
continue
|
||||
payload[key.lower()] = value
|
||||
|
||||
for key in optional:
|
||||
env_key = f"{prefix}_{key}"
|
||||
value = os.getenv(env_key)
|
||||
if value:
|
||||
payload[key.lower()] = value
|
||||
|
||||
if missing:
|
||||
raise RuntimeError(f"Missing required credentials: {', '.join(missing)}")
|
||||
return APICredentials(values=payload)
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
@dataclass
|
||||
class RetryConfig:
|
||||
timeout_sec: float = 5.0
|
||||
max_attempts: int = 3
|
||||
backoff_sec: float = 0.5
|
||||
|
||||
|
||||
class HttpClient:
|
||||
def __init__(self, config: RetryConfig) -> None:
|
||||
self.config = config
|
||||
|
||||
def get_json(
|
||||
self,
|
||||
url: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
last_err = "unknown"
|
||||
for attempt in range(1, self.config.max_attempts + 1):
|
||||
try:
|
||||
response = requests.get(url, params=params, headers=headers, timeout=self.config.timeout_sec)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError("Expected JSON object response")
|
||||
return data
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_err = str(exc)
|
||||
if attempt < self.config.max_attempts:
|
||||
time.sleep(self.config.backoff_sec * attempt)
|
||||
|
||||
raise RuntimeError(
|
||||
f"HTTP GET failed after {self.config.max_attempts} attempts for {url}: {last_err}"
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class BaseResponseParser:
|
||||
def parse_price(self, payload: dict[str, Any], start: datetime, end: datetime) -> pd.DataFrame:
|
||||
return _extract_price(payload, start, end)
|
||||
|
||||
def parse_macro(self, payload: dict[str, Any], key: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
return _extract_series(payload, key, start, end)
|
||||
|
||||
def parse_rate(self, payload: dict[str, Any], column: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
return _extract_series(payload, column, start, end)
|
||||
|
||||
|
||||
class BreezeResponseParser(BaseResponseParser):
|
||||
pass
|
||||
|
||||
|
||||
class ZerodhaResponseParser(BaseResponseParser):
|
||||
pass
|
||||
|
||||
|
||||
class NSEResponseParser(BaseResponseParser):
|
||||
pass
|
||||
|
||||
|
||||
class RBIResponseParser(BaseResponseParser):
|
||||
pass
|
||||
|
||||
|
||||
def _extract_price(payload: dict[str, Any], start: datetime, end: datetime) -> pd.DataFrame:
|
||||
records = payload.get("data")
|
||||
if not isinstance(records, list) or not records:
|
||||
raise RuntimeError("Invalid payload: 'data' must be a non-empty list")
|
||||
|
||||
df = pd.DataFrame(records)
|
||||
if "timestamp" in df.columns:
|
||||
df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True, errors="coerce")
|
||||
df = df.dropna(subset=["timestamp"]).set_index("timestamp")
|
||||
elif "date" in df.columns:
|
||||
df["date"] = pd.to_datetime(df["date"], utc=True, errors="coerce")
|
||||
df = df.dropna(subset=["date"]).set_index("date")
|
||||
else:
|
||||
raise RuntimeError("Invalid payload: missing timestamp/date column")
|
||||
|
||||
if "close" not in df.columns:
|
||||
if "price" in df.columns:
|
||||
df = df.rename(columns={"price": "close"})
|
||||
else:
|
||||
raise RuntimeError("Invalid payload: missing close/price column")
|
||||
|
||||
out = df[["close"]].sort_index()
|
||||
start_ts = pd.Timestamp(start)
|
||||
end_ts = pd.Timestamp(end)
|
||||
start_ts = start_ts.tz_localize("UTC") if start_ts.tzinfo is None else start_ts.tz_convert("UTC")
|
||||
end_ts = end_ts.tz_localize("UTC") if end_ts.tzinfo is None else end_ts.tz_convert("UTC")
|
||||
clipped = out[(out.index >= start_ts) & (out.index <= end_ts)]
|
||||
if clipped.empty:
|
||||
raise RuntimeError("Invalid payload: no records in requested window")
|
||||
return clipped
|
||||
|
||||
|
||||
def _extract_series(payload: dict[str, Any], key: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
records = payload.get("data")
|
||||
if not isinstance(records, list) or not records:
|
||||
raise RuntimeError("Invalid payload: 'data' must be a non-empty list")
|
||||
|
||||
df = pd.DataFrame(records)
|
||||
if "timestamp" in df.columns:
|
||||
df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True, errors="coerce")
|
||||
df = df.dropna(subset=["timestamp"]).set_index("timestamp")
|
||||
elif "date" in df.columns:
|
||||
df["date"] = pd.to_datetime(df["date"], utc=True, errors="coerce")
|
||||
df = df.dropna(subset=["date"]).set_index("date")
|
||||
else:
|
||||
raise RuntimeError("Invalid payload: missing timestamp/date column")
|
||||
|
||||
if key not in df.columns:
|
||||
raise RuntimeError(f"Invalid payload: missing required column '{key}'")
|
||||
|
||||
out = df[[key]].sort_index()
|
||||
start_ts = pd.Timestamp(start)
|
||||
end_ts = pd.Timestamp(end)
|
||||
start_ts = start_ts.tz_localize("UTC") if start_ts.tzinfo is None else start_ts.tz_convert("UTC")
|
||||
end_ts = end_ts.tz_localize("UTC") if end_ts.tzinfo is None else end_ts.tz_convert("UTC")
|
||||
clipped = out[(out.index >= start_ts) & (out.index <= end_ts)]
|
||||
if clipped.empty:
|
||||
raise RuntimeError("Invalid payload: no records in requested window")
|
||||
return clipped
|
||||
@@ -0,0 +1,155 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from fx_quant_engine.ingestion.base import BaseDataAdapter
|
||||
from fx_quant_engine.schemas import DataFetchResult, SourceUsageRecord
|
||||
from fx_quant_engine.utils.logging import get_logger
|
||||
|
||||
|
||||
class DataSourceRouter:
|
||||
def __init__(
|
||||
self,
|
||||
adapters: dict[str, BaseDataAdapter],
|
||||
config: dict[str, Any],
|
||||
) -> None:
|
||||
self.adapters = adapters
|
||||
self.config = config
|
||||
self.logger = get_logger(self.__class__.__name__)
|
||||
self.usage_log: list[SourceUsageRecord] = []
|
||||
|
||||
cb_cfg = config.get("circuit_breaker", {})
|
||||
self.failure_threshold = int(cb_cfg.get("failure_threshold", 3))
|
||||
self.cooldown_seconds = int(cb_cfg.get("cooldown_seconds", 120))
|
||||
self.source_state: dict[str, dict[str, Any]] = {
|
||||
name: {"failures": 0, "open_until": None} for name in adapters
|
||||
}
|
||||
|
||||
def _get_candidate_sources(self, asset: str) -> list[str]:
|
||||
source_cfg = self.config
|
||||
mapped = source_cfg.get("asset_source_map", {}).get(asset)
|
||||
if mapped:
|
||||
return mapped
|
||||
g10 = asset in {"EURUSD", "GBPUSD", "USDJPY", "AUDUSD", "USDCAD", "USDCHF"}
|
||||
key = "g10" if g10 else "default"
|
||||
return source_cfg.get("source_priority", {}).get(key, ["mock"])
|
||||
|
||||
def _is_enabled(self, source: str) -> bool:
|
||||
return bool(self.config.get("enabled_sources", {}).get(source, False))
|
||||
|
||||
def _tag(self, source: str) -> tuple[str, int]:
|
||||
reliability = self.config.get("reliability_tags", {}).get(source, "low")
|
||||
latency_ms = int(self.config.get("latency_tags_ms", {}).get(source, 1000))
|
||||
return reliability, latency_ms
|
||||
|
||||
def _source_available(self, source: str, now: datetime) -> bool:
|
||||
state = self.source_state.get(source, {"open_until": None})
|
||||
open_until = state.get("open_until")
|
||||
if open_until is None:
|
||||
return True
|
||||
if now >= open_until:
|
||||
state["open_until"] = None
|
||||
state["failures"] = 0
|
||||
self.source_state[source] = state
|
||||
return True
|
||||
return False
|
||||
|
||||
def _register_success(self, source: str) -> None:
|
||||
state = self.source_state.get(source, {"failures": 0, "open_until": None})
|
||||
state["failures"] = 0
|
||||
state["open_until"] = None
|
||||
self.source_state[source] = state
|
||||
|
||||
def _register_failure(self, source: str, now: datetime) -> None:
|
||||
state = self.source_state.get(source, {"failures": 0, "open_until": None})
|
||||
state["failures"] = int(state.get("failures", 0)) + 1
|
||||
if state["failures"] >= self.failure_threshold:
|
||||
state["open_until"] = now + timedelta(seconds=self.cooldown_seconds)
|
||||
self.logger.warning(
|
||||
"Opening circuit for source=%s until %s after %s failures",
|
||||
source,
|
||||
state["open_until"].isoformat(),
|
||||
state["failures"],
|
||||
)
|
||||
self.source_state[source] = state
|
||||
|
||||
def fetch_price_data(self, asset: str, start: datetime, end: datetime) -> DataFetchResult:
|
||||
return self._fetch_with_fallback("price", asset, start, end)
|
||||
|
||||
def fetch_macro_data(self, key: str, start: datetime, end: datetime) -> DataFetchResult:
|
||||
return self._fetch_with_fallback("macro", key, start, end)
|
||||
|
||||
def fetch_rate_data(self, asset: str, start: datetime, end: datetime) -> DataFetchResult:
|
||||
return self._fetch_with_fallback("rate", asset, start, end)
|
||||
|
||||
def _fetch_with_fallback(
|
||||
self,
|
||||
usage_type: str,
|
||||
key: str,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
) -> DataFetchResult:
|
||||
candidates = self._get_candidate_sources(key)
|
||||
last_err = "No source attempted"
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
for source in candidates:
|
||||
if not self._is_enabled(source):
|
||||
continue
|
||||
if not self._source_available(source, now):
|
||||
self.logger.info("Skipping source=%s due to cooldown window", source)
|
||||
continue
|
||||
|
||||
adapter = self.adapters.get(source)
|
||||
if adapter is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
if usage_type == "price":
|
||||
data = adapter.fetch_price_data(key, start, end)
|
||||
elif usage_type == "macro":
|
||||
data = adapter.fetch_macro_data(key, start, end)
|
||||
else:
|
||||
data = adapter.fetch_rate_data(key, start, end)
|
||||
|
||||
self._register_success(source)
|
||||
reliability, latency_ms = self._tag(source)
|
||||
self.usage_log.append(SourceUsageRecord(asset=key, source=source, usage_type=usage_type))
|
||||
self.logger.info("Fetched %s data for %s via %s", usage_type, key, source)
|
||||
return DataFetchResult(
|
||||
data=data,
|
||||
source=source,
|
||||
reliability=reliability,
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_err = str(exc)
|
||||
self._register_failure(source, now)
|
||||
self.usage_log.append(
|
||||
SourceUsageRecord(
|
||||
asset=key,
|
||||
source=source,
|
||||
usage_type=usage_type,
|
||||
success=False,
|
||||
error=last_err,
|
||||
)
|
||||
)
|
||||
self.logger.warning(
|
||||
"Failed %s data fetch for %s via %s: %s",
|
||||
usage_type,
|
||||
key,
|
||||
source,
|
||||
last_err,
|
||||
)
|
||||
|
||||
return DataFetchResult(
|
||||
data=pd.DataFrame(),
|
||||
source="none",
|
||||
reliability="low",
|
||||
latency_ms=9999,
|
||||
success=False,
|
||||
error=last_err,
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
from fx_quant_engine.integration.hooks import IntegrationHooks
|
||||
|
||||
__all__ = ["IntegrationHooks"]
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from fx_quant_engine.schemas import RegimeState
|
||||
|
||||
|
||||
class IntegrationHooks:
|
||||
def export_scores(self, features: pd.DataFrame, regime: RegimeState) -> dict[str, float]:
|
||||
if features.empty:
|
||||
return {
|
||||
"usd_strength_score": 0.0,
|
||||
"inr_stress_score": 0.0,
|
||||
"carry_attractiveness": 0.0,
|
||||
"fx_volatility_stress": 0.0,
|
||||
"risk_on_off_score": 0.0,
|
||||
}
|
||||
row = features.iloc[-1]
|
||||
return {
|
||||
"usd_strength_score": float(row.get("usd_strength_score", 0.0)),
|
||||
"inr_stress_score": float(row.get("em_stress_proxy", 0.0) + row.get("inr_vol_clustering", 0.0)),
|
||||
"carry_attractiveness": float(row.get("carry_proxy", 0.0)),
|
||||
"fx_volatility_stress": float(row.get("realized_vol", 0.0)),
|
||||
"risk_on_off_score": float(row.get("risk_on_off_proxy", 0.0)),
|
||||
"regime_stability": float(regime.stability),
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
from fx_quant_engine.models.ensemble import EnsembleModel
|
||||
from fx_quant_engine.models.relative_value import RelativeValueModel
|
||||
|
||||
__all__ = ["EnsembleModel", "RelativeValueModel"]
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class EnsembleModel:
|
||||
def __init__(self, config: dict[str, Any]) -> None:
|
||||
self.config = config
|
||||
|
||||
def directional_score(self, row: pd.Series) -> tuple[float, dict[str, float], float]:
|
||||
weights = self.config.get("directional", {}).get("model_weights", {})
|
||||
component = {
|
||||
"trend": float(row.get("trend_strength", 0.0)),
|
||||
"momentum": float(row.get("momentum_multi_horizon", 0.0)),
|
||||
"mean_reversion": -float(row.get("mean_reversion_zscore", 0.0)),
|
||||
"carry": float(row.get("carry_proxy", 0.0)),
|
||||
"macro": float(row.get("risk_on_off_proxy", 0.0)),
|
||||
}
|
||||
wsum = sum(float(weights.get(k, 0.0)) for k in component)
|
||||
score = 0.0
|
||||
if wsum > 0:
|
||||
score = sum(component[k] * float(weights.get(k, 0.0)) for k in component) / wsum
|
||||
normalized = float(np.tanh(score))
|
||||
signs = [np.sign(v) for v in component.values() if abs(v) > 1e-12]
|
||||
agreement = float((abs(sum(signs)) / len(signs))) if signs else 0.0
|
||||
return normalized, component, agreement
|
||||
|
||||
def relative_value_score(self, row_a: pd.Series, row_b: pd.Series) -> tuple[float, dict[str, float], float]:
|
||||
weights = self.config.get("relative_value", {}).get("model_weights", {})
|
||||
spread = float(row_a.get("mean_reversion_zscore", 0.0) - row_b.get("mean_reversion_zscore", 0.0))
|
||||
mom_diff = float(row_a.get("momentum_multi_horizon", 0.0) - row_b.get("momentum_multi_horizon", 0.0))
|
||||
carry_diff = float(row_a.get("carry_proxy", 0.0) - row_b.get("carry_proxy", 0.0))
|
||||
component = {"spread": spread, "momentum_diff": mom_diff, "carry_diff": carry_diff}
|
||||
wsum = sum(float(weights.get(k, 0.0)) for k in component)
|
||||
score = 0.0
|
||||
if wsum > 0:
|
||||
score = sum(component[k] * float(weights.get(k, 0.0)) for k in component) / wsum
|
||||
normalized = float(np.tanh(score))
|
||||
signs = [np.sign(v) for v in component.values() if abs(v) > 1e-12]
|
||||
agreement = float((abs(sum(signs)) / len(signs))) if signs else 0.0
|
||||
return normalized, component, agreement
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Placeholder for future model extensions.
|
||||
|
||||
This module is intentionally lightweight in the starter phase. Future additions can
|
||||
include gradient boosting, probabilistic forecasts, and online learning modules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelExtensionConfig:
|
||||
enabled: bool = False
|
||||
name: str = "baseline_extension"
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
@dataclass
|
||||
class RelativeValueResult:
|
||||
score: float
|
||||
hedge_ratio: float
|
||||
spread_zscore: float
|
||||
expected_volatility: float
|
||||
drivers: dict[str, float]
|
||||
long_asset: str
|
||||
short_asset: str
|
||||
|
||||
|
||||
class RelativeValueModel:
|
||||
def __init__(self, lookback: int = 60) -> None:
|
||||
self.lookback = lookback
|
||||
|
||||
def generate(
|
||||
self,
|
||||
asset_a: str,
|
||||
asset_b: str,
|
||||
prices_a: pd.Series,
|
||||
prices_b: pd.Series,
|
||||
features_a: pd.DataFrame,
|
||||
features_b: pd.DataFrame,
|
||||
) -> RelativeValueResult:
|
||||
pa, pb = self._align(prices_a, prices_b)
|
||||
la = pa.astype(float).apply(np.log)
|
||||
lb = pb.astype(float).apply(np.log)
|
||||
|
||||
hedge_ratio = self._hedge_ratio(la.tail(self.lookback), lb.tail(self.lookback))
|
||||
spread = la - hedge_ratio * lb
|
||||
|
||||
window = max(20, self.lookback // 2)
|
||||
zscore = self._zscore(spread, window=window)
|
||||
spread_z = float(zscore.iloc[-1]) if len(zscore) else 0.0
|
||||
|
||||
row_a = features_a.iloc[-1]
|
||||
row_b = features_b.iloc[-1]
|
||||
momentum_diff = float(row_a.get("momentum_multi_horizon", 0.0) - row_b.get("momentum_multi_horizon", 0.0))
|
||||
carry_diff = float(row_a.get("carry_proxy", 0.0) - row_b.get("carry_proxy", 0.0))
|
||||
|
||||
raw = -0.60 * spread_z + 0.25 * momentum_diff + 0.15 * carry_diff
|
||||
score = float(np.tanh(raw))
|
||||
|
||||
if score >= 0:
|
||||
long_asset, short_asset = asset_a, asset_b
|
||||
else:
|
||||
long_asset, short_asset = asset_b, asset_a
|
||||
|
||||
expected_vol = float(spread.diff().tail(window).std() * np.sqrt(252.0)) if len(spread) > 2 else 0.0
|
||||
drivers = {
|
||||
"spread_zscore": spread_z,
|
||||
"momentum_diff": momentum_diff,
|
||||
"carry_diff": carry_diff,
|
||||
"hedge_ratio": float(hedge_ratio),
|
||||
}
|
||||
return RelativeValueResult(
|
||||
score=abs(score),
|
||||
hedge_ratio=float(hedge_ratio),
|
||||
spread_zscore=spread_z,
|
||||
expected_volatility=max(expected_vol, 0.0),
|
||||
drivers=drivers,
|
||||
long_asset=long_asset,
|
||||
short_asset=short_asset,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _align(a: pd.Series, b: pd.Series) -> tuple[pd.Series, pd.Series]:
|
||||
joined = pd.concat([a.rename("a"), b.rename("b")], axis=1).dropna()
|
||||
return joined["a"], joined["b"]
|
||||
|
||||
@staticmethod
|
||||
def _hedge_ratio(a: pd.Series, b: pd.Series) -> float:
|
||||
denom = float(np.var(b))
|
||||
if abs(denom) < 1e-12:
|
||||
return 1.0
|
||||
return float(np.cov(a, b)[0, 1] / denom)
|
||||
|
||||
@staticmethod
|
||||
def _zscore(series: pd.Series, window: int) -> pd.Series:
|
||||
mu = series.rolling(window).mean()
|
||||
sigma = series.rolling(window).std()
|
||||
return ((series - mu) / (sigma + 1e-9)).fillna(0.0)
|
||||
@@ -0,0 +1,3 @@
|
||||
from fx_quant_engine.outputs.formatter import OutputFormatter
|
||||
|
||||
__all__ = ["OutputFormatter"]
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fx_quant_engine.schemas import SignalPayload
|
||||
|
||||
|
||||
class OutputFormatter:
|
||||
def __init__(self, config: dict[str, Any]) -> None:
|
||||
self.config = config.get("output", {})
|
||||
|
||||
def to_json_payload(self, signal: SignalPayload) -> dict[str, Any]:
|
||||
p = self.config.get("precision", 4)
|
||||
data = signal.to_dict()
|
||||
data["signal_strength"] = round(float(data["signal_strength"]), p)
|
||||
data["confidence"] = round(float(data["confidence"]), p)
|
||||
data["expected_volatility"] = round(float(data["expected_volatility"]), p)
|
||||
data["position_sizing_multiplier"] = round(float(data["position_sizing_multiplier"]), p)
|
||||
return data
|
||||
|
||||
def trader_summary(self, signal: SignalPayload) -> str:
|
||||
return (
|
||||
f"{signal.asset} | {signal.signal_type} | {signal.signal_direction} | "
|
||||
f"strength={signal.signal_strength:.2f} confidence={signal.confidence:.2f} "
|
||||
f"action={signal.recommended_action} size={signal.position_sizing_multiplier:.2f} "
|
||||
f"regime={signal.regime.get('combined', 'neutral')}"
|
||||
)
|
||||
|
||||
def dashboard_payload(self, signal: SignalPayload) -> dict[str, Any]:
|
||||
return {
|
||||
"asset": signal.asset,
|
||||
"score": signal.signal_strength,
|
||||
"confidence": signal.confidence,
|
||||
"action": signal.recommended_action,
|
||||
"risk_flags": signal.risk_flags,
|
||||
"regime": signal.regime,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
from fx_quant_engine.preprocessing.cleaning import preprocess_market_data
|
||||
|
||||
__all__ = ["preprocess_market_data"]
|
||||
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def preprocess_market_data(df: pd.DataFrame) -> pd.DataFrame:
|
||||
if df.empty:
|
||||
return df
|
||||
out = df.copy().sort_index()
|
||||
out = out[~out.index.duplicated(keep="last")]
|
||||
out = out.ffill().dropna(how="all")
|
||||
return out
|
||||
@@ -0,0 +1,3 @@
|
||||
from fx_quant_engine.regime.engine import RegimeEngine
|
||||
|
||||
__all__ = ["RegimeEngine"]
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from fx_quant_engine.schemas import RegimeState
|
||||
|
||||
|
||||
class RegimeEngine:
|
||||
def __init__(self, config: dict[str, Any]) -> None:
|
||||
self.config = config
|
||||
|
||||
def detect(self, features: pd.DataFrame) -> RegimeState:
|
||||
if features.empty:
|
||||
return RegimeState("neutral", "normal", "neutral", "risk_on", "neutral", 0.0)
|
||||
|
||||
row = features.iloc[-1]
|
||||
trend = self._trend_regime(row)
|
||||
vol = self._vol_regime(features)
|
||||
dollar = self._dollar_regime(row)
|
||||
risk = self._risk_regime(row, vol)
|
||||
combined = self._combine(trend, vol, dollar, risk)
|
||||
stability = self._stability(features)
|
||||
return RegimeState(trend, vol, dollar, risk, combined, stability)
|
||||
|
||||
def _trend_regime(self, row: pd.Series) -> str:
|
||||
score = float(row.get("trend_strength", 0.0))
|
||||
th = float(self.config.get("trend", {}).get("slope_threshold", 0.0005))
|
||||
band = float(self.config.get("trend", {}).get("mr_band", 0.003))
|
||||
if score > th:
|
||||
return "trend"
|
||||
if score < -band:
|
||||
return "mean_reversion"
|
||||
return "neutral"
|
||||
|
||||
def _vol_regime(self, features: pd.DataFrame) -> str:
|
||||
vol = features.get("realized_vol", pd.Series(0.0, index=features.index)).fillna(0.0)
|
||||
cur = float(vol.iloc[-1])
|
||||
ql = float(vol.quantile(self.config.get("volatility", {}).get("low_quantile", 0.3)))
|
||||
qh = float(vol.quantile(self.config.get("volatility", {}).get("high_quantile", 0.7)))
|
||||
qs = float(vol.quantile(self.config.get("volatility", {}).get("stress_quantile", 0.9)))
|
||||
if cur >= qs:
|
||||
return "stress"
|
||||
if cur >= qh:
|
||||
return "high"
|
||||
if cur <= ql:
|
||||
return "low"
|
||||
return "normal"
|
||||
|
||||
def _dollar_regime(self, row: pd.Series) -> str:
|
||||
usd = float(row.get("usd_strength_score", 0.0))
|
||||
band = float(self.config.get("dollar", {}).get("neutral_band", 0.2))
|
||||
if usd > band:
|
||||
return "strengthening"
|
||||
if usd < -band:
|
||||
return "weakening"
|
||||
return "neutral"
|
||||
|
||||
def _risk_regime(self, row: pd.Series, vol_regime: str) -> str:
|
||||
risk_score = float(row.get("risk_on_off_proxy", 0.0))
|
||||
threshold = float(self.config.get("risk", {}).get("risk_off_equity_threshold", -0.01))
|
||||
if vol_regime == "stress":
|
||||
return "stressed"
|
||||
if risk_score < threshold:
|
||||
return "risk_off"
|
||||
return "risk_on"
|
||||
|
||||
def _combine(self, trend: str, vol: str, dollar: str, risk: str) -> str:
|
||||
if vol == "stress" or risk == "stressed":
|
||||
return "defensive"
|
||||
if trend == "trend" and risk == "risk_on":
|
||||
return "pro_trend"
|
||||
if trend == "mean_reversion" and risk != "stressed":
|
||||
return "mean_revert"
|
||||
if dollar == "strengthening":
|
||||
return "usd_dominant"
|
||||
return "neutral"
|
||||
|
||||
def _stability(self, features: pd.DataFrame) -> float:
|
||||
cols = [c for c in ["trend_strength", "realized_vol", "usd_strength_score", "risk_on_off_proxy"] if c in features]
|
||||
if not cols:
|
||||
return 0.0
|
||||
tail = features[cols].tail(10)
|
||||
vol = float(tail.std().mean())
|
||||
return float(np.clip(1.0 - vol, 0.0, 1.0))
|
||||
@@ -0,0 +1,3 @@
|
||||
from fx_quant_engine.risk.engine import RiskEngine
|
||||
|
||||
__all__ = ["RiskEngine"]
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fx_quant_engine.schemas import RegimeState, SignalPayload
|
||||
from fx_quant_engine.utils.time import is_within_market_hours
|
||||
|
||||
|
||||
class RiskEngine:
|
||||
def __init__(self, config: dict[str, Any]) -> None:
|
||||
self.config = config.get("risk", {})
|
||||
|
||||
def apply(self, signal: SignalPayload, regime: RegimeState, reliability: str, liquidity_ok: bool, event_risk: float) -> SignalPayload:
|
||||
penalties = self.config.get("data_reliability_penalty", {})
|
||||
rel_mult = float(penalties.get(reliability, 0.6))
|
||||
size = signal.signal_strength * signal.confidence * rel_mult
|
||||
|
||||
if regime.volatility in {"high", "stress"}:
|
||||
size *= float(self.config.get("stressed_regime_multiplier", 0.5))
|
||||
signal.risk_flags.append("high_volatility_regime")
|
||||
|
||||
if not liquidity_ok:
|
||||
size *= float(self.config.get("illiquid_market_multiplier", 0.6))
|
||||
signal.risk_flags.append("liquidity_constraint")
|
||||
|
||||
if event_risk > 0.6:
|
||||
size *= 0.5
|
||||
signal.risk_flags.append("event_risk_high")
|
||||
|
||||
if not self._market_open_now():
|
||||
signal.risk_flags.append("outside_primary_market_hours")
|
||||
size *= 0.8
|
||||
|
||||
signal.position_sizing_multiplier = max(0.0, min(size, 1.0))
|
||||
signal.recommended_action = self._action(signal.position_sizing_multiplier)
|
||||
return signal
|
||||
|
||||
def _market_open_now(self) -> bool:
|
||||
now = datetime.now(timezone.utc)
|
||||
open_utc = self.config.get("market_hours", {}).get("inr_derivatives_open_utc", "03:45")
|
||||
close_utc = self.config.get("market_hours", {}).get("inr_derivatives_close_utc", "10:00")
|
||||
return is_within_market_hours(now, open_utc, close_utc)
|
||||
|
||||
@staticmethod
|
||||
def _action(size: float) -> str:
|
||||
if size >= 0.75:
|
||||
return "enter"
|
||||
if size >= 0.45:
|
||||
return "enter_small"
|
||||
if size >= 0.25:
|
||||
return "hold"
|
||||
if size > 0.0:
|
||||
return "reduce"
|
||||
return "no_trade"
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataFetchResult:
|
||||
data: pd.DataFrame
|
||||
source: str
|
||||
reliability: str
|
||||
latency_ms: int
|
||||
success: bool = True
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RegimeState:
|
||||
trend: str
|
||||
volatility: str
|
||||
dollar: str
|
||||
risk: str
|
||||
combined: str
|
||||
stability: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class SignalPayload:
|
||||
engine: str
|
||||
timestamp: str
|
||||
asset: str
|
||||
signal_type: str
|
||||
signal_direction: str
|
||||
signal_strength: float
|
||||
confidence: float
|
||||
regime: dict[str, Any]
|
||||
expected_volatility: float
|
||||
risk_flags: list[str]
|
||||
drivers: dict[str, Any]
|
||||
recommended_action: str
|
||||
position_sizing_multiplier: float
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EvaluationReport:
|
||||
generated_at: str
|
||||
hit_rate: float
|
||||
avg_forward_return: float
|
||||
regime_performance: dict[str, float]
|
||||
signal_decay: dict[str, float]
|
||||
feature_importance: dict[str, float]
|
||||
drift_flags: list[str]
|
||||
calibration_score: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class BacktestResult:
|
||||
pair_level_returns: dict[str, float]
|
||||
portfolio_return: float
|
||||
max_drawdown: float
|
||||
turnover: float
|
||||
metrics: dict[str, float]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SourceUsageRecord:
|
||||
asset: str
|
||||
source: str
|
||||
usage_type: str
|
||||
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
success: bool = True
|
||||
error: str | None = None
|
||||
@@ -0,0 +1,4 @@
|
||||
from fx_quant_engine.signals.confidence import ConfidenceEngine
|
||||
from fx_quant_engine.signals.engine import SignalEngine
|
||||
|
||||
__all__ = ["SignalEngine", "ConfidenceEngine"]
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
class ConfidenceEngine:
|
||||
def __init__(self, config: dict[str, Any]) -> None:
|
||||
self.config = config
|
||||
|
||||
def compute(
|
||||
self,
|
||||
feature_agreement: float,
|
||||
model_agreement: float,
|
||||
regime_stability: float,
|
||||
data_quality: float,
|
||||
volatility: float,
|
||||
event_risk: float,
|
||||
) -> float:
|
||||
c = self.config.get("confidence", {})
|
||||
agreement_w = float(c.get("agreement_weight", 0.25))
|
||||
regime_w = float(c.get("regime_stability_weight", 0.20))
|
||||
data_w = float(c.get("data_quality_weight", 0.20))
|
||||
vol_w = float(c.get("volatility_penalty_weight", 0.20))
|
||||
event_w = float(c.get("event_penalty_weight", 0.15))
|
||||
|
||||
agreement = 0.5 * (feature_agreement + model_agreement)
|
||||
vol_penalty = np.clip(volatility, 0.0, 1.0)
|
||||
event_penalty = np.clip(event_risk, 0.0, 1.0)
|
||||
|
||||
raw = (
|
||||
agreement_w * agreement
|
||||
+ regime_w * np.clip(regime_stability, 0.0, 1.0)
|
||||
+ data_w * np.clip(data_quality, 0.0, 1.0)
|
||||
- vol_w * vol_penalty
|
||||
- event_w * event_penalty
|
||||
)
|
||||
return float(np.clip((raw + 1.0) / 2.0, 0.0, 1.0))
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from fx_quant_engine.schemas import RegimeState, SignalPayload
|
||||
|
||||
|
||||
class SignalEngine:
|
||||
def __init__(self, engine_name: str = "fx_quant_engine") -> None:
|
||||
self.engine_name = engine_name
|
||||
|
||||
def directional_signal(
|
||||
self,
|
||||
asset: str,
|
||||
feature_row: pd.Series,
|
||||
regime: RegimeState,
|
||||
score: float,
|
||||
confidence: float,
|
||||
drivers: dict[str, float],
|
||||
expected_volatility: float,
|
||||
) -> SignalPayload:
|
||||
direction = self._direction(asset, score)
|
||||
return SignalPayload(
|
||||
engine=self.engine_name,
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
asset=asset,
|
||||
signal_type="directional",
|
||||
signal_direction=direction,
|
||||
signal_strength=float(np.clip(abs(score), 0.0, 1.0)),
|
||||
confidence=float(np.clip(confidence, 0.0, 1.0)),
|
||||
regime=regime.__dict__,
|
||||
expected_volatility=float(max(0.0, expected_volatility)),
|
||||
risk_flags=[],
|
||||
drivers={k: float(v) for k, v in drivers.items()},
|
||||
recommended_action="hold",
|
||||
position_sizing_multiplier=0.0,
|
||||
)
|
||||
|
||||
def relative_value_signal(
|
||||
self,
|
||||
long_asset: str,
|
||||
short_asset: str,
|
||||
regime: RegimeState,
|
||||
score: float,
|
||||
confidence: float,
|
||||
drivers: dict[str, float],
|
||||
expected_volatility: float,
|
||||
) -> SignalPayload:
|
||||
direction = f"long_{long_asset}_vs_{short_asset}"
|
||||
asset = f"{long_asset}:{short_asset}"
|
||||
return SignalPayload(
|
||||
engine=self.engine_name,
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
asset=asset,
|
||||
signal_type="relative_value",
|
||||
signal_direction=direction,
|
||||
signal_strength=float(np.clip(abs(score), 0.0, 1.0)),
|
||||
confidence=float(np.clip(confidence, 0.0, 1.0)),
|
||||
regime=regime.__dict__,
|
||||
expected_volatility=float(max(0.0, expected_volatility)),
|
||||
risk_flags=[],
|
||||
drivers={k: float(v) for k, v in drivers.items()},
|
||||
recommended_action="hold",
|
||||
position_sizing_multiplier=0.0,
|
||||
)
|
||||
|
||||
def _direction(self, asset: str, score: float) -> str:
|
||||
if asset.startswith("USD"):
|
||||
return "long_usd" if score >= 0 else "short_usd"
|
||||
return "long_base" if score >= 0 else "short_base"
|
||||
@@ -0,0 +1,4 @@
|
||||
from fx_quant_engine.utils.config import load_all_configs, load_yaml
|
||||
from fx_quant_engine.utils.logging import get_logger
|
||||
|
||||
__all__ = ["load_yaml", "load_all_configs", "get_logger"]
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def load_yaml(path: str | Path) -> dict[str, Any]:
|
||||
with Path(path).open("r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"Config file must contain a mapping: {path}")
|
||||
return data
|
||||
|
||||
|
||||
def load_all_configs(config_dir: str | Path) -> dict[str, dict[str, Any]]:
|
||||
cfg_dir = Path(config_dir)
|
||||
return {
|
||||
"universe": load_yaml(cfg_dir / "universe.yaml"),
|
||||
"data_sources": load_yaml(cfg_dir / "data_sources.yaml"),
|
||||
"features": load_yaml(cfg_dir / "features.yaml"),
|
||||
"regimes": load_yaml(cfg_dir / "regimes.yaml"),
|
||||
"models": load_yaml(cfg_dir / "models.yaml"),
|
||||
"risk": load_yaml(cfg_dir / "risk.yaml"),
|
||||
"output": load_yaml(cfg_dir / "output.yaml"),
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
logger = logging.getLogger(name)
|
||||
if not logger.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
fmt = "%(asctime)s | %(name)s | %(levelname)s | %(message)s"
|
||||
handler.setFormatter(logging.Formatter(fmt))
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
return logger
|
||||
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, time, timezone
|
||||
|
||||
|
||||
def is_within_market_hours(
|
||||
now_utc: datetime,
|
||||
open_hhmm: str,
|
||||
close_hhmm: str,
|
||||
) -> bool:
|
||||
open_h, open_m = [int(x) for x in open_hhmm.split(":")]
|
||||
close_h, close_m = [int(x) for x in close_hhmm.split(":")]
|
||||
open_t = time(hour=open_h, minute=open_m)
|
||||
close_t = time(hour=close_h, minute=close_m)
|
||||
t = now_utc.astimezone(timezone.utc).time()
|
||||
return open_t <= t <= close_t
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
name: options-quant-engine-ci
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "options_quant_engine/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- "options_quant_engine/**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
lint-test-build:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: options_quant_engine
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e .[dev]
|
||||
|
||||
- name: Lint
|
||||
run: ruff check .
|
||||
|
||||
- name: Test
|
||||
run: pytest
|
||||
|
||||
- name: Generate sample run artifact
|
||||
run: python scripts/run_engine.py
|
||||
|
||||
- name: Upload run artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: options-engine-runs
|
||||
path: options_quant_engine/examples/runs/*.json
|
||||
if-no-files-found: warn
|
||||
@@ -0,0 +1,193 @@
|
||||
# options_quant_engine
|
||||
|
||||
A professional, modular, explainable, and production-ready options quant engine with a clean, extensible architecture for standalone deployment.
|
||||
|
||||
## What This Engine Does
|
||||
|
||||
- Ingests market/macro/rate data from multiple pluggable adapters
|
||||
- Uses configurable source priority and fallback logic per asset
|
||||
- Engineers economically meaningful features through a registry-based pipeline
|
||||
- Detects interpretable market regimes
|
||||
- Generates directional and relative-value signals
|
||||
- Separates signal strength from confidence
|
||||
- Applies risk overlays and outputs action recommendations
|
||||
- Supports evaluation and realistic backtest scaffolding
|
||||
- Exports integration hooks for cross-engine multi-asset platforms
|
||||
|
||||
## Underlying Universe
|
||||
|
||||
Primary INR pairs:
|
||||
|
||||
- USDINR
|
||||
- EURINR
|
||||
- GBPINR
|
||||
- JPYINR
|
||||
|
||||
Secondary G10 pairs:
|
||||
|
||||
- EURUSD
|
||||
- GBPUSD
|
||||
- USDJPY
|
||||
- AUDUSD
|
||||
- USDCAD
|
||||
- USDCHF
|
||||
|
||||
## Repository Structure
|
||||
|
||||
- options_quant_engine/ingestion: adapters + fallback router
|
||||
- options_quant_engine/preprocessing: data cleaning/alignment
|
||||
- options_quant_engine/features: registry + modular feature pipeline
|
||||
- options_quant_engine/regime: explainable regime detection
|
||||
- options_quant_engine/models: model scoring and extension points
|
||||
- options_quant_engine/signals: directional/RV signal + confidence engine
|
||||
- options_quant_engine/risk: risk overlays and action mapping
|
||||
- options_quant_engine/evaluation: signal quality diagnostics
|
||||
- options_quant_engine/backtest: no-lookahead pair/portfolio simulation
|
||||
- options_quant_engine/outputs: machine-readable + trader-readable payloads
|
||||
- options_quant_engine/integration: cross-engine score exports
|
||||
- config: YAML-driven behavior
|
||||
- scripts: runnable examples
|
||||
- tests: unit tests
|
||||
- examples: sample payloads and run artifacts
|
||||
|
||||
## Data Adapter Architecture
|
||||
|
||||
Base interface:
|
||||
|
||||
```python
|
||||
class BaseDataAdapter:
|
||||
def fetch_price_data(self, asset, start, end):
|
||||
pass
|
||||
|
||||
def fetch_macro_data(self, key, start, end):
|
||||
pass
|
||||
|
||||
def fetch_rate_data(self, asset, start, end):
|
||||
pass
|
||||
|
||||
def health_check(self):
|
||||
pass
|
||||
```
|
||||
|
||||
Included adapters:
|
||||
|
||||
- BreezeAdapter (ICICI Breeze)
|
||||
- ZerodhaAdapter (optional via config)
|
||||
- NSEAdapter
|
||||
- RBIAdapter
|
||||
- FreeFXAdapter
|
||||
- MockAdapter
|
||||
|
||||
Live API stubs are environment-driven and include strict timeout/retry logic:
|
||||
|
||||
- OQE_HTTP_TIMEOUT_SEC
|
||||
- OQE_HTTP_MAX_ATTEMPTS
|
||||
- OQE_HTTP_BACKOFF_SEC
|
||||
- BREEZE_LIVE_ENABLED, BREEZE_BASE_URL, BREEZE_API_KEY, BREEZE_API_SECRET
|
||||
- ZERODHA_LIVE_ENABLED, ZERODHA_BASE_URL, ZERODHA_API_KEY, ZERODHA_ACCESS_TOKEN
|
||||
- NSE_LIVE_ENABLED, NSE_BASE_URL
|
||||
- RBI_LIVE_ENABLED, RBI_BASE_URL
|
||||
|
||||
## Config-Driven Files
|
||||
|
||||
- config/universe.yaml
|
||||
- config/data_sources.yaml
|
||||
- config/features.yaml
|
||||
- config/regimes.yaml
|
||||
- config/models.yaml
|
||||
- config/risk.yaml
|
||||
- config/output.yaml
|
||||
|
||||
## Quickstart
|
||||
|
||||
1. Create and activate a Python 3.10+ environment.
|
||||
1. Install package and dev dependencies:
|
||||
|
||||
```bash
|
||||
pip install -e .[dev]
|
||||
```
|
||||
|
||||
1. Run tests:
|
||||
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
|
||||
1. Run engine example:
|
||||
|
||||
```bash
|
||||
python scripts/run_engine.py
|
||||
```
|
||||
|
||||
The script saves artifacts to examples/runs.
|
||||
|
||||
## Signal Output Schema
|
||||
|
||||
Each signal includes:
|
||||
|
||||
- direction
|
||||
- signal strength
|
||||
- confidence (separate module)
|
||||
- regime context
|
||||
- expected volatility
|
||||
- key drivers
|
||||
- risk flags
|
||||
- recommended action
|
||||
- position sizing multiplier
|
||||
|
||||
See examples/sample_signal.json.
|
||||
|
||||
## Relative Value Spread Model
|
||||
|
||||
Relative-value signal generation uses a true spread framework:
|
||||
|
||||
- explicit hedge ratio estimation from aligned log prices
|
||||
- spread z-score signal component
|
||||
- pair-level momentum and carry differentials
|
||||
- expected spread volatility and long/short leg assignment
|
||||
|
||||
The output includes `hedge_ratio` and `spread_zscore` in RV payloads.
|
||||
|
||||
## CI
|
||||
|
||||
GitHub Actions workflow: `.github/workflows/ci.yml`
|
||||
|
||||
- ruff lint checks
|
||||
- pytest execution
|
||||
- sample run generation
|
||||
- artifact upload of `examples/runs/*.json`
|
||||
|
||||
## Build Phases Coverage
|
||||
|
||||
Phase 1:
|
||||
|
||||
- repo structure
|
||||
- config system
|
||||
- ingestion + preprocessing
|
||||
- basic features
|
||||
|
||||
Phase 2:
|
||||
|
||||
- regime detection
|
||||
- signal engine
|
||||
- confidence engine
|
||||
- outputs
|
||||
|
||||
Phase 3:
|
||||
|
||||
- risk layer
|
||||
- evaluation framework
|
||||
- backtesting scaffolding
|
||||
|
||||
Phase 4 starter:
|
||||
|
||||
- model extension hooks
|
||||
- integration hooks
|
||||
- tests
|
||||
- docs
|
||||
|
||||
## Notes
|
||||
|
||||
- No credentials are hardcoded.
|
||||
- Live adapter wiring is stubbed with production-style retries/timeouts and env credentials.
|
||||
- Mock pathways remain default for deterministic testing and offline development.
|
||||
@@ -0,0 +1,50 @@
|
||||
enabled_sources:
|
||||
breeze: true
|
||||
zerodha: false
|
||||
nse: true
|
||||
rbi: true
|
||||
freefx: true
|
||||
mock: true
|
||||
|
||||
source_priority:
|
||||
default: [nse, rbi, freefx, mock]
|
||||
g10: [freefx, mock]
|
||||
|
||||
asset_source_map:
|
||||
USDINR: [nse, breeze, rbi, mock]
|
||||
EURINR: [nse, breeze, freefx, mock]
|
||||
GBPINR: [nse, freefx, mock]
|
||||
JPYINR: [nse, freefx, mock]
|
||||
EURUSD: [freefx, mock]
|
||||
GBPUSD: [freefx, mock]
|
||||
USDJPY: [freefx, mock]
|
||||
AUDUSD: [freefx, mock]
|
||||
USDCAD: [freefx, mock]
|
||||
USDCHF: [freefx, mock]
|
||||
|
||||
reliability_tags:
|
||||
breeze: medium
|
||||
zerodha: medium
|
||||
nse: high
|
||||
rbi: high
|
||||
freefx: medium
|
||||
mock: low
|
||||
|
||||
latency_tags_ms:
|
||||
breeze: 350
|
||||
zerodha: 250
|
||||
nse: 500
|
||||
rbi: 800
|
||||
freefx: 700
|
||||
mock: 1
|
||||
|
||||
live_api:
|
||||
breeze_live_enabled: false
|
||||
zerodha_live_enabled: false
|
||||
nse_live_enabled: false
|
||||
rbi_live_enabled: false
|
||||
|
||||
http_controls:
|
||||
timeout_sec: 5
|
||||
max_attempts: 3
|
||||
backoff_sec: 0.5
|
||||
@@ -0,0 +1,28 @@
|
||||
features:
|
||||
returns: true
|
||||
moving_averages: true
|
||||
breakouts: true
|
||||
trend_strength: true
|
||||
momentum_multi_horizon: true
|
||||
momentum_persistence: true
|
||||
momentum_acceleration: true
|
||||
mean_reversion_zscore: true
|
||||
bollinger_position: true
|
||||
realized_vol: true
|
||||
atr_proxy: true
|
||||
vol_regime_proxy: true
|
||||
carry_proxy: true
|
||||
usd_strength_score: true
|
||||
risk_on_off_proxy: true
|
||||
inr_crude_proxy: true
|
||||
em_stress_proxy: true
|
||||
inr_vol_clustering: true
|
||||
rbi_intervention_proxy: true
|
||||
cross_asset_bond_proxy: true
|
||||
cross_asset_commodity_proxy: true
|
||||
cross_asset_equity_proxy: true
|
||||
|
||||
windows:
|
||||
short: 5
|
||||
medium: 20
|
||||
long: 60
|
||||
@@ -0,0 +1,22 @@
|
||||
directional:
|
||||
model_weights:
|
||||
trend: 0.35
|
||||
momentum: 0.25
|
||||
mean_reversion: 0.15
|
||||
carry: 0.10
|
||||
macro: 0.15
|
||||
|
||||
relative_value:
|
||||
lookback: 60
|
||||
spread_zscore_threshold: 1.0
|
||||
model_weights:
|
||||
spread: 0.5
|
||||
momentum_diff: 0.3
|
||||
carry_diff: 0.2
|
||||
|
||||
confidence:
|
||||
agreement_weight: 0.25
|
||||
regime_stability_weight: 0.20
|
||||
data_quality_weight: 0.20
|
||||
volatility_penalty_weight: 0.20
|
||||
event_penalty_weight: 0.15
|
||||
@@ -0,0 +1,5 @@
|
||||
output:
|
||||
include_dashboard_payload: true
|
||||
include_trader_summary: true
|
||||
precision: 4
|
||||
engine_name: options_quant_engine
|
||||
@@ -0,0 +1,15 @@
|
||||
trend:
|
||||
slope_threshold: 0.0005
|
||||
mr_band: 0.003
|
||||
|
||||
volatility:
|
||||
low_quantile: 0.3
|
||||
high_quantile: 0.7
|
||||
stress_quantile: 0.9
|
||||
|
||||
dollar:
|
||||
neutral_band: 0.2
|
||||
|
||||
risk:
|
||||
stress_vol_threshold: 0.75
|
||||
risk_off_equity_threshold: -0.01
|
||||
@@ -0,0 +1,13 @@
|
||||
risk:
|
||||
target_vol: 0.10
|
||||
max_gross_exposure: 1.0
|
||||
max_single_asset_exposure: 0.25
|
||||
stressed_regime_multiplier: 0.5
|
||||
illiquid_market_multiplier: 0.6
|
||||
data_reliability_penalty:
|
||||
high: 1.0
|
||||
medium: 0.85
|
||||
low: 0.6
|
||||
market_hours:
|
||||
inr_derivatives_open_utc: "03:45"
|
||||
inr_derivatives_close_utc: "10:00"
|
||||
@@ -0,0 +1,12 @@
|
||||
primary_inr_pairs:
|
||||
- USDINR
|
||||
- EURINR
|
||||
- GBPINR
|
||||
- JPYINR
|
||||
secondary_g10_pairs:
|
||||
- EURUSD
|
||||
- GBPUSD
|
||||
- USDJPY
|
||||
- AUDUSD
|
||||
- USDCAD
|
||||
- USDCHF
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"engine": "options_quant_engine",
|
||||
"timestamp": "2026-03-30T10:00:00",
|
||||
"asset": "USDINR",
|
||||
"signal_type": "directional",
|
||||
"signal_direction": "long_usd",
|
||||
"signal_strength": 0.74,
|
||||
"confidence": 0.68,
|
||||
"regime": {
|
||||
"trend": "trend",
|
||||
"volatility": "normal",
|
||||
"dollar": "strengthening",
|
||||
"risk": "risk_off",
|
||||
"combined": "usd_dominant",
|
||||
"stability": 0.71
|
||||
},
|
||||
"expected_volatility": 0.089,
|
||||
"risk_flags": ["outside_primary_market_hours"],
|
||||
"drivers": {
|
||||
"trend": 0.51,
|
||||
"momentum": 0.36,
|
||||
"mean_reversion": -0.12,
|
||||
"carry": 0.09,
|
||||
"macro": -0.07
|
||||
},
|
||||
"recommended_action": "enter_small",
|
||||
"position_sizing_multiplier": 0.65
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Options Quant Engine package."""
|
||||
|
||||
from options_quant_engine.engine import OptionsQuantEngine
|
||||
|
||||
__all__ = ["OptionsQuantEngine"]
|
||||
@@ -0,0 +1,3 @@
|
||||
from options_quant_engine.backtest.engine import BacktestEngine
|
||||
|
||||
__all__ = ["BacktestEngine"]
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from options_quant_engine.schemas import BacktestResult
|
||||
|
||||
|
||||
class BacktestEngine:
|
||||
def __init__(self, transaction_cost_bps: float = 1.5, slippage_bps: float = 1.0) -> None:
|
||||
self.tc = transaction_cost_bps / 10000.0
|
||||
self.slippage = slippage_bps / 10000.0
|
||||
|
||||
def simulate_pair(self, prices: pd.Series, signal_strength: pd.Series) -> pd.Series:
|
||||
returns = prices.pct_change().fillna(0.0)
|
||||
position = signal_strength.shift(1).fillna(0.0) # no lookahead
|
||||
traded = position.diff().abs().fillna(0.0)
|
||||
net = position * returns - traded * (self.tc + self.slippage)
|
||||
return net
|
||||
|
||||
def simulate_portfolio(self, pair_returns: dict[str, pd.Series]) -> BacktestResult:
|
||||
if not pair_returns:
|
||||
return BacktestResult({}, 0.0, 0.0, 0.0, {})
|
||||
|
||||
df = pd.DataFrame(pair_returns).fillna(0.0)
|
||||
port = df.mean(axis=1)
|
||||
equity = (1.0 + port).cumprod()
|
||||
dd = equity / equity.cummax() - 1.0
|
||||
|
||||
pair_level = {k: float(v.mean() * 252.0) for k, v in pair_returns.items()}
|
||||
metrics = {
|
||||
"annualized_return": float(port.mean() * 252.0),
|
||||
"annualized_vol": float(port.std() * np.sqrt(252.0)),
|
||||
"sharpe": float((port.mean() / (port.std() + 1e-9)) * np.sqrt(252.0)),
|
||||
}
|
||||
|
||||
return BacktestResult(
|
||||
pair_level_returns=pair_level,
|
||||
portfolio_return=float((equity.iloc[-1] - 1.0) if len(equity) else 0.0),
|
||||
max_drawdown=float(dd.min() if len(dd) else 0.0),
|
||||
turnover=float(df.diff().abs().sum().sum()),
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
def walk_forward(self, prices: pd.Series, signal_strength: pd.Series, train_window: int = 120, test_window: int = 20) -> pd.Series:
|
||||
out = []
|
||||
idx = prices.index
|
||||
i = train_window
|
||||
while i < len(idx):
|
||||
end = min(i + test_window, len(idx))
|
||||
segment = self.simulate_pair(prices.iloc[:end], signal_strength.iloc[:end])
|
||||
out.append(segment.iloc[i:end])
|
||||
i += test_window
|
||||
return pd.concat(out).sort_index() if out else pd.Series(dtype=float)
|
||||
@@ -0,0 +1,210 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from options_quant_engine.features.pipeline import FeaturePipeline
|
||||
from options_quant_engine.ingestion.adapters import (
|
||||
BreezeAdapter,
|
||||
FreeFXAdapter,
|
||||
MockAdapter,
|
||||
NSEAdapter,
|
||||
RBIAdapter,
|
||||
ZerodhaAdapter,
|
||||
)
|
||||
from options_quant_engine.ingestion.router import DataSourceRouter
|
||||
from options_quant_engine.integration.hooks import IntegrationHooks
|
||||
from options_quant_engine.models.ensemble import EnsembleModel
|
||||
from options_quant_engine.models.relative_value import RelativeValueModel
|
||||
from options_quant_engine.outputs.formatter import OutputFormatter
|
||||
from options_quant_engine.preprocessing.cleaning import preprocess_market_data
|
||||
from options_quant_engine.regime.engine import RegimeEngine
|
||||
from options_quant_engine.risk.engine import RiskEngine
|
||||
from options_quant_engine.schemas import SignalPayload
|
||||
from options_quant_engine.signals.confidence import ConfidenceEngine
|
||||
from options_quant_engine.signals.engine import SignalEngine
|
||||
from options_quant_engine.utils.config import load_all_configs
|
||||
|
||||
|
||||
class OptionsQuantEngine:
|
||||
def __init__(self, config_dir: str | Path = "config") -> None:
|
||||
self.config = load_all_configs(config_dir)
|
||||
self.adapters = {
|
||||
"breeze": BreezeAdapter(),
|
||||
"zerodha": ZerodhaAdapter(),
|
||||
"nse": NSEAdapter(),
|
||||
"rbi": RBIAdapter(),
|
||||
"freefx": FreeFXAdapter(),
|
||||
"mock": MockAdapter(),
|
||||
}
|
||||
self.router = DataSourceRouter(self.adapters, self.config["data_sources"])
|
||||
self.features = FeaturePipeline(self.config["features"])
|
||||
self.regimes = RegimeEngine(self.config["regimes"])
|
||||
self.models = EnsembleModel(self.config["models"])
|
||||
self.rv_model = RelativeValueModel(lookback=int(self.config["models"].get("relative_value", {}).get("lookback", 60)))
|
||||
self.confidence = ConfidenceEngine(self.config["models"])
|
||||
self.signals = SignalEngine(engine_name=self.config["output"]["output"].get("engine_name", "options_quant_engine"))
|
||||
self.risk = RiskEngine(self.config["risk"])
|
||||
self.output = OutputFormatter(self.config["output"])
|
||||
self.integration = IntegrationHooks()
|
||||
|
||||
def _asset_context(self, asset: str, start: datetime, end: datetime) -> dict[str, Any]:
|
||||
px_res = self.router.fetch_price_data(asset, start, end)
|
||||
macro_res = self.router.fetch_macro_data("macro_proxy", start, end)
|
||||
rate_res = self.router.fetch_rate_data(asset, start, end)
|
||||
|
||||
if not px_res.success or px_res.data.empty:
|
||||
raise RuntimeError(f"No price data available for {asset}: {px_res.error}")
|
||||
|
||||
df = px_res.data.join(macro_res.data, how="left").join(rate_res.data, how="left")
|
||||
if f"{asset}_rate" in df.columns:
|
||||
df["short_rate"] = df[f"{asset}_rate"]
|
||||
df["usd_rate"] = 0.045
|
||||
df["equity_proxy"] = df["close"].pct_change().rolling(5).mean().fillna(0.0)
|
||||
df["crude_proxy"] = (1.0 + df["close"].pct_change().fillna(0.0)).cumprod()
|
||||
df["emfx_proxy"] = df["close"].pct_change().fillna(0.0)
|
||||
|
||||
clean = preprocess_market_data(df)
|
||||
feat = self.features.transform(clean)
|
||||
regime = self.regimes.detect(feat)
|
||||
return {
|
||||
"price": px_res,
|
||||
"macro": macro_res,
|
||||
"rate": rate_res,
|
||||
"clean": clean,
|
||||
"features": feat,
|
||||
"regime": regime,
|
||||
}
|
||||
|
||||
def run_asset(self, asset: str, start: datetime, end: datetime) -> dict[str, Any]:
|
||||
ctx = self._asset_context(asset, start, end)
|
||||
px_res = ctx["price"]
|
||||
feat = ctx["features"]
|
||||
regime = ctx["regime"]
|
||||
row = feat.iloc[-1]
|
||||
|
||||
score, model_drivers, model_agreement = self.models.directional_score(row)
|
||||
feature_agreement = float((row > 0).mean())
|
||||
data_quality = 1.0 if px_res.reliability == "high" else 0.8 if px_res.reliability == "medium" else 0.6
|
||||
volatility = float(min(1.0, row.get("realized_vol", 0.0)))
|
||||
event_risk = float(min(1.0, row.get("rbi_intervention_proxy", 0.0)))
|
||||
|
||||
conf = self.confidence.compute(
|
||||
feature_agreement=feature_agreement,
|
||||
model_agreement=model_agreement,
|
||||
regime_stability=regime.stability,
|
||||
data_quality=data_quality,
|
||||
volatility=volatility,
|
||||
event_risk=event_risk,
|
||||
)
|
||||
|
||||
sig: SignalPayload = self.signals.directional_signal(
|
||||
asset=asset,
|
||||
feature_row=row,
|
||||
regime=regime,
|
||||
score=score,
|
||||
confidence=conf,
|
||||
drivers=model_drivers,
|
||||
expected_volatility=float(row.get("realized_vol", 0.0)),
|
||||
)
|
||||
sig = self.risk.apply(
|
||||
signal=sig,
|
||||
regime=regime,
|
||||
reliability=px_res.reliability,
|
||||
liquidity_ok=True,
|
||||
event_risk=event_risk,
|
||||
)
|
||||
|
||||
payload = self.output.to_json_payload(sig)
|
||||
summary = self.output.trader_summary(sig)
|
||||
dashboard = self.output.dashboard_payload(sig)
|
||||
hooks = self.integration.export_scores(feat, regime)
|
||||
|
||||
return {
|
||||
"signal": payload,
|
||||
"summary": summary,
|
||||
"dashboard": dashboard,
|
||||
"integration_hooks": hooks,
|
||||
"source_usage": [u.__dict__ for u in self.router.usage_log],
|
||||
}
|
||||
|
||||
def run_relative_value(self, asset_a: str, asset_b: str, start: datetime, end: datetime) -> dict[str, Any]:
|
||||
ctx_a = self._asset_context(asset_a, start, end)
|
||||
ctx_b = self._asset_context(asset_b, start, end)
|
||||
feat_a = ctx_a["features"]
|
||||
feat_b = ctx_b["features"]
|
||||
|
||||
rv = self.rv_model.generate(
|
||||
asset_a=asset_a,
|
||||
asset_b=asset_b,
|
||||
prices_a=ctx_a["clean"]["close"],
|
||||
prices_b=ctx_b["clean"]["close"],
|
||||
features_a=feat_a,
|
||||
features_b=feat_b,
|
||||
)
|
||||
|
||||
regime = ctx_a["regime"] if ctx_a["regime"].stability >= ctx_b["regime"].stability else ctx_b["regime"]
|
||||
driver_vals = [v for k, v in rv.drivers.items() if k != "hedge_ratio"]
|
||||
signs = [np.sign(v) for v in driver_vals if abs(v) > 1e-12]
|
||||
model_agreement = float(abs(sum(signs)) / len(signs)) if signs else 0.0
|
||||
feature_agreement = float((feat_a.iloc[-1] - feat_b.iloc[-1] > 0).mean())
|
||||
|
||||
rel_map = {"high": 1.0, "medium": 0.8, "low": 0.6}
|
||||
q_a = rel_map.get(ctx_a["price"].reliability, 0.6)
|
||||
q_b = rel_map.get(ctx_b["price"].reliability, 0.6)
|
||||
data_quality = float((q_a + q_b) / 2.0)
|
||||
|
||||
volatility = float(min(1.0, rv.expected_volatility))
|
||||
event_risk = float(
|
||||
min(
|
||||
1.0,
|
||||
(
|
||||
float(feat_a.iloc[-1].get("rbi_intervention_proxy", 0.0))
|
||||
+ float(feat_b.iloc[-1].get("rbi_intervention_proxy", 0.0))
|
||||
)
|
||||
/ 2.0,
|
||||
)
|
||||
)
|
||||
|
||||
conf = self.confidence.compute(
|
||||
feature_agreement=feature_agreement,
|
||||
model_agreement=model_agreement,
|
||||
regime_stability=regime.stability,
|
||||
data_quality=data_quality,
|
||||
volatility=volatility,
|
||||
event_risk=event_risk,
|
||||
)
|
||||
|
||||
sig = self.signals.relative_value_signal(
|
||||
long_asset=rv.long_asset,
|
||||
short_asset=rv.short_asset,
|
||||
regime=regime,
|
||||
score=rv.score,
|
||||
confidence=conf,
|
||||
drivers=rv.drivers,
|
||||
expected_volatility=rv.expected_volatility,
|
||||
)
|
||||
reliability = "high" if min(q_a, q_b) >= 1.0 else "medium" if min(q_a, q_b) >= 0.8 else "low"
|
||||
sig = self.risk.apply(
|
||||
signal=sig,
|
||||
regime=regime,
|
||||
reliability=reliability,
|
||||
liquidity_ok=True,
|
||||
event_risk=event_risk,
|
||||
)
|
||||
|
||||
return {
|
||||
"relative_value_pair": f"{asset_a}:{asset_b}",
|
||||
"long": rv.long_asset,
|
||||
"short": rv.short_asset,
|
||||
"spread_score": rv.score,
|
||||
"hedge_ratio": rv.hedge_ratio,
|
||||
"spread_zscore": rv.spread_zscore,
|
||||
"signal": self.output.to_json_payload(sig),
|
||||
"summary": self.output.trader_summary(sig),
|
||||
"dashboard": self.output.dashboard_payload(sig),
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
from options_quant_engine.evaluation.engine import Evaluator
|
||||
|
||||
__all__ = ["Evaluator"]
|
||||
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from options_quant_engine.schemas import EvaluationReport
|
||||
|
||||
|
||||
class Evaluator:
|
||||
def evaluate(
|
||||
self,
|
||||
signals: pd.DataFrame,
|
||||
forward_returns: pd.Series,
|
||||
regimes: pd.Series,
|
||||
feature_importance: dict[str, float],
|
||||
) -> EvaluationReport:
|
||||
if signals.empty or forward_returns.empty:
|
||||
return EvaluationReport(
|
||||
generated_at=datetime.now(timezone.utc).isoformat(),
|
||||
hit_rate=0.0,
|
||||
avg_forward_return=0.0,
|
||||
regime_performance={},
|
||||
signal_decay={},
|
||||
feature_importance=feature_importance,
|
||||
drift_flags=["insufficient_data"],
|
||||
calibration_score=0.0,
|
||||
)
|
||||
|
||||
aligned = signals.join(forward_returns.rename("fwd"), how="inner")
|
||||
aligned = aligned.join(regimes.rename("regime"), how="left").fillna("unknown")
|
||||
pred = np.sign(aligned["signal_strength"] * aligned.get("signal_sign", 1.0))
|
||||
realized = np.sign(aligned["fwd"])
|
||||
hit_rate = float((pred == realized).mean())
|
||||
avg_ret = float(aligned["fwd"].mean())
|
||||
|
||||
regime_perf = {
|
||||
r: float(v)
|
||||
for r, v in aligned.groupby("regime")["fwd"].mean().to_dict().items()
|
||||
}
|
||||
|
||||
signal_decay = {
|
||||
"1d": float(aligned["fwd"].mean()),
|
||||
"5d": float(aligned["fwd"].rolling(5).mean().dropna().mean() if len(aligned) >= 5 else 0.0),
|
||||
"20d": float(aligned["fwd"].rolling(20).mean().dropna().mean() if len(aligned) >= 20 else 0.0),
|
||||
}
|
||||
|
||||
drift = []
|
||||
if abs(avg_ret) < 1e-4:
|
||||
drift.append("low_signal_edge")
|
||||
if hit_rate < 0.48:
|
||||
drift.append("hit_rate_deterioration")
|
||||
|
||||
conf = aligned.get("confidence", pd.Series(0.5, index=aligned.index))
|
||||
calibration_score = float(np.clip(1.0 - abs(conf.mean() - hit_rate), 0.0, 1.0))
|
||||
|
||||
return EvaluationReport(
|
||||
generated_at=datetime.now(timezone.utc).isoformat(),
|
||||
hit_rate=hit_rate,
|
||||
avg_forward_return=avg_ret,
|
||||
regime_performance=regime_perf,
|
||||
signal_decay=signal_decay,
|
||||
feature_importance=feature_importance,
|
||||
drift_flags=drift,
|
||||
calibration_score=calibration_score,
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
from options_quant_engine.features.pipeline import FeaturePipeline
|
||||
|
||||
__all__ = ["FeaturePipeline"]
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
FeatureFn = Callable[[pd.DataFrame, dict[str, Any]], pd.Series]
|
||||
|
||||
|
||||
class FeatureRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._features: dict[str, FeatureFn] = {}
|
||||
|
||||
def register(self, name: str, fn: FeatureFn) -> None:
|
||||
if name in self._features:
|
||||
raise ValueError(f"Feature already registered: {name}")
|
||||
self._features[name] = fn
|
||||
|
||||
def get(self, name: str) -> FeatureFn:
|
||||
return self._features[name]
|
||||
|
||||
def names(self) -> list[str]:
|
||||
return list(self._features.keys())
|
||||
|
||||
|
||||
registry = FeatureRegistry()
|
||||
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from options_quant_engine.features.base import registry
|
||||
|
||||
|
||||
def _close(df: pd.DataFrame) -> pd.Series:
|
||||
if "close" in df.columns:
|
||||
return df["close"]
|
||||
numeric = df.select_dtypes(include="number")
|
||||
if numeric.empty:
|
||||
return pd.Series(0.0, index=df.index)
|
||||
return numeric.iloc[:, 0]
|
||||
|
||||
|
||||
def returns(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
return _close(df).pct_change().fillna(0.0)
|
||||
|
||||
|
||||
def moving_averages(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
w = cfg["windows"]["medium"]
|
||||
c = _close(df)
|
||||
return (c / c.rolling(w).mean() - 1.0).fillna(0.0)
|
||||
|
||||
|
||||
def breakouts(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
w = cfg["windows"]["long"]
|
||||
c = _close(df)
|
||||
high = c.rolling(w).max()
|
||||
low = c.rolling(w).min()
|
||||
rng = (high - low).replace(0.0, np.nan)
|
||||
return ((c - low) / rng).fillna(0.5)
|
||||
|
||||
|
||||
def trend_strength(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
w = cfg["windows"]["medium"]
|
||||
r = returns(df, cfg)
|
||||
return (r.rolling(w).mean() / (r.rolling(w).std() + 1e-9)).fillna(0.0)
|
||||
|
||||
|
||||
def momentum_multi_horizon(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
c = _close(df)
|
||||
s = cfg["windows"]["short"]
|
||||
m = cfg["windows"]["medium"]
|
||||
l = cfg["windows"]["long"]
|
||||
return ((c.pct_change(s) + c.pct_change(m) + c.pct_change(l)) / 3.0).fillna(0.0)
|
||||
|
||||
|
||||
def momentum_persistence(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
r = returns(df, cfg)
|
||||
return r.rolling(cfg["windows"]["short"]).apply(lambda x: float((x > 0).mean()), raw=False).fillna(0.5)
|
||||
|
||||
|
||||
def momentum_acceleration(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
m = momentum_multi_horizon(df, cfg)
|
||||
return m.diff().fillna(0.0)
|
||||
|
||||
|
||||
def mean_reversion_zscore(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
c = _close(df)
|
||||
w = cfg["windows"]["medium"]
|
||||
m = c.rolling(w).mean()
|
||||
s = c.rolling(w).std()
|
||||
return ((c - m) / (s + 1e-9)).fillna(0.0)
|
||||
|
||||
|
||||
def bollinger_position(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
z = mean_reversion_zscore(df, cfg)
|
||||
return (z / 2.0).clip(-1, 1)
|
||||
|
||||
|
||||
def realized_vol(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
r = returns(df, cfg)
|
||||
return (r.rolling(cfg["windows"]["medium"]).std() * np.sqrt(252)).fillna(0.0)
|
||||
|
||||
|
||||
def atr_proxy(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
c = _close(df)
|
||||
return c.diff().abs().rolling(cfg["windows"]["short"]).mean().fillna(0.0)
|
||||
|
||||
|
||||
def vol_regime_proxy(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
vol = realized_vol(df, cfg)
|
||||
return (vol / (vol.rolling(cfg["windows"]["long"]).mean() + 1e-9)).fillna(1.0)
|
||||
|
||||
|
||||
def carry_proxy(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
short_r = df.get("short_rate", pd.Series(0.05, index=df.index))
|
||||
usd_r = df.get("usd_rate", pd.Series(0.045, index=df.index))
|
||||
return (short_r - usd_r).fillna(0.0)
|
||||
|
||||
|
||||
def usd_strength_score(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
r = returns(df, cfg)
|
||||
return (-r.rolling(cfg["windows"]["medium"]).mean()).fillna(0.0)
|
||||
|
||||
|
||||
def risk_on_off_proxy(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
eq = df.get("equity_proxy", returns(df, cfg)).fillna(0.0)
|
||||
vix = df.get("vix_proxy", realized_vol(df, cfg)).fillna(0.0)
|
||||
return (eq - vix).fillna(0.0)
|
||||
|
||||
|
||||
def inr_crude_proxy(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
crude = df.get("crude_proxy", pd.Series(0.0, index=df.index))
|
||||
return crude.pct_change().fillna(0.0)
|
||||
|
||||
|
||||
def em_stress_proxy(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
em = df.get("emfx_proxy", returns(df, cfg)).fillna(0.0)
|
||||
return (-em.rolling(cfg["windows"]["medium"]).mean()).fillna(0.0)
|
||||
|
||||
|
||||
def inr_vol_clustering(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
r = returns(df, cfg)
|
||||
return r.abs().rolling(cfg["windows"]["short"]).mean().fillna(0.0)
|
||||
|
||||
|
||||
def rbi_intervention_proxy(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
z = mean_reversion_zscore(df, cfg)
|
||||
return (z.abs() > 2.0).astype(float)
|
||||
|
||||
|
||||
def cross_asset_bond_proxy(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
y = df.get("bond_yield_proxy", pd.Series(0.0, index=df.index))
|
||||
return y.diff().fillna(0.0)
|
||||
|
||||
|
||||
def cross_asset_commodity_proxy(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
g = df.get("gold_proxy", pd.Series(0.0, index=df.index))
|
||||
o = df.get("oil_proxy", pd.Series(0.0, index=df.index))
|
||||
return (g.pct_change().fillna(0.0) + o.pct_change().fillna(0.0)) / 2.0
|
||||
|
||||
|
||||
def cross_asset_equity_proxy(df: pd.DataFrame, cfg: dict) -> pd.Series:
|
||||
e = df.get("equity_proxy", pd.Series(0.0, index=df.index))
|
||||
return e.pct_change().fillna(0.0)
|
||||
|
||||
|
||||
def register_builtin_features() -> None:
|
||||
registry.register("returns", returns)
|
||||
registry.register("moving_averages", moving_averages)
|
||||
registry.register("breakouts", breakouts)
|
||||
registry.register("trend_strength", trend_strength)
|
||||
registry.register("momentum_multi_horizon", momentum_multi_horizon)
|
||||
registry.register("momentum_persistence", momentum_persistence)
|
||||
registry.register("momentum_acceleration", momentum_acceleration)
|
||||
registry.register("mean_reversion_zscore", mean_reversion_zscore)
|
||||
registry.register("bollinger_position", bollinger_position)
|
||||
registry.register("realized_vol", realized_vol)
|
||||
registry.register("atr_proxy", atr_proxy)
|
||||
registry.register("vol_regime_proxy", vol_regime_proxy)
|
||||
registry.register("carry_proxy", carry_proxy)
|
||||
registry.register("usd_strength_score", usd_strength_score)
|
||||
registry.register("risk_on_off_proxy", risk_on_off_proxy)
|
||||
registry.register("inr_crude_proxy", inr_crude_proxy)
|
||||
registry.register("em_stress_proxy", em_stress_proxy)
|
||||
registry.register("inr_vol_clustering", inr_vol_clustering)
|
||||
registry.register("rbi_intervention_proxy", rbi_intervention_proxy)
|
||||
registry.register("cross_asset_bond_proxy", cross_asset_bond_proxy)
|
||||
registry.register("cross_asset_commodity_proxy", cross_asset_commodity_proxy)
|
||||
registry.register("cross_asset_equity_proxy", cross_asset_equity_proxy)
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from options_quant_engine.features.base import registry
|
||||
from options_quant_engine.features.builtins import register_builtin_features
|
||||
|
||||
|
||||
class FeaturePipeline:
|
||||
def __init__(self, config: dict[str, Any]) -> None:
|
||||
self.config = config
|
||||
if not registry.names():
|
||||
register_builtin_features()
|
||||
|
||||
def transform(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
feature_cfg = self.config.get("features", {})
|
||||
out = pd.DataFrame(index=df.index)
|
||||
for feature_name, enabled in feature_cfg.items():
|
||||
if not enabled:
|
||||
continue
|
||||
if feature_name not in registry.names():
|
||||
continue
|
||||
out[feature_name] = registry.get(feature_name)(df, self.config)
|
||||
return out.fillna(0.0)
|
||||
@@ -0,0 +1,21 @@
|
||||
from options_quant_engine.ingestion.adapters import (
|
||||
BreezeAdapter,
|
||||
FreeFXAdapter,
|
||||
MockAdapter,
|
||||
NSEAdapter,
|
||||
RBIAdapter,
|
||||
ZerodhaAdapter,
|
||||
)
|
||||
from options_quant_engine.ingestion.base import BaseDataAdapter
|
||||
from options_quant_engine.ingestion.router import DataSourceRouter
|
||||
|
||||
__all__ = [
|
||||
"BaseDataAdapter",
|
||||
"BreezeAdapter",
|
||||
"ZerodhaAdapter",
|
||||
"NSEAdapter",
|
||||
"RBIAdapter",
|
||||
"FreeFXAdapter",
|
||||
"MockAdapter",
|
||||
"DataSourceRouter",
|
||||
]
|
||||
@@ -0,0 +1,298 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from options_quant_engine.ingestion.base import BaseDataAdapter
|
||||
from options_quant_engine.ingestion.credentials import APICredentials, load_credentials
|
||||
from options_quant_engine.ingestion.http_client import HttpClient, RetryConfig
|
||||
|
||||
|
||||
def _mock_price_series(asset: str, start: datetime, end: datetime, seed: int) -> pd.DataFrame:
|
||||
idx = pd.date_range(start=start, end=end, freq="B")
|
||||
rng = np.random.default_rng(seed)
|
||||
steps = rng.normal(loc=0.0, scale=0.002, size=len(idx))
|
||||
base = 80.0 if asset.endswith("INR") else 1.1
|
||||
px = base * np.exp(np.cumsum(steps))
|
||||
return pd.DataFrame({"close": px}, index=idx)
|
||||
|
||||
|
||||
def _mock_macro_series(key: str, start: datetime, end: datetime, seed: int) -> pd.DataFrame:
|
||||
idx = pd.date_range(start=start, end=end, freq="B")
|
||||
rng = np.random.default_rng(seed)
|
||||
values = rng.normal(loc=0.0, scale=1.0, size=len(idx))
|
||||
return pd.DataFrame({key: values}, index=idx)
|
||||
|
||||
|
||||
def _mock_rate_series(asset: str, start: datetime, end: datetime, seed: int) -> pd.DataFrame:
|
||||
idx = pd.date_range(start=start, end=end, freq="B")
|
||||
rng = np.random.default_rng(seed)
|
||||
rate = 0.05 + rng.normal(loc=0.0, scale=0.002, size=len(idx))
|
||||
return pd.DataFrame({f"{asset}_rate": rate}, index=idx)
|
||||
|
||||
|
||||
def _retry_config_from_env() -> RetryConfig:
|
||||
return RetryConfig(
|
||||
timeout_sec=float(os.getenv("OQE_HTTP_TIMEOUT_SEC", "5.0")),
|
||||
max_attempts=int(os.getenv("OQE_HTTP_MAX_ATTEMPTS", "3")),
|
||||
backoff_sec=float(os.getenv("OQE_HTTP_BACKOFF_SEC", "0.5")),
|
||||
)
|
||||
|
||||
|
||||
def _to_price_frame(payload: dict[str, Any], start: datetime, end: datetime) -> pd.DataFrame:
|
||||
records = payload.get("data", [])
|
||||
if not isinstance(records, list) or not records:
|
||||
raise RuntimeError("Price payload missing 'data' list")
|
||||
df = pd.DataFrame(records)
|
||||
if "timestamp" in df.columns:
|
||||
df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True, errors="coerce")
|
||||
df = df.dropna(subset=["timestamp"]).set_index("timestamp")
|
||||
elif "date" in df.columns:
|
||||
df["date"] = pd.to_datetime(df["date"], utc=True, errors="coerce")
|
||||
df = df.dropna(subset=["date"]).set_index("date")
|
||||
else:
|
||||
idx = pd.date_range(start=start, end=end, periods=len(df), tz="UTC")
|
||||
df.index = idx
|
||||
if "close" not in df.columns:
|
||||
if "price" in df.columns:
|
||||
df = df.rename(columns={"price": "close"})
|
||||
else:
|
||||
raise RuntimeError("Price payload missing close/price column")
|
||||
return df[["close"]].sort_index()
|
||||
|
||||
|
||||
def _to_series_frame(payload: dict[str, Any], key: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
records = payload.get("data", [])
|
||||
if not isinstance(records, list) or not records:
|
||||
raise RuntimeError("Series payload missing 'data' list")
|
||||
df = pd.DataFrame(records)
|
||||
if "timestamp" in df.columns:
|
||||
df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True, errors="coerce")
|
||||
df = df.dropna(subset=["timestamp"]).set_index("timestamp")
|
||||
elif "date" in df.columns:
|
||||
df["date"] = pd.to_datetime(df["date"], utc=True, errors="coerce")
|
||||
df = df.dropna(subset=["date"]).set_index("date")
|
||||
else:
|
||||
idx = pd.date_range(start=start, end=end, periods=len(df), tz="UTC")
|
||||
df.index = idx
|
||||
if key not in df.columns:
|
||||
# Accept first numeric column as a tolerant API stub fallback.
|
||||
numeric = df.select_dtypes(include="number")
|
||||
if numeric.empty:
|
||||
raise RuntimeError(f"Series payload missing '{key}'")
|
||||
return numeric.iloc[:, :1].rename(columns={numeric.columns[0]: key})
|
||||
return df[[key]].sort_index()
|
||||
|
||||
|
||||
def _live_enabled(env_key: str) -> bool:
|
||||
return os.getenv(env_key, "false").lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BreezeAdapter(BaseDataAdapter):
|
||||
use_live: bool = False
|
||||
base_url: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.use_live = self.use_live or _live_enabled("BREEZE_LIVE_ENABLED")
|
||||
self.base_url = self.base_url or os.getenv("BREEZE_BASE_URL")
|
||||
self.client = HttpClient(_retry_config_from_env())
|
||||
|
||||
def _creds(self) -> APICredentials:
|
||||
return load_credentials("BREEZE", required=["API_KEY", "API_SECRET"], optional=["SESSION_TOKEN"])
|
||||
|
||||
def fetch_price_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
if self.use_live:
|
||||
if not self.base_url:
|
||||
raise RuntimeError("Breeze live enabled but BREEZE_BASE_URL not configured")
|
||||
creds = self._creds()
|
||||
payload = self.client.get_json(
|
||||
f"{self.base_url.rstrip('/')}/api/v1/price",
|
||||
params={"symbol": asset, "start": start.isoformat(), "end": end.isoformat()},
|
||||
headers={"X-API-KEY": creds.get("api_key", "")},
|
||||
)
|
||||
return _to_price_frame(payload, start, end)
|
||||
return _mock_price_series(asset, start, end, seed=1)
|
||||
|
||||
def fetch_macro_data(self, key: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
if self.use_live:
|
||||
if not self.base_url:
|
||||
raise RuntimeError("Breeze live enabled but BREEZE_BASE_URL not configured")
|
||||
creds = self._creds()
|
||||
payload = self.client.get_json(
|
||||
f"{self.base_url.rstrip('/')}/api/v1/macro",
|
||||
params={"key": key, "start": start.isoformat(), "end": end.isoformat()},
|
||||
headers={"X-API-KEY": creds.get("api_key", "")},
|
||||
)
|
||||
return _to_series_frame(payload, key, start, end)
|
||||
return _mock_macro_series(key, start, end, seed=2)
|
||||
|
||||
def fetch_rate_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
if self.use_live:
|
||||
if not self.base_url:
|
||||
raise RuntimeError("Breeze live enabled but BREEZE_BASE_URL not configured")
|
||||
creds = self._creds()
|
||||
col = f"{asset}_rate"
|
||||
payload = self.client.get_json(
|
||||
f"{self.base_url.rstrip('/')}/api/v1/rates",
|
||||
params={"symbol": asset, "start": start.isoformat(), "end": end.isoformat()},
|
||||
headers={"X-API-KEY": creds.get("api_key", "")},
|
||||
)
|
||||
return _to_series_frame(payload, col, start, end)
|
||||
return _mock_rate_series(asset, start, end, seed=3)
|
||||
|
||||
def health_check(self) -> bool:
|
||||
if not self.use_live:
|
||||
return True
|
||||
return bool(self.base_url)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZerodhaAdapter(BaseDataAdapter):
|
||||
use_live: bool = False
|
||||
base_url: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.use_live = self.use_live or _live_enabled("ZERODHA_LIVE_ENABLED")
|
||||
self.base_url = self.base_url or os.getenv("ZERODHA_BASE_URL")
|
||||
self.client = HttpClient(_retry_config_from_env())
|
||||
|
||||
def _creds(self) -> APICredentials:
|
||||
return load_credentials("ZERODHA", required=["API_KEY", "ACCESS_TOKEN"])
|
||||
|
||||
def fetch_price_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
if self.use_live:
|
||||
if not self.base_url:
|
||||
raise RuntimeError("Zerodha live enabled but ZERODHA_BASE_URL not configured")
|
||||
creds = self._creds()
|
||||
payload = self.client.get_json(
|
||||
f"{self.base_url.rstrip('/')}/instruments/historical",
|
||||
params={"symbol": asset, "start": start.isoformat(), "end": end.isoformat()},
|
||||
headers={
|
||||
"X-Kite-Version": "3",
|
||||
"Authorization": f"token {creds.get('api_key', '')}:{creds.get('access_token', '')}",
|
||||
},
|
||||
)
|
||||
return _to_price_frame(payload, start, end)
|
||||
return _mock_price_series(asset, start, end, seed=4)
|
||||
|
||||
def fetch_macro_data(self, key: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
raise NotImplementedError("Zerodha macro data not supported")
|
||||
|
||||
def fetch_rate_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
raise NotImplementedError("Zerodha rates not supported")
|
||||
|
||||
def health_check(self) -> bool:
|
||||
if not self.use_live:
|
||||
return True
|
||||
return bool(self.base_url)
|
||||
|
||||
|
||||
class NSEAdapter(BaseDataAdapter):
|
||||
def __init__(self, use_live: bool = False, base_url: str | None = None) -> None:
|
||||
self.use_live = use_live or _live_enabled("NSE_LIVE_ENABLED")
|
||||
self.base_url = base_url or os.getenv("NSE_BASE_URL", "https://www.nseindia.com")
|
||||
self.client = HttpClient(_retry_config_from_env())
|
||||
|
||||
def fetch_price_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
if self.use_live:
|
||||
payload = self.client.get_json(
|
||||
f"{self.base_url.rstrip('/')}/api/historical/foCPV",
|
||||
params={"symbol": asset, "from": start.strftime("%d-%m-%Y"), "to": end.strftime("%d-%m-%Y")},
|
||||
headers={"User-Agent": "options-quant-engine/0.1"},
|
||||
)
|
||||
return _to_price_frame(payload, start, end)
|
||||
return _mock_price_series(asset, start, end, seed=5)
|
||||
|
||||
def fetch_macro_data(self, key: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
if self.use_live:
|
||||
payload = self.client.get_json(
|
||||
f"{self.base_url.rstrip('/')}/api/allIndices",
|
||||
headers={"User-Agent": "options-quant-engine/0.1"},
|
||||
)
|
||||
return _to_series_frame(payload, key, start, end)
|
||||
return _mock_macro_series(key, start, end, seed=6)
|
||||
|
||||
def fetch_rate_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
if self.use_live:
|
||||
col = f"{asset}_rate"
|
||||
payload = self.client.get_json(
|
||||
f"{self.base_url.rstrip('/')}/api/live-analysis-oi-spurts-underlyings",
|
||||
headers={"User-Agent": "options-quant-engine/0.1"},
|
||||
)
|
||||
return _to_series_frame(payload, col, start, end)
|
||||
return _mock_rate_series(asset, start, end, seed=7)
|
||||
|
||||
def health_check(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class RBIAdapter(BaseDataAdapter):
|
||||
def __init__(self, use_live: bool = False, base_url: str | None = None) -> None:
|
||||
self.use_live = use_live or _live_enabled("RBI_LIVE_ENABLED")
|
||||
self.base_url = base_url or os.getenv("RBI_BASE_URL", "https://data.rbi.org.in")
|
||||
self.client = HttpClient(_retry_config_from_env())
|
||||
|
||||
def fetch_price_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
if self.use_live:
|
||||
payload = self.client.get_json(
|
||||
f"{self.base_url.rstrip('/')}/api/exchange-rate",
|
||||
params={"pair": asset, "from": start.isoformat(), "to": end.isoformat()},
|
||||
)
|
||||
return _to_price_frame(payload, start, end)
|
||||
return _mock_price_series(asset, start, end, seed=8)
|
||||
|
||||
def fetch_macro_data(self, key: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
if self.use_live:
|
||||
payload = self.client.get_json(
|
||||
f"{self.base_url.rstrip('/')}/api/macro",
|
||||
params={"series": key, "from": start.isoformat(), "to": end.isoformat()},
|
||||
)
|
||||
return _to_series_frame(payload, key, start, end)
|
||||
return _mock_macro_series(key, start, end, seed=9)
|
||||
|
||||
def fetch_rate_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
if self.use_live:
|
||||
col = f"{asset}_rate"
|
||||
payload = self.client.get_json(
|
||||
f"{self.base_url.rstrip('/')}/api/policy-rates",
|
||||
params={"asset": asset, "from": start.isoformat(), "to": end.isoformat()},
|
||||
)
|
||||
return _to_series_frame(payload, col, start, end)
|
||||
return _mock_rate_series(asset, start, end, seed=10)
|
||||
|
||||
def health_check(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class FreeFXAdapter(BaseDataAdapter):
|
||||
def fetch_price_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
return _mock_price_series(asset, start, end, seed=11)
|
||||
|
||||
def fetch_macro_data(self, key: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
return _mock_macro_series(key, start, end, seed=12)
|
||||
|
||||
def fetch_rate_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
return _mock_rate_series(asset, start, end, seed=13)
|
||||
|
||||
def health_check(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class MockAdapter(BaseDataAdapter):
|
||||
def fetch_price_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
return _mock_price_series(asset, start, end, seed=42)
|
||||
|
||||
def fetch_macro_data(self, key: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
return _mock_macro_series(key, start, end, seed=43)
|
||||
|
||||
def fetch_rate_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
return _mock_rate_series(asset, start, end, seed=44)
|
||||
|
||||
def health_check(self) -> bool:
|
||||
return True
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class BaseDataAdapter(ABC):
|
||||
@abstractmethod
|
||||
def fetch_price_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def fetch_macro_data(self, key: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def fetch_rate_data(self, asset: str, start: datetime, end: datetime) -> pd.DataFrame:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def health_check(self) -> bool:
|
||||
pass
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class APICredentials:
|
||||
values: dict[str, str]
|
||||
|
||||
def get(self, key: str, default: str | None = None) -> str | None:
|
||||
return self.values.get(key, default)
|
||||
|
||||
|
||||
def load_credentials(prefix: str, required: list[str], optional: list[str] | None = None) -> APICredentials:
|
||||
optional = optional or []
|
||||
payload: dict[str, str] = {}
|
||||
|
||||
missing: list[str] = []
|
||||
for key in required:
|
||||
env_key = f"{prefix}_{key}"
|
||||
value = os.getenv(env_key)
|
||||
if not value:
|
||||
missing.append(env_key)
|
||||
continue
|
||||
payload[key.lower()] = value
|
||||
|
||||
for key in optional:
|
||||
env_key = f"{prefix}_{key}"
|
||||
value = os.getenv(env_key)
|
||||
if value:
|
||||
payload[key.lower()] = value
|
||||
|
||||
if missing:
|
||||
raise RuntimeError(f"Missing required credentials: {', '.join(missing)}")
|
||||
return APICredentials(values=payload)
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
@dataclass
|
||||
class RetryConfig:
|
||||
timeout_sec: float = 5.0
|
||||
max_attempts: int = 3
|
||||
backoff_sec: float = 0.5
|
||||
|
||||
|
||||
class HttpClient:
|
||||
def __init__(self, config: RetryConfig) -> None:
|
||||
self.config = config
|
||||
|
||||
def get_json(self, url: str, params: dict[str, Any] | None = None, headers: dict[str, str] | None = None) -> dict[str, Any]:
|
||||
last_err = "unknown"
|
||||
for attempt in range(1, self.config.max_attempts + 1):
|
||||
try:
|
||||
response = requests.get(url, params=params, headers=headers, timeout=self.config.timeout_sec)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError("Expected JSON object response")
|
||||
return data
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_err = str(exc)
|
||||
if attempt < self.config.max_attempts:
|
||||
time.sleep(self.config.backoff_sec * attempt)
|
||||
|
||||
raise RuntimeError(
|
||||
f"HTTP GET failed after {self.config.max_attempts} attempts for {url}: {last_err}"
|
||||
)
|
||||
@@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from options_quant_engine.ingestion.base import BaseDataAdapter
|
||||
from options_quant_engine.schemas import DataFetchResult, SourceUsageRecord
|
||||
from options_quant_engine.utils.logging import get_logger
|
||||
|
||||
|
||||
class DataSourceRouter:
|
||||
def __init__(
|
||||
self,
|
||||
adapters: dict[str, BaseDataAdapter],
|
||||
config: dict[str, Any],
|
||||
) -> None:
|
||||
self.adapters = adapters
|
||||
self.config = config
|
||||
self.logger = get_logger(self.__class__.__name__)
|
||||
self.usage_log: list[SourceUsageRecord] = []
|
||||
|
||||
def _get_candidate_sources(self, asset: str) -> list[str]:
|
||||
source_cfg = self.config
|
||||
mapped = source_cfg.get("asset_source_map", {}).get(asset)
|
||||
if mapped:
|
||||
return mapped
|
||||
g10 = asset in {"EURUSD", "GBPUSD", "USDJPY", "AUDUSD", "USDCAD", "USDCHF"}
|
||||
key = "g10" if g10 else "default"
|
||||
return source_cfg.get("source_priority", {}).get(key, ["mock"])
|
||||
|
||||
def _is_enabled(self, source: str) -> bool:
|
||||
return bool(self.config.get("enabled_sources", {}).get(source, False))
|
||||
|
||||
def _tag(self, source: str) -> tuple[str, int]:
|
||||
reliability = self.config.get("reliability_tags", {}).get(source, "low")
|
||||
latency_ms = int(self.config.get("latency_tags_ms", {}).get(source, 1000))
|
||||
return reliability, latency_ms
|
||||
|
||||
def fetch_price_data(self, asset: str, start: datetime, end: datetime) -> DataFetchResult:
|
||||
return self._fetch_with_fallback("price", asset, start, end)
|
||||
|
||||
def fetch_macro_data(self, key: str, start: datetime, end: datetime) -> DataFetchResult:
|
||||
return self._fetch_with_fallback("macro", key, start, end)
|
||||
|
||||
def fetch_rate_data(self, asset: str, start: datetime, end: datetime) -> DataFetchResult:
|
||||
return self._fetch_with_fallback("rate", asset, start, end)
|
||||
|
||||
def _fetch_with_fallback(
|
||||
self,
|
||||
usage_type: str,
|
||||
key: str,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
) -> DataFetchResult:
|
||||
candidates = self._get_candidate_sources(key)
|
||||
last_err = "No source attempted"
|
||||
|
||||
for source in candidates:
|
||||
if not self._is_enabled(source):
|
||||
continue
|
||||
adapter = self.adapters.get(source)
|
||||
if adapter is None:
|
||||
continue
|
||||
try:
|
||||
if usage_type == "price":
|
||||
data = adapter.fetch_price_data(key, start, end)
|
||||
elif usage_type == "macro":
|
||||
data = adapter.fetch_macro_data(key, start, end)
|
||||
else:
|
||||
data = adapter.fetch_rate_data(key, start, end)
|
||||
reliability, latency_ms = self._tag(source)
|
||||
self.usage_log.append(SourceUsageRecord(asset=key, source=source, usage_type=usage_type))
|
||||
self.logger.info("Fetched %s data for %s via %s", usage_type, key, source)
|
||||
return DataFetchResult(
|
||||
data=data,
|
||||
source=source,
|
||||
reliability=reliability,
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_err = str(exc)
|
||||
self.usage_log.append(
|
||||
SourceUsageRecord(
|
||||
asset=key,
|
||||
source=source,
|
||||
usage_type=usage_type,
|
||||
success=False,
|
||||
error=last_err,
|
||||
)
|
||||
)
|
||||
self.logger.warning(
|
||||
"Failed %s data fetch for %s via %s: %s",
|
||||
usage_type,
|
||||
key,
|
||||
source,
|
||||
last_err,
|
||||
)
|
||||
|
||||
return DataFetchResult(
|
||||
data=pd.DataFrame(),
|
||||
source="none",
|
||||
reliability="low",
|
||||
latency_ms=9999,
|
||||
success=False,
|
||||
error=last_err,
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
from options_quant_engine.integration.hooks import IntegrationHooks
|
||||
|
||||
__all__ = ["IntegrationHooks"]
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from options_quant_engine.schemas import RegimeState
|
||||
|
||||
|
||||
class IntegrationHooks:
|
||||
def export_scores(self, features: pd.DataFrame, regime: RegimeState) -> dict[str, float]:
|
||||
if features.empty:
|
||||
return {
|
||||
"usd_strength_score": 0.0,
|
||||
"inr_stress_score": 0.0,
|
||||
"carry_attractiveness": 0.0,
|
||||
"fx_volatility_stress": 0.0,
|
||||
"risk_on_off_score": 0.0,
|
||||
}
|
||||
row = features.iloc[-1]
|
||||
return {
|
||||
"usd_strength_score": float(row.get("usd_strength_score", 0.0)),
|
||||
"inr_stress_score": float(row.get("em_stress_proxy", 0.0) + row.get("inr_vol_clustering", 0.0)),
|
||||
"carry_attractiveness": float(row.get("carry_proxy", 0.0)),
|
||||
"fx_volatility_stress": float(row.get("realized_vol", 0.0)),
|
||||
"risk_on_off_score": float(row.get("risk_on_off_proxy", 0.0)),
|
||||
"regime_stability": float(regime.stability),
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
from options_quant_engine.models.ensemble import EnsembleModel
|
||||
from options_quant_engine.models.relative_value import RelativeValueModel
|
||||
|
||||
__all__ = ["EnsembleModel", "RelativeValueModel"]
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class EnsembleModel:
|
||||
def __init__(self, config: dict[str, Any]) -> None:
|
||||
self.config = config
|
||||
|
||||
def directional_score(self, row: pd.Series) -> tuple[float, dict[str, float], float]:
|
||||
weights = self.config.get("directional", {}).get("model_weights", {})
|
||||
component = {
|
||||
"trend": float(row.get("trend_strength", 0.0)),
|
||||
"momentum": float(row.get("momentum_multi_horizon", 0.0)),
|
||||
"mean_reversion": -float(row.get("mean_reversion_zscore", 0.0)),
|
||||
"carry": float(row.get("carry_proxy", 0.0)),
|
||||
"macro": float(row.get("risk_on_off_proxy", 0.0)),
|
||||
}
|
||||
wsum = sum(float(weights.get(k, 0.0)) for k in component)
|
||||
score = 0.0
|
||||
if wsum > 0:
|
||||
score = sum(component[k] * float(weights.get(k, 0.0)) for k in component) / wsum
|
||||
normalized = float(np.tanh(score))
|
||||
signs = [np.sign(v) for v in component.values() if abs(v) > 1e-12]
|
||||
agreement = float((abs(sum(signs)) / len(signs))) if signs else 0.0
|
||||
return normalized, component, agreement
|
||||
|
||||
def relative_value_score(self, row_a: pd.Series, row_b: pd.Series) -> tuple[float, dict[str, float], float]:
|
||||
weights = self.config.get("relative_value", {}).get("model_weights", {})
|
||||
spread = float(row_a.get("mean_reversion_zscore", 0.0) - row_b.get("mean_reversion_zscore", 0.0))
|
||||
mom_diff = float(row_a.get("momentum_multi_horizon", 0.0) - row_b.get("momentum_multi_horizon", 0.0))
|
||||
carry_diff = float(row_a.get("carry_proxy", 0.0) - row_b.get("carry_proxy", 0.0))
|
||||
component = {"spread": spread, "momentum_diff": mom_diff, "carry_diff": carry_diff}
|
||||
wsum = sum(float(weights.get(k, 0.0)) for k in component)
|
||||
score = 0.0
|
||||
if wsum > 0:
|
||||
score = sum(component[k] * float(weights.get(k, 0.0)) for k in component) / wsum
|
||||
normalized = float(np.tanh(score))
|
||||
signs = [np.sign(v) for v in component.values() if abs(v) > 1e-12]
|
||||
agreement = float((abs(sum(signs)) / len(signs))) if signs else 0.0
|
||||
return normalized, component, agreement
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Placeholder for future model extensions.
|
||||
|
||||
This module is intentionally lightweight in the starter phase. Future additions can
|
||||
include gradient boosting, probabilistic forecasts, and online learning modules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelExtensionConfig:
|
||||
enabled: bool = False
|
||||
name: str = "baseline_extension"
|
||||
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
@dataclass
|
||||
class RelativeValueResult:
|
||||
score: float
|
||||
hedge_ratio: float
|
||||
spread_zscore: float
|
||||
expected_volatility: float
|
||||
drivers: dict[str, float]
|
||||
long_asset: str
|
||||
short_asset: str
|
||||
|
||||
|
||||
class RelativeValueModel:
|
||||
def __init__(self, lookback: int = 60) -> None:
|
||||
self.lookback = lookback
|
||||
|
||||
def generate(
|
||||
self,
|
||||
asset_a: str,
|
||||
asset_b: str,
|
||||
prices_a: pd.Series,
|
||||
prices_b: pd.Series,
|
||||
features_a: pd.DataFrame,
|
||||
features_b: pd.DataFrame,
|
||||
) -> RelativeValueResult:
|
||||
pa, pb = self._align(prices_a, prices_b)
|
||||
la = np.log(pa)
|
||||
lb = np.log(pb)
|
||||
|
||||
hedge_ratio = self._hedge_ratio(la.tail(self.lookback), lb.tail(self.lookback))
|
||||
spread = la - hedge_ratio * lb
|
||||
|
||||
window = max(20, self.lookback // 2)
|
||||
zscore = self._zscore(spread, window=window)
|
||||
spread_z = float(zscore.iloc[-1]) if len(zscore) else 0.0
|
||||
|
||||
row_a = features_a.iloc[-1]
|
||||
row_b = features_b.iloc[-1]
|
||||
momentum_diff = float(row_a.get("momentum_multi_horizon", 0.0) - row_b.get("momentum_multi_horizon", 0.0))
|
||||
carry_diff = float(row_a.get("carry_proxy", 0.0) - row_b.get("carry_proxy", 0.0))
|
||||
|
||||
# Mean-reversion spread score with feature differentials.
|
||||
raw = -0.60 * spread_z + 0.25 * momentum_diff + 0.15 * carry_diff
|
||||
score = float(np.tanh(raw))
|
||||
|
||||
if score >= 0:
|
||||
long_asset, short_asset = asset_a, asset_b
|
||||
else:
|
||||
long_asset, short_asset = asset_b, asset_a
|
||||
|
||||
expected_vol = float(spread.diff().tail(window).std() * np.sqrt(252.0)) if len(spread) > 2 else 0.0
|
||||
drivers = {
|
||||
"spread_zscore": spread_z,
|
||||
"momentum_diff": momentum_diff,
|
||||
"carry_diff": carry_diff,
|
||||
"hedge_ratio": float(hedge_ratio),
|
||||
}
|
||||
return RelativeValueResult(
|
||||
score=abs(score),
|
||||
hedge_ratio=float(hedge_ratio),
|
||||
spread_zscore=spread_z,
|
||||
expected_volatility=max(expected_vol, 0.0),
|
||||
drivers=drivers,
|
||||
long_asset=long_asset,
|
||||
short_asset=short_asset,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _align(a: pd.Series, b: pd.Series) -> tuple[pd.Series, pd.Series]:
|
||||
joined = pd.concat([a.rename("a"), b.rename("b")], axis=1).dropna()
|
||||
return joined["a"], joined["b"]
|
||||
|
||||
@staticmethod
|
||||
def _hedge_ratio(a: pd.Series, b: pd.Series) -> float:
|
||||
denom = float(np.var(b))
|
||||
if abs(denom) < 1e-12:
|
||||
return 1.0
|
||||
return float(np.cov(a, b)[0, 1] / denom)
|
||||
|
||||
@staticmethod
|
||||
def _zscore(series: pd.Series, window: int) -> pd.Series:
|
||||
mu = series.rolling(window).mean()
|
||||
sigma = series.rolling(window).std()
|
||||
return ((series - mu) / (sigma + 1e-9)).fillna(0.0)
|
||||
@@ -0,0 +1,3 @@
|
||||
from options_quant_engine.outputs.formatter import OutputFormatter
|
||||
|
||||
__all__ = ["OutputFormatter"]
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from options_quant_engine.schemas import SignalPayload
|
||||
|
||||
|
||||
class OutputFormatter:
|
||||
def __init__(self, config: dict[str, Any]) -> None:
|
||||
self.config = config.get("output", {})
|
||||
|
||||
def to_json_payload(self, signal: SignalPayload) -> dict[str, Any]:
|
||||
p = self.config.get("precision", 4)
|
||||
data = signal.to_dict()
|
||||
data["signal_strength"] = round(float(data["signal_strength"]), p)
|
||||
data["confidence"] = round(float(data["confidence"]), p)
|
||||
data["expected_volatility"] = round(float(data["expected_volatility"]), p)
|
||||
data["position_sizing_multiplier"] = round(float(data["position_sizing_multiplier"]), p)
|
||||
return data
|
||||
|
||||
def trader_summary(self, signal: SignalPayload) -> str:
|
||||
return (
|
||||
f"{signal.asset} | {signal.signal_type} | {signal.signal_direction} | "
|
||||
f"strength={signal.signal_strength:.2f} confidence={signal.confidence:.2f} "
|
||||
f"action={signal.recommended_action} size={signal.position_sizing_multiplier:.2f} "
|
||||
f"regime={signal.regime.get('combined', 'neutral')}"
|
||||
)
|
||||
|
||||
def dashboard_payload(self, signal: SignalPayload) -> dict[str, Any]:
|
||||
return {
|
||||
"asset": signal.asset,
|
||||
"score": signal.signal_strength,
|
||||
"confidence": signal.confidence,
|
||||
"action": signal.recommended_action,
|
||||
"risk_flags": signal.risk_flags,
|
||||
"regime": signal.regime,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
from options_quant_engine.preprocessing.cleaning import preprocess_market_data
|
||||
|
||||
__all__ = ["preprocess_market_data"]
|
||||
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def preprocess_market_data(df: pd.DataFrame) -> pd.DataFrame:
|
||||
if df.empty:
|
||||
return df
|
||||
out = df.copy().sort_index()
|
||||
out = out[~out.index.duplicated(keep="last")]
|
||||
out = out.ffill().dropna(how="all")
|
||||
return out
|
||||
@@ -0,0 +1,3 @@
|
||||
from options_quant_engine.regime.engine import RegimeEngine
|
||||
|
||||
__all__ = ["RegimeEngine"]
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from options_quant_engine.schemas import RegimeState
|
||||
|
||||
|
||||
class RegimeEngine:
|
||||
def __init__(self, config: dict[str, Any]) -> None:
|
||||
self.config = config
|
||||
|
||||
def detect(self, features: pd.DataFrame) -> RegimeState:
|
||||
if features.empty:
|
||||
return RegimeState("neutral", "normal", "neutral", "risk_on", "neutral", 0.0)
|
||||
|
||||
row = features.iloc[-1]
|
||||
trend = self._trend_regime(row)
|
||||
vol = self._vol_regime(features)
|
||||
dollar = self._dollar_regime(row)
|
||||
risk = self._risk_regime(row, vol)
|
||||
combined = self._combine(trend, vol, dollar, risk)
|
||||
stability = self._stability(features)
|
||||
return RegimeState(trend, vol, dollar, risk, combined, stability)
|
||||
|
||||
def _trend_regime(self, row: pd.Series) -> str:
|
||||
score = float(row.get("trend_strength", 0.0))
|
||||
th = float(self.config.get("trend", {}).get("slope_threshold", 0.0005))
|
||||
band = float(self.config.get("trend", {}).get("mr_band", 0.003))
|
||||
if score > th:
|
||||
return "trend"
|
||||
if score < -band:
|
||||
return "mean_reversion"
|
||||
return "neutral"
|
||||
|
||||
def _vol_regime(self, features: pd.DataFrame) -> str:
|
||||
vol = features.get("realized_vol", pd.Series(0.0, index=features.index)).fillna(0.0)
|
||||
cur = float(vol.iloc[-1])
|
||||
ql = float(vol.quantile(self.config.get("volatility", {}).get("low_quantile", 0.3)))
|
||||
qh = float(vol.quantile(self.config.get("volatility", {}).get("high_quantile", 0.7)))
|
||||
qs = float(vol.quantile(self.config.get("volatility", {}).get("stress_quantile", 0.9)))
|
||||
if cur >= qs:
|
||||
return "stress"
|
||||
if cur >= qh:
|
||||
return "high"
|
||||
if cur <= ql:
|
||||
return "low"
|
||||
return "normal"
|
||||
|
||||
def _dollar_regime(self, row: pd.Series) -> str:
|
||||
usd = float(row.get("usd_strength_score", 0.0))
|
||||
band = float(self.config.get("dollar", {}).get("neutral_band", 0.2))
|
||||
if usd > band:
|
||||
return "strengthening"
|
||||
if usd < -band:
|
||||
return "weakening"
|
||||
return "neutral"
|
||||
|
||||
def _risk_regime(self, row: pd.Series, vol_regime: str) -> str:
|
||||
risk_score = float(row.get("risk_on_off_proxy", 0.0))
|
||||
threshold = float(self.config.get("risk", {}).get("risk_off_equity_threshold", -0.01))
|
||||
if vol_regime == "stress":
|
||||
return "stressed"
|
||||
if risk_score < threshold:
|
||||
return "risk_off"
|
||||
return "risk_on"
|
||||
|
||||
def _combine(self, trend: str, vol: str, dollar: str, risk: str) -> str:
|
||||
if vol == "stress" or risk == "stressed":
|
||||
return "defensive"
|
||||
if trend == "trend" and risk == "risk_on":
|
||||
return "pro_trend"
|
||||
if trend == "mean_reversion" and risk != "stressed":
|
||||
return "mean_revert"
|
||||
if dollar == "strengthening":
|
||||
return "usd_dominant"
|
||||
return "neutral"
|
||||
|
||||
def _stability(self, features: pd.DataFrame) -> float:
|
||||
cols = [c for c in ["trend_strength", "realized_vol", "usd_strength_score", "risk_on_off_proxy"] if c in features]
|
||||
if not cols:
|
||||
return 0.0
|
||||
tail = features[cols].tail(10)
|
||||
vol = float(tail.std().mean())
|
||||
return float(np.clip(1.0 - vol, 0.0, 1.0))
|
||||
@@ -0,0 +1,3 @@
|
||||
from options_quant_engine.risk.engine import RiskEngine
|
||||
|
||||
__all__ = ["RiskEngine"]
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from options_quant_engine.schemas import RegimeState, SignalPayload
|
||||
from options_quant_engine.utils.time import is_within_market_hours
|
||||
|
||||
|
||||
class RiskEngine:
|
||||
def __init__(self, config: dict[str, Any]) -> None:
|
||||
self.config = config.get("risk", {})
|
||||
|
||||
def apply(self, signal: SignalPayload, regime: RegimeState, reliability: str, liquidity_ok: bool, event_risk: float) -> SignalPayload:
|
||||
penalties = self.config.get("data_reliability_penalty", {})
|
||||
rel_mult = float(penalties.get(reliability, 0.6))
|
||||
size = signal.signal_strength * signal.confidence * rel_mult
|
||||
|
||||
if regime.volatility in {"high", "stress"}:
|
||||
size *= float(self.config.get("stressed_regime_multiplier", 0.5))
|
||||
signal.risk_flags.append("high_volatility_regime")
|
||||
|
||||
if not liquidity_ok:
|
||||
size *= float(self.config.get("illiquid_market_multiplier", 0.6))
|
||||
signal.risk_flags.append("liquidity_constraint")
|
||||
|
||||
if event_risk > 0.6:
|
||||
size *= 0.5
|
||||
signal.risk_flags.append("event_risk_high")
|
||||
|
||||
if not self._market_open_now():
|
||||
signal.risk_flags.append("outside_primary_market_hours")
|
||||
size *= 0.8
|
||||
|
||||
signal.position_sizing_multiplier = max(0.0, min(size, 1.0))
|
||||
signal.recommended_action = self._action(signal.position_sizing_multiplier)
|
||||
return signal
|
||||
|
||||
def _market_open_now(self) -> bool:
|
||||
now = datetime.now(timezone.utc)
|
||||
open_utc = self.config.get("market_hours", {}).get("inr_derivatives_open_utc", "03:45")
|
||||
close_utc = self.config.get("market_hours", {}).get("inr_derivatives_close_utc", "10:00")
|
||||
return is_within_market_hours(now, open_utc, close_utc)
|
||||
|
||||
@staticmethod
|
||||
def _action(size: float) -> str:
|
||||
if size >= 0.75:
|
||||
return "enter"
|
||||
if size >= 0.45:
|
||||
return "enter_small"
|
||||
if size >= 0.25:
|
||||
return "hold"
|
||||
if size > 0.0:
|
||||
return "reduce"
|
||||
return "no_trade"
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataFetchResult:
|
||||
data: pd.DataFrame
|
||||
source: str
|
||||
reliability: str
|
||||
latency_ms: int
|
||||
success: bool = True
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RegimeState:
|
||||
trend: str
|
||||
volatility: str
|
||||
dollar: str
|
||||
risk: str
|
||||
combined: str
|
||||
stability: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class SignalPayload:
|
||||
engine: str
|
||||
timestamp: str
|
||||
asset: str
|
||||
signal_type: str
|
||||
signal_direction: str
|
||||
signal_strength: float
|
||||
confidence: float
|
||||
regime: dict[str, Any]
|
||||
expected_volatility: float
|
||||
risk_flags: list[str]
|
||||
drivers: dict[str, Any]
|
||||
recommended_action: str
|
||||
position_sizing_multiplier: float
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EvaluationReport:
|
||||
generated_at: str
|
||||
hit_rate: float
|
||||
avg_forward_return: float
|
||||
regime_performance: dict[str, float]
|
||||
signal_decay: dict[str, float]
|
||||
feature_importance: dict[str, float]
|
||||
drift_flags: list[str]
|
||||
calibration_score: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class BacktestResult:
|
||||
pair_level_returns: dict[str, float]
|
||||
portfolio_return: float
|
||||
max_drawdown: float
|
||||
turnover: float
|
||||
metrics: dict[str, float]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SourceUsageRecord:
|
||||
asset: str
|
||||
source: str
|
||||
usage_type: str
|
||||
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
||||
success: bool = True
|
||||
error: str | None = None
|
||||
@@ -0,0 +1,4 @@
|
||||
from options_quant_engine.signals.confidence import ConfidenceEngine
|
||||
from options_quant_engine.signals.engine import SignalEngine
|
||||
|
||||
__all__ = ["SignalEngine", "ConfidenceEngine"]
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
class ConfidenceEngine:
|
||||
def __init__(self, config: dict[str, Any]) -> None:
|
||||
self.config = config
|
||||
|
||||
def compute(
|
||||
self,
|
||||
feature_agreement: float,
|
||||
model_agreement: float,
|
||||
regime_stability: float,
|
||||
data_quality: float,
|
||||
volatility: float,
|
||||
event_risk: float,
|
||||
) -> float:
|
||||
c = self.config.get("confidence", {})
|
||||
agreement_w = float(c.get("agreement_weight", 0.25))
|
||||
regime_w = float(c.get("regime_stability_weight", 0.20))
|
||||
data_w = float(c.get("data_quality_weight", 0.20))
|
||||
vol_w = float(c.get("volatility_penalty_weight", 0.20))
|
||||
event_w = float(c.get("event_penalty_weight", 0.15))
|
||||
|
||||
agreement = 0.5 * (feature_agreement + model_agreement)
|
||||
vol_penalty = np.clip(volatility, 0.0, 1.0)
|
||||
event_penalty = np.clip(event_risk, 0.0, 1.0)
|
||||
|
||||
raw = (
|
||||
agreement_w * agreement
|
||||
+ regime_w * np.clip(regime_stability, 0.0, 1.0)
|
||||
+ data_w * np.clip(data_quality, 0.0, 1.0)
|
||||
- vol_w * vol_penalty
|
||||
- event_w * event_penalty
|
||||
)
|
||||
return float(np.clip((raw + 1.0) / 2.0, 0.0, 1.0))
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from options_quant_engine.schemas import RegimeState, SignalPayload
|
||||
|
||||
|
||||
class SignalEngine:
|
||||
def __init__(self, engine_name: str = "options_quant_engine") -> None:
|
||||
self.engine_name = engine_name
|
||||
|
||||
def directional_signal(
|
||||
self,
|
||||
asset: str,
|
||||
feature_row: pd.Series,
|
||||
regime: RegimeState,
|
||||
score: float,
|
||||
confidence: float,
|
||||
drivers: dict[str, float],
|
||||
expected_volatility: float,
|
||||
) -> SignalPayload:
|
||||
direction = self._direction(asset, score)
|
||||
return SignalPayload(
|
||||
engine=self.engine_name,
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
asset=asset,
|
||||
signal_type="directional",
|
||||
signal_direction=direction,
|
||||
signal_strength=float(np.clip(abs(score), 0.0, 1.0)),
|
||||
confidence=float(np.clip(confidence, 0.0, 1.0)),
|
||||
regime=regime.__dict__,
|
||||
expected_volatility=float(max(0.0, expected_volatility)),
|
||||
risk_flags=[],
|
||||
drivers={k: float(v) for k, v in drivers.items()},
|
||||
recommended_action="hold",
|
||||
position_sizing_multiplier=0.0,
|
||||
)
|
||||
|
||||
def relative_value_signal(
|
||||
self,
|
||||
long_asset: str,
|
||||
short_asset: str,
|
||||
regime: RegimeState,
|
||||
score: float,
|
||||
confidence: float,
|
||||
drivers: dict[str, float],
|
||||
expected_volatility: float,
|
||||
) -> SignalPayload:
|
||||
direction = f"long_{long_asset}_vs_{short_asset}"
|
||||
asset = f"{long_asset}:{short_asset}"
|
||||
return SignalPayload(
|
||||
engine=self.engine_name,
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
asset=asset,
|
||||
signal_type="relative_value",
|
||||
signal_direction=direction,
|
||||
signal_strength=float(np.clip(abs(score), 0.0, 1.0)),
|
||||
confidence=float(np.clip(confidence, 0.0, 1.0)),
|
||||
regime=regime.__dict__,
|
||||
expected_volatility=float(max(0.0, expected_volatility)),
|
||||
risk_flags=[],
|
||||
drivers={k: float(v) for k, v in drivers.items()},
|
||||
recommended_action="hold",
|
||||
position_sizing_multiplier=0.0,
|
||||
)
|
||||
|
||||
def _direction(self, asset: str, score: float) -> str:
|
||||
if asset.startswith("USD"):
|
||||
return "long_usd" if score >= 0 else "short_usd"
|
||||
return "long_base" if score >= 0 else "short_base"
|
||||
@@ -0,0 +1,4 @@
|
||||
from options_quant_engine.utils.config import load_all_configs, load_yaml
|
||||
from options_quant_engine.utils.logging import get_logger
|
||||
|
||||
__all__ = ["load_yaml", "load_all_configs", "get_logger"]
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def load_yaml(path: str | Path) -> dict[str, Any]:
|
||||
with Path(path).open("r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"Config file must contain a mapping: {path}")
|
||||
return data
|
||||
|
||||
|
||||
def load_all_configs(config_dir: str | Path) -> dict[str, dict[str, Any]]:
|
||||
cfg_dir = Path(config_dir)
|
||||
return {
|
||||
"universe": load_yaml(cfg_dir / "universe.yaml"),
|
||||
"data_sources": load_yaml(cfg_dir / "data_sources.yaml"),
|
||||
"features": load_yaml(cfg_dir / "features.yaml"),
|
||||
"regimes": load_yaml(cfg_dir / "regimes.yaml"),
|
||||
"models": load_yaml(cfg_dir / "models.yaml"),
|
||||
"risk": load_yaml(cfg_dir / "risk.yaml"),
|
||||
"output": load_yaml(cfg_dir / "output.yaml"),
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
logger = logging.getLogger(name)
|
||||
if not logger.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
fmt = "%(asctime)s | %(name)s | %(levelname)s | %(message)s"
|
||||
handler.setFormatter(logging.Formatter(fmt))
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
return logger
|
||||
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, time, timezone
|
||||
|
||||
|
||||
def is_within_market_hours(
|
||||
now_utc: datetime,
|
||||
open_hhmm: str,
|
||||
close_hhmm: str,
|
||||
) -> bool:
|
||||
open_h, open_m = [int(x) for x in open_hhmm.split(":")]
|
||||
close_h, close_m = [int(x) for x in close_hhmm.split(":")]
|
||||
open_t = time(hour=open_h, minute=open_m)
|
||||
close_t = time(hour=close_h, minute=close_m)
|
||||
t = now_utc.astimezone(timezone.utc).time()
|
||||
return open_t <= t <= close_t
|
||||
@@ -0,0 +1,41 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "options-quant-engine"
|
||||
version = "0.1.0"
|
||||
description = "Modular, explainable, production-grade options quant engine"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "Options Quant Team" }]
|
||||
dependencies = [
|
||||
"numpy>=1.26",
|
||||
"pandas>=2.2",
|
||||
"PyYAML>=6.0",
|
||||
"scipy>=1.12",
|
||||
"requests>=2.32"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-cov>=5.0",
|
||||
"ruff>=0.5"
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["options_quant_engine*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "B", "UP"]
|
||||
ignore = ["E501"]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user