技术面接入工作包一(后端):取数落表、相位合成与映射打通

方案见 docs/技术面接入与三源合议方案_2026-09-11.md 第五节。让每只票多一份结构化
技术面立场,数据取自决策系统全市场技术面接口(布林线/多空布林线/SAR)。
- 新表 pms_tech_daily 存全市场每日读数,保留 40 个交易日
- core/tech_rules 相位合成(纯逻辑)、repo/tech_repo 单表访问、services/tech_service 分页取数落表与每早映射
- 调度加 06:30 tech_pull;plan_pull 拉完计划后补拉一次并重建映射
- 三接口:技术面状态、手动拉取(管理员)、单票研究面
- 参数 PMS_TECH_* 登记;哨兵同步(DDL 表数 20、调度位、路由清单)
- batch27 二十七例,开发机全量 ALL SUITES PASS;相位与五个阈值记台账 006

页面七处建议并入工作包二;合议(二)、离场(三)、选股打分(四)未做。未真机判收。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zlt 2026-09-11 12:32:00 +08:00
parent cabcf04397
commit 0d3984582c
16 changed files with 1502 additions and 6 deletions

View File

@ -300,8 +300,28 @@ docker compose run --rm --no-deps pms-web python scripts/probe_bionic.py --base
| V1.0 | 2026-08-03 | 首版定稿并双侧落码。择时部分是一套盘中判定规则(资金阈值、动量追买等) | | V1.0 | 2026-08-03 | 首版定稿并双侧落码。择时部分是一套盘中判定规则(资金阈值、动量追买等) |
| V1.1 | 2026-08-03 | 按用户审核意见重做择时:删掉 V1.0 的盘中判定规则(违反「提前计算为主、盘中监控为辅、不另做盘中判断、可以接受买不上」),改为由昨夜支撑压力推出执行区间、盘中只做区间比对与当日监控核对;研判留痕不再写 `decision_ledger`(每晚判分全表扫描,已核实);文档与代码注释清理生造词 | | V1.1 | 2026-08-03 | 按用户审核意见重做择时:删掉 V1.0 的盘中判定规则(违反「提前计算为主、盘中监控为辅、不另做盘中判断、可以接受买不上」),改为由昨夜支撑压力推出执行区间、盘中只做区间比对与当日监控核对;研判留痕不再写 `decision_ledger`(每晚判分全表扫描,已核实);文档与代码注释清理生造词 |
| V1.2 | 2026-09-08 | 量价研判链《量价研判链吸收方案_2026-09-08》请求侧硬数字白名单多送 `events_text`、`pricing_text` 两句整句(催化事件与定价状态,事件与量价不是产业逻辑);应答侧新建仓研判可并列多带 `pv_heads``h5`、`h20` 各含 `score` 1 到 1、`direction` 看多/中性/看空、`justification`PMS 归一后进提议硬数字 `judge_pv_heads`,只显示不触发;三值 verdict 口径不变,缺 `pv_heads` 就是 None向下兼容 | | V1.2 | 2026-09-08 | 量价研判链《量价研判链吸收方案_2026-09-08》请求侧硬数字白名单多送 `events_text`、`pricing_text` 两句整句(催化事件与定价状态,事件与量价不是产业逻辑);应答侧新建仓研判可并列多带 `pv_heads``h5`、`h20` 各含 `score` 1 到 1、`direction` 看多/中性/看空、`justification`PMS 归一后进提议硬数字 `judge_pv_heads`,只显示不触发;三值 verdict 口径不变,缺 `pv_heads` 就是 None向下兼容 |
| V1.3 | 2026-09-09 | 公司深度整句(详见下节):研判请求硬数字新增 `company_review_text`,开关 `PMS_JUDGE_COMPANY_TEXT` |
| V1.4 | 2026-09-11 | 技术面只读接口详见下节PMS 新接一个取全市场技术面读数的只读接口;研判请求体一字不变,技术面整句不送研判 |
### 7.x 2026-09-09 公司深度整句V1.3 ### 7.x 2026-09-09 公司深度整句V1.3
- 研判请求的硬数字新增 `company_review_text`(选股系统写好的整句:质地档与三类、十视角计数、买方论点一句、置信度、失效条件、报告链接)。 - 研判请求的硬数字新增 `company_review_text`(选股系统写好的整句:质地档与三类、十视角计数、买方论点一句、置信度、失效条件、报告链接)。
`verdict/reasons` 同一性质上游结论送去让研判对照研判不重判质地。PMS 侧开关 `PMS_JUDGE_COMPANY_TEXT`(默认开)关掉即不送。 `verdict/reasons` 同一性质上游结论送去让研判对照研判不重判质地。PMS 侧开关 `PMS_JUDGE_COMPANY_TEXT`(默认开)关掉即不送。
- bionic 侧不需要改:硬数字逐键渲染进提示词。应答形状不变。 - bionic 侧不需要改:硬数字逐键渲染进提示词。应答形状不变。
### 7.x 2026-09-11 技术面只读接口V1.4
决策系统每晚扫全市场给每只票算布林线、多空布林线、SAR 三个指标。持仓管理系统盘前很早06:30拉一次落表供三源合议里技术面这一票用。这是只读取数不产生指令技术面整句也不送研判决策系统夜里自己算过一份两份读数可能不一致
**接口。** `GET {根地址}/api/v1/market/technical?limit=&offset=`。根地址取持仓管理系统侧参数 `PMS_TECH_API_BASE`,留空时沿用研判接口根地址 `PMS_JUDGE_API_BASE`(同一台决策系统)。分页用 `limit``offset`,逐页拉到取满外层的 `matched`
**应答外层。** `status`(只有 OK 才落表)、`data_date`(读数日 YYYYMMDD北京日期、`computed_at`(算完时刻,北京时间)、`algo_version`、`scanned_total`、`scanned_available`、`matched`、`count`、`limit`、`offset`、`warnings`、`items`。
**每只票items 内一项)。**
- `stock_code`:交易所前缀式,如 `SH600000`。持仓管理系统落库前归一成点式 `600000.SH`
- `stock_name`
- `boll`(布林线):`upper`、`mid`、`lower`、`bandwidth_pct`(带宽百分比)、`pos`价格在带内位置0 到 1、`state`(文字态,如「带内偏上」)、`squeeze`(是否收口,布尔,接口直接给)。
- `bbiboll`(多空布林线):`bbi`(中枢)、`upper`、`lower`、`pos`、`state`(多头区 / 中性区 / 空头区)、`dist_pct`。
- `sar``value`(止损位)、`side`(多 / 空)、`flip_days`(本次翻向后经过的交易日数)、`dist_pct`。
- `reanchored`(是否做了除权重锚)、`in_pool`(是否在夜间分析池)、`quality`数据质量OK 才用)、`bars_used`、`last_bar_date`、`bars_lag`。
**持仓管理系统侧怎么用。** 存进 pms_tech_daily 保留 40 个交易日;相位合成与五个阈值见该系统台账 006。四种无读数情形没有当天行、读数超过两个交易日、未除权重锚、数据质量非 OK一律弃权绝不折成看空。本接口不改变研判请求体研判闸的请求一字不变。

View File

@ -1316,6 +1316,35 @@ PMS_JUDGE_ONLINE_EFFORT=low / PMS_JUDGE_ONLINE_MAX_TOKENS=16384 / PMS_JUDGE_REPA
4. trading_position 表写入方与 QMT 不同步照旧待与 QMT 侧核对; 155 上四周前的残留 4. trading_position 表写入方与 QMT 不同步照旧待与 QMT 侧核对; 155 上四周前的残留
一次性容器 tradingsystem-pms-web-run-a4f4a87e7662 (unhealthy) 建议顺手删掉。 一次性容器 tradingsystem-pms-web-run-a4f4a87e7662 (unhealthy) 建议顺手删掉。
---
## 2026-09-11 · 技术面接入工作包一:技术面这一路打通(后端,未真机判收)
**做了什么**
落地《技术面接入与三源合议方案_2026-09-11》第五节工作包一的后端部分让每只票多出一份结构化的技术面立场。数据来自决策系统新开的全市场技术面接口。
- 先只读探活了 188 的接口 `GET /api/v1/market/technical`,坐实字段形状:外层是 status、data_date、algo_version、matched、items每只票带 boll含 squeeze 收口布尔、bandwidth_pct、pos、state、bbiboll含 state 多头区/中性区/空头区、sar含 side 多空、flip_days 翻向天数、value 止损位、reanchored、quality。代码是 SH600000 式,落库前归一成点式 600000.SH。
- 新表 pms_tech_daily 存全市场每日读数,唯一键是读数日加代码,保留 40 个交易日。存全市场而非只存持仓与候选,是因为新进榜的票要 20 个交易日历史才判得了震荡。
- 相位合成 `app/core/tech_rules.py` 是纯逻辑:先判有没有读数(无读数一律弃权,绝不折成看空),再判震荡市(最近 20 个交易日 SAR 翻向四次),再按固定次序判九个相位(收口等待、开口向上向下、转空转多、趋势多空、分歧、震荡)。五个阈值一次定死,记在台账 006。
- `app/services/tech_service.py` 分页拉接口、宽松解析、落表(接口状态非 OK 整轮不落表),每早对在持、计划主榜观察、待拍板的票合成立场写进映射 `PMS_TECH_STATE_MAP`,手法照 logic_state_service读不到留空带原因超期按无读数。
- 调度新增 06:30 的 `pms.tech_pull``plan_pull` 在 08:40 拉完计划后补拉一次并重建映射,让覆盖带上当天的榜。
- 三个接口:`GET /api/tech/status`、`POST /api/ops/tech-pull`(管理员)、`GET /api/research/{ts_code}`(单票研究面,工作包一先给技术面一块,其余块工作包二补)。
- 十一个 PMS_TECH_* 参数登记进 param_store 的 RUNTIME_EXTRA 与 _RANGES。
**动了哪些文件**
新增 `app/core/tech_rules.py`、`app/repo/tech_repo.py`、`app/services/tech_service.py`、`scripts/test_batch27_units.py`。
`ddl_pms_v1.sql`(第 20 张表)、`app/core/tradedays.py`(加 prev_trade_day、`app/services/param_store.py`、`app/scheduler.py`、`app/web/main.py`、`scripts/test_batch6_units.py`DDL 表数 19 改 20、`scripts/test_wiring.py`(调度位加 tech_pull、路由加三接口、`scripts/run_tests.py`(登记 batch27顺手补登记既有遗漏的 batch26
**部署方式**
改了 Python`make deploy` 重建镜像加 force-recreate`init_db` 建出 pms_tech_daily。只在模拟仓 155 收盘后执行,先经用户审批。接口地址留空即沿用研判接口根地址 `PMS_JUDGE_API_BASE`
**真机判收**
未判收。开发机临时 venv 跑全量单测见 ALL SUITES PASS含新增 batch27 二十七例)。接口字段已只读探活 188 坐实。待 155 部署后按方案第七节判收make test、pull_and_map 行数五千上下、映射只数等于持仓加候选、status、第一个交易日 08:4x 映射写入时刻与读数日。
**还欠着什么**
1. 页面七处建议并入工作包二,与三源合议的提议卡芯片、基本面质地芯片一次铺齐,避免先做技术面芯片再返工重排提议卡。
2. 工作包二三源合议与仓位矩阵、工作包三离场纪律、工作包四选股打分另一仓库单独审批、工作包五README 调度总表补 13 位等文档)未做。
3. BIONIC_PMS_INTERFACE.md 需补技术面只读接口一节(本次接口探活的字段事实源)。
--- ---
<!-- <!--
下一条节点从这里往下写,格式照抄上面: 下一条节点从这里往下写,格式照抄上面:

158
app/core/tech_rules.py Normal file
View File

@ -0,0 +1,158 @@
# -*- coding: utf-8 -*-
"""技术面相位合成 (2026-09-11 技术面接入, 纯逻辑, 零外部依赖)。
输入是一只票按 data_date 升序的近若干个交易日技术面读数 (tech_repo 落表的行, 字段见
ddl_pms_v1.sql pms_tech_daily), 最后一行是最新那天数据新鲜度 有没有当天行读数
陈不陈旧 tech_service 在调用前保证 (它握着交易日历); 本模块只认传进来的 rows 内容
合成三步, 技术面接入与三源合议方案第三节一一对应:
, 判有没有读数: 没有最新行 / 未除权重锚 / 数据质量非 OK / 关键读数缺失, 一律无读数
无读数是弃权, 绝不折成看空 (设计原则二无读数等于弃权)
, 判是不是震荡市: 最近 20 个交易日 SAR 翻向达到 choppy_flips 次即震荡震荡市里 SAR
翻向不当信号, 这是决策系统文档明说的指标特性
, 判相位: 按固定次序逐条比对, 第一个命中的就是结论 (方案第三节相位表)
立场取值只有四个中文词: 看多 / 看空 / 中性 / 无读数强弱只给看多看空用, 中性与无读数没有
阈值默认值见 DEFAULTS, tech_service 会从参数中心读实际值覆盖; 本模块不读参数中心, 以便脱库单测
"""
from __future__ import annotations
# 数翻向的窗口「最近 20 个交易日」(方案第三节)。窗口本身不设页面参数, 翻向次数阈值才设。
CHOPPY_WINDOW = 20
DEFAULTS = {
"squeeze_lookback": 5, # 近几个交易日内收过口算「刚收口过」(PMS_TECH_SQUEEZE_LOOKBACK)
"open_bw_growth": 0.20, # 今日带宽比收口日至少扩这么多算开口 (PMS_TECH_OPEN_BW_GROWTH)
"choppy_flips": 4, # 20 日 SAR 翻向达到这么多次算震荡 (PMS_TECH_CHOPPY_FLIPS)
"flip_fresh_days": 2, # SAR 翻向后这么多个交易日内算「刚转向」(PMS_TECH_FLIP_FRESH_DAYS)
}
# 价格「贴下轨」的派生判据 (带内位置 ≤ 此值)。转空确认的次要条件, 主条件是多空布林线空头区。
# 这不是方案里一次定死的五个阈值之一, 是实现派生量; 要调改这里即可。
_NEAR_LOWER = 0.20
_LONG = ""
_SHORT = ""
def _num(v):
try:
return None if v is None else float(v)
except (TypeError, ValueError):
return None
def _truthy(v) -> bool:
"""TINYINT / 布尔 / 字符串都收: 1 / True / "" 为真。"""
if isinstance(v, str):
return v.strip() in ("1", "true", "True", "")
return bool(v)
def _no_read(why: str, latest=None) -> dict:
return {"stance": "无读数", "strength": None, "phase": None, "confirm": None,
"sar_side": None, "sar_value": None, "sar_flip_days": None, "choppy": None,
"reason": why, "no_read_why": why,
"data_date": (latest or {}).get("data_date")}
def count_sar_flips(rows) -> int:
"""rows 升序, 数相邻两行 SAR 方向变化的次数 (只看最近 CHOPPY_WINDOW 行)。"""
seq = [str(r.get("sar_side") or "") for r in (rows or [])[-CHOPPY_WINDOW:]]
seq = [s for s in seq if s in (_LONG, _SHORT)]
return sum(1 for a, b in zip(seq, seq[1:]) if a != b)
def _recent_squeeze_bw(rows, lookback):
"""近 lookback 个交易日内 (不含今日) 最近一次收口那天的带宽; 没收口过返回 None。"""
window = (rows or [])[-(lookback + 1):-1] # 去掉今日那一行
for r in reversed(window): # 由近及远
if _truthy(r.get("boll_squeeze")):
return _num(r.get("boll_bw_pct"))
return None
def synthesize(rows, *, params=None) -> dict:
"""把一只票的近 N 日读数 (升序) 合成技术面立场。返回含 stance/strength/phase/confirm/
sar_value/choppy/reason/data_date/no_read_why 的字典"""
p = dict(DEFAULTS)
if params:
p.update({k: params[k] for k in DEFAULTS if params.get(k) is not None})
rows = list(rows or [])
latest = rows[-1] if rows else None
# ---- 第一步: 有没有读数 (无读数是弃权) ----
if not latest:
return _no_read("没有当天的技术面读数")
if not _truthy(latest.get("reanchored")):
return _no_read("未做除权重锚,读数不可比", latest)
if str(latest.get("quality") or "").upper() != "OK":
return _no_read(f"数据质量非正常({latest.get('quality') or ''}", latest)
side = str(latest.get("sar_side") or "")
pos = _num(latest.get("boll_pos"))
if side not in (_LONG, _SHORT) or pos is None:
return _no_read("关键读数缺失SAR 方向或带内位置)", latest)
squeeze = _truthy(latest.get("boll_squeeze"))
bbi_state = str(latest.get("bbi_state") or "")
bw = _num(latest.get("boll_bw_pct"))
flip_days = latest.get("sar_flip_days")
flip_days = int(flip_days) if flip_days is not None else None
sar_value = _num(latest.get("sar_value"))
base = {"sar_side": side, "sar_value": sar_value, "sar_flip_days": flip_days,
"data_date": latest.get("data_date"), "no_read_why": None}
# ---- 第二步: 震荡市 ----
flips = count_sar_flips(rows)
choppy = flips >= int(p["choppy_flips"])
base["choppy"] = choppy
def out(stance, strength, phase, reason, confirm=None):
r = dict(base)
r.update({"stance": stance, "strength": strength, "phase": phase,
"confirm": confirm, "reason": reason})
return r
long_, short_ = side == _LONG, side == _SHORT
fresh_flip = flip_days is not None and flip_days <= int(p["flip_fresh_days"])
bbi_bull = bbi_state == "多头区"
bbi_bear = bbi_state == "空头区"
upper_half = pos >= 0.5
# ---- 第三步: 相位 (固定次序, 第一个命中为结论) ----
# 1 收口等待
if squeeze:
return out("中性", None, "收口等待", "布林带收口,等开口再定方向")
# 2 / 3 开口向上 / 开口向下 (今天已不收口; 近 lookback 日内收过口且带宽扩够)
sq_bw = _recent_squeeze_bw(rows, int(p["squeeze_lookback"]))
opened = (sq_bw is not None and sq_bw > 0 and bw is not None
and (bw / sq_bw - 1) >= p["open_bw_growth"])
if opened and upper_half and long_:
return out("看多", "", "开口向上", "刚收口后放开、价在带内上半部、SAR 多:向上变盘")
if opened and (not upper_half) and short_:
return out("看空", "", "开口向下", "刚收口后放开、价在带内下半部、SAR 空:向下变盘")
# 4 转空 (非震荡, SAR 刚翻空)
if short_ and fresh_flip and not choppy:
confirm = bbi_bear or pos <= _NEAR_LOWER
why = "SAR 刚翻空" + ("、多空布林线空头区或贴下轨(确认)" if confirm else "(待确认)")
return out("看空", "" if confirm else "", "转空", why, confirm=confirm)
# 5 转多 (非震荡, SAR 刚翻多, 多空布林线不在空头区)
if long_ and fresh_flip and not bbi_bear and not choppy:
return out("看多", "", "转多", "SAR 刚翻多、多空布林线未在空头区")
# 6 趋势多 (SAR 多且多空布林线在多头区为强, 中性区为弱)
if long_ and bbi_bull:
return out("看多", "", "趋势多", "SAR 多且多空布林线多头区:上升趋势延续")
if long_ and not bbi_bear:
return out("看多", "", "趋势多", "SAR 多、多空布林线中性区:偏多但不强")
# 7 趋势空 (SAR 空且多空布林线不在多头区)
if short_ and not bbi_bull:
return out("看空", "" if bbi_bear else "", "趋势空", "SAR 空且多空布林线未在多头区:下行")
# 8 分歧 (SAR 与多空布林线方向相反)
if (long_ and bbi_bear) or (short_ and bbi_bull):
return out("中性", None, "分歧", "SAR 与多空布林线方向相反,不表态")
# 9 震荡 (震荡市且以上都不命中)
if choppy:
return out("中性", None, "震荡", f"近 20 日 SAR 翻向 {flips} 次,震荡市不表态")
# 兜底: side 已限定在 {多, 空}, 上面各分支理应已覆盖全部组合; 到这里保守中性。
return out("中性", None, "未归类", "指标组合未归入任何相位,保守中性")

View File

@ -93,6 +93,20 @@ def next_trade_day(d=None, n: int = 1) -> date:
return cur return cur
def prev_trade_day(d=None, n: int = 1) -> date:
"""d 之前的第 n 个交易日 (n≥1); n=0 返回 d 当天 (不判是否交易日)。与 next_trade_day 对称。"""
dd = _as_date(d)
if n <= 0:
return dd
cnt, cur, guard = 0, dd, 0
while cnt < n and guard < MAX_SCAN_DAYS:
cur -= timedelta(days=1)
guard += 1
if is_trade_day(cur):
cnt += 1
return cur
def window_deadline(start=None, window_tdays: int = 3) -> date: def window_deadline(start=None, window_tdays: int = 3) -> date:
"""执行窗口截止日 = 起始日(含, 若为交易日) 起的第 window_tdays 个交易日。 """执行窗口截止日 = 起始日(含, 若为交易日) 起的第 window_tdays 个交易日。

108
app/repo/tech_repo.py Normal file
View File

@ -0,0 +1,108 @@
# -*- coding: utf-8 -*-
"""pms_tech_daily 单表访问 (2026-09-11 技术面接入)。
全市场每日技术面读数的落表与回看**严格单表访问**: 每个函数只碰 pms_tech_daily 一张表
批量落表走 execute_many + ON DUPLICATE KEY UPDATE (同一 (读数日, 代码) 重拉即覆盖);
回看按 (ts_code, data_date) 索引取近 N 保留窗口的门槛日由 tech_service 按交易日历算出,
再调 prune 删更旧的行
"""
from __future__ import annotations
from datetime import datetime
from app.db.session import execute, execute_many, fetch_all, fetch_one
# 落表的业务列 (与 ddl_pms_v1.sql 的 pms_tech_daily 对齐; created_at/updated_at 由本层补北京时间)
_COLS = (
"data_date", "ts_code", "stock_name",
"boll_upper", "boll_mid", "boll_lower", "boll_bw_pct", "boll_pos", "boll_state", "boll_squeeze",
"bbi", "bbi_upper", "bbi_lower", "bbi_pos", "bbi_state", "bbi_dist_pct",
"sar_value", "sar_side", "sar_flip_days", "sar_dist_pct",
"reanchored", "in_pool", "quality", "bars_used", "last_bar_date", "bars_lag", "algo_version",
)
def upsert_daily(rows: list) -> int:
"""批量落当日读数。rows 每项是 {列名: 值}, 至少含 data_date 与 ts_code。同 (日, 码) 覆盖。"""
rows = [r for r in (rows or []) if r.get("data_date") and r.get("ts_code")]
if not rows:
return 0
now = datetime.now() # 北京时间 (容器时钟), 与项目写记录口径一致
payload = []
for r in rows:
d = {c: r.get(c) for c in _COLS}
d["ts"] = now
payload.append(d)
cols = ", ".join(_COLS) + ", created_at, updated_at"
vals = ", ".join(f":{c}" for c in _COLS) + ", :ts, :ts"
updates = ", ".join(f"{c} = :{c}" for c in _COLS
if c not in ("data_date", "ts_code")) + ", updated_at = :ts"
return execute_many(
f"INSERT INTO pms_tech_daily ({cols}) VALUES ({vals}) "
f"ON DUPLICATE KEY UPDATE {updates}", payload)
def latest_date():
"""最新读数日 YYYYMMDD; 空表返回 None。"""
r = fetch_one("SELECT MAX(data_date) AS d FROM pms_tech_daily")
return int(r["d"]) if r and r.get("d") is not None else None
def count_on(data_date: int) -> int:
"""某读数日在库的行数 (状态与探活用)。"""
r = fetch_one("SELECT COUNT(*) AS n FROM pms_tech_daily WHERE data_date = :d",
{"d": int(data_date)})
return int(r["n"]) if r else 0
def distinct_dates(limit: int = 60) -> list:
"""最近 limit 个读数日, 降序。"""
rows = fetch_all("SELECT DISTINCT data_date FROM pms_tech_daily "
"ORDER BY data_date DESC LIMIT :n", {"n": int(limit)})
return [int(r["data_date"]) for r in rows]
def history(ts_code: str, *, since: int = 0, limit: int = 60) -> list:
"""一只票的近若干日读数, **升序** (最后一行最新)。since>0 时只取该日及以后。"""
code = (ts_code or "").strip()
if not code:
return []
p = {"c": code, "n": int(limit)}
where = "ts_code = :c"
if since:
where += " AND data_date >= :since"
p["since"] = int(since)
rows = fetch_all(f"SELECT * FROM pms_tech_daily WHERE {where} "
f"ORDER BY data_date DESC LIMIT :n", p)
return list(reversed(rows))
def history_multi(codes, *, since: int = 0) -> dict:
"""一批票各自的近日读数, 返回 {ts_code: [行, 升序]}。按 IN 取回再在内存分组。
codes 多时分批 (每批 800) SQL 过长; IN 占位符手动展开 ( pms_repo.ledger_by_ref)"""
out: dict = {}
uniq = [c for c in dict.fromkeys(str(x).strip() for x in (codes or []) if x) if c]
if not uniq:
return out
for i in range(0, len(uniq), 800):
chunk = uniq[i:i + 800]
keys, p = [], {}
for j, c in enumerate(chunk):
keys.append(f":c{j}")
p[f"c{j}"] = c
where = f"ts_code IN ({', '.join(keys)})"
if since:
where += " AND data_date >= :since"
p["since"] = int(since)
rows = fetch_all(f"SELECT * FROM pms_tech_daily WHERE {where} "
f"ORDER BY ts_code ASC, data_date ASC", p)
for r in rows:
out.setdefault(r["ts_code"], []).append(r)
return out
def prune(before_ymd: int) -> int:
"""删读数日早于 before_ymd 的行 (保留窗口门槛由调用方按交易日算)。返回删除行数。"""
if not before_ymd:
return 0
return execute("DELETE FROM pms_tech_daily WHERE data_date < :d", {"d": int(before_ymd)})

View File

@ -147,6 +147,19 @@ def _brief(r):
# ================================================================ 任务 # ================================================================ 任务
@celery_app.task(name="pms.tech_pull")
@guard(trade_day=True, respect_exec_halt=False) # 取数动作, 休假模式照跑 (只落读数更新映射, 不产生指令)
def tech_pull():
"""盘前很早 (06:30) 拉决策系统的全市场技术面读数, 落 pms_tech_daily 并建映射。
决策系统夜扫 23:20 实测 00:38 前完成, 06:30 留足余量此刻今天的选股计划还没拉
(plan_pull 08:40), 映射先覆盖在持与待拍板; 08:40 拉完计划后 plan_pull 会补拉一次并
重建映射, 让覆盖面带上当天的主榜与观察档拉失败只记, 当天按无读数, 不拦任何动作
"""
from app.services import tech_service
return tech_service.pull_and_map()
@celery_app.task(name="pms.plan_pull") @celery_app.task(name="pms.plan_pull")
@guard(trade_day=True, respect_exec_halt=False) # 取数动作, 休假模式照跑 (只更新映射不产生指令) @guard(trade_day=True, respect_exec_halt=False) # 取数动作, 休假模式照跑 (只更新映射不产生指令)
def plan_pull(): def plan_pull():
@ -169,6 +182,14 @@ def plan_pull():
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.error("[plan_pull] 逻辑状态取回失败: %s", e) logger.error("[plan_pull] 逻辑状态取回失败: %s", e)
out["logic_state"] = {"ok": False, "error": f"{type(e).__name__}: {e}"} out["logic_state"] = {"ok": False, "error": f"{type(e).__name__}: {e}"}
# 技术面补拉并重建映射 (2026-09-11): 06:30 已拉过一次, 这里拉完计划后再拉一次并重建,
# 让映射覆盖当天主榜与观察档的新票。失败只记, 不影响 plan_pull (取不到就按无读数)。
try:
from app.services import tech_service
out["tech"] = tech_service.pull_and_map()
except Exception as e: # noqa: BLE001
logger.error("[plan_pull] 技术面补拉失败: %s", e)
out["tech"] = {"ok": False, "error": f"{type(e).__name__}: {e}"}
return out return out
@ -336,6 +357,8 @@ def daily_report():
# ================================================================ beat 调度表 # ================================================================ beat 调度表
celery_app.conf.beat_schedule = { celery_app.conf.beat_schedule = {
# 技术面取数 (2026-09-11): 决策系统夜扫实测 00:38 完, 06:30 留足余量; 在计划拉取 (08:40) 之前。
"tech_pull": {"task": "pms.tech_pull", "schedule": crontab(hour=6, minute=30)},
"plan_pull": {"task": "pms.plan_pull", "schedule": crontab(hour=8, minute=40)}, "plan_pull": {"task": "pms.plan_pull", "schedule": crontab(hour=8, minute=40)},
"premarket": {"task": "pms.premarket", "schedule": crontab(hour=8, minute=50)}, "premarket": {"task": "pms.premarket", "schedule": crontab(hour=8, minute=50)},
"command_poll": {"task": "pms.command_poll", "schedule": crontab(minute="*"), "command_poll": {"task": "pms.command_poll", "schedule": crontab(minute="*"),

View File

@ -92,6 +92,21 @@ RUNTIME_EXTRA = {
"PMS_PLAN_BY_ADVICE": (True, bool, "建议方案直接作为新建仓提议的仓位档与分批 (模拟侧默认开; 关掉只并排显示, 提议照机械方案)"), "PMS_PLAN_BY_ADVICE": (True, bool, "建议方案直接作为新建仓提议的仓位档与分批 (模拟侧默认开; 关掉只并排显示, 提议照机械方案)"),
"PMS_ADVICE_TRIAL_CONF_MIN": (60, int, "研判把握度低于此值 (或不可用) 时建议方案降为试探仓, 只在人工确认后下"), "PMS_ADVICE_TRIAL_CONF_MIN": (60, int, "研判把握度低于此值 (或不可用) 时建议方案降为试探仓, 只在人工确认后下"),
"PMS_LOGIC_STATE_MAP": ("", str, "在持票的逻辑状态映射 (plan_pull 每早写入的 JSON: 时刻/日期/逐票状态), 扫描层与页面只读, 勿手改"), "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: 时刻/读数日/逐票紧凑立场), 扫描层与页面只读, 勿手改"),
} }
# **读不到时必须按"已暂停"处理的键 (fail-closed)。** # **读不到时必须按"已暂停"处理的键 (fail-closed)。**
@ -463,6 +478,11 @@ _RANGES = {
"PMS_OPEN_REASK_REF_DRIFT": (0.005, 0.3), "PMS_OPEN_REASK_UP": (0.005, 0.2), "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_OPEN_REASK_VOL_MIN": (0.5, 10), "PMS_OPEN_REASK_VOL_MULT": (1.0, 5),
"PMS_JUDGE_REJECT_CONF_MIN": (0, 100), "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),
# 宏观择时层 # 宏观择时层
"PMS_MACRO_HOT_TH": (0, 100), "PMS_MACRO_COLD_TH": (-100, 0), "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_EXIT_BAND": (0, 100), "PMS_MACRO_CONFIRM_DAYS": (1, 10),

View File

@ -0,0 +1,430 @@
# -*- coding: utf-8 -*-
"""技术面取数、落表、映射与对外状态 (2026-09-11 技术面接入)。
职责三件:
, 分页拉决策系统 GET /api/v1/market/technical 的全市场读数, 宽松解析后落进
pms_tech_daily**状态非 OK 不落表**; 分页里任一页失败不推进 data_date, 整轮算失败,
绝不落半份 (先记账后动作, 宁可这轮没有也不要落一份残缺的)
, 每早对相关票 (在持 + 计划主榜观察 + 待拍板) core.tech_rules 合成技术面立场, 写进
运行参数 PMS_TECH_STATE_MAP logic_state_service 的映射同一手法: 读不到留空带原因
FRESH_DAYS 天按无读数绝不折成看空 (设计原则二)
, 给页面出状态 (开关/新鲜度/行数/映射覆盖/错误) 与按票的研究面读数 (点击时调)
取数纯函数 (fetch_page / fetch_all_pages / parse_item) 不读参数中心, 以便脱库单测
总开关 PMS_TECH_ENABLED 关掉即整模块歇工 (不拉不建映射状态标注已停, 行为逐字如旧)
"""
from __future__ import annotations
import json
import logging
from collections import Counter
from datetime import date, datetime
from app.core import tech_rules, tradedays
from app.core.command_spec import normalize_code
from app.repo import tech_repo
from app.services import param_store
logger = logging.getLogger("pms.tech")
MAP_KEY = "PMS_TECH_STATE_MAP"
FRESH_DAYS = 3 # 映射超过这么多自然日没刷新就当没读数 (调度断了不拿旧态拦人)
DEFAULT_PATH = "/api/v1/market/technical"
PAGE_SIZE = 1500 # 分页每页条数 (全市场约 5000, 三四页取完)
MAX_PAGES = 20 # 防呆: 分页硬上限
class TechFeedError(RuntimeError):
"""拉技术面失败。调用方按无读数降级, 不产生任何拦截。"""
# ================================================================ 参数
def _getf(key: str, d: float) -> float:
v = param_store.get(key, None)
try:
return float(v) if v not in (None, "") else float(d)
except (TypeError, ValueError):
return float(d)
def _params() -> dict:
g, gi, gb = param_store.get, param_store.get_int, param_store.get_bool
base = (g("PMS_TECH_API_BASE", "") or "").strip().rstrip("/")
if not base: # 留空沿用研判接口根地址 (同一台 bionic)
base = (g("PMS_JUDGE_API_BASE", "") or "").strip().rstrip("/")
return {
"enabled": gb("PMS_TECH_ENABLED", True),
"base": base,
"path": (g("PMS_TECH_API_PATH", DEFAULT_PATH) or DEFAULT_PATH).strip(),
"timeout": gi("PMS_TECH_TIMEOUT", 20),
"keep_days": gi("PMS_TECH_KEEP_DAYS", 40),
"stale_tdays": gi("PMS_TECH_STALE_TDAYS", 2),
"rules": {
"squeeze_lookback": gi("PMS_TECH_SQUEEZE_LOOKBACK", 5),
"open_bw_growth": _getf("PMS_TECH_OPEN_BW_GROWTH", 0.20),
"choppy_flips": gi("PMS_TECH_CHOPPY_FLIPS", 4),
"flip_fresh_days": gi("PMS_TECH_FLIP_FRESH_DAYS", 2),
},
}
def enabled() -> bool:
return param_store.get_bool("PMS_TECH_ENABLED", True)
# ================================================================ 解析 (纯函数)
def _f(v):
try:
return None if v in (None, "") else float(v)
except (TypeError, ValueError):
return None
def _i(v):
try:
return None if v in (None, "") else int(float(v))
except (TypeError, ValueError):
return None
def _s(v):
if v is None:
return None
s = str(v).strip()
return s or None
def _tinyint(v):
"""接口布尔 → 1/0; None 保留 None (缺字段不误判成 0)。"""
if v is None:
return None
if isinstance(v, str):
return 1 if v.strip() in ("1", "true", "True", "") else 0
return 1 if v else 0
def parse_item(it: dict, data_date: int, algo_version=None):
"""接口一只票 → 落表行 dict。代码归一 (SH600000 → 600000.SH); 认不出返回 None。"""
if not isinstance(it, dict):
return None
code = normalize_code(str(it.get("stock_code") or it.get("code") or it.get("ts_code") or ""))
if not code or "." not in code:
return None
boll = it.get("boll") if isinstance(it.get("boll"), dict) else {}
bbi = it.get("bbiboll") if isinstance(it.get("bbiboll"), dict) else {}
sar = it.get("sar") if isinstance(it.get("sar"), dict) else {}
return {
"data_date": int(data_date), "ts_code": code,
"stock_name": _s(it.get("stock_name") or it.get("name")),
"boll_upper": _f(boll.get("upper")), "boll_mid": _f(boll.get("mid")),
"boll_lower": _f(boll.get("lower")), "boll_bw_pct": _f(boll.get("bandwidth_pct")),
"boll_pos": _f(boll.get("pos")), "boll_state": _s(boll.get("state")),
"boll_squeeze": _tinyint(boll.get("squeeze")),
"bbi": _f(bbi.get("bbi")), "bbi_upper": _f(bbi.get("upper")),
"bbi_lower": _f(bbi.get("lower")), "bbi_pos": _f(bbi.get("pos")),
"bbi_state": _s(bbi.get("state")), "bbi_dist_pct": _f(bbi.get("dist_pct")),
"sar_value": _f(sar.get("value")), "sar_side": _s(sar.get("side")),
"sar_flip_days": _i(sar.get("flip_days")), "sar_dist_pct": _f(sar.get("dist_pct")),
"reanchored": _tinyint(it.get("reanchored")), "in_pool": _tinyint(it.get("in_pool")),
"quality": _s(it.get("quality")), "bars_used": _i(it.get("bars_used")),
"last_bar_date": _i(it.get("last_bar_date")), "bars_lag": _i(it.get("bars_lag")),
"algo_version": _s(algo_version),
}
# ================================================================ 取数 (纯函数, 可注入 fetch)
def _http_get(url, params, timeout):
import requests
r = requests.get(url, params=(params or None), timeout=timeout)
r.raise_for_status()
return r.json()
def fetch_page(*, base, path, timeout, limit, offset, fetch=None) -> dict:
"""拉一页。fetch 可注入 (单测)。失败或应答非对象抛 TechFeedError。"""
base = (base or "").strip().rstrip("/")
if not base:
raise TechFeedError("技术面接口地址为空PMS_TECH_API_BASE 与 PMS_JUDGE_API_BASE 都没填)")
path = (path or DEFAULT_PATH).strip()
if not path.startswith("/"):
path = "/" + path
url = base + path
try:
payload = (fetch or _http_get)(url, {"limit": int(limit), "offset": int(offset)}, int(timeout))
except Exception as e:
logger.warning("拉技术面失败 %s offset=%s: %s: %s", url, offset, type(e).__name__, e)
raise TechFeedError(f"{type(e).__name__}: {e}") from e
if not isinstance(payload, dict):
raise TechFeedError(f"应答不是 JSON 对象: {type(payload).__name__}")
return payload
def fetch_all_pages(*, base=None, path=None, timeout=None, page_size=PAGE_SIZE, fetch=None) -> dict:
"""分页拉全市场。返回 {status, data_date, algo_version, rows, pages, total, matched}。
任一页 status OK 或抛错 整轮失败 ( TechFeedError), 不返回半份"""
if base is None or path is None or timeout is None:
p = _params()
base = p["base"] if base is None else base
path = p["path"] if path is None else path
timeout = p["timeout"] if timeout is None else timeout
rows, offset, pages, matched = [], 0, 0, None
data_date = algo = None
while pages < MAX_PAGES:
payload = fetch_page(base=base, path=path, timeout=timeout,
limit=page_size, offset=offset, fetch=fetch)
status = str(payload.get("status") or "")
if status != "OK":
raise TechFeedError(f"接口状态非 OK: {status or ''}")
if data_date is None:
data_date = _i(payload.get("data_date"))
algo = _s(payload.get("algo_version"))
matched = _i(payload.get("matched"))
items = payload.get("items") if isinstance(payload.get("items"), list) else []
for it in items:
row = parse_item(it, data_date, algo)
if row:
rows.append(row)
pages += 1
got = len(items)
offset += got
if got < page_size: # 最后一页
break
if matched and offset >= matched: # 够了 (服务端若忽略 limit 也在这里收住)
break
return {"status": "OK", "data_date": data_date, "algo_version": algo,
"rows": rows, "pages": pages, "total": len(rows), "matched": matched}
# ================================================================ 落表
def pull(*, fetch=None) -> dict:
"""拉一轮全市场 + 落表 + 删旧。返回读数供调度日志。失败不落表、不推进日期。"""
out = {"ok": True, "at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"data_date": None, "rows": 0, "pruned": 0, "error": None}
p = _params()
if not p["enabled"]:
out.update(ok=False, error="PMS_TECH_ENABLED 关着, 本轮不拉取")
return out
try:
got = fetch_all_pages(base=p["base"], path=p["path"], timeout=p["timeout"], fetch=fetch)
except TechFeedError as e:
out.update(ok=False, error=str(e)[:300])
return out
dd, rows = got["data_date"], got["rows"]
if not dd or not rows:
out.update(ok=False, error="接口未给 data_date 或无读数行")
return out
try:
out["rows"] = tech_repo.upsert_daily(rows)
except Exception as e: # noqa: BLE001
out.update(ok=False, error=f"落表失败: {type(e).__name__}: {e}")
return out
out["data_date"] = dd
out["pages"], out["matched"] = got["pages"], got["matched"]
# 删旧: 保留 keep_days 个交易日, 门槛 = data_date 往前第 keep_days 个交易日
try:
before = tradedays.ymd(tradedays.prev_trade_day(dd, int(p["keep_days"])))
out["pruned"] = tech_repo.prune(before)
except Exception as e: # noqa: BLE001
out["prune_error"] = f"{type(e).__name__}: {e}"
return out
# ================================================================ 映射 (照 logic_state_service)
def load_map() -> dict:
raw = param_store.get(MAP_KEY, "") or ""
if not raw:
return {}
try:
m = json.loads(raw)
except (TypeError, ValueError):
return {}
return m if isinstance(m, dict) else {}
def _save_map(m: dict) -> dict:
r = param_store.set_param(MAP_KEY, json.dumps(m, ensure_ascii=False), "system")
if not r.get("ok"):
logger.error("[技术面] 映射写入失败: %s —— 扫描层沿用上一份 (最多 %d 天后自动失效)",
r.get("error"), FRESH_DAYS)
return r
def state_map(m: dict | None = None) -> dict:
"""{ts_code: 立场 dict} —— 只在映射新鲜时给; 超 FRESH_DAYS 自然日一律按无读数返回空。"""
m = load_map() if m is None else (m or {})
at = str(m.get("at") or "")[:10]
try:
age = (date.today() - date.fromisoformat(at)).days
except ValueError:
return {}
if age > FRESH_DAYS:
return {}
states = m.get("states")
return dict(states) if isinstance(states, dict) else {}
def _compact(st: dict) -> dict:
"""synthesize 的结果压成映射里存的紧凑态 (方案附录丁「逐票紧凑状态」)。"""
return {"stance": st.get("stance"), "strength": st.get("strength"),
"phase": st.get("phase"), "confirm": st.get("confirm"),
"sar_side": st.get("sar_side"), "sar_value": st.get("sar_value"),
"sar_flip_days": st.get("sar_flip_days"), "choppy": st.get("choppy"),
"reason": st.get("reason"), "data_date": st.get("data_date")}
def _tdays_elapsed(from_ymd, to_ymd) -> int:
"""from_ymd 到 to_ymd 之间经过的交易日数 (不含 from, 含 to); to<=from 返回 0。"""
n = tradedays.trade_days_left(to_ymd, from_ymd) # 含两端的交易日数
return max(0, n - 1) if n else 0
def _relevant_codes() -> list:
"""映射要覆盖的票: 在持 + 计划主榜观察 + 待拍板提议。任一来源失败只跳过该来源、记日志。"""
from app.repo import pms_repo
from app.services import plan_feed
codes = []
try:
codes += [r.get("ts_code") for r in pms_repo.list_positions(only_open=True)]
except Exception as e: # noqa: BLE001
logger.warning("[技术面] 取持仓失败: %s", e)
try:
plan = plan_feed.get_plan()
codes += [r.get("ts_code") for r in (plan.get("main") or [])]
codes += [r.get("ts_code") for r in (plan.get("observe") or [])]
except Exception as e: # noqa: BLE001
logger.warning("[技术面] 取计划失败: %s", e)
try:
codes += [r.get("ts_code") for r in
pms_repo.list_proposals(statuses=("WAIT_USER",), limit=200)]
except Exception as e: # noqa: BLE001
logger.warning("[技术面] 取待拍板提议失败: %s", e)
return [c for c in dict.fromkeys(normalize_code(c) for c in codes if c) if c]
def build_map(*, now=None) -> dict:
"""对相关票算技术面立场并写映射。与 logic_state_service.pull_for_held 同构:
任何一步失败都不抛, 取不到写空映射带原因 (扫描层据此按无读数)"""
now = now or datetime.now()
stamp = now.strftime("%Y-%m-%d %H:%M:%S")
out = {"ok": True, "at": stamp, "data_date": None, "codes": 0, "states": 0,
"by_stance": {}, "error": None}
p = _params()
if not p["enabled"]:
_save_map({"at": stamp, "data_date": None, "states": {}, "note": "PMS_TECH_ENABLED 关着"})
out.update(ok=False, error="PMS_TECH_ENABLED 关着")
return out
try:
dd = tech_repo.latest_date()
except Exception as e: # noqa: BLE001
_save_map({"at": stamp, "data_date": None, "states": {}, "error": f"读最新日失败: {e}"})
out.update(ok=False, error=f"读最新日失败: {type(e).__name__}: {e}")
return out
if not dd:
_save_map({"at": stamp, "data_date": None, "states": {}, "note": "库里还没有技术面读数"})
out.update(ok=False, error="库里还没有技术面读数")
return out
out["data_date"] = dd
# 读数陈旧 (最新日距今超 stale_tdays 个交易日) → 整份按无读数, 只记原因
if _tdays_elapsed(dd, tradedays.ymd(now.date())) > int(p["stale_tdays"]):
_save_map({"at": stamp, "data_date": dd, "stale": True, "states": {},
"note": f"技术面读数 {dd} 距今超过 {p['stale_tdays']} 个交易日, 按无读数"})
out.update(ok=False, error="读数陈旧")
return out
codes = _relevant_codes()
out["codes"] = len(codes)
if not codes:
_save_map({"at": stamp, "data_date": dd, "states": {}, "note": "没有需要技术面的票"})
return out
since = tradedays.ymd(tradedays.prev_trade_day(dd, tech_rules.CHOPPY_WINDOW + 5))
try:
hist = tech_repo.history_multi(codes, since=since)
except Exception as e: # noqa: BLE001
_save_map({"at": stamp, "data_date": dd, "states": {}, "error": f"取历史失败: {e}"})
out.update(ok=False, error=f"取历史失败: {type(e).__name__}: {e}")
return out
states = {}
for c in codes:
rows = hist.get(c) or []
if not rows or int(rows[-1].get("data_date") or 0) != dd:
continue # 今日没有这只票的读数 → 无读数, 不拿旧行硬合成
states[c] = _compact(tech_rules.synthesize(rows, params=p["rules"]))
r = _save_map({"at": stamp, "data_date": dd, "states": states})
if not r.get("ok"):
out["error"] = f"映射写入失败: {r.get('error')}"
out["states"] = len(states)
out["by_stance"] = dict(Counter(s.get("stance") or "" for s in states.values()))
return out
def pull_and_map(*, fetch=None) -> dict:
"""拉取 + 落表 + 重建映射, 一把梭 (手动拉取接口与 08:40 补拉用)。落表失败就不建映射。"""
pr = pull(fetch=fetch)
mp = build_map() if pr.get("ok") else {"ok": False, "error": "未落表, 跳过建映射"}
return {"ok": bool(pr.get("ok")), "pull": pr, "map": mp}
# ================================================================ 页面挂载与状态
def attach(rows, states=None):
"""把技术面立场挂到行上 (原地改 r['tech'])。无读数的票挂 None, 页面按无读数显示。"""
states = state_map() if states is None else (states or {})
for r in rows or []:
r["tech"] = states.get(normalize_code(r.get("ts_code") or "")) or None
return rows
def _row_view(r: dict) -> dict:
"""一行落表读数 → 页面友好的三块 (布林/多空布林/SAR)。"""
return {
"data_date": r.get("data_date"),
"boll": {"upper": r.get("boll_upper"), "mid": r.get("boll_mid"), "lower": r.get("boll_lower"),
"bw_pct": r.get("boll_bw_pct"), "pos": r.get("boll_pos"),
"state": r.get("boll_state"), "squeeze": bool(r.get("boll_squeeze"))},
"bbi": {"bbi": r.get("bbi"), "pos": r.get("bbi_pos"),
"state": r.get("bbi_state"), "dist_pct": r.get("bbi_dist_pct")},
"sar": {"value": r.get("sar_value"), "side": r.get("sar_side"),
"flip_days": r.get("sar_flip_days"), "dist_pct": r.get("sar_dist_pct")},
"quality": r.get("quality"), "reanchored": bool(r.get("reanchored")),
}
def research_feed(ts_code: str) -> dict:
"""单票研究面的技术面一块: 最新读数 + 合成立场 + 近日翻向次数。点击时直接读库算。"""
code = normalize_code(ts_code or "")
if not code:
return {"tech": None, "error": "代码认不出"}
if not enabled():
return {"tech": None, "note": "PMS_TECH_ENABLED 关着"}
p = _params()
try:
dd = tech_repo.latest_date()
since = tradedays.ymd(tradedays.prev_trade_day(dd, tech_rules.CHOPPY_WINDOW + 5)) if dd else 0
rows = tech_repo.history(code, since=since, limit=tech_rules.CHOPPY_WINDOW + 5)
except Exception as e: # noqa: BLE001
return {"tech": None, "error": f"{type(e).__name__}: {e}"}
if not rows:
return {"tech": None, "note": "这只票没有技术面读数"}
st = tech_rules.synthesize(rows, params=p["rules"])
return {"tech": {**_compact(st), "latest": _row_view(rows[-1]),
"flips": tech_rules.count_sar_flips(rows), "bars": len(rows)}}
def status() -> dict:
"""技术面模块状态: 开关/最新读数日/今日行数/映射覆盖与新鲜度/错误。页面顶栏芯片与运维看。"""
p = _params()
out = {"enabled": p["enabled"], "base_set": bool(p["base"]), "data_date": None,
"rows_today": 0, "map": {}, "error": None}
if not p["enabled"]:
out["note"] = "PMS_TECH_ENABLED 关着"
return out
try:
dd = tech_repo.latest_date()
out["data_date"] = dd
out["rows_today"] = tech_repo.count_on(dd) if dd else 0
except Exception as e: # noqa: BLE001
out["error"] = f"读库失败: {type(e).__name__}: {e}"
m = load_map()
if m:
out["map"] = {"at": m.get("at"), "data_date": m.get("data_date"),
"states": len(m.get("states") or {}), "fresh": bool(state_map(m)),
"note": m.get("note"), "error": m.get("error"), "stale": m.get("stale")}
return out

View File

@ -706,6 +706,40 @@ def api_upstream_signals_by_code(ts_code: str):
return ok(upstream_signals.by_code, ts_code) return ok(upstream_signals.by_code, ts_code)
# ================================================================ 技术面与研究面 (2026-09-11 技术面接入)
@app.get("/api/tech/status")
def api_tech_status():
"""技术面模块状态 (顶栏新鲜度芯片与设置页只读): 开关/最新读数日/今日行数/映射覆盖与新鲜度/错误。"""
from app.services import tech_service
return ok(tech_service.status)
@app.post("/api/ops/tech-pull")
def api_tech_pull():
"""手动拉取技术面并重建映射 (= 06:30 调度位; /api/ops 族归系统管理员)。供判收与盘中补拉。"""
from app.services import tech_service
return ok_logged("tech_pull", tech_service.pull_and_map)
@app.get("/api/research/{ts_code}")
def api_research(ts_code: str):
"""单票研究面 · 三源合议 (点击时调, 只读, 绝不进轮询)。
工作包一先给技术面读数一块; 基本面评析择时立场与三源合议由工作包二补 (现以 None 占位,
页面按缺块处理)代码归一后回"""
from app.services import tech_service
def _build(code):
code = cs.normalize_code(code)
t = tech_service.research_feed(code)
return {"ts_code": code, "company": None, "tech": t.get("tech"),
"tech_note": t.get("note") or t.get("error"),
"consensus": None, "timing": None, "feed": None,
"sources": {"tech": ("ok" if t.get("tech")
else (t.get("error") or t.get("note") or "无读数"))}}
return ok(_build, ts_code)
# ================================================================ 宏观择时 (MACRO_TIMING_PLAN.md) # ================================================================ 宏观择时 (MACRO_TIMING_PLAN.md)
@app.get("/api/macro/status") @app.get("/api/macro/status")
def api_macro_status(): def api_macro_status():

View File

@ -416,3 +416,43 @@ CREATE TABLE IF NOT EXISTS pms_nav_daily (
price_missing INT NOT NULL DEFAULT 0 COMMENT '现价缺失只数(>0=按成本顶价估算)', price_missing INT NOT NULL DEFAULT 0 COMMENT '现价缺失只数(>0=按成本顶价估算)',
created_at DATETIME NOT NULL created_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日净值快照 (公示导出)'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日净值快照 (公示导出)';
-- 20. 全市场每日技术面读数 (2026-09-11 技术面接入: 布林线 / 多空布林线 / SAR)
-- 数据源: 决策系统 GET /api/v1/market/technical (每晚全市场扫, 实测 00:38 前后算完)。
-- 存全市场而非只存持仓与候选 —— 新进榜的票要 20 个交易日历史才判得了震荡市。
-- 保留 40 个交易日 (pms.tech_pull 落表后按 data_date 删更旧的)。唯一键: 读数日 + 代码。
-- 代码由接口的 SH600000 归一成点式 600000.SH 落库; 读数日与 bar 日是北京日期, 不换算。
CREATE TABLE IF NOT EXISTS pms_tech_daily (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
data_date INT NOT NULL COMMENT '读数日 YYYYMMDD (北京日期, 接口 data_date)',
ts_code VARCHAR(16) NOT NULL COMMENT 'Tushare 点式; 接口 SH600000 归一而来',
stock_name VARCHAR(32) NULL,
boll_upper DECIMAL(12,4) NULL COMMENT '布林上轨',
boll_mid DECIMAL(12,4) NULL COMMENT '布林中轨',
boll_lower DECIMAL(12,4) NULL COMMENT '布林下轨',
boll_bw_pct DECIMAL(10,4) NULL COMMENT '带宽 (上下轨差/中轨, 百分比)',
boll_pos DECIMAL(8,4) NULL COMMENT '价格在带内位置 0~1',
boll_state VARCHAR(16) NULL COMMENT '接口文字态 (带内偏上 等)',
boll_squeeze TINYINT NULL COMMENT '今天是否收口 (接口直接给, 1=收口)',
bbi DECIMAL(12,4) NULL COMMENT '多空布林线 BBI 中枢',
bbi_upper DECIMAL(12,4) NULL,
bbi_lower DECIMAL(12,4) NULL,
bbi_pos DECIMAL(8,4) NULL,
bbi_state VARCHAR(16) NULL COMMENT '多头区 / 中性区 / 空头区',
bbi_dist_pct DECIMAL(10,4) NULL COMMENT '现价距 BBI 中枢百分比',
sar_value DECIMAL(12,4) NULL COMMENT 'SAR 值 (跟踪止损位)',
sar_side VARCHAR(4) NULL COMMENT '多 / 空',
sar_flip_days INT NULL COMMENT '本次翻向后经过的交易日数',
sar_dist_pct DECIMAL(10,4) NULL COMMENT '现价距 SAR 百分比',
reanchored TINYINT NULL COMMENT '是否做了除权重锚 (1=是; 非 1 一律按无读数)',
in_pool TINYINT NULL COMMENT '是否在决策系统夜间分析池',
quality VARCHAR(8) NULL COMMENT '数据质量 (OK / ...; 非 OK 按无读数)',
bars_used INT NULL COMMENT '计算用了多少根 bar',
last_bar_date INT NULL COMMENT '最后一根 bar 的北京日期 YYYYMMDD',
bars_lag INT NULL COMMENT 'bar 滞后根数',
algo_version VARCHAR(24) NULL COMMENT '接口算法版本 (tech_view_v1 等)',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_date_code (data_date, ts_code),
KEY idx_code_date (ts_code, data_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='全市场每日技术面读数 (保留 40 个交易日, 供回看判震荡与开口)';

View File

@ -87,3 +87,27 @@
顺带修了四处"弹了遮罩但没有信息":接口整体失败不再抹平成空数据(原来失败时页面显示"今天没有信号",在风控面板上这是最危险的一种谎)、告警派生的三个分组补失败角标、失败角标的样式选择器漏了信号栏、错误横幅在遮罩之上再浮一份。 顺带修了四处"弹了遮罩但没有信息":接口整体失败不再抹平成空数据(原来失败时页面显示"今天没有信号",在风控面板上这是最危险的一种谎)、告警派生的三个分组补失败角标、失败角标的样式选择器漏了信号栏、错误横幅在遮罩之上再浮一份。
**复核日期。** 不复核,功能改动。 **复核日期。** 不复核,功能改动。
## 006 · 2026-09-11 · 技术面相位与五个阈值
**改动。** 每只票多一份技术面立场取值只有看多、看空、中性、无读数四个词。立场由决策系统的布林线、多空布林线、SAR 三个指标合成。合成先判有没有读数,再判是不是震荡市,再按固定次序判相位,第一个命中的就是结论。九个相位与条件见方案第三节的相位表。
入场靠投票离场靠纪律。技术面这一票只进入场方向的合议那是工作包二。SAR 翻空的离场是纪律不是投票,那是工作包三。任何一路取不到读数一律弃权,绝不折成看空。
**五个阈值一次定死,要调先回这条台账,不按复盘读数回调。**
| 阈值 | 参数 | 值 | 含义 |
|---|---|---|---|
| 读数新鲜 | PMS_TECH_STALE_TDAYS | 2 | 读数超过两个交易日没更新按无读数 |
| 收口回看 | PMS_TECH_SQUEEZE_LOOKBACK | 5 | 近五个交易日内收过口才算刚收口过 |
| 开口带宽 | PMS_TECH_OPEN_BW_GROWTH | 0.20 | 今日带宽比收口那天扩两成才算开口 |
| 震荡翻向 | PMS_TECH_CHOPPY_FLIPS | 4 | 最近二十个交易日 SAR 翻向四次即震荡市 |
| 翻向新鲜 | PMS_TECH_FLIP_FRESH_DAYS | 2 | SAR 翻向后两个交易日内算刚转向 |
盘中放量确认的量比阈值一点五与解锁重问那次同源,属工作包二三,到那时再记。
**依据。** 决策系统技术面接口每晚扫全市场实测零点三十八分前算完。布林收口、多空布林线区、SAR 方向翻向止损位这些接口直接给;近五日收口与二十日翻向要回看历史,所以新表 pms_tech_daily 存全市场四十个交易日。震荡市里 SAR 翻向不当信号,这是决策系统文档明说的指标特性。对照机构做法,这是波动阶段加趋势过滤加跟踪止损的标准组合。
**预期。** 技术面整体无读数占比应低于百分之三。每天被等技术面开口拦下的票在一到十五只之间,这一条要等工作包二接入分流后才看得到。
**复核日期。** 工作包二的合议分流上线后满三个交易日。

View File

@ -0,0 +1,253 @@
# 技术面接入与三源合议 · 开发方案2026-09-11
> 本文是这一阶段改进的开发方案,供拍板与实施。写法遵守 `.claude/output-styles/readable-chinese.md`
> 第一到第八节是正文,读完就能照做;附录是实现细节,标了「可不读」。
---
## 一、这件事为最终目标买什么
我们是一套交易系统,交易要基于自己手里的数据。现在手里有两份数据没用好。
第一份是数据基座的买方评析。它已经流进持仓管理系统,但只在建议方案矩阵里粗用一次,页面上一整句挤在提议卡和持仓表里,看不清也用不上。
第二份是择时决策系统新开的技术面接口。它每晚扫全市场给每只票算布林线、多空布林线、SAR 三个指标。持仓管理系统还没接。
目标是让每只票在持仓管理系统里有三份结构化意见基本面立场、技术面立场、择时系统立场。三方多数决定方向方向进入现有的规则闸、研判闸、档位分流那条链一行不改。基本面定预算技术面定首批比例与增持节奏SAR 翻空是自动执行的离场纪律。页面把三份意见分开摆,点开能看全文。
**用户已拍板的四条。**
1. 买方评析没有读数或超过 120 天的候选不建仓。
2. 合议参与分流的开关上线即开。
3. SAR 转空的减持与清仓上线即自动执行,与保垫减仓同档。
4. 同时改选股系统的打分,让质地进入排序。那是另一个仓库,动之前单独审批。
**已查实的两个读数2026-09-11只读探测。**
一,当天计划主榜 96 只里前 50 名全部有买方评析,强传导的 43 只也全部有。所以「无评析不建仓」今天不损失任何候选。
二,主榜前 100 名有评析的 70 只里,质地差 50、中 14、好 6。选股确实偏题材传导改打分有必要。
---
## 二、设计原则
三条原则写进代码注释与台账。
1. 入场投票离场纪律。方向由三方多数决定。止损止盈不投票SAR 翻空像保垫减仓一样按规则执行。
2. 无读数等于弃权。任何一路取不到都不投票、不设门、不出退出提议,绝不折成看空。
3. 研判闸不当选票。择时系统那一票只用昨夜定性与盘中转多留痕。研判仍是合议之后的关口,不重复计数。技术面整句不送研判,因为决策系统夜里自己算过一份,两份读数可能不一致。
对照机构的做法这是一套「波动阶段加趋势过滤加跟踪止损」的标准组合布林线收口开口判变盘阶段多空布林线判中期多空SAR 做跟踪止损。基本面决定投多少,技术面决定什么时候投和什么时候撤,择时系统决定盘中怎么下单。
---
## 三、技术面意见怎么合成
三个指标各回答一个问题。布林线回答「波动处在哪个阶段」。多空布林线回答「中期多空谁占上风」。SAR 回答「趋势有没有延续、止损位在哪」。
合成分三步。
第一步判有没有读数。没有当天行、读数超过两个交易日、没做除权重锚、数据质量不是正常,四种情形一律算无读数。
第二步判是不是震荡市。数最近 20 个交易日 SAR 翻向了几次,四次以上就是震荡市。震荡市里 SAR 翻向不当信号,这是决策系统文档里明说的指标特性。
第三步判相位,第一个命中的就是结论。
| 相位 | 条件 | 立场 |
|---|---|---|
| 收口等待 | 今天布林带收口(带宽处于近 60 日最低两成) | 中性,只等开口 |
| 开口向上 | 今天不收口,近 5 日内收过口,带宽比收口时至少扩 20%价格在带内上半部SAR 为多 | 看多,强 |
| 开口向下 | 对称价格在带内下半部SAR 为空 | 看空,强 |
| 转空 | SAR 为空且翻向不超过 2 天,非震荡市 | 看空;多空布林线也在空头区或价格贴下轨为「确认」 |
| 转多 | SAR 为多且翻向不超过 2 天,多空布林线不在空头区,非震荡市 | 看多,弱 |
| 趋势多 | SAR 为多且多空布林线在多头区 | 看多,强;中性区为弱 |
| 趋势空 | SAR 为空且多空布林线不在多头区 | 看空 |
| 分歧 | SAR 与多空布林线方向相反 | 中性 |
| 震荡 | 震荡市且以上都不命中 | 中性 |
盘中另有一道确认,只用于入场。时段在 09:45 到 14:30时段折算量比不低于 1.5。昨天已开口的票要求现价不低于当日均价。还在收口的票,今天现价站上昨日上轨且放量,当天就视为开口进候选。
五个数字一次定死并记台账:读数两天内算新鲜,回看 5 日内的收口,带宽扩 20% 算开口20 日翻向 4 次算震荡,量比 1.5 算放量。量比口径与解锁重问那次同源。
---
## 四、基本面与择时系统的意见怎么读
**基本面。** 质地好为看多。质地中且三类里两好零差、十视角差不超过一个,也算看多,这就是「整体方向为好就够」。质地差或有硬疑点为看空。其余中性。缺报告或超过 120 天为无读数。十视角好达八个且估值贵时打「共识已定价」标记,不改立场,只让首批推迟。
**择时系统。** 昨夜定性 BUY 为看多WATCH 中性SELL、AVOID、DROPPED 看空,超期或缺为无读数。当天盘中转多留痕把无读数与中性升为看多,与看空相遇归中性。
**合议。** 三票多数决定方向。无读数弃权不计。平局中性。只有一方表态算弱。三方一致算强。合议之后按路由表决定放行、观察、交人或跳过,路由表在附录丙。
**仓位矩阵。** 基本面定预算,技术面定首批比例,见附录乙。两条来自你的规则:基本面好但技术面差,不放弃,进观察态,转向后分批建,已持仓只停增持不卖;基本面差但技术面好,只给试探仓一批,交人拍板,并配回撤 3%、硬目标 8% 的紧止盈与 SAR 线。
---
## 五、做哪几件事
### 工作包一:技术面取数、落表、映射与页面显示
先做它,是因为后面所有判定都依赖一份持仓管理系统自己记下的读数,先记账后动作。
1. 新表 `pms_tech_daily`,存全市场每日读数,唯一键是数据日加代码,保留 40 个交易日。存全市场而不只存持仓与候选,是因为新进榜的票需要 20 天历史判震荡。
2. 新仓库 `app/repo/tech_repo.py`,全部单表访问。
3. 新服务 `app/services/tech_service.py`。分页拉接口逐页落表,状态不是 OK 就不动表。每早对在持、计划主榜、观察档、有策略的票、等拍板的票算一份技术面判定,写运行参数 `PMS_TECH_STATE_MAP`,与逻辑状态映射同一手法。
4. 调度新增 06:30 一位 `pms.tech_pull`。决策系统夜扫 23:20 起、实测 00:38 完留足余量。08:40 拉计划之后补拉一次并重建映射,让映射覆盖当天的榜。
5. 三个接口:技术面状态、手动拉取(管理员)、单票研究面(评析全文、技术面读数、择时立场、合议,只在点击时调)。
6. 页面七处:提议卡卡面换成三枚芯片加一行合议,整句收进展开区并各带标签;我的持仓新增「研究面」列;单票抽屉顶部新增「研究面 · 三源合议」一节;管理视图持仓总览修一处显示错误并加「研究面」列与评析报告链接;候选栏每行加质地与技术芯片,新处置词「等技术面开口」;顶栏新增「技术面 MM-DD」新鲜度芯片设置卡新增「研究面与合议」一张。
### 工作包二:三源合议与仓位矩阵
1. 四个纯逻辑模块:`app/core/tech_rules.py`、`app/core/fund_rules.py`、`app/core/timing_rules.py`、`app/core/consensus.py`。零外部依赖,可单测。
2. 批量读昨夜定性的仓库函数,与装配候选和持仓行四块意见的服务 `app/services/consensus_service.py`
3. `advice_service.advise_v2()` 新函数,旧函数原样保留。
4. 候选排序:先按基本面立场分桶再按分数,在主题限额与截断之前生效。技术面不参与排序,只当门。观察档里质地看多的行进池默认关,守「宁缺毋滥」。
5. 新建仓扫描接入合议与路由。跳过与观察不占名额不占金额。硬数字新增六键,不进送研判白名单。
6. 持仓增持门:合议看空停增持侧三类;技术面看空跳过回踩补足与盈利加仓;基本面看空跳过补仓。减持侧不受门影响。
### 工作包三:离场纪律
1. 新评估器 `eval_tech_exit`。相位为转空且翻向不超过 2 天才评。确认转空清仓全部可卖量,未确认减三分之一。按你拍板默认自动执行,与保垫减仓同档。同一次翻向只处理一次。
2. 扫描里「策略票只看目标价」那条判断改成按函数判,否则策略票会被新评估器误放行。
3. 跟踪止盈策略加盘中 SAR 止损线09:45 后现价低于昨日 SAR 值千分之三即卖,当日只触发一次。每天 09:40 把映射里的 SAR 值刷进每条自动挂载的跟踪止盈。
4. 紧止盈自动挂载:基本面看空加技术面看多的试探仓,次日 09:40 自动挂回撤 3%、硬目标 8%、带 SAR 线的跟踪止盈,不占每日新挂名额。
### 工作包四:选股系统打分(另一仓库,单独审批)
**为什么动。** 主榜前 100 名里质地差占一半,好只有 6 只。质地不进排序就永远排在后面,决策系统夜间分析池按这份排名取前 50好公司也进不了分析池。
**动哪里。** `akg-factor-bridge/plan.py` 装配阶段已经读到评析字典。算质地调整分:好加 8、差减 8、其余 0在同一传导档内排序不跨档。档步长是 20组内分夹正负 9.9,加 8 后最大 17.9 仍小于 20先档后分的次序不破。主榜与观察档按调整分重排并重编名次原分数保留供档位解码接口与快照多带一个调整分字段。不动因子表不在每早 07:10 的因子构建里读数据基座库。判决收敛仍只降不升。
**怎么回退。** 环境变量把加分设为零即关,或回滚提交后重启桥容器。
**执行方式。** 代码与自测先在本地做完并提交。155 上拉代码后重启桥容器要先经你批准,避开每早 07:10。台账记到 akg-factor-bridge 第 058 条。
### 工作包五:文档、台账与登记
台账追加 006 到 011候选按质地排序基本面立场规则技术面相位与五个阈值三源合议路由表SAR 转空自动离场弱基本面入场的紧止盈。开发节点记录追加五段式节点。README 调度总表补齐 13 个调度位并加一节。接口契约文档新增技术面只读接口一节,注明研判请求体不变。
---
## 六、开关与参数
全部经参数中心读,页面可改即时生效。每个键都有真实读取处,否则死参数扫描会红。
| 键 | 默认 | 说明 |
|---|---|---|
| PMS_TECH_ENABLED | True | 总闸。关掉即不拉取、不挂读数、不设门、不出退出、策略忽略 SAR 线,行为逐字如旧 |
| PMS_TECH_API_BASE 与路径与超时 | 空、/api/v1/market/technical、20 秒 | 地址空则沿用研判接口根地址 |
| PMS_TECH_KEEP_DAYS、PMS_TECH_STALE_TDAYS | 40、2 | 保留交易日数;读数超此日龄按无读数 |
| PMS_TECH_STATE_MAP | 空 | 运行态映射,页面只读 |
| PMS_TECH_SQUEEZE_LOOKBACK、PMS_TECH_OPEN_BW_GROWTH | 5、0.20 | 开口判定回看根数;带宽至少扩多少 |
| PMS_TECH_CHOPPY_FLIPS、PMS_TECH_FLIP_FRESH_DAYS | 4、2 | 20 日翻向次数判震荡;翻向几天内算新鲜 |
| PMS_TECH_VOL_CONFIRM、PMS_TECH_INTRADAY_BREAKOUT、PMS_TECH_INTRADAY_WINDOW | 1.5、True、0945-1430 | 盘中放量确认 |
| PMS_FUND_REQUIRED、PMS_FUND_STALE_DAYS、PMS_FUND_CONSENSUS_GOOD_MIN | True、120、8 | 无评析不建仓;评析陈旧天数;共识已定价的好视角数 |
| PMS_CONSENSUS_ROUTE、PMS_CONSENSUS_WEAK_CONFIRM、PMS_TECH_GATE_INCREASE | True、True、True | 合议参与分流;单方表态交人;增持侧三道门 |
| PMS_TECH_EXIT_AUTONOMY、PMS_TECH_EXIT_TRIM_RATIO | full、三分之一 | 转空处置 off、propose_only、full未确认减持比例 |
| PMS_TECH_SAR_STOP_ON_TRAIL、PMS_TECH_SAR_STOP_BUFFER | True、0.003 | 自动跟踪止盈每日刷新 SAR 线;跌破缓冲 |
| PMS_TECH_TIGHT_TRAIL_GIVEBACK、PMS_TECH_TIGHT_TRAIL_TARGET | 0.03、0.08 | 试探仓紧止盈回撤与硬目标 |
| PMS_PLAN_RANK_BY_QUALITY、PMS_PLAN_OBSERVE_IF_FUND_BULL | True、False | 候选先按质地分桶;观察档里质地看多的行进池 |
---
## 七、验证方式
每条命令标明在哪台机器上运行,一个命令块只放一台机器。
**开发机。** 预期两道页面守卫都打 ALL OK新批次打 ALL PASS。
```bash
cd /Users/baobao/Documents/work/project/tradingSystem && python3 scripts/test_page_wiring_guard.py && python3 scripts/test_page_enum_guard.py && python3 scripts/test_batch27_units.py
```
**决策系统机 188只读探活。** 预期 status 为 OKdata_date 是最近交易日。
```bash
curl -s "http://127.0.0.1:38000/api/v1/market/technical?limit=2" | head -c 400
```
**模拟仓 155仓库根目录。** 部署要重建容器,先经你批准,收盘后执行。预期 make test 最后一行 ALL SUITES PASS建表输出多出 pms_tech_daily 一张。
```bash
cd /home/factor/project/tradingSystem && git pull --ff-only && make deploy && docker compose run --rm --no-deps pms-web python scripts/init_db.py --yes && make test
```
**模拟仓 155手动拉一次技术面并看映射。** 预期 ok 为真data_date 是最近交易日,行数五千上下,映射只数等于持仓加候选。
```bash
cd /home/factor/project/tradingSystem && docker compose run --rm --no-deps pms-web python -c "from app.services import tech_service as t; import json; print(json.dumps(t.pull_and_map(), ensure_ascii=False)[:600]); print(json.dumps(t.status(), ensure_ascii=False)[:600])"
```
**模拟仓 155全链试算不落表。** 预期每个候选带合议方向与技术立场,跳过原因里出现「等待技术面开口」或「没有买方评析」。
```bash
cd /home/factor/project/tradingSystem && docker compose run --rm --no-deps pms-web python -c "from app.services import proposal_service as p; import json; d=p.scan_and_route(dry_run=True); print(json.dumps(d, ensure_ascii=False)[:1500])"
```
**页面判收。** 顶栏出现绿色「技术面 MM-DD」芯片。我的持仓多一列研究面。点任一票抽屉第一节是三源合议四枚芯片。候选栏出现「等技术面开口」。提议卡卡面第三段是三芯片加合议行。管理视图证据列不再印两遍旁边有评析报告链接。
**第一个交易日早上 08:50 之后,模拟仓 155。** 预期映射写入时刻是当天 08:4x读数日是前一交易日错误为空。
```bash
cd /home/factor/project/tradingSystem && docker compose run --rm --no-deps pms-web python -c "import json; from app.services import param_store as ps; m=json.loads(ps.get('PMS_TECH_STATE_MAP') or '{}'); print(m.get('at'), m.get('data_date'), len(m.get('states') or {}), m.get('error'))"
```
**回退。** 参数即时生效,不重启。一键全关把 PMS_TECH_ENABLED 设为关。只关分流把 PMS_CONSENSUS_ROUTE 设为关。只关自动离场把 PMS_TECH_EXIT_AUTONOMY 改成 propose_only 或 off。
---
## 八、上线与观察
按你拍板,四个开关上线即开。观察读数按天记。技术面无读数占比应低于 3%。每天被「等待技术面开口」拦下的票应在 1 到 15 只之间,零说明太紧。技术面退出每周 0 到 3 条,误伤逐只记台账 010。三源合议方向与人的判断对不上的逐只记。选股系统打分改动单独排期批准后再动。
**不在本次。** 建议方案里赔率分支在计划路径永远不触发的既有缺陷另开一次修。数据基座只给计划前 120 只出评析全市场里质地好但没进计划的公司现在发现不了扩评析范围是数据基座的事另议。README 里影子模式的过时说法,交接文档第九节已在等你拍板。技术面整体无读数时自主新建仓不停,走旧管线。
---
## 附录甲(可不读):要改的文件与哨兵
**新增。** `app/repo/tech_repo.py`、`app/services/tech_service.py`、`app/services/consensus_service.py`、`app/core/tech_rules.py`、`app/core/fund_rules.py`、`app/core/timing_rules.py`、`app/core/consensus.py`、`scripts/test_batch27_units.py`。
**修改。** `ddl_pms_v1.sql`、`app/scheduler.py`、`app/core/tradedays.py`(加上一交易日函数)、`app/repo/downstream_repo.py`(批量读昨夜定性)、`app/services/plan_feed.py`、`app/services/advice_service.py`、`app/core/action_engine.py`、`app/services/proposal_service.py`、`app/services/judge.py`(白名单注释)、`app/services/strategy_service.py`、`app/services/strategy_runner.py`、`app/services/strategy_advisor.py`、`app/services/logic_state_service.py`、`app/services/param_store.py`、`config/settings.py`、`app/web/main.py`、`app/web/static/index.html`、`scripts/test_page_enum_guard.py`、`scripts/run_tests.py`、`README.md`、`CLAUDE.md`、`BIONIC_PMS_INTERFACE.md`、`DEVLOG.md`、`docs/复盘决定台账.md`。
**另一仓库。** `akg-factor-bridge/plan.py`、新增 `test_plan_quality_rank.py`、其台账。
**哨兵同步。** DDL 表数 19 改 20test_batch6 第 594 行)。调度位集合与任务名加 tech_pulltest_wiring 第 663 到 674 行。路由清单加三个新路由test_wiring 第 642 到 654 行)。动作求值器次序改为 TRIM、TRIM、EXIT、EXIT、ADD、FILL、DCAtest_batch12 第 372 到 373 行。run_tests 登记新批次与例数。页面文案守卫把 flags 进枚举名单、立场词进开放集。
**新批次约 50 例。** 技术面相位表逐格与盘中确认;基本面七情形;择时五情形;合议多数决与路由六格;仓位矩阵;新建仓扫描的跳过不占名额、收口观察、盘中确认进候选、弱表态交人、开关关时与旧路逐字相同;持仓三道门;技术退出五情形与同轮优先级;研判白名单不含六键;策略层 SAR 线触发、09:45 前不触发、当日一次、校验拒非法值;自动挂载刷新与紧止盈;取数服务分页、状态非 OK 不落表、页失败不推进日期、代码归一;候选分桶与观察档;参数登记与单表守卫;页面组四条。
## 附录乙(可不读):基本面与技术面的仓位矩阵
| 基本面 \ 技术面 | 看多 | 中性 | 看空 |
|---|---|---|---|
| 看多 | 标准仓 5/2.5/2.5;估值贵减半仓 5/5共识已定价减半仓且首批推迟 | 同左;收口等待已在路由层拦成观察 | 观察,等技术面转向;已持仓只停增持 |
| 中性 | 标准仓 3/3.5/3.5,后两批要求技术面看多且非高位兑现 | 同左 | 观察 |
| 看空 | 试探仓 1%,一批,交人,配紧止盈与 SAR 线 | 不建 | 不建 |
| 无读数 | 不建(没有买方评析) | 不建 | 不建 |
保留的既有调整:逻辑存疑不建;定价状态高位兑现首批推迟;研判把握度低降为试探仓并重算股数。「第二批要求技术面不看空」从文案变成可执行的门。
## 附录丙(可不读):合议路由表
| 情形 | 路由 |
|---|---|
| 基本面无读数且开关开着 | 跳过,原因「没有买方评析」 |
| 方向看空 | 跳过,原因写三方意见 |
| 中性且技术面收口等待且基本面非看空 | 观察,等开口 |
| 中性且基本面看多技术面看空 | 观察,等技术面转向 |
| 基本面看空技术面看多 | 交人,试探仓 |
| 看多但异议在择时系统 | 交人 |
| 看多但只有一方表态 | 交人 |
| 看多强或异议只在技术面 | 放行 |
| 三方都无读数 | 放行,机械方案 |
## 附录丁(可不读):数据契约
立场词与合议词一律是中文原文,取值只有看多、中性、看空、无读数。
新建仓提议的硬数字新增六键:`fund_stance`(立场、标记、一句事实)、`tech`三指标读数加派生的立场、相位、理由、SAR 止损位、读数日)、`tech_text`、`timing`(立场、昨夜定性、盘中转多时刻)、`consensus`(方向、三票、强弱、一句理由)、`consensus_text`。这六键不进 `judge.OPEN_JUDGE_KEYS`
持仓接口每行新增 `company`(评析全文与立场)、`tech`、`consensus` 三块,顶层新增 `research_feed` 记技术面读数日期、缺失名单与错误。单票研究面接口返回 `company`、`tech`、`consensus`、`timing`、`feed`、`sources` 六块,`sources` 逐路标失败。候选处置快照每行加 `fund`、`tech`、`consensus` 三小块,处置词新增 `wait_tech`
技术面映射的形状:写入时刻、读数日、日龄、算法版本、是否完整、逐票紧凑状态、各态计数、错误。超三个自然日不刷新按无读数。

View File

@ -68,15 +68,20 @@
白名单不收/判决候选而逻辑存疑强制交人/持仓存疑停增持侧/研究走弱 白名单不收/判决候选而逻辑存疑强制交人/持仓存疑停增持侧/研究走弱
减持默认关且开了必交人/同轮减持优先级/早上取回与映射新鲜度/策略 减持默认关且开了必交人/同轮减持优先级/早上取回与映射新鲜度/策略
买入腿按来源暂停恢复/持仓视图两栏三情形/假仓库签名/安全边际整句透传/参考目标价/催化事件与定价状态透传/两个期限的头 (19 ) 买入腿按来源暂停恢复/持仓视图两栏三情形/假仓库签名/安全边际整句透传/参考目标价/催化事件与定价状态透传/两个期限的头 (19 )
test_batch26_units.py 建议档位与真下股数对齐 (2026-09-10): 卡上写试探仓 1% 采纳后就真只买 1%/
提议卡重排要紧的在前证据收进展开/建议档位与实下股数不一致标出 (17 )
test_batch27_units.py 技术面接入·工作包一 (2026-09-11): 相位合成九格与四种无读数/震荡市抑制翻向/
取数解析与代码归一/分页拼接与状态非 OK 不落表/映射只覆盖有当天读数的相关票/
读数陈旧按无读数/参数登记与交易日差 (27 )
test_page_enum_guard.py 页面文案守卫 (静态扫描, 不连库不起浏览器): 枚举字段不许 test_page_enum_guard.py 页面文案守卫 (静态扫描, 不连库不起浏览器): 枚举字段不许
直接印到页面上 / 判据码显示前必须剥前缀 / 不许把整个对象 直接印到页面上 / 判据码显示前必须剥前缀 / 不许把整个对象
打给交易员看 / 翻译兜底不许让英文码单独当句子 (1 ) 打给交易员看 / 翻译兜底不许让英文码单独当句子 (1 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) + test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) +
目标价到价必定入队 (档位 full 也不自动卖) + 目标价到价必定入队 (档位 full 也不自动卖) +
用户设的止损价与目标价单独成列显示 (70 ) 用户设的止损价与目标价单独成列显示 (70 )
680 724
(总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条 (总数按实跑逐批相加校正过两次: 曾写 649 是笔误, 实为 650; 09-03 先后加了同轮只发一条
减持与研究理由两键各一例, 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 654; 又加了页面文案守卫一例, 655; 09-07 审查修复加了跨轮减持等五例, 660; 第二件低把握驳回交人一例, 661; 第三件逻辑状态接入第二十二批十五例, 676; 第四件安全边际整句透传一例, 677; 参考目标价一例, 678; 催化事件与定价状态透传一例, 679; 两个期限的头一例, 现为 680) 减持与研究理由两键各一例, 652; 09-04 加了仅展示跳过原因与空候选说明各一例, 654; 又加了页面文案守卫一例, 655; 09-07 审查修复加了跨轮减持等五例, 660; 第二件低把握驳回交人一例, 661; 第三件逻辑状态接入第二十二批十五例, 676; 第四件安全边际整句透传一例, 677; 参考目标价一例, 678; 催化事件与定价状态透传一例, 679; 两个期限的头一例, 现为 680; 09-10 建议档位对齐第二十六批十七例到 697; 09-11 技术面接入工作包一第二十七批二十七例到 724)
任一子集失败即整体失败 (退出码 1) 任一子集失败即整体失败 (退出码 1)
哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了): 哨兵位置清单 (2026-09-03 抄录; 改了对应的东西就得来这些地方改断言, 断言不动就是漏了):
@ -125,6 +130,7 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py",
"test_batch21_units.py", "test_batch22_units.py", "test_batch23_units.py", "test_batch21_units.py", "test_batch22_units.py", "test_batch23_units.py",
"test_batch24_units.py", "test_batch25_units.py", "test_batch24_units.py", "test_batch25_units.py",
"test_batch26_units.py", "test_batch26_units.py",
"test_batch27_units.py",
"test_page_enum_guard.py", "test_page_enum_guard.py",
"test_page_wiring_guard.py", "test_page_wiring_guard.py",
"test_wiring.py"] "test_wiring.py"]

View File

@ -0,0 +1,332 @@
# -*- coding: utf-8 -*-
"""技术面接入 · 工作包一 (2026-09-11 方案第五节, 台账 008)。全部离线, 不连库不连网。
A 相位合成 (core/tech_rules): 四种无读数九个相位逐格震荡市抑制翻向信号数翻向
B 取数解析 (tech_service.parse_item): 代码归一 SH600000600000.SH字段映射认不出返回 None
C 分页取数 (fetch_all_pages): 多页拼接matched 终止状态非 OK 抛错 (注入 fetch, 不连网)
D 映射 (build_map): 正常合成只覆盖有当天读数的相关票读数陈旧整份按无读数 (桩掉库与网络)
E 参数与口径: PMS_TECH_* 登记进 RUNTIME_EXTRA _RANGES交易日差口径
F 落表 (pull): 接口状态非 OK 整轮不落表正常落表并推进日期 (桩掉库)
"""
import os
import sys
import traceback
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.core import tech_rules as tr # noqa: E402
from app.services import param_store # noqa: E402
from app.services import tech_service as ts # noqa: E402
RESULTS = []
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
class _Patch:
def __init__(self): self._saved = []
def __enter__(self): return self
def __call__(self, obj, name, value):
self._saved.append((obj, name, getattr(obj, name))); setattr(obj, name, value)
def __exit__(self, *a):
for obj, name, v in reversed(self._saved):
setattr(obj, name, v)
def _row(dd, *, side="", pos=0.6, squeeze=False, bw=5.0, bbi="中性区",
flip=8, quality="OK", reanchor=1, sar=10.0):
"""造一行 pms_tech_daily 读数 (只填 synthesize 用得到的字段)。"""
return {"data_date": dd, "ts_code": "600000.SH", "sar_side": side, "boll_pos": pos,
"boll_squeeze": 1 if squeeze else 0, "boll_bw_pct": bw, "bbi_state": bbi,
"sar_flip_days": flip, "quality": quality, "reanchored": reanchor, "sar_value": sar}
# ================================================================ A 相位合成
@case("A 无读数: 没有当天行 → 弃权, 不折成看空")
def test_noread_empty():
r = tr.synthesize([])
assert r["stance"] == "无读数" and r["no_read_why"]
@case("A 无读数: 未除权重锚")
def test_noread_reanchor():
r = tr.synthesize([_row(20260910, reanchor=0)])
assert r["stance"] == "无读数" and "除权" in r["reason"]
@case("A 无读数: 数据质量非 OK")
def test_noread_quality():
r = tr.synthesize([_row(20260910, quality="STALE")])
assert r["stance"] == "无读数" and "质量" in r["reason"]
@case("A 无读数: 关键读数缺失 (SAR 方向)")
def test_noread_missing():
r = tr.synthesize([_row(20260910, side="")])
assert r["stance"] == "无读数"
@case("A 收口等待: 今天收口一律中性")
def test_squeeze():
r = tr.synthesize([_row(20260910, squeeze=True, side="", bbi="多头区")])
assert r["phase"] == "收口等待" and r["stance"] == "中性"
@case("A 趋势多: SAR 多 + 多空布林线多头区 = 看多强")
def test_trend_long_strong():
r = tr.synthesize([_row(20260910, side="", bbi="多头区", flip=8)])
assert r["phase"] == "趋势多" and r["stance"] == "看多" and r["strength"] == ""
@case("A 趋势多: SAR 多 + 中性区 = 看多弱")
def test_trend_long_weak():
r = tr.synthesize([_row(20260910, side="", bbi="中性区", flip=8)])
assert r["phase"] == "趋势多" and r["stance"] == "看多" and r["strength"] == ""
@case("A 趋势空: SAR 空 + 空头区 = 看空强")
def test_trend_short_strong():
r = tr.synthesize([_row(20260910, side="", bbi="空头区", flip=8)])
assert r["phase"] == "趋势空" and r["stance"] == "看空" and r["strength"] == ""
@case("A 趋势空: SAR 空 + 中性区 = 看空弱")
def test_trend_short_weak():
r = tr.synthesize([_row(20260910, side="", bbi="中性区", flip=8)])
assert r["phase"] == "趋势空" and r["stance"] == "看空" and r["strength"] == ""
@case("A 分歧: SAR 多 + 空头区 = 中性")
def test_diverge_long():
r = tr.synthesize([_row(20260910, side="", bbi="空头区", flip=8)])
assert r["phase"] == "分歧" and r["stance"] == "中性"
@case("A 分歧: SAR 空 + 多头区 = 中性")
def test_diverge_short():
r = tr.synthesize([_row(20260910, side="", bbi="多头区", flip=8)])
assert r["phase"] == "分歧" and r["stance"] == "中性"
@case("A 转多: SAR 刚翻多 + 多空布林线非空头 = 看多弱")
def test_flip_long():
r = tr.synthesize([_row(20260910, side="", bbi="中性区", flip=1)])
assert r["phase"] == "转多" and r["stance"] == "看多" and r["strength"] == ""
@case("A 转空: 刚翻空 + 非空头非贴轨 = 看空弱 (未确认)")
def test_flip_short_unconfirmed():
r = tr.synthesize([_row(20260910, side="", bbi="中性区", pos=0.5, flip=1)])
assert r["phase"] == "转空" and r["confirm"] is False and r["strength"] == ""
@case("A 转空: 刚翻空 + 空头区 = 看空强 (确认)")
def test_flip_short_confirmed():
r = tr.synthesize([_row(20260910, side="", bbi="空头区", flip=1)])
assert r["phase"] == "转空" and r["confirm"] is True and r["strength"] == ""
@case("A 开口向上: 收口后放开 + 上半部 + SAR 多 = 看多强")
def test_open_up():
rows = [_row(20260909, squeeze=True, bw=5.0, side="", bbi="中性区", flip=8),
_row(20260910, squeeze=False, bw=6.2, pos=0.6, side="", bbi="中性区", flip=8)]
r = tr.synthesize(rows)
assert r["phase"] == "开口向上" and r["stance"] == "看多" and r["strength"] == ""
@case("A 开口向下: 收口后放开 + 下半部 + SAR 空 = 看空强")
def test_open_down():
rows = [_row(20260909, squeeze=True, bw=5.0, side="", bbi="中性区", flip=8),
_row(20260910, squeeze=False, bw=6.2, pos=0.4, side="", bbi="中性区", flip=8)]
r = tr.synthesize(rows)
assert r["phase"] == "开口向下" and r["stance"] == "看空" and r["strength"] == ""
@case("A 带宽没扩够不算开口, 落回趋势")
def test_open_not_enough():
rows = [_row(20260909, squeeze=True, bw=5.0, side="", bbi="中性区", flip=8),
_row(20260910, squeeze=False, bw=5.5, pos=0.6, side="", bbi="中性区", flip=8)]
r = tr.synthesize(rows)
assert r["phase"] != "开口向上" and r["phase"] == "趋势多"
@case("A 震荡市里刚翻空不判转空 (SAR 翻向不当信号), 按趋势走")
def test_choppy_suppresses_flip():
# 20 行交替方向 → 翻向 19 次, 远超 4 → 震荡市; 今日刚翻空 (flip=1)
flippy = [_row(20260800 + i, side=("" if i % 2 == 0 else ""), bbi="中性区", flip=1)
for i in range(20)]
r = tr.synthesize(flippy)
assert r["choppy"] is True
assert r["phase"] != "转空", "震荡市里刚翻空不该判转空"
assert r["phase"] == "趋势空" and r["stance"] == "看空"
@case("A count_sar_flips: 数相邻方向变化")
def test_count_flips():
rows = [_row(i, side=("" if i % 2 == 0 else "")) for i in range(5)]
assert tr.count_sar_flips(rows) == 4
assert tr.count_sar_flips([_row(0, side=""), _row(1, side="")]) == 0
# ================================================================ B 取数解析
@case("B parse_item: 代码归一 + 字段映射 + 认不出返回 None")
def test_parse_item():
it = {"stock_code": "SH600000", "stock_name": "浦发银行",
"boll": {"upper": 9.4, "mid": 9.1, "lower": 8.9, "bandwidth_pct": 5.4,
"pos": 0.85, "state": "带内偏上", "squeeze": True},
"bbiboll": {"bbi": 9.2, "state": "多头区", "dist_pct": 1.0},
"sar": {"value": 9.1, "side": "", "flip_days": 8, "dist_pct": 2.5},
"reanchored": True, "in_pool": False, "quality": "OK",
"bars_used": 120, "last_bar_date": 20260910, "bars_lag": 0}
r = ts.parse_item(it, 20260910, "tech_view_v1")
assert r["ts_code"] == "600000.SH" and r["stock_name"] == "浦发银行"
assert r["boll_squeeze"] == 1 and r["reanchored"] == 1 and r["in_pool"] == 0
assert r["sar_side"] == "" and r["bbi_state"] == "多头区" and r["sar_flip_days"] == 8
assert r["algo_version"] == "tech_view_v1" and r["data_date"] == 20260910
assert ts.parse_item({"stock_code": ""}, 20260910) is None
assert ts.parse_item("x", 20260910) is None
# ================================================================ C 分页取数
@case("C fetch_all_pages: 分页拼接 + matched 终止 + 状态非 OK 抛错")
def test_fetch_pages():
pages = {0: [{"stock_code": "SH600000", "sar": {"side": ""}, "reanchored": True,
"quality": "OK", "boll": {"pos": 0.5}},
{"stock_code": "SZ000001", "sar": {"side": ""}, "reanchored": True,
"quality": "OK", "boll": {"pos": 0.4}}],
2: [{"stock_code": "SH600004", "sar": {"side": ""}, "reanchored": True,
"quality": "OK", "boll": {"pos": 0.6}}]}
def f(url, params, timeout):
return {"status": "OK", "data_date": 20260910, "matched": 3, "algo_version": "v1",
"items": pages.get(params["offset"], [])}
got = ts.fetch_all_pages(base="x", path="/y", timeout=5, page_size=2, fetch=f)
assert got["total"] == 3 and got["pages"] == 2, got
assert {r["ts_code"] for r in got["rows"]} == {"600000.SH", "000001.SZ", "600004.SH"}
def bad(url, params, timeout):
return {"status": "DEGRADED", "items": []}
try:
ts.fetch_all_pages(base="x", path="/y", timeout=5, fetch=bad)
assert False, "状态非 OK 应抛 TechFeedError"
except ts.TechFeedError:
pass
# ================================================================ D 映射
@case("D build_map: 正常合成, 只覆盖有当天读数的相关票")
def test_build_map_ok():
P = {"enabled": True, "stale_tdays": 2, "keep_days": 40, "base": "x", "path": "/y",
"timeout": 20, "rules": dict(tr.DEFAULTS)}
saved = {}
with _Patch() as p:
p(ts, "_params", lambda: P)
p(ts.tech_repo, "latest_date", lambda: 20260910)
p(ts, "_relevant_codes", lambda: ["600000.SH", "000001.SZ"])
p(ts.tech_repo, "history_multi",
lambda codes, since=0: {"600000.SH": [_row(20260910, side="", bbi="多头区", flip=8)]})
p(ts, "_save_map", lambda m: (saved.update(m), {"ok": True})[1])
out = ts.build_map(now=datetime(2026, 9, 11, 8, 45))
assert out["ok"] and out["data_date"] == 20260910 and out["codes"] == 2
assert out["states"] == 1, "000001 今日无读数, 不进映射"
assert saved["states"]["600000.SH"]["stance"] == "看多"
@case("D build_map: 读数陈旧整份按无读数, 映射置空带原因")
def test_build_map_stale():
P = {"enabled": True, "stale_tdays": 2, "keep_days": 40, "base": "x", "path": "/y",
"timeout": 20, "rules": dict(tr.DEFAULTS)}
saved = {}
with _Patch() as p:
p(ts, "_params", lambda: P)
p(ts.tech_repo, "latest_date", lambda: 20260901) # 距 9/11 超过 2 个交易日
p(ts, "_relevant_codes", lambda: ["600000.SH"])
p(ts, "_save_map", lambda m: (saved.update(m), {"ok": True})[1])
out = ts.build_map(now=datetime(2026, 9, 11, 8, 45))
assert not out["ok"] and out["error"] == "读数陈旧"
assert saved.get("stale") is True and saved["states"] == {}
# ================================================================ E 参数与口径
@case("E 参数登记: PMS_TECH_* 在 RUNTIME_EXTRA 与 _RANGES")
def test_params_registered():
for k in ("PMS_TECH_ENABLED", "PMS_TECH_API_BASE", "PMS_TECH_API_PATH", "PMS_TECH_TIMEOUT",
"PMS_TECH_KEEP_DAYS", "PMS_TECH_STALE_TDAYS", "PMS_TECH_SQUEEZE_LOOKBACK",
"PMS_TECH_OPEN_BW_GROWTH", "PMS_TECH_CHOPPY_FLIPS", "PMS_TECH_FLIP_FRESH_DAYS",
"PMS_TECH_STATE_MAP"):
assert k in param_store.RUNTIME_EXTRA, k
for k in ("PMS_TECH_TIMEOUT", "PMS_TECH_KEEP_DAYS", "PMS_TECH_STALE_TDAYS",
"PMS_TECH_SQUEEZE_LOOKBACK", "PMS_TECH_OPEN_BW_GROWTH", "PMS_TECH_CHOPPY_FLIPS",
"PMS_TECH_FLIP_FRESH_DAYS"):
assert k in param_store._RANGES, k
@case("E _tdays_elapsed: 昨→今 1 个交易日, 同日 0")
def test_tdays():
assert ts._tdays_elapsed(20260910, 20260911) == 1
assert ts._tdays_elapsed(20260911, 20260911) == 0
# ================================================================ F 落表
@case("F pull: 接口状态非 OK 整轮不落表")
def test_pull_no_ok():
P = {"enabled": True, "base": "x", "path": "/y", "timeout": 5, "keep_days": 40,
"stale_tdays": 2, "rules": dict(tr.DEFAULTS)}
called = {"upsert": 0}
with _Patch() as p:
p(ts, "_params", lambda: P)
p(ts.tech_repo, "upsert_daily",
lambda rows: (called.__setitem__("upsert", called["upsert"] + 1), len(rows))[1])
out = ts.pull(fetch=lambda url, params, timeout: {"status": "ERR", "items": []})
assert not out["ok"] and called["upsert"] == 0, "状态非 OK 绝不落表"
@case("F pull: 正常落表并推进日期")
def test_pull_ok():
P = {"enabled": True, "base": "x", "path": "/y", "timeout": 5, "keep_days": 40,
"stale_tdays": 2, "rules": dict(tr.DEFAULTS)}
got = {"rows": None}
def good(url, params, timeout):
if params["offset"] == 0:
return {"status": "OK", "data_date": 20260910, "matched": 1, "algo_version": "v1",
"items": [{"stock_code": "SH600000", "boll": {"squeeze": False, "pos": 0.6},
"bbiboll": {"state": "多头区"},
"sar": {"side": "", "value": 9.1, "flip_days": 8},
"reanchored": True, "quality": "OK"}]}
return {"status": "OK", "data_date": 20260910, "matched": 1, "items": []}
with _Patch() as p:
p(ts, "_params", lambda: P)
p(ts.tech_repo, "upsert_daily", lambda rows: (got.__setitem__("rows", rows), len(rows))[1])
p(ts.tech_repo, "prune", lambda before: 0)
out = ts.pull(fetch=good)
assert out["ok"] and out["data_date"] == 20260910 and out["rows"] == 1
assert got["rows"][0]["ts_code"] == "600000.SH"
def main():
ok = 0
for name, fn in RESULTS:
try:
fn()
ok += 1
print(" ok " + name)
except Exception:
print(" FAIL " + name)
traceback.print_exc()
print("-" * 60)
if ok == len(RESULTS):
print("ALL PASS (%d cases)" % ok)
return 0
print("FAILED %d/%d" % (len(RESULTS) - ok, len(RESULTS)))
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -577,7 +577,7 @@ def run():
"DEFAULT 'NONE' COMMENT 'NONE/REQUESTED/SENT'"): "DEFAULT 'NONE' COMMENT 'NONE/REQUESTED/SENT'"):
eq(find_adjacent_literals(good), [], f"误报: {good[:40]}") eq(find_adjacent_literals(good), [], f"误报: {good[:40]}")
@case("DDL 文件本身体检通过 (18 张表 + 1 条初始行)") @case("DDL 文件本身体检通过 (20 张表 + 1 条初始行)")
def _(): def _():
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from init_db import DDL_FILE, find_adjacent_literals, parse_statements from init_db import DDL_FILE, find_adjacent_literals, parse_statements
@ -591,7 +591,8 @@ def run():
# ddl_pms_v1.sql (init_db 只认这个文件, 建不出来的表在实机上才会报"表不存在") # ddl_pms_v1.sql (init_db 只认这个文件, 建不出来的表在实机上才会报"表不存在")
# (2026-08-18: +pms_macro_signal 宏观信号日快照 → 18) # (2026-08-18: +pms_macro_signal 宏观信号日快照 → 18)
# (2026-08-28: +pms_nav_daily 每日净值快照·公示导出 → 19) # (2026-08-28: +pms_nav_daily 每日净值快照·公示导出 → 19)
eq(len([1 for k, _, _ in stmts if k == "table"]), 19) # (2026-09-11: +pms_tech_daily 全市场每日技术面读数 → 20)
eq(len([1 for k, _, _ in stmts if k == "table"]), 20)
eq(len([1 for k, _, _ in stmts if k == "seed"]), 1) eq(len([1 for k, _, _ in stmts if k == "seed"]), 1)
@case("通道三表的 SQL 全部单表合规") @case("通道三表的 SQL 全部单表合规")

View File

@ -648,6 +648,8 @@ def _():
"/api/ops/downstream-schema", "/api/industry", "/api/ops/downstream-schema", "/api/industry",
"/api/upstream/plan", "/api/ops/plan-refresh", "/api/upstream/plan", "/api/ops/plan-refresh",
"/api/upstream/signals/{ts_code}", # 2026-09-09 点股票看它今天全部信号 "/api/upstream/signals/{ts_code}", # 2026-09-09 点股票看它今天全部信号
"/api/tech/status", "/api/ops/tech-pull", # 2026-09-11 技术面状态与手动拉取
"/api/research/{ts_code}", # 2026-09-11 单票研究面 (工作包一给技术面块)
"/api/ops/strategy-attach-scan"] # 2026-08-25 策略自动挂载试算位 "/api/ops/strategy-attach-scan"] # 2026-08-25 策略自动挂载试算位
missing = [p for p in need if p not in paths] missing = [p for p in need if p not in paths]
assert not missing, missing assert not missing, missing
@ -665,11 +667,13 @@ def _():
"daily_report", "daily_report",
"macro_scan", # 2026-08-18 +宏观择时 09:35 "macro_scan", # 2026-08-18 +宏观择时 09:35
"strategy_attach", # 2026-08-25 +策略自动挂载 09:40 "strategy_attach", # 2026-08-25 +策略自动挂载 09:40
"nav_snapshot"}, names # 2026-08-28 +公示净值快照 15:20 "nav_snapshot", # 2026-08-28 +公示净值快照 15:20
"tech_pull"}, names # 2026-09-11 +技术面取数 06:30
tasks = set(sch.celery_app.tasks) tasks = set(sch.celery_app.tasks)
for t in ("pms.premarket", "pms.command_poll", "pms.replay_fills", "pms.daily_settle", for t in ("pms.premarket", "pms.command_poll", "pms.replay_fills", "pms.daily_settle",
"pms.daily_report", "pms.t0_close", "pms.intraday_exec", "pms.signal_digest", "pms.daily_report", "pms.t0_close", "pms.intraday_exec", "pms.signal_digest",
"pms.plan_pull", "pms.macro_scan", "pms.strategy_attach", "pms.nav_snapshot"): "pms.plan_pull", "pms.macro_scan", "pms.strategy_attach", "pms.nav_snapshot",
"pms.tech_pull"):
assert t in tasks, t assert t in tasks, t
assert sch.in_session(datetime(2026, 7, 27, 10, 0)) is True assert sch.in_session(datetime(2026, 7, 27, 10, 0)) is True
assert sch.in_session(datetime(2026, 7, 27, 12, 0)) is False assert sch.in_session(datetime(2026, 7, 27, 12, 0)) is False