Compare commits
3 Commits
cabcf04397
...
83022e1347
| Author | SHA1 | Date |
|---|---|---|
|
|
83022e1347 | |
|
|
d89580240b | |
|
|
0d3984582c |
|
|
@ -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)一律弃权,绝不折成看空。本接口不改变研判请求体,研判闸的请求一字不变。
|
||||||
|
|
|
||||||
51
DEVLOG.md
51
DEVLOG.md
|
|
@ -1316,6 +1316,57 @@ 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. 页面已补技术面这一路的核心三项:顶栏新鲜度芯片、我的持仓「研究面」列、单票抽屉「研究面·技术面」节;设置页自动列出十一个 PMS_TECH_* 参数(无需另做设置卡)。持仓接口每行挂 tech 立场。开发机起本地服务冒烟确认不白屏、芯片与列渲染正确。剩下与三源合议耦合的两项——候选栏技术芯片、管理视图持仓总览修显示错误——并入工作包二,与提议卡三芯片、基本面质地芯片一次铺齐,避免返工。
|
||||||
|
2. 工作包二(三源合议与仓位矩阵)、工作包三(离场纪律)、工作包四(选股打分,另一仓库单独审批)、工作包五(README 调度总表补 13 位等文档)未做。
|
||||||
|
3. BIONIC_PMS_INTERFACE.md 需补技术面只读接口一节(本次接口探活的字段事实源)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-12 · 提议卡重构:卡面留结论、详情进弹窗、加技术面(未真机判收)
|
||||||
|
|
||||||
|
**做了什么**
|
||||||
|
用户反馈新建仓提议卡信息过载 —— 选股系统、决策系统、公司深度三段整句全平铺在卡面,公司深度那句最长,占了大半屏。重构成卡面只留结论、点开弹窗看详情。
|
||||||
|
- 卡面留数量、建议档位、四行来源结论(选股系统、决策系统、公司深度、技术面),采纳驳回。每行一个彩色结论标签加一句副文案,点开弹窗。
|
||||||
|
- 弹窗 teleport 到 body,层级 3000/2995 同单票信号抽屉,从底部滑出。四类详情各自排版:选股系统给判决整句、理由、榜单;决策系统给结论、把握度、原话、择时层两期限;公司深度给质地档、估值、十视角计数、买方论点、失效条件、反方、催化、报告链接,分段带彩色小标题;技术面给立场加布林、多空布林线、SAR 三个指标块,点开时调 /api/research 取完整读数。
|
||||||
|
- 颜色按页面口径红涨绿跌:技术面看多用红、看空用绿;评价类(候选、质地好)用中性蓝,交你定用黄。
|
||||||
|
|
||||||
|
**动了哪些文件**
|
||||||
|
app/web/static/index.html(CSS 加来源行与弹窗样式、模板卡面重构加弹窗、JS 加四个结论标签函数与弹窗开关、return 导出),app/web/main.py(/api/proposals 每条挂技术面立场 tech)。
|
||||||
|
|
||||||
|
**部署方式**
|
||||||
|
改了 Python 与页面,make deploy 重建镜像加 force-recreate;收盘后 155,先经审批。
|
||||||
|
|
||||||
|
**真机判收**
|
||||||
|
未判收。两道页面守卫 ALL OK。开发机注入一条华测导航假提议起本地服务,截图确认卡面四行结论、技术面弹窗三指标块、公司深度弹窗四段分组都渲染正确、不白屏。待 155 有真实提议时判收。
|
||||||
|
|
||||||
|
**还欠着什么**
|
||||||
|
合议行、候选芯片仍是工作包二。提议卡的技术面立场依赖工作包一的技术面映射(已上,未真机判收)。
|
||||||
|
|
||||||
---
|
---
|
||||||
<!--
|
<!--
|
||||||
下一条节点从这里往下写,格式照抄上面:
|
下一条节点从这里往下写,格式照抄上面:
|
||||||
|
|
|
||||||
|
|
@ -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, "未归类", "指标组合未归入任何相位,保守中性")
|
||||||
|
|
@ -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 个交易日。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)})
|
||||||
|
|
@ -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="*"),
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -381,6 +381,12 @@ def api_positions():
|
||||||
logic_state_service.decorate_positions(v["positions"])
|
logic_state_service.decorate_positions(v["positions"])
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
logger.warning("[持仓] 入场论点与逻辑状态两栏取不到: %s", e)
|
logger.warning("[持仓] 入场论点与逻辑状态两栏取不到: %s", e)
|
||||||
|
# 技术面立场 (2026-09-11): 每行挂 tech(看多/看空/中性/无读数),只是显示,取不到不塌。
|
||||||
|
try:
|
||||||
|
from app.services import tech_service
|
||||||
|
tech_service.attach(v["positions"])
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning("[持仓] 技术面立场挂不上: %s", e)
|
||||||
return {"ok": True, **v, "stock_params": sp}
|
return {"ok": True, **v, "stock_params": sp}
|
||||||
return ok(_view)
|
return ok(_view)
|
||||||
|
|
||||||
|
|
@ -434,8 +440,18 @@ def api_export_publish_xlsx():
|
||||||
def api_proposals(status: str = Query("WAIT_USER"), limit: int = 100,
|
def api_proposals(status: str = Query("WAIT_USER"), limit: int = 100,
|
||||||
include_archived: bool = Query(False)):
|
include_archived: bool = Query(False)):
|
||||||
statuses = tuple(s for s in (status or "").split(",") if s) or ("WAIT_USER",)
|
statuses = tuple(s for s in (status or "").split(",") if s) or ("WAIT_USER",)
|
||||||
return ok(pms_repo.list_proposals, statuses=statuses, limit=limit,
|
|
||||||
|
def _list():
|
||||||
|
rows = pms_repo.list_proposals(statuses=statuses, limit=limit,
|
||||||
include_archived=include_archived)
|
include_archived=include_archived)
|
||||||
|
# 技术面立场 (2026-09-11): 每条提议挂 tech(提议卡第四行结论用),取不到不塌。
|
||||||
|
try:
|
||||||
|
from app.services import tech_service
|
||||||
|
tech_service.attach(rows)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning("[提议] 技术面立场挂不上: %s", e)
|
||||||
|
return rows
|
||||||
|
return ok(_list)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/proposals/{proposal_id}/decide")
|
@app.post("/api/proposals/{proposal_id}/decide")
|
||||||
|
|
@ -706,6 +722,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():
|
||||||
|
|
|
||||||
|
|
@ -406,6 +406,26 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
||||||
padding:11px;font-size:14px;font-weight:600;cursor:pointer;}
|
padding:11px;font-size:14px;font-weight:600;cursor:pointer;}
|
||||||
.login-btn:disabled{opacity:.6;cursor:default;}
|
.login-btn:disabled{opacity:.6;cursor:default;}
|
||||||
.chip.hd-user{background:var(--accent-weak);color:var(--accent);} .chip.hd-user .dot{background:var(--accent);}
|
.chip.hd-user{background:var(--accent-weak);color:var(--accent);} .chip.hd-user .dot{background:var(--accent);}
|
||||||
|
/* ── 提议卡: 来源结论行 + 详情弹窗 (2026-09-12 重构) ── */
|
||||||
|
.prop-src{margin:9px 0 4px;border-top:1px solid var(--hair);}
|
||||||
|
.prop-src-row{display:flex;align-items:center;gap:8px;padding:8px 2px;border-bottom:1px solid rgba(0,0,0,.04);cursor:pointer;}
|
||||||
|
.prop-src-row:last-child{border-bottom:none;}
|
||||||
|
.prop-src-row:hover{background:var(--surface-2);}
|
||||||
|
.prop-src-row .lbl{font-size:12px;color:var(--muted);width:52px;flex:none;}
|
||||||
|
.prop-src-row .sub{font-size:12px;color:var(--ink-2);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||||
|
.prop-src-row .car{font-style:normal;color:var(--muted);flex:none;font-size:15px;}
|
||||||
|
.ptag{font-size:11.5px;padding:1px 8px;border-radius:6px;flex:none;font-weight:500;}
|
||||||
|
.ptag.up{background:var(--up-weak);color:var(--up);}
|
||||||
|
.ptag.down{background:var(--down-weak);color:var(--down);}
|
||||||
|
.ptag.accent{background:var(--accent-weak);color:var(--accent);}
|
||||||
|
.ptag.warn{background:var(--warn-weak);color:var(--warn);}
|
||||||
|
.ptag.neutral{background:var(--surface-2);color:var(--muted);}
|
||||||
|
.prop-sheet{position:fixed;left:0;right:0;bottom:0;z-index:3000;max-height:86vh;overflow:auto;
|
||||||
|
background:var(--surface);border-radius:16px 16px 0 0;box-shadow:0 -12px 40px rgba(17,17,16,.22);}
|
||||||
|
.prop-metric{display:flex;flex-direction:column;gap:7px;margin:8px 0;}
|
||||||
|
.prop-metric .m{background:var(--surface-2);border-radius:9px;padding:9px 11px;display:flex;align-items:center;gap:10px;}
|
||||||
|
.prop-metric .m .nm{font-size:12.5px;flex:1;} .prop-metric .m .dt{font-size:11px;color:var(--muted);}
|
||||||
|
.prop-badge{font-size:11px;font-family:ui-monospace,Menlo,monospace;background:var(--surface-2);padding:2px 7px;border-radius:5px;}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -432,6 +452,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
||||||
<span class="chip hd-exec" :class="ov.exec_halt?'warn':'ok'"><span class="dot"></span>执行 {{ ov.exec_halt ? '已暂停':'正常' }}</span>
|
<span class="chip hd-exec" :class="ov.exec_halt?'warn':'ok'"><span class="dot"></span>执行 {{ ov.exec_halt ? '已暂停':'正常' }}</span>
|
||||||
<span class="chip" :class="ov.autonomy==='off'?'warn':'ok'"><span class="dot"></span>自主 {{ tx('autonomy', ov.autonomy) }}<span class="help" data-tip="系统能自己做主到什么程度。全自动=各项检查通过就直接下单;只提议=先在「等我拍板」问过你;暂停=系统不自己动。">?</span></span>
|
<span class="chip" :class="ov.autonomy==='off'?'warn':'ok'"><span class="dot"></span>自主 {{ tx('autonomy', ov.autonomy) }}<span class="help" data-tip="系统能自己做主到什么程度。全自动=各项检查通过就直接下单;只提议=先在「等我拍板」问过你;暂停=系统不自己动。">?</span></span>
|
||||||
<span class="chip hd-plan" :class="planStatus.ok===false?'warn':'ok'"><span class="dot"></span>选股计划<span class="hd-plan-d"> {{ planStatus.ok===false ? '读不到' : (planStatus.date || '-') }}</span></span>
|
<span class="chip hd-plan" :class="planStatus.ok===false?'warn':'ok'"><span class="dot"></span>选股计划<span class="hd-plan-d"> {{ planStatus.ok===false ? '读不到' : (planStatus.date || '-') }}</span></span>
|
||||||
|
<span class="chip hd-tech" :class="techChipCls"><span class="dot"></span>技术面<span class="hd-plan-d"> {{ techChipText }}</span><span class="help" data-tip="每晚算的技术面读数(布林线、多空布林线、SAR)是哪天的。绿=今天的映射新鲜;黄=读不到或已过期;灰=技术面这一层没开。">?</span></span>
|
||||||
<el-tooltip placement="bottom-start" effect="light" :show-after="120">
|
<el-tooltip placement="bottom-start" effect="light" :show-after="120">
|
||||||
<template #content>
|
<template #content>
|
||||||
<div style="max-width:360px;font-size:12px;line-height:1.75">
|
<div style="max-width:360px;font-size:12px;line-height:1.75">
|
||||||
|
|
@ -926,6 +947,17 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
||||||
<div class="muted">{{ pct(s.row.cushion_pct) }}</div>
|
<div class="muted">{{ pct(s.row.cushion_pct) }}</div>
|
||||||
<div class="muted" v-if="s.row.neg_cushion_days>0">连亏 {{ s.row.neg_cushion_days }} 日</div></template>
|
<div class="muted" v-if="s.row.neg_cushion_days>0">连亏 {{ s.row.neg_cushion_days }} 日</div></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="研究面" width="92">
|
||||||
|
<template #default="s">
|
||||||
|
<template v-if="s.row.tech">
|
||||||
|
<b :class="techStanceCls(s.row.tech.stance)" style="cursor:pointer;text-decoration:underline dotted"
|
||||||
|
@click.stop="openCodeSignals(s.row.ts_code)"
|
||||||
|
:title="'技术面:' + s.row.tech.stance + '(' + (s.row.tech.phase||'') + ')\n' + (s.row.tech.reason||'') + '\n点一下看研究面'">{{ s.row.tech.stance }}</b>
|
||||||
|
<div class="muted" style="font-size:11px" v-if="s.row.tech.phase">{{ s.row.tech.phase }}</div>
|
||||||
|
</template>
|
||||||
|
<span class="muted" v-else>无读数</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="今天的信号" width="108">
|
<el-table-column label="今天的信号" width="108">
|
||||||
<template #default="s">
|
<template #default="s">
|
||||||
<template v-if="heldSigOf(s.row.ts_code).length">
|
<template v-if="heldSigOf(s.row.ts_code).length">
|
||||||
|
|
@ -1091,33 +1123,30 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
||||||
<div class="st" v-if="propSizeMismatch(p)" style="color:#d8574a">{{ propSizeMismatch(p) }}</div>
|
<div class="st" v-if="propSizeMismatch(p)" style="color:#d8574a">{{ propSizeMismatch(p) }}</div>
|
||||||
<div class="st muted" v-if="propResized(p)">{{ propResized(p) }}</div>
|
<div class="st muted" v-if="propResized(p)">{{ propResized(p) }}</div>
|
||||||
|
|
||||||
<div class="st" v-if="(p.hard_numbers||{}).verdict"><span class="muted">选股系统:</span>{{ propVerdictLine(p) }}</div>
|
<!-- 四行来源结论, 点开看完整依据 (2026-09-12 重构): 原先每类一整句平铺在卡面,
|
||||||
<div class="st" v-if="p.judge_reason"><span class="muted">决策系统:</span>{{ propJudgeShort(p) }}<span class="muted" v-if="(p.hard_numbers||{}).judge_conf!=null">(把握度 {{ (p.hard_numbers||{}).judge_conf }})</span></div>
|
公司深度那句最长 —— 现在卡面只留结论标签, 整句进弹窗。判定用的键一个没动。 -->
|
||||||
|
<div class="prop-src">
|
||||||
<!-- 公司深度只在真有报告时才占一行。判据用结构化的 company_review 而不是
|
<div class="prop-src-row" v-if="propPlanTag(p)" @click="openPropDetail(p,'plan')">
|
||||||
那句整句 —— 没报告时上游下发的是一句 21 字的占位, 实测 77 只票里 64 只
|
<span class="lbl">选股系统</span>
|
||||||
都是它, 占着位置又不说事。 -->
|
<span class="ptag" :class="propPlanTag(p).cls">{{ propPlanTag(p).text }}</span>
|
||||||
<div class="st" v-if="(p.hard_numbers||{}).company_review"><span class="muted">公司深度:</span>{{ propText(p, 'company_review_text') }}</div>
|
<span class="sub">{{ propPlanSub(p) }}</span><i class="car">›</i>
|
||||||
|
</div>
|
||||||
<!-- 为什么选它。这三条一直算好放在 hard_numbers.reasons 里, 但模板从来没用过。 -->
|
<div class="prop-src-row" v-if="propJudgeTag(p)" @click="openPropDetail(p,'judge')">
|
||||||
<div class="st muted" v-for="(x, ri) in propReasons(p)" :key="'r'+ri">{{ x }}</div>
|
<span class="lbl">决策系统</span>
|
||||||
|
<span class="ptag" :class="propJudgeTag(p).cls">{{ propJudgeTag(p).text }}</span>
|
||||||
<div class="st muted clickable" @click="togglePropMore(p.proposal_id)">
|
<span class="sub" v-if="(p.hard_numbers||{}).judge_conf!=null">把握 {{ (p.hard_numbers||{}).judge_conf }}</span><i class="car">›</i>
|
||||||
{{ propMoreOpen.has(p.proposal_id) ? '▾ 收起证据' : '▸ 展开证据(研报、安全边际、催化、定价、快讯、行业、择时层)' }}
|
</div>
|
||||||
|
<div class="prop-src-row" v-if="propCompanyTag(p)" @click="openPropDetail(p,'company')">
|
||||||
|
<span class="lbl">公司深度</span>
|
||||||
|
<span class="ptag" :class="propCompanyTag(p).cls">{{ propCompanyTag(p).text }}</span>
|
||||||
|
<span class="sub" v-if="cr(p) && cr(p).valuation">估值{{ cr(p).valuation }}</span><i class="car">›</i>
|
||||||
|
</div>
|
||||||
|
<div class="prop-src-row" v-if="propTechTag(p)" @click="openPropDetail(p,'tech')">
|
||||||
|
<span class="lbl">技术面</span>
|
||||||
|
<span class="ptag" :class="propTechTag(p).cls">{{ propTechTag(p).text }}</span>
|
||||||
|
<span class="sub">{{ propTechSub(p) }}</span><i class="car">›</i>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="propMoreOpen.has(p.proposal_id)">
|
|
||||||
<div class="st" v-if="propLogic(p)"><span class="muted">研报说:</span>{{ propLogic(p) }}<span class="muted" v-if="propLogicMore(p)">(另有 {{ propLogicMore(p) }} 条)</span></div>
|
|
||||||
<div class="st muted" v-if="propText(p, 'valuation_text')">{{ propText(p, 'valuation_text') }}</div>
|
|
||||||
<div class="st muted" v-if="propText(p, 'events_text')">{{ propText(p, 'events_text') }}</div>
|
|
||||||
<div class="st muted" v-if="propText(p, 'pricing_text')">{{ propText(p, 'pricing_text') }}</div>
|
|
||||||
<div class="st muted" v-if="propText(p, 'news_text')">{{ propText(p, 'news_text') }}</div>
|
|
||||||
<div class="st muted" v-if="propText(p, 'industry_catalyst_text')">{{ propText(p, 'industry_catalyst_text') }}</div>
|
|
||||||
<div class="st" v-if="propText(p, 'company_gate')"><span class="muted">判决收敛:</span>{{ propText(p, 'company_gate') }}</div>
|
|
||||||
<div class="st" v-if="propText(p, 'advice_text')">{{ propText(p, 'advice_text') }}</div>
|
|
||||||
<div class="st" v-if="propHeads(p)"><span class="muted">择时层两个期限:</span>{{ propHeads(p) }}</div>
|
|
||||||
<div class="st muted">{{ propWhy(p) }}</div>
|
|
||||||
<div class="st muted" v-if="p.judge_reason && propJudgeShort(p) !== p.judge_reason">决策系统原话:{{ p.judge_reason }}</div>
|
|
||||||
</template>
|
|
||||||
<div style="margin-top:6px">
|
<div style="margin-top:6px">
|
||||||
<el-button type="success" size="small" @click="decide(p,'ACCEPTED')">采纳</el-button>
|
<el-button type="success" size="small" @click="decide(p,'ACCEPTED')">采纳</el-button>
|
||||||
<el-button type="danger" size="small" @click="decide(p,'DECLINED')">驳回</el-button>
|
<el-button type="danger" size="small" @click="decide(p,'DECLINED')">驳回</el-button>
|
||||||
|
|
@ -1913,6 +1942,77 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ 提议来源详情弹窗 (2026-09-12): 点提议卡某个来源看它的完整依据。teleport 到 body,
|
||||||
|
层级 3000/2995 同单票信号抽屉, 盖住右栏浮层 ═══ -->
|
||||||
|
<teleport to="body">
|
||||||
|
<div v-if="propDetailOpen">
|
||||||
|
<div class="cs-scrim" @click="closePropDetail"></div>
|
||||||
|
<aside class="prop-sheet">
|
||||||
|
<div class="cs-h">
|
||||||
|
<span class="ttl">{{ nm((propDetailP||{}).ts_code) }}</span>
|
||||||
|
<span class="muted mono" style="font-size:11px">{{ (propDetailP||{}).ts_code }}</span>
|
||||||
|
<span class="ptag neutral">{{ PROP_SRC_LABEL[propDetailKind] }}</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="iconbtn" @click="closePropDetail" title="关闭">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="cs-b" v-if="propDetailP">
|
||||||
|
<template v-if="propDetailKind==='plan'">
|
||||||
|
<div class="st" style="margin-bottom:8px">{{ propVerdictLine(propDetailP) }}</div>
|
||||||
|
<div class="cs-sect" v-if="propReasons(propDetailP).length">
|
||||||
|
<div class="h">为什么选它</div>
|
||||||
|
<div class="st" v-for="(x,ri) in propReasons(propDetailP)" :key="ri">· {{ x }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="cs-sect"><div class="h">榜单</div><div class="st muted">{{ propWhy(propDetailP) }}</div></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="propDetailKind==='judge'">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||||
|
<span class="ptag" :class="propJudgeTag(propDetailP).cls">{{ propJudgeTag(propDetailP).text }}</span>
|
||||||
|
<span class="muted" v-if="(propDetailP.hard_numbers||{}).judge_conf!=null">把握度 {{ (propDetailP.hard_numbers||{}).judge_conf }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="st">{{ propDetailP.judge_reason || '决策系统没有留下原话。' }}</div>
|
||||||
|
<div class="cs-sect" v-if="propHeads(propDetailP)"><div class="h">择时层两个期限</div><div class="st muted">{{ propHeads(propDetailP) }}</div></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="propDetailKind==='company'">
|
||||||
|
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px" v-if="cr(propDetailP)">
|
||||||
|
<span class="ptag" :class="propCompanyTag(propDetailP).cls">质地{{ cr(propDetailP).overall }}</span>
|
||||||
|
<span class="ptag neutral" v-if="cr(propDetailP).valuation">估值{{ cr(propDetailP).valuation }}</span>
|
||||||
|
<span class="prop-badge" v-if="cr(propDetailP).grade_counts">十视角 好 {{ cr(propDetailP).grade_counts['好'] }} / 中 {{ cr(propDetailP).grade_counts['中'] }} / 差 {{ cr(propDetailP).grade_counts['差'] }}</span>
|
||||||
|
<span class="prop-badge" v-if="cr(propDetailP).confidence">置信 {{ cr(propDetailP).confidence }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="cs-sect" v-if="cr(propDetailP) && cr(propDetailP).thesis"><div class="h" style="color:var(--down)">买方论点</div><div class="st">{{ cr(propDetailP).thesis }}</div></div>
|
||||||
|
<div class="cs-sect" v-if="cr(propDetailP) && cr(propDetailP).invalidation"><div class="h" style="color:var(--warn)">失效条件</div><div class="st">{{ cr(propDetailP).invalidation }}</div></div>
|
||||||
|
<div class="cs-sect" v-if="cr(propDetailP) && cr(propDetailP).antithesis"><div class="h" style="color:var(--up)">反方</div><div class="st">{{ cr(propDetailP).antithesis }}</div></div>
|
||||||
|
<div class="cs-sect" v-if="cr(propDetailP) && cr(propDetailP).catalyst"><div class="h">催化</div><div class="st muted">{{ cr(propDetailP).catalyst }}</div></div>
|
||||||
|
<div class="st" v-if="cr(propDetailP) && cr(propDetailP).report_url" style="margin-top:8px"><a :href="cr(propDetailP).report_url" target="_blank">查看完整报告 ↗</a></div>
|
||||||
|
<div class="st muted" v-if="!cr(propDetailP)">这只票没有公司深度评析。</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="propDetailKind==='tech'">
|
||||||
|
<div class="muted" v-if="propTechErr">{{ propTechErr }}</div>
|
||||||
|
<div class="muted" v-else-if="!propTechFull">正在读技术面…</div>
|
||||||
|
<template v-else>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap">
|
||||||
|
<span class="ptag" :class="propTechTag(propDetailP).cls">{{ propTechFull.stance }}<template v-if="propTechFull.strength"> · {{ propTechFull.strength }}</template></span>
|
||||||
|
<span class="muted" v-if="propTechFull.phase">{{ propTechFull.phase }}</span>
|
||||||
|
<span class="ptag warn" v-if="propTechFull.choppy">震荡市</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<span class="muted" style="font-size:11px" v-if="propTechFull.data_date">读数日 {{ propTechFull.data_date }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="prop-metric" v-if="propTechFull.latest">
|
||||||
|
<div class="m"><span class="nm">布林线<template v-if="(propTechFull.latest.boll||{}).bw_pct!=null"><span class="dt"> · 带宽 {{ (propTechFull.latest.boll||{}).bw_pct }}%</span></template><template v-if="(propTechFull.latest.boll||{}).squeeze"><span class="dt"> · 收口</span></template></span><span class="ptag accent" v-if="(propTechFull.latest.boll||{}).state">{{ (propTechFull.latest.boll||{}).state }}</span></div>
|
||||||
|
<div class="m"><span class="nm">多空布林线</span><span class="ptag accent" v-if="(propTechFull.latest.bbi||{}).state">{{ (propTechFull.latest.bbi||{}).state }}</span></div>
|
||||||
|
<div class="m"><span class="nm">SAR 跟踪止损<template v-if="(propTechFull.latest.sar||{}).value!=null"><span class="dt"> · 止损位 {{ (propTechFull.latest.sar||{}).value }}</span></template><template v-if="propTechFull.sar_flip_days!=null"><span class="dt"> · 翻向 {{ propTechFull.sar_flip_days }} 天</span></template></span><span class="ptag" :class="propTechTag(propDetailP).cls" v-if="(propTechFull.latest.sar||{}).side">{{ (propTechFull.latest.sar||{}).side }}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="st muted" v-if="propTechFull.reason">{{ propTechFull.reason }}</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</teleport>
|
||||||
|
|
||||||
<!-- 运维抽屉 -->
|
<!-- 运维抽屉 -->
|
||||||
<!-- ═══ 单票信号抽屉 (2026-09-09): 点一只票看它今天的全部信号 ═══
|
<!-- ═══ 单票信号抽屉 (2026-09-09): 点一只票看它今天的全部信号 ═══
|
||||||
不用 el-drawer 是因为弹层库的层级从 2000 起自增, 信号栏浮层写死 2010、遮罩 2005,
|
不用 el-drawer 是因为弹层库的层级从 2000 起自增, 信号栏浮层写死 2010、遮罩 2005,
|
||||||
|
|
@ -1947,6 +2047,30 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
|
||||||
下面列出的不是这只票的全部,是能读到的那部分。
|
下面列出的不是这只票的全部,是能读到的那部分。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="cs-sect" v-if="techFeed || techFeedErr">
|
||||||
|
<div class="h">研究面 · 技术面<span class="muted" style="font-weight:400" v-if="techFeed && techFeed.tech"> · 读数日 {{ techFeed.tech.data_date }}</span></div>
|
||||||
|
<div class="muted" v-if="techFeedErr">读不到技术面:{{ techFeedErr }}</div>
|
||||||
|
<div class="muted" v-else-if="!techFeed || !techFeed.tech">{{ (techFeed && techFeed.tech_note) || '这只票没有技术面读数' }}</div>
|
||||||
|
<template v-else>
|
||||||
|
<div class="cs-row">
|
||||||
|
<span class="s">立场</span>
|
||||||
|
<b :class="techStanceCls(techFeed.tech.stance)">{{ techFeed.tech.stance }}<span v-if="techFeed.tech.strength"> · {{ techFeed.tech.strength }}</span></b>
|
||||||
|
<span v-if="techFeed.tech.phase">{{ techFeed.tech.phase }}</span>
|
||||||
|
<span class="ab-stale" v-if="techFeed.tech.choppy">震荡市</span>
|
||||||
|
<div style="flex-basis:100%;font-size:12px;line-height:1.45" class="muted" v-if="techFeed.tech.reason">{{ techFeed.tech.reason }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="cs-row" v-if="techFeed.tech.latest">
|
||||||
|
<span class="s">布林</span><span>{{ (techFeed.tech.latest.boll||{}).state || '—' }}</span>
|
||||||
|
<span class="muted" v-if="(techFeed.tech.latest.boll||{}).squeeze">收口</span>
|
||||||
|
<span class="s">多空布林线</span><span>{{ (techFeed.tech.latest.bbi||{}).state || '—' }}</span>
|
||||||
|
<span class="s">SAR</span>
|
||||||
|
<b :class="techFeed.tech.sar_side==='多'?'clr-up':'clr-down'">{{ techFeed.tech.sar_side || '—' }}</b>
|
||||||
|
<span class="muted" v-if="techFeed.tech.sar_value!=null">止损位 <span class="mono">{{ techFeed.tech.sar_value }}</span></span>
|
||||||
|
<span class="muted" v-if="techFeed.tech.sar_flip_days!=null">翻向 {{ techFeed.tech.sar_flip_days }} 天</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="cs-sect">
|
<div class="cs-sect">
|
||||||
<div class="h">时间线 · {{ csTimeline.length }} 条</div>
|
<div class="h">时间线 · {{ csTimeline.length }} 条</div>
|
||||||
<div class="muted" v-if="!csTimeline.length && !csLoading">
|
<div class="muted" v-if="!csTimeline.length && !csLoading">
|
||||||
|
|
@ -2326,6 +2450,26 @@ createApp({
|
||||||
const planStatus = computed(() => planRaw.value.status || {});
|
const planStatus = computed(() => planRaw.value.status || {});
|
||||||
const planRows = computed(() => planRaw.value.rows || []);
|
const planRows = computed(() => planRaw.value.rows || []);
|
||||||
const planCand = computed(() => planRaw.value.candidates_raw || null);
|
const planCand = computed(() => planRaw.value.candidates_raw || null);
|
||||||
|
// 技术面模块状态 (2026-09-11): 顶栏新鲜度芯片。只读 /api/tech/status, 交易员也看得到。
|
||||||
|
const techStatus = ref({});
|
||||||
|
const techChipText = computed(() => {
|
||||||
|
const t = techStatus.value || {};
|
||||||
|
if (t.enabled === false) return '关';
|
||||||
|
const dd = t.data_date;
|
||||||
|
if (!dd) return '无';
|
||||||
|
const s = String(dd);
|
||||||
|
return s.length === 8 ? s.slice(4, 6) + '-' + s.slice(6, 8) : s;
|
||||||
|
});
|
||||||
|
const techChipCls = computed(() => {
|
||||||
|
const t = techStatus.value || {};
|
||||||
|
if (t.enabled === false) return 'idle';
|
||||||
|
if (t.error || !t.data_date) return 'warn';
|
||||||
|
return (t.map || {}).fresh ? 'ok' : 'warn';
|
||||||
|
});
|
||||||
|
const loadTech = async () => {
|
||||||
|
const d = await call('get', '/api/tech/status');
|
||||||
|
techStatus.value = (d && d.ok) ? (d.data || {}) : { error: (d || {}).error || '读不到' };
|
||||||
|
};
|
||||||
// 榜单变化 (PMS 自算 —— 上游 changes 字段恒为 null)。另起一个接口, 不塞进 /api/upstream/plan:
|
// 榜单变化 (PMS 自算 —— 上游 changes 字段恒为 null)。另起一个接口, 不塞进 /api/upstream/plan:
|
||||||
// 它要读库, 而计划预览要读上游, 两者的故障域不同, 混在一起会互相拖垮。
|
// 它要读库, 而计划预览要读上游, 两者的故障域不同, 混在一起会互相拖垮。
|
||||||
const chgRaw = ref({}), chgLoading = ref(false), chgTab = ref('held');
|
const chgRaw = ref({}), chgLoading = ref(false), chgTab = ref('held');
|
||||||
|
|
@ -2835,6 +2979,59 @@ createApp({
|
||||||
const r = String(p.judge_reason || '').trim();
|
const r = String(p.judge_reason || '').trim();
|
||||||
return r.length > 60 ? r.slice(0, 60) + '…' : r;
|
return r.length > 60 ? r.slice(0, 60) + '…' : r;
|
||||||
}
|
}
|
||||||
|
// ── 2026-09-12 提议卡重构: 卡面每个来源只留结论标签, 整句进弹窗 ──
|
||||||
|
// 标签返回 {text, cls}; cls 走 .ptag 取色 —— 技术面看多看空按页面口径红涨绿跌 (up/down),
|
||||||
|
// 其余评价类用中性色 (accent 蓝 / warn 黄 / neutral 灰), 不掺进涨跌红绿。
|
||||||
|
const cr = (p) => (p && p.hard_numbers && p.hard_numbers.company_review) || null;
|
||||||
|
const propPlanTag = (p) => {
|
||||||
|
const v = String((p.hard_numbers || {}).verdict || '').trim();
|
||||||
|
if (!v) return null;
|
||||||
|
return { text: v, cls: v === '候选' ? 'accent' : (v === '关注' ? 'warn' : 'neutral') };
|
||||||
|
};
|
||||||
|
const propPlanSub = (p) => {
|
||||||
|
const h = p.hard_numbers || {};
|
||||||
|
if (h.upside != null) return '空间 ' + (h.upside > 0 ? '+' : '') + (h.upside * 100).toFixed(0) + '%';
|
||||||
|
return propReasons(p)[0] || '';
|
||||||
|
};
|
||||||
|
const propJudgeTag = (p) => {
|
||||||
|
if (!p.judge_reason && !p.judge_verdict) return null;
|
||||||
|
return String(p.judge_verdict || '').trim() === 'PASS'
|
||||||
|
? { text: '通过', cls: 'accent' } : { text: '交你定', cls: 'warn' };
|
||||||
|
};
|
||||||
|
const propCompanyTag = (p) => {
|
||||||
|
const c = cr(p);
|
||||||
|
if (!c || !c.overall) return null;
|
||||||
|
return { text: '质地' + c.overall, cls: c.overall === '好' ? 'accent' : (c.overall === '差' ? 'warn' : 'neutral') };
|
||||||
|
};
|
||||||
|
const propTechTag = (p) => {
|
||||||
|
const t = p.tech;
|
||||||
|
if (!t || !t.stance) return null;
|
||||||
|
return { text: t.stance, cls: t.stance === '看多' ? 'up' : (t.stance === '看空' ? 'down' : 'neutral') };
|
||||||
|
};
|
||||||
|
const propTechSub = (p) => {
|
||||||
|
const t = p.tech || {}, bits = [];
|
||||||
|
if (t.phase) bits.push(t.phase);
|
||||||
|
if (t.sar_side) bits.push('SAR ' + t.sar_side);
|
||||||
|
return bits.join(' · ');
|
||||||
|
};
|
||||||
|
// 弹窗: 点某来源看它的完整依据。teleport 到 body, 层级同单票信号抽屉 (3000/2995) 盖住右栏。
|
||||||
|
const propDetailOpen = ref(false), propDetailKind = ref(''), propDetailP = ref(null);
|
||||||
|
const propTechFull = ref(null), propTechErr = ref('');
|
||||||
|
const PROP_SRC_LABEL = { plan: '选股系统', judge: '决策系统', company: '公司深度', tech: '技术面' };
|
||||||
|
function openPropDetail(p, kind) {
|
||||||
|
propDetailP.value = p; propDetailKind.value = kind; propDetailOpen.value = true;
|
||||||
|
propTechFull.value = null; propTechErr.value = '';
|
||||||
|
if (kind === 'tech') loadPropTech(p.ts_code);
|
||||||
|
}
|
||||||
|
function closePropDetail() { propDetailOpen.value = false; }
|
||||||
|
async function loadPropTech(code) {
|
||||||
|
const d = await call('get', '/api/research/' + encodeURIComponent(code));
|
||||||
|
if (!propDetailOpen.value || propDetailKind.value !== 'tech') return;
|
||||||
|
if (d && d.ok === false) { propTechErr.value = d.error || '读不到技术面'; return; }
|
||||||
|
const data = (d && d.data) ? d.data : d;
|
||||||
|
propTechFull.value = (data && data.tech) || null;
|
||||||
|
if (!propTechFull.value) propTechErr.value = (data && data.tech_note) || '这只票没有技术面读数';
|
||||||
|
}
|
||||||
// 硬数字里由上游写好的整句 (安全边际 / 催化事件 / 定价状态): 原样显示, 不是字符串就不显示。
|
// 硬数字里由上游写好的整句 (安全边际 / 催化事件 / 定价状态): 原样显示, 不是字符串就不显示。
|
||||||
function propText(p, key) {
|
function propText(p, key) {
|
||||||
const v = (p.hard_numbers || {})[key];
|
const v = (p.hard_numbers || {})[key];
|
||||||
|
|
@ -3286,6 +3483,17 @@ createApp({
|
||||||
// 补全失败就保留快照并挂一条黄条, 不清空 —— 有一半总比没有强。
|
// 补全失败就保留快照并挂一条黄条, 不清空 —— 有一半总比没有强。
|
||||||
const csOpen = ref(false), csCode = ref(''), csLoading = ref(false),
|
const csOpen = ref(false), csCode = ref(''), csLoading = ref(false),
|
||||||
csErr = ref(''), csData = ref(null), csFromSnap = ref(false);
|
csErr = ref(''), csData = ref(null), csFromSnap = ref(false);
|
||||||
|
// 研究面·技术面 (2026-09-11): 单票抽屉顶部一节, 点票时顺带调 /api/research 拉回。
|
||||||
|
const techFeed = ref(null), techFeedErr = ref('');
|
||||||
|
const techStanceCls = (s) => s === '看多' ? 'clr-up' : (s === '看空' ? 'clr-down' : 'muted');
|
||||||
|
async function csLoadResearch() {
|
||||||
|
const code = csCode.value;
|
||||||
|
techFeed.value = null; techFeedErr.value = '';
|
||||||
|
const d = await call('get', '/api/research/' + encodeURIComponent(code));
|
||||||
|
if (csCode.value !== code) return; // 期间换了票, 丢弃这份回包
|
||||||
|
if (d && d.ok === false) { techFeedErr.value = d.error || '读不到研究面'; return; }
|
||||||
|
techFeed.value = (d && d.data) ? d.data : d;
|
||||||
|
}
|
||||||
function _csSnapRows(code) {
|
function _csSnapRows(code) {
|
||||||
const k = code6(code), out = [];
|
const k = code6(code), out = [];
|
||||||
const push = (src, label, r, extra) => out.push(Object.assign(
|
const push = (src, label, r, extra) => out.push(Object.assign(
|
||||||
|
|
@ -3319,6 +3527,7 @@ createApp({
|
||||||
csOpen.value = true;
|
csOpen.value = true;
|
||||||
csLoadFull();
|
csLoadFull();
|
||||||
csLoadLedger();
|
csLoadLedger();
|
||||||
|
csLoadResearch();
|
||||||
}
|
}
|
||||||
function closeCodeSignals() { csOpen.value = false; }
|
function closeCodeSignals() { csOpen.value = false; }
|
||||||
async function csLoadFull() {
|
async function csLoadFull() {
|
||||||
|
|
@ -3500,7 +3709,7 @@ createApp({
|
||||||
// (通道告警对交易员改由 dispatch-mode 里的 channel 兜底, 见 wsc 的注释)
|
// (通道告警对交易员改由 dispatch-mode 里的 channel 兜底, 见 wsc 的注释)
|
||||||
(isAdmin.value ? loadWs() : Promise.resolve()),
|
(isAdmin.value ? loadWs() : Promise.resolve()),
|
||||||
loadStrategies(), loadOpLog(), loadOpenScan(), loadUpstream(),
|
loadStrategies(), loadOpLog(), loadOpenScan(), loadUpstream(),
|
||||||
loadMacro()]);
|
loadMacro(), loadTech()]);
|
||||||
await loadNames();
|
await loadNames();
|
||||||
lastRefresh.value = new Date().toTimeString().slice(0, 8);
|
lastRefresh.value = new Date().toTimeString().slice(0, 8);
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
|
@ -4003,6 +4212,7 @@ createApp({
|
||||||
cancelCmd, replan, decide, ops, loadSchema, openReport, cancelIns,
|
cancelCmd, replan, decide, ops, loadSchema, openReport, cancelIns,
|
||||||
planDrawer, planLoading, planStatus, planRows, planCand, planResult,
|
planDrawer, planLoading, planStatus, planRows, planCand, planResult,
|
||||||
loadPlan, openPlan, refreshPlan,
|
loadPlan, openPlan, refreshPlan,
|
||||||
|
techStatus, techChipText, techChipCls,
|
||||||
chgRaw, chgSt, chgLoading, chgTab, chg, chgHeld, chgLog, chgRevised, chgWatch,
|
chgRaw, chgSt, chgLoading, chgTab, chg, chgHeld, chgLog, chgRevised, chgWatch,
|
||||||
loadChanges,
|
loadChanges,
|
||||||
mode, tx, nm, fullName, nameMap, attention, heldPositions, traderParams,
|
mode, tx, nm, fullName, nameMap, attention, heldPositions, traderParams,
|
||||||
|
|
@ -4016,6 +4226,10 @@ createApp({
|
||||||
// 2026-09-10 提议卡重排新增
|
// 2026-09-10 提议卡重排新增
|
||||||
propScale, propSizePct, propAdviceTier, propSizeMismatch, propResized, propJudgeShort,
|
propScale, propSizePct, propAdviceTier, propSizeMismatch, propResized, propJudgeShort,
|
||||||
propMoreOpen, togglePropMore,
|
propMoreOpen, togglePropMore,
|
||||||
|
// 2026-09-12 提议卡重构: 来源结论标签 + 详情弹窗
|
||||||
|
cr, propPlanTag, propPlanSub, propJudgeTag, propCompanyTag, propTechTag, propTechSub,
|
||||||
|
propDetailOpen, propDetailKind, propDetailP, propTechFull, propTechErr, PROP_SRC_LABEL,
|
||||||
|
openPropDetail, closePropDetail,
|
||||||
pHaltBuy, pResumeBuy, pHaltAll, pResumeAll, pReduce, pIncrease, pLiquidate, pSectorExit,
|
pHaltBuy, pResumeBuy, pHaltAll, pResumeAll, pReduce, pIncrease, pLiquidate, pSectorExit,
|
||||||
pctOf, tgtPos, posMoveValid, posMovePreview, doPosMove, heldSectors, secSel, doSectorExit,
|
pctOf, tgtPos, posMoveValid, posMovePreview, doPosMove, heldSectors, secSel, doSectorExit,
|
||||||
pmap, pval, dcaOn, sumPosition, sumDca, sumAutonomy,
|
pmap, pval, dcaOn, sumPosition, sumDca, sumAutonomy,
|
||||||
|
|
@ -4031,6 +4245,7 @@ createApp({
|
||||||
csOpen, csCode, csLoading, csErr, csData, csFromSnap, csTimeline, csSrcFails,
|
csOpen, csCode, csLoading, csErr, csData, csFromSnap, csTimeline, csSrcFails,
|
||||||
csTruncated, csSrcLabel, csLedger, csLedgerErr, csHeld, csInPlan,
|
csTruncated, csSrcLabel, csLedger, csLedgerErr, csHeld, csInPlan,
|
||||||
openCodeSignals, closeCodeSignals, csLoadFull,
|
openCodeSignals, closeCodeSignals, csLoadFull,
|
||||||
|
techFeed, techFeedErr, techStanceCls,
|
||||||
macro, macroGateOn, macroUnavailable, mzLabel, mzColor, mzBarStyle,
|
macro, macroGateOn, macroUnavailable, mzLabel, mzColor, mzBarStyle,
|
||||||
loadMacro, macroScan, macroAdopt,
|
loadMacro, macroScan, macroAdopt,
|
||||||
mzSig, mzAdvicePending, mzChipCls, mzChipText, macroChipClick,
|
mzSig, mzAdvicePending, mzChipCls, mzChipText, macroChipClick,
|
||||||
|
|
|
||||||
|
|
@ -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 个交易日, 供回看判震荡与开口)';
|
||||||
|
|
|
||||||
|
|
@ -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 翻向不当信号,这是决策系统文档明说的指标特性。对照机构做法,这是波动阶段加趋势过滤加跟踪止损的标准组合。
|
||||||
|
|
||||||
|
**预期。** 技术面整体无读数占比应低于百分之三。每天被等技术面开口拦下的票在一到十五只之间,这一条要等工作包二接入分流后才看得到。
|
||||||
|
|
||||||
|
**复核日期。** 工作包二的合议分流上线后满三个交易日。
|
||||||
|
|
|
||||||
|
|
@ -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 为 OK,data_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 改 20(test_batch6 第 594 行)。调度位集合与任务名加 tech_pull(test_wiring 第 663 到 674 行)。路由清单加三个新路由(test_wiring 第 642 到 654 行)。动作求值器次序改为 TRIM、TRIM、EXIT、EXIT、ADD、FILL、DCA(test_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`。
|
||||||
|
|
||||||
|
技术面映射的形状:写入时刻、读数日、日龄、算法版本、是否完整、逐票紧凑状态、各态计数、错误。超三个自然日不刷新按无读数。
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,332 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""技术面接入 · 工作包一 (2026-09-11 方案第五节, 台账 008)。全部离线, 不连库不连网。
|
||||||
|
|
||||||
|
A 相位合成 (core/tech_rules): 四种无读数、九个相位逐格、震荡市抑制翻向信号、数翻向。
|
||||||
|
B 取数解析 (tech_service.parse_item): 代码归一 SH600000→600000.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())
|
||||||
|
|
@ -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 全部单表合规")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue