diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e21941..5b938e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,23 @@ jobs: - name: Checkout uses: actions/checkout@v6 + - name: Resolve QuantPlatformKit ref + id: quant-platform-kit-ref + run: | + set -euo pipefail + ref="main" + if [ -n "${GITHUB_HEAD_REF:-}" ] && git ls-remote --exit-code --heads https://github.com/QuantStrategyLab/QuantPlatformKit.git "${GITHUB_HEAD_REF}" >/dev/null 2>&1; then + ref="${GITHUB_HEAD_REF}" + fi + echo "ref=${ref}" >> "$GITHUB_OUTPUT" + + - name: Checkout QuantPlatformKit + uses: actions/checkout@v6 + with: + repository: QuantStrategyLab/QuantPlatformKit + ref: ${{ steps.quant-platform-kit-ref.outputs.ref }} + path: external/QuantPlatformKit + - name: Setup Python uses: actions/setup-python@v6 with: @@ -22,7 +39,8 @@ jobs: run: | set -euo pipefail python -m pip install --upgrade pip - python -m pip install -e . ruff + python -m pip install -e . numpy pandas ruff + python -m pip install --no-deps -e external/QuantPlatformKit - name: Run Ruff run: | diff --git a/pyproject.toml b/pyproject.toml index 3a49f82..d17b891 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ description = "Shared crypto strategy catalog and implementations" readme = "README.md" requires-python = ">=3.11" dependencies = [ - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@6e8cc058b821aea8a54015d4b39e02fbdd3dc198", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@5174d9e40f79fffae47450a42e26434145d28b31", ] [tool.setuptools] diff --git a/scripts/prepare_auto_optimization_pr.py b/scripts/prepare_auto_optimization_pr.py index d81d383..13233ef 100644 --- a/scripts/prepare_auto_optimization_pr.py +++ b/scripts/prepare_auto_optimization_pr.py @@ -97,7 +97,8 @@ r"^market_snapshot_support\.py$", r"^trade_state_support\.py$", r"^trend_pool_support\.py$", - r"^strategy_core\.py$", + r"^strategy_runtime\.py$", + r"^decision_mapper\.py$", r"^strategy_loader\.py$", r"^strategy_registry\.py$", ), diff --git a/src/crypto_strategies/__init__.py b/src/crypto_strategies/__init__.py index b4a3a12..2b15286 100644 --- a/src/crypto_strategies/__init__.py +++ b/src/crypto_strategies/__init__.py @@ -4,6 +4,7 @@ get_strategy_catalog, get_strategy_definition, get_strategy_definitions, + get_strategy_entrypoint, get_strategy_index_rows, get_strategy_metadata, ) @@ -14,6 +15,7 @@ "get_strategy_catalog", "get_strategy_definition", "get_strategy_definitions", + "get_strategy_entrypoint", "get_strategy_index_rows", "get_strategy_metadata", ] diff --git a/src/crypto_strategies/catalog.py b/src/crypto_strategies/catalog.py index a85492b..57c1fe6 100644 --- a/src/crypto_strategies/catalog.py +++ b/src/crypto_strategies/catalog.py @@ -5,15 +5,31 @@ StrategyCatalog, StrategyComponentDefinition, StrategyDefinition, + StrategyEntrypointDefinition, StrategyMetadata, build_strategy_catalog, build_strategy_index_rows, get_catalog_strategy_definition, get_catalog_strategy_metadata, + load_strategy_entrypoint, ) CRYPTO_LEADER_ROTATION_PROFILE = "crypto_leader_rotation" +CRYPTO_LEADER_ROTATION_DEFAULT_CONFIG = { + "trend_pool_size": 5, + "rotation_top_n": 2, + "min_history_days": 365, + "min_avg_quote_vol_180": 8000000.0, + "membership_bonus": 0.10, + "weight_mode": "inverse_vol", + "allow_rotation_refresh": True, + "atr_multiplier": 2.5, + "artifact_contract_version": "crypto_leader_rotation.live_pool.v1", + "artifact_max_age_days": 45, + "artifact_acceptable_modes": ("core_major",), +} + STRATEGY_DEFINITIONS: dict[str, StrategyDefinition] = { CRYPTO_LEADER_ROTATION_PROFILE: StrategyDefinition( profile=CRYPTO_LEADER_ROTATION_PROFILE, @@ -29,6 +45,20 @@ module_path="crypto_strategies.strategies.crypto_leader_rotation.rotation", ), ), + entrypoint=StrategyEntrypointDefinition( + module_path="crypto_strategies.entrypoints", + attribute_name="crypto_leader_rotation_entrypoint", + ), + required_inputs=frozenset( + { + "prices", + "trend_indicators", + "btc_snapshot", + "account_metrics", + "trend_universe_symbols", + } + ), + default_config=CRYPTO_LEADER_ROTATION_DEFAULT_CONFIG, ), } @@ -64,6 +94,12 @@ def get_strategy_definition(profile: str) -> StrategyDefinition: return get_catalog_strategy_definition(STRATEGY_CATALOG, profile) +def get_strategy_entrypoint(profile: str): + definition = get_strategy_definition(profile) + metadata = get_strategy_metadata(profile) + return load_strategy_entrypoint(definition, metadata=metadata) + + def get_strategy_metadata(profile: str) -> StrategyMetadata: return get_catalog_strategy_metadata(STRATEGY_CATALOG, profile) diff --git a/src/crypto_strategies/entrypoints/__init__.py b/src/crypto_strategies/entrypoints/__init__.py new file mode 100644 index 0000000..6e2bd03 --- /dev/null +++ b/src/crypto_strategies/entrypoints/__init__.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +from collections.abc import Callable, Mapping +from copy import deepcopy + +from quant_platform_kit.strategy_contracts import ( + BudgetIntent, + CallableStrategyEntrypoint, + PositionTarget, + StrategyContext, + StrategyDecision, +) + +from crypto_strategies.manifests import crypto_leader_rotation_manifest +from crypto_strategies.strategies.crypto_leader_rotation import core as legacy_core +from crypto_strategies.strategies.crypto_leader_rotation import rotation as legacy_rotation + + +"""Unified crypto strategy entrypoints built on top of legacy core/rotation modules.""" + + +def _merge_runtime_config(ctx: StrategyContext) -> dict[str, object]: + config = dict(crypto_leader_rotation_manifest.default_config) + config.update(dict(ctx.runtime_config)) + return config + + +def _require_market_data(ctx: StrategyContext, key: str): + if key not in ctx.market_data: + raise ValueError(f"StrategyContext.market_data missing required key: {key}") + return ctx.market_data[key] + + +def _resolve_translator(config: Mapping[str, object]) -> Callable[[str], str]: + translator = config.get("translator") + if callable(translator): + return translator + return lambda key, **_kwargs: str(key) + + +def _default_symbol_state() -> dict[str, object]: + return { + "is_holding": False, + "entry_price": 0.0, + "highest_price": 0.0, + } + + +def _resolve_state_helpers(config: Mapping[str, object]): + get_symbol_trade_state_fn = config.get("get_symbol_trade_state_fn") + set_symbol_trade_state_fn = config.get("set_symbol_trade_state_fn") + + if callable(get_symbol_trade_state_fn) and callable(set_symbol_trade_state_fn): + return get_symbol_trade_state_fn, set_symbol_trade_state_fn + + def _get_symbol_trade_state(state, symbol): + value = state.get(symbol) + if not isinstance(value, Mapping): + return _default_symbol_state() + merged = _default_symbol_state() + merged.update(dict(value)) + return merged + + def _set_symbol_trade_state(state, symbol, symbol_state): + state[symbol] = dict(symbol_state) + + return _get_symbol_trade_state, _set_symbol_trade_state + + +def evaluate_crypto_leader_rotation(ctx: StrategyContext) -> StrategyDecision: + config = _merge_runtime_config(ctx) + prices = _require_market_data(ctx, "prices") + indicators_map = _require_market_data(ctx, "trend_indicators") + btc_snapshot = _require_market_data(ctx, "btc_snapshot") + account_metrics = _require_market_data(ctx, "account_metrics") + trend_universe_symbols = list(_require_market_data(ctx, "trend_universe_symbols")) + state = dict(ctx.state) + working_state = deepcopy(state) + translator = _resolve_translator(config) + get_symbol_trade_state_fn, set_symbol_trade_state_fn = _resolve_state_helpers(config) + + budgets = legacy_core.compute_allocation_budgets( + account_metrics["total_equity"], + account_metrics["cash_usdt"], + account_metrics.get("trend_value", 0.0), + account_metrics.get("dca_value", 0.0), + ) + + selected_pool, ranking = legacy_rotation.refresh_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"), + ) + selected_candidates = legacy_core.select_rotation_weights( + indicators_map, + prices, + btc_snapshot, + selected_pool, + config["rotation_top_n"], + weight_mode=str(config.get("weight_mode", "inverse_vol")), + ) + + sell_reasons: dict[str, str] = {} + atr_multiplier = float(config.get("atr_multiplier", 2.5)) + for symbol in trend_universe_symbols: + curr_price = prices.get(symbol) + if curr_price is None: + continue + reason = legacy_rotation.get_trend_sell_reason( + working_state, + symbol, + curr_price, + indicators_map.get(symbol), + selected_candidates, + atr_multiplier, + get_symbol_trade_state_fn=get_symbol_trade_state_fn, + set_symbol_trade_state_fn=set_symbol_trade_state_fn, + translate_fn=translator, + ) + if reason: + sell_reasons[symbol] = str(reason) + + eligible_buy_symbols, planned_trend_buys = legacy_rotation.plan_trend_buys( + working_state, + runtime_trend_universe={symbol: {"base_asset": symbol[:-4]} for symbol in trend_universe_symbols}, + selected_candidates=selected_candidates, + trend_indicators=indicators_map, + prices=prices, + available_trend_buy_budget=float(budgets["trend_usdt_pool"]), + allow_new_trend_entries=bool(config.get("allow_new_trend_entries", True)), + get_symbol_trade_state_fn=get_symbol_trade_state_fn, + allocate_trend_buy_budget_fn=legacy_core.allocate_trend_buy_budget, + ) + + positions = [ + PositionTarget( + symbol="BTCUSDT", + target_weight=float(budgets["btc_target_ratio"]), + role="core", + ) + ] + trend_target_ratio = float(budgets["trend_target_ratio"]) + for symbol, payload in sorted(selected_candidates.items()): + positions.append( + PositionTarget( + symbol=symbol, + target_weight=trend_target_ratio * float(payload["weight"]), + role="trend_rotation", + ) + ) + + budget_intents = ( + BudgetIntent( + name="btc_core_dca_pool", + symbol="BTCUSDT", + amount=float(budgets["dca_usdt_pool"]), + purpose="btc_core_accumulation", + ), + BudgetIntent( + name="trend_rotation_pool", + amount=float(budgets["trend_usdt_pool"]), + purpose="trend_rotation", + ), + ) + + risk_flags: tuple[str, ...] = () + if not btc_snapshot.get("regime_on"): + risk_flags += ("regime_off",) + if not selected_candidates: + risk_flags += ("no_trend_candidates",) + + diagnostics = { + "trend_pool": tuple(selected_pool), + "rotation_candidates": { + symbol: { + "weight": float(payload["weight"]), + "relative_score": float(payload["relative_score"]), + "abs_momentum": float(payload["abs_momentum"]), + } + for symbol, payload in selected_candidates.items() + }, + "ranking_preview": tuple(item["symbol"] for item in ranking[: 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"), + "sell_reasons": sell_reasons, + "eligible_buy_symbols": tuple(eligible_buy_symbols), + "planned_trend_buys": {symbol: float(amount) for symbol, amount in planned_trend_buys.items()}, + "btc_base_order_usdt": float(legacy_core.get_dynamic_btc_base_order(account_metrics["total_equity"])), + "btc_target_ratio": float(budgets["btc_target_ratio"]), + "trend_target_ratio": float(budgets["trend_target_ratio"]), + "artifact_contract": { + "version": config.get("artifact_contract_version"), + "max_age_days": config.get("artifact_max_age_days"), + "acceptable_modes": tuple(config.get("artifact_acceptable_modes", ())), + **dict(ctx.artifacts.get("trend_pool_contract", {})), + }, + } + return StrategyDecision( + positions=tuple(positions), + budgets=budget_intents, + risk_flags=risk_flags, + diagnostics=diagnostics, + ) + + +crypto_leader_rotation_entrypoint = CallableStrategyEntrypoint( + manifest=crypto_leader_rotation_manifest, + _evaluate=evaluate_crypto_leader_rotation, +) + + +__all__ = ["crypto_leader_rotation_entrypoint", "evaluate_crypto_leader_rotation"] diff --git a/src/crypto_strategies/manifests/__init__.py b/src/crypto_strategies/manifests/__init__.py new file mode 100644 index 0000000..ea37037 --- /dev/null +++ b/src/crypto_strategies/manifests/__init__.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from quant_platform_kit.strategy_contracts import StrategyManifest + + +crypto_leader_rotation_manifest = StrategyManifest( + profile="crypto_leader_rotation", + domain="crypto", + display_name="Crypto Leader Rotation", + description="Trend-following crypto rotation with staged entries, degradation controls, and cash parking.", + required_inputs=frozenset( + { + "prices", + "trend_indicators", + "btc_snapshot", + "account_metrics", + "trend_universe_symbols", + } + ), + default_config={ + "trend_pool_size": 5, + "rotation_top_n": 2, + "min_history_days": 365, + "min_avg_quote_vol_180": 8000000.0, + "membership_bonus": 0.10, + "weight_mode": "inverse_vol", + "allow_rotation_refresh": True, + "atr_multiplier": 2.5, + "artifact_contract_version": "crypto_leader_rotation.live_pool.v1", + "artifact_max_age_days": 45, + "artifact_acceptable_modes": ("core_major",), + }, +) + +MANIFESTS = {crypto_leader_rotation_manifest.profile: crypto_leader_rotation_manifest} + + +def get_strategy_manifest(profile: str) -> StrategyManifest: + return MANIFESTS[profile] + + +__all__ = ["MANIFESTS", "crypto_leader_rotation_manifest", "get_strategy_manifest"] diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py new file mode 100644 index 0000000..caa39f9 --- /dev/null +++ b/tests/test_entrypoints.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import unittest + +from quant_platform_kit.strategy_contracts import StrategyContext +from crypto_strategies import get_strategy_entrypoint +from crypto_strategies.strategies.crypto_leader_rotation import core as legacy_core +from crypto_strategies.strategies.crypto_leader_rotation import rotation as legacy_rotation + + +class CryptoStrategyEntrypointTests(unittest.TestCase): + def test_crypto_leader_rotation_entrypoint_matches_legacy_budget_and_rotation_outputs(self) -> None: + entrypoint = get_strategy_entrypoint("crypto_leader_rotation") + prices = { + "ETHUSDT": 3000.0, + "SOLUSDT": 180.0, + "BNBUSDT": 700.0, + } + trend_indicators = { + "ETHUSDT": { + "close": 3000.0, + "sma20": 2800.0, + "sma60": 2600.0, + "sma200": 2200.0, + "roc20": 0.20, + "roc60": 0.35, + "roc120": 0.60, + "vol20": 0.25, + "avg_quote_vol_30": 60000000.0, + "avg_quote_vol_90": 50000000.0, + "avg_quote_vol_180": 45000000.0, + "trend_persist_90": 0.80, + "age_days": 500, + "atr14": 120.0, + }, + "SOLUSDT": { + "close": 180.0, + "sma20": 170.0, + "sma60": 160.0, + "sma200": 120.0, + "roc20": 0.28, + "roc60": 0.45, + "roc120": 0.75, + "vol20": 0.30, + "avg_quote_vol_30": 42000000.0, + "avg_quote_vol_90": 39000000.0, + "avg_quote_vol_180": 36000000.0, + "trend_persist_90": 0.76, + "age_days": 450, + "atr14": 8.0, + }, + "BNBUSDT": { + "close": 700.0, + "sma20": 690.0, + "sma60": 650.0, + "sma200": 540.0, + "roc20": 0.10, + "roc60": 0.22, + "roc120": 0.40, + "vol20": 0.18, + "avg_quote_vol_30": 30000000.0, + "avg_quote_vol_90": 28000000.0, + "avg_quote_vol_180": 26000000.0, + "trend_persist_90": 0.72, + "age_days": 600, + "atr14": 20.0, + }, + } + btc_snapshot = { + "regime_on": True, + "btc_roc20": 0.08, + "btc_roc60": 0.16, + "btc_roc120": 0.30, + } + account_metrics = { + "total_equity": 100000.0, + "cash_usdt": 25000.0, + "trend_value": 15000.0, + "dca_value": 12000.0, + } + state = { + "trend_pool_version": "2026-03-15-core_major", + "trend_pool_as_of_date": "2026-03-15", + } + expected_budgets = legacy_core.compute_allocation_budgets( + account_metrics["total_equity"], + account_metrics["cash_usdt"], + account_metrics["trend_value"], + account_metrics["dca_value"], + ) + expected_state = dict(state) + expected_pool, ranking = legacy_rotation.refresh_rotation_pool( + expected_state, + trend_indicators, + btc_snapshot, + trend_universe_symbols=list(prices), + 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, + prices, + btc_snapshot, + expected_pool, + entrypoint.manifest.default_config["rotation_top_n"], + weight_mode=entrypoint.manifest.default_config["weight_mode"], + ) + 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}, + selected_candidates=expected_candidates, + trend_indicators=trend_indicators, + prices=prices, + available_trend_buy_budget=expected_budgets["trend_usdt_pool"], + allow_new_trend_entries=True, + get_symbol_trade_state_fn=lambda current_state, symbol: current_state.get( + symbol, + {"is_holding": False, "entry_price": 0.0, "highest_price": 0.0}, + ), + allocate_trend_buy_budget_fn=legacy_core.allocate_trend_buy_budget, + ) + + decision = entrypoint.evaluate( + StrategyContext( + as_of="2026-04-06", + market_data={ + "prices": prices, + "trend_indicators": trend_indicators, + "btc_snapshot": btc_snapshot, + "account_metrics": account_metrics, + "trend_universe_symbols": list(prices), + }, + state=state, + artifacts={"trend_pool_contract": {"source": "explicit_artifact"}}, + ) + ) + + budget_map = {budget.name: budget.amount for budget in decision.budgets} + self.assertAlmostEqual(budget_map["btc_core_dca_pool"], expected_budgets["dca_usdt_pool"]) + self.assertAlmostEqual(budget_map["trend_rotation_pool"], expected_budgets["trend_usdt_pool"]) + position_map = {position.symbol: position.target_weight for position in decision.positions} + self.assertAlmostEqual(position_map["BTCUSDT"], expected_budgets["btc_target_ratio"]) + for symbol, payload in expected_candidates.items(): + self.assertAlmostEqual( + position_map[symbol], + expected_budgets["trend_target_ratio"] * payload["weight"], + ) + self.assertEqual(decision.diagnostics["trend_pool"], tuple(expected_pool)) + self.assertEqual( + decision.diagnostics["rotation_pool_source_version"], + expected_state["rotation_pool_source_version"], + ) + self.assertEqual( + tuple(decision.diagnostics["ranking_preview"]), + tuple(item["symbol"] for item in ranking[: 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)) + self.assertEqual(decision.diagnostics["planned_trend_buys"], expected_planned_trend_buys) + self.assertEqual(decision.diagnostics["sell_reasons"], {}) + self.assertAlmostEqual( + decision.diagnostics["btc_base_order_usdt"], + legacy_core.get_dynamic_btc_base_order(account_metrics["total_equity"]), + ) + + def test_crypto_leader_rotation_entrypoint_sets_regime_off_flag_when_btc_regime_is_off(self) -> None: + entrypoint = get_strategy_entrypoint("crypto_leader_rotation") + decision = entrypoint.evaluate( + StrategyContext( + as_of="2026-04-06", + market_data={ + "prices": {}, + "trend_indicators": {}, + "btc_snapshot": {"regime_on": False, "btc_roc20": 0.0, "btc_roc60": 0.0, "btc_roc120": 0.0}, + "account_metrics": {"total_equity": 1000.0, "cash_usdt": 1000.0, "trend_value": 0.0, "dca_value": 0.0}, + "trend_universe_symbols": [], + }, + state={}, + ) + ) + + self.assertIn("regime_off", decision.risk_flags) + self.assertIn("no_trend_candidates", decision.risk_flags) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_prepare_auto_optimization_pr.py b/tests/test_prepare_auto_optimization_pr.py index 52505c0..7b5f1ce 100644 --- a/tests/test_prepare_auto_optimization_pr.py +++ b/tests/test_prepare_auto_optimization_pr.py @@ -84,6 +84,15 @@ def test_evaluate_changed_files_blocks_shared_strategy_paths(self) -> None: self.assertFalse(blocked["allowed"]) self.assertEqual(blocked["blocked_files"], ["src/crypto_strategies/catalog.py"]) + def test_evaluate_changed_files_blocks_binance_runtime_boundary_paths(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + repo_root = Path(temp_dir) / "BinancePlatform" + repo_root.mkdir() + blocked = evaluate_changed_files(["strategy_runtime.py", "decision_mapper.py"], repo_root=repo_root) + + self.assertFalse(blocked["allowed"]) + self.assertEqual(blocked["blocked_files"], ["strategy_runtime.py", "decision_mapper.py"]) + def test_render_pr_body_contains_merge_policy_and_issue_reference(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: payload = build_payload(self.issue_context, repo_root=Path(temp_dir))