|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
3 | 3 | from dataclasses import dataclass, field |
| 4 | +from importlib import import_module |
4 | 5 | from typing import Any, Callable, Mapping, Protocol |
5 | 6 | import math |
6 | 7 |
|
| 8 | +import pandas as pd |
| 9 | + |
7 | 10 |
|
8 | 11 | class StrategyContractValidationError(ValueError): |
9 | 12 | """Raised when a strategy manifest or decision violates the shared contract.""" |
@@ -110,6 +113,11 @@ class ValueTargetExecutionAnnotations: |
110 | 113 | signal_display: str | None = None |
111 | 114 | status_display: str | None = None |
112 | 115 | dashboard_text: str | None = None |
| 116 | + signal_date: str | None = None |
| 117 | + effective_date: str | None = None |
| 118 | + execution_timing_contract: str | None = None |
| 119 | + execution_calendar_source: str | None = None |
| 120 | + signal_effective_after_trading_days: int | None = None |
113 | 121 | separator: str | None = None |
114 | 122 | benchmark_symbol: str | None = None |
115 | 123 | benchmark_price: float | None = None |
@@ -142,6 +150,7 @@ class StrategyArtifactContract: |
142 | 150 | class StrategyRuntimePolicy: |
143 | 151 | reconciliation_output_policy: str = "none" |
144 | 152 | runtime_execution_window_trading_days: int | None = None |
| 153 | + signal_effective_after_trading_days: int | None = None |
145 | 154 |
|
146 | 155 |
|
147 | 156 | @dataclass(frozen=True) |
@@ -217,6 +226,94 @@ def _ensure_allowed_string( |
217 | 226 | _RECONCILIATION_OUTPUT_POLICIES = frozenset({"none", "optional", "required"}) |
218 | 227 |
|
219 | 228 |
|
| 229 | +def _load_nyse_calendar(): |
| 230 | + try: |
| 231 | + module = import_module("pandas_market_calendars") |
| 232 | + except Exception: |
| 233 | + return None |
| 234 | + try: |
| 235 | + return module.get_calendar("NYSE") |
| 236 | + except Exception: |
| 237 | + return None |
| 238 | + |
| 239 | + |
| 240 | +def _normalize_as_of_date(as_of: Any) -> pd.Timestamp: |
| 241 | + timestamp = pd.Timestamp(as_of) |
| 242 | + if timestamp.tzinfo is not None: |
| 243 | + timestamp = timestamp.tz_convert(None) |
| 244 | + return timestamp.normalize() |
| 245 | + |
| 246 | + |
| 247 | +def _next_trading_days( |
| 248 | + start_date: pd.Timestamp, |
| 249 | + *, |
| 250 | + count: int, |
| 251 | +) -> tuple[tuple[pd.Timestamp, ...], str]: |
| 252 | + normalized_start = _normalize_as_of_date(start_date) |
| 253 | + calendar = _load_nyse_calendar() |
| 254 | + if calendar is not None: |
| 255 | + schedule = calendar.schedule( |
| 256 | + start_date=normalized_start + pd.Timedelta(days=1), |
| 257 | + end_date=normalized_start + pd.Timedelta(days=max(10, count * 10)), |
| 258 | + ) |
| 259 | + if not schedule.empty: |
| 260 | + days = tuple(pd.Timestamp(index).tz_localize(None).normalize() for index in schedule.index[:count]) |
| 261 | + if len(days) == count: |
| 262 | + return days, "pandas_market_calendars" |
| 263 | + fallback_days = tuple( |
| 264 | + pd.bdate_range( |
| 265 | + start=normalized_start + pd.Timedelta(days=1), |
| 266 | + periods=max(1, count), |
| 267 | + ).normalize() |
| 268 | + ) |
| 269 | + return fallback_days[:count], "business_day_fallback" |
| 270 | + |
| 271 | + |
| 272 | +def build_execution_timing_metadata( |
| 273 | + *, |
| 274 | + signal_date: Any, |
| 275 | + signal_effective_after_trading_days: int | None = None, |
| 276 | +) -> dict[str, Any]: |
| 277 | + resolved_signal_date = _normalize_as_of_date(signal_date) |
| 278 | + metadata: dict[str, Any] = { |
| 279 | + "signal_date": resolved_signal_date.date().isoformat(), |
| 280 | + } |
| 281 | + if signal_effective_after_trading_days is None: |
| 282 | + return metadata |
| 283 | + |
| 284 | + delay = int(signal_effective_after_trading_days) |
| 285 | + metadata["signal_effective_after_trading_days"] = delay |
| 286 | + if delay == 0: |
| 287 | + metadata["effective_date"] = resolved_signal_date.date().isoformat() |
| 288 | + metadata["execution_timing_contract"] = "same_trading_day" |
| 289 | + metadata["execution_calendar_source"] = "signal_date" |
| 290 | + return metadata |
| 291 | + |
| 292 | + trading_days, calendar_source = _next_trading_days( |
| 293 | + resolved_signal_date, |
| 294 | + count=delay, |
| 295 | + ) |
| 296 | + effective_date = trading_days[-1] if trading_days else resolved_signal_date |
| 297 | + metadata["effective_date"] = effective_date.date().isoformat() |
| 298 | + metadata["execution_timing_contract"] = ( |
| 299 | + "next_trading_day" if delay == 1 else f"next_{delay}_trading_days" |
| 300 | + ) |
| 301 | + metadata["execution_calendar_source"] = calendar_source |
| 302 | + return metadata |
| 303 | + |
| 304 | + |
| 305 | +def apply_runtime_policy_to_runtime_config( |
| 306 | + runtime_config: dict[str, Any], |
| 307 | + runtime_adapter: StrategyRuntimeAdapter, |
| 308 | +) -> None: |
| 309 | + trading_days = runtime_adapter.runtime_policy.runtime_execution_window_trading_days |
| 310 | + if trading_days is not None: |
| 311 | + runtime_config.setdefault("runtime_execution_window_trading_days", trading_days) |
| 312 | + signal_delay = runtime_adapter.runtime_policy.signal_effective_after_trading_days |
| 313 | + if signal_delay is not None: |
| 314 | + runtime_config.setdefault("signal_effective_after_trading_days", signal_delay) |
| 315 | + |
| 316 | + |
220 | 317 | def validate_strategy_manifest(manifest: StrategyManifest) -> StrategyManifest: |
221 | 318 | if not isinstance(manifest, StrategyManifest): |
222 | 319 | raise StrategyContractValidationError( |
@@ -337,6 +434,14 @@ def validate_strategy_runtime_policy(policy: StrategyRuntimePolicy) -> StrategyR |
337 | 434 | raise StrategyContractValidationError( |
338 | 435 | "runtime_policy.runtime_execution_window_trading_days must be a positive integer" |
339 | 436 | ) |
| 437 | + if policy.signal_effective_after_trading_days is not None: |
| 438 | + if ( |
| 439 | + not isinstance(policy.signal_effective_after_trading_days, int) |
| 440 | + or policy.signal_effective_after_trading_days < 0 |
| 441 | + ): |
| 442 | + raise StrategyContractValidationError( |
| 443 | + "runtime_policy.signal_effective_after_trading_days must be a non-negative integer" |
| 444 | + ) |
340 | 445 | return policy |
341 | 446 |
|
342 | 447 |
|
@@ -809,6 +914,11 @@ def build_value_target_plan_payload( |
809 | 914 | "signal_display": annotations.signal_display, |
810 | 915 | "status_display": annotations.status_display, |
811 | 916 | "dashboard_text": annotations.dashboard_text, |
| 917 | + "signal_date": annotations.signal_date, |
| 918 | + "effective_date": annotations.effective_date, |
| 919 | + "execution_timing_contract": annotations.execution_timing_contract, |
| 920 | + "execution_calendar_source": annotations.execution_calendar_source, |
| 921 | + "signal_effective_after_trading_days": annotations.signal_effective_after_trading_days, |
812 | 922 | "separator": annotations.separator, |
813 | 923 | "benchmark_symbol": annotations.benchmark_symbol, |
814 | 924 | "benchmark_price": annotations.benchmark_price, |
@@ -896,6 +1006,15 @@ def _pick_float(*keys: str, default: float | None = None) -> float | None: |
896 | 1006 | signal_display=_pick_str("signal_display", "signal_message"), |
897 | 1007 | status_display=_pick_str("status_display", "market_status"), |
898 | 1008 | dashboard_text=_pick_str("dashboard_text", "dashboard"), |
| 1009 | + signal_date=_pick_str("signal_date"), |
| 1010 | + effective_date=_pick_str("effective_date"), |
| 1011 | + execution_timing_contract=_pick_str("execution_timing_contract"), |
| 1012 | + execution_calendar_source=_pick_str("execution_calendar_source"), |
| 1013 | + signal_effective_after_trading_days=( |
| 1014 | + int(signal_delay) |
| 1015 | + if (signal_delay := _pick_float("signal_effective_after_trading_days")) is not None |
| 1016 | + else None |
| 1017 | + ), |
899 | 1018 | separator=_pick_str("separator"), |
900 | 1019 | benchmark_symbol=_pick_str("benchmark_symbol"), |
901 | 1020 | benchmark_price=_pick_float("benchmark_price", "qqq_price"), |
|
0 commit comments