|
47 | 47 | DCA_PROFILES = frozenset({"nasdaq_sp500_smart_dca", "ibit_smart_dca"}) |
48 | 48 | IBIT_ZSCORE_EXIT_PROFILE = "ibit_smart_dca" |
49 | 49 | _MARKET_HISTORY_INPUT = "market_history" |
| 50 | +_MARKET_DATA_INPUT = "market_data" |
50 | 51 | _BENCHMARK_HISTORY_INPUT = "benchmark_history" |
51 | 52 | _DERIVED_INDICATORS_INPUT = "derived_indicators" |
52 | 53 | _PORTFOLIO_SNAPSHOT_INPUT = "portfolio_snapshot" |
@@ -459,6 +460,61 @@ def _build_market_history_inputs( |
459 | 460 | } |
460 | 461 | } |
461 | 462 |
|
| 463 | + def _build_direct_market_data_inputs( |
| 464 | + self, |
| 465 | + ib, |
| 466 | + historical_close_loader: Callable[..., Any], |
| 467 | + ) -> Mapping[str, Any]: |
| 468 | + if self.profile != "us_equity_combo_leveraged": |
| 469 | + raise ValueError(f"Unsupported market_data strategy profile {self.profile!r}") |
| 470 | + |
| 471 | + trend_symbol = str(self.merged_runtime_config.get("market_data_trend_symbol") or "SPY").strip().upper() |
| 472 | + ma_window = int( |
| 473 | + self.merged_runtime_config.get( |
| 474 | + "market_data_ma_window", |
| 475 | + self.merged_runtime_config.get("sma_period", 200), |
| 476 | + ) |
| 477 | + ) |
| 478 | + if ma_window <= 0: |
| 479 | + raise ValueError("market_data_ma_window must be positive") |
| 480 | + history = historical_close_loader( |
| 481 | + ib, |
| 482 | + trend_symbol, |
| 483 | + duration=str(self.merged_runtime_config.get("market_data_history_duration") or "2 Y"), |
| 484 | + bar_size=str(self.merged_runtime_config.get("market_data_history_bar_size") or "1 day"), |
| 485 | + ) |
| 486 | + if isinstance(history, pd.DataFrame): |
| 487 | + if "close" in history.columns: |
| 488 | + close_values = history["close"] |
| 489 | + else: |
| 490 | + close_values = history.iloc[:, 0] |
| 491 | + elif isinstance(history, pd.Series): |
| 492 | + close_values = history |
| 493 | + else: |
| 494 | + close_values = [ |
| 495 | + item.get("close") if isinstance(item, Mapping) else getattr(item, "close", item) |
| 496 | + for item in (history or ()) |
| 497 | + ] |
| 498 | + close_series = pd.to_numeric(pd.Series(close_values), errors="coerce").dropna() |
| 499 | + close_series = close_series[close_series > 0] |
| 500 | + if len(close_series) < ma_window: |
| 501 | + raise ValueError( |
| 502 | + f"{self.profile} requires at least {ma_window} positive {trend_symbol} closes, " |
| 503 | + f"got {len(close_series)}" |
| 504 | + ) |
| 505 | + trend_price = float(close_series.iloc[-1]) |
| 506 | + trend_ma = float(close_series.tail(ma_window).mean()) |
| 507 | + return { |
| 508 | + _MARKET_DATA_INPUT: { |
| 509 | + "spy_above_ma200": trend_price > trend_ma, |
| 510 | + "trend_symbol": trend_symbol, |
| 511 | + "trend_price": trend_price, |
| 512 | + "trend_ma": trend_ma, |
| 513 | + "trend_ma_window": ma_window, |
| 514 | + "history_observation_count": int(len(close_series)), |
| 515 | + } |
| 516 | + } |
| 517 | + |
462 | 518 | def _build_strategy_context( |
463 | 519 | self, |
464 | 520 | *, |
@@ -517,6 +573,16 @@ def evaluate( |
517 | 573 | pacing_sec=pacing_sec, |
518 | 574 | strategy_plugin_signals=strategy_plugin_signals, |
519 | 575 | ) |
| 576 | + if _MARKET_DATA_INPUT in self.required_inputs: |
| 577 | + return self._evaluate_direct_market_data_strategy( |
| 578 | + ib=ib, |
| 579 | + current_holdings=current_holdings, |
| 580 | + historical_close_loader=historical_close_loader, |
| 581 | + run_as_of=run_as_of, |
| 582 | + translator=translator, |
| 583 | + pacing_sec=pacing_sec, |
| 584 | + strategy_plugin_signals=strategy_plugin_signals, |
| 585 | + ) |
520 | 586 | if _MARKET_HISTORY_INPUT in self.required_inputs: |
521 | 587 | return self._evaluate_market_data_strategy( |
522 | 588 | ib=ib, |
@@ -547,6 +613,81 @@ def evaluate( |
547 | 613 | f"{', '.join(sorted(self.required_inputs)) or '<none>'}" |
548 | 614 | ) |
549 | 615 |
|
| 616 | + def _evaluate_direct_market_data_strategy( |
| 617 | + self, |
| 618 | + *, |
| 619 | + ib, |
| 620 | + current_holdings, |
| 621 | + historical_close_loader: Callable[..., Any], |
| 622 | + run_as_of: pd.Timestamp, |
| 623 | + translator: Callable[[str], str], |
| 624 | + pacing_sec: float, |
| 625 | + strategy_plugin_signals=(), |
| 626 | + ) -> StrategyEvaluationResult: |
| 627 | + runtime_config = dict(self.runtime_config) |
| 628 | + runtime_config.setdefault("translator", translator) |
| 629 | + runtime_config.setdefault("pacing_sec", float(pacing_sec)) |
| 630 | + apply_runtime_policy_to_runtime_config(runtime_config, self.runtime_adapter) |
| 631 | + portfolio_snapshot = self._fetch_portfolio_snapshot_for_context( |
| 632 | + ib, |
| 633 | + required=False, |
| 634 | + ) |
| 635 | + portfolio_snapshot = self._project_portfolio_snapshot( |
| 636 | + portfolio_snapshot, |
| 637 | + self._configured_strategy_symbols(include_ranking_pool=True), |
| 638 | + ) |
| 639 | + portfolio_snapshot = self._attach_strategy_plugin_metadata(portfolio_snapshot, strategy_plugin_signals) |
| 640 | + option_chains = self._fetch_option_chains_for_runtime(ib, runtime_config, portfolio_snapshot) |
| 641 | + if option_chains: |
| 642 | + runtime_config["option_chains"] = option_chains |
| 643 | + ctx = self._build_strategy_context( |
| 644 | + runtime_adapter=self.runtime_adapter, |
| 645 | + as_of=run_as_of, |
| 646 | + market_inputs=self._build_direct_market_data_inputs(ib, historical_close_loader), |
| 647 | + portfolio_snapshot=portfolio_snapshot, |
| 648 | + runtime_config=runtime_config, |
| 649 | + current_holdings=current_holdings, |
| 650 | + ib=ib, |
| 651 | + ) |
| 652 | + decision = self.entrypoint.evaluate(ctx) |
| 653 | + managed_symbols = tuple( |
| 654 | + dict.fromkeys( |
| 655 | + str(position.symbol).strip().upper() |
| 656 | + for position in decision.positions |
| 657 | + if str(position.symbol or "").strip() |
| 658 | + ) |
| 659 | + ) |
| 660 | + price_fallbacks = self._build_historical_close_map( |
| 661 | + ib, |
| 662 | + historical_close_loader, |
| 663 | + self._build_price_fallback_symbol_list( |
| 664 | + decision, |
| 665 | + managed_symbols=managed_symbols, |
| 666 | + current_holdings=current_holdings, |
| 667 | + ), |
| 668 | + ) |
| 669 | + metadata = { |
| 670 | + "strategy_profile": self.profile, |
| 671 | + "managed_symbols": managed_symbols, |
| 672 | + "status_icon": self.status_icon, |
| 673 | + "dry_run_only": self.runtime_settings.dry_run_only, |
| 674 | + **build_execution_timing_metadata( |
| 675 | + signal_date=run_as_of, |
| 676 | + signal_effective_after_trading_days=( |
| 677 | + self.runtime_adapter.runtime_policy.signal_effective_after_trading_days |
| 678 | + ), |
| 679 | + ), |
| 680 | + } |
| 681 | + if portfolio_snapshot is not None: |
| 682 | + metadata = self._enrich_portfolio_metadata(metadata, portfolio_snapshot) |
| 683 | + if "BOXX" in managed_symbols: |
| 684 | + metadata["safe_haven_symbol"] = "BOXX" |
| 685 | + if price_fallbacks: |
| 686 | + metadata["price_fallbacks"] = price_fallbacks |
| 687 | + metadata["dry_run_price_fallbacks"] = price_fallbacks |
| 688 | + metadata["price_fallback_source"] = "historical_close" |
| 689 | + return StrategyEvaluationResult(decision=decision, metadata=metadata) |
| 690 | + |
550 | 691 | def _evaluate_market_data_strategy( |
551 | 692 | self, |
552 | 693 | *, |
@@ -942,7 +1083,10 @@ def load_strategy_runtime( |
942 | 1083 | logger=logger, |
943 | 1084 | ) |
944 | 1085 | runtime_config: dict[str, Any] = {} |
945 | | - if _FEATURE_SNAPSHOT_INPUT in frozenset(entrypoint.manifest.required_inputs): |
| 1086 | + if ( |
| 1087 | + _FEATURE_SNAPSHOT_INPUT in frozenset(entrypoint.manifest.required_inputs) |
| 1088 | + or runtime_settings.strategy_config_path |
| 1089 | + ): |
946 | 1090 | runtime_config = runtime.load_runtime_parameters() |
947 | 1091 | runtime_config.update(_build_runtime_overrides(runtime_settings)) |
948 | 1092 |
|
|
0 commit comments