274 lines
12 KiB
Python
274 lines
12 KiB
Python
|
|
# -*- 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")
|
|||
|
|
|
|||
|
|
# 运行态开关: 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, "组合市值高水位 (刹车判定基准)"),
|
|||
|
|
"PMS_REPLAY_CURSOR": ("", str, "成交回放游标 (trading_order.order_id)"),
|
|||
|
|
"PMS_RECON_STREAK": (0, int, "连续对账不一致天数"),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 页面展示用的中文说明 (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_SECTOR_SOURCE": "行业划分数据源: 空=约束停用 / custom_table / gp_stock_category",
|
|||
|
|
"PMS_SECTOR_MAX_NAMES": "同行业最大持仓只数 (硬拦截)",
|
|||
|
|
"PMS_SECTOR_MAX_RATIO": "同行业最大占总仓比例 (硬拦截)",
|
|||
|
|
"PMS_EXEC_WINDOW_TDAYS": "任务命令默认执行窗口 (交易日)",
|
|||
|
|
"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": "指令下发后未被接受的过期时间 (分钟)",
|
|||
|
|
"PMS_RISK_WARN_ENTRY": "单笔敞口告警线 (占规模)", "PMS_RISK_WARN_PORTFOLIO": "组合敞口告警线",
|
|||
|
|
"PMS_BRAKE_DRAWDOWN": "组合刹车: 自高水位回撤", "PMS_BRAKE_DAYS": "刹车持续交易日",
|
|||
|
|
"PMS_STOP_ATR_MULT": "自算止损参考: 成本 − N×ATR",
|
|||
|
|
"PMS_REF_STALE_TDAYS": "决策系统结论日龄超此转自算兜底",
|
|||
|
|
"PMS_JUDGE_ENABLED": "研判闸开关", "PMS_JUDGE_ACTIONS": "需过研判闸的动作",
|
|||
|
|
"PMS_JUDGE_TIMEOUT": "研判超时 (秒) → 降级 propose_only",
|
|||
|
|
"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亏熔断",
|
|||
|
|
"PMS_REPLAY_INTERVAL_MIN": "成交回放间隔 (分钟)", "PMS_RECON_ALARM_DAYS": "连续不一致升级天数",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_cache = {"at": 0.0, "data": {}, "error": None}
|
|||
|
|
_lock = threading.Lock()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _editable_keys() -> dict:
|
|||
|
|
"""可调业务参数 = settings 中 PMS_ 开头且非基础设施的字段。"""
|
|||
|
|
out = {}
|
|||
|
|
for name, field in type(settings).model_fields.items():
|
|||
|
|
if not name.startswith("PMS_"):
|
|||
|
|
continue
|
|||
|
|
if any(name.startswith(p) for p in INFRA_PREFIX):
|
|||
|
|
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 now - _cache["at"] < CACHE_TTL and _cache["data"]:
|
|||
|
|
return _cache["data"]
|
|||
|
|
with _lock:
|
|||
|
|
try:
|
|||
|
|
rows = pms_repo.all_params()
|
|||
|
|
_cache.update({"data": rows, "at": now, "error": None})
|
|||
|
|
except Exception as e:
|
|||
|
|
_cache.update({"at": now, "error": f"{type(e).__name__}: {e}"})
|
|||
|
|
logger.warning("参数表读取失败, 退回 settings 初值: %s", e)
|
|||
|
|
return _cache["data"]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def get(key: str, default=None):
|
|||
|
|
"""取参数当前值: 表值优先 → settings 初值 → RUNTIME_EXTRA 默认 → default。"""
|
|||
|
|
rows = refresh()
|
|||
|
|
t = _type_of(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["at"] = 0.0
|
|||
|
|
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),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _range_check(key, v):
|
|||
|
|
if key == "PMS_AUTONOMY" and v not in ("full", "propose_only", "off"):
|
|||
|
|
return "PMS_AUTONOMY 只能是 full / propose_only / off"
|
|||
|
|
if key == "PMS_SECTOR_SOURCE" and v not in ("", "custom_table", "gp_stock_category"):
|
|||
|
|
return "PMS_SECTOR_SOURCE 只能是 空 / custom_table / gp_stock_category"
|
|||
|
|
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),
|
|||
|
|
}
|