This commit is contained in:
aaron 2026-06-07 21:23:12 +08:00
parent 507d4ee99c
commit 076a15da35
8 changed files with 255 additions and 20 deletions

View File

@ -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_MIN_AGE_HOURS=0.5
ALPHAX_PAPER_POSITION_GUARD_CRITICAL_MAX_PNL_PCT=1 ALPHAX_PAPER_POSITION_GUARD_CRITICAL_MAX_PNL_PCT=1
# 实盘准备模块。默认关闭且 dry-run只生成订单意图不真实下单。 # 实盘模块。交易所 endpoint、API key/secret 只通过环境变量管理;
# 多 API 账号请在页面中配置不同 account_code 和不同 env key 名。 # 多 API 账号请在页面中配置不同 account_code 和不同 env key 名。
ALPHAX_LIVE_TRADING_ENABLED=0 ALPHAX_LIVE_TRADING_ENABLED=0
ALPHAX_LIVE_TRADING_EXECUTION_MODE=exchange_api 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_ENABLED=0
ALPHAX_SYSTEM_ERROR_FEISHU_WEBHOOK= ALPHAX_SYSTEM_ERROR_FEISHU_WEBHOOK=
ALPHAX_BINANCE_DEMO_API_KEY=r7dHchnHGVeyDU6rNUnZgZHZpqRpzWjqTzDAB46sUVDua5mp5amW7KSrltDipSuk # 可选Binance Futures Demo endpoint 对应的 key。不要提交真实值。
ALPHAX_BINANCE_DEMO_API_SECRET=jLKzapcO0iPtyxdPgKMK0FKMXLHpkg1EuhNYNHGUqCISwuJmuX7kQ6nardqK4K2Y ALPHAX_BINANCE_DEMO_API_KEY=
ALPHAX_BINANCE_DEMO_API_SECRET=
# 邮箱验证码 SMTP 配置。没有配置时,注册验证码只会生成,不会发邮件。 # 邮箱验证码 SMTP 配置。没有配置时,注册验证码只会生成,不会发邮件。
ASTOCK_SMTP_HOST= ASTOCK_SMTP_HOST=

View File

@ -291,6 +291,16 @@ AlphaX 是一个以 `Python + FastAPI + PostgreSQL + Docker + 静态 HTML` 组
- `system_config` / `strategy_runtime_config` 等 PostgreSQL 表承载运行态配置。 - `system_config` / `strategy_runtime_config` 等 PostgreSQL 表承载运行态配置。
- `live_trading` runtime config 使用 `execution_mode=exchange_api` 表示真实调用当前配置的交易所 API endpointAPI key/secret 只通过环境变量名引用;多账户配置保存在 `live_trade_accounts` - `live_trading` runtime config 使用 `execution_mode=exchange_api` 表示真实调用当前配置的交易所 API endpointAPI 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而不是只看环境变量。 如果要改筛选阈值、确认门槛、止盈止损、动态权重逻辑,优先检查 `rules.yaml``app/config/config_loader.py`。如果要改调度行为或系统开关,优先检查 runtime config而不是只看环境变量。
## 7. 目录速览 ## 7. 目录速览

View File

@ -8,6 +8,9 @@ from app.db.schema import get_conn
from app.db.scheduler_db import get_scheduler_overview from app.db.scheduler_db import get_scheduler_overview
RETIRED_RUNTIME_JOBS = {"onchain"}
def _now() -> datetime: def _now() -> datetime:
return datetime.now() return datetime.now()
@ -142,6 +145,8 @@ def _build_scheduler_cards() -> tuple[list[dict], list[dict]]:
cards = [] cards = []
timeline = [] timeline = []
for job in overview.get("jobs") or []: for job in overview.get("jobs") or []:
if str(job.get("job_name") or "") in RETIRED_RUNTIME_JOBS:
continue
runtime = job.get("runtime") or {} runtime = job.get("runtime") or {}
latest = job.get("latest_cron") or {} latest = job.get("latest_cron") or {}
enabled = bool(job.get("enabled")) enabled = bool(job.get("enabled"))
@ -341,6 +346,8 @@ def _build_timeline(conn, base_events: list[dict], since: str) -> list[dict]:
""", """,
(since,), (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" status = "danger" if row.get("run_status") == "error" or row.get("error_message") else "ok"
timeline.append({ timeline.append({
"time": _iso(row.get("time")), "time": _iso(row.get("time")),

View File

@ -17,6 +17,151 @@ STRATEGY_TABLE = "strategy_runtime_config"
SYSTEM_TABLE = "system_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: def _now() -> str:
return datetime.now().isoformat() return datetime.now().isoformat()
@ -110,7 +255,7 @@ def delete_config(kind: str, key: str) -> bool:
conn.close() conn.close()
def list_configs(kind: str): def list_configs(kind: str, *, include_hidden: bool = False):
_ensure() _ensure()
table = _table(kind) table = _table(kind)
conn = get_conn() conn = get_conn()
@ -121,8 +266,12 @@ def list_configs(kind: str):
items = [] items = []
for row in rows: for row in rows:
item = dict(row) 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["config"] = _loads(item.pop("config_json", "{}"), {})
item["kind"] = kind item["kind"] = kind
item.update(policy)
if item.get("is_secret"): if item.get("is_secret"):
item["config"] = {"masked": True} item["config"] = {"masked": True}
items.append(item) items.append(item)
@ -273,6 +422,7 @@ def seed_system_defaults(defaults: dict[str, tuple[object, str]]):
__all__ = [ __all__ = [
"deep_merge", "deep_merge",
"config_policy",
"delete_config", "delete_config",
"get_config", "get_config",
"get_event_driven_config", "get_event_driven_config",
@ -289,6 +439,8 @@ __all__ = [
"get_learned_rules_config", "get_learned_rules_config",
"get_strategy_meta", "get_strategy_meta",
"get_strategy_override", "get_strategy_override",
"is_config_delete_allowed",
"is_config_editable",
"list_configs", "list_configs",
"set_config", "set_config",
"set_event_driven_config", "set_event_driven_config",

View File

@ -15,7 +15,15 @@ from app.db.scheduler_db import (
set_job_enabled, set_job_enabled,
set_job_interval, 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.db.system_logs import get_system_error, get_system_error_stats, list_system_errors
from app.web.shared import ( from app.web.shared import (
RuntimeConfigRequest, RuntimeConfigRequest,
@ -151,13 +159,18 @@ def build_router(templates):
require_admin(altcoin_session) require_admin(altcoin_session)
if kind not in ("strategy", "system"): if kind not in ("strategy", "system"):
raise HTTPException(status_code=400, detail="kind must be strategy or 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}") @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="")): async def api_runtime_config_update(kind: str, config_key: str, payload: RuntimeConfigRequest, altcoin_session: str = Cookie(default="")):
user = require_admin(altcoin_session) user = require_admin(altcoin_session)
if kind not in ("strategy", "system"): if kind not in ("strategy", "system"):
raise HTTPException(status_code=400, detail="kind must be strategy or 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", "")) 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} return {"ok": True, "kind": kind, "config_key": config_key, "config": config}
@ -166,6 +179,8 @@ def build_router(templates):
require_admin(altcoin_session) require_admin(altcoin_session)
if kind not in ("strategy", "system"): if kind not in ("strategy", "system"):
raise HTTPException(status_code=400, detail="kind must be strategy or 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} return {"ok": delete_config(kind, config_key), "kind": kind, "config_key": config_key}
@router.get("/api/scheduler/jobs") @router.get("/api/scheduler/jobs")

View File

@ -2,7 +2,7 @@
{% block title %}AlphaX Agent — 配置中心{% endblock %} {% block title %}AlphaX Agent — 配置中心{% endblock %}
{% block extra_head_css %} {% block extra_head_css %}
<style> <style>
.shell{width:min(100% - 40px,1280px);margin:0 auto;padding:24px 0 44px}.page-head{display:flex;align-items:flex-end;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap}.page-head h1{font-size:28px;font-weight:950;letter-spacing:-.6px;color:var(--ink)}.page-head p{margin-top:5px;color:var(--stone);font-size:13px;line-height:1.55;max-width:860px}.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.btn,.select{height:38px;border:1px solid var(--hairline-strong);background:var(--canvas);border-radius:var(--radius-md);padding:0 12px;font-size:13px;font-weight:850;color:var(--ink)}.btn{cursor:pointer}.layout{display:grid;grid-template-columns:380px minmax(0,1fr);gap:14px;align-items:start}.panel{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-md);overflow:hidden;min-width:0}.panel-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:13px 14px;border-bottom:1px solid var(--hairline-soft)}.panel-title{font-size:14px;font-weight:950;color:var(--ink)}.panel-note{font-size:11px;color:var(--stone);font-weight:850}.config-list{display:grid}.config-row{padding:12px 14px;border-bottom:1px solid var(--hairline-soft);cursor:pointer;transition:.12s}.config-row:hover{background:var(--surface)}.config-row.active{background:rgba(66,98,255,.06);box-shadow:inset 3px 0 0 var(--blue)}.key{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:950;color:var(--ink);font-size:13px}.meta{margin-top:5px;color:var(--stone);font-size:11px;line-height:1.45}.badge{display:inline-flex;height:22px;align-items:center;border-radius:999px;border:1px solid var(--hairline-soft);background:var(--surface);padding:0 8px;font-size:11px;font-weight:900;color:var(--slate)}.badge.strategy{color:var(--blue);background:rgba(66,98,255,.07);border-color:rgba(66,98,255,.16)}.badge.system{color:var(--green);background:var(--green-light);border-color:rgba(0,180,115,.18)}.editor{padding:14px}.field{margin-bottom:12px}.field label{display:block;font-size:11px;font-weight:900;color:var(--stone);margin-bottom:6px}.input,.textarea{width:100%;border:1px solid var(--hairline-strong);background:var(--canvas);border-radius:var(--radius-md);padding:10px 12px;font-size:13px;color:var(--ink);outline:none}.textarea{min-height:420px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;line-height:1.55;resize:vertical}.msg{font-size:12px;min-height:18px;margin-top:8px}.msg.ok{color:var(--green)}.msg.err{color:var(--red)}.empty,.loading{padding:34px 16px;text-align:center;color:var(--stone);font-size:13px}.hint{padding:11px 12px;border:1px solid rgba(66,98,255,.14);background:rgba(66,98,255,.045);border-radius:var(--radius-md);color:var(--slate);font-size:12px;line-height:1.55;margin-bottom:14px}@media(max-width:980px){.layout{grid-template-columns:1fr}.shell{width:min(100% - 24px,1280px)}.textarea{min-height:320px}} .shell{width:min(100% - 40px,1280px);margin:0 auto;padding:24px 0 44px}.page-head{display:flex;align-items:flex-end;justify-content:space-between;gap:14px;margin-bottom:16px;flex-wrap:wrap}.page-head h1{font-size:28px;font-weight:950;letter-spacing:-.6px;color:var(--ink)}.page-head p{margin-top:5px;color:var(--stone);font-size:13px;line-height:1.55;max-width:860px}.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.btn,.select{height:38px;border:1px solid var(--hairline-strong);background:var(--canvas);border-radius:var(--radius-md);padding:0 12px;font-size:13px;font-weight:850;color:var(--ink)}.btn{cursor:pointer}.btn:disabled,.input:disabled,.textarea:disabled,.select:disabled{opacity:.55;cursor:not-allowed;background:var(--surface)}.layout{display:grid;grid-template-columns:380px minmax(0,1fr);gap:14px;align-items:start}.panel{border:1px solid var(--hairline-soft);background:var(--canvas);border-radius:var(--radius-md);overflow:hidden;min-width:0}.panel-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:13px 14px;border-bottom:1px solid var(--hairline-soft)}.panel-title{font-size:14px;font-weight:950;color:var(--ink)}.panel-note{font-size:11px;color:var(--stone);font-weight:850}.config-list{display:grid}.config-row{padding:12px 14px;border-bottom:1px solid var(--hairline-soft);cursor:pointer;transition:.12s}.config-row:hover{background:var(--surface)}.config-row.active{background:rgba(66,98,255,.06);box-shadow:inset 3px 0 0 var(--blue)}.key{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:950;color:var(--ink);font-size:13px;margin-top:7px}.meta{margin-top:5px;color:var(--stone);font-size:11px;line-height:1.45}.badge{display:inline-flex;height:22px;align-items:center;border-radius:999px;border:1px solid var(--hairline-soft);background:var(--surface);padding:0 8px;font-size:11px;font-weight:900;color:var(--slate);margin:0 4px 4px 0}.badge.strategy{color:var(--blue);background:rgba(66,98,255,.07);border-color:rgba(66,98,255,.16)}.badge.system{color:var(--green);background:var(--green-light);border-color:rgba(0,180,115,.18)}.badge.locked{color:var(--orange);background:rgba(255,174,0,.1);border-color:rgba(255,174,0,.22)}.badge.editable{color:var(--blue);background:rgba(66,98,255,.07);border-color:rgba(66,98,255,.16)}.editor{padding:14px}.field{margin-bottom:12px}.field label{display:block;font-size:11px;font-weight:900;color:var(--stone);margin-bottom:6px}.input,.textarea{width:100%;border:1px solid var(--hairline-strong);background:var(--canvas);border-radius:var(--radius-md);padding:10px 12px;font-size:13px;color:var(--ink);outline:none}.textarea{min-height:420px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;line-height:1.55;resize:vertical}.msg{font-size:12px;min-height:18px;margin-top:8px}.msg.ok{color:var(--green)}.msg.err{color:var(--red)}.empty,.loading{padding:34px 16px;text-align:center;color:var(--stone);font-size:13px}.hint{padding:11px 12px;border:1px solid rgba(66,98,255,.14);background:rgba(66,98,255,.045);border-radius:var(--radius-md);color:var(--slate);font-size:12px;line-height:1.55;margin-bottom:14px}.guard{padding:10px 12px;border:1px solid rgba(255,174,0,.2);background:rgba(255,174,0,.08);border-radius:var(--radius-md);color:var(--slate);font-size:12px;line-height:1.55;margin-bottom:12px}@media(max-width:980px){.layout{grid-template-columns:1fr}.shell{width:min(100% - 24px,1280px)}.textarea{min-height:320px}}
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@ -10,7 +10,7 @@
<div class="page-head"> <div class="page-head">
<div> <div>
<h1>配置中心</h1> <h1>配置中心</h1>
<p>运行期配置保存在 PostgreSQL不再写回 rules.yaml。代码部署只更新默认模板不覆盖线上策略迭代和系统配置</p> <p>运行期配置保存在 PostgreSQL但这里只展示适合在线调参的项目。密钥、数据库、SMTP、交易所底层连接和启动管理员只通过环境变量或专用页面管理</p>
</div> </div>
<div class="actions"> <div class="actions">
<select class="select" id="kindFilter" onchange="loadConfigs()"> <select class="select" id="kindFilter" onchange="loadConfigs()">
@ -19,11 +19,10 @@
<option value="system">系统配置</option> <option value="system">系统配置</option>
</select> </select>
<button class="btn" onclick="newConfig('strategy')">新增策略</button> <button class="btn" onclick="newConfig('strategy')">新增策略</button>
<button class="btn" onclick="newConfig('system')">新增系统</button>
<button class="btn" onclick="loadConfigs()">刷新</button> <button class="btn" onclick="loadConfigs()">刷新</button>
</div> </div>
</div> </div>
<div class="hint">建议新闻源、LLM、调度、策略交易属于系统配置复盘 meta、learned_rules、策略覆盖属于策略运行时配置</div> <div class="hint">配置边界:这里适合调策略交易、通知开关、事件/舆情、监控和调度参数。API key、webhook 真实地址、SMTP 密码、DATABASE_URL、交易所 endpoint 等只放环境变量,避免线上配置漂移和误暴露</div>
<div class="layout"> <div class="layout">
<section class="panel"> <section class="panel">
<div class="panel-head"><div class="panel-title">配置列表</div><div class="panel-note" id="countNote">--</div></div> <div class="panel-head"><div class="panel-title">配置列表</div><div class="panel-note" id="countNote">--</div></div>
@ -35,10 +34,11 @@
<div class="field"><label>类型</label><select class="select" id="editKind"><option value="strategy">策略运行时</option><option value="system">系统配置</option></select></div> <div class="field"><label>类型</label><select class="select" id="editKind"><option value="strategy">策略运行时</option><option value="system">系统配置</option></select></div>
<div class="field"><label>Key</label><input class="input" id="editKey" placeholder="例如 event_driven.sources 或 rules_override"></div> <div class="field"><label>Key</label><input class="input" id="editKey" placeholder="例如 event_driven.sources 或 rules_override"></div>
<div class="field"><label>说明</label><input class="input" id="editDesc" placeholder="这项配置的用途"></div> <div class="field"><label>说明</label><input class="input" id="editDesc" placeholder="这项配置的用途"></div>
<div class="guard" id="guardNote">选择一项配置后会显示它的管理边界。</div>
<div class="field"><label>JSON 配置</label><textarea class="textarea" id="editJson" spellcheck="false">{}</textarea></div> <div class="field"><label>JSON 配置</label><textarea class="textarea" id="editJson" spellcheck="false">{}</textarea></div>
<div class="actions"> <div class="actions">
<button class="btn" onclick="saveCurrent()">保存</button> <button class="btn" id="saveBtn" onclick="saveCurrent()">保存</button>
<button class="btn" onclick="deleteCurrent()">删除</button> <button class="btn" id="deleteBtn" onclick="deleteCurrent()">删除</button>
</div> </div>
<div class="msg" id="msg"></div> <div class="msg" id="msg"></div>
</div> </div>
@ -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')} 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 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='<div class="loading">加载中...</div>';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='<div class="empty">'+esc(e.message)+'</div>'}} async function loadConfigs(){document.getElementById('configList').innerHTML='<div class="loading">加载中...</div>';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='<div class="empty">'+esc(e.message)+'</div>'}}
function renderList(){if(!items.length){document.getElementById('configList').innerHTML='<div class="empty">暂无运行期配置</div>';return}document.getElementById('configList').innerHTML=items.map(function(x){var id=x.kind+'::'+x.config_key;return '<div class="config-row '+(id===selected?'active':'')+'" onclick="selectItem(\''+esc(id)+'\')"><div><span class="badge '+esc(x.kind)+'">'+(x.kind==='system'?'系统':'策略')+'</span></div><div class="key">'+esc(x.config_key)+'</div><div class="meta">'+esc(x.description||'--')+'<br>更新 '+fmtTime(x.updated_at)+' · '+esc(x.source||'system')+'</div></div>'}).join('')} function currentItem(){return items.find(function(i){return i.kind+'::'+i.config_key===selected})||null}
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 renderList(){if(!items.length){document.getElementById('configList').innerHTML='<div class="empty">暂无可在线调整配置</div>';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 '<div class="config-row '+(id===selected?'active':'')+'" onclick="selectItem(\''+esc(id)+'\')"><div><span class="badge '+esc(x.kind)+'">'+(x.kind==='system'?'系统':'策略')+'</span><span class="badge '+permClass+'">'+perm+'</span></div><div class="key">'+esc(x.config_key)+'</div><div class="meta">'+esc(x.description||'--')+'<br>'+esc(x.category||'运行配置')+' · '+esc(x.source_of_truth||'配置中心')+' · 更新 '+fmtTime(x.updated_at)+'</div></div>'}).join('')}
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()} 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}
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}} 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()}
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 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(); loadConfigs();
</script> </script>
{% endblock %} {% endblock %}

View File

@ -58,6 +58,28 @@ def test_operations_dashboard_read_model_shape(pg_conn):
assert isinstance(data["trading"], dict) 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(): def test_operations_dashboard_sanitizes_external_provider_errors():
summary = _display_error_summary( summary = _display_error_summary(
"market:HTTPSConnectionPool(host='api.binance.com', port=443): " "market:HTTPSConnectionPool(host='api.binance.com', port=443): "

View File

@ -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.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 import auth_db
from app.db.paper_trading import get_paper_trading_summary 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.integrations import feishu_push
from app.services.llm_insights import get_llm_module_enabled, get_llm_params from app.services.llm_insights import get_llm_module_enabled, get_llm_params
from app.web import web_server 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 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"]: for key in ["llm", "paper_trading", "live_trading", "price_streamer", "notification", "email", "bootstrap_admin", "scheduler"]:
delete_config("system", key) delete_config("system", key)
@ -186,8 +186,34 @@ def test_runtime_config_api_seeds_all_system_defaults_when_listing():
assert resp.status_code == 200 assert resp.status_code == 200
keys = {x["config_key"] for x in resp.json()["items"]} 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 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): def test_llm_system_config_overrides_env_defaults(monkeypatch):