Coverage for adrs/execution/runner.py: 59%
37 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-29 05:07 +0800
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-29 05:07 +0800
1import asyncio
2from datetime import timedelta
4import polars as pl
6from adrs.alpha import Alpha
7from adrs.portfolio import Portfolio
8from adrs.data import DataLoader, MetricStream, DatasourceStream
9from adrs.execution.executor import PortfolioExecutor, AlphaExecutor
10from adrs.types import Topic
13def _remap_portfolio_ids(portfolio: Portfolio, alpha_id_map: dict[str, str]) -> None:
14 """Rewrite the portfolio's alpha ids in place using alpha_id_map.
16 run_portfolio renames the Alpha objects' ids (publish side) but the
17 portfolio's frames are keyed by the original ids. Without this, the live
18 signal an alpha publishes (under its renamed id) never matches the
19 portfolio's lookup and any "is this my alpha?" guard rejects every signal.
21 metadata_df/weight_df key by a `custom_id` column; signal_df keys each alpha
22 by a column name. Unmapped values are left unchanged."""
23 portfolio.metadata_df = portfolio.metadata_df.with_columns(
24 pl.col("custom_id").replace(alpha_id_map)
25 )
26 portfolio.weight_df = portfolio.weight_df.with_columns(
27 pl.col("custom_id").replace(alpha_id_map)
28 )
29 portfolio.signal_df = portfolio.signal_df.rename(
30 {
31 old: new
32 for old, new in alpha_id_map.items()
33 if old in portfolio.signal_df.columns
34 }
35 )
38def _finest_interval(alphas: list[Alpha]) -> timedelta | None:
39 """Smallest signal interval across all alphas' data topics. Used to default
40 the signal-freshness window so a daily portfolio isn't rejected by a flat
41 2h rule. Returns None if no topic declares an interval."""
42 intervals: list[timedelta] = []
43 for alpha in alphas:
44 for di in getattr(alpha, "data_infos", []):
45 try:
46 iv = Topic.from_str(di.topic).interval()
47 except Exception:
48 iv = None
49 if iv is not None:
50 intervals.append(iv)
51 return min(intervals) if intervals else None
54async def run_portfolio(
55 portfolio: Portfolio,
56 alphas: list[Alpha],
57 dataloader: DataLoader,
58 metric_stream: MetricStream,
59 datasource_stream: DatasourceStream,
60 run_alphas: bool = True,
61 alpha_id_map: dict[str, str] = {},
62 max_signal_age: timedelta | None = None,
63 signal_namespace: str | None = None,
64 insert_prefix: str = "public_ts",
65 resync_interval: timedelta | None = None,
66):
67 for alpha in alphas:
68 if alpha.id in alpha_id_map:
69 alpha.id = alpha_id_map[alpha.id]
71 # The rename above only touches the Alpha objects (publish side); remap the
72 # portfolio's frames with the same map too (see _remap_portfolio_ids).
73 if alpha_id_map:
74 _remap_portfolio_ids(portfolio, alpha_id_map)
76 # Freshness window for PortfolioExecutor's staleness guard. When unset,
77 # default to twice the finest signal interval (daily portfolio → 2d) but
78 # never tighter than the legacy 2h — so intraday strategies are unaffected
79 # and slow strategies aren't spuriously rejected.
80 if max_signal_age is None:
81 finest = _finest_interval(alphas)
82 max_signal_age = (
83 max(timedelta(hours=2), finest * 2)
84 if finest is not None
85 else timedelta(hours=2)
86 )
88 portfolio_executor = PortfolioExecutor(
89 portfolio=portfolio,
90 metric_stream=metric_stream,
91 max_signal_age=max_signal_age,
92 signal_namespace=signal_namespace,
93 insert_prefix=insert_prefix,
94 )
96 if run_alphas:
97 alpha_executor = AlphaExecutor(
98 alphas=alphas,
99 dataloader=dataloader,
100 metric_stream=metric_stream,
101 datasource_stream=datasource_stream,
102 signal_namespace=signal_namespace,
103 insert_prefix=insert_prefix,
104 resync_interval=resync_interval,
105 )
106 await asyncio.gather(alpha_executor.start(), portfolio_executor.start())
107 else:
108 await portfolio_executor.start()