tradingSystem/app/services/param_store.py

588 lines
43 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
"""
运行参数中心 (页面调参即时生效的唯一入口)
==========================================
纪律 (config/settings.py 头部第 3 ): 业务代码**禁止**直接读 settings 取业务参数,
一律走 ParamStore 表值 (pms_runtime_param) 优先于文件初值, 页面改完立刻生效
settings 里声明的业务参数外, 另有一批运行态开关也落同一张表 (DDL 无需新增表):
PMS_GLOBAL_BUY_HALT / PMS_GLOBAL_EXEC_HALT 全局暂停买入 / 暂停执行
PMS_BRAKE_UNTIL 组合刹车解除日 (YYYYMMDD, 0=未刹车)
PMS_HIGH_WATER 组合高水位 (刹车判定基准)
PMS_REPLAY_CURSOR 成交回放游标
PMS_RECON_STREAK 连续对账不一致天数
PMS_SECTOR_CAP_* 单行业上限 (SECTOR_CAP 命令写入)
"""
from __future__ import annotations
import logging
import threading
import time
from config.settings import settings
from app.repo import pms_repo
logger = logging.getLogger("pms.params")
CACHE_TTL = 5.0 # 秒; 页面改参后最迟 5 秒全进程可见 (worker 多进程各持一份)
# 不允许页面修改的基础设施键 (连接串等只在 .env 维护)
INFRA_PREFIX = ("PROXY_DB", "SOURCE_DB", "DB_MYSQL", "SIGNAL_REDIS", "PMS_REDIS", "PMS_WEB")
2026-07-28 15:48:57 +08:00
# 密钥: 既不可改, 也**不可读** —— 连页面快照里都不出现。
# 协议 QMT_WS_PROTOCOL.md §10.1.1 明确要求「只走 .env, 不入库、不进 ParamStore、
# 不写进代码」。它们同样以 PMS_ 开头, 不单列的话会被 _editable_keys() 当成普通业务参数:
# 页面能改 (于是私钥 seed 落进 pms_runtime_param 表), snapshot() 还会把它原文显示出来。
# 2026-09-03 补进会话票签名密钥 PMS_SESSION_SECRET: 它 2026-08 随登录一起加进 settings,
# 当时漏了这份名单, 于是参数设置页把它当普通业务参数原文显示出来 (登录中间件读的是
# settings, 功能没受影响, 但密钥不该出现在页面上)。
SECRET_KEYS = ("PMS_QMT_SIGN_SEED_HEX", "PMS_QMT_PEER_PUBKEY_B64", "PMS_SESSION_SECRET")
2026-07-28 15:48:57 +08:00
# 运行态开关: key -> (默认值, 类型, 说明)
RUNTIME_EXTRA = {
"PMS_GLOBAL_BUY_HALT": (False, bool, "全局暂停买入 (HALT_BUY 命令置位)"),
"PMS_GLOBAL_EXEC_HALT": (False, bool, "全局暂停执行 / 休假模式"),
"PMS_BRAKE_UNTIL": (0, int, "组合刹车解除日 YYYYMMDD, 0=未刹车"),
"PMS_HIGH_WATER": (0.0, float, "组合市值高水位 (刹车判定基准)"),
2026-07-29 10:01:00 +08:00
"PMS_REPLAY_CURSOR": ("", str, "成交回放游标 (trading_order.order_id)。留空=首次启动时自动对齐到当前最新成交且不追认历史; 填某个 order_id=从它之后开始; 填 ALL=从头全量回放"),
"PMS_RECON_STREAK": (0, int, "连续对账不一致天数"),
2026-07-31 16:10:58 +08:00
# 2026-07-31: 这个键漏在白名单外, 于是 ledger_service 每次写都被 set_param 拒掉
# (它只回 ok=False 不抛异常, 调用点又把返回值丢了)。后果是 prev_ymd 永远读回 0,
# 「连续 N 日」的按日推进彻底失效, 修复形同虚设。加表时**必须同步加这里**。
"PMS_RECON_STREAK_YMD": (0, int, "上次推进连续天数的交易日 YYYYMMDD (同日不重复计数)"),
# 2026-08-14: 上游信号告警面板的三个显示参数。原来只在 upstream_signals.py 里用 get_int
# 兜底、没登记, 所以设置页调不到、改一次得重新部署。注册进来后可在页面实时调 (即时生效)。
"PMS_UPSTREAM_ALERT_SCAN": (300, int, "告警扫描窗: 每轮从告警流取最近 N 条再分类限量 (窗要大于各类上限之和, 防 price_notice 等高频源把风控告警挤出最新N)"),
"PMS_UPSTREAM_ALERT_PER_CAT": (20, int, "告警每类上限: 同一类型最多显示最新 N 条 (砍单类刷屏, 不挤别的类)"),
"PMS_UPSTREAM_ALERT_MAX_AGE_MIN": (60, int, "告警时间窗(分钟): 超过此龄判为陈旧并在页面置灰标注(不删除, 风控不丢信息), 0=不限龄。与每类上限正交, 先按龄标记再每类限量"),
点股票看它今天的全部信号:按代码回扫接口、抽屉、遮罩空白四处修复(台账 055 第一件) 信号栏一行等于上游流里的一条原始消息,一只活跃票一天十来条散在好几个分组里。 现在点一下票名就能把它今天的全部信号按时间倒序摊开。 后端: - upstream_signals.by_code() 按代码回扫五个上游源。快照口径注定不全(告警每类只留 最新 20 条),到下午一只活跃票早盘的信号早被挤掉,所以这条路径分页倒序回扫(每页 500 条,上限 PMS_UPSTREAM_BYCODE_SCAN 默认 5000):两条按日期分键的流整键就是今天, 两条不分键的流扫到今天零点为止且不算截断。同源同分钟同值合并计数,跨源永不合并。 按票缓存八秒防连点,单源失败不拖垮整体且逐源标明。实况分没有时间,只进快照段不 编时间戳。只读纪律与快照相同:一律 XREVRANGE/ZREVRANGE,不建消费组、不 ACK、不写。 - GET /api/upstream/signals/{ts_code},只能点击触发,绝不进轮询。 前端: - 抽屉两段取数:先用手上的快照渲染,第一帧就有内容;同时发回扫请求,回来替换成完整 版并把口径说明从「可能不全」翻成「已回扫到今天开盘」。补全失败保留快照挂黄条带重试。 - 三处入口:信号栏每行票名(14 处,@click.stop 免得顺手翻开风控卖出的理由条)、持仓表 「今天的信号」列(连「今天没有」也可点,速览口径下的「没有」可能是假的)、消息栏两处。 - 遮罩空白四处修复:整体失败不再抹平成空数据(原来失败时页面显示「今天没有信号」, 在风控面板上这是最危险的一种谎);告警派生的三个分组补失败角标、各路空文案给失败 态让位;失败角标的样式选择器漏了信号栏;错误横幅在遮罩之上再浮一份;抽屉层级写死 3000 压住浮层 2010 与遮罩 2005,打开前先收浮层。选股计划抽屉补加载提示。 测试:新增 scripts/test_batch24_units.py(9 例),已在 run_tests.py 的清单与例数表、 test_wiring.py 的路由清单三处登记。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 14:53:29 +08:00
# 2026-09-09: 点股票看它今天全部信号 (台账 055)。快照口径只留每类最新 N 条, 到下午
# 一只活跃票早盘的信号早被挤掉; 按代码回扫是另一条路径, 上限单独可调。
"PMS_UPSTREAM_BYCODE_SCAN": (5000, int, "单票信号回扫上限: 点开一只票时每条上游流最多倒序回扫 N 条 (分页 500/次)。只在点击时触发, 不进轮询; 调大更全但更慢"),
# ── 2026-09-09 解锁重问 (台账 055): 研判驳回后的当日闸不再是死的, 输入确实变了就放一次。
# 八个键一次定死, 不按复盘读数回调 —— 要调先记台账。
"PMS_OPEN_REASK_ENABLED": (True, bool, "解锁重问总开关: 新建仓被研判驳回后, 盘中输入确实变了就解除当日闸、重问一次。关掉就回到「驳回即当天不再问」"),
"PMS_OPEN_REASK_MIN_GAP_MIN": (20, int, "距上次驳回至少多少分钟才准重问。20 分钟等于四根五分钟线, 够量能确认成形"),
"PMS_OPEN_REASK_MAX_PER_DAY": (1, int, "每只票每天最多重问几次。填 1 是硬约束: 两次以上等于取消当日闸, 而当日闸挡的是账本刷屏这个真问题"),
"PMS_OPEN_REASK_WINDOW": ("0945-1430", str, "重问时段。早于 09:45 时段折算量比不可靠; 晚于 14:30 即便放行也来不及走完提议到拍板到下单"),
"PMS_OPEN_REASK_REF_DRIFT": (0.03, float, "解锁条件二: 昨夜支撑或压力相对驳回时漂过这个比例就算底牌变了。与 PMS_OPEN_REF_DRIFT_MAX 同值同语义, 复盘能对照"),
"PMS_OPEN_REASK_UP": (0.02, float, "解锁条件三之一: 现价较驳回时至少涨这么多。低于 2% 是噪声"),
"PMS_OPEN_REASK_VOL_MIN": (1.5, float, "解锁条件三之二: 时段折算量比的绝对下限"),
"PMS_OPEN_REASK_VOL_MULT": (1.3, float, "解锁条件三之三: 量比要涨到驳回那一刻的几倍。保证是相对变强, 不是一整天都在放量"),
# 2026-09-08 就发现没登记, 代码一直走默认值 60, 页面上改不了也写不进去。补登记。
"PMS_JUDGE_REJECT_CONF_MIN": (60, int, "新建仓的低把握驳回转交人: 研判驳回但把握度低于这个数时, 按证据不足进你的待确认队列, 不记驳回"),
2026-08-19 10:04:20 +08:00
# 2026-08-18 宏观择时层: 闸状态由 macro_service 每次扫描写入, 扫描层与策略层只读。
# 用运行参数承载 (不进策略 state、不另建表) —— 与 PMS_STRATEGY_BUYPAUSE 同一手法。
"PMS_MACRO_GATE_STATE": ("", str, "宏观闸状态 (macro_service 维护的 JSON: active/ymd/原因), 页面与扫描层只读, 勿手改"),
2026-08-26 10:11:29 +08:00
# 2026-08-26: 页面要能回答「今天的正式一跳跑没跑、做了什么」。零动作的一跳在账上没有
# 任何痕迹 (设计如此, 台账只记真动作), 所以由 advisor 每次真跑后写一份摘要在这里。
"PMS_AUTO_LAST_SCAN": ("", str, "自动挂载最近一次正式扫描的摘要 (advisor 真跑后写入的 JSON: 日期/时刻/各类计数), 页面只读, 勿手改"),
2026-08-28 15:45:30 +08:00
# 2026-08-28 公示导出: 每日持仓/净值导出为公司《量化数据》公示表 (页面按钮 + 15:20
# 净值快照)。账户名/结构/标题是公示口径的固定文案, 不进账务, 放这里页面可改。
"PMS_PUBLISH_TITLE": ("25年投研量化平层产品基本信息", str, "公示表标题 (首行大字)"),
"PMS_PUBLISH_ACCOUNT": ("天盟实业", str, "公示表·开单账户列的账户名"),
"PMS_PUBLISH_STRUCTURE": ("二级(平层)", str, "公示表·结构列的产品结构标签"),
"PMS_PUBLISH_NAV_SCALE": (0.0, float, "公示净值规模 (元): 净值=1+累计盈亏/此数; 0=用 PMS_TOTAL_SCALE"),
# 2026-09-07 第三件: 选股系统的逻辑状态四态在 PMS 的三个开关与一份运行态映射 (模块见
# logic_state_service.py)。映射与 PMS_MACRO_GATE_STATE 同一手法: 早上写一次, 扫描层与页面只读。
"PMS_LOGIC_STATE_ROUTE": (True, bool, "按选股系统的逻辑状态分流: 逻辑存疑的持仓停增持侧自主动作并暂停策略买入腿, 判决候选而逻辑存疑的新建仓强制人工确认 (默认开; 关掉即回到只按判决)"),
"PMS_LOGIC_DOUBT_TRIM": (False, bool, "逻辑存疑的持仓出减持提议 (必定交人裁决, 绝不自动卖; 默认关, 先跑停增持一个月看误伤再开)"),
"PMS_LOGIC_DOUBT_TRIM_RATIO": (0.3333, float, "逻辑存疑减持提议的比例 (占总持仓; 默认与保垫减仓相同的三分之一; 一次定死, 不按复盘读数回调)"),
# 2026-09-09 接入方案 (台账 053): 公司深度与建议方案的三个开关。
"PMS_JUDGE_COMPANY_TEXT": (True, bool, "新建仓研判送公司深度整句 (质地档、买方论点、置信度、失效条件); 关掉只给人看"),
"PMS_PLAN_BY_ADVICE": (True, bool, "建议方案直接作为新建仓提议的仓位档与分批 (模拟侧默认开; 关掉只并排显示, 提议照机械方案)"),
"PMS_ADVICE_TRIAL_CONF_MIN": (60, int, "研判把握度低于此值 (或不可用) 时建议方案降为试探仓, 只在人工确认后下"),
"PMS_LOGIC_STATE_MAP": ("", str, "在持票的逻辑状态映射 (plan_pull 每早写入的 JSON: 时刻/日期/逐票状态), 扫描层与页面只读, 勿手改"),
# ── 2026-09-11 技术面接入 (台账 008): 取数 / 落表 / 映射的开关与阈值。取数接口字段见
# BIONIC_PMS_INTERFACE.md 技术面只读接口一节; 相位合成在 core/tech_rules.py。五个阈值一次
# 定死 (读数两天新鲜、回看 5 日、带宽扩 20%、20 日翻向 4 次), 要调先记台账。盘中放量确认的
# 量比阈值属工作包二三, 随那几批登记, 这里不放。
"PMS_TECH_ENABLED": (True, bool, "技术面总闸: 关掉即不拉取、不挂读数、不设门、不出退出, 行为逐字如旧"),
"PMS_TECH_API_BASE": ("", str, "技术面只读接口根地址; 留空=沿用 PMS_JUDGE_API_BASE (同一台决策系统)"),
"PMS_TECH_API_PATH": ("/api/v1/market/technical", str, "技术面接口路径 (bionic 侧全市场读数)"),
"PMS_TECH_TIMEOUT": (20, int, "技术面取数超时 (秒); 分页逐页, 任一页失败整轮不落表"),
"PMS_TECH_KEEP_DAYS": (40, int, "pms_tech_daily 保留多少个交易日 (落表后删更旧的; 判震荡要 20 日, 留足余量)"),
"PMS_TECH_STALE_TDAYS": (2, int, "技术面读数超过这么多个交易日没更新就按无读数 (不拿旧读数当今天)"),
"PMS_TECH_SQUEEZE_LOOKBACK": (5, int, "开口判定回看根数: 近这么多个交易日内收过口才算「刚收口过」"),
"PMS_TECH_OPEN_BW_GROWTH": (0.20, float, "开口判定: 今日带宽比收口那天至少扩这么多 (0.20=扩 20%)"),
"PMS_TECH_CHOPPY_FLIPS": (4, int, "震荡市判定: 最近 20 个交易日 SAR 翻向达到这么多次即震荡, 翻向不当信号"),
"PMS_TECH_FLIP_FRESH_DAYS": (2, int, "SAR 翻向后这么多个交易日内算「刚转向」(转多/转空相位用)"),
"PMS_TECH_STATE_MAP": ("", str, "技术面读数映射 (tech_pull 与 08:40 补拉写入的 JSON: 时刻/读数日/逐票紧凑立场), 扫描层与页面只读, 勿手改"),
2026-07-31 16:10:58 +08:00
}
# **读不到时必须按"已暂停"处理的键 (fail-closed)。**
# 这几个开关只活在表里 (settings.py 里根本没有), 所以 `get()` 读不到时会退回
# RUNTIME_EXTRA 的默认值 False/0 —— 而那恰好等于"放行"。参数表抖一下 (celery worker
# 冷启、连库超时), 全局暂停买入、休假模式、组合刹车就一起静默解除, 且没有任何调用方
# 看得出这是降级。安全开关的默认方向必须是"拦", 不是"放"。
FAIL_CLOSED = {
"PMS_GLOBAL_BUY_HALT": True, # 读不到 → 当作已暂停买入
"PMS_GLOBAL_EXEC_HALT": True, # 读不到 → 当作已暂停执行
2026-08-06 13:55:27 +08:00
# 自主新建仓的默认值是 full (无人值守地开新仓), 所以它读不到时的安全方向格外要紧:
# 参数表抖一下就按 full 走, 等于在读不到任何约束的情况下自己去买没买过的票。
# 读不到 → off (本轮不扫描新建仓), 已有持仓的四类动作不受影响。
"PMS_OPEN_AUTONOMY": "off",
2026-08-19 10:04:20 +08:00
# 宏观择时总开关的文件初值是 True (模拟仓拍板) —— 表读不到时若退文件初值, 宏观层会在
# 基础设施故障期间照常想下命令。安全方向是关: 宁可少动一轮。取数环节同库先挂是第一道
# 保险, 这条名单是第二道。
"PMS_MACRO_ENABLED": False,
2026-08-25 13:45:51 +08:00
# 2026-08-25 策略自动挂载: 同宏观层的理由 —— 文件初值 True (模拟仓拍板全自动),
# 表读不到时退文件初值等于故障期间照常自动挂策略。安全方向是关, 本轮不挂不撤。
"PMS_AUTO_STRATEGY_ENABLED": False,
}
# ================================================================================
# 页面能改、但代码里没有任何地方读它的参数 (2026-09-03 全库清点)
# ================================================================================
# 清点方法, 想复核照做即可: 拿 DESC / RUNTIME_EXTRA / settings 里 PMS_ 开头、页面能改的
# 全部键名 (当日 179 个), 逐个在 app/ 下做整词检索, 并把两类**不算读取**的文本剔掉 ——
# 一是本文件里 DESC、RUNTIME_EXTRA、SECRET_KEYS、FAIL_CLOSED、INFRA_PREFIX 这几处声明
# (它们只是登记), 二是全库的注释与文档字符串 (说到不等于读到; PMS_T0_CLOSE_TIME 当初就是
# 靠一句文档字符串把自己藏了一年)。剩下零命中的就是「能改但不生效」。
# 只在 sizing_params() 里出现的键还要再跟一层短名 (它把 PMS_XXX 折成 batch_split 这类短名
# 交给纯逻辑层), 当日三个 (分批比例、一手合并、清弱票天数) 都跟到了真实读取处, 不算在内。
# 这份清点由 scripts/test_batch10_units.py 的 [P] 组守着 —— 名单变了单测就红, 逼人来这里
# 记一笔, 不让新的「改了不生效」再悄悄长出来。
#
# 一、参数表里零读取处的四个 (页面可改, 改了什么都不会发生):
# PMS_REPLAY_INTERVAL_MIN 说明写「成交回放间隔 (分钟)」, 而回放的调度位在 scheduler.py
# 里写死是 crontab(minute="*") 每分钟一跳, 这个数根本没人读。
# PMS_T0_CLOSE_TIME 说明写「T仓强制平回时点」, 同一种毛病: 调度位写死在
# crontab(hour=14, minute=50)。页面把它改成 14:30 不会有任何
# 效果, 而这是一条与过夜风险直接相关的时点。
# 以上两个要么让调度位读它, 要么把它连同说明一起摘掉 —— 一条
# 时点参数摆在页面上而调度位不认, 比没有这条参数更危险。
# PMS_RISK_WARN_ENTRY 单笔敞口告警线。sizer.risk_warnings() / risk_exposure() 这对
# PMS_RISK_WARN_PORTFOLIO 函数写完之后**从来没有被 app/ 里任何地方调用过** (只有单测在
# 调), 于是这两条披露线是两个纯粹的摆设。风险披露要不要真做,
# 是一件要拍板的事, 不是改个数就能生效的事。
#
# 二、个股参数命令里同一种毛病 (不在本表, 但病因一样, 一并记在这里):
# 设定某股目标价 SET_TARGET_PRICE 2026-09-03 已接通 —— 动作引擎 eval_target 到价产出
# 清仓提议, 必定交人拍板。接通前它只存进命令表没人读。
# 设定某股止损价 SET_STOP_PRICE 2026-09-03 已接进规则闸的告警层 (只披露不拦截,
# 不自动卖)。它投影到持仓行的 stop_ref 每天盘前会被
# ledger_service.premarket() 的参考位取数覆盖掉,
# 所以判断一律读命令表 (事实源), 别读投影。
# 这处覆盖是系统自算参考位的正常行为, 保留不动;
# 由此带来的页面错位已在 2026-09-03 修掉显示那一侧:
# 用户设的止损价与目标价由 command_service
# .attach_user_prices 从命令表单独取出来, 与系统参考位
# 分列显示, 两张持仓表都看得到自己设的价。
# 做T授权 T0_ENABLE 只把 t0_enabled / t0_ratio 投影到持仓行给页面显示,
# 没有任何代码把它当成「可以做T」的授权来检查 (真正的
# 做T由单独挂的交易方案驱动)。要不要把它变成硬授权,
# 需要拍板, 本次不动。
#
# 页面展示用的中文说明 (settings.py 用行尾注释, pydantic 取不到, 故在此集中维护)
DESC = {
"PMS_TOTAL_SCALE": "总操作规模 (元) —— 所有百分比约束的分母",
"PMS_PORTFOLIO_CAP": "总仓上限 (占规模)", "PMS_STOCK_CAP": "单股上限 (占规模)",
"PMS_STOCK_TARGET_DEFAULT": "默认单股目标仓位", "PMS_MAX_NAMES": "最大持仓只数",
"PMS_CASH_RESERVE": "预留现金比例 (永不动用, 与总仓上限双重约束)",
"PMS_AUTONOMY": "自主档位 full / propose_only / off",
"PMS_BATCH_SPLIT": "单股分批比例 (底仓/回踩补足/盈利加仓)",
"PMS_CUSHION_SOLID": "安全垫厚垫线 (解锁盈利加仓)",
"PMS_TRIM_PEAK": "保垫减仓: 垫子峰值门槛", "PMS_TRIM_GIVEBACK": "保垫减仓: 回吐比例门槛",
"PMS_DCA_TRIGGERS": "补仓评估档 (浮亏)", "PMS_DCA_DEEP_CONFIRM": "此档及更深永远需用户确认",
"PMS_DCA_MAX_RATIO": "补仓上限 (占底仓)", "PMS_NO_CHASE_MA5": "距 MA5 超此幅度不追买",
"PMS_BUILD_WINDOW_TDAYS": "建仓期窗口 (交易日)",
"PMS_FILL_MAX_LOSS": "浮亏深于此不走回踩补足",
"PMS_WEAK_NEG_DAYS": "降仓清弱票: 安全垫连续为负天数",
"PMS_PROPOSAL_TTL_HOURS": "自主提议待确认有效期 (小时)",
"PMS_CANDIDATE_SOURCE": "升仓候选池来源: plan_api=上游选股计划接口 / buy_plan=旧 trading_buy_plan 表 / both=并集",
"PMS_PLAN_API_BASE": "上游选股计划接口根地址; 留空=停用, 此时候选池恒为空并告警",
"PMS_PLAN_API_PATH": "计划接口路径 (默认 /plan)",
"PMS_PLAN_TIMEOUT": "计划接口超时 (秒)",
"PMS_PLAN_CACHE_SEC": "计划缓存秒数 (上游日频产出)",
"PMS_PLAN_TOP": "发给上游的 top: 主榜要多少条 (上游默认 20; 0=不传)",
"PMS_PLAN_OBS_TOP": "发给上游的 obs_top: 观察档要多少条 (上游默认 10; 0=不传)",
"PMS_PLAN_THEME_CAP": "发给上游的 theme_cap: 上游侧同主题限额 (上游默认 5; 设大值=让上游别裁, 由 PMS 自己裁; 0=不传)",
"PMS_PLAN_THEME_CAP_LOCAL": "PMS 侧同主题限额, 在 TOP_N 截断之前按 score 序生效, 0=不限。防止宽池子里前 N 名被单一主题垄断、切完再被规则闸拦掉",
"PMS_PLAN_TOP_N": "主榜按 score 降序取前 N 只进候选池",
"PMS_PLAN_TIERS": "传导档白名单 (如 强传导), 留空=不按档过滤",
"PMS_PLAN_INCLUDE_OBSERVE": "观察档是否进候选池",
"PMS_PLAN_MIN_SCORE": "候选 score 下限, 0=不设",
"PMS_PLAN_MIN_SOURCES": "候选 evidence.n_sources 下限, 0=不设",
"PMS_PLAN_MIN_UPSIDE": "候选预期空间下限 (相对现价, 0.5=+50%), 0=不设。只过滤不参与排序; 设了就会把 upside 缺失的行(含整个观察档)一起挡掉",
"PMS_PLAN_STALE_TDAYS": "计划日龄超此交易日数即判过期拒用 (防上游停更时拿旧榜当今天)",
"PMS_PLAN_EXCLUDE_ST": "剔除 ST/*ST/退市整理 (上游名单明确不剔除, 这道闸在 PMS 侧)",
"PMS_PLAN_THEME_SYNC": "把 theme 灌进 pms_industry_map。默认关 —— theme 是事件驱动的传导主题(覆盖约三成、词表不规范), 当行业标签会让集中度约束天天漂; 行业源请用 gp_stock_category",
"PMS_PLAN_QUERY_EXTRA": "计划接口附加查询串逃生口 (上游加了新参数时不用改代码); top/obs_top/theme_cap 请用各自的显式参数, 同名键以显式参数为准",
"PMS_SECTOR_SOURCE": "行业划分数据源: 空=约束停用 / gp_hybk (199 库行业板块表, 当前口径) / custom_table (自己灌数) / gp_stock_category (实测不可用, 勿选)",
"PMS_SECTOR_HYBK_LEVEL": "gp_hybk 取哪一级: l3=三级 bk_code 884* (默认) / l2=二级 881*",
"PMS_SECTOR_MAX_NAMES": "同行业最大持仓只数 (硬拦截)",
"PMS_SECTOR_MAX_RATIO": "同行业最大占总仓比例 (硬拦截)",
"PMS_EXEC_WINDOW_TDAYS": "任务命令默认执行窗口 (交易日)",
2026-08-03 12:49:01 +08:00
"PMS_EXEC_IMPL": "择时实现: B=内置保守择时 / A=委托决策系统按凌晨结论给执行区间 (不可用自动退B)",
"PMS_EXEC_API_BASE": "择时实现A接口根地址; 留空=沿用 PMS_JUDGE_API_BASE",
"PMS_EXEC_PATH": "择时接口路径 (bionic 侧 /api/intraday/pms_exec)",
"PMS_EXEC_TIMEOUT_SEC": "择时咨询超时 (秒), 超时本轮退实现B",
"PMS_EXEC_ADVICE_TTL_MIN": "择时应答有效期 (交易分钟), 期内不重复咨询",
"PMS_EXEC_FAIL_COOLDOWN_MIN": "择时咨询失败后的冷却 (交易分钟), 冷却内直接走B",
"PMS_EXEC_LIMIT_BAND": "建议价偏离现价超此幅度视为异常数据改用本地口径 (建议价=区间边缘, 偏离几个点属正常)",
"PMS_SELL_AVOID_OPEN_MIN": "卖出避开开盘 N 分钟",
"PMS_BUY_HALT_DAYUP": "当日涨幅超此停止买入 (不追高)",
"PMS_EOD_FORCE_TIME": "当日配额兜底时点", "PMS_EOD_FORCE_DISCOUNT": "兜底限价系数 (卖出)",
"PMS_MIN_LOT_MERGE": "一手检查: 批次自动合并",
"PMS_DISPATCH_EXPIRE_MIN": "指令下发后未被接受的过期时间 (分钟)",
2026-07-29 10:01:00 +08:00
"PMS_DISPATCH_MODE": "下发通道: shadow=只记账待人工 / ws=WebSocket 直连 QMT (需先起 pms-ws)",
"PMS_EXEC_SLICES": "当日配额分几笔出手",
2026-07-28 15:48:57 +08:00
"PMS_ORDER_TTL_MIN": "单笔挂单有效期 (交易分钟), 到点下游自动撤; 兜底单不受限",
2026-08-17 15:09:37 +08:00
"PMS_SELL_BUCKET_TIMES": "卖出分桶收口时点 (逗号分隔, 如 11:30,14:00): 桶内照旧择价, 桶收口无条件补齐该桶份额, 把跌日的强制卖出从 14:45 一根针分散到多个时点; 留空=不分桶回到旧行为",
"PMS_URGENT_SELL_DISCOUNT": "紧急卖出限价系数 (一键清仓/高置信风控清仓直通出手, 比兜底 0.998 更激进, 每分钟按新现价重定)",
"PMS_RISK_WARN_ENTRY": "单笔敞口告警线 (占规模)", "PMS_RISK_WARN_PORTFOLIO": "组合敞口告警线",
"PMS_BRAKE_DRAWDOWN": "组合刹车: 自高水位回撤", "PMS_BRAKE_DAYS": "刹车持续交易日",
"PMS_STOP_ATR_MULT": "自算止损参考: 成本 N×ATR",
"PMS_REF_STALE_TDAYS": "决策系统结论日龄超此转自算兜底",
2026-08-06 13:55:27 +08:00
"PMS_OPEN_AUTONOMY": "自主新建仓档位兼总开关 full / propose_only / off "
"(与 PMS_AUTONOMY 分开: 新建仓要不要人点头是另一个决定)",
"PMS_OPEN_REQUIRE_WS_CASH": "拿不到 ws 资金快照时不自动新建仓 (只影响新建仓, 其余动作照旧)",
"PMS_OPEN_SIGNAL_PRIORITY": "今天被决策系统判过盘中转多的候选票排最前 (只影响先后, 不影响资格)",
2026-08-06 13:55:27 +08:00
"PMS_OPEN_REF_DRIFT_MAX": "参考位盘中被改写超此幅度 → 该票当日暂停新建仓",
"PMS_PLAN_ROUTE_BY_VERDICT": "按选股系统的判决分流自主新建仓: 候选走自动流程 / 关注强制人工确认 / "
"仅展示不出提议 (上游行无 verdict 或本开关关着时按档位, 与旧行为一致)",
"PMS_OPEN_AUTO_EXEC_ON_VERDICT": "propose_only 档位下, 判决为候选 + 决策系统研判通过 + 规则闸通过 + "
"上游风险列表为空 的新建仓提议直接落指令 (默认关; 其余仍入队等人裁决)",
"PMS_JUDGE_REJECT_CONF_MIN": "新建仓被决策系统驳回时, 把握度低于这个数 (0~100) 的驳回按「证据不足」"
"交人裁决而不是记驳回 (默认 60; 只对新建仓; 一次定死, 不按复盘读数回调)",
2026-08-06 13:55:27 +08:00
"PMS_JUDGE_TICK_BUDGET_SEC": "单轮提议扫描用于研判的时间预算 (秒), 用尽则剩下的候选留到下一跳",
"PMS_JUDGE_ENABLED": "研判闸开关", "PMS_JUDGE_ACTIONS": "需过研判闸的动作",
"PMS_JUDGE_TIMEOUT": "研判超时 (秒) → 降级 propose_only",
"PMS_JUDGE_API_BASE": "决策系统 PMS 研判接口根地址; 留空=未接通, 自动降级人工确认",
"PMS_JUDGE_PATH": "研判接口路径 (bionic 侧配套改造后确定)",
"PMS_T0_RATIO_MAX": "T 仓硬上限 (占持仓)", "PMS_T0_PULLBACK_PCT": "正T: 距当日高点回落触发",
"PMS_T0_RALLY_PCT": "反T: 日内涨幅触发", "PMS_T0_ROUND_TARGET": "单次T目标价差",
"PMS_T0_CLOSE_TIME": "T仓强制平回时点", "PMS_T0_STOCK_DAY_LOSS": "单票当日T亏熔断",
"PMS_T0_GLOBAL_DAY_LOSS": "全局当日T亏熔断",
2026-08-11 10:35:03 +08:00
"PMS_STRATEGY_ENABLED": "个股交易方案(策略)层总开关 (关=strategy_runner 空转)",
2026-08-25 13:45:51 +08:00
"PMS_AUTO_STRATEGY_ENABLED": "策略自动挂载总开关 (个股打法状态机; 关=09:40 调度位空转不挂不撤)",
2026-08-25 14:07:42 +08:00
"PMS_AUTO_STRATEGY_RULES": "启用的规则 (逗号分隔): accum_grid 吸筹挂网格 / heat_trail 热度挂止盈 / accum_exit 转派发暂停买入 / handoff 网格换止盈接力",
2026-08-25 13:45:51 +08:00
"PMS_AUTO_STRATEGY_DAILY_MAX": "每日自动新挂上限 (只数; 接力换挂是换不是增, 不计入)",
"PMS_AUTO_ACCUM_STALE_TDAYS": "吸筹结论日龄超此 (交易日) 视为过期不触发",
"PMS_AUTO_GRID_BAND": "网格区间退化带宽: 支撑/压力锚不住时用 现价×(1±带宽)",
"PMS_AUTO_GRID_STEP_PCT": "自动网格步长 (占中枢价)",
"PMS_AUTO_GRID_CAP_RATIO": "自动网格最大投入占单股上限余量的比例",
"PMS_AUTO_HEAT_TH": "热度阈值 0~1 (2026-08-25 实测: ≥0.80 约取全市场前 4%, 194 只)",
"PMS_AUTO_TRAIL_GIVEBACK": "自动跟踪止盈: 距高水位回撤触发比例",
"PMS_AUTO_TRAIL_SELL_RATIO": "自动跟踪止盈: 触发时卖出比例",
"PMS_AUTO_HANDOFF_ENABLED": "边四接力独立开关 (自动网格站上区间上界且热度达标→撤网格换挂止盈)",
"PMS_AUTO_HANDOFF_COOLDOWN_TDAYS": "接力后同票网格边冷却 (交易日; 防区间上沿震荡把两种策略翻来覆去换)",
"PMS_AUTO_OPTOUT_COOLDOWN_TDAYS": "人工撤下自动策略后同票同规则冷却 (交易日; 尊重人的否决, 防人机拉锯)",
"PMS_REPLAY_INTERVAL_MIN": "成交回放间隔 (分钟)", "PMS_RECON_ALARM_DAYS": "连续不一致升级天数",
"PMS_SIGNAL_ENABLED": "是否消化决策系统盘中信号",
"PMS_SIGNAL_GROUP": "信号消费组名 (独立于 trading_service, 互不抢消息)",
"PMS_SIGNAL_SELL_CONF_MIN": "卖出信号消化门槛 (低于此不动)",
"PMS_SIGNAL_AUTO_EXIT_CONF": "卖出信号直接清仓门槛 (之间则落提议)",
"PMS_SIGNAL_TRIM_RATIO": "中等置信度卖出信号的减仓比例",
2026-07-28 15:48:57 +08:00
"PMS_QMT_WS_URL": "QMT 执行服务 WebSocket 端点",
"PMS_QMT_WS_ENABLED": "pms-ws 常驻进程总开关 (关=空转不连接, 此时一律拒发)",
"PMS_QMT_ACK_BATCH": "累积确认: 每落库 N 条发一次 ack_seq",
"PMS_QMT_ACK_INTERVAL_SEC": "累积确认: 或每 N 秒发一次 (与条数取先到)",
"PMS_QMT_HEARTBEAT_SEC": "协议 ping 间隔 (秒)",
"PMS_QMT_IDLE_TIMEOUT_SEC": "超过此秒数未收到对端任何消息即断开重连",
"PMS_QMT_OUTBOX_POLL_SEC": "出口队列轮询间隔 (秒)",
"PMS_QMT_HEARTBEAT_DB_SEC": "ws 进程写存活心跳的间隔 (秒)",
"PMS_QMT_HEARTBEAT_STALE_SEC": "心跳陈旧超此秒数 → 判定 ws 进程已死, 指令一律拒发",
"PMS_QMT_CONNECT_TIMEOUT_SEC": "建连超时 (秒)",
"PMS_QMT_SEND_MAX_ATTEMPTS": "单张委托发送重试上限, 试满置 SEND_FAILED 等人工",
2026-08-19 10:04:20 +08:00
"PMS_MACRO_ENABLED": "宏观择时总开关 (关=调度位空转不取数不计算; 模拟仓默认开, 转实盘前改回关)",
"PMS_MACRO_AUTONOMY": "宏观档位 off 只算不动 / propose_only 出建议 / full 自动下命令 (模拟仓默认 full)",
"PMS_MACRO_SIGNALS": "启用的宏观信号清单 (逗号分隔, 当前仅 hedge_fx 股汇对冲指数)",
"PMS_MACRO_STOCK_GATE": "个股宏观闸: 偏热确认期间暂停自主增持 (OPEN/FILL/ADD/DCA) 与策略买开腿; 减持与用户命令不受限",
"PMS_MACRO_HOT_TH": "偏热进入阈值 (指数点; 标定 ±25 约每年 1.4 段)",
"PMS_MACRO_COLD_TH": "偏冷进入阈值 (负数; 标定 -20 约每年 4 段, 事后反弹更强)",
"PMS_MACRO_EXIT_BAND": "迟滞退出带 (|指数| 回落到此内算离区; 偏热侧它同时是降仓命令触发线)",
"PMS_MACRO_CONFIRM_DAYS": "偏冷进区连续 N 个交易日才触发 (标定: 等第二天反而更差, 取 1)",
"PMS_MACRO_TRIGGER_HOT": "偏热触发口径: zone_exit=极值回落再降仓 (标定采纳) / zone_enter=进区即降",
"PMS_MACRO_TRIGGER_COLD": "偏冷触发口径: zone_enter=进区即升仓 (标定采纳) / zone_exit=回落再升",
"PMS_MACRO_LOG_S0": "对数映射系数: 周期累计目标 = S0×ln(1+超额深度/k), 标定回填 0.049",
"PMS_MACRO_LOG_K": "对数映射尺度 k, 标定回填 1.6",
"PMS_MACRO_SHIFT_MAX": "单个极值周期累计调整封顶 (占规模百分点)",
"PMS_MACRO_STEP_MIN": "裁剪后步长小于此不动; 也是偏冷确认当日的首步保底",
"PMS_MACRO_COOLDOWN_TDAYS": "同方向两次宏观动作最小间隔 (交易日; 零方案被取消的命令不占冷却)",
"PMS_MACRO_MIN_PCT": "宏观降仓地板: 自动降仓不把总仓位降到此线以下 (用户命令不受限)",
"PMS_MACRO_MAX_PCT": "宏观升仓天花板; 0=不单设, 由总仓上限 PMS_PORTFOLIO_CAP 兜底",
"PMS_MACRO_WINDOW_TDAYS": "宏观命令的执行窗口 (交易日)",
"PMS_MACRO_RESPECT_BRAKE": "组合刹车期间不自动升仓 (降仓不受限; 方案唯一假设项, 可放开)",
"PMS_MACRO_STALE_TDAYS": "任一数据源日龄超此 (交易日) → 信号不可用, 不动作不落闸",
"PMS_MACRO_RET_WIN": "股汇收益计算窗口 (交易日)",
"PMS_MACRO_Z_WIN": "滚动标准化窗口 (交易日)",
"PMS_MACRO_SHIBOR_BETA": "利率修正系数 β (spread β×SHIBOR1M 二十日变化)",
}
# loaded 标记必不可少: 不能用「data 是否为空」判断缓存是否有效 ——
# 参数表为空 (还没在页面改过参数) 或读表失败时 data 都是 {}, 那样每次 get() 都会穿透去连库,
# 健康时是白白打表, 故障时是刷屏重试。实测 check_db 一次跑出 50 条重复告警即此故。
_cache = {"at": 0.0, "data": {}, "error": None, "loaded": False}
_lock = threading.Lock()
def _editable_keys() -> dict:
2026-07-28 15:48:57 +08:00
"""可调业务参数 = settings 中 PMS_ 开头、非基础设施、且非密钥的字段。"""
out = {}
for name, field in type(settings).model_fields.items():
if not name.startswith("PMS_"):
continue
2026-07-28 15:48:57 +08:00
if any(name.startswith(p) for p in INFRA_PREFIX) or name in SECRET_KEYS:
continue
out[name] = field
return out
def _coerce(value, target_type):
if target_type is bool:
if isinstance(value, bool):
return value
return str(value).strip().lower() in ("1", "true", "yes", "on", "y")
if target_type is int:
return int(float(value))
if target_type is float:
return float(value)
return str(value)
def _type_of(key: str):
f = _editable_keys().get(key)
if f is not None:
return f.annotation
if key in RUNTIME_EXTRA:
return RUNTIME_EXTRA[key][1]
return str
def refresh(force: bool = False) -> dict:
"""拉一次表值 (带 TTL 缓存)。连库失败不抛异常 —— 退回文件初值并记 error。"""
now = time.time()
if not force and _cache["loaded"] and now - _cache["at"] < CACHE_TTL:
return _cache["data"]
with _lock:
try:
rows = pms_repo.all_params()
_cache.update({"data": rows, "at": now, "error": None, "loaded": True})
except Exception as e:
msg = f"{type(e).__name__}: {e}"
changed = _cache.get("error") != msg
# 失败也要更新时间戳并置 loaded, 否则下一次 get() 立刻又去连库
_cache.update({"at": now, "error": msg, "loaded": True})
if changed: # 同一个错误只吼一次, 恢复或换错才再吼
logger.warning("参数表读取失败, 退回 settings 初值: %s", e)
return _cache["data"]
def get(key: str, default=None):
2026-07-28 15:48:57 +08:00
"""取参数当前值: 表值优先 → settings 初值 → RUNTIME_EXTRA 默认 → default。
密钥一律返回空串 想拿签名密钥只有一条路: 直接读 settings ( .env)
堵死这里是为了让密钥不进 ParamStore这句话在代码里成立, 而不只是写在文档上
"""
if key in SECRET_KEYS:
return ""
rows = refresh()
t = _type_of(key)
2026-07-31 16:10:58 +08:00
# 表读不到时, 安全开关按"拦"而不是按默认值放行 —— 见 FAIL_CLOSED 的注释。
# 只在**表确实读失败**时生效; 表读到了而这个键没设过, 那是真的没设, 照常走默认值。
if _cache.get("error") and key in FAIL_CLOSED and key not in rows:
logger.error("[参数] 读不到 %s (参数表: %s) —— 按 fail-closed 取 %r, "
"宁可多拦一轮也不放行", key, _cache["error"], FAIL_CLOSED[key])
return FAIL_CLOSED[key]
if key in rows:
try:
return _coerce(rows[key]["param_value"], t)
except (TypeError, ValueError):
logger.warning("参数 %s 表值非法 (%s), 退回初值", key, rows[key]["param_value"])
if hasattr(settings, key):
return getattr(settings, key)
if key in RUNTIME_EXTRA:
return RUNTIME_EXTRA[key][0]
return default
def get_float(key, default=0.0):
try:
return float(get(key, default))
except (TypeError, ValueError):
return default
def get_int(key, default=0):
try:
return int(float(get(key, default)))
except (TypeError, ValueError):
return default
def get_bool(key, default=False):
v = get(key, default)
return v if isinstance(v, bool) else str(v).strip().lower() in ("1", "true", "yes", "on")
def get_list(key, default=None, sep=","):
v = get(key, None)
if v in (None, ""):
return list(default or [])
return [x.strip() for x in str(v).split(sep) if x.strip()]
def get_tuple_floats(key, default=(0.5, 0.25, 0.25)):
try:
vals = tuple(float(x) for x in str(get(key, "")).split(",") if str(x).strip())
return vals or tuple(default)
except (TypeError, ValueError):
return tuple(default)
def set_param(key: str, value, updated_by: str = "user") -> dict:
"""页面改参入口。校验键名与类型后落表, 并立即失效缓存。"""
editable = _editable_keys()
if key not in editable and key not in RUNTIME_EXTRA and not key.startswith("PMS_SECTOR_CAP_"):
return {"ok": False, "error": f"参数 {key} 不可修改 (基础设施参数只在 .env 维护)"}
t = _type_of(key)
try:
v = _coerce(value, t)
except (TypeError, ValueError):
return {"ok": False, "error": f"参数 {key} 类型应为 {getattr(t, '__name__', t)}, "
f"收到 {value!r}"}
bad = _range_check(key, v)
if bad:
return {"ok": False, "error": bad}
try:
pms_repo.set_param(key, v, updated_by)
except Exception as e:
return {"ok": False, "error": f"写入失败: {type(e).__name__}: {e}"}
_cache["loaded"] = False
refresh(force=True)
return {"ok": True, "key": key, "value": v}
_RANGES = {
"PMS_PORTFOLIO_CAP": (0, 1), "PMS_STOCK_CAP": (0, 1), "PMS_STOCK_TARGET_DEFAULT": (0, 1),
"PMS_CASH_RESERVE": (0, 1), "PMS_CUSHION_SOLID": (0, 1), "PMS_TRIM_GIVEBACK": (0, 1),
"PMS_DCA_MAX_RATIO": (0, 1), "PMS_SECTOR_MAX_RATIO": (0, 1), "PMS_T0_RATIO_MAX": (0, 0.3334),
"PMS_MAX_NAMES": (1, 200), "PMS_TOTAL_SCALE": (0, 10 ** 12),
"PMS_EXEC_WINDOW_TDAYS": (1, 20), "PMS_BRAKE_DAYS": (0, 30),
2026-08-03 12:49:01 +08:00
"PMS_EXEC_TIMEOUT_SEC": (1, 60), "PMS_EXEC_ADVICE_TTL_MIN": (1, 120),
"PMS_EXEC_FAIL_COOLDOWN_MIN": (1, 120), "PMS_EXEC_LIMIT_BAND": (0, 0.2),
"PMS_PLAN_TOP_N": (1, 1000), "PMS_PLAN_TIMEOUT": (1, 120),
"PMS_PLAN_CACHE_SEC": (5, 86400), "PMS_PLAN_STALE_TDAYS": (0, 20),
"PMS_PLAN_MIN_SOURCES": (0, 100), "PMS_PLAN_TOP": (0, 5000),
"PMS_PLAN_OBS_TOP": (0, 5000), "PMS_PLAN_THEME_CAP": (0, 5000),
"PMS_PLAN_THEME_CAP_LOCAL": (0, 1000),
2026-08-06 13:55:27 +08:00
"PMS_OPEN_REF_DRIFT_MAX": (0, 0.5), "PMS_JUDGE_TICK_BUDGET_SEC": (0, 240),
# 上限 240 = scheduler 给调度任务设的软超时。填得比它还大, 预算就形同虚设,
# 任务会先被 celery 打死 (而且是在已经落了一部分表之后)。
"PMS_UPSTREAM_ALERT_SCAN": (1, 20000), "PMS_UPSTREAM_ALERT_PER_CAT": (1, 1000),
"PMS_UPSTREAM_ALERT_MAX_AGE_MIN": (0, 1440), # 0=不限龄, 上限 24 小时
点股票看它今天的全部信号:按代码回扫接口、抽屉、遮罩空白四处修复(台账 055 第一件) 信号栏一行等于上游流里的一条原始消息,一只活跃票一天十来条散在好几个分组里。 现在点一下票名就能把它今天的全部信号按时间倒序摊开。 后端: - upstream_signals.by_code() 按代码回扫五个上游源。快照口径注定不全(告警每类只留 最新 20 条),到下午一只活跃票早盘的信号早被挤掉,所以这条路径分页倒序回扫(每页 500 条,上限 PMS_UPSTREAM_BYCODE_SCAN 默认 5000):两条按日期分键的流整键就是今天, 两条不分键的流扫到今天零点为止且不算截断。同源同分钟同值合并计数,跨源永不合并。 按票缓存八秒防连点,单源失败不拖垮整体且逐源标明。实况分没有时间,只进快照段不 编时间戳。只读纪律与快照相同:一律 XREVRANGE/ZREVRANGE,不建消费组、不 ACK、不写。 - GET /api/upstream/signals/{ts_code},只能点击触发,绝不进轮询。 前端: - 抽屉两段取数:先用手上的快照渲染,第一帧就有内容;同时发回扫请求,回来替换成完整 版并把口径说明从「可能不全」翻成「已回扫到今天开盘」。补全失败保留快照挂黄条带重试。 - 三处入口:信号栏每行票名(14 处,@click.stop 免得顺手翻开风控卖出的理由条)、持仓表 「今天的信号」列(连「今天没有」也可点,速览口径下的「没有」可能是假的)、消息栏两处。 - 遮罩空白四处修复:整体失败不再抹平成空数据(原来失败时页面显示「今天没有信号」, 在风控面板上这是最危险的一种谎);告警派生的三个分组补失败角标、各路空文案给失败 态让位;失败角标的样式选择器漏了信号栏;错误横幅在遮罩之上再浮一份;抽屉层级写死 3000 压住浮层 2010 与遮罩 2005,打开前先收浮层。选股计划抽屉补加载提示。 测试:新增 scripts/test_batch24_units.py(9 例),已在 run_tests.py 的清单与例数表、 test_wiring.py 的路由清单三处登记。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 14:53:29 +08:00
"PMS_UPSTREAM_BYCODE_SCAN": (100, 50000), # 下限 100 保证至少扫得到一页
# 解锁重问 (2026-09-09)
"PMS_OPEN_REASK_MIN_GAP_MIN": (5, 240), "PMS_OPEN_REASK_MAX_PER_DAY": (0, 5),
"PMS_OPEN_REASK_REF_DRIFT": (0.005, 0.3), "PMS_OPEN_REASK_UP": (0.005, 0.2),
"PMS_OPEN_REASK_VOL_MIN": (0.5, 10), "PMS_OPEN_REASK_VOL_MULT": (1.0, 5),
"PMS_JUDGE_REJECT_CONF_MIN": (0, 100),
# 技术面接入 (2026-09-11): 数值阈值的校验范围 (bool/str 不进 _RANGES)
"PMS_TECH_TIMEOUT": (1, 120), "PMS_TECH_KEEP_DAYS": (20, 250),
"PMS_TECH_STALE_TDAYS": (0, 10), "PMS_TECH_SQUEEZE_LOOKBACK": (1, 20),
"PMS_TECH_OPEN_BW_GROWTH": (0, 2), "PMS_TECH_CHOPPY_FLIPS": (1, 20),
"PMS_TECH_FLIP_FRESH_DAYS": (1, 10),
2026-08-19 10:04:20 +08:00
# 宏观择时层
"PMS_MACRO_HOT_TH": (0, 100), "PMS_MACRO_COLD_TH": (-100, 0),
"PMS_MACRO_EXIT_BAND": (0, 100), "PMS_MACRO_CONFIRM_DAYS": (1, 10),
"PMS_MACRO_LOG_S0": (0, 1), "PMS_MACRO_LOG_K": (0.01, 1000),
"PMS_MACRO_SHIFT_MAX": (0, 1), "PMS_MACRO_STEP_MIN": (0, 0.5),
"PMS_MACRO_COOLDOWN_TDAYS": (0, 20), "PMS_MACRO_MIN_PCT": (0, 1),
"PMS_MACRO_MAX_PCT": (0, 1), "PMS_MACRO_WINDOW_TDAYS": (1, 20),
"PMS_MACRO_STALE_TDAYS": (1, 20), "PMS_MACRO_RET_WIN": (5, 60),
"PMS_MACRO_Z_WIN": (10, 250), "PMS_MACRO_SHIBOR_BETA": (0, 1),
2026-08-25 13:45:51 +08:00
# 策略自动挂载
"PMS_AUTO_STRATEGY_DAILY_MAX": (0, 20), "PMS_AUTO_ACCUM_STALE_TDAYS": (1, 20),
"PMS_AUTO_GRID_BAND": (0.01, 0.5), "PMS_AUTO_GRID_STEP_PCT": (0.005, 0.2),
"PMS_AUTO_GRID_CAP_RATIO": (0, 1), "PMS_AUTO_HEAT_TH": (0, 1),
"PMS_AUTO_TRAIL_GIVEBACK": (0.01, 0.5), "PMS_AUTO_TRAIL_SELL_RATIO": (0.1, 1),
"PMS_AUTO_HANDOFF_COOLDOWN_TDAYS": (0, 60), "PMS_AUTO_OPTOUT_COOLDOWN_TDAYS": (0, 60),
}
def _range_check(key, v):
if key == "PMS_AUTONOMY" and v not in ("full", "propose_only", "off"):
return "PMS_AUTONOMY 只能是 full / propose_only / off"
2026-08-19 10:04:20 +08:00
if key == "PMS_MACRO_AUTONOMY" and v not in ("full", "propose_only", "off"):
return "PMS_MACRO_AUTONOMY 只能是 full / propose_only / off"
if key in ("PMS_MACRO_TRIGGER_HOT", "PMS_MACRO_TRIGGER_COLD") \
and v not in ("zone_enter", "zone_exit"):
return f"{key} 只能是 zone_enter (进区即动) / zone_exit (极值回落再动)"
2026-08-06 13:55:27 +08:00
if key == "PMS_OPEN_AUTONOMY" and v not in ("full", "propose_only", "off"):
return "PMS_OPEN_AUTONOMY 只能是 full / propose_only / off"
2026-08-03 12:49:01 +08:00
if key == "PMS_EXEC_IMPL" and str(v).strip().upper() not in ("A", "B"):
return "PMS_EXEC_IMPL 只能是 A (委托决策系统) / B (内置保守择时)"
if key == "PMS_SECTOR_SOURCE" and v not in ("", "gp_hybk", "custom_table",
"gp_stock_category"):
return "PMS_SECTOR_SOURCE 只能是 空 / gp_hybk / custom_table / gp_stock_category"
if key == "PMS_SECTOR_HYBK_LEVEL" and v not in ("l2", "l3"):
return "PMS_SECTOR_HYBK_LEVEL 只能是 l2 / l3"
2026-07-28 15:48:57 +08:00
if key == "PMS_DISPATCH_MODE" and v not in ("shadow", "ws"):
return "PMS_DISPATCH_MODE 只能是 shadow / ws"
if key == "PMS_CANDIDATE_SOURCE" and v not in ("plan_api", "buy_plan", "both"):
return "PMS_CANDIDATE_SOURCE 只能是 plan_api / buy_plan / both"
2026-08-25 13:45:51 +08:00
if key == "PMS_AUTO_STRATEGY_RULES":
# 页面上打错一个边名不会报错、只会静默少一条边 —— 在写入口就拦下来
bad = [t.strip() for t in str(v).split(",")
if t.strip() and t.strip() not in ("accum_grid", "heat_trail",
"accum_exit", "handoff")]
if bad:
return (f"PMS_AUTO_STRATEGY_RULES 含未知边 {bad}; "
f"可选 accum_grid / heat_trail / accum_exit / handoff")
lo_hi = _RANGES.get(key)
if lo_hi and isinstance(v, (int, float)) and not isinstance(v, bool):
lo, hi = lo_hi
if v < lo or v > hi:
return f"{key} 应在 [{lo}, {hi}] 区间, 收到 {v}"
return None
def snapshot() -> dict:
"""页面「参数设置」用: 每个可调参数的 当前值/来源/初值/说明。"""
rows = refresh()
out = {"params": [], "source_error": _cache.get("error")}
for key, field in sorted(_editable_keys().items()):
cur = get(key)
out["params"].append({
"key": key, "value": cur, "file_default": getattr(settings, key, None),
"source": "table" if key in rows else "file",
"type": getattr(field.annotation, "__name__", str(field.annotation)),
"desc": DESC.get(key, (field.description or "").strip()),
"updated_at": str(rows.get(key, {}).get("updated_at") or ""),
"updated_by": rows.get(key, {}).get("updated_by") or "",
})
for key, (dv, t, desc) in RUNTIME_EXTRA.items():
out["params"].append({
"key": key, "value": get(key), "file_default": dv,
"source": "table" if key in rows else "default",
"type": t.__name__, "desc": desc, "group": "runtime",
"updated_at": str(rows.get(key, {}).get("updated_at") or ""),
"updated_by": rows.get(key, {}).get("updated_by") or "",
})
return out
# ---------------------------------------------------------------- 常用组合读取
def sizing_params() -> dict:
"""方案生成器/规则闸共用的一组参数快照 (一次取齐, 避免逐项穿透缓存)。"""
return {
"scale": get_float("PMS_TOTAL_SCALE", 0),
"portfolio_cap": get_float("PMS_PORTFOLIO_CAP", 0.6),
"stock_cap": get_float("PMS_STOCK_CAP", 0.08),
"stock_target_default": get_float("PMS_STOCK_TARGET_DEFAULT", 0.06),
"max_names": get_int("PMS_MAX_NAMES", 15),
"cash_reserve": get_float("PMS_CASH_RESERVE", 0.0),
"autonomy": get("PMS_AUTONOMY", "propose_only"),
"batch_split": get_tuple_floats("PMS_BATCH_SPLIT"),
"min_lot_merge": get_bool("PMS_MIN_LOT_MERGE", True),
"sector_max_names": get_int("PMS_SECTOR_MAX_NAMES", 4),
"sector_max_ratio": get_float("PMS_SECTOR_MAX_RATIO", 0.40),
"sector_source": get("PMS_SECTOR_SOURCE", ""),
"exec_window_tdays": get_int("PMS_EXEC_WINDOW_TDAYS", 3),
"cushion_solid": get_float("PMS_CUSHION_SOLID", 0.03),
"weak_neg_days": get_int("PMS_WEAK_NEG_DAYS", 5),
"buy_halt": get_bool("PMS_GLOBAL_BUY_HALT", False),
"exec_halt": get_bool("PMS_GLOBAL_EXEC_HALT", False),
}