finrix 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- finrix-1.0.0/LICENSE +21 -0
- finrix-1.0.0/PKG-INFO +21 -0
- finrix-1.0.0/README.md +192 -0
- finrix-1.0.0/finrix.egg-info/PKG-INFO +21 -0
- finrix-1.0.0/finrix.egg-info/SOURCES.txt +28 -0
- finrix-1.0.0/finrix.egg-info/dependency_links.txt +1 -0
- finrix-1.0.0/finrix.egg-info/entry_points.txt +2 -0
- finrix-1.0.0/finrix.egg-info/requires.txt +14 -0
- finrix-1.0.0/finrix.egg-info/top_level.txt +1 -0
- finrix-1.0.0/macro_forecast_engine/__init__.py +2 -0
- finrix-1.0.0/macro_forecast_engine/config.py +28 -0
- finrix-1.0.0/macro_forecast_engine/data_fetcher.py +9 -0
- finrix-1.0.0/macro_forecast_engine/data_loader.py +300 -0
- finrix-1.0.0/macro_forecast_engine/data_processor.py +40 -0
- finrix-1.0.0/macro_forecast_engine/finrix_config.py +46 -0
- finrix-1.0.0/macro_forecast_engine/indicator_params.py +77 -0
- finrix-1.0.0/macro_forecast_engine/json_utils.py +27 -0
- finrix-1.0.0/macro_forecast_engine/modal_engine.py +606 -0
- finrix-1.0.0/macro_forecast_engine/run_engine.py +68 -0
- finrix-1.0.0/macro_forecast_engine/trader_execution.py +84 -0
- finrix-1.0.0/macro_forecast_engine/trader_risk.py +103 -0
- finrix-1.0.0/macro_forecast_engine/trader_timemachine.py +413 -0
- finrix-1.0.0/pyproject.toml +38 -0
- finrix-1.0.0/setup.cfg +4 -0
- finrix-1.0.0/tests/test_backtest.py +235 -0
- finrix-1.0.0/tests/test_diffusion.py +50 -0
- finrix-1.0.0/tests/test_indicators.py +75 -0
- finrix-1.0.0/tests/test_integration.py +128 -0
- finrix-1.0.0/tests/test_risk.py +76 -0
- finrix-1.0.0/tests/test_security.py +110 -0
finrix-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 carnoba
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
finrix-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: finrix
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: FINRIX Quantitative Trading Terminal — XAUUSD macro forecast engine with Rich CLI
|
|
5
|
+
Requires-Python: <3.13,>=3.11
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Dist: modal==0.73.0
|
|
8
|
+
Requires-Dist: neuralforecast==1.7.5
|
|
9
|
+
Requires-Dist: pyfredapi==0.10.2
|
|
10
|
+
Requires-Dist: transformers==4.46.3
|
|
11
|
+
Requires-Dist: torch==2.5.1
|
|
12
|
+
Requires-Dist: pandas==2.2.3
|
|
13
|
+
Requires-Dist: numpy==2.1.3
|
|
14
|
+
Requires-Dist: scikit-learn==1.5.2
|
|
15
|
+
Requires-Dist: yfinance==0.2.48
|
|
16
|
+
Requires-Dist: requests==2.32.3
|
|
17
|
+
Requires-Dist: python-dotenv==1.0.1
|
|
18
|
+
Requires-Dist: rich==13.9.4
|
|
19
|
+
Requires-Dist: questionary==2.0.1
|
|
20
|
+
Requires-Dist: InquirerPy==0.3.4
|
|
21
|
+
Dynamic: license-file
|
finrix-1.0.0/README.md
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# FINRIX XAUUSD Quant Trading Terminal
|
|
2
|
+
|
|
3
|
+
> **Status: Research Prototype / Quantitative Framework.** FINRIX supports
|
|
4
|
+
> reproducible market research, forecasting, and backtesting. It is not
|
|
5
|
+
> investment advice and does not execute live orders without a separately
|
|
6
|
+
> installed and explicitly authorized broker bridge.
|
|
7
|
+
|
|
8
|
+
[](https://github.com/carnoba/trading-prediction/actions/workflows/ci.yml)
|
|
9
|
+
[](https://github.com/carnoba/trading-prediction/actions/workflows/ci.yml)
|
|
10
|
+
[](https://www.python.org/downloads/release/python-3100-rc1/)
|
|
11
|
+
[](https://opensource.org/licenses/MIT)
|
|
12
|
+
[](macro_forecast_engine/)
|
|
13
|
+
[](https://github.com/psf/black)
|
|
14
|
+
[](FINRIX_SECURITY_AND_QA_AUDIT_REPORT.md)
|
|
15
|
+
[](macro_forecast_engine/)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
https://github.com/user-attachments/assets/b600ad35-9752-46c7-a912-12deb9714708
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
## One-Line Execution
|
|
29
|
+
|
|
30
|
+
Run the full FINRIX terminal directly from command line:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
finrix
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or launch the Rich/questionary terminal:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
python app.py
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The System Settings menu controls Modal A10G vs local CPU, default equity, and risk per trade -- all forecast actions use saved values automatically.
|
|
43
|
+
|
|
44
|
+
## Core Architecture & Key Features
|
|
45
|
+
|
|
46
|
+
### Dual-Path Execution Engine
|
|
47
|
+
|
|
48
|
+
- **Local CPU** -- Lightweight drift & baseline execution, runs instantly on any machine
|
|
49
|
+
- **Modal A10G Cloud GPU** -- NeuralForecast TFT deep learning, 100,000-path CUDA Monte Carlo, CUDA-accelerated quantile regression
|
|
50
|
+
|
|
51
|
+
### Quantile Forecasting Envelope
|
|
52
|
+
|
|
53
|
+
Monotonic price corridors $q_{10} \le q_{50} \le q_{90}$ with dynamic volatility decay across daily (30 business days), monthly (22 business days), and yearly (252 business days) horizons.
|
|
54
|
+
|
|
55
|
+
### Dynamic Equity-Based Position Sizing
|
|
56
|
+
|
|
57
|
+
Real-time dollar risk math with standardized Gold pip sizing, pip-based
|
|
58
|
+
stop-loss/take-profit calculation, equity compounding, transaction-cost-aware
|
|
59
|
+
trade sizing, and fractional-Kelly allocation across 14 strategies.
|
|
60
|
+
|
|
61
|
+
### Genuine Trade-Based Backtesting
|
|
62
|
+
|
|
63
|
+
The timemachine engine simulates signal entry, open-position tracking,
|
|
64
|
+
stop-loss/take-profit exits, signal reversal, 1.5-pip spread, and slippage.
|
|
65
|
+
Metrics use closed trades and the dollar equity curve rather than static
|
|
66
|
+
per-bar percentage returns.
|
|
67
|
+
|
|
68
|
+
### 14-Strategy Backtesting & Parameter Engine
|
|
69
|
+
|
|
70
|
+
All strategies persist to `data/finrix_indicator_params.json`:
|
|
71
|
+
|
|
72
|
+
| Strategy | Indicators |
|
|
73
|
+
|----------|------------|
|
|
74
|
+
| SMC Fair Value Gap (FVG) | FVG detection, order block breakout |
|
|
75
|
+
| SMC Liquidity Order Block Breakout | SSL/BSL liquidity pool detection |
|
|
76
|
+
| EMA Golden Cross / Death Cross | Fast/slow EMA crossover, trend period |
|
|
77
|
+
| Triple EMA Ribbon | Fast/medium/slow EMA ribbon dynamics |
|
|
78
|
+
| RSI Mean Reversion | RSI period, overbought, oversold |
|
|
79
|
+
| MACD Zero-Line & Signal Line Crossover | Fast/slow/signal MACD components |
|
|
80
|
+
| Bollinger Bands Squeeze & Reversal | Period, standard deviation |
|
|
81
|
+
| Supertrend ATR Trend Follower | ATR period, multiplier |
|
|
82
|
+
| Stochastic Oscillator | K period, D period, slowing |
|
|
83
|
+
| ADX + DI Trend Classifier | ADX period, DI threshold |
|
|
84
|
+
| Ichimoku Cloud Kumo Breakout | Tenkan, Kijun, Senkou B periods |
|
|
85
|
+
| ATR Volatility Expansion Channel | ATR period |
|
|
86
|
+
| TFT Log-Return Trend | Temporal Fusion Transformer ML |
|
|
87
|
+
| Volatility Parity & Risk-Adjusted Allocation | Volatility-normalized allocation |
|
|
88
|
+
|
|
89
|
+
### Macro Sentiment Analysis
|
|
90
|
+
|
|
91
|
+
Live RSS headlines processed via **FinBERT** NLP model with dovish/hawkish net score cancellation math. Fallback regex-based scoring when transformer models are unavailable.
|
|
92
|
+
|
|
93
|
+
## Terminal Menu Workflow Guide (Options 1-8)
|
|
94
|
+
|
|
95
|
+
| Option | Description |
|
|
96
|
+
|--------|-------------|
|
|
97
|
+
| **1** | Instant Execution Card -- Kelly Position Sizing, dynamic Entry bounds, Dynamic SL (exact pip distance), Risk:Reward ratios |
|
|
98
|
+
| **2** | Daily Price Path & Signals -- 30-day forward forecast with quantile monotonicity ($q_{10} \le q_{50} \le q_{90}$) |
|
|
99
|
+
| **3** | Monthly Macro Horizon -- 22-day outlook & weekly pivots with dynamic bias calculation |
|
|
100
|
+
| **4** | Yearly Regime Trend Shift -- 252-day forecast with volatility envelopes, $q_{90}$ capped at 6500 |
|
|
101
|
+
| **5** | Macro News & FinBERT Sentiment -- Live RSS headline aggregation, Dovish/Hawkish score cancellation |
|
|
102
|
+
| **6** | Risk & Position Sizing Calculator -- Custom market price overrides, pip distance math, zero static hardcoded outputs |
|
|
103
|
+
| **7** | Historical Strategy Backtester -- All 14 strategies across 15m, 1h, 4h, 1D timeframes, dynamic lot sizing |
|
|
104
|
+
| **8** | Multi-Strategy Ensemble Analyzer -- Top 5 leaderboard, dynamic header title (Local CPU vs Modal A10G), all strategies |
|
|
105
|
+
|
|
106
|
+
### System Settings
|
|
107
|
+
|
|
108
|
+
- Toggle execution environment (local/modal)
|
|
109
|
+
- Set default account equity
|
|
110
|
+
- Set default risk per trade %
|
|
111
|
+
- Customize indicator parameters (all 10 groups with limits)
|
|
112
|
+
- Save & apply settings persistence via `data/finrix_config.json`
|
|
113
|
+
|
|
114
|
+
## Quality Assurance
|
|
115
|
+
|
|
116
|
+
The repository includes a real pytest suite covering dynamic risk sizing,
|
|
117
|
+
lookahead-safe trade lifecycle execution, all 14 indicator strategies,
|
|
118
|
+
timeframe-specific caching, non-finite JSON coercion, and configuration
|
|
119
|
+
sanitization.
|
|
120
|
+
|
|
121
|
+
```text
|
|
122
|
+
207 passed
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Run the checks with:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
python -m pytest -q
|
|
129
|
+
python -m compileall -q app.py macro_forecast_engine
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Finrix Screenshots:
|
|
133
|
+
|
|
134
|
+
<img width="283" height="304" alt="Screenshot 2026-11-08 105821" src="https://github.com/user-attachments/assets/919fb0d2-2d04-4172-a401-9f4caab112c7" />
|
|
135
|
+
<img width="282" height="281" alt="Screenshot 2026-11-08 105740" src="https://github.com/user-attachments/assets/d5558b17-58df-434c-9fda-0e8376d08090" />
|
|
136
|
+
<img width="275" height="259" alt="Screenshot 2026-11-08 105707" src="https://github.com/user-attachments/assets/c11f30cd-bc4c-45fa-9ffa-6ab9fde55a88" />
|
|
137
|
+
<img width="276" height="128" alt="Screenshot 2026-11-07 225107" src="https://github.com/user-attachments/assets/b0487285-9d31-4dd7-8191-e7b53a166eeb" />
|
|
138
|
+
<img width="285" height="529" alt="Screenshot 2026-11-07 225031" src="https://github.com/user-attachments/assets/894637f0-90d2-4c23-9465-83352218779e" />
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
## Future Roadmap
|
|
144
|
+
|
|
145
|
+
- **Multi-asset expansion** -- Forex pairs, Crypto indices, Equity indices
|
|
146
|
+
- **Direct MetaTrader 5 (MT5) execution bridge** & Webhook integration
|
|
147
|
+
- **Automated portfolio rebalancing** with dynamic risk limits
|
|
148
|
+
- **Walking-forward analysis** & rolling-origin backtesting
|
|
149
|
+
- **Cluster deployment** across multiple Modal GPU workers
|
|
150
|
+
|
|
151
|
+
## Repository Layout
|
|
152
|
+
|
|
153
|
+
```text
|
|
154
|
+
macro_forecast_engine/
|
|
155
|
+
├── modal_engine.py # Primary Modal A10G pipeline
|
|
156
|
+
├── run_engine.py # Forecast orchestration utilities
|
|
157
|
+
├── data_loader.py # Market, FRED, RSS, and sentiment ingestion
|
|
158
|
+
├── execution_layer.py # Macro-bias signal filtering utilities
|
|
159
|
+
├── config.py # Environment-backed configuration
|
|
160
|
+
├── indicator_params.py # Persistent indicator parameter JSON
|
|
161
|
+
├── finrix_config.py # Terminal settings persistence
|
|
162
|
+
├── trader_execution.py # SMC + TFT execution levels
|
|
163
|
+
├── trader_risk.py # Kelly sizing & volatility parity
|
|
164
|
+
├── trader_timemachine.py # Strategy replay & confidence scoring
|
|
165
|
+
├── config.py
|
|
166
|
+
├── __init__.py
|
|
167
|
+
.github/
|
|
168
|
+
├── workflows/
|
|
169
|
+
│ └── ci.yml # GitHub Actions CI pipeline
|
|
170
|
+
└── ISSUE_TEMPLATE/
|
|
171
|
+
├── bug_report.md # Bug report template
|
|
172
|
+
└── feature_request.md # Feature request template
|
|
173
|
+
.github/PULL_REQUEST_TEMPLATE.md # PR checklist template
|
|
174
|
+
CONTRIBUTING.md # Open-source guidelines
|
|
175
|
+
FINRIX_Institutional_Project_Report.docx # Project Report
|
|
176
|
+
requirements.txt
|
|
177
|
+
.env.example
|
|
178
|
+
README.md (this file)
|
|
179
|
+
FINRIX_SECURITY_AND_QA_AUDIT_REPORT.md
|
|
180
|
+
data/
|
|
181
|
+
├── finrix_config.json
|
|
182
|
+
├── finrix_indicator_params.json
|
|
183
|
+
├── macro_daily_forecast.json
|
|
184
|
+
├── macro_month_forecast.json
|
|
185
|
+
└── macro_year_forecast.json
|
|
186
|
+
cache/
|
|
187
|
+
gold_market_cache.csv
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
MIT License. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: finrix
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: FINRIX Quantitative Trading Terminal — XAUUSD macro forecast engine with Rich CLI
|
|
5
|
+
Requires-Python: <3.13,>=3.11
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Dist: modal==0.73.0
|
|
8
|
+
Requires-Dist: neuralforecast==1.7.5
|
|
9
|
+
Requires-Dist: pyfredapi==0.10.2
|
|
10
|
+
Requires-Dist: transformers==4.46.3
|
|
11
|
+
Requires-Dist: torch==2.5.1
|
|
12
|
+
Requires-Dist: pandas==2.2.3
|
|
13
|
+
Requires-Dist: numpy==2.1.3
|
|
14
|
+
Requires-Dist: scikit-learn==1.5.2
|
|
15
|
+
Requires-Dist: yfinance==0.2.48
|
|
16
|
+
Requires-Dist: requests==2.32.3
|
|
17
|
+
Requires-Dist: python-dotenv==1.0.1
|
|
18
|
+
Requires-Dist: rich==13.9.4
|
|
19
|
+
Requires-Dist: questionary==2.0.1
|
|
20
|
+
Requires-Dist: InquirerPy==0.3.4
|
|
21
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
finrix.egg-info/PKG-INFO
|
|
5
|
+
finrix.egg-info/SOURCES.txt
|
|
6
|
+
finrix.egg-info/dependency_links.txt
|
|
7
|
+
finrix.egg-info/entry_points.txt
|
|
8
|
+
finrix.egg-info/requires.txt
|
|
9
|
+
finrix.egg-info/top_level.txt
|
|
10
|
+
macro_forecast_engine/__init__.py
|
|
11
|
+
macro_forecast_engine/config.py
|
|
12
|
+
macro_forecast_engine/data_fetcher.py
|
|
13
|
+
macro_forecast_engine/data_loader.py
|
|
14
|
+
macro_forecast_engine/data_processor.py
|
|
15
|
+
macro_forecast_engine/finrix_config.py
|
|
16
|
+
macro_forecast_engine/indicator_params.py
|
|
17
|
+
macro_forecast_engine/json_utils.py
|
|
18
|
+
macro_forecast_engine/modal_engine.py
|
|
19
|
+
macro_forecast_engine/run_engine.py
|
|
20
|
+
macro_forecast_engine/trader_execution.py
|
|
21
|
+
macro_forecast_engine/trader_risk.py
|
|
22
|
+
macro_forecast_engine/trader_timemachine.py
|
|
23
|
+
tests/test_backtest.py
|
|
24
|
+
tests/test_diffusion.py
|
|
25
|
+
tests/test_indicators.py
|
|
26
|
+
tests/test_integration.py
|
|
27
|
+
tests/test_risk.py
|
|
28
|
+
tests/test_security.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
modal==0.73.0
|
|
2
|
+
neuralforecast==1.7.5
|
|
3
|
+
pyfredapi==0.10.2
|
|
4
|
+
transformers==4.46.3
|
|
5
|
+
torch==2.5.1
|
|
6
|
+
pandas==2.2.3
|
|
7
|
+
numpy==2.1.3
|
|
8
|
+
scikit-learn==1.5.2
|
|
9
|
+
yfinance==0.2.48
|
|
10
|
+
requests==2.32.3
|
|
11
|
+
python-dotenv==1.0.1
|
|
12
|
+
rich==13.9.4
|
|
13
|
+
questionary==2.0.1
|
|
14
|
+
InquirerPy==0.3.4
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
macro_forecast_engine
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Environment-backed configuration for the Modal forecast engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from dotenv import load_dotenv
|
|
10
|
+
except ImportError: # pragma: no cover
|
|
11
|
+
load_dotenv = None
|
|
12
|
+
|
|
13
|
+
BASE_DIR = Path(__file__).resolve().parent
|
|
14
|
+
PROJECT_ROOT = BASE_DIR.parent
|
|
15
|
+
DATA_DIR = PROJECT_ROOT / "data"
|
|
16
|
+
|
|
17
|
+
if load_dotenv is not None:
|
|
18
|
+
load_dotenv(PROJECT_ROOT / ".env")
|
|
19
|
+
|
|
20
|
+
FRED_API_KEY = os.getenv("FRED_API_KEY", "").strip()
|
|
21
|
+
FORECAST_MODE = os.getenv("FORECAST_MODE", os.getenv("FORECAST_HORIZON", "monthly")).lower()
|
|
22
|
+
FINBERT_MODEL = os.getenv("FINBERT_MODEL", "ProsusAI/finbert")
|
|
23
|
+
FRED_SERIES_TIPS = "DFII10"
|
|
24
|
+
FRED_SERIES_2Y = "DGS2"
|
|
25
|
+
|
|
26
|
+
HORIZONS = {"daily": 30, "monthly": 22, "yearly": 252}
|
|
27
|
+
if FORECAST_MODE not in HORIZONS:
|
|
28
|
+
raise ValueError(f"FORECAST_MODE must be one of {', '.join(HORIZONS)}")
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Institutional cross-asset and macro data ingestion helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import warnings
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from io import StringIO
|
|
10
|
+
import xml.etree.ElementTree as ET
|
|
11
|
+
from typing import Any, Iterable, Mapping, Optional
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import pandas as pd
|
|
15
|
+
import requests
|
|
16
|
+
from sklearn.preprocessing import RobustScaler
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from .data_processor import add_sentiment_and_flow
|
|
20
|
+
except ImportError: # Modal may load this file as a top-level script.
|
|
21
|
+
from macro_forecast_engine.data_processor import add_sentiment_and_flow
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
import yfinance as yf
|
|
25
|
+
except ImportError: # pragma: no cover
|
|
26
|
+
yf = None
|
|
27
|
+
|
|
28
|
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
29
|
+
GOLD_TICKERS = ("GC=F", "XAUUSD=X")
|
|
30
|
+
INTRADAY_PERIOD_LIMITS = {
|
|
31
|
+
"15m": ("60d", "15m"),
|
|
32
|
+
"1h": ("60d", "1h"),
|
|
33
|
+
"4h": ("60d", "1h"),
|
|
34
|
+
"1d": ("730d", "1d"),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _download(ticker: str, period: str = "10y", interval: str = "1d") -> pd.DataFrame:
|
|
39
|
+
if yf is None:
|
|
40
|
+
raise RuntimeError("yfinance is required for market data ingestion")
|
|
41
|
+
frame = yf.download(ticker, period=period, interval=interval, auto_adjust=False,
|
|
42
|
+
progress=False, threads=False)
|
|
43
|
+
if isinstance(frame.columns, pd.MultiIndex):
|
|
44
|
+
frame.columns = frame.columns.get_level_values(0)
|
|
45
|
+
if frame.empty:
|
|
46
|
+
raise ValueError(f"No data returned for {ticker}")
|
|
47
|
+
frame.index = pd.to_datetime(frame.index, utc=True).tz_localize(None)
|
|
48
|
+
frame.columns = [str(column).lower() for column in frame.columns]
|
|
49
|
+
return frame
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _normalized_request(period: str, interval: str) -> tuple[str, str]:
|
|
53
|
+
configured = INTRADAY_PERIOD_LIMITS.get(interval.lower())
|
|
54
|
+
return configured if configured else (period, interval)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _cache_path(interval: str) -> Path:
|
|
58
|
+
normalized = interval.lower()
|
|
59
|
+
suffix = "1d" if normalized in {"1d", "d"} else normalized
|
|
60
|
+
safe_suffix = re.sub(r"[^a-z0-9_-]", "", suffix) or "1d"
|
|
61
|
+
return PROJECT_ROOT / "cache" / f"gold_cache_{safe_suffix}.csv"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _load_cached_gold(interval: str) -> pd.DataFrame:
|
|
65
|
+
cache_path = _cache_path(interval)
|
|
66
|
+
if not cache_path.exists():
|
|
67
|
+
return pd.DataFrame()
|
|
68
|
+
try:
|
|
69
|
+
cached = pd.read_csv(cache_path, index_col=0, parse_dates=True)
|
|
70
|
+
cached.index = pd.to_datetime(cached.index, utc=True).tz_localize(None)
|
|
71
|
+
if "gold_close" not in cached.columns:
|
|
72
|
+
return pd.DataFrame()
|
|
73
|
+
return cached.sort_index().dropna(subset=["gold_close"])
|
|
74
|
+
except (OSError, ValueError, TypeError):
|
|
75
|
+
return pd.DataFrame()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _fetch_gold(period: str, interval: str, cache_key: str | None = None) -> pd.DataFrame:
|
|
79
|
+
request_period, request_interval = _normalized_request(period, interval)
|
|
80
|
+
for ticker in GOLD_TICKERS:
|
|
81
|
+
try:
|
|
82
|
+
data = _download(ticker, request_period, request_interval)
|
|
83
|
+
if "close" not in data.columns or data["close"].dropna().empty:
|
|
84
|
+
continue
|
|
85
|
+
result = data.rename(columns={
|
|
86
|
+
"open": "gold_open", "high": "gold_high", "low": "gold_low",
|
|
87
|
+
"close": "gold_close", "volume": "gold_volume",
|
|
88
|
+
})
|
|
89
|
+
cache_path = _cache_path(cache_key or request_interval)
|
|
90
|
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
result.to_csv(cache_path)
|
|
92
|
+
return result
|
|
93
|
+
except (RuntimeError, ValueError, requests.RequestException, OSError):
|
|
94
|
+
continue
|
|
95
|
+
cached = _load_cached_gold(cache_key or request_interval)
|
|
96
|
+
if not cached.empty:
|
|
97
|
+
return cached
|
|
98
|
+
raise ValueError("Gold market data is unavailable from GC=F, XAUUSD=X, and local cache")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def fetch_market_data(period: str = "10y", interval: str = "1d") -> pd.DataFrame:
|
|
102
|
+
"""Fetch gold, DXY, GVZ, ETF holdings/volume, and align daily factors."""
|
|
103
|
+
gold = _fetch_gold(period, interval, cache_key=interval)
|
|
104
|
+
frames: list[pd.DataFrame] = [gold]
|
|
105
|
+
request_period, request_interval = _normalized_request(period, interval)
|
|
106
|
+
for ticker, prefix in (("DX-Y.NYB", "dxy"),
|
|
107
|
+
("^GVZ", "gvz"), ("GLD", "gld"), ("IAU", "iau")):
|
|
108
|
+
try:
|
|
109
|
+
data = _download(ticker, request_period, request_interval)
|
|
110
|
+
except (RuntimeError, ValueError, requests.RequestException, OSError):
|
|
111
|
+
continue
|
|
112
|
+
columns = {
|
|
113
|
+
"open": f"{prefix}_open", "high": f"{prefix}_high",
|
|
114
|
+
"low": f"{prefix}_low", "close": f"{prefix}_close",
|
|
115
|
+
"volume": f"{prefix}_volume",
|
|
116
|
+
}
|
|
117
|
+
available = {source: target for source, target in columns.items() if source in data.columns}
|
|
118
|
+
frames.append(data.rename(columns=available)[list(available.values())])
|
|
119
|
+
frame = frames[0]
|
|
120
|
+
for other in frames[1:]:
|
|
121
|
+
frame = frame.join(other, how="left")
|
|
122
|
+
return frame.sort_index().ffill().dropna(subset=["gold_close"])
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _fred_series(series_id: str, start_date: Optional[str], end_date: Optional[str],
|
|
126
|
+
api_key: Optional[str] = None) -> pd.Series:
|
|
127
|
+
params = {"id": series_id, "cosd": start_date or "", "coed": end_date or ""}
|
|
128
|
+
url = "https://fred.stlouisfed.org/graph/fredgraph.csv"
|
|
129
|
+
api_key = (api_key or os.getenv("FRED_API_KEY", "")).strip()
|
|
130
|
+
if api_key:
|
|
131
|
+
url = "https://api.stlouisfed.org/fred/series/observations"
|
|
132
|
+
params = {"series_id": series_id, "api_key": api_key, "file_type": "json",
|
|
133
|
+
"observation_start": start_date or "", "observation_end": end_date or ""}
|
|
134
|
+
response = requests.get(url, params=params, timeout=30)
|
|
135
|
+
response.raise_for_status()
|
|
136
|
+
data = pd.DataFrame(response.json().get("observations", []))
|
|
137
|
+
series = data.set_index("date")["value"]
|
|
138
|
+
else:
|
|
139
|
+
response = requests.get(url, params=params, timeout=30)
|
|
140
|
+
response.raise_for_status()
|
|
141
|
+
data = pd.read_csv(StringIO(response.text))
|
|
142
|
+
date_column = "DATE" if "DATE" in data.columns else "observation_date"
|
|
143
|
+
series = data.set_index(date_column)[series_id].replace(".", np.nan)
|
|
144
|
+
series.index = pd.to_datetime(series.index, utc=True).tz_localize(None)
|
|
145
|
+
return pd.to_numeric(series, errors="coerce").rename(series_id).dropna()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def fetch_macro_data(start_date: str, end_date: str,
|
|
149
|
+
fred_api_key: Optional[str] = None) -> pd.DataFrame:
|
|
150
|
+
"""Fetch TIPS real yields and the 10Y-2Y Treasury curve spread."""
|
|
151
|
+
result = pd.DataFrame(index=pd.date_range(start_date, end_date, freq="D"))
|
|
152
|
+
for series_id, name in (("DFII10", "tips10y"), ("DGS10", "ust10y"), ("DGS2", "ust2y")):
|
|
153
|
+
try:
|
|
154
|
+
result = result.join(
|
|
155
|
+
_fred_series(series_id, start_date, end_date, api_key=fred_api_key),
|
|
156
|
+
how="left",
|
|
157
|
+
)
|
|
158
|
+
except (requests.RequestException, ValueError, KeyError):
|
|
159
|
+
result[name] = np.nan
|
|
160
|
+
if "DGS10" in result and "DGS2" in result:
|
|
161
|
+
result["curve_spread_10y2y"] = result["DGS10"] - result["DGS2"]
|
|
162
|
+
else:
|
|
163
|
+
result["curve_spread_10y2y"] = np.nan
|
|
164
|
+
return result.rename(columns={"DFII10": "tips10y"}).drop(columns=["DGS10", "DGS2"], errors="ignore")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def fetch_cot_data() -> pd.DataFrame:
|
|
168
|
+
"""Fetch current CFTC disaggregated gold positioning when available."""
|
|
169
|
+
url = "https://publicreporting.cftc.gov/resource/jun7-fc8e.json"
|
|
170
|
+
try:
|
|
171
|
+
response = requests.get(url, params={"$limit": 2000, "$q": "GOLD"}, timeout=30)
|
|
172
|
+
response.raise_for_status()
|
|
173
|
+
rows = response.json()
|
|
174
|
+
data = pd.DataFrame(rows)
|
|
175
|
+
date_col = next((c for c in ("report_date_as_yyyy_mm_dd", "report_date") if c in data), None)
|
|
176
|
+
if date_col is None:
|
|
177
|
+
return pd.DataFrame()
|
|
178
|
+
data.index = pd.to_datetime(data[date_col], errors="coerce")
|
|
179
|
+
result = pd.DataFrame(index=data.index)
|
|
180
|
+
for prefix, target in (("comm_positions", "commercial_positions"),
|
|
181
|
+
("noncomm_positions", "noncommercial_positions")):
|
|
182
|
+
long = next((c for c in data.columns if c.startswith(f"{prefix}_long")), None)
|
|
183
|
+
short = next((c for c in data.columns if c.startswith(f"{prefix}_short")), None)
|
|
184
|
+
if long and short:
|
|
185
|
+
result[target] = (
|
|
186
|
+
pd.to_numeric(data[long], errors="coerce")
|
|
187
|
+
- pd.to_numeric(data[short], errors="coerce")
|
|
188
|
+
)
|
|
189
|
+
return result.groupby(level=0).last().sort_index()
|
|
190
|
+
except (requests.RequestException, ValueError, KeyError):
|
|
191
|
+
return pd.DataFrame()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
GOLD_NEWS_FEEDS: tuple[tuple[str, str], ...] = (
|
|
195
|
+
("forexlive_gold", "https://www.forexlive.com/feed/news"),
|
|
196
|
+
("investing_commodities", "https://www.investing.com/rss/commodities.rss"),
|
|
197
|
+
("marketwatch_macro", "https://feeds.marketwatch.com/marketwatch/marketpulse/"),
|
|
198
|
+
)
|
|
199
|
+
GOLD_MACRO_KEYWORDS = re.compile(
|
|
200
|
+
r"\b("
|
|
201
|
+
r"gold|xauusd|fomc rate|cpi|inflation|non[- ]farm payrolls?|"
|
|
202
|
+
r"dxy|yields?"
|
|
203
|
+
r")\b",
|
|
204
|
+
re.IGNORECASE,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def filter_gold_macro_headlines(headlines: Iterable[Any]) -> list[str]:
|
|
209
|
+
"""Keep only headlines tied to gold macro drivers."""
|
|
210
|
+
return [text for item in headlines if (text := str(item).strip())
|
|
211
|
+
and GOLD_MACRO_KEYWORDS.search(text)]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def fetch_live_news() -> pd.DataFrame:
|
|
215
|
+
rows: list[dict[str, Any]] = []
|
|
216
|
+
for source, feed_url in GOLD_NEWS_FEEDS:
|
|
217
|
+
try:
|
|
218
|
+
response = requests.get(feed_url, headers={"User-Agent": "macro-forecast-engine/1.0"}, timeout=30)
|
|
219
|
+
response.raise_for_status()
|
|
220
|
+
root = ET.fromstring(response.content)
|
|
221
|
+
except (requests.RequestException, ET.ParseError):
|
|
222
|
+
continue
|
|
223
|
+
for item in root.findall(".//item"):
|
|
224
|
+
title = (item.findtext("title") or "").strip()
|
|
225
|
+
date = pd.to_datetime(item.findtext("pubDate"), errors="coerce", utc=True)
|
|
226
|
+
if title and not pd.isna(date) and GOLD_MACRO_KEYWORDS.search(title):
|
|
227
|
+
rows.append({"date": date.tz_localize(None), "headline": title, "source": source})
|
|
228
|
+
result = pd.DataFrame(rows, columns=["date", "headline", "source"])
|
|
229
|
+
if result.empty:
|
|
230
|
+
return result
|
|
231
|
+
return result.drop_duplicates(subset=["headline"]).sort_values("date", ascending=False).reset_index(drop=True)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def score_headlines(headlines: Iterable[Any], model_name: str = "ProsusAI/finbert") -> float:
|
|
235
|
+
texts = filter_gold_macro_headlines(headlines)
|
|
236
|
+
if not texts:
|
|
237
|
+
return float("nan")
|
|
238
|
+
try:
|
|
239
|
+
from transformers import pipeline
|
|
240
|
+
try:
|
|
241
|
+
classifier = pipeline("text-classification", model=model_name, top_k=None, device=0)
|
|
242
|
+
except (RuntimeError, OSError, ValueError):
|
|
243
|
+
with warnings.catch_warnings():
|
|
244
|
+
warnings.simplefilter("ignore", UserWarning)
|
|
245
|
+
classifier = pipeline("text-classification", model=model_name, top_k=None, device=-1)
|
|
246
|
+
values = []
|
|
247
|
+
for output in classifier(texts, truncation=True, max_length=256):
|
|
248
|
+
labels = output if isinstance(output, list) else [output]
|
|
249
|
+
scores = {str(item["label"]).lower(): float(item["score"]) for item in labels}
|
|
250
|
+
positive = scores.get("positive", scores.get("bullish", 0.0))
|
|
251
|
+
negative = scores.get("negative", scores.get("bearish", 0.0))
|
|
252
|
+
neutral = scores.get("neutral", 0.0)
|
|
253
|
+
values.append((positive - negative) * (1.0 - 0.25 * neutral))
|
|
254
|
+
return float(np.mean(values)) if values else float("nan")
|
|
255
|
+
except (ImportError, OSError, RuntimeError, ValueError):
|
|
256
|
+
bullish = re.compile(
|
|
257
|
+
r"\b(weak dollar|dollar falls?|dxy falls?|lower yield|rate cut|"
|
|
258
|
+
r"dovish|recession|uncertainty|gold rises?)\b", re.I,
|
|
259
|
+
)
|
|
260
|
+
bearish = re.compile(
|
|
261
|
+
r"\b(strong dollar|dollar rises?|dxy rises?|higher yield|yields? rise|"
|
|
262
|
+
r"rate hike|hawkish|hot inflation|cpi rises|ppi rises|payrolls beat)\b", re.I,
|
|
263
|
+
)
|
|
264
|
+
values = []
|
|
265
|
+
for text in texts:
|
|
266
|
+
positive = len(bullish.findall(text))
|
|
267
|
+
negative = len(bearish.findall(text))
|
|
268
|
+
values.append((positive - negative) / max(1, positive + negative))
|
|
269
|
+
return float(np.mean(values)) if values else float("nan")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def build_feature_frame(daily: pd.DataFrame, macro: Optional[pd.DataFrame] = None,
|
|
273
|
+
cot: Optional[pd.DataFrame] = None,
|
|
274
|
+
news: Optional[pd.DataFrame] = None) -> pd.DataFrame:
|
|
275
|
+
"""Align market, macro, positioning, ETF, volatility, and NLP velocity features."""
|
|
276
|
+
frame = daily.copy().sort_index()
|
|
277
|
+
if macro is not None:
|
|
278
|
+
frame = frame.join(macro.reindex(frame.index, method="ffill"), how="left")
|
|
279
|
+
if cot is not None and not cot.empty:
|
|
280
|
+
frame = frame.join(cot.reindex(frame.index, method="ffill"), how="left")
|
|
281
|
+
sentiment = 0.0
|
|
282
|
+
if news is not None and not news.empty:
|
|
283
|
+
scored_sentiment = score_headlines(news["headline"].tolist())
|
|
284
|
+
sentiment = 0.0 if pd.isna(scored_sentiment) else float(scored_sentiment)
|
|
285
|
+
frame = add_sentiment_and_flow(frame, sentiment, cot)
|
|
286
|
+
frame["sentiment"] = sentiment
|
|
287
|
+
frame["sentiment_velocity"] = frame["sentiment"].diff().fillna(0.0)
|
|
288
|
+
for column in ("tips10y", "curve_spread_10y2y", "commercial_positions",
|
|
289
|
+
"noncommercial_positions", "gvz_close", "gld_volume", "iau_volume"):
|
|
290
|
+
if column not in frame:
|
|
291
|
+
frame[column] = 0.0
|
|
292
|
+
frame[column] = pd.to_numeric(frame[column], errors="coerce").ffill().bfill().fillna(0.0)
|
|
293
|
+
scaled_columns = [
|
|
294
|
+
"gvz_close", "gld_volume", "iau_volume",
|
|
295
|
+
"commercial_positions", "noncommercial_positions",
|
|
296
|
+
]
|
|
297
|
+
frame[scaled_columns] = RobustScaler().fit_transform(frame[scaled_columns])
|
|
298
|
+
frame["gold_return"] = frame["gold_close"].pct_change()
|
|
299
|
+
frame["dxy_return"] = frame["dxy_close"].pct_change()
|
|
300
|
+
return frame.replace([np.inf, -np.inf], np.nan).dropna(subset=["gold_close"])
|