finrix 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- finrix-1.0.0.dist-info/METADATA +21 -0
- finrix-1.0.0.dist-info/RECORD +19 -0
- finrix-1.0.0.dist-info/WHEEL +5 -0
- finrix-1.0.0.dist-info/entry_points.txt +2 -0
- finrix-1.0.0.dist-info/licenses/LICENSE +21 -0
- finrix-1.0.0.dist-info/top_level.txt +1 -0
- macro_forecast_engine/__init__.py +2 -0
- macro_forecast_engine/config.py +28 -0
- macro_forecast_engine/data_fetcher.py +9 -0
- macro_forecast_engine/data_loader.py +300 -0
- macro_forecast_engine/data_processor.py +40 -0
- macro_forecast_engine/finrix_config.py +46 -0
- macro_forecast_engine/indicator_params.py +77 -0
- macro_forecast_engine/json_utils.py +27 -0
- macro_forecast_engine/modal_engine.py +606 -0
- macro_forecast_engine/run_engine.py +68 -0
- macro_forecast_engine/trader_execution.py +84 -0
- macro_forecast_engine/trader_risk.py +103 -0
- macro_forecast_engine/trader_timemachine.py +413 -0
|
@@ -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,19 @@
|
|
|
1
|
+
finrix-1.0.0.dist-info/licenses/LICENSE,sha256=PkWpx3QYKjoCyajUqZ4EKlh_FsSnt1zjueGdh-gx5iM,1085
|
|
2
|
+
macro_forecast_engine/__init__.py,sha256=2peZxcAiMEZzRcF5Ejp1RNgL3KviaTz9M9yP7MK1CcY,49
|
|
3
|
+
macro_forecast_engine/config.py,sha256=2CgbnZ71fMDQQNxbhf3mumXKaa0qKqhziuTb5ZDizkM,878
|
|
4
|
+
macro_forecast_engine/data_fetcher.py,sha256=sCid1C6kdsBuX6bnM_P4y4znRNCQz31lw8lO8BOxjkY,254
|
|
5
|
+
macro_forecast_engine/data_loader.py,sha256=5xDK-p7ARwb1Ny22kF8Os3Pv-wV-yWytWKp4_J3AXs4,14121
|
|
6
|
+
macro_forecast_engine/data_processor.py,sha256=3Nx-nflg_fqtWDEYI1agxtZNAPRZdBOXbjUdHChficM,1684
|
|
7
|
+
macro_forecast_engine/finrix_config.py,sha256=XFxfTeItVlV05SJzPVO0jo6w7VHNipSxfTqLXsAixpU,1493
|
|
8
|
+
macro_forecast_engine/indicator_params.py,sha256=Bwa7koq71SAs2NwMJX0cAQ6nnqhJi43OcGPDAx0h66w,3047
|
|
9
|
+
macro_forecast_engine/json_utils.py,sha256=5b1lloZliIwKTXYIvXbM3lYcw2fOYlOANEr28XseHQI,855
|
|
10
|
+
macro_forecast_engine/modal_engine.py,sha256=E8l1K6WSW-G7Xc4X6dsHjiB6eeUkcNfdun-R5WeuICY,29895
|
|
11
|
+
macro_forecast_engine/run_engine.py,sha256=dK_4dCctHY3mhW7pcmQyzFjvRP8kitC7sZHbav6b0t8,2412
|
|
12
|
+
macro_forecast_engine/trader_execution.py,sha256=FUtXuNUuRrPxcvnkLoWszWdFijFyabKgCrMFel5H3-E,3433
|
|
13
|
+
macro_forecast_engine/trader_risk.py,sha256=Vk28Uo3EvDU2AXKhuNZ1AFxW-3u8vfSrmcVBJH0llt4,4040
|
|
14
|
+
macro_forecast_engine/trader_timemachine.py,sha256=gT7yBMl7r1ilqzIjstM0vB6acbjQzmwxq0m-bn59-pQ,21630
|
|
15
|
+
finrix-1.0.0.dist-info/METADATA,sha256=-YoF4abg8iT_TEWUCkal89gmknVSvI0FFJnJbukGOjQ,688
|
|
16
|
+
finrix-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
17
|
+
finrix-1.0.0.dist-info/entry_points.txt,sha256=l3tMR6voJdR6q35MSksaiRVUD_upmt2alyfu6SHMlY0,36
|
|
18
|
+
finrix-1.0.0.dist-info/top_level.txt,sha256=L_MI7Zo4vb_ZOfz6YbyFfpsr_IIaVD5yFtoIHtP5XiA,22
|
|
19
|
+
finrix-1.0.0.dist-info/RECORD,,
|
|
@@ -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.
|
|
@@ -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"])
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Macro sentiment and institutional flow feature aggregation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def add_sentiment_and_flow(
|
|
11
|
+
frame: pd.DataFrame,
|
|
12
|
+
sentiment: float,
|
|
13
|
+
cot: Optional[pd.DataFrame],
|
|
14
|
+
) -> pd.DataFrame:
|
|
15
|
+
"""Add central-bank sentiment and CFTC-derived institutional flow features."""
|
|
16
|
+
result = frame.copy()
|
|
17
|
+
result["central_bank_sentiment"] = float(sentiment)
|
|
18
|
+
result["institutional_flow"] = 0.0
|
|
19
|
+
if cot is not None and not cot.empty:
|
|
20
|
+
columns = [name for name in ("commercial_positions", "noncommercial_positions")
|
|
21
|
+
if name in result]
|
|
22
|
+
if columns:
|
|
23
|
+
result["institutional_flow"] = result[columns].diff().sum(axis=1).fillna(0.0)
|
|
24
|
+
return result
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def analyze_news_sentiment(news: pd.DataFrame, model_name: str = "ProsusAI/finbert") -> dict[str, float]:
|
|
28
|
+
"""Score live news and expose a simple sentiment velocity metric."""
|
|
29
|
+
from .data_loader import score_headlines
|
|
30
|
+
|
|
31
|
+
if news.empty or "headline" not in news:
|
|
32
|
+
return {"score": 0.0, "velocity": 0.0}
|
|
33
|
+
score = score_headlines(news["headline"].tolist(), model_name=model_name)
|
|
34
|
+
if pd.isna(score):
|
|
35
|
+
return {"score": float("nan"), "velocity": float("nan")}
|
|
36
|
+
midpoint = max(1, len(news) // 2)
|
|
37
|
+
recent = score_headlines(news["headline"].head(midpoint).tolist(), model_name=model_name)
|
|
38
|
+
prior = score_headlines(news["headline"].tail(len(news) - midpoint).tolist(), model_name=model_name)
|
|
39
|
+
velocity = float(recent - prior) if not pd.isna(prior) else float(score)
|
|
40
|
+
return {"score": float(score), "velocity": float(velocity)}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Persistent FINRIX terminal settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
DEFAULT_CONFIG: dict[str, Any] = {
|
|
10
|
+
"execution_environment": "modal",
|
|
11
|
+
"account_equity": 1000.0,
|
|
12
|
+
"risk_per_trade_pct": 1.0,
|
|
13
|
+
"micro_lot_floor": 0.01,
|
|
14
|
+
"theme": "dark_green",
|
|
15
|
+
}
|
|
16
|
+
VALID_ENVIRONMENTS = {"modal", "local"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_config(path: Path) -> dict[str, Any]:
|
|
20
|
+
config = dict(DEFAULT_CONFIG)
|
|
21
|
+
if path.exists():
|
|
22
|
+
try:
|
|
23
|
+
loaded = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
24
|
+
if isinstance(loaded, dict):
|
|
25
|
+
config.update(loaded)
|
|
26
|
+
except (OSError, json.JSONDecodeError):
|
|
27
|
+
pass
|
|
28
|
+
config["execution_environment"] = (
|
|
29
|
+
config["execution_environment"]
|
|
30
|
+
if config["execution_environment"] in VALID_ENVIRONMENTS
|
|
31
|
+
else DEFAULT_CONFIG["execution_environment"]
|
|
32
|
+
)
|
|
33
|
+
for key in ("account_equity", "risk_per_trade_pct", "micro_lot_floor"):
|
|
34
|
+
try:
|
|
35
|
+
config[key] = max(0.0, float(config[key]))
|
|
36
|
+
except (TypeError, ValueError):
|
|
37
|
+
config[key] = DEFAULT_CONFIG[key]
|
|
38
|
+
return config
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def save_config(path: Path, config: dict[str, Any]) -> dict[str, Any]:
|
|
42
|
+
normalized = load_config(path)
|
|
43
|
+
normalized.update(config)
|
|
44
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
path.write_text(json.dumps(normalized, indent=2) + "\n", encoding="utf-8")
|
|
46
|
+
return normalized
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Persistent FINRIX MT5/TradingView indicator parameters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
11
|
+
INDICATOR_PARAMS_PATH = PROJECT_ROOT / "data" / "finrix_indicator_params.json"
|
|
12
|
+
|
|
13
|
+
DEFAULT_INDICATOR_PARAMS: dict[str, dict[str, float | int]] = {
|
|
14
|
+
"rsi": {"period": 14, "overbought": 70, "oversold": 30},
|
|
15
|
+
"ema": {"fast_period": 9, "slow_period": 21, "trend_period": 200},
|
|
16
|
+
"macd": {"fast": 12, "slow": 26, "signal": 9},
|
|
17
|
+
"bollinger": {"period": 20, "std_dev": 2.0},
|
|
18
|
+
"supertrend": {"atr_period": 10, "factor": 3.0},
|
|
19
|
+
"stochastic": {"k_period": 14, "d_period": 3, "slowing": 3},
|
|
20
|
+
"adx": {"period": 14, "threshold": 25},
|
|
21
|
+
"atr": {"period": 14},
|
|
22
|
+
"ichimoku": {"tenkan": 9, "kijun": 26, "senkou_b": 52},
|
|
23
|
+
"smc": {"fvg_min_pip_gap": 1.5, "order_block_lookback": 20},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_LIMITS: dict[str, dict[str, tuple[float, float]]] = {
|
|
27
|
+
"rsi": {"period": (2, 100), "overbought": (50, 99), "oversold": (1, 50)},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _coerce(value: Any, default: float | int) -> float | int:
|
|
32
|
+
try:
|
|
33
|
+
converted = float(value)
|
|
34
|
+
except (TypeError, ValueError):
|
|
35
|
+
return default
|
|
36
|
+
# Guard against infinity and NaN - clamp to safe defaults
|
|
37
|
+
if not math.isfinite(converted):
|
|
38
|
+
return default
|
|
39
|
+
if isinstance(default, int):
|
|
40
|
+
return int(round(converted))
|
|
41
|
+
return converted
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def validate_indicator_params(params: dict[str, Any] | None) -> dict[str, dict[str, float | int]]:
|
|
45
|
+
source = params or {}
|
|
46
|
+
result: dict[str, dict[str, float | int]] = {}
|
|
47
|
+
for group, defaults in DEFAULT_INDICATOR_PARAMS.items():
|
|
48
|
+
result[group] = {}
|
|
49
|
+
for key, default in defaults.items():
|
|
50
|
+
value = _coerce(source.get(group, {}).get(key), default)
|
|
51
|
+
low, high = _LIMITS.get(group, {}).get(key, (0.1, 1000.0))
|
|
52
|
+
result[group][key] = min(high, max(low, value))
|
|
53
|
+
result["rsi"]["oversold"] = min(result["rsi"]["oversold"], result["rsi"]["overbought"] - 1)
|
|
54
|
+
result["rsi"]["overbought"] = max(result["rsi"]["overbought"], result["rsi"]["oversold"] + 1)
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_indicator_params(path: Path = INDICATOR_PARAMS_PATH) -> dict[str, dict[str, float | int]]:
|
|
59
|
+
try:
|
|
60
|
+
params = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
61
|
+
except (OSError, json.JSONDecodeError):
|
|
62
|
+
params = {}
|
|
63
|
+
validated = validate_indicator_params(params)
|
|
64
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
if not path.exists() or validated != params:
|
|
66
|
+
path.write_text(json.dumps(validated, indent=2), encoding="utf-8")
|
|
67
|
+
return validated
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def save_indicator_params(
|
|
71
|
+
params: dict[str, Any],
|
|
72
|
+
path: Path = INDICATOR_PARAMS_PATH,
|
|
73
|
+
) -> dict[str, dict[str, float | int]]:
|
|
74
|
+
validated = validate_indicator_params(params)
|
|
75
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
path.write_text(json.dumps(validated, indent=2), encoding="utf-8")
|
|
77
|
+
return validated
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""JSON-safe conversion helpers shared by local and Modal execution paths."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date, datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _json_safe(value: Any) -> Any:
|
|
13
|
+
if isinstance(value, dict):
|
|
14
|
+
return {str(key): _json_safe(item) for key, item in value.items()}
|
|
15
|
+
if isinstance(value, (list, tuple)):
|
|
16
|
+
return [_json_safe(item) for item in value]
|
|
17
|
+
if value is pd.NaT or isinstance(value, type(pd.NaT)):
|
|
18
|
+
return None
|
|
19
|
+
if isinstance(value, (datetime, date)):
|
|
20
|
+
return value.isoformat()
|
|
21
|
+
if value is None:
|
|
22
|
+
return None
|
|
23
|
+
if isinstance(value, (np.integer, np.floating)):
|
|
24
|
+
value = value.item()
|
|
25
|
+
if isinstance(value, float) and not np.isfinite(value):
|
|
26
|
+
return None
|
|
27
|
+
return value
|