""" app_config.py — deployment-settings overlay (config.json) ========================================================= Loads the gitignored config.json at the repo root ONCE per process. Deployment surface ONLY: Telegram credentials, data-provider selection and API keys, scanned pairs/timeframes, per-pair base blocked hours, alert-quality boot default, log level. Signal logic, weights, thresholds and gates stay in config.py / pair_signals. Precedence for every setting: env var → config.json → code default. A repo-root .env file (gitignored) is loaded at import via setdefault, so it behaves exactly like real environment variables except that a real shell export always wins. Secrets have NO code default — with neither env nor config.json the bot refuses to start (telegram_bot.main) and data fetches fail with a named error (data_provider). Parity contract (test-enforced): with no config.json, or one holding the config.json.example defaults, every produced PlatformConfig is byte-identical to the pure-code defaults. apply_pair_overrides() runs in the pair factory BEFORE the validated per-timeframe deltas, so the bot, CLI and backtester all see the same overlay (live == backtest). """ import json import logging import math import os from dataclasses import dataclass, field from typing import Any, Dict, List, Optional from config import PAIR_INFO, PlatformConfig, Timeframe, XAUUSD logger = logging.getLogger(__name__) CONFIG_PATH = os.path.join( os.path.dirname(os.path.abspath(__file__)), "config.json") ENV_PATH = os.path.join( os.path.dirname(os.path.abspath(__file__)), ".env") def load_dotenv(path: str = ENV_PATH) -> None: """Minimal .env loader (no python-dotenv dependency). KEY=VALUE lines land in os.environ via setdefault — a variable already exported in the shell always wins, keeping the env → config.json precedence intact (.env is just a convenient place to put env vars). Comments, blank lines, ``export `` prefixes and single/double quotes are handled; anything else is skipped. """ try: with open(path, encoding="utf-8") as fh: lines = fh.read().splitlines() except OSError: return for line in lines: line = line.strip() if not line or line.startswith("#") or "=" not in line: continue if line.startswith("export "): line = line[len("export "):] key, _, value = line.partition("=") key, value = key.strip(), value.strip() if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": value = value[1:-1] elif value[:1] not in "\"'": # Unquoted value: whitespace-then-# starts an inline comment # (KEY=abc # prod). Quote the value to keep a literal " #". for sep in (" #", "\t#"): cut = value.find(sep) if cut != -1: value = value[:cut].rstrip() if key: os.environ.setdefault(key, value) # Module import == process boot, before any resolver can run. load_dotenv() _VALID_QUALITIES = ("A+", "A", "B", "C") _VALID_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL") # Timeframes the bot's candle-boundary scheduler supports (must have # TF_CONFIRMATIONS entries in telegram_bot.py). _SCANNABLE_TFS = ("M5", "M15", "H1", "H4") _KNOWN_TOP = {"telegram", "data_providers", "scanning", "pairs", "log_level", "oanda", "deepseek", "agent_panel", # "market_outlook" is TOLERATED but ignored: the AI market # outlook was removed 2026-09-15 (last commit 2836024). Live # config.json files still carry the block and must keep # loading; load_app_config logs one warning when it is there. "market_outlook", "violent_watch", "signal_log", "news_archive", "news_model", "trade_size"} _KNOWN_OANDA = { # "account_id" is TOLERATED but ignored: it belonged to the auto- # execution daemon removed 2026-09-03 (last commit 31002cb). The candle # provider needs only api_token + base_url; older config.json files # still carry the key and must keep loading. "account_id", "api_token", "base_url", } _KNOWN_DEEPSEEK = { "api_key", "model", "enabled", "timeout_seconds", "daily_request_cap", } _KNOWN_AGENT_PANEL = { "enabled", "telegram", "dashboard", "min_quality", "max_panels_per_day", "reuse_ttl_seconds", "candles_enabled", "candle_bars_primary", "candle_bars_confirm", "verdict_source", "personas", } _KNOWN_VIOLENT_WATCH = {"enabled", "alert_line", "headsup", "max_headsup_per_day", "headsup_min_level"} _KNOWN_SIGNAL_LOG = {"enabled", "path", "log_blocked_scans"} _KNOWN_NEWS_ARCHIVE = {"enabled", "path"} _KNOWN_NEWS_MODEL = {"path", "threads", "mode", "deepseek_audit_per_day"} _NEWS_MODEL_MODES = ("off", "fallback", "primary") # Lot-size guidance on the alert (exposure.py, display only). Off until # the owner sets risk_per_trade. The size reveals the owner's risk per # trade, so it is printed in the owner's own chat (telegram.chat_id) and # the CLI only; telegram_channel switches it on for every other Telegram # chat (the subscriber channel, a group), dashboard for the public page. _KNOWN_TRADE_SIZE = {"risk_per_trade", "contract_size", "lot_step", "telegram_channel", "dashboard"} _TRADE_SIZE_DEFAULTS = { "contract_size": 100.0, # ounces per 1.00 lot of XAU/USD "lot_step": 0.01, # smallest size increment the broker accepts "telegram_channel": False, "dashboard": False, } _KNOWN_TELEGRAM = {"token", "chat_id", "channel_id", "alert_min_quality"} _KNOWN_SCANNING = {"pairs", "timeframes"} _KNOWN_PAIR_KEYS = {"blocked_hours_utc"} # Violent-move watch (display-only heads-up) — dark by default. _VIOLENT_WATCH_DEFAULTS = { "enabled": False, "alert_line": True, "headsup": True, "max_headsup_per_day": 12, "headsup_min_level": "hot", } _VALID_WATCH_LEVELS = ("watch", "hot") class AppConfigError(Exception): """config.json is present but invalid — fail fast at boot.""" @dataclass class AppConfig: telegram_token: str = "" telegram_chat_id: str = "" telegram_channel_id: str = "" # "" = channel feed disabled alert_min_quality: str = "C" active_provider: str = "twelve_data" streaming_provider: Optional[str] = None # None = active provider provider_settings: Dict[str, Dict[str, Any]] = field(default_factory=dict) scan_pairs: List[str] = field(default_factory=lambda: [XAUUSD]) scan_timeframes: List[Timeframe] = field(default_factory=lambda: [ Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4]) pair_overrides: Dict[str, Dict[str, Any]] = field(default_factory=dict) oanda_settings: Dict[str, Any] = field(default_factory=dict) deepseek_settings: Dict[str, Any] = field(default_factory=dict) agent_panel_settings: Dict[str, Any] = field(default_factory=dict) violent_watch_settings: Dict[str, Any] = field( default_factory=lambda: dict(_VIOLENT_WATCH_DEFAULTS)) signal_log_settings: Dict[str, Any] = field(default_factory=dict) news_archive_settings: Dict[str, Any] = field(default_factory=dict) news_model_settings: Dict[str, Any] = field(default_factory=dict) trade_size_settings: Dict[str, Any] = field(default_factory=dict) log_level: str = "INFO" def _check_keys(section: Dict[str, Any], known: set, where: str) -> None: if not isinstance(section, dict): raise AppConfigError(f"config.json: {where} must be a JSON object") unknown = sorted(set(section) - known) if unknown: raise AppConfigError( f"config.json: unknown key(s) {unknown} in {where}; " f"known keys: {sorted(known)}") def load_app_config(path: str = CONFIG_PATH) -> AppConfig: """Parse + validate config.json. Missing file → pure defaults.""" app = AppConfig() if not os.path.exists(path): logger.warning( "config.json not found at %s — running on code defaults " "(secrets must come from environment variables)", path) return app try: with open(path, encoding="utf-8") as fh: raw = json.load(fh) except json.JSONDecodeError as e: raise AppConfigError(f"config.json is not valid JSON: {e}") from e if not isinstance(raw, dict): raise AppConfigError("config.json top level must be a JSON object") _check_keys(raw, _KNOWN_TOP, "top level") tg = raw.get("telegram", {}) _check_keys(tg, _KNOWN_TELEGRAM, '"telegram"') app.telegram_token = str(tg.get("token", app.telegram_token)) app.telegram_chat_id = str(tg.get("chat_id", app.telegram_chat_id)) app.telegram_channel_id = str( tg.get("channel_id", app.telegram_channel_id)) quality = tg.get("alert_min_quality", app.alert_min_quality) if quality not in _VALID_QUALITIES: raise AppConfigError( f'config.json: telegram.alert_min_quality "{quality}" ' f"not in {_VALID_QUALITIES}") app.alert_min_quality = quality dp = raw.get("data_providers") if dp is not None: if not isinstance(dp, dict): raise AppConfigError('config.json: "data_providers" must be an object') app.active_provider = str(dp.get("active", app.active_provider)) app.provider_settings = {} for name, block in dp.items(): # "active" and "streaming" are reserved scalars, not blocks. if name in ("active", "streaming"): continue if not isinstance(block, dict): raise AppConfigError( f'config.json: data_providers.{name} must be an object') app.provider_settings[name] = dict(block) if app.active_provider not in app.provider_settings: raise AppConfigError( f'config.json: data_providers.active "{app.active_provider}" ' "has no matching provider block") streaming = dp.get("streaming") if streaming is not None: if not isinstance(streaming, str): raise AppConfigError( 'config.json: data_providers.streaming must be a string ' "(a provider name)") if streaming not in app.provider_settings: raise AppConfigError( f'config.json: data_providers.streaming "{streaming}" ' "has no matching provider block") app.streaming_provider = streaming scanning = raw.get("scanning", {}) _check_keys(scanning, _KNOWN_SCANNING, '"scanning"') if "pairs" in scanning: pairs = scanning["pairs"] if not isinstance(pairs, list): raise AppConfigError('config.json: scanning.pairs must be a list') bad = [p for p in pairs if p not in PAIR_INFO] if bad: raise AppConfigError( f"config.json: unknown pair(s) {bad} in scanning.pairs; " f"known: {sorted(PAIR_INFO)}") app.scan_pairs = list(pairs) if "timeframes" in scanning: names = scanning["timeframes"] if not isinstance(names, list): raise AppConfigError( 'config.json: scanning.timeframes must be a list') bad = [n for n in names if n not in _SCANNABLE_TFS] if bad: raise AppConfigError( f"config.json: unsupported timeframe(s) {bad} in " f"scanning.timeframes; supported: {list(_SCANNABLE_TFS)}") app.scan_timeframes = [Timeframe[n] for n in names] pair_section = raw.get("pairs", {}) if not isinstance(pair_section, dict): raise AppConfigError('config.json: "pairs" must be a JSON object') for symbol, overrides in pair_section.items(): if symbol not in PAIR_INFO: raise AppConfigError( f'config.json: unknown pair "{symbol}" in pairs; ' f"known: {sorted(PAIR_INFO)}") _check_keys(overrides, _KNOWN_PAIR_KEYS, f'pairs."{symbol}"') if "blocked_hours_utc" in overrides: hours = overrides["blocked_hours_utc"] valid = isinstance(hours, list) and all( isinstance(h, int) and not isinstance(h, bool) and 0 <= h <= 23 for h in hours) if not valid: raise AppConfigError( f'config.json: pairs."{symbol}".blocked_hours_utc must ' "be a list of integers in 0-23") app.pair_overrides = {s: dict(o) for s, o in pair_section.items()} oanda = raw.get("oanda", {}) _check_keys(oanda, _KNOWN_OANDA, '"oanda"') app.oanda_settings = dict(oanda) deepseek = raw.get("deepseek", {}) _check_keys(deepseek, _KNOWN_DEEPSEEK, '"deepseek"') if "enabled" in deepseek and not isinstance(deepseek["enabled"], bool): raise AppConfigError( "config.json: deepseek.enabled must be true or false") if "timeout_seconds" in deepseek: ts = deepseek["timeout_seconds"] if isinstance(ts, bool) or not isinstance(ts, (int, float)) or ts <= 0: raise AppConfigError( "config.json: deepseek.timeout_seconds must be a positive " "number") if "daily_request_cap" in deepseek: cap = deepseek["daily_request_cap"] if isinstance(cap, bool) or not isinstance(cap, int) or cap < 1: raise AppConfigError( "config.json: deepseek.daily_request_cap must be a positive " "integer") if "model" in deepseek: if not isinstance(deepseek["model"], str) or not deepseek["model"]: raise AppConfigError( "config.json: deepseek.model must be a non-empty string") app.deepseek_settings = dict(deepseek) panel = raw.get("agent_panel", {}) _check_keys(panel, _KNOWN_AGENT_PANEL, '"agent_panel"') for flag in ("enabled", "telegram", "dashboard"): if flag in panel and not isinstance(panel[flag], bool): raise AppConfigError( f"config.json: agent_panel.{flag} must be true or false") if "min_quality" in panel and panel["min_quality"] not in _VALID_QUALITIES: raise AppConfigError( f'config.json: agent_panel.min_quality "{panel["min_quality"]}" ' f"not in {_VALID_QUALITIES}") if "max_panels_per_day" in panel: mpd = panel["max_panels_per_day"] if isinstance(mpd, bool) or not isinstance(mpd, int) or mpd < 1: raise AppConfigError( "config.json: agent_panel.max_panels_per_day must be a " "positive integer") if "reuse_ttl_seconds" in panel: ttl = panel["reuse_ttl_seconds"] if isinstance(ttl, bool) or not isinstance(ttl, (int, float)) \ or ttl < 0: raise AppConfigError( "config.json: agent_panel.reuse_ttl_seconds must be a " "non-negative number") if "candles_enabled" in panel \ and not isinstance(panel["candles_enabled"], bool): raise AppConfigError( "config.json: agent_panel.candles_enabled must be true or false") for key in ("candle_bars_primary", "candle_bars_confirm"): if key in panel: n = panel[key] if isinstance(n, bool) or not isinstance(n, int) \ or not 1 <= n <= 100: raise AppConfigError( f"config.json: agent_panel.{key} must be an integer " "between 1 and 100") if "verdict_source" in panel and panel["verdict_source"] not in \ ("mean", "macro", "chair"): raise AppConfigError( 'config.json: agent_panel.verdict_source must be "mean", ' '"macro" or "chair"') if "personas" in panel: names = panel["personas"] valid = ("macro", "technical", "risk", "contrarian") if (not isinstance(names, list) or not names or any(not isinstance(n, str) or n not in valid for n in names) or len(set(names)) != len(names)): raise AppConfigError( "config.json: agent_panel.personas must be a non-empty list " f"of distinct names from {valid}") if panel.get("verdict_source") == "chair" and len(names) < 2: raise AppConfigError( 'config.json: agent_panel.verdict_source "chair" needs at ' "least two personas") if panel.get("verdict_source") == "macro" and "macro" not in names: raise AppConfigError( 'config.json: agent_panel.verdict_source "macro" needs the ' "macro persona in agent_panel.personas") app.agent_panel_settings = dict(panel) if "market_outlook" in raw: # The AI market-outlook feature was removed 2026-09-15; older # config.json files still carry its block and must keep loading. logger.warning( "config.json: market_outlook block is ignored (feature removed " "2026-09-15) — delete it") watch = raw.get("violent_watch", {}) _check_keys(watch, _KNOWN_VIOLENT_WATCH, '"violent_watch"') for flag in ("enabled", "alert_line", "headsup"): if flag in watch and not isinstance(watch[flag], bool): raise AppConfigError( f"config.json: violent_watch.{flag} must be true or false") if "max_headsup_per_day" in watch: cap = watch["max_headsup_per_day"] if isinstance(cap, bool) or not isinstance(cap, int) or cap < 0: raise AppConfigError( "config.json: violent_watch.max_headsup_per_day must be a " "non-negative integer") if ("headsup_min_level" in watch and watch["headsup_min_level"] not in _VALID_WATCH_LEVELS): raise AppConfigError( f'config.json: violent_watch.headsup_min_level ' f'"{watch["headsup_min_level"]}" not in {_VALID_WATCH_LEVELS}') app.violent_watch_settings.update(watch) slog = raw.get("signal_log", {}) _check_keys(slog, _KNOWN_SIGNAL_LOG, '"signal_log"') if "enabled" in slog and not isinstance(slog["enabled"], bool): raise AppConfigError( "config.json: signal_log.enabled must be true or false") if "path" in slog and (not isinstance(slog["path"], str) or not slog["path"]): raise AppConfigError( "config.json: signal_log.path must be a non-empty string") if ("log_blocked_scans" in slog and not isinstance(slog["log_blocked_scans"], bool)): raise AppConfigError( "config.json: signal_log.log_blocked_scans must be true or false") app.signal_log_settings = dict(slog) narch = raw.get("news_archive", {}) _check_keys(narch, _KNOWN_NEWS_ARCHIVE, '"news_archive"') if "enabled" in narch and not isinstance(narch["enabled"], bool): raise AppConfigError( "config.json: news_archive.enabled must be true or false") if "path" in narch and (not isinstance(narch["path"], str) or not narch["path"]): raise AppConfigError( "config.json: news_archive.path must be a non-empty string") app.news_archive_settings = dict(narch) nm = raw.get("news_model", {}) _check_keys(nm, _KNOWN_NEWS_MODEL, '"news_model"') if "mode" in nm and nm["mode"] not in _NEWS_MODEL_MODES: raise AppConfigError( 'config.json: news_model.mode must be "off", "fallback" or "primary"') if "threads" in nm and (not isinstance(nm["threads"], int) or isinstance(nm["threads"], bool) or nm["threads"] < 1): raise AppConfigError( "config.json: news_model.threads must be a positive integer") if "deepseek_audit_per_day" in nm and ( not isinstance(nm["deepseek_audit_per_day"], int) or isinstance(nm["deepseek_audit_per_day"], bool) or nm["deepseek_audit_per_day"] < 0): raise AppConfigError( "config.json: news_model.deepseek_audit_per_day must be a " "non-negative integer") if nm.get("mode", "off") != "off" and ( not isinstance(nm.get("path"), str) or not nm.get("path")): raise AppConfigError( "config.json: news_model.path is required when news_model.mode " "is not off") app.news_model_settings = dict(nm) size = raw.get("trade_size", {}) _check_keys(size, _KNOWN_TRADE_SIZE, '"trade_size"') for key in ("risk_per_trade", "contract_size", "lot_step"): if key in size: v = size[key] if (isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v)): raise AppConfigError( f"config.json: trade_size.{key} must be a number") # risk_per_trade 0 = the size line is off (the default) if v < 0 or (v == 0 and key != "risk_per_trade"): raise AppConfigError( f"config.json: trade_size.{key} must be " + ("zero (off) or a positive number" if key == "risk_per_trade" else "a positive number")) for flag in ("telegram_channel", "dashboard"): if flag in size and not isinstance(size[flag], bool): raise AppConfigError( f"config.json: trade_size.{flag} must be true or false") app.trade_size_settings = dict(size) level = raw.get("log_level", app.log_level) if level not in _VALID_LOG_LEVELS: raise AppConfigError( f'config.json: log_level "{level}" not in {_VALID_LOG_LEVELS}') app.log_level = level return app # ── Process-wide singleton ──────────────────────────────────────────── _cached: Optional[AppConfig] = None def get_app_config() -> AppConfig: global _cached if _cached is None: _cached = load_app_config() return _cached def set_app_config(app: Optional[AppConfig]) -> None: """Override the process-wide AppConfig (tests/experiments). Pass None to re-enable lazy loading from CONFIG_PATH.""" global _cached _cached = app # ── Resolution helpers: env var → config.json → default ─────────────── def telegram_token(app: Optional[AppConfig] = None) -> str: app = app or get_app_config() return os.environ.get("TELEGRAM_TOKEN") or app.telegram_token def telegram_chat_id(app: Optional[AppConfig] = None) -> str: app = app or get_app_config() return os.environ.get("TELEGRAM_CHAT_ID") or app.telegram_chat_id def telegram_channel_id(app: Optional[AppConfig] = None) -> str: """Env set-but-empty ("") means "disable the feed" and beats JSON, so this checks None-ness, not truthiness.""" app = app or get_app_config() env = os.environ.get("TELEGRAM_CHANNEL_ID") return env if env is not None else app.telegram_channel_id # Per-provider env-var overrides for API keys (env → config.json → ""). _PROVIDER_ENV_KEYS = { "twelve_data": "TWELVE_DATA_API_KEY", "tiingo": "TIINGO_API_KEY", } def provider_api_key( app: Optional[AppConfig] = None, provider: Optional[str] = None ) -> str: """API key for `provider` (default: the ACTIVE provider). The env override is per-provider (TWELVE_DATA_API_KEY applies only to twelve_data, TIINGO_API_KEY only to tiingo).""" app = app or get_app_config() name = provider or app.active_provider env_key = _PROVIDER_ENV_KEYS.get(name) if env_key: env = os.environ.get(env_key) if env: return env block = app.provider_settings.get(name, {}) return str(block.get("api_key", "")) # ── Oanda resolution helpers (env → config.json → default) ─────────── def oanda_api_token(app: Optional[AppConfig] = None) -> str: app = app or get_app_config() return os.environ.get("OANDA_API_TOKEN") or str( app.oanda_settings.get("api_token", "")) def oanda_base_url(app: Optional[AppConfig] = None) -> str: app = app or get_app_config() return os.environ.get("OANDA_BASE_URL") or str( app.oanda_settings.get("base_url", "https://api-fxpractice.oanda.com")) # ── DeepSeek resolution helper (env → config.json → "") ────────────── def deepseek_api_key(app: Optional[AppConfig] = None) -> str: app = app or get_app_config() return os.environ.get("DEEPSEEK_API_KEY") or str( app.deepseek_settings.get("api_key", "")) # ── Violent-move watch resolution helper ───────────────────────────── def violent_watch_enabled(app: Optional[AppConfig] = None) -> bool: """Whether the display-only violent-move watch is on (env wins).""" env = os.environ.get("VIOLENT_WATCH_ENABLED") if env: return env.strip().lower() in ("1", "true", "yes", "on") app = app or get_app_config() return bool(app.violent_watch_settings.get("enabled", False)) # ── Process-level database write switch ────────────────────────────── #: True in every process by default. The dashboard publisher turns it #: off at startup (owner decision 2026-09-17): it is not always running, #: so the Telegram bot must be the single writer of signals.db and #: newsdata.db — two writers were also two processes contending for the #: same WAL files. Every SQLite writer (signal_log, news_archive) reads #: the switch through signal_log_enabled() / news_archive_enabled(), so #: a switched-off process cannot write whatever config.json or the #: environment says. Read-only users of the databases are unaffected. _process_db_writes = True def set_db_writes_for_process(enabled: bool) -> None: global _process_db_writes _process_db_writes = bool(enabled) def db_writes_enabled() -> bool: return _process_db_writes # ── Signal-log resolution helpers (env → config.json → default) ────── def signal_log_enabled(app: Optional[AppConfig] = None) -> bool: """Default ON — signal history is the platform's audit trail.""" if not _process_db_writes: return False env = os.environ.get("SIGNAL_LOG_ENABLED") if env: return env.strip().lower() in ("1", "true", "yes", "on") app = app or get_app_config() return bool(app.signal_log_settings.get("enabled", True)) def signal_log_blocked_scans(app: Optional[AppConfig] = None) -> bool: """Whether weekend/day-filter early returns are logged (as minimal rows without news/candles). Default ON — they are liveness evidence; turn off to keep the history table to real scans only.""" env = os.environ.get("SIGNAL_LOG_BLOCKED_SCANS") if env: return env.strip().lower() in ("1", "true", "yes", "on") app = app or get_app_config() return bool(app.signal_log_settings.get("log_blocked_scans", True)) def signal_log_path(app: Optional[AppConfig] = None) -> str: """Absolute path to the SQLite signal-history DB. A relative config value resolves against the repo root so CWD never matters.""" env = os.environ.get("SIGNAL_LOG_PATH") if env: path = env else: app = app or get_app_config() path = str(app.signal_log_settings.get("path", "logs/signals.db")) if not os.path.isabs(path): path = os.path.join(os.path.dirname(os.path.abspath(__file__)), path) return path # ── Local news model (env → config.json → default) ─────────────────── # # The owner's ML-NEWS XAU transformer scores headlines locally # (news_model.py). "off" is today's DeepSeek/keyword path; "fallback" runs # the model only on rows DeepSeek left keyword-scored; "primary" scores # every headline with the model and never calls DeepSeek for headlines # (except the optional audit sample). NEWS_MODEL_MODE lets one process # (the dashboard daemon's systemd unit) pick its own mode: each process # that loads the model holds ~0.6 GB. def news_model_mode(app: Optional[AppConfig] = None) -> str: env = os.environ.get("NEWS_MODEL_MODE") if env: mode = env.strip().lower() if mode in _NEWS_MODEL_MODES: return mode logger.warning( 'NEWS_MODEL_MODE=%r is not one of %s; using config.json', env, _NEWS_MODEL_MODES) app = app or get_app_config() mode = app.news_model_settings.get("mode", "off") return mode if mode in _NEWS_MODEL_MODES else "off" def news_model_path(app: Optional[AppConfig] = None) -> Optional[str]: """Absolute path to the bundle directory (model.onnx, tokenizer.json, meta.json), or None when not configured. A relative value resolves against the repo root like news_archive_path.""" app = app or get_app_config() path = app.news_model_settings.get("path") if not isinstance(path, str) or not path: return None if not os.path.isabs(path): path = os.path.join(os.path.dirname(os.path.abspath(__file__)), path) return path def news_model_threads(app: Optional[AppConfig] = None) -> int: """ONNX intra-op threads (measured 2026-09-27: 1 → 19 ms per headline, 4 → 9 ms, 16 → 13 ms).""" app = app or get_app_config() try: return max(1, int(app.news_model_settings.get("threads", 4))) except (TypeError, ValueError): return 4 def news_model_audit_per_day(app: Optional[AppConfig] = None) -> int: """primary mode only: headlines per UTC day also sent to DeepSeek so the archive keeps receiving fresh training labels. Archive-only, never displayed. 0 = no DeepSeek headline calls at all (owner decision 2026-09-27).""" app = app or get_app_config() try: return max(0, int(app.news_model_settings.get("deepseek_audit_per_day", 0))) except (TypeError, ValueError): return 0 # ── News-archive resolution helpers (env → config.json → default) ──── def news_archive_enabled(app: Optional[AppConfig] = None) -> bool: """Default ON — the headline corpus is training data for a local scorer and cannot be backfilled once a fetch window has passed. It lives in its own DB so a signals.db rotation cannot take it with it (which is how three weeks of news were lost on 2026-09-04).""" if not _process_db_writes: return False env = os.environ.get("NEWS_ARCHIVE_ENABLED") if env: return env.strip().lower() in ("1", "true", "yes", "on") app = app or get_app_config() return bool(app.news_archive_settings.get("enabled", True)) def news_archive_path(app: Optional[AppConfig] = None) -> str: """Absolute path to the SQLite headline corpus. A relative config value resolves against the repo root so CWD never matters.""" env = os.environ.get("NEWS_ARCHIVE_PATH") if env: path = env else: app = app or get_app_config() path = str(app.news_archive_settings.get("path", "logs/newsdata.db")) if not os.path.isabs(path): path = os.path.join(os.path.dirname(os.path.abspath(__file__)), path) return path # ── Lot-size guidance (config.json only; display) ──────────────────── def trade_size_settings(app: Optional[AppConfig] = None) -> Optional[Dict[str, Any]]: """The lot-size guidance settings with their defaults filled in, or None when the owner has not set `trade_size.risk_per_trade` (the size line is then off on every surface).""" app = app or get_app_config() block = app.trade_size_settings or {} if not block.get("risk_per_trade"): return None out = dict(_TRADE_SIZE_DEFAULTS) out.update(block) return out # ── PlatformConfig overlay ──────────────────────────────────────────── def apply_pair_overrides( cfg: PlatformConfig, app: Optional[AppConfig] = None ) -> PlatformConfig: """Stamp deployment settings from config.json onto a PlatformConfig. Called by the pair factories BEFORE their validated per-timeframe deltas, so e.g. the M5 hour-9 append lands on top of the JSON base. The pair's base blocked_hours_utc is set on BOTH mirrored copies (xau_strategy + backtest) so they cannot drift. """ app = app or get_app_config() overrides = app.pair_overrides.get(cfg.symbol, {}) if "blocked_hours_utc" in overrides: hours = list(overrides["blocked_hours_utc"]) cfg.xau_strategy.blocked_hours_utc = hours cfg.backtest.blocked_hours_utc = list(hours) cfg.scan_pairs = list(app.scan_pairs) cfg.scan_timeframes = list(app.scan_timeframes) return cfg