diff --git a/README.md b/README.md index a0b6ae7..b3d9e1b 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ The current `crypto_leader_rotation` pure strategy modules are sourced from `Cry Full strategy documentation now lives in [`CryptoStrategies`](https://github.com/QuantStrategyLab/CryptoStrategies#crypto_leader_rotation). The sections below focus on downstream execution assumptions and runtime behavior. -**Artifact contract:** Local replay and monitoring helpers now follow an explicit live-pool artifact contract: runtime payload, Firestore payload, `TREND_POOL_FILE`, repo-local `artifacts/live_pool_legacy.json`, then compatible fallback candidates. A sibling `../CryptoLeaderRotation` checkout is only one fallback candidate, not the sole default source. +**Artifact contract:** Local replay and monitoring helpers now follow an explicit strategy artifact contract: runtime payload, Firestore payload, `STRATEGY_ARTIFACT_FILE`, repo-local `artifacts/live_pool_legacy.json`, then compatible fallback candidates. The old `TREND_POOL_*` settings remain compatibility aliases for `crypto_leader_rotation`. A sibling `../CryptoLeaderRotation` checkout is only one fallback candidate, not the sole default source. **Python runtime:** Prefer Python `3.11`. CI is pinned to 3.11, and local helper commands now prefer `python3.11` when available while still falling back to `python3`. @@ -170,9 +170,9 @@ Runs hourly; signals are daily trend and risk, not high-frequency. **Default:** CryptoLeaderRotation monthly output. -1. Firestore `strategy` / `CRYPTO_LEADER_ROTATION_LIVE_POOL` (override: `TREND_POOL_FIRESTORE_COLLECTION`, `TREND_POOL_FIRESTORE_DOCUMENT`). +1. Firestore `strategy` / `CRYPTO_LEADER_ROTATION_LIVE_POOL` (override: `STRATEGY_ARTIFACT_FIRESTORE_COLLECTION`, `STRATEGY_ARTIFACT_FIRESTORE_DOCUMENT`; legacy aliases: `TREND_POOL_FIRESTORE_COLLECTION`, `TREND_POOL_FIRESTORE_DOCUMENT`). 2. Last known good upstream payload persisted in Firestore state after a successful accepted upstream read. -3. Local `live_pool_legacy.json` or `live_pool.json` style file (override: `TREND_POOL_FILE`). +3. Local `live_pool_legacy.json` or `live_pool.json` style file (override: `STRATEGY_ARTIFACT_FILE`; legacy alias: `TREND_POOL_FILE`). 4. Static `TREND_UNIVERSE` as emergency fallback only. **Stable upstream contract fields:** @@ -223,9 +223,9 @@ The monthly execution pool is rebuilt when the accepted upstream `version` / `as **Validation and degraded mode:** - Upstream payloads must have a non-empty symbol set, a parseable `as_of_date`, and an acceptable `mode`. -- Freshness is validated with `TREND_POOL_MAX_AGE_DAYS` against the upstream `as_of_date`. +- Freshness is validated with `STRATEGY_ARTIFACT_MAX_AGE_DAYS` against the upstream `as_of_date`; `TREND_POOL_MAX_AGE_DAYS` remains a compatibility alias. - If the fresh upstream payload is stale or malformed, the runtime does not silently treat weaker fallbacks as equivalent. -- In degraded mode, the script prefers the last known good upstream payload, then a validated local file fallback, and pauses new trend buys by default unless `TREND_POOL_ALLOW_NEW_ENTRIES_ON_DEGRADED=1`. +- In degraded mode, the script prefers the last known good upstream payload, then a validated local file fallback, and pauses new trend buys by default unless `STRATEGY_ARTIFACT_ALLOW_NEW_ENTRIES_ON_DEGRADED=1` or the legacy alias `TREND_POOL_ALLOW_NEW_ENTRIES_ON_DEGRADED=1`. - Retired symbols stay in state until sold; active pool changes are source-tagged in state for auditability. ## Environment @@ -245,6 +245,18 @@ Across multiple quant repositories, `GLOBAL_TELEGRAM_CHAT_ID` and `NOTIFY_LANG` Optional: | Variable | Description | +|----------|-------------| +| `STRATEGY_PROFILE` | Strategy profile selector (default: `crypto_leader_rotation`; supported value: `crypto_leader_rotation`) | +| `STRATEGY_ARTIFACT_FILE` | Local live-pool artifact path; legacy alias: `TREND_POOL_FILE` | +| `STRATEGY_ARTIFACT_MANIFEST_FILE` | Optional local artifact manifest path for operator tooling | +| `STRATEGY_ARTIFACT_FIRESTORE_COLLECTION` | Firestore collection for the live artifact (default `strategy`; legacy alias: `TREND_POOL_FIRESTORE_COLLECTION`) | +| `STRATEGY_ARTIFACT_FIRESTORE_DOCUMENT` | Firestore document for the live artifact (default `CRYPTO_LEADER_ROTATION_LIVE_POOL`; legacy alias: `TREND_POOL_FIRESTORE_DOCUMENT`) | +| `STRATEGY_ARTIFACT_MAX_AGE_DAYS` | Max allowed upstream `as_of_date` age before payload is stale (default `45`; legacy alias: `TREND_POOL_MAX_AGE_DAYS`) | +| `STRATEGY_ARTIFACT_ACCEPTABLE_MODES` | Comma-separated acceptable upstream modes (default `core_major`; legacy alias: `TREND_POOL_ACCEPTABLE_MODES`) | +| `STRATEGY_ARTIFACT_EXPECTED_SIZE` | Expected live-pool size for contract checks (default `5`; legacy alias: `TREND_POOL_EXPECTED_SIZE`) | +| `STRATEGY_ARTIFACT_ALLOW_NEW_ENTRIES_ON_DEGRADED` | Allow trend buys while using last-known-good or fallback sources (default `false`; legacy alias: `TREND_POOL_ALLOW_NEW_ENTRIES_ON_DEGRADED`) | +| `BTC_STATUS_REPORT_INTERVAL_HOURS` | Interval for BTC status report (default `24`) | +| `NOTIFY_LANG` | Log and notification language: `en` (English, default) or `zh` (Chinese) | --- @@ -294,17 +306,6 @@ Optional: - `GOOGLE_APPLICATION_CREDENTIALS` 可选环境变量、趋势池契约、日志和月度审阅的完整中文说明,见 [README.zh-CN.md](README.zh-CN.md)。 -|----------|-------------| -| `BTC_STATUS_REPORT_INTERVAL_HOURS` | Interval for BTC status report (default 24) | -| `TREND_POOL_FILE` | Path to `live_pool_legacy.json` | -| `TREND_POOL_FIRESTORE_COLLECTION` | Firestore collection for live pool (default `strategy`) | -| `TREND_POOL_FIRESTORE_DOCUMENT` | Firestore document for live pool (default `CRYPTO_LEADER_ROTATION_LIVE_POOL`) | -| `TREND_POOL_MAX_AGE_DAYS` | Max allowed age for upstream `as_of_date` before payload is treated as stale (default `45`) | -| `TREND_POOL_ACCEPTABLE_MODES` | Comma-separated allowed upstream modes (default `core_major`) | -| `TREND_POOL_EXPECTED_SIZE` | Expected upstream live-pool size for contract checks (default `5`) | -| `TREND_POOL_ALLOW_NEW_ENTRIES_ON_DEGRADED` | Allow trend buys when running on last-known-good or fallback pool sources (default `false`) | -| `STRATEGY_PROFILE` | Strategy profile selector (default: `crypto_leader_rotation`; supported value: `crypto_leader_rotation`) | -| `NOTIFY_LANG` | Log and notification language: `en` (English, default) or `zh` (Chinese) | ## Notification Format diff --git a/README.zh-CN.md b/README.zh-CN.md index 21b4e36..b4bc11e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -15,7 +15,7 @@ 完整策略说明现在放在 [`CryptoStrategies`](https://github.com/QuantStrategyLab/CryptoStrategies#crypto_leader_rotation)。下面这些章节主要保留下游执行侧的约束、运行时行为和运维说明。 -**artifact contract:** 本地 replay、monitor 和 review 工具现在按显式 live-pool artifact contract 取数:runtime 注入 payload、Firestore payload、`TREND_POOL_FILE`、仓库内 `artifacts/live_pool_legacy.json`,最后才是兼容 fallback 候选。`../CryptoLeaderRotation` 只是不保证存在的候选之一,不再是默认唯一来源。 +**artifact contract:** 本地 replay、monitor 和 review 工具现在按显式 strategy artifact contract 取数:runtime 注入 payload、Firestore payload、`STRATEGY_ARTIFACT_FILE`、仓库内 `artifacts/live_pool_legacy.json`,最后才是兼容 fallback 候选。旧的 `TREND_POOL_*` 仍作为 `crypto_leader_rotation` 的兼容别名。`../CryptoLeaderRotation` 只是不保证存在的候选之一,不再是默认唯一来源。 **Python 版本:** 推荐 `Python 3.11`。CI 固定在 `3.11`,本地辅助命令会优先使用 `python3.11`,没有时回退到 `python3`。 @@ -101,9 +101,9 @@ **候选池来源:** 优先使用上游 live pool。读取顺序为: -1. 新鲜的上游 Firestore payload +1. 新鲜的上游 Firestore payload(主配置:`STRATEGY_ARTIFACT_FIRESTORE_COLLECTION` / `STRATEGY_ARTIFACT_FIRESTORE_DOCUMENT`;兼容别名:`TREND_POOL_FIRESTORE_COLLECTION` / `TREND_POOL_FIRESTORE_DOCUMENT`) 2. Firestore 状态中记录的 last known good 上游 payload -3. 通过校验的本地 upstream 文件 fallback +3. 通过校验的本地 upstream 文件 fallback(主配置:`STRATEGY_ARTIFACT_FILE`;兼容别名:`TREND_POOL_FILE`) 4. 静态 `TREND_UNIVERSE` 紧急 fallback **官方输入池:** 上游发布 5 币 live pool,本仓库把这 5 个币视为月度官方输入集。 @@ -167,9 +167,9 @@ 读取顺序: -1. Firestore `strategy` / `CRYPTO_LEADER_ROTATION_LIVE_POOL` +1. Firestore `strategy` / `CRYPTO_LEADER_ROTATION_LIVE_POOL`(主配置:`STRATEGY_ARTIFACT_FIRESTORE_COLLECTION` / `STRATEGY_ARTIFACT_FIRESTORE_DOCUMENT`;兼容别名:`TREND_POOL_FIRESTORE_COLLECTION` / `TREND_POOL_FIRESTORE_DOCUMENT`) 2. 状态里保存的 last known good 上游 payload -3. 本地 `live_pool_legacy.json` 或 `live_pool.json` +3. 本地 `live_pool_legacy.json` 或 `live_pool.json`(主配置:`STRATEGY_ARTIFACT_FILE`;兼容别名:`TREND_POOL_FILE`) 4. 静态 `TREND_UNIVERSE` **稳定字段:** @@ -185,9 +185,9 @@ **降级模式规则:** - 上游 payload 必须有非空币种列表、可解析的 `as_of_date`、可接受的 `mode` -- 新鲜度由 `TREND_POOL_MAX_AGE_DAYS` 和 `as_of_date` 控制 +- 新鲜度由 `STRATEGY_ARTIFACT_MAX_AGE_DAYS` 和 `as_of_date` 控制;`TREND_POOL_MAX_AGE_DAYS` 仍可兼容使用 - 如果 fresh upstream 过期或格式错误,不会把弱 fallback 当成等价替代 -- 进入 degraded mode 后,默认暂停新的趋势买入,除非显式设置 `TREND_POOL_ALLOW_NEW_ENTRIES_ON_DEGRADED=1` +- 进入 degraded mode 后,默认暂停新的趋势买入,除非显式设置 `STRATEGY_ARTIFACT_ALLOW_NEW_ENTRIES_ON_DEGRADED=1`,或使用兼容别名 `TREND_POOL_ALLOW_NEW_ENTRIES_ON_DEGRADED=1` - 已退役币种会保留在状态中直到真正卖出 ## 环境变量 @@ -209,13 +209,15 @@ | 变量 | 说明 | |---|---| | `BTC_STATUS_REPORT_INTERVAL_HOURS` | BTC 状态报告间隔,默认 `24` | -| `TREND_POOL_FILE` | 本地 `live_pool_legacy.json` 路径 | -| `TREND_POOL_FIRESTORE_COLLECTION` | 趋势池 Firestore collection,默认 `strategy` | -| `TREND_POOL_FIRESTORE_DOCUMENT` | 趋势池 Firestore document,默认 `CRYPTO_LEADER_ROTATION_LIVE_POOL` | -| `TREND_POOL_MAX_AGE_DAYS` | 上游 `as_of_date` 允许的最大天数,默认 `45` | -| `TREND_POOL_ACCEPTABLE_MODES` | 可接受的上游 mode,默认 `core_major` | -| `TREND_POOL_EXPECTED_SIZE` | 上游 live pool 期望数量,默认 `5` | -| `TREND_POOL_ALLOW_NEW_ENTRIES_ON_DEGRADED` | degraded mode 下是否允许趋势新开仓,默认 `false` | +| `STRATEGY_PROFILE` | 策略 profile 选择器,当前默认并仅支持 `crypto_leader_rotation` | +| `STRATEGY_ARTIFACT_FILE` | 本地 live-pool artifact 路径;兼容别名:`TREND_POOL_FILE` | +| `STRATEGY_ARTIFACT_MANIFEST_FILE` | 可选本地 artifact manifest 路径,供运维工具使用 | +| `STRATEGY_ARTIFACT_FIRESTORE_COLLECTION` | live artifact 的 Firestore collection,默认 `strategy`;兼容别名:`TREND_POOL_FIRESTORE_COLLECTION` | +| `STRATEGY_ARTIFACT_FIRESTORE_DOCUMENT` | live artifact 的 Firestore document,默认 `CRYPTO_LEADER_ROTATION_LIVE_POOL`;兼容别名:`TREND_POOL_FIRESTORE_DOCUMENT` | +| `STRATEGY_ARTIFACT_MAX_AGE_DAYS` | 上游 `as_of_date` 允许的最大天数,默认 `45`;兼容别名:`TREND_POOL_MAX_AGE_DAYS` | +| `STRATEGY_ARTIFACT_ACCEPTABLE_MODES` | 可接受的上游 mode,默认 `core_major`;兼容别名:`TREND_POOL_ACCEPTABLE_MODES` | +| `STRATEGY_ARTIFACT_EXPECTED_SIZE` | 上游 live pool 期望数量,默认 `5`;兼容别名:`TREND_POOL_EXPECTED_SIZE` | +| `STRATEGY_ARTIFACT_ALLOW_NEW_ENTRIES_ON_DEGRADED` | degraded mode 下是否允许趋势新开仓,默认 `false`;兼容别名:`TREND_POOL_ALLOW_NEW_ENTRIES_ON_DEGRADED` | | `NOTIFY_LANG` | 日志和通知语言: `en`(英文,默认)或 `zh`(中文) | ## 通知格式 diff --git a/degraded_mode_support.py b/degraded_mode_support.py index bed42dd..2f15f68 100644 --- a/degraded_mode_support.py +++ b/degraded_mode_support.py @@ -1,11 +1,11 @@ from __future__ import annotations -import os from datetime import datetime, timezone from pathlib import Path from typing import Any from notify_i18n_support import translate as t +from strategy_artifact_support import build_strategy_artifact_file_candidates, get_strategy_artifact_env from trend_pool_support import ( build_static_trend_pool_resolution, build_trend_pool_resolution, @@ -72,11 +72,11 @@ def resolve_trend_pool_source( ) messages.extend(last_good_result.get("errors", [])) - configured_path = str(os.getenv("TREND_POOL_FILE", "")).strip() - file_candidates: list[Path] = [] - if configured_path: - file_candidates.append(Path(configured_path).expanduser()) - file_candidates.extend(get_default_live_pool_candidates(default_live_pool_legacy_path)) + configured_path = get_strategy_artifact_env("STRATEGY_ARTIFACT_FILE", "TREND_POOL_FILE") + file_candidates = build_strategy_artifact_file_candidates( + configured_path=configured_path, + default_candidates=get_default_live_pool_candidates(default_live_pool_legacy_path), + ) seen_candidates: set[str] = set() for pool_path in file_candidates: diff --git a/docs/operator_runbook.md b/docs/operator_runbook.md index 2bc9608..3264d37 100644 --- a/docs/operator_runbook.md +++ b/docs/operator_runbook.md @@ -36,7 +36,7 @@ It is not responsible for: ## Normal Live Flow 1. Load runtime credentials and Firestore state. -2. Resolve the upstream trend pool in this order: +2. Resolve the upstream strategy artifact in this order: - fresh upstream Firestore payload - last known good upstream payload from state - validated local upstream file fallback @@ -75,14 +75,29 @@ Degraded mode: - Source is `last_known_good`, `local_file`, or `static` - New trend buys are paused by default -- Set `TREND_POOL_ALLOW_NEW_ENTRIES_ON_DEGRADED=1` only if you intentionally want degraded-mode entries +- Set `STRATEGY_ARTIFACT_ALLOW_NEW_ENTRIES_ON_DEGRADED=1` only if you intentionally want degraded-mode entries; `TREND_POOL_ALLOW_NEW_ENTRIES_ON_DEGRADED=1` remains a compatibility alias for the current live profile Interpretation: - `last_known_good` means fresh upstream validation failed, but a previously accepted upstream payload is still available in state -- `local_file` means upstream live access failed and the runtime fell back to a validated local file from CryptoLeaderRotation +- `local_file` means upstream live access failed and the runtime fell back to a validated local file from the configured `STRATEGY_ARTIFACT_FILE`, the repo-local artifact, or a compatible `CryptoLeaderRotation` checkout - `static` is emergency-only and should be treated as lowest-confidence operation +## Strategy Artifact Settings + +Use the generic `STRATEGY_ARTIFACT_*` names for new crypto strategies. The older `TREND_POOL_*` names are accepted only as compatibility aliases for `crypto_leader_rotation`. + +Primary settings: + +- `STRATEGY_PROFILE`: live profile selector; current supported value is `crypto_leader_rotation` +- `STRATEGY_ARTIFACT_FIRESTORE_COLLECTION`: upstream artifact collection, default `strategy` +- `STRATEGY_ARTIFACT_FIRESTORE_DOCUMENT`: upstream artifact document, default `CRYPTO_LEADER_ROTATION_LIVE_POOL` +- `STRATEGY_ARTIFACT_FILE`: local fallback artifact path +- `STRATEGY_ARTIFACT_MAX_AGE_DAYS`: freshness window for upstream `as_of_date` +- `STRATEGY_ARTIFACT_ACCEPTABLE_MODES`: comma-separated accepted upstream modes +- `STRATEGY_ARTIFACT_EXPECTED_SIZE`: expected live-pool size +- `STRATEGY_ARTIFACT_ALLOW_NEW_ENTRIES_ON_DEGRADED`: explicit degraded-entry override + ## Runtime Expectations By Failure Type ### Upstream stale or malformed diff --git a/requirements-lock.txt b/requirements-lock.txt index 273a625..2875b07 100644 --- a/requirements-lock.txt +++ b/requirements-lock.txt @@ -1,5 +1,5 @@ quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@v0.7.12 -crypto-strategies @ git+https://github.com/QuantStrategyLab/CryptoStrategies.git@v0.4.3 +crypto-strategies @ git+https://github.com/QuantStrategyLab/CryptoStrategies.git@v0.4.4 python-binance==1.0.36 pandas==3.0.2 numpy==2.4.4 diff --git a/requirements.txt b/requirements.txt index 0dbb5e3..c0f5190 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@v0.7.12 -crypto-strategies @ git+https://github.com/QuantStrategyLab/CryptoStrategies.git@v0.4.3 +crypto-strategies @ git+https://github.com/QuantStrategyLab/CryptoStrategies.git@v0.4.4 python-binance pandas numpy diff --git a/runtime_config_support.py b/runtime_config_support.py index c6ceb25..a9761fa 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -7,6 +7,7 @@ from notify_i18n_support import build_strategy_display_name, build_translator, get_notify_lang from runtime_support import ExecutionRuntime +from strategy_artifact_support import get_strategy_artifact_env from strategy_registry import ( BINANCE_PLATFORM, resolve_strategy_definition, @@ -28,6 +29,13 @@ def get_env_bool(name: str, default: bool = False) -> bool: return str(value).strip().lower() in {"1", "true", "yes", "y", "on"} +def get_env_bool_alias(name: str, legacy_name: str, default: bool = False) -> bool: + value = get_strategy_artifact_env(name, legacy_name) + if not value: + return bool(default) + return str(value).strip().lower() in {"1", "true", "yes", "y", "on"} + + def get_env_csv(name: str, default_values: list[str] | tuple[str, ...]) -> list[str]: raw = os.getenv(name) if raw is None or not str(raw).strip(): @@ -61,7 +69,8 @@ def load_cycle_execution_settings() -> CycleExecutionSettings: ) return CycleExecutionSettings( btc_status_report_interval_hours=max(1, min(24, get_env_int("BTC_STATUS_REPORT_INTERVAL_HOURS", 24))), - allow_new_trend_entries_on_degraded=get_env_bool( + allow_new_trend_entries_on_degraded=get_env_bool_alias( + "STRATEGY_ARTIFACT_ALLOW_NEW_ENTRIES_ON_DEGRADED", "TREND_POOL_ALLOW_NEW_ENTRIES_ON_DEGRADED", False, ), diff --git a/scripts/print_strategy_switch_env_plan.py b/scripts/print_strategy_switch_env_plan.py index cc9f557..ddd88c4 100644 --- a/scripts/print_strategy_switch_env_plan.py +++ b/scripts/print_strategy_switch_env_plan.py @@ -42,6 +42,14 @@ def build_switch_plan(profile: str) -> dict[str, object]: optional_env = [ "NOTIFY_LANG", "BTC_STATUS_REPORT_INTERVAL_HOURS", + "STRATEGY_ARTIFACT_FILE", + "STRATEGY_ARTIFACT_MANIFEST_FILE", + "STRATEGY_ARTIFACT_FIRESTORE_COLLECTION", + "STRATEGY_ARTIFACT_FIRESTORE_DOCUMENT", + "STRATEGY_ARTIFACT_MAX_AGE_DAYS", + "STRATEGY_ARTIFACT_ACCEPTABLE_MODES", + "STRATEGY_ARTIFACT_EXPECTED_SIZE", + "STRATEGY_ARTIFACT_ALLOW_NEW_ENTRIES_ON_DEGRADED", "TREND_POOL_FILE", "TREND_POOL_FIRESTORE_COLLECTION", "TREND_POOL_FIRESTORE_DOCUMENT", @@ -51,7 +59,8 @@ def build_switch_plan(profile: str) -> dict[str, object]: "TREND_POOL_ALLOW_NEW_ENTRIES_ON_DEGRADED", ] notes = [ - "Binance runtime has no broker-side profile-specific snapshot env today; switching is mainly STRATEGY_PROFILE plus the shared trend-pool artifact settings.", + "Binance runtime resolves strategy artifacts through STRATEGY_ARTIFACT_* settings; TREND_POOL_* remains accepted as a compatibility alias for crypto_leader_rotation.", + "Switching is mainly STRATEGY_PROFILE plus the shared strategy artifact settings.", "Keep exchange credentials and Telegram settings stable across strategy switches.", ] @@ -68,9 +77,10 @@ def build_switch_plan(profile: str) -> dict[str, object]: "optional_env": optional_env, "remove_if_present": [], "hints": { - "trend_pool_default_firestore_collection": "strategy", - "trend_pool_default_firestore_document": "CRYPTO_LEADER_ROTATION_LIVE_POOL", + "strategy_artifact_default_firestore_collection": "strategy", + "strategy_artifact_default_firestore_document": "CRYPTO_LEADER_ROTATION_LIVE_POOL", "default_local_artifact": str(ROOT / "artifacts" / "live_pool_legacy.json"), + "default_local_artifact_manifest": str(ROOT / "artifacts" / "artifact_manifest.json"), }, "notes": notes, } diff --git a/strategy_artifact_support.py b/strategy_artifact_support.py new file mode 100644 index 0000000..d740b66 --- /dev/null +++ b/strategy_artifact_support.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Iterable + + +def get_strategy_artifact_env(name: str, legacy_name: str | None = None, default: str = "") -> str: + primary = str(os.getenv(name, "")).strip() + if primary: + return primary + if legacy_name: + legacy = str(os.getenv(legacy_name, "")).strip() + if legacy: + return legacy + return str(default) + + +def get_strategy_artifact_int(name: str, legacy_name: str | None, default: int) -> int: + raw = get_strategy_artifact_env(name, legacy_name) + if not raw: + return int(default) + try: + return int(raw) + except Exception: + return int(default) + + +def get_strategy_artifact_csv( + name: str, + legacy_name: str | None, + default_values: Iterable[str], +) -> list[str]: + raw = get_strategy_artifact_env(name, legacy_name) + if not raw: + return list(default_values) + return [item.strip() for item in raw.split(",") if item.strip()] + + +def build_strategy_artifact_file_candidates( + *, + configured_path: str, + default_candidates: Iterable[Path], +) -> list[Path]: + candidates: list[Path] = [] + if configured_path: + candidates.append(Path(configured_path).expanduser()) + for candidate in default_candidates: + path = Path(candidate).expanduser() + if path not in candidates: + candidates.append(path) + return candidates diff --git a/strategy_runtime.py b/strategy_runtime.py index 753a67e..2b60e4e 100644 --- a/strategy_runtime.py +++ b/strategy_runtime.py @@ -12,6 +12,7 @@ StrategyEntrypoint, StrategyRuntimeAdapter, build_strategy_context_from_available_inputs, + resolve_strategy_artifact_contract, ) from crypto_strategies import get_platform_runtime_adapter @@ -48,10 +49,17 @@ def trend_pool_size(self) -> int: @property def artifact_contract(self) -> dict[str, Any]: + contract = resolve_strategy_artifact_contract(self.runtime_adapter) return { - "version": str(self.merged_runtime_config.get("artifact_contract_version", "")), + "version": str( + contract.snapshot_contract_version + or self.merged_runtime_config.get("artifact_contract_version", "") + ), "max_age_days": int(self.merged_runtime_config.get("artifact_max_age_days", 45)), "acceptable_modes": tuple(self.merged_runtime_config.get("artifact_acceptable_modes", ())), + "requires_artifacts": bool(contract.requires_snapshot_artifacts), + "requires_manifest": bool(contract.requires_snapshot_manifest_path), + "config_source_policy": str(contract.config_source_policy), "default_local_candidates": tuple(str(path) for path in self.local_artifact_candidates), } diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 5e3d0df..72d968e 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -45,6 +45,19 @@ def test_load_cycle_execution_settings_clamps_interval_and_reads_degraded_flag(s self.assertEqual(settings.strategy_display_name_localized, "Crypto Leader Rotation") self.assertEqual(settings.strategy_domain, CRYPTO_DOMAIN) + def test_load_cycle_execution_settings_accepts_strategy_artifact_degraded_alias(self): + with patch.dict( + os.environ, + { + "STRATEGY_ARTIFACT_ALLOW_NEW_ENTRIES_ON_DEGRADED": "1", + "TREND_POOL_ALLOW_NEW_ENTRIES_ON_DEGRADED": "0", + }, + clear=False, + ): + settings = load_cycle_execution_settings() + + self.assertTrue(settings.allow_new_trend_entries_on_degraded) + def test_load_cycle_execution_settings_rejects_unknown_strategy_profile(self): with patch.dict(os.environ, {"STRATEGY_PROFILE": "global_etf_rotation"}, clear=False): with self.assertRaisesRegex(ValueError, "Unsupported STRATEGY_PROFILE"): @@ -164,7 +177,13 @@ def test_switch_env_plan_script_json_matches_binance_runtime_shape(self): self.assertIn("BINANCE_API_KEY", plan["keep_env"]) self.assertIn("BINANCE_API_SECRET", plan["keep_env"]) self.assertIn("TG_TOKEN", plan["keep_env"]) + self.assertIn("STRATEGY_ARTIFACT_FILE", plan["optional_env"]) + self.assertIn("STRATEGY_ARTIFACT_MANIFEST_FILE", plan["optional_env"]) self.assertIn("TREND_POOL_FILE", plan["optional_env"]) + self.assertEqual( + plan["hints"]["strategy_artifact_default_firestore_document"], + "CRYPTO_LEADER_ROTATION_LIVE_POOL", + ) self.assertEqual(plan["remove_if_present"], []) def test_switch_env_plan_script_table_contains_expected_sections(self): diff --git a/tests/test_strategy_runtime.py b/tests/test_strategy_runtime.py index cc63424..f712193 100644 --- a/tests/test_strategy_runtime.py +++ b/tests/test_strategy_runtime.py @@ -38,6 +38,9 @@ def test_load_strategy_runtime_exposes_explicit_artifact_contract(self): self.assertEqual(runtime.runtime_adapter.portfolio_input_name, "portfolio_snapshot") self.assertTrue(str(runtime.default_local_artifact_path).endswith("BinancePlatform/artifacts/live_pool_legacy.json")) self.assertEqual(runtime.artifact_contract["version"], "crypto_leader_rotation.live_pool.v1") + self.assertTrue(runtime.artifact_contract["requires_artifacts"]) + self.assertTrue(runtime.artifact_contract["requires_manifest"]) + self.assertEqual(runtime.artifact_contract["config_source_policy"], "none") self.assertGreaterEqual(len(runtime.local_artifact_candidates), 1) self.assertIn(str(runtime.default_local_artifact_path), runtime.artifact_contract["default_local_candidates"]) diff --git a/tests/test_trend_pool_loading.py b/tests/test_trend_pool_loading.py index f1cfe65..2c17c7e 100644 --- a/tests/test_trend_pool_loading.py +++ b/tests/test_trend_pool_loading.py @@ -1,6 +1,7 @@ import sys import types import unittest +import os from datetime import datetime, timezone from pathlib import Path from unittest.mock import Mock, patch @@ -118,6 +119,25 @@ def test_validate_trend_pool_payload_rejects_stale_payload(self): self.assertFalse(result["ok"]) self.assertIn("stale", " ".join(result["errors"])) + def test_strategy_artifact_env_aliases_override_legacy_trend_pool_settings(self): + with patch.dict( + os.environ, + { + "STRATEGY_ARTIFACT_MAX_AGE_DAYS": "12", + "STRATEGY_ARTIFACT_ACCEPTABLE_MODES": "core_major,shadow", + "STRATEGY_ARTIFACT_EXPECTED_SIZE": "3", + "TREND_POOL_MAX_AGE_DAYS": "45", + "TREND_POOL_ACCEPTABLE_MODES": "legacy", + "TREND_POOL_EXPECTED_SIZE": "5", + }, + clear=False, + ): + settings = main.get_trend_pool_contract_settings() + + self.assertEqual(settings["max_age_days"], 12) + self.assertEqual(settings["acceptable_modes"], ["core_major", "shadow"]) + self.assertEqual(settings["expected_pool_size"], 3) + def test_resolve_trend_pool_source_prefers_last_known_good_before_local_file(self): last_good_payload = build_payload(as_of_date="2026-02-15") file_result = main.validate_trend_pool_payload( diff --git a/trend_pool_support.py b/trend_pool_support.py index 7bb33a1..e42ba25 100644 --- a/trend_pool_support.py +++ b/trend_pool_support.py @@ -5,6 +5,11 @@ from live_services import get_firestore_client from notify_i18n_support import translate as t +from strategy_artifact_support import ( + get_strategy_artifact_csv, + get_strategy_artifact_env, + get_strategy_artifact_int, +) def infer_base_asset(symbol): @@ -85,9 +90,27 @@ def extract_trend_pool_symbols(payload, symbol_map): def get_trend_pool_contract_settings(*, max_age_days_default, acceptable_modes_default, expected_pool_size_default): return { - "max_age_days": max(0, get_env_int("TREND_POOL_MAX_AGE_DAYS", max_age_days_default)), - "acceptable_modes": get_env_csv("TREND_POOL_ACCEPTABLE_MODES", acceptable_modes_default), - "expected_pool_size": max(1, get_env_int("TREND_POOL_EXPECTED_SIZE", expected_pool_size_default)), + "max_age_days": max( + 0, + get_strategy_artifact_int( + "STRATEGY_ARTIFACT_MAX_AGE_DAYS", + "TREND_POOL_MAX_AGE_DAYS", + max_age_days_default, + ), + ), + "acceptable_modes": get_strategy_artifact_csv( + "STRATEGY_ARTIFACT_ACCEPTABLE_MODES", + "TREND_POOL_ACCEPTABLE_MODES", + acceptable_modes_default, + ), + "expected_pool_size": max( + 1, + get_strategy_artifact_int( + "STRATEGY_ARTIFACT_EXPECTED_SIZE", + "TREND_POOL_EXPECTED_SIZE", + expected_pool_size_default, + ), + ), } @@ -237,8 +260,16 @@ def load_trend_pool_from_firestore( default_collection, default_document, ): - collection = os.getenv("TREND_POOL_FIRESTORE_COLLECTION", default_collection) - document = os.getenv("TREND_POOL_FIRESTORE_DOCUMENT", default_document) + collection = get_strategy_artifact_env( + "STRATEGY_ARTIFACT_FIRESTORE_COLLECTION", + "TREND_POOL_FIRESTORE_COLLECTION", + default_collection, + ) + document = get_strategy_artifact_env( + "STRATEGY_ARTIFACT_FIRESTORE_DOCUMENT", + "TREND_POOL_FIRESTORE_DOCUMENT", + default_document, + ) settings = settings or {} source_label = f"firestore:{collection}/{document}"