diff --git a/README.md b/README.md index 5708bfc..ef71d28 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,11 @@ CryptoStrategies is the QuantStrategyLab crypto strategy package. It provides sh It is one layer of a multi-repository system: - **Strategy packages**: hold reusable strategy code, metadata, and runtime entrypoints. -- **Snapshot pipelines**: produce feature snapshots, rankings, backtests, and release evidence. +- **Snapshot pipelines**: produce authoritative live-pool snapshots, rankings, backtests, and release evidence. - **Platform runtimes**: connect strategies to brokers, dry-run checks, notifications, and live deployment controls. - **Shared infrastructure**: keeps contracts, settings, adapters, plugins, and audit workflows reusable across repositories. -This repository owns strategy code and metadata. It does not hold broker credentials, submit orders by itself, or replace the snapshot/backtest evidence required before a profile is enabled for live runtime settings. +This repository owns strategy code and metadata. For snapshot-backed crypto profiles, it consumes the upstream live pool and does not rebuild monthly pool membership or ordering locally. It does not hold broker credentials, submit orders by itself, or replace the snapshot/backtest evidence required before a profile is enabled for live runtime settings. ## Strategy profiles @@ -31,7 +31,7 @@ These profiles depend on artifacts produced by `CryptoSnapshotPipelines` before | Profile | Name | Notes | | --- | --- | --- | -| `crypto_leader_rotation` | Crypto Leader Rotation | runtime-enabled trend-following rotation fed by CryptoSnapshotPipelines release artifacts. | +| `crypto_leader_rotation` | Crypto Leader Rotation | runtime-enabled trend-following rotation that consumes the ordered live pool published by CryptoSnapshotPipelines. Runtime code may gate and size trades inside that pool, but monthly selection and order remain upstream. | ### Research-only candidates @@ -47,7 +47,7 @@ Use the platform repositories for broker credentials, dry-run/live switches, ord ## Evidence and live enablement -Use this README as a map of the project, not as live performance data. Before enabling or changing a live profile, rerun the relevant snapshot/backtest pipeline and review short, medium, and long windows: return, max drawdown, benchmark-relative return, turnover, data freshness, and artifact version. If evidence is stale, incomplete, or the profile is marked research-only, keep it out of live runtime settings. +Use this README as a map of the project, not as live performance data. Before enabling or changing a live profile, rerun the relevant snapshot/backtest pipeline and review short, medium, and long windows: return, max drawdown, benchmark-relative return, turnover, data freshness, and artifact version. Monthly live-pool selection, ranking, and promotion evidence belong in CryptoSnapshotPipelines; strategy changes here should preserve that upstream authority. If evidence is stale, incomplete, or the profile is marked research-only, keep it out of live runtime settings. ## Repository layout diff --git a/README.zh-CN.md b/README.zh-CN.md index 023d469..6006481 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -11,11 +11,11 @@ CryptoStrategies 是 QuantStrategyLab 的加密货币策略包。为 Binance 执 它属于一套多仓库量化系统中的一层: - **策略包**:保存可复用策略代码、元数据和运行入口。 -- **Snapshot 流水线**:生成 feature snapshot、ranking、回测和发布证据。 +- **Snapshot 流水线**:生成官方 live-pool snapshot、ranking、回测和发布证据。 - **执行平台**:把策略接到券商、dry-run 检查、通知和 live 部署控制。 - **共享基础设施**:沉淀契约、配置、适配器、插件和审计 workflow,供多仓复用。 -本仓库负责策略代码和元数据,不保存券商凭据,不直接提交订单,也不替代 live enable 前需要看的 snapshot 和回测证据。 +本仓库负责策略代码和元数据。对 snapshot-backed 加密策略来说,本仓库只消费上游 live pool,不在本地重建月度池成员或顺序。本仓库不保存券商凭据,不直接提交订单,也不替代 live enable 前需要看的 snapshot 和回测证据。 ## 策略 profile @@ -31,7 +31,7 @@ CryptoStrategies 是 QuantStrategyLab 的加密货币策略包。为 Binance 执 | Profile | 名称 | 说明 | | --- | --- | --- | -| `crypto_leader_rotation` | Crypto Leader Rotation | 由 CryptoSnapshotPipelines 发布产物驱动的趋势轮动策略。 | +| `crypto_leader_rotation` | Crypto Leader Rotation | 消费 CryptoSnapshotPipelines 发布的有序 live pool 的趋势轮动策略。运行时代码可以在该池内做交易门控和仓位 sizing,但月度选池和排序属于上游。 | ### 研究侧候选 @@ -47,7 +47,7 @@ CryptoStrategies 是 QuantStrategyLab 的加密货币策略包。为 Binance 执 ## 策略证据和 live enablement -README 只作为项目地图,不替代最新表现数据。启用或调整 live profile 前,需要重新运行相关 snapshot/backtest pipeline,并分别看短、中、长周期的收益、最大回撤、相对基准收益、换手、数据新鲜度和 artifact 版本。证据过期、不完整,或者 profile 仍标记为 research-only,就不要放进 live runtime settings。 +README 只作为项目地图,不替代最新表现数据。启用或调整 live profile 前,需要重新运行相关 snapshot/backtest pipeline,并分别看短、中、长周期的收益、最大回撤、相对基准收益、换手、数据新鲜度和 artifact 版本。月度 live-pool 选择、ranking 和 promotion 证据属于 CryptoSnapshotPipelines;本仓库的策略改动应保留这个上游权威边界。证据过期、不完整,或者 profile 仍标记为 research-only,就不要放进 live runtime settings。 ## 仓库结构 diff --git a/docs/crypto_cross_platform_strategy_spec.md b/docs/crypto_cross_platform_strategy_spec.md index a56c00e..403d68c 100644 --- a/docs/crypto_cross_platform_strategy_spec.md +++ b/docs/crypto_cross_platform_strategy_spec.md @@ -32,7 +32,7 @@ Current meaning for the live profile: - `derived_indicators`: strategy-ready trend metrics keyed by symbol - `benchmark_snapshot`: benchmark regime snapshot, currently BTC - `portfolio_snapshot`: exchange-agnostic portfolio and cash snapshot -- `universe_snapshot`: candidate tradable symbols for this cycle +- `universe_snapshot`: ordered official live-pool symbols from the validated `CryptoSnapshotPipelines` artifact for this cycle ## Target mode @@ -66,6 +66,12 @@ The strategy package owns this declaration. Downstream platforms may decide how to fetch the artifact, but they should not infer artifact requirements from profile-name branches. +## Live-pool authority boundary + +For `crypto_leader_rotation`, `CryptoSnapshotPipelines` is the authority for monthly live-pool membership, ranking, and order. The execution platform validates and preserves the ordered `live_pool.json["symbols"]` list, then passes it into `StrategyContext.market_data["universe_snapshot"]`. + +Strategy code may apply runtime gates, sell rules, top-N selection, inverse-volatility sizing, BTC core allocation, and buy-budget allocation inside that upstream pool. It must not rebuild the monthly live pool from local indicators, replace the upstream order with a local ranking, or treat research CSVs as a substitute for the validated artifact contract. + ## Allowed and forbidden boundaries Allowed inside strategy code: @@ -81,6 +87,7 @@ Forbidden inside strategy code: - direct environment reads - exchange-specific order payloads - artifact-path lookup and freshness validation +- local monthly live-pool rebuilds for snapshot-backed profiles ## Current rollout diff --git a/docs/crypto_cross_platform_strategy_spec.zh-CN.md b/docs/crypto_cross_platform_strategy_spec.zh-CN.md index 317cfbd..d3bfa5a 100644 --- a/docs/crypto_cross_platform_strategy_spec.zh-CN.md +++ b/docs/crypto_cross_platform_strategy_spec.zh-CN.md @@ -33,7 +33,7 @@ - `derived_indicators`:按 symbol 组织的策略级趋势指标 - `benchmark_snapshot`:基准状态快照,当前是 BTC - `portfolio_snapshot`:与交易所无关的组合和现金快照 -- `universe_snapshot`:本轮候选可交易标的集合 +- `universe_snapshot`:来自已验证 `CryptoSnapshotPipelines` artifact 的本轮官方有序 live-pool 标的集合 ## target mode @@ -65,6 +65,12 @@ 策略包负责声明这些需求。下游平台可以决定从 Firestore、GCS、本地文件或状态里取 artifact,但不能再靠 profile 名称分支来猜这条策略需要什么 artifact。 +## live-pool 权威边界 + +对 `crypto_leader_rotation` 来说,`CryptoSnapshotPipelines` 是月度 live pool 成员、ranking 和顺序的权威来源。执行平台负责校验并保留 `live_pool.json["symbols"]` 的有序列表,然后把它传给 `StrategyContext.market_data["universe_snapshot"]`。 + +策略代码可以在这个上游池内做运行时门控、卖出规则、top-N 选择、逆波动 sizing、BTC core allocation 和买入预算分配。策略代码不能用本地指标重建月度 live pool,不能用本地 ranking 替换上游顺序,也不能把 research CSV 当作已验证 artifact contract 的替代品。 + ## 允许和禁止 策略代码允许做的事: @@ -80,6 +86,7 @@ - 直接读环境变量 - 直接输出交易所专属下单字段 - 直接查 artifact 路径或做 artifact 新鲜度校验 +- 为 snapshot-backed profile 在本地重建月度 live pool ## 当前落地状态 diff --git a/src/crypto_strategies/entrypoints/__init__.py b/src/crypto_strategies/entrypoints/__init__.py index 429da92..f4bb7e5 100644 --- a/src/crypto_strategies/entrypoints/__init__.py +++ b/src/crypto_strategies/entrypoints/__init__.py @@ -136,21 +136,10 @@ def evaluate_crypto_leader_rotation(ctx: StrategyContext) -> StrategyDecision: account_metrics.get("dca_value", 0.0), ) - selected_pool, ranking = legacy_rotation.refresh_rotation_pool( + selected_pool = legacy_rotation.resolve_authoritative_rotation_pool( working_state, - indicators_map, - btc_snapshot, trend_universe_symbols=trend_universe_symbols, trend_pool_size=config["trend_pool_size"], - build_stable_quality_pool_fn=lambda indicators, btc, previous_pool: legacy_core.build_stable_quality_pool( - indicators, - btc, - previous_pool, - pool_size=config["trend_pool_size"], - min_history_days=config["min_history_days"], - min_avg_quote_vol_180=config["min_avg_quote_vol_180"], - membership_bonus=config["membership_bonus"], - ), allow_refresh=bool(config.get("allow_rotation_refresh", True)), now_utc=config.get("now_utc"), ) @@ -242,7 +231,7 @@ def evaluate_crypto_leader_rotation(ctx: StrategyContext) -> StrategyDecision: } for symbol, payload in selected_candidates.items() }, - "ranking_preview": tuple(item["symbol"] for item in ranking[: int(config["trend_pool_size"])]), + "ranking_preview": tuple(selected_pool[: int(config["trend_pool_size"])]), "rotation_pool_source_version": working_state.get("rotation_pool_source_version"), "rotation_pool_source_as_of_date": working_state.get("rotation_pool_source_as_of_date"), "rotation_pool_last_month": working_state.get("rotation_pool_last_month"), diff --git a/src/crypto_strategies/strategies/crypto_leader_rotation/rotation.py b/src/crypto_strategies/strategies/crypto_leader_rotation/rotation.py index 9875464..7c2d422 100644 --- a/src/crypto_strategies/strategies/crypto_leader_rotation/rotation.py +++ b/src/crypto_strategies/strategies/crypto_leader_rotation/rotation.py @@ -5,6 +5,18 @@ from datetime import datetime, timezone +def _normalize_symbol_list(symbols): + normalized = [] + seen = set() + for value in symbols or (): + symbol = str(value or "").strip().upper() + if not symbol or symbol in seen: + continue + normalized.append(symbol) + seen.add(symbol) + return normalized + + def _set_rotation_pool_lock(state, *, source_version, source_as_of_date, now_utc): locked_version = str(source_version or "").strip() locked_as_of_date = str(source_as_of_date or "").strip() @@ -16,6 +28,46 @@ def _set_rotation_pool_lock(state, *, source_version, source_as_of_date, now_utc state["rotation_pool_last_month"] = (now_utc or datetime.now(timezone.utc)).strftime("%Y-%m") +def resolve_authoritative_rotation_pool( + state, + *, + trend_universe_symbols, + trend_pool_size, + allow_refresh=True, + now_utc=None, +): + now_utc = now_utc or datetime.now(timezone.utc) + upstream_pool = _normalize_symbol_list(trend_universe_symbols) + available_symbols = set(upstream_pool) + cached_pool = [ + symbol + for symbol in _normalize_symbol_list(state.get("rotation_pool_symbols", [])) + if not available_symbols or symbol in available_symbols + ] + current_source_version = str(state.get("trend_pool_version", "")).strip() + current_source_as_of_date = str(state.get("trend_pool_as_of_date", "")).strip() + + if not allow_refresh: + try: + fallback_size = max(0, int(trend_pool_size)) + except Exception: + fallback_size = len(upstream_pool) + selected_pool = cached_pool or upstream_pool[:fallback_size] + elif upstream_pool: + selected_pool = upstream_pool + else: + selected_pool = cached_pool + + _set_rotation_pool_lock( + state, + source_version=current_source_version, + source_as_of_date=current_source_as_of_date, + now_utc=now_utc, + ) + state["rotation_pool_symbols"] = selected_pool + return selected_pool + + def refresh_rotation_pool( state, indicators_map, diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py index 3ee5292..6ca5e2d 100644 --- a/tests/test_entrypoints.py +++ b/tests/test_entrypoints.py @@ -1,6 +1,8 @@ from __future__ import annotations +from types import SimpleNamespace import unittest +from unittest.mock import patch from quant_platform_kit import PortfolioSnapshot, Position from quant_platform_kit.strategy_contracts import StrategyContext @@ -8,7 +10,102 @@ class CryptoStrategyEntrypointTests(unittest.TestCase): - def test_crypto_leader_rotation_entrypoint_matches_legacy_budget_and_rotation_outputs(self) -> None: + def test_crypto_leader_rotation_entrypoint_resolves_pool_from_upstream_artifact(self) -> None: + entrypoint = get_strategy_entrypoint("crypto_leader_rotation") + upstream_pool = ["SOLUSDT", "ETHUSDT"] + calls: dict[str, object] = {} + + def compute_allocation_budgets(total_equity, cash_usdt, trend_value, dca_value): + return { + "btc_target_ratio": 0.4, + "trend_target_ratio": 0.6, + "trend_usdt_pool": 300.0, + "dca_usdt_pool": 200.0, + "trend_layer_equity": 700.0, + } + + def select_rotation_weights(indicators_map, prices, btc_snapshot, candidate_pool, top_n, *, weight_mode): + calls["candidate_pool"] = tuple(candidate_pool) + return { + "SOLUSDT": { + "weight": 1.0, + "relative_score": 1.0, + "abs_momentum": 0.25, + } + } + + def resolve_authoritative_rotation_pool(state, *, trend_universe_symbols, trend_pool_size, allow_refresh=True, now_utc=None): + calls["trend_universe_symbols"] = tuple(trend_universe_symbols) + state["rotation_pool_source_version"] = state.get("trend_pool_version", "") + state["rotation_pool_source_as_of_date"] = state.get("trend_pool_as_of_date", "") + state["rotation_pool_last_month"] = "2026-03" + state["rotation_pool_symbols"] = list(upstream_pool) + return list(upstream_pool) + + def plan_trend_buys( + state, + runtime_trend_universe, + selected_candidates, + trend_indicators, + prices, + available_trend_buy_budget, + allow_new_trend_entries, + *, + get_symbol_trade_state_fn, + allocate_trend_buy_budget_fn, + ): + calls["runtime_trend_universe"] = tuple(runtime_trend_universe) + return ["SOLUSDT"], {"SOLUSDT": 100.0} + + fake_core = SimpleNamespace( + compute_allocation_budgets=compute_allocation_budgets, + select_rotation_weights=select_rotation_weights, + get_dynamic_btc_base_order=lambda total_equity: 15.0, + allocate_trend_buy_budget=lambda *args, **kwargs: {}, + ) + fake_rotation = SimpleNamespace( + resolve_authoritative_rotation_pool=resolve_authoritative_rotation_pool, + get_trend_sell_reason=lambda *args, **kwargs: "", + plan_trend_buys=plan_trend_buys, + ) + + with patch( + "crypto_strategies.entrypoints._load_legacy_modules", + return_value=(fake_core, fake_rotation), + ): + decision = entrypoint.evaluate( + StrategyContext( + as_of="2026-04-06", + market_data={ + "market_prices": {"SOLUSDT": 180.0, "ETHUSDT": 3000.0}, + "derived_indicators": { + "SOLUSDT": {"sma20": 170.0, "sma60": 160.0, "sma200": 120.0}, + "ETHUSDT": {"sma20": 2900.0, "sma60": 2700.0, "sma200": 2300.0}, + }, + "benchmark_snapshot": {"regime_on": True}, + "portfolio_snapshot": PortfolioSnapshot( + as_of="2026-04-06", + total_equity=1000.0, + buying_power=500.0, + cash_balance=500.0, + ), + "universe_snapshot": upstream_pool, + }, + state={ + "trend_pool_version": "2026-03-15-core_major", + "trend_pool_as_of_date": "2026-03-15", + }, + ) + ) + + self.assertEqual(calls["trend_universe_symbols"], tuple(upstream_pool)) + self.assertEqual(calls["candidate_pool"], tuple(upstream_pool)) + self.assertEqual(calls["runtime_trend_universe"], tuple(upstream_pool)) + self.assertEqual(decision.diagnostics["trend_pool"], tuple(upstream_pool)) + self.assertEqual(decision.diagnostics["ranking_preview"], tuple(upstream_pool)) + self.assertEqual(decision.diagnostics["rotation_pool_source_version"], "2026-03-15-core_major") + + def test_crypto_leader_rotation_entrypoint_uses_authoritative_upstream_pool(self) -> None: try: from crypto_strategies.strategies.crypto_leader_rotation import core as legacy_core from crypto_strategies.strategies.crypto_leader_rotation import rotation as legacy_rotation @@ -88,6 +185,7 @@ def test_crypto_leader_rotation_entrypoint_matches_legacy_budget_and_rotation_ou "trend_pool_version": "2026-03-15-core_major", "trend_pool_as_of_date": "2026-03-15", } + upstream_pool = ["BNBUSDT", "ETHUSDT", "SOLUSDT"] expected_budgets = legacy_core.compute_allocation_budgets( account_metrics["total_equity"], account_metrics["cash_usdt"], @@ -95,21 +193,10 @@ def test_crypto_leader_rotation_entrypoint_matches_legacy_budget_and_rotation_ou account_metrics["dca_value"], ) expected_state = dict(state) - expected_pool, ranking = legacy_rotation.refresh_rotation_pool( + expected_pool = legacy_rotation.resolve_authoritative_rotation_pool( expected_state, - trend_indicators, - btc_snapshot, - trend_universe_symbols=list(prices), + trend_universe_symbols=upstream_pool, trend_pool_size=entrypoint.manifest.default_config["trend_pool_size"], - build_stable_quality_pool_fn=lambda indicators, btc, previous_pool: legacy_core.build_stable_quality_pool( - indicators, - btc, - previous_pool, - pool_size=entrypoint.manifest.default_config["trend_pool_size"], - min_history_days=entrypoint.manifest.default_config["min_history_days"], - min_avg_quote_vol_180=entrypoint.manifest.default_config["min_avg_quote_vol_180"], - membership_bonus=entrypoint.manifest.default_config["membership_bonus"], - ), ) expected_candidates = legacy_core.select_rotation_weights( trend_indicators, @@ -121,7 +208,7 @@ def test_crypto_leader_rotation_entrypoint_matches_legacy_budget_and_rotation_ou ) expected_eligible_buy_symbols, expected_planned_trend_buys = legacy_rotation.plan_trend_buys( dict(expected_state), - runtime_trend_universe={symbol: {"base_asset": symbol[:-4]} for symbol in prices}, + runtime_trend_universe={symbol: {"base_asset": symbol[:-4]} for symbol in upstream_pool}, selected_candidates=expected_candidates, trend_indicators=trend_indicators, prices=prices, @@ -156,7 +243,7 @@ def test_crypto_leader_rotation_entrypoint_matches_legacy_budget_and_rotation_ou "cash_available_for_trading": account_metrics["cash_usdt"], }, ), - "universe_snapshot": list(prices), + "universe_snapshot": upstream_pool, }, state=state, artifacts={"trend_pool_contract": {"source": "explicit_artifact"}}, @@ -180,7 +267,7 @@ def test_crypto_leader_rotation_entrypoint_matches_legacy_budget_and_rotation_ou ) self.assertEqual( tuple(decision.diagnostics["ranking_preview"]), - tuple(item["symbol"] for item in ranking[: entrypoint.manifest.default_config["trend_pool_size"]]), + tuple(expected_pool[: entrypoint.manifest.default_config["trend_pool_size"]]), ) self.assertEqual(decision.diagnostics["artifact_contract"]["source"], "explicit_artifact") self.assertEqual(tuple(decision.diagnostics["eligible_buy_symbols"]), tuple(expected_eligible_buy_symbols)) diff --git a/tests/test_rotation_authority.py b/tests/test_rotation_authority.py new file mode 100644 index 0000000..3b837d5 --- /dev/null +++ b/tests/test_rotation_authority.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from datetime import datetime, timezone +import unittest + +from crypto_strategies.strategies.crypto_leader_rotation.rotation import resolve_authoritative_rotation_pool + + +class RotationAuthorityTests(unittest.TestCase): + def test_resolve_authoritative_rotation_pool_uses_ordered_upstream_symbols(self) -> None: + state = { + "trend_pool_version": "2026-03-15-core_major", + "trend_pool_as_of_date": "2026-03-15", + "rotation_pool_symbols": ["ADAUSDT"], + } + + selected = resolve_authoritative_rotation_pool( + state, + trend_universe_symbols=[" ethusdt ", "SOLUSDT", "ETHUSDT", "BNBUSDT"], + trend_pool_size=2, + now_utc=datetime(2026, 4, 6, tzinfo=timezone.utc), + ) + + self.assertEqual(selected, ["ETHUSDT", "SOLUSDT", "BNBUSDT"]) + self.assertEqual(state["rotation_pool_symbols"], selected) + self.assertEqual(state["rotation_pool_source_version"], "2026-03-15-core_major") + self.assertEqual(state["rotation_pool_source_as_of_date"], "2026-03-15") + self.assertEqual(state["rotation_pool_last_month"], "2026-03") + + def test_resolve_authoritative_rotation_pool_uses_cached_pool_when_refresh_disabled(self) -> None: + state = { + "rotation_pool_symbols": ["SOLUSDT", "ADAUSDT", "ETHUSDT"], + "trend_pool_version": "2026-03-15-core_major", + "trend_pool_as_of_date": "2026-03-15", + } + + selected = resolve_authoritative_rotation_pool( + state, + trend_universe_symbols=["ETHUSDT", "SOLUSDT", "BNBUSDT"], + trend_pool_size=2, + allow_refresh=False, + now_utc=datetime(2026, 4, 6, tzinfo=timezone.utc), + ) + + self.assertEqual(selected, ["SOLUSDT", "ETHUSDT"]) + self.assertEqual(state["rotation_pool_symbols"], selected) + + def test_resolve_authoritative_rotation_pool_caps_fallback_when_refresh_disabled(self) -> None: + state: dict[str, object] = {} + + selected = resolve_authoritative_rotation_pool( + state, + trend_universe_symbols=["ETHUSDT", "SOLUSDT", "BNBUSDT"], + trend_pool_size=2, + allow_refresh=False, + now_utc=datetime(2026, 4, 6, tzinfo=timezone.utc), + ) + + self.assertEqual(selected, ["ETHUSDT", "SOLUSDT"]) + self.assertEqual(state["rotation_pool_symbols"], selected) + + +if __name__ == "__main__": + unittest.main()