Skip to content

Commit 0dcb4f5

Browse files
committed
Add execution timing contract metadata helpers
1 parent 8a7318a commit 0dcb4f5

4 files changed

Lines changed: 142 additions & 3 deletions

File tree

src/quant_platform_kit/common/feature_snapshot_runtime.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
StrategyDecision,
1111
StrategyEntrypoint,
1212
StrategyRuntimeAdapter,
13+
apply_runtime_policy_to_runtime_config,
1314
build_strategy_context_from_available_inputs,
1415
)
1516

@@ -269,9 +270,7 @@ def _apply_runtime_policy(
269270
runtime_config: dict[str, Any],
270271
runtime_adapter: StrategyRuntimeAdapter,
271272
) -> None:
272-
trading_days = runtime_adapter.runtime_policy.runtime_execution_window_trading_days
273-
if trading_days is not None:
274-
runtime_config.setdefault("runtime_execution_window_trading_days", trading_days)
273+
apply_runtime_policy_to_runtime_config(runtime_config, runtime_adapter)
275274

276275

277276
def _resolve_symbol(raw_value: Any, *, default: str | None) -> str | None:

src/quant_platform_kit/common/strategy_contracts.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
from __future__ import annotations
22

33
from dataclasses import dataclass, field
4+
from importlib import import_module
45
from typing import Any, Callable, Mapping, Protocol
56
import math
67

8+
import pandas as pd
9+
710

811
class StrategyContractValidationError(ValueError):
912
"""Raised when a strategy manifest or decision violates the shared contract."""
@@ -110,6 +113,11 @@ class ValueTargetExecutionAnnotations:
110113
signal_display: str | None = None
111114
status_display: str | None = None
112115
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
113121
separator: str | None = None
114122
benchmark_symbol: str | None = None
115123
benchmark_price: float | None = None
@@ -142,6 +150,7 @@ class StrategyArtifactContract:
142150
class StrategyRuntimePolicy:
143151
reconciliation_output_policy: str = "none"
144152
runtime_execution_window_trading_days: int | None = None
153+
signal_effective_after_trading_days: int | None = None
145154

146155

147156
@dataclass(frozen=True)
@@ -217,6 +226,94 @@ def _ensure_allowed_string(
217226
_RECONCILIATION_OUTPUT_POLICIES = frozenset({"none", "optional", "required"})
218227

219228

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+
220317
def validate_strategy_manifest(manifest: StrategyManifest) -> StrategyManifest:
221318
if not isinstance(manifest, StrategyManifest):
222319
raise StrategyContractValidationError(
@@ -337,6 +434,14 @@ def validate_strategy_runtime_policy(policy: StrategyRuntimePolicy) -> StrategyR
337434
raise StrategyContractValidationError(
338435
"runtime_policy.runtime_execution_window_trading_days must be a positive integer"
339436
)
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+
)
340445
return policy
341446

342447

@@ -809,6 +914,11 @@ def build_value_target_plan_payload(
809914
"signal_display": annotations.signal_display,
810915
"status_display": annotations.status_display,
811916
"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,
812922
"separator": annotations.separator,
813923
"benchmark_symbol": annotations.benchmark_symbol,
814924
"benchmark_price": annotations.benchmark_price,
@@ -896,6 +1006,15 @@ def _pick_float(*keys: str, default: float | None = None) -> float | None:
8961006
signal_display=_pick_str("signal_display", "signal_message"),
8971007
status_display=_pick_str("status_display", "market_status"),
8981008
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+
),
8991018
separator=_pick_str("separator"),
9001019
benchmark_symbol=_pick_str("benchmark_symbol"),
9011020
benchmark_price=_pick_float("benchmark_price", "qqq_price"),

src/quant_platform_kit/strategy_contracts.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
ValueTargetExecutionPlan,
1515
ValueTargetExecutionAnnotations,
1616
ValueTargetPortfolioPlan,
17+
apply_runtime_policy_to_runtime_config,
18+
build_execution_timing_metadata,
1719
build_allocation_intent,
1820
build_allocation_payload,
1921
build_value_target_allocation_intent,
@@ -62,6 +64,8 @@
6264
"ValueTargetExecutionPlan",
6365
"ValueTargetPortfolioInputs",
6466
"ValueTargetPortfolioPlan",
67+
"apply_runtime_policy_to_runtime_config",
68+
"build_execution_timing_metadata",
6569
"build_allocation_intent",
6670
"build_allocation_payload",
6771
"build_account_state_from_portfolio_snapshot",

tests/test_strategy_contracts.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
build_allocation_intent,
3636
build_allocation_payload,
3737
build_account_state_from_portfolio_snapshot,
38+
build_execution_timing_metadata,
3839
build_portfolio_snapshot_from_account_state,
3940
build_strategy_evaluation_inputs,
4041
build_value_target_allocation_intent,
@@ -261,6 +262,7 @@ def test_runtime_adapter_supports_explicit_artifact_contract_and_policy(self) ->
261262
StrategyRuntimePolicy(
262263
reconciliation_output_policy="optional",
263264
runtime_execution_window_trading_days=1,
265+
signal_effective_after_trading_days=1,
264266
)
265267
)
266268
adapter = validate_strategy_runtime_adapter(
@@ -282,6 +284,21 @@ def test_runtime_adapter_supports_explicit_artifact_contract_and_policy(self) ->
282284
self.assertEqual(resolved_contract.config_source_policy, "bundled_or_env")
283285
self.assertEqual(adapter.runtime_policy.reconciliation_output_policy, "optional")
284286
self.assertEqual(adapter.runtime_policy.runtime_execution_window_trading_days, 1)
287+
self.assertEqual(adapter.runtime_policy.signal_effective_after_trading_days, 1)
288+
289+
def test_build_execution_timing_metadata_uses_next_trading_day_contract(self) -> None:
290+
metadata = build_execution_timing_metadata(
291+
signal_date="2026-04-01",
292+
signal_effective_after_trading_days=1,
293+
)
294+
295+
self.assertEqual(metadata["signal_date"], "2026-04-01")
296+
self.assertEqual(metadata["effective_date"], "2026-04-02")
297+
self.assertEqual(metadata["execution_timing_contract"], "next_trading_day")
298+
self.assertIn(
299+
metadata["execution_calendar_source"],
300+
{"pandas_market_calendars", "business_day_fallback"},
301+
)
285302

286303
def test_artifact_contract_resolver_preserves_legacy_adapter_inference(self) -> None:
287304
adapter = StrategyRuntimeAdapter(

0 commit comments

Comments
 (0)