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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ finrix = app:main
@@ -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,2 @@
1
+ """Macro-quant XAUUSD forecasting package."""
2
+
@@ -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,9 @@
1
+ """Compatibility exports for timeframe-aware market data ingestion."""
2
+
3
+ from .data_loader import (
4
+ GOLD_TICKERS,
5
+ INTRADAY_PERIOD_LIMITS,
6
+ fetch_market_data,
7
+ )
8
+
9
+ __all__ = ["GOLD_TICKERS", "INTRADAY_PERIOD_LIMITS", "fetch_market_data"]
@@ -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