From 076a15da35a16911e9df79ea5c38cfc0500af466 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Sun, 7 Jun 2026 21:23:12 +0800 Subject: [PATCH] 1 --- .env.example | 7 +- AGENTS.md | 10 ++ app/db/operations_dashboard.py | 7 ++ app/db/runtime_config_db.py | 154 ++++++++++++++++++++++++++++- app/web/routes_admin.py | 19 +++- static/config.html | 24 ++--- tests/test_operations_dashboard.py | 22 +++++ tests/test_runtime_config.py | 32 +++++- 8 files changed, 255 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index 648ce2d..a312c34 100644 --- a/.env.example +++ b/.env.example @@ -107,7 +107,7 @@ ALPHAX_PAPER_POSITION_GUARD_CRITICAL_EXIT_ENABLED=1 ALPHAX_PAPER_POSITION_GUARD_CRITICAL_MIN_AGE_HOURS=0.5 ALPHAX_PAPER_POSITION_GUARD_CRITICAL_MAX_PNL_PCT=1 -# 实盘准备模块。默认关闭且 dry-run,只生成订单意图,不真实下单。 +# 实盘模块。交易所 endpoint、API key/secret 只通过环境变量管理; # 多 API 账号请在页面中配置不同 account_code 和不同 env key 名。 ALPHAX_LIVE_TRADING_ENABLED=0 ALPHAX_LIVE_TRADING_EXECUTION_MODE=exchange_api @@ -136,8 +136,9 @@ ALPHAX_LIVE_TRADING_ALLOWED_SYMBOLS= ALPHAX_SYSTEM_ERROR_FEISHU_ENABLED=0 ALPHAX_SYSTEM_ERROR_FEISHU_WEBHOOK= -ALPHAX_BINANCE_DEMO_API_KEY=r7dHchnHGVeyDU6rNUnZgZHZpqRpzWjqTzDAB46sUVDua5mp5amW7KSrltDipSuk -ALPHAX_BINANCE_DEMO_API_SECRET=jLKzapcO0iPtyxdPgKMK0FKMXLHpkg1EuhNYNHGUqCISwuJmuX7kQ6nardqK4K2Y +# 可选:Binance Futures Demo endpoint 对应的 key。不要提交真实值。 +ALPHAX_BINANCE_DEMO_API_KEY= +ALPHAX_BINANCE_DEMO_API_SECRET= # 邮箱验证码 SMTP 配置。没有配置时,注册验证码只会生成,不会发邮件。 ASTOCK_SMTP_HOST= diff --git a/AGENTS.md b/AGENTS.md index 26fe81a..677cb83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -291,6 +291,16 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组 - `system_config` / `strategy_runtime_config` 等 PostgreSQL 表承载运行态配置。 - `live_trading` runtime config 使用 `execution_mode=exchange_api` 表示真实调用当前配置的交易所 API endpoint;API key/secret 只通过环境变量名引用;多账户配置保存在 `live_trade_accounts`。 +配置治理原则: + +- `rules.yaml`:策略基线与默认参数,适合版本化管理。 +- PostgreSQL 配置中心:只放可在线调整且可以审计的运行参数,例如策略交易、事件/舆情、通知开关、调度、监控复盘。 +- 环境变量:只放密钥和基础设施参数,例如 `DATABASE_URL`、API key/secret、真实 webhook URL、SMTP 密码、bootstrap admin、交易所 endpoint、缓存目录。 +- 专用页面:实盘多账号、账号风控、API env key 名等通过实盘控制台管理,不要再暴露到通用 JSON 配置中心。 +- `app/db/runtime_config_db.py#SYSTEM_CONFIG_POLICY` 是配置中心可见性和可编辑性的边界。新增 system 配置前必须先登记 `visible/editable/delete_allowed/source_of_truth`,未登记配置默认不公开、不允许页面编辑。 +- 配置中心前端只应展示可安全在线调参的配置。不要恢复“新增系统配置”这类万能入口,也不要让页面直接编辑 LLM、SMTP、bootstrap、交易所底层连接、API env 指针等配置。 +- 环境变量可以作为紧急覆盖或部署级配置,但不要和 DB 配置长期重复表达同一个业务参数;新增配置前先判断它属于规则基线、运行调参、环境密钥还是专用页面。 + 如果要改筛选阈值、确认门槛、止盈止损、动态权重逻辑,优先检查 `rules.yaml` 和 `app/config/config_loader.py`。如果要改调度行为或系统开关,优先检查 runtime config,而不是只看环境变量。 ## 7. 目录速览 diff --git a/app/db/operations_dashboard.py b/app/db/operations_dashboard.py index 0ecafba..a441b87 100644 --- a/app/db/operations_dashboard.py +++ b/app/db/operations_dashboard.py @@ -8,6 +8,9 @@ from app.db.schema import get_conn from app.db.scheduler_db import get_scheduler_overview +RETIRED_RUNTIME_JOBS = {"onchain"} + + def _now() -> datetime: return datetime.now() @@ -142,6 +145,8 @@ def _build_scheduler_cards() -> tuple[list[dict], list[dict]]: cards = [] timeline = [] for job in overview.get("jobs") or []: + if str(job.get("job_name") or "") in RETIRED_RUNTIME_JOBS: + continue runtime = job.get("runtime") or {} latest = job.get("latest_cron") or {} enabled = bool(job.get("enabled")) @@ -341,6 +346,8 @@ def _build_timeline(conn, base_events: list[dict], since: str) -> list[dict]: """, (since,), ): + if str(row.get("job_name") or "") in RETIRED_RUNTIME_JOBS: + continue status = "danger" if row.get("run_status") == "error" or row.get("error_message") else "ok" timeline.append({ "time": _iso(row.get("time")), diff --git a/app/db/runtime_config_db.py b/app/db/runtime_config_db.py index 1038832..08dd85e 100644 --- a/app/db/runtime_config_db.py +++ b/app/db/runtime_config_db.py @@ -17,6 +17,151 @@ STRATEGY_TABLE = "strategy_runtime_config" SYSTEM_TABLE = "system_config" +SYSTEM_CONFIG_POLICY = { + "paper_trading": { + "visible": True, + "editable": True, + "delete_allowed": False, + "category": "策略交易", + "source_of_truth": "配置中心", + "managed_by": "config_center", + "warning": "", + }, + "event_driven": { + "visible": True, + "editable": True, + "delete_allowed": False, + "category": "事件驱动", + "source_of_truth": "配置中心", + "managed_by": "config_center", + "warning": "", + }, + "event_driven.sources": { + "visible": True, + "editable": True, + "delete_allowed": True, + "category": "事件驱动", + "source_of_truth": "配置中心", + "managed_by": "config_center", + "warning": "", + }, + "sentiment": { + "visible": True, + "editable": True, + "delete_allowed": False, + "category": "舆情", + "source_of_truth": "配置中心", + "managed_by": "config_center", + "warning": "", + }, + "monitoring": { + "visible": True, + "editable": True, + "delete_allowed": False, + "category": "监控复盘", + "source_of_truth": "配置中心", + "managed_by": "config_center", + "warning": "", + }, + "notification": { + "visible": True, + "editable": True, + "delete_allowed": False, + "category": "通知", + "source_of_truth": "配置中心 + 环境变量", + "managed_by": "config_center", + "warning": "只允许配置开关和 env key 名,真实 webhook 必须放在环境变量。", + }, + "scheduler": { + "visible": True, + "editable": True, + "delete_allowed": False, + "category": "调度", + "source_of_truth": "配置中心", + "managed_by": "config_center", + "warning": "", + }, + "llm": { + "visible": False, + "editable": False, + "delete_allowed": False, + "category": "AI 基础设施", + "source_of_truth": "环境变量", + "managed_by": "env", + "warning": "LLM endpoint、model 和 API key env 由部署环境管理,不在配置中心公开编辑。", + }, + "live_trading": { + "visible": False, + "editable": False, + "delete_allowed": False, + "category": "实盘基础设施", + "source_of_truth": "实盘账号页 + 环境变量", + "managed_by": "live_trading_console", + "warning": "实盘交易所 endpoint、API env 指针和账号风控由实盘控制台或环境变量管理。", + }, + "price_streamer": { + "visible": False, + "editable": False, + "delete_allowed": False, + "category": "行情基础设施", + "source_of_truth": "环境变量", + "managed_by": "env", + "warning": "行情源、websocket URL、缓存目录等属于基础设施配置,不在配置中心公开编辑。", + }, + "email": { + "visible": False, + "editable": False, + "delete_allowed": False, + "category": "邮件基础设施", + "source_of_truth": "环境变量", + "managed_by": "env", + "warning": "SMTP host/user/password/sender 只允许通过环境变量管理。", + }, + "bootstrap_admin": { + "visible": False, + "editable": False, + "delete_allowed": False, + "category": "启动安全", + "source_of_truth": "环境变量", + "managed_by": "env", + "warning": "默认管理员启动参数只允许通过环境变量管理。", + }, +} + + +DEFAULT_SYSTEM_CONFIG_POLICY = { + "visible": False, + "editable": False, + "delete_allowed": False, + "category": "内部配置", + "source_of_truth": "代码/环境变量", + "managed_by": "internal", + "warning": "未登记的 system 配置默认不在配置中心公开,避免配置漂移。", +} + + +def config_policy(kind: str, key: str) -> dict: + if kind == "strategy": + return { + "visible": True, + "editable": True, + "delete_allowed": True, + "category": "策略运行时", + "source_of_truth": "配置中心", + "managed_by": "config_center", + "warning": "", + } + return copy.deepcopy(SYSTEM_CONFIG_POLICY.get(key, DEFAULT_SYSTEM_CONFIG_POLICY)) + + +def is_config_editable(kind: str, key: str) -> bool: + return bool(config_policy(kind, key).get("editable")) + + +def is_config_delete_allowed(kind: str, key: str) -> bool: + return bool(config_policy(kind, key).get("delete_allowed")) + + def _now() -> str: return datetime.now().isoformat() @@ -110,7 +255,7 @@ def delete_config(kind: str, key: str) -> bool: conn.close() -def list_configs(kind: str): +def list_configs(kind: str, *, include_hidden: bool = False): _ensure() table = _table(kind) conn = get_conn() @@ -121,8 +266,12 @@ def list_configs(kind: str): items = [] for row in rows: item = dict(row) + policy = config_policy(kind, item.get("config_key", "")) + if not include_hidden and not policy.get("visible"): + continue item["config"] = _loads(item.pop("config_json", "{}"), {}) item["kind"] = kind + item.update(policy) if item.get("is_secret"): item["config"] = {"masked": True} items.append(item) @@ -273,6 +422,7 @@ def seed_system_defaults(defaults: dict[str, tuple[object, str]]): __all__ = [ "deep_merge", + "config_policy", "delete_config", "get_config", "get_event_driven_config", @@ -289,6 +439,8 @@ __all__ = [ "get_learned_rules_config", "get_strategy_meta", "get_strategy_override", + "is_config_delete_allowed", + "is_config_editable", "list_configs", "set_config", "set_event_driven_config", diff --git a/app/web/routes_admin.py b/app/web/routes_admin.py index 373ff63..44e7b59 100644 --- a/app/web/routes_admin.py +++ b/app/web/routes_admin.py @@ -15,7 +15,15 @@ from app.db.scheduler_db import ( set_job_enabled, set_job_interval, ) -from app.db.runtime_config_db import delete_config, get_config, list_configs, set_config +from app.db.runtime_config_db import ( + config_policy, + delete_config, + get_config, + is_config_delete_allowed, + is_config_editable, + list_configs, + set_config, +) from app.db.system_logs import get_system_error, get_system_error_stats, list_system_errors from app.web.shared import ( RuntimeConfigRequest, @@ -151,13 +159,18 @@ def build_router(templates): require_admin(altcoin_session) if kind not in ("strategy", "system"): raise HTTPException(status_code=400, detail="kind must be strategy or system") - return {"kind": kind, "config_key": config_key, "config": get_config(kind, config_key, default={})} + policy = config_policy(kind, config_key) + if not policy.get("visible"): + raise HTTPException(status_code=403, detail="该配置由环境变量或专用页面管理,不在配置中心公开") + return {"kind": kind, "config_key": config_key, "config": get_config(kind, config_key, default={}), **policy} @router.put("/api/runtime-config/{kind}/{config_key:path}") async def api_runtime_config_update(kind: str, config_key: str, payload: RuntimeConfigRequest, altcoin_session: str = Cookie(default="")): user = require_admin(altcoin_session) if kind not in ("strategy", "system"): raise HTTPException(status_code=400, detail="kind must be strategy or system") + if not is_config_editable(kind, config_key): + raise HTTPException(status_code=403, detail="该配置由环境变量或专用页面管理,不能在配置中心编辑") config = set_config(kind, config_key, payload.config, description=payload.description, source="admin_page", updated_by=user.get("email", "")) return {"ok": True, "kind": kind, "config_key": config_key, "config": config} @@ -166,6 +179,8 @@ def build_router(templates): require_admin(altcoin_session) if kind not in ("strategy", "system"): raise HTTPException(status_code=400, detail="kind must be strategy or system") + if not is_config_delete_allowed(kind, config_key): + raise HTTPException(status_code=403, detail="该配置不允许从配置中心删除") return {"ok": delete_config(kind, config_key), "kind": kind, "config_key": config_key} @router.get("/api/scheduler/jobs") diff --git a/static/config.html b/static/config.html index cb443f6..52a9e1d 100644 --- a/static/config.html +++ b/static/config.html @@ -2,7 +2,7 @@ {% block title %}AlphaX Agent — 配置中心{% endblock %} {% block extra_head_css %} {% endblock %} {% block content %} @@ -10,7 +10,7 @@

配置中心

-

运行期配置保存在 PostgreSQL,不再写回 rules.yaml。代码部署只更新默认模板,不覆盖线上策略迭代和系统配置。

+

运行期配置保存在 PostgreSQL,但这里只展示适合在线调参的项目。密钥、数据库、SMTP、交易所底层连接和启动管理员只通过环境变量或专用页面管理。

-
-
建议:新闻源、LLM、调度、策略交易属于系统配置;复盘 meta、learned_rules、策略覆盖属于策略运行时配置。
+
配置边界:这里适合调策略交易、通知开关、事件/舆情、监控和调度参数。API key、webhook 真实地址、SMTP 密码、DATABASE_URL、交易所 endpoint 等只放环境变量,避免线上配置漂移和误暴露。
配置列表
--
@@ -35,10 +34,11 @@
+
选择一项配置后会显示它的管理边界。
- - + +
@@ -53,11 +53,13 @@ function esc(v){return String(v==null?'':v).replace(/[&<>"']/g,function(c){retur function fmtTime(t){if(!t)return'--';var d=new Date(t);if(isNaN(d.getTime()))return String(t).slice(0,16).replace('T',' ');return (d.getMonth()+1)+'/'+d.getDate()+' '+String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0')} async function api(url,opt){var r=await fetch(url,opt||{});var d=await r.json().catch(function(){return{}});if(!r.ok)throw new Error(d.detail||d.error||'请求失败');return d} async function loadConfigs(){document.getElementById('configList').innerHTML='
加载中...
';try{var kind=document.getElementById('kindFilter').value;var d=await api('/api/runtime-config?kind='+encodeURIComponent(kind));items=d.items||[];document.getElementById('countNote').textContent=items.length+' 项';renderList();if(items.length&&!selected)selectItem(items[0].kind+'::'+items[0].config_key)}catch(e){document.getElementById('configList').innerHTML='
'+esc(e.message)+'
'}} -function renderList(){if(!items.length){document.getElementById('configList').innerHTML='
暂无运行期配置
';return}document.getElementById('configList').innerHTML=items.map(function(x){var id=x.kind+'::'+x.config_key;return '
'+(x.kind==='system'?'系统':'策略')+'
'+esc(x.config_key)+'
'+esc(x.description||'--')+'
更新 '+fmtTime(x.updated_at)+' · '+esc(x.source||'system')+'
'}).join('')} -function selectItem(id){selected=id;var x=items.find(function(i){return i.kind+'::'+i.config_key===id});if(!x)return;document.getElementById('editKind').value=x.kind;document.getElementById('editKey').value=x.config_key;document.getElementById('editDesc').value=x.description||'';document.getElementById('editJson').value=JSON.stringify(x.config||{},null,2);document.getElementById('editNote').textContent=x.kind+' / '+x.config_key;document.getElementById('msg').textContent='';renderList()} -function newConfig(kind){selected='';document.getElementById('editKind').value=kind;document.getElementById('editKey').value='';document.getElementById('editDesc').value='';document.getElementById('editJson').value='{}';document.getElementById('editNote').textContent='新增配置';document.getElementById('msg').textContent='';renderList()} -async function saveCurrent(){var msg=document.getElementById('msg');try{var kind=document.getElementById('editKind').value;var key=document.getElementById('editKey').value.trim();if(!key)throw new Error('请填写 Key');var config=JSON.parse(document.getElementById('editJson').value||'{}');await api('/api/runtime-config/'+encodeURIComponent(kind)+'/'+encodeURIComponent(key),{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({config:config,description:document.getElementById('editDesc').value||''})});msg.className='msg ok';msg.textContent='已保存到 PostgreSQL';selected=kind+'::'+key;await loadConfigs()}catch(e){msg.className='msg err';msg.textContent=e.message}} -async function deleteCurrent(){var kind=document.getElementById('editKind').value;var key=document.getElementById('editKey').value.trim();if(!key)return;if(!confirm('确认删除 '+key+' ?'))return;try{await api('/api/runtime-config/'+encodeURIComponent(kind)+'/'+encodeURIComponent(key),{method:'DELETE'});selected='';newConfig(kind);await loadConfigs()}catch(e){document.getElementById('msg').className='msg err';document.getElementById('msg').textContent=e.message}} +function currentItem(){return items.find(function(i){return i.kind+'::'+i.config_key===selected})||null} +function renderList(){if(!items.length){document.getElementById('configList').innerHTML='
暂无可在线调整配置
';return}document.getElementById('configList').innerHTML=items.map(function(x){var id=x.kind+'::'+x.config_key;var perm=x.editable===false?'只读':'可在线调整';var permClass=x.editable===false?'locked':'editable';return '
'+(x.kind==='system'?'系统':'策略')+''+perm+'
'+esc(x.config_key)+'
'+esc(x.description||'--')+'
'+esc(x.category||'运行配置')+' · '+esc(x.source_of_truth||'配置中心')+' · 更新 '+fmtTime(x.updated_at)+'
'}).join('')} +function applyEditMode(x){var editable=!x||x.editable!==false;var deletable=!!(x&&x.delete_allowed);document.getElementById('editKind').disabled=!!x;document.getElementById('editKey').disabled=!!x;document.getElementById('editDesc').disabled=!editable;document.getElementById('editJson').disabled=!editable;document.getElementById('saveBtn').disabled=!editable;document.getElementById('deleteBtn').disabled=!deletable;var note='新建策略运行时配置。系统配置必须先在后端登记管理边界,不能从页面随意新增。';if(x){note=(x.editable===false?'只读配置。':'可在线调整。')+'来源:'+(x.source_of_truth||'配置中心')+'。'+(x.warning?' '+x.warning:'')}document.getElementById('guardNote').textContent=note} +function selectItem(id){selected=id;var x=currentItem();if(!x)return;document.getElementById('editKind').value=x.kind;document.getElementById('editKey').value=x.config_key;document.getElementById('editDesc').value=x.description||'';document.getElementById('editJson').value=JSON.stringify(x.config||{},null,2);document.getElementById('editNote').textContent=x.kind+' / '+x.config_key;document.getElementById('msg').textContent='';applyEditMode(x);renderList()} +function newConfig(kind){if(kind==='system'){document.getElementById('msg').className='msg err';document.getElementById('msg').textContent='系统配置不能从页面新增,请先在后端登记配置边界。';return}selected='';document.getElementById('editKind').value=kind;document.getElementById('editKey').value='';document.getElementById('editDesc').value='';document.getElementById('editJson').value='{}';document.getElementById('editNote').textContent='新增策略配置';document.getElementById('msg').textContent='';applyEditMode(null);renderList()} +async function saveCurrent(){var msg=document.getElementById('msg');try{var x=currentItem();if(x&&x.editable===false)throw new Error('该配置不可在配置中心编辑');var kind=document.getElementById('editKind').value;var key=document.getElementById('editKey').value.trim();if(!key)throw new Error('请填写 Key');var config=JSON.parse(document.getElementById('editJson').value||'{}');await api('/api/runtime-config/'+encodeURIComponent(kind)+'/'+encodeURIComponent(key),{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({config:config,description:document.getElementById('editDesc').value||''})});msg.className='msg ok';msg.textContent='已保存到 PostgreSQL';selected=kind+'::'+key;await loadConfigs()}catch(e){msg.className='msg err';msg.textContent=e.message}} +async function deleteCurrent(){var x=currentItem();if(x&&x.delete_allowed===false){document.getElementById('msg').className='msg err';document.getElementById('msg').textContent='该配置不允许从配置中心删除';return}var kind=document.getElementById('editKind').value;var key=document.getElementById('editKey').value.trim();if(!key)return;if(!confirm('确认删除 '+key+' ?'))return;try{await api('/api/runtime-config/'+encodeURIComponent(kind)+'/'+encodeURIComponent(key),{method:'DELETE'});selected='';newConfig('strategy');await loadConfigs()}catch(e){document.getElementById('msg').className='msg err';document.getElementById('msg').textContent=e.message}} loadConfigs(); {% endblock %} diff --git a/tests/test_operations_dashboard.py b/tests/test_operations_dashboard.py index 9a0f7c2..1a64ba5 100644 --- a/tests/test_operations_dashboard.py +++ b/tests/test_operations_dashboard.py @@ -58,6 +58,28 @@ def test_operations_dashboard_read_model_shape(pg_conn): assert isinstance(data["trading"], dict) +def test_operations_dashboard_hides_retired_onchain_runtime_data(pg_conn): + pg_conn.execute( + """ + INSERT INTO scheduler_job_config (job_name, command, every_seconds, enabled, lock_group, created_at, updated_at) + VALUES ('onchain', 'python -m app.cli onchain', 60, 1, 'onchain', NOW(), NOW()) + ON CONFLICT(job_name) DO UPDATE SET enabled=1, updated_at=NOW() + """ + ) + pg_conn.execute( + """ + INSERT INTO cron_run_log (job_name, script_name, run_status, result_status, started_at, finished_at, duration_ms) + VALUES ('onchain', 'onchain', 'success', 'no_onchain_data', NOW(), NOW(), 100) + """ + ) + pg_conn.commit() + + data = get_operations_dashboard(hours=24) + + assert all(x.get("job_name") != "onchain" for x in data["scheduler"]) + assert all("onchain" not in str(x.get("title") or "").lower() for x in data["timeline"]) + + def test_operations_dashboard_sanitizes_external_provider_errors(): summary = _display_error_summary( "market:HTTPSConnectionPool(host='api.binance.com', port=443): " diff --git a/tests/test_runtime_config.py b/tests/test_runtime_config.py index 2e357a5..8645d36 100644 --- a/tests/test_runtime_config.py +++ b/tests/test_runtime_config.py @@ -6,7 +6,7 @@ from app.config import config_loader from app.config.system_config import bootstrap_admin_config, email_config, live_trading_config, notification_config, scheduler_config from app.db import auth_db from app.db.paper_trading import get_paper_trading_summary -from app.db.runtime_config_db import delete_config, get_event_sources, set_config, set_event_driven_config, set_event_sources, set_strategy_meta +from app.db.runtime_config_db import delete_config, get_event_sources, list_configs, set_config, set_event_driven_config, set_event_sources, set_strategy_meta from app.integrations import feishu_push from app.services.llm_insights import get_llm_module_enabled, get_llm_params from app.web import web_server @@ -177,7 +177,7 @@ def test_runtime_config_api_can_manage_system_config(): assert "event_driven.sources" in keys -def test_runtime_config_api_seeds_all_system_defaults_when_listing(): +def test_runtime_config_api_lists_only_public_system_defaults_by_default(): for key in ["llm", "paper_trading", "live_trading", "price_streamer", "notification", "email", "bootstrap_admin", "scheduler"]: delete_config("system", key) @@ -186,8 +186,34 @@ def test_runtime_config_api_seeds_all_system_defaults_when_listing(): assert resp.status_code == 200 keys = {x["config_key"] for x in resp.json()["items"]} - for key in ["llm", "paper_trading", "live_trading", "price_streamer", "notification", "email", "bootstrap_admin", "scheduler"]: + for key in ["paper_trading", "notification", "scheduler"]: assert key in keys + for key in ["llm", "live_trading", "price_streamer", "email", "bootstrap_admin"]: + assert key not in keys + + paper_item = next(x for x in resp.json()["items"] if x["config_key"] == "paper_trading") + assert paper_item["editable"] is True + assert paper_item["delete_allowed"] is False + assert paper_item["source_of_truth"] == "配置中心" + + hidden = list_configs("system", include_hidden=True) + hidden_keys = {x["config_key"] for x in hidden} + assert "llm" in hidden_keys + assert "bootstrap_admin" in hidden_keys + + +def test_runtime_config_api_blocks_protected_system_config_updates(): + client = TestClient(web_server.app) + + resp = client.put("/api/runtime-config/system/bootstrap_admin", json={"config": {"enabled": False}}) + assert resp.status_code == 403 + assert "环境变量" in resp.json()["detail"] + + detail = client.get("/api/runtime-config/system/bootstrap_admin") + assert detail.status_code == 403 + + delete_resp = client.delete("/api/runtime-config/system/paper_trading") + assert delete_resp.status_code == 403 def test_llm_system_config_overrides_env_defaults(monkeypatch):