""" Central configuration for the Moirai-MoE / AutoARIMA / Chronos-2 Forex/Crypto forecasting system. All thresholds, feature schema, and resource limits live here so they are auditable and change in one place (spec sections 19-20, 30, 42, 85, 111). """ from dataclasses import dataclass from typing import Optional # --- Predictive models --------------------------------------------------- # Moirai-MoE was the sole model through Aug 2026. AutoARIMA and # Chronos-2 were added alongside it as fully independent, selectable # models (app.py's model picker) -- never blended, never a fallback for # one another (see forecast_types.py's docstring for what "independent" # means structurally). MODEL_CHECKPOINT is kept as a standalone constant # (rather than folded into the registry below) because moirai_model.py # and existing tests already reference it directly. MODEL_CHECKPOINT = "Salesforce/moirai-moe-1.0-R-small" # key -> (display name, module.ClassName as a string for lazy import, # real pretrained checkpoint or None for a classical/no-checkpoint model) AVAILABLE_MODELS = { "moirai": {"display_name": "Moirai-MoE-1.0-R-Small", "checkpoint": MODEL_CHECKPOINT}, "autoarima": {"display_name": "AutoARIMA", "checkpoint": None}, "chronos2": {"display_name": "Chronos-2", "checkpoint": "amazon/chronos-2"}, } DEFAULT_MODEL_KEY = "moirai" # --- Timeframes --------------------------------------------------------- # native = directly retrievable from yfinance # derived = built via causal resampling from a finer native timeframe (section 10-11) NATIVE_INTRADAY = ["1m", "2m", "5m", "15m", "30m", "1h"] NATIVE_OTHER = ["1d", "1wk", "1mo"] DERIVED_MAP = { # target_tf: (source_tf, bars_per_bin) "10m": ("5m", 2), "2h": ("1h", 2), "4h": ("1h", 4), } ALL_TIMEFRAMES = NATIVE_INTRADAY + list(DERIVED_MAP.keys()) + NATIVE_OTHER TIMEFRAME_MINUTES = { "1m": 1, "2m": 2, "5m": 5, "10m": 10, "15m": 15, "30m": 30, "1h": 60, "2h": 120, "4h": 240, "1d": 1440, "1wk": 10080, "1mo": 43200, } # Approximate Yahoo intraday lookback ceilings in days (source-enforced, # see spec section 8; yfinance will simply return less data than # requested rather than erroring, so we surface this explicitly) YAHOO_INTRADAY_MAX_DAYS = { "1m": 7, "2m": 60, "5m": 60, "15m": 60, "30m": 60, "1h": 730, } # Pandas frequency alias for each timeframe. This is passed EXPLICITLY to # gluonts' PandasDataset(freq=...) in moirai_model.py rather than relying # on pd.infer_freq() on the raw timestamps -- real Yahoo data has gaps # (weekends, exchange closures, occasional missing bars) that break # automatic frequency inference deterministically, on every call, which # is exactly the "Model errors: 11200/11200" failure mode this fixes. TIMEFRAME_TO_PANDAS_FREQ = { "1m": "1min", "2m": "2min", "5m": "5min", "10m": "10min", "15m": "15min", "30m": "30min", "1h": "1h", "2h": "2h", "4h": "4h", "1d": "1D", "1wk": "1W", "1mo": "1MS", } # --- History window choices (UI-facing) -------------------------------- # Fine-grained day counts plus month counts, so the person can pick from # a dropdown instead of typing a period string. data_layer.resolve_history_window() # turns a label from this list into an actual start date (or "max"). HISTORY_WINDOW_CHOICES = ( [f"{d} day" if d == 1 else f"{d} days" for d in range(1, 31)] + [f"{m} months" for m in range(2, 13)] + ["1 year", "2 years", "5 years", "max"] ) # --- Symbol choices (UI-facing) ----------------------------------------- # A curated, non-exhaustive list of common Yahoo Finance Forex/Crypto # tickers for convenient selection (spec section 12 wants search/ # autocomplete "when feasible" -- yfinance itself exposes no symbol # directory, so this is that best-effort list). The UI Dropdown also # allows a custom value, so any Yahoo-supported symbol still works even # if it isn't in this list -- this list is a convenience, not a limit. FOREX_SYMBOLS = [ "EURUSD=X", "GBPUSD=X", "USDJPY=X", "USDCHF=X", "AUDUSD=X", "USDCAD=X", "NZDUSD=X", "EURGBP=X", "EURJPY=X", "GBPJPY=X", "EURCHF=X", "AUDJPY=X", "EURAUD=X", "CHFJPY=X", "GBPCHF=X", "USDCNY=X", "USDINR=X", "USDMXN=X", "USDZAR=X", "USDTRY=X", "USDSEK=X", "USDNOK=X", "USDSGD=X", "USDHKD=X", ] CRYPTO_SYMBOLS = [ "BTC-USD", "ETH-USD", "BNB-USD", "XRP-USD", "SOL-USD", "ADA-USD", "DOGE-USD", "AVAX-USD", "DOT-USD", "LTC-USD", "LINK-USD", "MATIC-USD", "TRX-USD", "SHIB-USD", "BCH-USD", "XLM-USD", "ATOM-USD", "UNI-USD", "ETC-USD", "XMR-USD", ] ALL_SYMBOLS = FOREX_SYMBOLS + CRYPTO_SYMBOLS @dataclass class IndicatorConfig: rsi_period: int = 14 macd_fast: int = 12 macd_slow: int = 26 macd_signal: int = 9 bb_period: int = 20 bb_std: float = 2.0 sma_periods: tuple = (20, 50) ema_periods: tuple = (12, 26) stoch_k: int = 14 stoch_d: int = 3 atr_period: int = 14 adx_period: int = 14 roc_period: int = 12 # Deterministic feature schema — order matters and is persisted with every # forecast/model artifact (spec section 85). Never silently reordered. PAST_DYNAMIC_FEATURES = [ "open", "high", "low", "volume", "rsi", "macd", "macd_signal", "macd_hist", "bb_mid", "bb_upper", "bb_lower", "sma_20", "sma_50", "ema_12", "ema_26", "stoch_k", "stoch_d", "atr", "adx", "roc", "obv", ] TARGET_FEATURE = "close" @dataclass class DecisionConfig: cost_bps: float = 5.0 # default round-trip cost estimate, basis points buy_threshold: float = 0.0015 # expected edge required to BUY, post-cost sell_threshold: float = -0.0015 min_probability: float = 0.55 # P(return favorable) required to act max_interval_width_pct: float = 0.05 # HOLD if forecast too uncertain (P90-P10) @dataclass class BacktestCostProfile: spread_bps: float commission_bps: float slippage_bps: float execution_delay_bars: int = 1 @dataclass class RiskManagement: """Optional intra-trade exit rules, checked against intrabar high/low between entry and the horizon exit (see backtest_engine.py). Added Aug 2026 after real backtest data showed the previously-only exit rule -- always hold the full fixed horizon, no matter what happens in between -- was exactly why losing trades ran larger than winning ones: a trade that turns adverse right after entry still rides out the entire horizon before closing, with no way to cut it short. None (the default for both fields) means "no early exit at all", which reproduces every prior backtest's numbers exactly -- this is opt-in, not a change to existing behavior.""" stop_loss_frac: Optional[float] = None # e.g. 0.003 = exit if price moves 0.3% against the position take_profit_frac: Optional[float] = None # e.g. 0.006 = exit if price moves 0.6% in favor of the position @dataclass class WalkForwardConfig: """Controls for walk_forward.py's train/validation/test split and the (deliberately small, bounded) parameter search -- spec: "do not search thousands of combinations", "avoid parameter values that produce only a handful of trades". Every field here is a knob a person can see and change, not a hidden magic number (section 25).""" train_frac: float = 0.5 val_frac: float = 0.25 # remaining ~0.25 is test, computed as 1 - train_frac - val_frac min_probability_grid: tuple = (0.55, 0.60, 0.65, 0.70) buy_threshold_grid: tuple = (0.0010, 0.0015, 0.0020, 0.0030) min_trades_for_acceptance: int = 30 # item 13: reject configs "profitable" on a handful of trades min_trades_for_calibration: int = 50 # separate, usually-larger floor for calibration itself (needs more pairs than a single accept/reject check) max_single_period_profit_share: float = 0.70 # item 18: reject if one sub-period explains >70% of total profit # Forex and crypto have different microstructure -> different cost profiles # (spec section 30). These are reasonable defaults, not measured values — # override them per-symbol if you have real spread/fee data. COST_PROFILES = { "forex": BacktestCostProfile(spread_bps=1.5, commission_bps=0.0, slippage_bps=0.5), "crypto": BacktestCostProfile(spread_bps=2.0, commission_bps=10.0, slippage_bps=2.0), } def round_trip_cost_frac(profile: BacktestCostProfile) -> float: """Round-trip cost (spread+commission+slippage) as a return fraction. Lives here (not in backtest_engine.py) because it's a pure function of a COST_PROFILES entry, and both the backtest engine and the Live tab's decide() call need the same, real, asset-class-specific number rather than each guessing independently (spec section 30).""" return (profile.spread_bps + profile.commission_bps + profile.slippage_bps) / 10_000 def infer_asset_class(symbol: str) -> str: """Best-effort classification of a Yahoo ticker into a COST_PROFILES key. The Live tab has no explicit asset-class selector (unlike the Backtest tab's `b_class` radio), so without this it silently fell back to a flat, asset-unaware cost estimate for every symbol -- wrong for both forex (too high) and crypto (too low). Checks the curated lists first, then falls back to Yahoo's own ticker conventions ('=X' suffix for forex pairs, a '-USD'/'-USDT'/etc. suffix for crypto pairs) for any other symbol typed via allow_custom_value, since those conventions are Yahoo's, not a guess of ours. Defaults to 'forex' (the cheaper, more conservative profile) only if nothing matches.""" if symbol in FOREX_SYMBOLS: return "forex" if symbol in CRYPTO_SYMBOLS: return "crypto" s = symbol.strip().upper() if s.endswith("=X"): return "forex" quote = s.split("-")[-1] if "-" in s else "" if quote in ("USD", "USDT", "USDC", "BTC", "ETH"): return "crypto" return "forex" # Context length ceiling — validate against the ACTUAL loaded checkpoint's # max_seq_len at runtime (moirai_model.py does this); this is a # conservative fallback default, not an assumption of unlimited context # (spec section 86). MODEL_MAX_CONTEXT_DEFAULT = 512 RESOURCE_LIMITS = dict(cpu_cores=2, ram_gb=16, disk_gb=50, max_forecast_samples=100, max_workers=2)