1
This commit is contained in:
parent
507d4ee99c
commit
076a15da35
@ -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=
|
||||||
|
|||||||
10
AGENTS.md
10
AGENTS.md
@ -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 endpoint;API key/secret 只通过环境变量名引用;多账户配置保存在 `live_trade_accounts`。
|
- `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,而不是只看环境变量。
|
如果要改筛选阈值、确认门槛、止盈止损、动态权重逻辑,优先检查 `rules.yaml` 和 `app/config/config_loader.py`。如果要改调度行为或系统开关,优先检查 runtime config,而不是只看环境变量。
|
||||||
|
|
||||||
## 7. 目录速览
|
## 7. 目录速览
|
||||||
|
|||||||
@ -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")),
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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): "
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user