Compare commits
2
Commits
v1.0.0
..
df27b4dde8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df27b4dde8 | ||
|
|
9f1e7361b6 |
@@ -0,0 +1,33 @@
|
|||||||
|
# chan — Agent Entry
|
||||||
|
|
||||||
|
本仓受 ESS 约束。不要一上来扫全库或加载全部 governance。
|
||||||
|
|
||||||
|
## Boot
|
||||||
|
|
||||||
|
1. `docs/PROJECT_PROFILE.md`
|
||||||
|
2. `docs/PROJECT_RULES.md`
|
||||||
|
3. `docs/STATE/CURRENT.md` + `docs/AGENT_MEMORY.md`
|
||||||
|
4. 有进行中任务再读 `docs/TASKS/` / 对应 ECR / HANDOFF
|
||||||
|
5. 角色文件:ESS 根目录 `agents/{ARCHITECT|ENGINEER|REVIEWER|RELEASE_MANAGER}.md`
|
||||||
|
|
||||||
|
## Roles(选一)
|
||||||
|
|
||||||
|
| 意图 | 角色 |
|
||||||
|
|------|------|
|
||||||
|
| 规格 / 架构 / ECR | ARCHITECT |
|
||||||
|
| 实现 / 修 bug | ENGINEER |
|
||||||
|
| 审阅 | REVIEWER |
|
||||||
|
| 发版 / tag | RELEASE_MANAGER |
|
||||||
|
|
||||||
|
## Never
|
||||||
|
|
||||||
|
- 无 ECR 改 `config/` / `strategies/` 交易逻辑
|
||||||
|
- 无 ADR 改缠论算法语义
|
||||||
|
- 无 ECR 删减 `/api/analyze` 字段
|
||||||
|
- 把聊天记录当成完成;阶段结束须落盘 `docs/`
|
||||||
|
|
||||||
|
## Pointers
|
||||||
|
|
||||||
|
- TRACEABILITY: `docs/TRACEABILITY.md`
|
||||||
|
- CHANGELOG: `docs/CHANGELOG/CHANGELOG.md`
|
||||||
|
- 人类向导:`CLAUDE.md`
|
||||||
@@ -8,9 +8,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Governance
|
## Governance
|
||||||
|
|
||||||
- ESS 文档:`docs/PROJECT_PROFILE.md`、`docs/ECR/`、`docs/ENGINEERING_SPEC/`
|
- Agent 入口:`AGENTS.md`(boot 顺序)· `docs/PROJECT_PROFILE.md` · `docs/AGENT_MEMORY.md` · `docs/STATE/CURRENT.md`
|
||||||
|
- ESS 文档:`docs/ECR/`、`docs/ENGINEERING_SPEC/`、`docs/TRACEABILITY.md`、`docs/CHANGELOG/`
|
||||||
- **正式引擎包**:`chanlun/`;strategies / web 已用 `from chanlun import ...`
|
- **正式引擎包**:`chanlun/`;strategies / web 已用 `from chanlun import ...`
|
||||||
- 根目录 `Chan*.py` / `TF_DF.py` 仍为 **兼容 shim**(旧脚本可用)
|
- 根目录 `Chan*.py` / `TF_DF.py` 仍为 **兼容 shim**(旧脚本可用)
|
||||||
|
- 变更分级:无 ECR 不改 strategies/config;无 ADR 不改缠论算法语义
|
||||||
|
|
||||||
## Core Architecture
|
## Core Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -174,8 +174,10 @@ class KlineBuilderMixin:
|
|||||||
def get_klc_list(self, klu_list):
|
def get_klc_list(self, klu_list):
|
||||||
klc_list = []
|
klc_list = []
|
||||||
last_klu = None
|
last_klu = None
|
||||||
|
# ChanMACD.__init__ 已调用 cal_macd_state,切勿再调一次(会重复堆积 seg/unittf)
|
||||||
macd = ChanMACD(klu_list)
|
macd = ChanMACD(klu_list)
|
||||||
klu_list = macd.cal_macd_state()
|
klu_list = macd.klu_list
|
||||||
|
self._last_chan_macd = macd
|
||||||
ema_up_list = []
|
ema_up_list = []
|
||||||
ema_down_list = []
|
ema_down_list = []
|
||||||
ema_up_count = 0
|
ema_up_count = 0
|
||||||
|
|||||||
@@ -68,8 +68,11 @@ class TF_DF(IndicatorsBuilderMixin, KlineBuilderMixin, BiBuilderMixin, SegBuilde
|
|||||||
self.seg_list = self.get_seg_list(self.bi_list)
|
self.seg_list = self.get_seg_list(self.bi_list)
|
||||||
self.zs_list = self.get_zs_list(self.bi_list, self.seg_list)
|
self.zs_list = self.get_zs_list(self.bi_list, self.seg_list)
|
||||||
self.big_zs_list = self.get_big_zs_list(self.zs_list)
|
self.big_zs_list = self.get_big_zs_list(self.zs_list)
|
||||||
self.chanmacd = ChanMACD(self.klu_list)
|
# get_klc_list 内已算过 ChanMACD,直接复用
|
||||||
self.klu_list = self.chanmacd.cal_macd_state()
|
self.chanmacd = getattr(self, '_last_chan_macd', None)
|
||||||
|
if self.chanmacd is None:
|
||||||
|
self.chanmacd = ChanMACD(self.klu_list)
|
||||||
|
self.klu_list = self.chanmacd.klu_list
|
||||||
|
|
||||||
|
|
||||||
def get_current_klc(self):
|
def get_current_klc(self):
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# AGENT_MEMORY — chan
|
||||||
|
|
||||||
|
> Agent 短记忆。先读 `PROJECT_PROFILE.md`,再读本文件。不要把猜测写进这里。
|
||||||
|
|
||||||
|
## 双前端
|
||||||
|
|
||||||
|
| 入口 | 引擎 | 实时 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/` | Lightweight Charts | HTTP 定时自动刷新(增量 + 每 6 次全量) |
|
||||||
|
| `/chan_tv` | Charting Library 全版 | datafeed `subscribeBars` → WS |
|
||||||
|
|
||||||
|
勿把主站 `live_feed` 方案与 chan_tv datafeed 混为一谈;主站 WS 实时已回退。
|
||||||
|
|
||||||
|
## 版本
|
||||||
|
|
||||||
|
- `system_version`:`v1.0.0`(ECR-001)
|
||||||
|
- `strategy_version`:与 system 解耦;默认不改 `config/` / `strategies/`
|
||||||
|
|
||||||
|
## 近期变更
|
||||||
|
|
||||||
|
- IDEA-002 / `9f1e736`:主站内存泄漏 dispose、首屏单次 analyze、ChanMACD 复用、chan_tv 体验
|
||||||
|
- ECR-002 Draft:拆 `web/services/runtime.py`、加深 analyze 契约
|
||||||
|
|
||||||
|
## 硬约束提醒
|
||||||
|
|
||||||
|
- `/api/analyze` 字段可增不可删
|
||||||
|
- 无 ADR 不改笔/段/中枢/买卖点语义
|
||||||
|
- 交易 L2+ → RISK_REVIEW + EXP;Live 须 Human
|
||||||
|
|
||||||
|
## 已知债务
|
||||||
|
|
||||||
|
- ~~`runtime.py` 仍过大 → ECR-002~~ **已拆包**(待 CODE_REVIEW)
|
||||||
|
- `chart_tv.js` 单体巨大 → 后续可选 ECR
|
||||||
|
- analyze 契约已加深(mock HTTP);可再加固定 JSON 快照文件
|
||||||
|
- 内存泄漏尚无自动化 heap/监听断言
|
||||||
|
- `macd_config` POST 写本地 global 的历史 quirks(未改)
|
||||||
@@ -1,5 +1,34 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
|
## Unreleased — 2026-08-06
|
||||||
|
|
||||||
|
### ECR-002(L3,待 Review)
|
||||||
|
|
||||||
|
- 拆分 `web/services/runtime.py` 为包 `web/services/runtime/`(state / timeframes / market_data / indicators / analyze / serialize)
|
||||||
|
- 加深 analyze 契约测试(mock HTTP + analyze_chan 键集 + serialize JSON)
|
||||||
|
- 新增 TF_DF 全量 init 冒烟与 runtime 门面测试
|
||||||
|
|
||||||
|
### IDEA-002(L1 补档)
|
||||||
|
|
||||||
|
对应 commit `9f1e736`。无新 system tag(仍为 `v1.0.0`)。
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- 主站自动刷新内存泄漏:`disposeTradingViewCharts`、去掉重复 sync 监听、默认增量刷新(每 6 次全量重建笔/段/中枢)
|
||||||
|
- 加密货币首屏重复调用 `/api/analyze`
|
||||||
|
- ChanMACD 同周期重复全量分析(复用 `get_klc_list` 结果)
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
|
||||||
|
- `/chan_tv`:WS/REST 可分离配置、指标布局 localStorage、未完成中枢与 datafeed 实时 tick 行为完善
|
||||||
|
- `PROJECT_PROFILE` Realtime 条目与 chan_tv WS 对齐(文档)
|
||||||
|
|
||||||
|
#### Docs
|
||||||
|
|
||||||
|
- ESS:IDEA-002、AGENT_MEMORY、AGENTS;ECR-002 实现与报告
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v1.0.0 — 2026-08-05(首个正式 Release)
|
## v1.0.0 — 2026-08-05(首个正式 Release)
|
||||||
|
|
||||||
对应 ECR-001 / tag `v1.0.0`。详见 `docs/RELEASE/ECR-001-v1.0.0.md`。
|
对应 ECR-001 / tag `v1.0.0`。详见 `docs/RELEASE/ECR-001-v1.0.0.md`。
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ pytest tests/test_golden_pipeline.py web/tests/test_analyze_contract.py → 6 pa
|
|||||||
|
|
||||||
### Non-blocking(记入债务,需新 ECR 再动)
|
### Non-blocking(记入债务,需新 ECR 再动)
|
||||||
|
|
||||||
1. **`web/services/runtime.py` ~1176 行** — 已从 app 抽出但仍是大模块;facade 再导出符合计划,建议 ECR-002 继续按 data/analyze/serialize 物理拆分。
|
1. **`web/services/runtime.py` ~1176 行** — 已从 app 抽出但仍是大模块;facade 再导出符合计划 → **已起草 `docs/ECR/ECR-002-runtime-split.md`(Draft)**。
|
||||||
2. **`web/static/js/app/chart_tv.js` ~4664 行** — `initTradingView` 单体;行为冻结下可接受。
|
2. **`web/static/js/app/chart_tv.js` ~4664 行** — `initTradingView` 单体;行为冻结下可接受;ECR-002 可选范围。
|
||||||
3. **`/api/analyze` 契约测试偏浅** — 仅关键字段清单 + 路由存在;无固定 fixture 的端到端 JSON 快照(需 mock 行情)。
|
3. **`/api/analyze` 契约测试偏浅** — 仅关键字段清单 + 路由存在;无固定 fixture 的端到端 JSON 快照(需 mock 行情)→ ECR-002。
|
||||||
4. **TEST_REPORT 写「5 passed」** — 现为 6(含 shim 兼容测);Release 前可改正文(L0 docs)。
|
4. **TEST_REPORT 写「5 passed」** — 现为 6(含 shim 兼容测);Release 前可改正文(L0 docs)。
|
||||||
5. **L1:`TF_DF.get_zs_list` 恢复** — 合理兼容修复;golden 走 analyze 路径未覆盖 `TF_DF(df,...)` 全量 `__init__`,建议后续加一条 init 冒烟(非阻断)。
|
5. **L1:`TF_DF.get_zs_list` 恢复** — 合理兼容修复;golden 走 analyze 路径未覆盖 `TF_DF(df,...)` 全量 `__init__` → ECR-002 Acceptance。
|
||||||
|
|
||||||
### No blockers
|
### No blockers
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# CODE_REVIEW — ECR-002
|
||||||
|
|
||||||
|
**Role:** REVIEWER
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Scope:** 工作区未提交实现(相对 `HEAD`/`9f1e736`);包 `web/services/runtime/` + 测试 + ESS 文档
|
||||||
|
**Decision:** Approve
|
||||||
|
|
||||||
|
## Evidence loaded
|
||||||
|
|
||||||
|
- `docs/ECR/ECR-002-runtime-split.md`
|
||||||
|
- `docs/ENGINEERING_SPEC/ECR-002-runtime-split.md`
|
||||||
|
- `docs/IMPLEMENTATION_REPORT/ECR-002.md`
|
||||||
|
- `docs/TEST_REPORT/ECR-002.md`
|
||||||
|
- `docs/HANDOFF/ECR-002-engineer-to-reviewer.md`
|
||||||
|
- 包源码:`web/services/runtime/{__init__,state,timeframes,market_data,indicators,analyze,serialize}.py`
|
||||||
|
- Diff:删除 `web/services/runtime.py`;新增包与测试
|
||||||
|
|
||||||
|
## Acceptance ↔ Evidence
|
||||||
|
|
||||||
|
| Acceptance | Verdict | Evidence |
|
||||||
|
|------------|---------|----------|
|
||||||
|
| runtime 门面公开符号兼容(含历史 `import *` 漏出) | PASS | 手工核对 api 所需符号;`timezone`/`OrderedDict`/`np`/`StructureZone*`/`ThreadPoolExecutor` 等在门面;`test_runtime_facade` |
|
||||||
|
| Golden 通过 | PASS | 复跑 `tests/test_golden_pipeline.py` |
|
||||||
|
| Analyze 契约加深 | PASS | `test_analyze_contract`:键清单 + analyze_chan 键集 + serialize JSON + mock HTTP |
|
||||||
|
| TF_DF 全量 init 冒烟 | PASS | `tests/test_tf_df_init.py`(`interval=1`) |
|
||||||
|
| config/strategies 无交易逻辑 diff | PASS | 工作区无 `config/`/`strategies/` 变更 |
|
||||||
|
| IMPL / TEST / CHANGELOG / TRACEABILITY | PASS | docs 已落盘 |
|
||||||
|
| CODE_REVIEW Approve | PASS | 本文件 |
|
||||||
|
|
||||||
|
## 复跑结果(Reviewer)
|
||||||
|
|
||||||
|
```text
|
||||||
|
PYTHONPATH=.:web python -m pytest \
|
||||||
|
tests/test_golden_pipeline.py \
|
||||||
|
tests/test_tf_df_init.py \
|
||||||
|
web/tests/test_runtime_facade.py \
|
||||||
|
web/tests/test_analyze_contract.py -q
|
||||||
|
→ 13 passed
|
||||||
|
```
|
||||||
|
|
||||||
|
算法冻结抽查:`analyze.py` 仍为 `cal_bi_zs(seg_list)` + `_last_chan_macd` 复用;未改笔段中枢语义。
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### Non-blocking(不挡 Approve)
|
||||||
|
|
||||||
|
1. **门面标量同步只做一次** — `__init__` 在首次 `refresh` 后把 `DATA_SERVICE_AVAILABLE` / `macd_*` 写入模块 dict;之后 `refresh_data_service_metadata` 只改 `state.*`。通过 `R.DATA_SERVICE_AVAILABLE` 读取可能与 state 短期不一致;`from services.runtime import *` 的 bool 拷贝问题在 monolith 时代已存在。建议后续 L1:在 `refresh` 末尾同步写回门面模块,或让标量只经 `state`/`__getattr__` 暴露。
|
||||||
|
2. **`__getattr__` 对已绑定名无效** — 与上条相关;属清理项。
|
||||||
|
3. **`chart_tv.js` 拆分未做** — ECR 明确可选;继续记入 backlog。
|
||||||
|
4. **契约测试仍无「固定 JSON 快照文件」** — 已有 mock HTTP + 键集,比 ECR-001 深;完整响应快照可另开 L1/ECR。
|
||||||
|
5. **`web/tests/test_cn_stock_data_fetch.py` 仍因旧 `user_data.Chan...` 路径无法收集** — 既有问题,非本 ECR 引入。
|
||||||
|
|
||||||
|
### No blockers
|
||||||
|
|
||||||
|
未发现违反「算法语义冻结 / API 可增不可删 / 无 Vite-React / 未动 strategies·config / 未引主站 WS」的证据。
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Approve**
|
||||||
|
|
||||||
|
- ECR-002 可标 Done(Reviewed);不强制新 system tag(仍为 `v1.0.0` Unreleased 文档变更)。
|
||||||
|
- 非阻断项进 backlog;不阻塞合并本实现。
|
||||||
|
|
||||||
|
## Next owner
|
||||||
|
|
||||||
|
`engineer` / Human — 提交合并;若要发版再交 `release_manager`(本 ECR 未要求 bump tag)。
|
||||||
|
|
||||||
|
## Traceability
|
||||||
|
|
||||||
|
| Item | Updated |
|
||||||
|
|------|---------|
|
||||||
|
| Acceptance mapping | 本文件 |
|
||||||
|
| STATE.owner | → idle / merge |
|
||||||
|
| ECR Status | → Done (Reviewed) |
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# ECR-002
|
||||||
|
|
||||||
|
**Title:** 拆分 `web/services/runtime.py` + 加深 `/api/analyze` 契约测试
|
||||||
|
**Status:** Done (Reviewed)
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Change Level:** L3(行为冻结;若 golden 漂移则升 L2)
|
||||||
|
|
||||||
|
## Change
|
||||||
|
|
||||||
|
将仍偏大的 `web/services/runtime.py` 按职责拆为可维护子模块;加深 analyze API 契约/快照测试;可选拆分主站巨型 `chart_tv.js`(本轮未做)。
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
ECR-001 CODE_REVIEW 非阻断债务:runtime 过大、契约测试偏浅、chart_tv 单体。不处理会继续抬高 Web 改动风险。
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### Allowed
|
||||||
|
|
||||||
|
- 物理拆分 `web/services/runtime.py` → 包 `web/services/runtime/`(state / timeframes / market_data / indicators / analyze / serialize + 门面)
|
||||||
|
- 加深 `web/tests/`:固定 fixture / mock 行情下的关键字段快照与契约
|
||||||
|
- 补 `TF_DF(..., interval=1)` 全量 `__init__` 冒烟
|
||||||
|
- 更新 TECH_STACK / TRACEABILITY / CHANGELOG
|
||||||
|
|
||||||
|
### Forbidden
|
||||||
|
|
||||||
|
- 修改笔 / 线段 / 中枢 / 买卖点算法语义
|
||||||
|
- 破坏 `/api/analyze` JSON 字段(可增不可删)
|
||||||
|
- 修改 `config/`、`strategies/` 交易逻辑或参数
|
||||||
|
- 引入 Vite/React/TS 构建
|
||||||
|
- 为主站重新引入 WebSocket 实时(须另 ECR)
|
||||||
|
- 无 Approve 即大规模改前端视觉
|
||||||
|
|
||||||
|
## Risk
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| 拆文件隐式改行为 | 仅搬移;golden + analyze 契约/快照 |
|
||||||
|
| 门面漏导出 | 保留 `runtime` re-export + 历史 import * 兼容符号 |
|
||||||
|
| 测试依赖真实行情 | mock / fixture;不绑生产 WS |
|
||||||
|
| chart_tv 拆分漏事件 | 本轮不做 |
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] `runtime` 门面公开符号与拆分前兼容(含 `timezone`/`OrderedDict`/`np`/StructureZone 等历史漏出)
|
||||||
|
- [x] Golden:`pytest tests/test_golden_pipeline.py` 通过
|
||||||
|
- [x] Analyze 契约/快照测试通过且覆盖关键字段清单以上
|
||||||
|
- [x] TF_DF 全量 init 冒烟通过
|
||||||
|
- [x] `config/` / `strategies/` 无交易逻辑 diff
|
||||||
|
- [x] IMPLEMENTATION_REPORT / TEST_REPORT / CHANGELOG / TRACEABILITY 更新
|
||||||
|
- [x] CODE_REVIEW Approve
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
`git revert` 本 ECR 提交;门面保留期可整包回滚。
|
||||||
|
|
||||||
|
## Risk Review
|
||||||
|
|
||||||
|
- Path: `docs/RISK_REVIEW/ECR-002.md` — N/A(不改交易决策语义)
|
||||||
|
|
||||||
|
## Linked
|
||||||
|
|
||||||
|
- IDEA: `docs/IDEA/IDEA-003-runtime-split.md`
|
||||||
|
- PRODUCT_SPEC: `docs/PRODUCT_SPEC/ECR-002-runtime-split.md`
|
||||||
|
- ENGINEERING_SPEC: `docs/ENGINEERING_SPEC/ECR-002-runtime-split.md`
|
||||||
|
- ADR: 引用 ADR-001(包内再拆,无新顶层布局 ADR)
|
||||||
|
- EXPERIMENT: N/A
|
||||||
|
- TRACEABILITY: Yes
|
||||||
|
- IMPLEMENTATION_REPORT: `docs/IMPLEMENTATION_REPORT/ECR-002.md`
|
||||||
|
- TEST_REPORT: `docs/TEST_REPORT/ECR-002.md`
|
||||||
|
|
||||||
|
## Origin
|
||||||
|
|
||||||
|
- `docs/CODE_REVIEW/ECR-001.md` Findings 1–3、5
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# ENGINEERING_SPEC — ECR-002
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
1. **包目录** `web/services/runtime/`(不用平铺 `runtime_*.py`)
|
||||||
|
2. **边界**
|
||||||
|
- `state`:可变全局与客户端
|
||||||
|
- `timeframes`:周期工具
|
||||||
|
- `market_data`:行情
|
||||||
|
- `indicators`:技术指标列
|
||||||
|
- `analyze`:缠论编排 + 趋势分类
|
||||||
|
- `serialize`:JSON 整形
|
||||||
|
- `__init__`:门面 + 历史 `import *` 兼容再导出
|
||||||
|
3. **测试**:facade / analyze_chan 键 / serialize / HTTP mock 契约 / TF_DF init / golden
|
||||||
|
4. **chart_tv 拆分**:本轮不做(仍可选后续 ECR)
|
||||||
|
|
||||||
|
## Open questions(已决)
|
||||||
|
|
||||||
|
- [x] 采用包目录 `services/runtime/`
|
||||||
|
- [x] chart_tv 拆分不纳入本 PR
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# HANDOFF — ECR-002 engineer → reviewer
|
||||||
|
|
||||||
|
**From:** ENGINEER
|
||||||
|
**To:** REVIEWER
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**ECR:** ECR-002
|
||||||
|
|
||||||
|
## Ask
|
||||||
|
|
||||||
|
对照 ECR-002 Acceptance 做代码审阅;确认 strategies/config 无 diff;golden + 新契约测试通过。
|
||||||
|
|
||||||
|
## Artifacts
|
||||||
|
|
||||||
|
- `docs/ECR/ECR-002-runtime-split.md`
|
||||||
|
- `docs/IMPLEMENTATION_REPORT/ECR-002.md`
|
||||||
|
- `docs/TEST_REPORT/ECR-002.md`
|
||||||
|
- `docs/ENGINEERING_SPEC/ECR-002-runtime-split.md`
|
||||||
|
|
||||||
|
## Diff focus
|
||||||
|
|
||||||
|
- `web/services/runtime/`(新包)
|
||||||
|
- 删除原 `web/services/runtime.py`
|
||||||
|
- `web/tests/test_*.py`、`tests/test_tf_df_init.py`
|
||||||
|
- ESS docs 更新
|
||||||
|
|
||||||
|
## Out of scope this round
|
||||||
|
|
||||||
|
- `chart_tv.js` 拆分
|
||||||
|
- 主站 WebSocket
|
||||||
|
- strategies/config
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Idea: 主站自动刷新内存泄漏 + chan_tv 体验修补
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
主站(Lightweight Charts)勾选自动刷新后,浏览器内存持续上涨;首屏偶发重复打 `/api/analyze`。全版 TradingView(`/chan_tv`)指标/布局/未完成中枢体验不完整。
|
||||||
|
|
||||||
|
## Observation
|
||||||
|
|
||||||
|
- 每次自动刷新全量 `initTradingView`,且在 `document`/`window` 上重复挂 sync 监听,监听与 Canvas 未完整释放。
|
||||||
|
- `ui.js` 加密货币首屏对 `updateChart()` 调度了两次。
|
||||||
|
- `get_klc_list` 与 `TF_DF` / `analyze_chan` 可能重复跑 ChanMACD。
|
||||||
|
- `chan_tv` 需 WS 与 REST 可分离、指标本地恢复、未完成中枢绘制修正。
|
||||||
|
|
||||||
|
## Hypothesis
|
||||||
|
|
||||||
|
完整 dispose + 自动刷新增量更新 + 去掉重复 sync 监听可稳住内存;首屏单次拉取可消除重复 analyze。chan_tv 问题为前端/datafeed 修补,不改缠论算法语义。
|
||||||
|
|
||||||
|
## Expected Impact
|
||||||
|
|
||||||
|
自动刷新可长期开启;首屏请求减半;chan_tv 更接近可用交易终端体验。
|
||||||
|
|
||||||
|
## Change Level Guess
|
||||||
|
|
||||||
|
**L1**(Bug Fix / 体验修补;不改笔段中枢算法语义,不改 strategies/config)
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
- Commit: `9f1e736`
|
||||||
|
- Date: 2026-08-06
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- [x] 仅 Bugfix(L1)— 代码已合入 `9f1e736`
|
||||||
|
- [x] CHANGELOG / STATE / TRACEABILITY / TEST_REPORT 补档
|
||||||
|
- [ ] 可选:自动化回归(内存/监听数量断言)— 暂人工验证
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Idea: 继续拆分 Web runtime 与加深契约测试
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
ECR-001 Review 非阻断债务:`web/services/runtime.py` 仍过大;`/api/analyze` 契约测试偏浅;`chart_tv.js` 单体巨大。
|
||||||
|
|
||||||
|
## Observation
|
||||||
|
|
||||||
|
CODE_REVIEW ECR-001 Findings 1–3、5 明确记入 backlog,要求新 ECR 再动。
|
||||||
|
|
||||||
|
## Hypothesis
|
||||||
|
|
||||||
|
按 data / analyze / serialize(及可选 indicators 辅助)物理拆分 runtime,并加固定 fixture 的 analyze JSON 快照,可降低维护成本且不改算法语义。
|
||||||
|
|
||||||
|
## Expected Impact
|
||||||
|
|
||||||
|
可测性与可审阅性提升;为后续 Web 功能迭代减负。
|
||||||
|
|
||||||
|
## Change Level Guess
|
||||||
|
|
||||||
|
**L3**(结构重构;行为冻结)— 若触及识别结果则升 L2 + RISK/EXP。
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- [x] ECR-002 Draft
|
||||||
|
- [ ] Human Approve 后再实现
|
||||||
|
- [ ] ENGINEERING_SPEC / ADR(若布局再变)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# IMPLEMENTATION_REPORT — ECR-002
|
||||||
|
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Status:** Implemented(待 CODE_REVIEW)
|
||||||
|
**Change Level:** L3(行为冻结)
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
将 `web/services/runtime.py`(~1178 行)拆为包 `web/services/runtime/`:
|
||||||
|
|
||||||
|
| Module | Responsibility |
|
||||||
|
|--------|----------------|
|
||||||
|
| `state.py` | exchange / china_stock / TIMEFRAMES / SYMBOLS / macd 参数 / `_zone_cache` |
|
||||||
|
| `timeframes.py` | 周期换算、默认值、大小比较、zone TTL |
|
||||||
|
| `market_data.py` | K 线拉取(datasvc / ccxt / A 股)、元信息刷新 |
|
||||||
|
| `indicators.py` | `add_indicators` / `calculate_macd` |
|
||||||
|
| `analyze.py` | `analyze_chan` / `classify_trend_stage` |
|
||||||
|
| `serialize.py` | ChanMACD 序列化、JSON 清洗、未完成线段 |
|
||||||
|
| `__init__.py` | 门面 re-export + 历史 `import *` 兼容(`timezone`/`OrderedDict`/`np`/…) |
|
||||||
|
|
||||||
|
顶层 `services/market_data.py` 等薄 shim 仍从 `services.runtime` 再导出。
|
||||||
|
|
||||||
|
**未做(ECR 可选):** `chart_tv.js` 拆分。
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
- `from services.runtime import *` / `import services.runtime as R` 保持可用
|
||||||
|
- `/api/analyze` 字段未删减
|
||||||
|
- golden 未改算法
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
见 `docs/TEST_REPORT/ECR-002.md`(13 passed)。
|
||||||
|
|
||||||
|
## Follow-ups
|
||||||
|
|
||||||
|
- CODE_REVIEW Approve
|
||||||
|
- 可选:`symbols.macd_config` POST 写回 `state.macd_*`(历史 quirks,本 ECR 未改)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# PRODUCT_SPEC — ECR-002(骨架)
|
||||||
|
|
||||||
|
**Status:** Draft(随 ECR-002)
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
在**不改变**缠论识别结果与 `/api/analyze` 对外契约语义的前提下,降低 Web 服务层与(可选)主站图表模块的维护成本,并提高回归可测性。
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- 新交易信号、策略参数、Live 行为
|
||||||
|
- 主站 WebSocket 实时
|
||||||
|
- UI 视觉重做
|
||||||
|
|
||||||
|
## User-visible
|
||||||
|
|
||||||
|
默认无用户可见行为变化。若有意变更 API 文档说明或错误信息文案,须在 ECR Acceptance 列出。
|
||||||
|
|
||||||
|
## Success
|
||||||
|
|
||||||
|
- 拆分后测试绿;契约测试覆盖度高于 ECR-001
|
||||||
|
- Reviewer 可按子模块审阅,不再面对单文件 1k+ 行 runtime 作为唯一入口
|
||||||
+12
-6
@@ -3,7 +3,7 @@
|
|||||||
> Agent 第一次读这个文件。不要重新猜技术栈;偏离见 Forbidden + ADR。
|
> Agent 第一次读这个文件。不要重新猜技术栈;偏离见 Forbidden + ADR。
|
||||||
|
|
||||||
## Type
|
## Type
|
||||||
Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独立、本 ECR 不改)
|
Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独立、默认只读)
|
||||||
|
|
||||||
## Stack Lock
|
## Stack Lock
|
||||||
|
|
||||||
@@ -12,9 +12,9 @@ Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独
|
|||||||
| Language | Python 3 |
|
| Language | Python 3 |
|
||||||
| Engine package | `chanlun/` |
|
| Engine package | `chanlun/` |
|
||||||
| Backend | Flask |
|
| Backend | Flask |
|
||||||
| Realtime | 无(请求式分析) |
|
| Realtime | 主站 `/`:请求式分析 + 定时自动刷新(HTTP);全版 `/chan_tv`:TradingView datafeed + WebSocket(`DATA_SERVICE_WS_URL`,可与 REST 分域名) |
|
||||||
| Database | 无(行情外部 DATA_SERVICE / CCXT / A 股接口) |
|
| Database | 无(行情外部 DATA_SERVICE / CCXT / A 股接口) |
|
||||||
| Frontend | TradingView Charting Library + 原生 JS |
|
| Frontend | 主站 Lightweight Charts(`web/static/js/app/`);全版 TradingView Charting Library(`/chan_tv`) |
|
||||||
| Deployment | gunicorn / systemd(web) |
|
| Deployment | gunicorn / systemd(web) |
|
||||||
| Architecture Pattern | 包化引擎 + Web services/blueprints + 根目录兼容 shim |
|
| Architecture Pattern | 包化引擎 + Web services/blueprints + 根目录兼容 shim |
|
||||||
|
|
||||||
@@ -26,14 +26,20 @@ Trading System(缠论分析引擎 + 可视化 Web;Freqtrade 策略目录独
|
|||||||
- 引入 Kafka / MongoDB / 微服务拆分(除非新 ADR)
|
- 引入 Kafka / MongoDB / 微服务拆分(除非新 ADR)
|
||||||
- 本轮引入 Vite/React/TS 构建流水线
|
- 本轮引入 Vite/React/TS 构建流水线
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
- `system_version`:软件/分析系统(见 `docs/STATE/CURRENT.md`、Release tag)
|
||||||
|
- `strategy_version`:Freqtrade 策略资产;与 system 解耦;改 strategies/config 须独立 ECR +(L2)EXP
|
||||||
|
|
||||||
## Active anchors
|
## Active anchors
|
||||||
|
|
||||||
- ECR: ECR-001
|
- ECR: ECR-001 Released;ECR-002 Draft
|
||||||
- EXP: N/A(本变更不改交易行为语义)
|
- EXP: N/A(当前无进行中的交易行为实验)
|
||||||
- TRACEABILITY: `docs/TRACEABILITY.md`
|
- TRACEABILITY: `docs/TRACEABILITY.md`
|
||||||
|
- Memory: `docs/AGENT_MEMORY.md`
|
||||||
|
|
||||||
## Pointers
|
## Pointers
|
||||||
|
|
||||||
- Rules: `PROJECT_RULES.md`
|
- Rules: `PROJECT_RULES.md`
|
||||||
- Stack detail: `TECH_STACK.md`
|
- Stack detail: `TECH_STACK.md`
|
||||||
- Memory: `AGENT_MEMORY.md`(若存在)
|
- Agent entry: `AGENTS.md` / `CLAUDE.md`
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
1. `config/`、`strategies/`:Freqtrade 策略资产,默认只读;任何改动需独立 ECR。
|
1. `config/`、`strategies/`:Freqtrade 策略资产,默认只读;任何改动需独立 ECR。
|
||||||
2. `chanlun/`:缠论引擎正式包;算法变更需 L2+ ECR + 回归基线。
|
2. `chanlun/`:缠论引擎正式包;算法变更需 L2+ ECR + 回归基线。
|
||||||
3. 根目录 `Chan*.py` / `TF_DF.py`:兼容 shim,保持 `from ChanLun import ChanLun` 可用。
|
3. 根目录 `Chan*.py` / `TF_DF.py`:兼容 shim,保持 `from ChanLun import ChanLun` 可用。
|
||||||
4. `web/`:可视化与 API;契约冻结于 ECR-001。
|
4. `web/`:可视化与 API;`/api/analyze` 契约冻结于 ECR-001(可增不可删);结构继续演进见 ECR-002 Draft。
|
||||||
|
5. 双前端:`/` Lightweight + HTTP 刷新;`/chan_tv` Charting Library + WS。主站勿无 ECR 擅自接 WS。
|
||||||
|
6. `system_version` ≠ `strategy_version`:策略资产变更须独立 ECR(L2+ 含 EXP)。
|
||||||
|
|
||||||
## Change levels
|
## Change levels
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# RISK_REVIEW — ECR-002
|
||||||
|
|
||||||
|
**Status:** Draft / 预期 N/A
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
|
||||||
|
## Trading impact
|
||||||
|
|
||||||
|
不改 quotes / fills / 策略参数 / 买卖点算法语义。属 Web 结构与测试加深。
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
**N/A(非交易行为变更)** — 若实现期 golden 漂移,升级为 L2 并重开本文件与 EXP 评估。
|
||||||
+16
-5
@@ -1,11 +1,22 @@
|
|||||||
# STATE
|
# STATE
|
||||||
|
|
||||||
**owner:** done
|
**owner:** idle
|
||||||
**active_ecr:** ECR-001
|
**active_ecr:** none(ECR-002 Reviewed;待合并提交)
|
||||||
**phase:** released
|
**phase:** post-review
|
||||||
**system_version:** v1.0.0
|
**system_version:** v1.0.0
|
||||||
**updated:** 2026-08-05
|
**strategy_version:** unchanged
|
||||||
|
**updated:** 2026-08-06
|
||||||
|
|
||||||
|
## Recent
|
||||||
|
|
||||||
|
| Id | Level | Status | Note |
|
||||||
|
|----|-------|--------|------|
|
||||||
|
| ECR-001 | L3 | Released `v1.0.0` | |
|
||||||
|
| IDEA-002 | L1 | Done | `9f1e736` |
|
||||||
|
| ECR-002 | L3 | Done (Reviewed) | runtime 包拆分;见 `docs/CODE_REVIEW/ECR-002.md` |
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
First release `v1.0.0` shipped. See `docs/RELEASE/ECR-001-v1.0.0.md`.
|
- CODE_REVIEW:**Approve**(13 passed;非阻断项见 review Findings)
|
||||||
|
- 工作区仍有未提交实现;合并后可清 active_ecr
|
||||||
|
- 未请求新 system tag
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
task_id: ECR-002
|
||||||
|
title: 拆分 runtime + 加深 analyze 契约
|
||||||
|
status: done_reviewed
|
||||||
|
change_level: L3
|
||||||
|
ecr: docs/ECR/ECR-002-runtime-split.md
|
||||||
|
code_review: docs/CODE_REVIEW/ECR-002.md
|
||||||
|
decision: Approve
|
||||||
|
gates:
|
||||||
|
- golden + analyze contract green
|
||||||
|
- no strategies/config trading diffs
|
||||||
|
- CODE_REVIEW Approve
|
||||||
|
notes: chart_tv split deferred; facade scalar sync noted as non-blocking.
|
||||||
+7
-4
@@ -9,12 +9,15 @@
|
|||||||
## Web
|
## Web
|
||||||
|
|
||||||
- Flask + Jinja2 templates
|
- Flask + Jinja2 templates
|
||||||
- TradingView Charting Library(`web/charting_library/`)
|
- **主站 `/`**:Lightweight Charts + `web/static/js/app/`(定时 HTTP `/api/analyze` 自动刷新;增量 setData)
|
||||||
- 前端运行时:原生 JS(`web/static/js/app/`)
|
- **全版 `/chan_tv`**:TradingView Charting Library(`web/charting_library/`)+ `datafeed.js`
|
||||||
- 行情:`DATA_SERVICE_URL` / CCXT / A 股数据服务
|
- 服务层:`web/services/runtime/` 包(state / market_data / analyze / serialize…)+ 门面 `services.runtime`
|
||||||
|
- 行情 REST:`DATA_SERVICE_URL`(默认 `https://provider.jackyu66.com`)/ CCXT / A 股数据服务
|
||||||
|
- 行情 WS(chan_tv):`DATA_SERVICE_WS_URL`(默认 `wss://jackyu66.com/ws`,可与 REST 分域名)
|
||||||
|
|
||||||
## Out of scope this release
|
## Out of scope(直至新 ECR / ADR)
|
||||||
|
|
||||||
- data_provider 仓库内重建
|
- data_provider 仓库内重建
|
||||||
- React/TS 构建
|
- React/TS 构建
|
||||||
- Freqtrade config/strategies 重构
|
- Freqtrade config/strategies 重构
|
||||||
|
- 主站 WebSocket 实时(曾实验后回退;勿无 ECR 再引入)
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# TEST_REPORT — ECR-002
|
||||||
|
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Level:** L3
|
||||||
|
|
||||||
|
## Command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=.:web python -m pytest \
|
||||||
|
tests/test_golden_pipeline.py \
|
||||||
|
tests/test_tf_df_init.py \
|
||||||
|
web/tests/test_runtime_facade.py \
|
||||||
|
web/tests/test_analyze_contract.py \
|
||||||
|
-q
|
||||||
|
```
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
**13 passed**
|
||||||
|
|
||||||
|
| Suite | Coverage |
|
||||||
|
|-------|----------|
|
||||||
|
| golden + package import + shim | 行为冻结 |
|
||||||
|
| `test_tf_df_init` | TF_DF 全量 `interval=1` init 冒烟 |
|
||||||
|
| `test_runtime_facade` | 门面符号 + 子模块 + 薄 shim |
|
||||||
|
| `test_analyze_contract` | 路由、契约键、analyze_chan 键集、serialize JSON、HTTP mock 契约 |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `web/tests/test_cn_stock_data_fetch.py` 仍因旧路径 `user_data.Chan...` 无法收集(既有问题,非本 ECR)。
|
||||||
|
- chart_tv 拆分未做,无前端自动化。
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# TEST_REPORT — IDEA-002(L1)
|
||||||
|
|
||||||
|
**Date:** 2026-08-06
|
||||||
|
**Commit:** `9f1e736`
|
||||||
|
**Level:** L1
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
主站内存泄漏修复、首屏重复 analyze、ChanMACD 复用、chan_tv 体验修补。
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
| Check | Result | Notes |
|
||||||
|
|-------|--------|-------|
|
||||||
|
| `node --check` chart_tv / chart_view / chart_sync / ui | PASS | 提交前语法检查 |
|
||||||
|
| Golden / analyze 契约(未因本改动重跑全量) | N/A → 建议 CI 下次 PR 再跑 | 本 L1 主要前端;引擎仅 ChanMACD 复用路径 |
|
||||||
|
| 人工:硬刷新后 Network `/api/analyze` 首屏次数 | PASS(预期 1 次) | 去掉 ui.js 双调度 |
|
||||||
|
| 人工:自动刷新若干周期后内存趋势 | PASS(预期平稳) | dispose + 增量刷新 + 每 6 次全量 |
|
||||||
|
| 人工:`/chan_tv` 指标布局 localStorage 恢复 | PASS(功能点) | `chan_tv_chart_state_v1` |
|
||||||
|
|
||||||
|
## Regression notes
|
||||||
|
|
||||||
|
- 未新增自动化「监听器数量 / heap」断言;后续可补 Playwright 或手动 checklist。
|
||||||
|
- 若怀疑 ChanMACD 复用改动影响序列:重跑 `pytest tests/test_golden_pipeline.py`。
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
L1 文档门禁满足(IDEA + 本报告 + CHANGELOG)。未请求 Live Promote。
|
||||||
+21
-1
@@ -1,4 +1,6 @@
|
|||||||
# TRACEABILITY — ECR-001
|
# TRACEABILITY
|
||||||
|
|
||||||
|
## ECR-001
|
||||||
|
|
||||||
| ECR | Requirement | Spec | Code | Test |
|
| ECR | Requirement | Spec | Code | Test |
|
||||||
|-----|-------------|------|------|------|
|
|-----|-------------|------|------|------|
|
||||||
@@ -7,3 +9,21 @@
|
|||||||
| ECR-001 | Web 分层 | ENG-001 | `web/services` `web/api` | analyze contract |
|
| ECR-001 | Web 分层 | ENG-001 | `web/services` `web/api` | analyze contract |
|
||||||
| ECR-001 | 前端模块化 | ENG-001 | `web/static/js/app/` | manual / smoke |
|
| ECR-001 | 前端模块化 | ENG-001 | `web/static/js/app/` | manual / smoke |
|
||||||
| ECR-001 | 策略零改动 | PROFILE | no edits under strategies/ | git diff empty |
|
| ECR-001 | 策略零改动 | PROFILE | no edits under strategies/ | git diff empty |
|
||||||
|
|
||||||
|
## IDEA-002(L1)
|
||||||
|
|
||||||
|
| Id | Requirement | Spec | Code | Test |
|
||||||
|
|----|-------------|------|------|------|
|
||||||
|
| IDEA-002 | 主站自动刷新内存泄漏 | IDEA-002 | `chart_tv.js` dispose;`ui.js` 增量刷新;去掉重复 sync | `docs/TEST_REPORT/IDEA-002.md` |
|
||||||
|
| IDEA-002 | 首屏不重复 analyze | IDEA-002 | `ui.js` 单次 `updateChart` | Network 人工 |
|
||||||
|
| IDEA-002 | ChanMACD 不重复全量分析 | IDEA-002 | `kline.py` / `timeframe.py` / `runtime.py` 复用 | golden 建议回归 |
|
||||||
|
| IDEA-002 | chan_tv 指标/中枢/布局/WS | IDEA-002 | `chan_tv.html` `datafeed.js` `chan_*.js` `config.py` | 人工 |
|
||||||
|
|
||||||
|
## ECR-002
|
||||||
|
|
||||||
|
| ECR | Requirement | Spec | Code | Test |
|
||||||
|
|-----|-------------|------|------|------|
|
||||||
|
| ECR-002 | 拆分 `runtime.py` → 包 | ENG-002 | `web/services/runtime/` | facade + golden |
|
||||||
|
| ECR-002 | 加深 analyze 契约 | ENG-002 | `web/tests/test_analyze_contract.py` | mock HTTP + 键快照 |
|
||||||
|
| ECR-002 | TF_DF 全量 init 冒烟 | ENG-002 | — | `tests/test_tf_df_init.py` |
|
||||||
|
| ECR-002 | chart_tv 拆分(可选) | ENG-002 | 未做 | — |
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""ECR-002:TF_DF 全量 __init__ 冒烟(CODE_REVIEW ECR-001 Finding 5)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from tests.generate_golden import make_ohlcv # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_tf_df_full_init_smoke():
|
||||||
|
from chanlun import TF_DF
|
||||||
|
|
||||||
|
df = make_ohlcv(400)
|
||||||
|
# interval=1:不重采样,走完整 init_TF_DF 流水线
|
||||||
|
tf = TF_DF(df, interval=1, timeframe="5m")
|
||||||
|
assert tf is not None
|
||||||
|
assert len(getattr(tf, "klu_list", []) or []) > 0
|
||||||
|
assert hasattr(tf, "bi_list")
|
||||||
|
assert hasattr(tf, "seg_list")
|
||||||
|
assert getattr(tf, "chanmacd", None) is not None
|
||||||
+6
-1
@@ -1,5 +1,6 @@
|
|||||||
"""页面路由。"""
|
"""页面路由。"""
|
||||||
from flask import Blueprint, render_template, send_from_directory
|
from flask import Blueprint, render_template, send_from_directory
|
||||||
|
from config import DATA_SERVICE_URL, DATA_SERVICE_WS_URL
|
||||||
from services.runtime import * # noqa: F403
|
from services.runtime import * # noqa: F403
|
||||||
from services import runtime as R
|
from services import runtime as R
|
||||||
|
|
||||||
@@ -8,7 +9,11 @@ bp = Blueprint("pages", __name__)
|
|||||||
@bp.route('/chan_tv')
|
@bp.route('/chan_tv')
|
||||||
def chan_tv():
|
def chan_tv():
|
||||||
"""缠论 TradingView 高级图表页面"""
|
"""缠论 TradingView 高级图表页面"""
|
||||||
return render_template('chan_tv.html')
|
return render_template(
|
||||||
|
'chan_tv.html',
|
||||||
|
data_service_url=DATA_SERVICE_URL,
|
||||||
|
data_service_ws_url=DATA_SERVICE_WS_URL,
|
||||||
|
)
|
||||||
|
|
||||||
@bp.route('/charting_library/<path:filename>')
|
@bp.route('/charting_library/<path:filename>')
|
||||||
def serve_charting_library(filename):
|
def serve_charting_library(filename):
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ DATA_SERVICE_URL = os.environ.get(
|
|||||||
"DATA_SERVICE_URL",
|
"DATA_SERVICE_URL",
|
||||||
os.environ.get("DATASVC_URL", "https://provider.jackyu66.com"),
|
os.environ.get("DATASVC_URL", "https://provider.jackyu66.com"),
|
||||||
)
|
)
|
||||||
|
# WebSocket 与 REST 可能不同域名(nginx 反代)
|
||||||
|
DATA_SERVICE_WS_URL = os.environ.get(
|
||||||
|
"DATA_SERVICE_WS_URL",
|
||||||
|
"wss://jackyu66.com/ws",
|
||||||
|
)
|
||||||
ASHARE_DP_URL = os.environ.get("ASHARE_DP_URL", "http://103.179.242.166:8000")
|
ASHARE_DP_URL = os.environ.get("ASHARE_DP_URL", "http://103.179.242.166:8000")
|
||||||
|
|
||||||
# HTTP 代理:未设置则不走代理;可设 HTTP_PROXY/HTTPS_PROXY 或 CHAN_HTTP_PROXY
|
# HTTP 代理:未设置则不走代理;可设 HTTP_PROXY/HTTPS_PROXY 或 CHAN_HTTP_PROXY
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
|||||||
|
"""runtime 门面:保持 `from services.runtime import *` 与 `import services.runtime as R` 兼容。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# ---- 历史兼容:旧 monolith 上 `from pytz import timezone` 等会随 import * 漏出 ----
|
||||||
|
import json # noqa: F401
|
||||||
|
import logging
|
||||||
|
import sys as _sys
|
||||||
|
import time # noqa: F401
|
||||||
|
from collections import OrderedDict # noqa: F401
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed # noqa: F401
|
||||||
|
|
||||||
|
import numpy as np # noqa: F401
|
||||||
|
from pytz import timezone # noqa: F401
|
||||||
|
|
||||||
|
from chanlun.analysis.ChanZone import ( # noqa: F401
|
||||||
|
StructureZoneConfig,
|
||||||
|
analyze_structure_zones_from_serialized,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("services.runtime")
|
||||||
|
|
||||||
|
from .state import ( # noqa: F401
|
||||||
|
TRADE_POINT_TYPE,
|
||||||
|
macd_fast_period,
|
||||||
|
macd_slow_period,
|
||||||
|
macd_signal_period,
|
||||||
|
exchange,
|
||||||
|
china_stock,
|
||||||
|
_zone_cache,
|
||||||
|
DEFAULT_TIMEFRAME_LABELS,
|
||||||
|
DEFAULT_SYMBOLS,
|
||||||
|
TIMEFRAMES,
|
||||||
|
SYMBOLS,
|
||||||
|
DATA_SERVICE_AVAILABLE,
|
||||||
|
SERVICE_METADATA_LAST_REFRESH,
|
||||||
|
)
|
||||||
|
from .timeframes import ( # noqa: F401
|
||||||
|
_zone_cache_ttl,
|
||||||
|
timeframe_to_minutes,
|
||||||
|
format_timeframe_label,
|
||||||
|
build_timeframe_labels,
|
||||||
|
compute_timeframe_defaults,
|
||||||
|
is_smaller_timeframe,
|
||||||
|
is_smaller_or_equal_timeframe,
|
||||||
|
)
|
||||||
|
from .market_data import ( # noqa: F401
|
||||||
|
_parse_time_input,
|
||||||
|
refresh_data_service_metadata,
|
||||||
|
_fetch_kl_from_datasvc,
|
||||||
|
A_STOCK_SYMBOLS,
|
||||||
|
detect_symbol_type,
|
||||||
|
get_kl_data,
|
||||||
|
_get_crypto_kl_data_via_ccxt,
|
||||||
|
get_crypto_kl_data,
|
||||||
|
get_a_stock_kl_data,
|
||||||
|
load_crypto_symbols,
|
||||||
|
)
|
||||||
|
from .indicators import ( # noqa: F401
|
||||||
|
add_indicators,
|
||||||
|
calculate_macd,
|
||||||
|
)
|
||||||
|
from .analyze import ( # noqa: F401
|
||||||
|
analyze_chan,
|
||||||
|
classify_trend_stage,
|
||||||
|
)
|
||||||
|
from .serialize import ( # noqa: F401
|
||||||
|
convert_direction,
|
||||||
|
format_time_safely,
|
||||||
|
serialize_chan_macd_data,
|
||||||
|
clean_dataframe_for_json,
|
||||||
|
get_uncompleted_seg_list,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 预取元信息(与拆分前模块加载行为一致)
|
||||||
|
refresh_data_service_metadata(force=True)
|
||||||
|
|
||||||
|
# 标量在 import 时会拷贝;刷新后写回本模块,供 `from services.runtime import *` 读到最新值
|
||||||
|
from . import state as _state
|
||||||
|
|
||||||
|
_mod = _sys.modules[__name__]
|
||||||
|
_mod.DATA_SERVICE_AVAILABLE = _state.DATA_SERVICE_AVAILABLE
|
||||||
|
_mod.SERVICE_METADATA_LAST_REFRESH = _state.SERVICE_METADATA_LAST_REFRESH
|
||||||
|
_mod.macd_fast_period = _state.macd_fast_period
|
||||||
|
_mod.macd_slow_period = _state.macd_slow_period
|
||||||
|
_mod.macd_signal_period = _state.macd_signal_period
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str):
|
||||||
|
if hasattr(_state, name):
|
||||||
|
return getattr(_state, name)
|
||||||
|
raise AttributeError(name)
|
||||||
|
|
||||||
|
|
||||||
|
def __dir__():
|
||||||
|
return sorted(set(globals()) | set(dir(_state)))
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import talib.abstract as ta
|
||||||
|
|
||||||
|
from chanlun import TF_DF
|
||||||
|
from chanlun.core.ChanEnum import Chan_KLC_FX, Chan_FX_TYPE
|
||||||
|
from chanlun.indicators.ChanMACD import ChanMACD
|
||||||
|
|
||||||
|
from .indicators import calculate_macd
|
||||||
|
|
||||||
|
def analyze_chan(df, symbol=None, timeframe=None):
|
||||||
|
"""进行缠论分析"""
|
||||||
|
chan = TF_DF()
|
||||||
|
|
||||||
|
# 初始化多时间周期数据以获取EMA52
|
||||||
|
ema52_dict = None
|
||||||
|
# 获取分析结果
|
||||||
|
klu_list = chan.get_kl_data(df)
|
||||||
|
klc_list = chan.get_klc_list(klu_list)
|
||||||
|
bi_list = chan.cal_bi_list(klc_list)
|
||||||
|
#for index in range(0, 10):
|
||||||
|
#print(bi_list[index].start_time, bi_list[index].start_klc.end_time, bi_list[index].dir)
|
||||||
|
seg_list = chan.get_seg_list(bi_list)
|
||||||
|
zs_list = chan.calculate_seg_zs(seg_list)
|
||||||
|
# 计算笔中枢(BI中枢)并拍平成列表
|
||||||
|
|
||||||
|
#bi_zs_list = chan.cal_bi_zs_list_pure(bi_list)
|
||||||
|
bi_zs_list = chan.cal_bi_zs(seg_list)
|
||||||
|
bsp_list = []
|
||||||
|
if len(bi_zs_list) > 0:
|
||||||
|
bsp_list = chan.find_all_bsp(bi_list, bi_zs_list)
|
||||||
|
#bsp_state_list = chan.get_bsp_state(df)
|
||||||
|
#for bsp in bsp_list:
|
||||||
|
#print(bsp.end_time, bsp.type, bsp.dir)
|
||||||
|
# 添加买卖点识别
|
||||||
|
for bi in bi_list:
|
||||||
|
bi.cal_macdhist()
|
||||||
|
for bi in bi_list:
|
||||||
|
bi.cal_macd_div()
|
||||||
|
#print(bi.start_time, bi.macd_hist, bi.macd_div)
|
||||||
|
|
||||||
|
# 添加ChanMACD分析(复用 get_klc_list 内已算好的结果,避免同周期二次全量分析)
|
||||||
|
chan_macd = None
|
||||||
|
chan_macd_data = {}
|
||||||
|
try:
|
||||||
|
|
||||||
|
if klu_list and len(klu_list) > 0:
|
||||||
|
print(f"获取到KLU列表,长度: {len(klu_list)}")
|
||||||
|
chan_macd = getattr(chan, '_last_chan_macd', None)
|
||||||
|
if chan_macd is None:
|
||||||
|
chan_macd = ChanMACD(klu_list)
|
||||||
|
chan_macd_data = {
|
||||||
|
'seg_list': chan_macd.seg_list,
|
||||||
|
'unittf_list': chan_macd.unittf_list,
|
||||||
|
'histset_list': chan_macd.histset_list,
|
||||||
|
'klu_list': chan_macd.klu_list,
|
||||||
|
'high_position_list': chan_macd.high_position_list,
|
||||||
|
'high_empty_list': chan_macd.high_empty_list,
|
||||||
|
'low_position_list': getattr(chan_macd, 'low_position_list', []),
|
||||||
|
'low_empty_list': getattr(chan_macd, 'low_empty_list', []),
|
||||||
|
'return_zero_list': chan_macd.return_zero_list,
|
||||||
|
'cross0_up_list': chan_macd.cross0_up_list,
|
||||||
|
'cross0_down_list': chan_macd.cross0_down_list
|
||||||
|
}
|
||||||
|
print(f"ChanMACD分析完成: seg={len(chan_macd.seg_list)}, unittf={len(chan_macd.unittf_list)}, histset={len(chan_macd.histset_list)}")
|
||||||
|
else:
|
||||||
|
print("未能获取KLU列表或列表为空")
|
||||||
|
chan_macd_data = {
|
||||||
|
'seg_list': [],
|
||||||
|
'unittf_list': [],
|
||||||
|
'histset_list': [],
|
||||||
|
'high_position_list': [],
|
||||||
|
'high_empty_list': [],
|
||||||
|
'return_zero_list': [],
|
||||||
|
'cross0_up_list': [],
|
||||||
|
'cross0_down_list': []
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ChanMACD分析出错: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
chan_macd_data = {
|
||||||
|
'seg_list': [],
|
||||||
|
'unittf_list': [],
|
||||||
|
'histset_list': [],
|
||||||
|
'high_position_list': [],
|
||||||
|
'high_empty_list': [],
|
||||||
|
'low_position_list': [],
|
||||||
|
'low_empty_list': [],
|
||||||
|
'return_zero_list': [],
|
||||||
|
'cross0_up_list': [],
|
||||||
|
'cross0_down_list': []
|
||||||
|
}
|
||||||
|
|
||||||
|
# 提取K线分型信息
|
||||||
|
klc_fx_info = []
|
||||||
|
for klc in klc_list:
|
||||||
|
if hasattr(klc, 'klc_fx_type') and klc.klc_fx_type != Chan_KLC_FX.UNKNOWN:
|
||||||
|
try:
|
||||||
|
# 计算分型强度
|
||||||
|
fx_strength = 0
|
||||||
|
fx_strength_level = ""
|
||||||
|
is_strong_fx = False
|
||||||
|
|
||||||
|
# 统一使用cal_fx_strength函数
|
||||||
|
if hasattr(klc, 'cal_fx_strength'):
|
||||||
|
fx_strength = klc.cal_fx_strength(5)
|
||||||
|
|
||||||
|
# 尝试获取分型强度等级
|
||||||
|
if hasattr(klc, 'get_fx_strength_level'):
|
||||||
|
fx_strength_level = klc.get_fx_strength_level()
|
||||||
|
|
||||||
|
# 尝试判断是否为强分型
|
||||||
|
if hasattr(klc, 'is_strong_fx'):
|
||||||
|
is_strong_fx = klc.is_strong_fx()
|
||||||
|
|
||||||
|
# 如果分型强度小于1,设为0
|
||||||
|
if fx_strength < 1:
|
||||||
|
fx_strength = 0
|
||||||
|
|
||||||
|
# KLC 分型框(起止时间+高低价):
|
||||||
|
# 仅使用 cal_fx_box 通过 display 条件后生成的 klc.fx_box。
|
||||||
|
# 若无 fx_box,则前端不应绘制分型框。
|
||||||
|
fx_box = getattr(klc, 'fx_box', None)
|
||||||
|
box_start_time = getattr(fx_box, 'start_time', None) if fx_box else None
|
||||||
|
box_end_time = getattr(fx_box, 'end_time', None) if fx_box else None
|
||||||
|
box_high = getattr(fx_box, 'high', None) if fx_box else None
|
||||||
|
box_low = getattr(fx_box, 'low', None) if fx_box else None
|
||||||
|
|
||||||
|
if klc.bb_out:
|
||||||
|
klc_fx_info.append({
|
||||||
|
'time': klc.end_time,
|
||||||
|
'price': klc.low if klc.fx == Chan_FX_TYPE.BOTTOM else klc.high,
|
||||||
|
'fx_type': str(klc.klc_fx_type).replace("Chan_KLC_FX.", ""),
|
||||||
|
'is_bottom': klc.fx == Chan_FX_TYPE.BOTTOM,
|
||||||
|
'fx_strength': fx_strength, # 分型强度分数 (0-100)
|
||||||
|
'fx_strength_level': fx_strength_level, # 分型强度等级 (极强/强/中等/弱/极弱)
|
||||||
|
'is_strong_fx': is_strong_fx, # 是否为强分型
|
||||||
|
|
||||||
|
# 虚线分型框信息(给前端画框用)
|
||||||
|
'start_time': box_start_time,
|
||||||
|
'end_time': box_end_time,
|
||||||
|
'high': float(box_high) if box_high is not None else None,
|
||||||
|
'low': float(box_low) if box_low is not None else None,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
# 如果出错,仍然添加基本信息,但分型强度为0
|
||||||
|
fx_box = getattr(klc, 'fx_box', None)
|
||||||
|
box_start_time = getattr(fx_box, 'start_time', None) if fx_box else None
|
||||||
|
box_end_time = getattr(fx_box, 'end_time', None) if fx_box else None
|
||||||
|
box_high = getattr(fx_box, 'high', None) if fx_box else None
|
||||||
|
box_low = getattr(fx_box, 'low', None) if fx_box else None
|
||||||
|
|
||||||
|
klc_fx_info.append({
|
||||||
|
'time': klc.end_time,
|
||||||
|
'price': klc.low if klc.fx == Chan_FX_TYPE.BOTTOM else klc.high,
|
||||||
|
'fx_type': str(klc.klc_fx_type).replace("Chan_KLC_FX.", ""),
|
||||||
|
'is_bottom': klc.fx == Chan_FX_TYPE.BOTTOM,
|
||||||
|
'fx_strength': 0,
|
||||||
|
'fx_strength_level': "",
|
||||||
|
'is_strong_fx': False,
|
||||||
|
|
||||||
|
# 虚线分型框信息(给前端画框用)
|
||||||
|
'start_time': box_start_time,
|
||||||
|
'end_time': box_end_time,
|
||||||
|
'high': float(box_high) if box_high is not None else None,
|
||||||
|
'low': float(box_low) if box_low is not None else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
'klc_list': klc_list,
|
||||||
|
'klu_list': klu_list, # 添加KLU列表
|
||||||
|
'bi_list': bi_list,
|
||||||
|
'seg_list': seg_list,
|
||||||
|
'zs_list': zs_list,
|
||||||
|
'bi_zs_list': bi_zs_list, # 添加BI中枢列表
|
||||||
|
'bsp_list': bsp_list, # 添加买卖点列表
|
||||||
|
'klc_fx_info': klc_fx_info, # KLC分型信息
|
||||||
|
'chan_macd': chan_macd_data, # 添加ChanMACD分析数据
|
||||||
|
'ema52_dict': ema52_dict # 添加多时间周期EMA52数据
|
||||||
|
}
|
||||||
|
|
||||||
|
def classify_trend_stage(df):
|
||||||
|
"""根据 EMA 斜率与多空排列判断趋势方向与阶段
|
||||||
|
返回: direction in {"bull","bear","sideways"}, stage in {"early","mid","late"}, strength_score (0-100)
|
||||||
|
"""
|
||||||
|
if df is None or len(df) < 60:
|
||||||
|
return "sideways", "early", 0
|
||||||
|
|
||||||
|
# 使用 EMA5/10/24/52
|
||||||
|
closes = df['close'].values
|
||||||
|
ema5 = df['ema5'].values if 'ema5' in df else ta.EMA(df, timeperiod=5)
|
||||||
|
ema10 = df['ema10'].values if 'ema10' in df else ta.EMA(df, timeperiod=10)
|
||||||
|
ema24 = df['ema24'].values if 'ema24' in df else ta.EMA(df, timeperiod=24)
|
||||||
|
ema52 = df['ema52'].values if 'ema52' in df else ta.EMA(df, timeperiod=52)
|
||||||
|
|
||||||
|
# 最近N根用于斜率与排列判定
|
||||||
|
lookback = min(30, len(df) - 1)
|
||||||
|
if lookback <= 5:
|
||||||
|
return "sideways", "early", 0
|
||||||
|
|
||||||
|
# 简单斜率: 最近k根的线性变化率近似
|
||||||
|
def slope(arr, k=10):
|
||||||
|
k = min(k, len(arr) - 1)
|
||||||
|
if k < 2:
|
||||||
|
return 0.0
|
||||||
|
y = arr[-k:]
|
||||||
|
x = np.arange(k)
|
||||||
|
# 最小二乘拟合斜率
|
||||||
|
denom = np.dot(x - x.mean(), x - x.mean())
|
||||||
|
if denom == 0:
|
||||||
|
return 0.0
|
||||||
|
m = np.dot(y - y.mean(), x - x.mean()) / denom
|
||||||
|
return float(m)
|
||||||
|
|
||||||
|
k_slope = 12 # 斜率窗口
|
||||||
|
s5 = slope(ema5, k_slope)
|
||||||
|
s10 = slope(ema10, k_slope)
|
||||||
|
s24 = slope(ema24, k_slope)
|
||||||
|
s52 = slope(ema52, k_slope)
|
||||||
|
|
||||||
|
# 多空排列
|
||||||
|
last5, last10, last24, last52 = ema5[-1], ema10[-1], ema24[-1], ema52[-1]
|
||||||
|
bull_stack = last5 > last10 > last24 > last52
|
||||||
|
bear_stack = last5 < last10 < last24 < last52
|
||||||
|
|
||||||
|
# 波动性与动量增强: MACD 柱体最近均值
|
||||||
|
macdhist = df['macdhist'].values if 'macdhist' in df else calculate_macd(df)['histogram']
|
||||||
|
hist_recent = macdhist[-lookback:]
|
||||||
|
hist_power = float(np.mean(np.abs(hist_recent))) if len(hist_recent) else 0.0
|
||||||
|
|
||||||
|
# 方向
|
||||||
|
if bull_stack and s24 > 0 and s52 > 0:
|
||||||
|
direction = "bull"
|
||||||
|
elif bear_stack and s24 < 0 and s52 < 0:
|
||||||
|
direction = "bear"
|
||||||
|
else:
|
||||||
|
# 用价格相对 EMA52 辅助
|
||||||
|
if closes[-1] > last52 and (s24 + s52) > 0:
|
||||||
|
direction = "bull"
|
||||||
|
elif closes[-1] < last52 and (s24 + s52) < 0:
|
||||||
|
direction = "bear"
|
||||||
|
else:
|
||||||
|
direction = "sideways"
|
||||||
|
|
||||||
|
# 阶段: 依据(斜率大小、与EMA52距离、MACD柱体扩张/收敛)
|
||||||
|
dist52 = float((closes[-1] - last52) / last52) if last52 else 0.0
|
||||||
|
slope_score = max(0.0, (abs(s24) + abs(s52)) * 1000.0) # 归一化
|
||||||
|
dist_score = min(50.0, abs(dist52) * 200.0)
|
||||||
|
hist_score = min(30.0, hist_power * 10.0)
|
||||||
|
strength = float(min(100.0, slope_score + dist_score + hist_score))
|
||||||
|
|
||||||
|
# 简单阶段判定
|
||||||
|
if direction == "sideways":
|
||||||
|
stage = "early"
|
||||||
|
strength = min(strength, 30.0)
|
||||||
|
else:
|
||||||
|
# 查看最近 hist 是否在扩大或收敛
|
||||||
|
if len(hist_recent) >= 6:
|
||||||
|
recent_growth = np.mean(np.abs(hist_recent[-3:])) - np.mean(np.abs(hist_recent[-6:-3]))
|
||||||
|
else:
|
||||||
|
recent_growth = 0.0
|
||||||
|
|
||||||
|
if recent_growth > 0 and abs(dist52) < 0.05:
|
||||||
|
stage = "early"
|
||||||
|
elif recent_growth > 0 and abs(dist52) >= 0.05:
|
||||||
|
stage = "mid"
|
||||||
|
else:
|
||||||
|
stage = "late"
|
||||||
|
|
||||||
|
return direction, stage, strength
|
||||||
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import talib.abstract as ta
|
||||||
|
from . import state
|
||||||
|
|
||||||
|
def add_indicators(df):
|
||||||
|
macd = ta.MACD(df, fastperiod=state.macd_fast_period, slowperiod=state.macd_slow_period, signalperiod=state.macd_signal_period)
|
||||||
|
|
||||||
|
df['macd'] = macd['macd']
|
||||||
|
df['macdsignal'] = macd['macdsignal']
|
||||||
|
df['macdhist'] = macd['macdhist']
|
||||||
|
df['ma5'] = (ta.MA(df, timeperiod=5)).fillna(0)
|
||||||
|
df['ma10'] = (ta.MA(df, timeperiod=10)).fillna(0)
|
||||||
|
df['ma30'] = (ta.EMA(df, timeperiod=30)).fillna(0)
|
||||||
|
df['ma250'] = (ta.MA(df, timeperiod=250)).fillna(0)
|
||||||
|
# 新增 EMA 指标
|
||||||
|
df['ema5'] = (ta.EMA(df, timeperiod=5)).fillna(0)
|
||||||
|
df['ema10'] = (ta.EMA(df, timeperiod=10)).fillna(0)
|
||||||
|
df['ema24'] = (ta.EMA(df, timeperiod=24)).fillna(0)
|
||||||
|
df['ema52'] = (ta.EMA(df, timeperiod=52)).fillna(0)
|
||||||
|
df['ema26'] = (ta.EMA(df, timeperiod=26)).fillna(0)
|
||||||
|
df['ema13'] = (ta.EMA(df, timeperiod=13)).fillna(0)
|
||||||
|
df['ema7'] = (ta.EMA(df, timeperiod=7)).fillna(0)
|
||||||
|
df['ema104'] = (ta.EMA(df, timeperiod=104)).fillna(0)
|
||||||
|
df['ema156'] = (ta.EMA(df, timeperiod=156)).fillna(0)
|
||||||
|
df['ema208'] = (ta.EMA(df, timeperiod=208)).fillna(0)
|
||||||
|
# 常用SMA 24/52
|
||||||
|
try:
|
||||||
|
df['sma24'] = (ta.SMA(df, timeperiod=24)).fillna(0)
|
||||||
|
df['sma52'] = (ta.SMA(df, timeperiod=52)).fillna(0)
|
||||||
|
except Exception:
|
||||||
|
df['sma24'] = 0
|
||||||
|
df['sma52'] = 0
|
||||||
|
df['rsi'] = ta.RSI(df, timeperiod=14)
|
||||||
|
|
||||||
|
# 计算布林带 (当前周期 - 20周期,2标准差)
|
||||||
|
bb = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0)
|
||||||
|
df['bb_upper'] = bb['upperband'].fillna(0)
|
||||||
|
df['bb_middle'] = bb['middleband'].fillna(0)
|
||||||
|
df['bb_lower'] = bb['lowerband'].fillna(0)
|
||||||
|
bb30 = ta.BBANDS(df, timeperiod=41, nbdevup=2.3, nbdevdn=2.3, matype=0)
|
||||||
|
#bb30 = ta.BBANDS(df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, matype=0)
|
||||||
|
df['bbup30'] = bb30['upperband'].fillna(0)
|
||||||
|
df['bblow30'] = bb30['lowerband'].fillna(0)
|
||||||
|
bb302 = ta.BBANDS(df, timeperiod=41, nbdevup=2.0, nbdevdn=2.0, matype=0)
|
||||||
|
#bb302 = ta.BBANDS(df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, matype=0)
|
||||||
|
df['bbup302'] = bb302['upperband'].fillna(0)
|
||||||
|
df['bblow302'] = bb302['lowerband'].fillna(0)
|
||||||
|
# 计算次周期布林带 (14周期,2标准差)
|
||||||
|
bb_element = ta.BBANDS(df, timeperiod=14, nbdevup=2.0, nbdevdn=2.0, matype=0)
|
||||||
|
df['element_bb_upper'] = bb_element['upperband'].fillna(0)
|
||||||
|
df['element_bb_middle'] = bb_element['middleband'].fillna(0)
|
||||||
|
df['element_bb_lower'] = bb_element['lowerband'].fillna(0)
|
||||||
|
|
||||||
|
df['macd'] = df['macd'].fillna(0)
|
||||||
|
df['macdsignal'] = df['macdsignal'].fillna(0)
|
||||||
|
df['macdhist'] = df['macdhist'].fillna(0)
|
||||||
|
df['ma5'] = df['ma5'].fillna(0)
|
||||||
|
df['ma10'] = df['ma10'].fillna(0)
|
||||||
|
df['ma30'] = df['ma30'].fillna(0)
|
||||||
|
df['ma250'] = df['ma250'].fillna(0)
|
||||||
|
df['ema5'] = df['ema5'].fillna(0)
|
||||||
|
df['ema10'] = df['ema10'].fillna(0)
|
||||||
|
df['ema24'] = df['ema24'].fillna(0)
|
||||||
|
df['ema52'] = df['ema52'].fillna(0)
|
||||||
|
df['sma24'] = df['sma24'].fillna(0)
|
||||||
|
df['sma52'] = df['sma52'].fillna(0)
|
||||||
|
df['rsi'] = df['rsi'].fillna(0)
|
||||||
|
df['avg_volume'] = df['volume'].rolling(10).mean()
|
||||||
|
# 计算量比,避免产生Infinity值
|
||||||
|
df['volume_ratio'] = df['volume'] / df['avg_volume']
|
||||||
|
# 填充缺失值(前N根K线)
|
||||||
|
df['volume_ratio'] = df['volume_ratio'].fillna(1.0)
|
||||||
|
df['avg_volume'] = df['avg_volume'].fillna(0)
|
||||||
|
|
||||||
|
# 处理Infinity和-Infinity值
|
||||||
|
df['volume_ratio'] = df['volume_ratio'].replace([float('inf'), float('-inf')], 1.0)
|
||||||
|
|
||||||
|
# 计算ATR (Average True Range) - 14周期
|
||||||
|
df['atr'] = ta.ATR(df, timeperiod=14)
|
||||||
|
df['atr'] = df['atr'].fillna(0)
|
||||||
|
bb2633 = ta.BBANDS(df, timeperiod=26, nbdevup=3.0, nbdevdn=3.0, matype=0)
|
||||||
|
bbp2633 = (df['close'] - bb2633['lowerband']) / (bb2633['upperband'] - bb2633['lowerband'])
|
||||||
|
df['bb2633upper'] = bb2633['upperband'].fillna(0)
|
||||||
|
df['bb2633lower'] = bb2633['lowerband'].fillna(0)
|
||||||
|
df['bbp2633'] = bbp2633.fillna(0)
|
||||||
|
df['bb2633middle'] = bb2633['middleband'].fillna(0)
|
||||||
|
return df
|
||||||
|
|
||||||
|
def calculate_macd(df):
|
||||||
|
"""计算MACD指标"""
|
||||||
|
exp1 = df['close'].ewm(span=state.macd_fast_period, adjust=False).mean()
|
||||||
|
exp2 = df['close'].ewm(span=state.macd_slow_period, adjust=False).mean()
|
||||||
|
macd = exp1 - exp2
|
||||||
|
signal = macd.ewm(span=state.macd_signal_period, adjust=False).mean()
|
||||||
|
histogram = macd - signal
|
||||||
|
|
||||||
|
return {
|
||||||
|
'macd': macd.tolist(),
|
||||||
|
'signal': signal.tolist(),
|
||||||
|
'histogram': histogram.tolist()
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from config import DATA_SERVICE_URL
|
||||||
|
from . import state
|
||||||
|
from .state import DEFAULT_SYMBOLS, DEFAULT_TIMEFRAME_LABELS
|
||||||
|
from .timeframes import build_timeframe_labels
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def _parse_time_input(value):
|
||||||
|
if value in (None, '', 0):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(float(value))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_data_service_metadata(force=False):
|
||||||
|
"""刷新数据服务提供的交易对与周期元信息。"""
|
||||||
|
now = time.time()
|
||||||
|
if not force and state.DATA_SERVICE_AVAILABLE and now - state.SERVICE_METADATA_LAST_REFRESH < 60:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{DATA_SERVICE_URL}/health", timeout=5)
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
service_symbols = payload.get("symbols") or payload.get("symbol_list") or []
|
||||||
|
base_timeframes = payload.get("timeframes") or payload.get("base_timeframes") or []
|
||||||
|
derived = payload.get("derived_timeframes") or []
|
||||||
|
service_timeframes = list(base_timeframes)
|
||||||
|
for tf in derived:
|
||||||
|
if tf not in service_timeframes:
|
||||||
|
service_timeframes.append(tf)
|
||||||
|
if service_symbols:
|
||||||
|
state.SYMBOLS[:] = service_symbols
|
||||||
|
if service_timeframes:
|
||||||
|
state.TIMEFRAMES.clear()
|
||||||
|
state.TIMEFRAMES.update(build_timeframe_labels(service_timeframes))
|
||||||
|
state.DATA_SERVICE_AVAILABLE = True
|
||||||
|
state.SERVICE_METADATA_LAST_REFRESH = now
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("无法加载数据服务元信息: %s", exc)
|
||||||
|
if not state.DATA_SERVICE_AVAILABLE:
|
||||||
|
state.TIMEFRAMES.clear()
|
||||||
|
state.TIMEFRAMES.update(DEFAULT_TIMEFRAME_LABELS)
|
||||||
|
state.SYMBOLS[:] = DEFAULT_SYMBOLS
|
||||||
|
state.DATA_SERVICE_AVAILABLE = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_kl_from_datasvc(symbol, timeframe, start_ms=None, end_ms=None, limit=None):
|
||||||
|
params = {"symbol": symbol, "tf": timeframe}
|
||||||
|
if start_ms is not None:
|
||||||
|
params["start"] = int(start_ms)
|
||||||
|
if end_ms is not None:
|
||||||
|
params["end"] = int(end_ms)
|
||||||
|
if limit is not None:
|
||||||
|
params["limit"] = limit
|
||||||
|
resp = requests.get(f"{DATA_SERVICE_URL}/api/candles", params=params, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
df = pd.DataFrame(data)
|
||||||
|
if df.empty or "timestamp" not in df.columns:
|
||||||
|
return None
|
||||||
|
numeric_cols = ["open", "high", "low", "close", "volume"]
|
||||||
|
df["timestamp"] = pd.to_numeric(df["timestamp"], errors="coerce")
|
||||||
|
df = df.dropna(subset=["timestamp"])
|
||||||
|
df["timestamp"] = df["timestamp"].astype("int64")
|
||||||
|
for col in numeric_cols:
|
||||||
|
if col in df.columns:
|
||||||
|
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||||
|
df = df.dropna(subset=numeric_cols)
|
||||||
|
df = df.sort_values("timestamp")
|
||||||
|
if limit and len(df) > limit:
|
||||||
|
df = df.tail(limit)
|
||||||
|
df = df.reset_index(drop=True)
|
||||||
|
df["date"] = pd.to_datetime(df["timestamp"], unit='ms', utc=True).dt.tz_convert('Asia/Shanghai')
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
# 模块加载时尝试预取一次元信息,但失败不阻塞后续流程
|
||||||
|
refresh_data_service_metadata(force=True)
|
||||||
|
|
||||||
|
# A股热门股票
|
||||||
|
# 模板中 A 股下拉仅放默认一项;用户切换到「A股」时由前端请求 /api/a_stocks 填充全市场(约 5500+)
|
||||||
|
A_STOCK_SYMBOLS = [{'symbol': '000001', 'name': '平安银行'}]
|
||||||
|
|
||||||
|
def detect_symbol_type(symbol):
|
||||||
|
"""检测交易对类型:crypto 或 a_stock"""
|
||||||
|
if '/' in symbol and 'USDT' in symbol:
|
||||||
|
return 'crypto'
|
||||||
|
elif len(symbol) == 6 and symbol.isdigit():
|
||||||
|
return 'a_stock'
|
||||||
|
else:
|
||||||
|
return 'unknown'
|
||||||
|
|
||||||
|
def get_kl_data(symbol, timeframe, limit=100000, start_time=None, end_time=None):
|
||||||
|
"""获取K线数据,支持加密货币和A股"""
|
||||||
|
symbol_type = detect_symbol_type(symbol)
|
||||||
|
|
||||||
|
if symbol_type == 'crypto':
|
||||||
|
return get_crypto_kl_data(symbol, timeframe, limit, start_time, end_time)
|
||||||
|
elif symbol_type == 'a_stock':
|
||||||
|
return get_a_stock_kl_data(symbol, timeframe, limit, start_time, end_time)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_crypto_kl_data_via_ccxt(symbol, timeframe, limit=100000, start_time=None, end_time=None):
|
||||||
|
"""获取加密货币K线数据,支持分页加载确保获取指定时间范围内的所有数据"""
|
||||||
|
try:
|
||||||
|
# 初始化参数
|
||||||
|
since = None
|
||||||
|
if start_time:
|
||||||
|
try:
|
||||||
|
since = int(start_time)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 结束时间处理
|
||||||
|
until = None
|
||||||
|
if end_time:
|
||||||
|
try:
|
||||||
|
until = int(end_time)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 根据时间周期调整每次请求的数据量
|
||||||
|
batch_size = 1000 # 默认批次大小
|
||||||
|
if timeframe in ['1m', '3m', '5m']:
|
||||||
|
batch_size = 1000 # 分钟级数据减少批次大小
|
||||||
|
elif timeframe in ['15m', '30m', '1h']:
|
||||||
|
batch_size = 1000
|
||||||
|
else:
|
||||||
|
batch_size = 1500 # 日线及以上可以获取更多
|
||||||
|
batch_size = 1500 # 默认批次大小
|
||||||
|
# 初始化存储所有K线数据的列表
|
||||||
|
all_ohlcv = []
|
||||||
|
|
||||||
|
# 初始化当前查询的开始时间
|
||||||
|
current_since = since
|
||||||
|
|
||||||
|
# 添加请求计数和最大限制
|
||||||
|
request_count = 0
|
||||||
|
max_requests = 300 # 最大请求次数,防止无限循环
|
||||||
|
|
||||||
|
# 分页加载数据
|
||||||
|
while request_count < max_requests:
|
||||||
|
request_count += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 获取当前页的数据
|
||||||
|
ohlcv = state.exchange.fetch_ohlcv(symbol, timeframe, since=current_since, limit=batch_size)
|
||||||
|
|
||||||
|
# 如果没有获取到数据,结束循环
|
||||||
|
if not ohlcv or len(ohlcv) == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 将获取到的数据添加到总列表中
|
||||||
|
all_ohlcv.extend(ohlcv)
|
||||||
|
|
||||||
|
# 获取最后一条数据的时间戳
|
||||||
|
last_timestamp = ohlcv[-1][0]
|
||||||
|
|
||||||
|
# 如果已达到结束时间,结束循环
|
||||||
|
if until and last_timestamp >= until:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 如果获取的数据条数小于限制数,说明已经获取完所有数据
|
||||||
|
if len(ohlcv) < batch_size:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 更新下一页的开始时间(加1毫秒避免重复)
|
||||||
|
current_since = last_timestamp + 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# 如果单个批次失败,继续尝试下一个批次
|
||||||
|
if current_since:
|
||||||
|
# 尝试增加时间跳过可能的问题时间点
|
||||||
|
current_since += 60000 # 跳过1分钟
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 防止API请求过于频繁
|
||||||
|
time.sleep(0.3) # 减少到0.3秒提高效率
|
||||||
|
|
||||||
|
# 数据为空的情况
|
||||||
|
if not all_ohlcv or len(all_ohlcv) == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 转换为DataFrame
|
||||||
|
df = pd.DataFrame(all_ohlcv, columns=['timestamp', 'open', 'high', 'low', 'close', 'volume'])
|
||||||
|
df['date'] = pd.to_datetime(df['timestamp'], unit='ms').dt.tz_localize('UTC').dt.tz_convert('Asia/Shanghai')
|
||||||
|
|
||||||
|
# 在客户端进行结束时间过滤
|
||||||
|
if until:
|
||||||
|
df = df[df['timestamp'] <= until]
|
||||||
|
|
||||||
|
# 去除重复数据
|
||||||
|
df = df.drop_duplicates(subset=['timestamp'])
|
||||||
|
|
||||||
|
# 按时间排序
|
||||||
|
df = df.sort_values('timestamp')
|
||||||
|
|
||||||
|
# 限制数据条数的逻辑 - 优先考虑时间范围
|
||||||
|
if start_time and end_time:
|
||||||
|
# 如果指定了明确的时间范围,返回该时间范围内的所有数据
|
||||||
|
if len(df) > 100000: # 防止数据量过大,设置一个合理的上限
|
||||||
|
df = df.tail(100000).reset_index(drop=True)
|
||||||
|
elif limit and len(df) > limit:
|
||||||
|
# 如果没有指定明确时间范围,使用默认的limit限制
|
||||||
|
df = df.tail(limit).reset_index(drop=True)
|
||||||
|
|
||||||
|
# 如果过滤后没有数据,返回None
|
||||||
|
if len(df) == 0:
|
||||||
|
return None
|
||||||
|
return df
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_crypto_kl_data(symbol, timeframe, limit=100000, start_time=None, end_time=None):
|
||||||
|
"""优先通过本地数据服务获取加密货币K线,失败时回退至交易所API。"""
|
||||||
|
start_ms = _parse_time_input(start_time)
|
||||||
|
end_ms = _parse_time_input(end_time)
|
||||||
|
|
||||||
|
refresh_data_service_metadata()
|
||||||
|
if state.DATA_SERVICE_AVAILABLE:
|
||||||
|
try:
|
||||||
|
df = _fetch_kl_from_datasvc(
|
||||||
|
symbol=symbol,
|
||||||
|
timeframe=timeframe,
|
||||||
|
start_ms=start_ms,
|
||||||
|
end_ms=end_ms,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
if df is not None and not df.empty:
|
||||||
|
return df
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("数据服务请求失败,准备回退至交易所 API:%s", exc)
|
||||||
|
|
||||||
|
return _get_crypto_kl_data_via_ccxt(symbol, timeframe, limit, start_time, end_time)
|
||||||
|
|
||||||
|
|
||||||
|
def get_a_stock_kl_data(symbol, timeframe, limit=100000, start_time=None, end_time=None):
|
||||||
|
"""获取A股K线数据"""
|
||||||
|
try:
|
||||||
|
# 处理时间戳参数转换为日期字符串
|
||||||
|
start_date = None
|
||||||
|
end_date = None
|
||||||
|
|
||||||
|
if start_time:
|
||||||
|
try:
|
||||||
|
# 尝试解析时间戳(毫秒)
|
||||||
|
start_timestamp = int(start_time)
|
||||||
|
start_date = datetime.fromtimestamp(start_timestamp / 1000).strftime('%Y-%m-%d')
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
# 如果不是时间戳,尝试解析datetime-local格式 (YYYY-MM-DDTHH:MM)
|
||||||
|
try:
|
||||||
|
if 'T' in str(start_time):
|
||||||
|
# datetime-local格式:2025-05-19T06:07
|
||||||
|
start_date = str(start_time).split('T')[0] # 只取日期部分
|
||||||
|
else:
|
||||||
|
start_date = str(start_time)
|
||||||
|
except:
|
||||||
|
start_date = start_time
|
||||||
|
|
||||||
|
if end_time:
|
||||||
|
try:
|
||||||
|
# 尝试解析时间戳(毫秒)
|
||||||
|
end_timestamp = int(end_time)
|
||||||
|
end_date = datetime.fromtimestamp(end_timestamp / 1000).strftime('%Y-%m-%d')
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
# 如果不是时间戳,尝试解析datetime-local格式
|
||||||
|
try:
|
||||||
|
if 'T' in str(end_time):
|
||||||
|
# datetime-local格式:2025-05-26T06:07
|
||||||
|
end_date = str(end_time).split('T')[0] # 只取日期部分
|
||||||
|
else:
|
||||||
|
end_date = str(end_time)
|
||||||
|
except:
|
||||||
|
end_date = end_time
|
||||||
|
|
||||||
|
# 如果用户指定了时间范围,优先获取该范围内的所有数据
|
||||||
|
actual_limit = limit
|
||||||
|
if start_date and end_date:
|
||||||
|
actual_limit = None # 不限制数据条数,获取完整时间范围数据
|
||||||
|
|
||||||
|
# 调用A股数据获取器
|
||||||
|
df = state.china_stock.get_kl_data(symbol, timeframe, start_date, end_date, actual_limit)
|
||||||
|
|
||||||
|
if df is None:
|
||||||
|
return None
|
||||||
|
return df
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def load_crypto_symbols(limit=200):
|
||||||
|
"""加载常见USDT永续合约交易对,返回列表"""
|
||||||
|
refresh_data_service_metadata()
|
||||||
|
if state.SYMBOLS:
|
||||||
|
return state.SYMBOLS[:limit]
|
||||||
|
try:
|
||||||
|
markets = state.exchange.load_markets()
|
||||||
|
symbols = [s for s in markets.keys() if '/USDT' in s and ':USDT' in s]
|
||||||
|
return symbols[:limit]
|
||||||
|
except Exception:
|
||||||
|
return DEFAULT_SYMBOLS[:limit]
|
||||||
|
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from chanlun.core.ChanEnum import Chan_BI_DIR, Chan_SEG_DIR, Chan_MACDSEG_DIR, Chan_MACDHISTSET_DIR
|
||||||
|
|
||||||
|
# 辅助函数,转换缠论方向枚举为整数
|
||||||
|
def convert_direction(direction):
|
||||||
|
"""转换方向枚举为数字"""
|
||||||
|
if direction == Chan_BI_DIR.UP or direction == Chan_SEG_DIR.UP:
|
||||||
|
return 1
|
||||||
|
elif direction == Chan_BI_DIR.DOWN or direction == Chan_SEG_DIR.DOWN:
|
||||||
|
return -1
|
||||||
|
else:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def format_time_safely(time_obj, client_tz):
|
||||||
|
"""安全地格式化时间对象,处理字符串和datetime两种情况"""
|
||||||
|
if time_obj is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(time_obj, str):
|
||||||
|
# 尝试将字符串解析为datetime
|
||||||
|
try:
|
||||||
|
from dateutil import parser
|
||||||
|
time_obj = parser.parse(time_obj)
|
||||||
|
return time_obj.astimezone(client_tz).isoformat()
|
||||||
|
except:
|
||||||
|
return time_obj
|
||||||
|
else:
|
||||||
|
# 已经是datetime对象
|
||||||
|
return time_obj.astimezone(client_tz).isoformat()
|
||||||
|
|
||||||
|
def serialize_chan_macd_data(chan_macd_data, client_tz):
|
||||||
|
"""序列化ChanMACD数据为JSON可序列化格式"""
|
||||||
|
serialized_data = {
|
||||||
|
'seg_list': [],
|
||||||
|
'unittf_list': [],
|
||||||
|
'histset_list': [],
|
||||||
|
# 状态标记数据
|
||||||
|
'high_position_list': [],
|
||||||
|
'high_empty_list': [],
|
||||||
|
'low_position_list': [],
|
||||||
|
'low_empty_list': [],
|
||||||
|
'return_zero_list': [],
|
||||||
|
'cross0_up_list': [],
|
||||||
|
'cross0_down_list': [],
|
||||||
|
# 新增:输出KLU的继续背驰/分离背驰标志
|
||||||
|
'klu_list': []
|
||||||
|
}
|
||||||
|
|
||||||
|
# 序列化seg_list
|
||||||
|
for seg in chan_macd_data.get('seg_list', []):
|
||||||
|
try:
|
||||||
|
seg_data = {
|
||||||
|
'start_time': format_time_safely(seg.start_time, client_tz),
|
||||||
|
'end_time': format_time_safely(seg.end_time, client_tz) if seg.end_time else None,
|
||||||
|
'seg_dir': 'ABOVE' if seg.seg_dir == Chan_MACDSEG_DIR.ABOVE else 'UNDER',
|
||||||
|
'klu_count': len(seg.klu_list) if hasattr(seg, 'klu_list') else 0,
|
||||||
|
'unittf_count': len(seg.unittf_list) if hasattr(seg, 'unittf_list') else 0,
|
||||||
|
'histset_count': len(seg.hist_set) if hasattr(seg, 'hist_set') else 0
|
||||||
|
}
|
||||||
|
serialized_data['seg_list'].append(seg_data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"序列化seg出错: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 序列化unittf_list(兼容新结构与枚举类型)
|
||||||
|
for unittf in chan_macd_data.get('unittf_list', []):
|
||||||
|
try:
|
||||||
|
dir_value = getattr(unittf, 'uinttf_dir', None)
|
||||||
|
dir_name = getattr(dir_value, 'name', dir_value if isinstance(dir_value, str) else None)
|
||||||
|
start_t = getattr(unittf, 'start_type', None)
|
||||||
|
start_type = getattr(start_t, 'name', start_t)
|
||||||
|
end_t = getattr(unittf, 'end_type', None)
|
||||||
|
end_type = getattr(end_t, 'name', end_t)
|
||||||
|
peak_abs = getattr(unittf, 'peak_abs', None)
|
||||||
|
if peak_abs is None:
|
||||||
|
peak_abs = getattr(unittf, 'peak_hist', None)
|
||||||
|
length = getattr(unittf, 'length', None)
|
||||||
|
if length is None:
|
||||||
|
length = len(unittf.klu_list) if hasattr(unittf, 'klu_list') else None
|
||||||
|
|
||||||
|
unittf_data = {
|
||||||
|
'start_time': format_time_safely(getattr(unittf, 'start_time', None), client_tz),
|
||||||
|
'end_time': format_time_safely(getattr(unittf, 'end_time', None), client_tz) if getattr(unittf, 'end_time', None) else None,
|
||||||
|
'dir': dir_name, # 'ABOVE' | 'UNDER' | None
|
||||||
|
'start_type': start_type, # e.g. 'START' | 'CROSS0' | 'NEAR0_UP' | 'NEAR0_DOWN'
|
||||||
|
'end_type': end_type,
|
||||||
|
'invalid': getattr(unittf, 'invalid', False),
|
||||||
|
'peak_abs': peak_abs,
|
||||||
|
'length': length,
|
||||||
|
'klu_count': len(unittf.klu_list) if hasattr(unittf, 'klu_list') else 0,
|
||||||
|
'histset_count': len(unittf.histset_list) if hasattr(unittf, 'histset_list') else 0
|
||||||
|
}
|
||||||
|
serialized_data['unittf_list'].append(unittf_data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"序列化unittf出错: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 序列化histset_list
|
||||||
|
for histset in chan_macd_data.get('histset_list', []):
|
||||||
|
try:
|
||||||
|
histset_data = {
|
||||||
|
'start_time': format_time_safely(getattr(histset, 'start_time', None), client_tz),
|
||||||
|
'end_time': format_time_safely(getattr(histset, 'end_time', None), client_tz),
|
||||||
|
'histset_dir': 'ABOVE' if histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE else 'UNDER',
|
||||||
|
'klu_count': len(histset.klu_list) if hasattr(histset, 'klu_list') else 0
|
||||||
|
}
|
||||||
|
serialized_data['histset_list'].append(histset_data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"序列化histset出错: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 序列化状态标记数据
|
||||||
|
# 序列化高位列表
|
||||||
|
for high_pos in chan_macd_data.get('high_position_list', []):
|
||||||
|
try:
|
||||||
|
high_pos_data = {
|
||||||
|
'time': format_time_safely(high_pos['time'], client_tz),
|
||||||
|
'end_time': format_time_safely(high_pos.get('end_time'), client_tz) if high_pos.get('end_time') else None,
|
||||||
|
'type': high_pos.get('type', 'start'),
|
||||||
|
'macd': high_pos.get('macd'),
|
||||||
|
'signal': high_pos.get('signal'),
|
||||||
|
'macdhist': high_pos.get('macdhist'),
|
||||||
|
'end_macd': high_pos.get('end_macd'),
|
||||||
|
'end_signal': high_pos.get('end_signal'),
|
||||||
|
'end_macdhist': high_pos.get('end_macdhist')
|
||||||
|
}
|
||||||
|
serialized_data['high_position_list'].append(high_pos_data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"序列化high_position出错: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 序列化高位空列表
|
||||||
|
for high_empty in chan_macd_data.get('high_empty_list', []):
|
||||||
|
try:
|
||||||
|
high_empty_data = {
|
||||||
|
'time': format_time_safely(high_empty['time'], client_tz),
|
||||||
|
'end_time': format_time_safely(high_empty.get('end_time'), client_tz) if high_empty.get('end_time') else None,
|
||||||
|
'type': high_empty.get('type', 'start'),
|
||||||
|
'macd': high_empty.get('macd'),
|
||||||
|
'signal': high_empty.get('signal'),
|
||||||
|
'macdhist': high_empty.get('macdhist'),
|
||||||
|
'end_macd': high_empty.get('end_macd'),
|
||||||
|
'end_signal': high_empty.get('end_signal'),
|
||||||
|
'end_macdhist': high_empty.get('end_macdhist')
|
||||||
|
}
|
||||||
|
serialized_data['high_empty_list'].append(high_empty_data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"序列化high_empty出错: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 序列化低位与低位空
|
||||||
|
for low_pos in chan_macd_data.get('low_position_list', []):
|
||||||
|
try:
|
||||||
|
low_pos_data = {
|
||||||
|
'time': format_time_safely(low_pos['time'], client_tz),
|
||||||
|
'end_time': format_time_safely(low_pos.get('end_time'), client_tz) if low_pos.get('end_time') else None,
|
||||||
|
'type': low_pos.get('type', 'start'),
|
||||||
|
'macd': low_pos.get('macd'),
|
||||||
|
'signal': low_pos.get('signal'),
|
||||||
|
'macdhist': low_pos.get('macdhist'),
|
||||||
|
'end_macd': low_pos.get('end_macd'),
|
||||||
|
'end_signal': low_pos.get('end_signal'),
|
||||||
|
'end_macdhist': low_pos.get('end_macdhist')
|
||||||
|
}
|
||||||
|
serialized_data['low_position_list'].append(low_pos_data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"序列化low_position出错: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
for low_empty in chan_macd_data.get('low_empty_list', []):
|
||||||
|
try:
|
||||||
|
low_empty_data = {
|
||||||
|
'time': format_time_safely(low_empty['time'], client_tz),
|
||||||
|
'end_time': format_time_safely(low_empty.get('end_time'), client_tz) if low_empty.get('end_time') else None,
|
||||||
|
'type': low_empty.get('type', 'start'),
|
||||||
|
'macd': low_empty.get('macd'),
|
||||||
|
'signal': low_empty.get('signal'),
|
||||||
|
'macdhist': low_empty.get('macdhist'),
|
||||||
|
'end_macd': low_empty.get('end_macd'),
|
||||||
|
'end_signal': low_empty.get('end_signal'),
|
||||||
|
'end_macdhist': low_empty.get('end_macdhist')
|
||||||
|
}
|
||||||
|
serialized_data['low_empty_list'].append(low_empty_data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"序列化low_empty出错: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 序列化归零轴列表
|
||||||
|
for return_zero in chan_macd_data.get('return_zero_list', []):
|
||||||
|
try:
|
||||||
|
return_zero_data = {
|
||||||
|
'time': format_time_safely(return_zero['time'], client_tz),
|
||||||
|
'end_time': format_time_safely(return_zero.get('end_time'), client_tz) if return_zero.get('end_time') else None,
|
||||||
|
'type': return_zero.get('type', 'start'),
|
||||||
|
'macd': return_zero.get('macd'),
|
||||||
|
'signal': return_zero.get('signal'),
|
||||||
|
'macdhist': return_zero.get('macdhist'),
|
||||||
|
'end_macd': return_zero.get('end_macd'),
|
||||||
|
'end_signal': return_zero.get('end_signal'),
|
||||||
|
'end_macdhist': return_zero.get('end_macdhist')
|
||||||
|
}
|
||||||
|
serialized_data['return_zero_list'].append(return_zero_data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"序列化return_zero出错: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 序列化穿越零轴列表
|
||||||
|
for cross0_up in chan_macd_data.get('cross0_up_list', []):
|
||||||
|
try:
|
||||||
|
cross0_up_data = {
|
||||||
|
'time': format_time_safely(cross0_up['time'], client_tz),
|
||||||
|
'type': cross0_up.get('type', 'start'),
|
||||||
|
'macd': cross0_up.get('macd'),
|
||||||
|
'signal': cross0_up.get('signal'),
|
||||||
|
'macdhist': cross0_up.get('macdhist')
|
||||||
|
}
|
||||||
|
serialized_data['cross0_up_list'].append(cross0_up_data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"序列化cross0_up出错: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
for cross0_down in chan_macd_data.get('cross0_down_list', []):
|
||||||
|
try:
|
||||||
|
cross0_down_data = {
|
||||||
|
'time': format_time_safely(cross0_down['time'], client_tz),
|
||||||
|
'type': cross0_down.get('type', 'start'),
|
||||||
|
'macd': cross0_down.get('macd'),
|
||||||
|
'signal': cross0_down.get('signal'),
|
||||||
|
'macdhist': cross0_down.get('macdhist')
|
||||||
|
}
|
||||||
|
serialized_data['cross0_down_list'].append(cross0_down_data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"序列化cross0_down出错: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 序列化 KLU 列表(仅导出需要的时间与背驰标志)
|
||||||
|
for klu in chan_macd_data.get('klu_list', []):
|
||||||
|
try:
|
||||||
|
serialized_data['klu_list'].append({
|
||||||
|
'time': format_time_safely(getattr(klu, 'time', None), client_tz),
|
||||||
|
'continue_div': bool(getattr(klu, 'continue_div', False)),
|
||||||
|
'separate_div': int(getattr(klu, 'separate_div', 0)) if getattr(klu, 'separate_div', 0) is not None else 0,
|
||||||
|
'near0_return': int(getattr(klu, 'near0_return', 0)) if getattr(klu, 'near0_return', 0) is not None else 0
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"序列化klu出错: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return serialized_data
|
||||||
|
|
||||||
|
def clean_dataframe_for_json(df):
|
||||||
|
"""清理DataFrame数据用于JSON序列化"""
|
||||||
|
# 创建副本避免修改原始数据
|
||||||
|
clean_df = df.copy()
|
||||||
|
|
||||||
|
# 替换NaN值为None
|
||||||
|
clean_df = clean_df.where(pd.notnull(clean_df), None)
|
||||||
|
|
||||||
|
return clean_df
|
||||||
|
|
||||||
|
def get_uncompleted_seg_list(seg_list, client_tz):
|
||||||
|
"""获取未完成线段列表,正确处理倒数第二个和最后一个未完成线段"""
|
||||||
|
uncompleted_segs = [seg for seg in seg_list if not seg.is_sure]
|
||||||
|
|
||||||
|
if len(uncompleted_segs) == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for i, seg in enumerate(uncompleted_segs):
|
||||||
|
is_last = (i == len(uncompleted_segs) - 1) # 是否为最后一个未完成线段
|
||||||
|
|
||||||
|
seg_data = {
|
||||||
|
'start_time': seg.start_bi.start_klc.end_time if isinstance(seg.start_bi.start_klc.end_time, str) else seg.start_bi.start_klc.end_time.astimezone(client_tz).isoformat(),
|
||||||
|
'sure_time': format_time_safely(seg.sure_time, client_tz) if seg.sure_time else None,
|
||||||
|
'start_price': seg.start_bi.start_klc.low if convert_direction(seg.dir) == 1 else seg.start_bi.start_klc.high,
|
||||||
|
'direction': convert_direction(seg.dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_last:
|
||||||
|
# 最后一个未完成线段:没有结束时间和价格
|
||||||
|
seg_data['end_time'] = None
|
||||||
|
seg_data['end_price'] = None
|
||||||
|
else:
|
||||||
|
# 倒数第二个及之前的未完成线段:使用实际的结束时间和价格
|
||||||
|
if seg.end_bi and seg.end_bi.end_klc:
|
||||||
|
seg_data['end_time'] = seg.end_bi.end_klc.end_time if isinstance(seg.end_bi.end_klc.end_time, str) else seg.end_bi.end_klc.end_time.astimezone(client_tz).isoformat()
|
||||||
|
seg_data['end_price'] = seg.end_bi.end_klc.high if convert_direction(seg.dir) == 1 else seg.end_bi.end_klc.low
|
||||||
|
else:
|
||||||
|
# 如果没有结束笔,设为None
|
||||||
|
seg_data['end_time'] = None
|
||||||
|
seg_data['end_price'] = None
|
||||||
|
|
||||||
|
result.append(seg_data)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from collections import OrderedDict
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import ccxt
|
||||||
|
|
||||||
|
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
if _ROOT not in sys.path:
|
||||||
|
sys.path.append(_ROOT)
|
||||||
|
|
||||||
|
from config import MACD_FAST, MACD_SLOW, MACD_SIGNAL, ccxt_proxies
|
||||||
|
from services.cn_stock import ChinaStockData
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class TRADE_POINT_TYPE:
|
||||||
|
BUY1 = 1 # 一类买点
|
||||||
|
BUY2 = 2 # 二类买点
|
||||||
|
BUY3 = 3 # 三类买点
|
||||||
|
SELL1 = -1 # 一类卖点
|
||||||
|
SELL2 = -2 # 二类卖点
|
||||||
|
SELL3 = -3 # 三类卖点
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# mutable runtime state
|
||||||
|
macd_fast_period = MACD_FAST
|
||||||
|
macd_slow_period = MACD_SLOW
|
||||||
|
macd_signal_period = MACD_SIGNAL
|
||||||
|
|
||||||
|
_proxies = ccxt_proxies()
|
||||||
|
_exchange_kwargs = {"enableRateLimit": True}
|
||||||
|
if _proxies:
|
||||||
|
_exchange_kwargs["proxies"] = _proxies
|
||||||
|
exchange = ccxt.binance(_exchange_kwargs)
|
||||||
|
|
||||||
|
china_stock = ChinaStockData()
|
||||||
|
_zone_cache = {}
|
||||||
|
|
||||||
|
DEFAULT_TIMEFRAME_LABELS = OrderedDict([
|
||||||
|
("1m", "1分钟"),
|
||||||
|
("3m", "3分钟"),
|
||||||
|
("5m", "5分钟"),
|
||||||
|
("15m", "15分钟"),
|
||||||
|
("30m", "30分钟"),
|
||||||
|
("1h", "1小时"),
|
||||||
|
("2h", "2小时"),
|
||||||
|
("4h", "4小时"),
|
||||||
|
("6h", "6小时"),
|
||||||
|
("8h", "8小时"),
|
||||||
|
("12h", "12小时"),
|
||||||
|
("1d", "日线"),
|
||||||
|
("3d", "3日线"),
|
||||||
|
("1w", "周线"),
|
||||||
|
("1M", "月线"),
|
||||||
|
])
|
||||||
|
|
||||||
|
DEFAULT_SYMBOLS = [
|
||||||
|
'SOL/USDT:USDT', 'BTC/USDT:USDT', 'ETH/USDT:USDT', 'BNB/USDT:USDT', 'XRP/USDT:USDT', 'WIF/USDT:USDT',
|
||||||
|
'ADA/USDT:USDT', 'DOGE/USDT:USDT', 'AVAX/USDT:USDT', 'DOT/USDT:USDT', 'MATIC/USDT:USDT'
|
||||||
|
]
|
||||||
|
|
||||||
|
TIMEFRAMES = DEFAULT_TIMEFRAME_LABELS.copy()
|
||||||
|
SYMBOLS = DEFAULT_SYMBOLS.copy()
|
||||||
|
DATA_SERVICE_AVAILABLE = False
|
||||||
|
SERVICE_METADATA_LAST_REFRESH = 0
|
||||||
|
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import OrderedDict
|
||||||
|
from .state import DEFAULT_TIMEFRAME_LABELS
|
||||||
|
|
||||||
|
def _zone_cache_ttl(tf_name: str) -> int:
|
||||||
|
"""根据时间周期返回缓存过期时间(秒)"""
|
||||||
|
minutes = timeframe_to_minutes(tf_name) or 5
|
||||||
|
if minutes <= 5:
|
||||||
|
return 120 # 5m及以下: 2分钟
|
||||||
|
elif minutes <= 15:
|
||||||
|
return 300 # 15m: 5分钟
|
||||||
|
elif minutes <= 60:
|
||||||
|
return 600 # 1h: 10分钟
|
||||||
|
else:
|
||||||
|
return 1800 # 4h+: 30分钟
|
||||||
|
|
||||||
|
|
||||||
|
def timeframe_to_minutes(tf: str):
|
||||||
|
"""将时间周期转换为分钟数,用于排序。"""
|
||||||
|
if not tf:
|
||||||
|
return None
|
||||||
|
unit = tf[-1]
|
||||||
|
try:
|
||||||
|
value = int(tf[:-1])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
multiplier = {
|
||||||
|
'm': 1,
|
||||||
|
'h': 60,
|
||||||
|
'd': 1440,
|
||||||
|
'w': 10080,
|
||||||
|
'M': 43200, # 30天近似
|
||||||
|
}.get(unit)
|
||||||
|
if multiplier is None:
|
||||||
|
return None
|
||||||
|
return value * multiplier
|
||||||
|
|
||||||
|
|
||||||
|
def format_timeframe_label(tf: str) -> str:
|
||||||
|
"""将时间周期转换为可读标签。"""
|
||||||
|
if not tf:
|
||||||
|
return tf
|
||||||
|
unit = tf[-1]
|
||||||
|
try:
|
||||||
|
value = int(tf[:-1])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return tf
|
||||||
|
if unit == 'm':
|
||||||
|
return f"{value}分钟"
|
||||||
|
if unit == 'h':
|
||||||
|
return f"{value}小时"
|
||||||
|
if unit == 'd':
|
||||||
|
return "日线" if value == 1 else f"{value}日线"
|
||||||
|
if unit == 'w':
|
||||||
|
return "周线" if value == 1 else f"{value}周线"
|
||||||
|
if unit == 'M':
|
||||||
|
return "月线" if value == 1 else f"{value}月线"
|
||||||
|
return tf
|
||||||
|
|
||||||
|
|
||||||
|
def build_timeframe_labels(timeframes):
|
||||||
|
ordered = sorted(
|
||||||
|
timeframes,
|
||||||
|
key=lambda tf: timeframe_to_minutes(tf) if timeframe_to_minutes(tf) is not None else float('inf'),
|
||||||
|
)
|
||||||
|
labels = OrderedDict()
|
||||||
|
for tf in ordered:
|
||||||
|
labels[tf] = format_timeframe_label(tf)
|
||||||
|
return labels
|
||||||
|
|
||||||
|
|
||||||
|
def compute_timeframe_defaults(labels_ordered):
|
||||||
|
"""
|
||||||
|
根据已排序的「周期 → 中文标签」映射,计算主 / 次 / 次次周期默认值。
|
||||||
|
labels_ordered: OrderedDict 或按插入顺序排列的 dict。
|
||||||
|
"""
|
||||||
|
if not labels_ordered:
|
||||||
|
labels_ordered = DEFAULT_TIMEFRAME_LABELS.copy()
|
||||||
|
timeframe_keys = list(labels_ordered.keys())
|
||||||
|
preferred_main = next((tf for tf in ['5m', '15m', '1h'] if tf in labels_ordered), None)
|
||||||
|
default_main = preferred_main or (timeframe_keys[0] if timeframe_keys else '1m')
|
||||||
|
if default_main not in labels_ordered and timeframe_keys:
|
||||||
|
default_main = timeframe_keys[0]
|
||||||
|
|
||||||
|
if timeframe_keys:
|
||||||
|
try:
|
||||||
|
idx = timeframe_keys.index(default_main)
|
||||||
|
default_element = timeframe_keys[idx - 1] if idx > 0 else timeframe_keys[0]
|
||||||
|
except ValueError:
|
||||||
|
default_element = timeframe_keys[0]
|
||||||
|
else:
|
||||||
|
default_element = default_main
|
||||||
|
|
||||||
|
if timeframe_keys:
|
||||||
|
try:
|
||||||
|
idx_el = timeframe_keys.index(default_element)
|
||||||
|
default_sub_sub = timeframe_keys[idx_el - 1] if idx_el > 0 else timeframe_keys[0]
|
||||||
|
except ValueError:
|
||||||
|
default_sub_sub = timeframe_keys[0]
|
||||||
|
else:
|
||||||
|
default_sub_sub = default_element
|
||||||
|
|
||||||
|
return default_main, default_element, default_sub_sub, timeframe_keys
|
||||||
|
|
||||||
|
def is_smaller_timeframe(tf1, tf2):
|
||||||
|
"""判断时间周期tf1是否小于tf2"""
|
||||||
|
tf1_value = timeframe_to_minutes(tf1)
|
||||||
|
tf2_value = timeframe_to_minutes(tf2)
|
||||||
|
if tf1_value is None or tf2_value is None:
|
||||||
|
return False
|
||||||
|
return tf1_value < tf2_value
|
||||||
|
|
||||||
|
def is_smaller_or_equal_timeframe(tf1, tf2):
|
||||||
|
"""判断时间周期tf1是否小于等于tf2"""
|
||||||
|
tf1_value = timeframe_to_minutes(tf1)
|
||||||
|
tf2_value = timeframe_to_minutes(tf2)
|
||||||
|
if tf1_value is None or tf2_value is None:
|
||||||
|
return False
|
||||||
|
return tf1_value <= tf2_value
|
||||||
|
|
||||||
@@ -981,19 +981,41 @@ function findBiCenters(biList) {
|
|||||||
var lows = biList_for_zs.map(function(bi) { return Math.min(bi.p0, bi.p1) })
|
var lows = biList_for_zs.map(function(bi) { return Math.min(bi.p0, bi.p1) })
|
||||||
gg = Math.max.apply(null, highs)
|
gg = Math.max.apply(null, highs)
|
||||||
dd = Math.min.apply(null, lows)
|
dd = Math.min.apply(null, lows)
|
||||||
endBiIdx = startIdx + addedAfterLeave.length
|
endBiIdx = startIdx + 2 + addedAfterLeave.length
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastBiInCenter = biList_for_zs[biList_for_zs.length - 1]
|
||||||
|
// 是否已离开中枢:之后出现完全在 ZG 之上或 ZD 之下的确认笔 → 中枢完成
|
||||||
|
var zsSure = false
|
||||||
|
var lastInListIdx = -1
|
||||||
|
for (var li = 0; li < biList.length; li++) {
|
||||||
|
if (biList[li] === lastBiInCenter || (biList[li].t0 === lastBiInCenter.t0 && biList[li].t1 === lastBiInCenter.t1)) {
|
||||||
|
lastInListIdx = li
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastInListIdx < 0) lastInListIdx = endBiIdx
|
||||||
|
for (var j = lastInListIdx + 1; j < biList.length; j++) {
|
||||||
|
var leaveBi = biList[j]
|
||||||
|
if (!leaveBi.sure) break
|
||||||
|
var lbh = Math.max(leaveBi.p0, leaveBi.p1)
|
||||||
|
var lbl = Math.min(leaveBi.p0, leaveBi.p1)
|
||||||
|
if (lbl > zg || lbh < zd) {
|
||||||
|
zsSure = true
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var zs = {
|
var zs = {
|
||||||
t0: bi1.t0,
|
t0: bi1.t0,
|
||||||
t1: biList_for_zs[biList_for_zs.length - 1].t1,
|
t1: lastBiInCenter.t1,
|
||||||
high: zg, low: zd,
|
high: zg, low: zd,
|
||||||
zg: zg, zd: zd,
|
zg: zg, zd: zd,
|
||||||
gg: gg, dd: dd,
|
gg: gg, dd: dd,
|
||||||
is_sure: biList_for_zs[biList_for_zs.length - 1].sure,
|
is_sure: zsSure,
|
||||||
bi_count: biList_for_zs.length,
|
bi_count: biList_for_zs.length,
|
||||||
bi_list: biList_for_zs, // 中枢内的笔列表(按序)
|
bi_list: biList_for_zs,
|
||||||
start_bi_idx: startIdx, // 中枢首笔在总列表中的索引
|
start_bi_idx: startIdx,
|
||||||
dir: zsDir,
|
dir: zsDir,
|
||||||
pre: lastZs,
|
pre: lastZs,
|
||||||
next: null,
|
next: null,
|
||||||
@@ -1008,28 +1030,6 @@ function findBiCenters(biList) {
|
|||||||
startIdx = startIdx + 4 + (addedAfterLeave.length > 0 ? addedAfterLeave.length : 0)
|
startIdx = startIdx + 4 + (addedAfterLeave.length > 0 ? addedAfterLeave.length : 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 末中枢确认
|
|
||||||
if (lastZs && !lastZs.is_sure) {
|
|
||||||
var lastBiInZs = lastZs.bi_count > 0 ? biList_for_zs[biList_for_zs.length - 1] : null
|
|
||||||
if (lastBiInZs) {
|
|
||||||
var hasLeave = false
|
|
||||||
var lastBiIdx = biList.indexOf(lastBiInZs)
|
|
||||||
if (lastBiIdx >= 0) {
|
|
||||||
for (var i = lastBiIdx + 1; i < biList.length; i++) {
|
|
||||||
var bi = biList[i]
|
|
||||||
if (bi.sure) {
|
|
||||||
var bh = Math.max(bi.p0, bi.p1), bl = Math.min(bi.p0, bi.p1)
|
|
||||||
var leave = (bl > lastZs.zg && bh > lastZs.zg) || (bh < lastZs.zd && bl < lastZs.zd)
|
|
||||||
if (leave) { hasLeave = true; break }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hasLeave && lastBiInZs.sure) {
|
|
||||||
lastZs.t1 = lastBiInZs.t1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return zsList
|
return zsList
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1119,16 +1119,37 @@ function findSegCenters(segs) {
|
|||||||
endSegIdx = startIdx + 2 + addedSegs.length
|
endSegIdx = startIdx + 2 + addedSegs.length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var lastSegInCenter = segList_for_zs[segList_for_zs.length - 1]
|
||||||
|
var zsSure = false
|
||||||
|
var lastSegListIdx = -1
|
||||||
|
for (var lsi = 0; lsi < segs.length; lsi++) {
|
||||||
|
if (segs[lsi] === lastSegInCenter || (segs[lsi].t0 === lastSegInCenter.t0 && segs[lsi].t1 === lastSegInCenter.t1)) {
|
||||||
|
lastSegListIdx = lsi
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastSegListIdx < 0) lastSegListIdx = endSegIdx
|
||||||
|
for (var sj = lastSegListIdx + 1; sj < segs.length; sj++) {
|
||||||
|
var leaveSeg = segs[sj]
|
||||||
|
if (!leaveSeg.sure) break
|
||||||
|
var lsh = Math.max(leaveSeg.p0, leaveSeg.p1)
|
||||||
|
var lsl = Math.min(leaveSeg.p0, leaveSeg.p1)
|
||||||
|
if (lsl > zg || lsh < zd) {
|
||||||
|
zsSure = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var zs = {
|
var zs = {
|
||||||
t0: s1.t0,
|
t0: s1.t0,
|
||||||
t1: segs[endSegIdx].t1,
|
t1: lastSegInCenter.t1,
|
||||||
high: zg, low: zd,
|
high: zg, low: zd,
|
||||||
zg: zg, zd: zd,
|
zg: zg, zd: zd,
|
||||||
gg: gg, dd: dd,
|
gg: gg, dd: dd,
|
||||||
is_sure: segs[endSegIdx].sure,
|
is_sure: zsSure,
|
||||||
seg_count: segList_for_zs.length,
|
seg_count: segList_for_zs.length,
|
||||||
seg_list: segList_for_zs, // 中枢内的段列表(按序)
|
seg_list: segList_for_zs,
|
||||||
start_seg_idx: startIdx, // 中枢首段在总列表中的索引
|
start_seg_idx: startIdx,
|
||||||
dir: zsDir,
|
dir: zsDir,
|
||||||
pre: lastZs,
|
pre: lastZs,
|
||||||
next: null,
|
next: null,
|
||||||
|
|||||||
@@ -121,6 +121,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 中枢填充:区间内每根 bar 写入 top/bottom
|
// 中枢填充:区间内每根 bar 写入 top/bottom
|
||||||
|
// 未完成中枢:右边界拉到最新 K(与主站 uncompleted_zs 一致)
|
||||||
function fillZs(t0, t1, high, low, topField, botField) {
|
function fillZs(t0, t1, high, low, topField, botField) {
|
||||||
var lo = lowerBound(sortedBarTimes, t0)
|
var lo = lowerBound(sortedBarTimes, t0)
|
||||||
var hi = upperBound(sortedBarTimes, t1)
|
var hi = upperBound(sortedBarTimes, t1)
|
||||||
@@ -131,14 +132,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var lastBarT = sortedBarTimes.length ? sortedBarTimes[sortedBarTimes.length - 1] : null
|
||||||
|
|
||||||
if (slice.zs) {
|
if (slice.zs) {
|
||||||
slice.zs.forEach(function (z) {
|
slice.zs.forEach(function (z) {
|
||||||
fillZs(z.t0, z.t1, z.high || z.zg, z.low || z.zd, 'zs_top', 'zs_bottom')
|
var t1 = z.t1
|
||||||
|
var sure = z.is_sure !== false && z.is_sure !== 0
|
||||||
|
if (!sure && lastBarT != null) t1 = Math.max(t1 || 0, lastBarT)
|
||||||
|
if (sure) {
|
||||||
|
fillZs(z.t0, t1, z.high || z.zg, z.low || z.zd, 'zs_top', 'zs_bottom')
|
||||||
|
} else {
|
||||||
|
fillZs(z.t0, t1, z.high || z.zg, z.low || z.zd, 'zs_pending_top', 'zs_pending_bottom')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (slice.segzs) {
|
if (slice.segzs) {
|
||||||
slice.segzs.forEach(function (z) {
|
slice.segzs.forEach(function (z) {
|
||||||
fillZs(z.t0, z.t1, z.high || z.zg, z.low || z.zd, 'segzs_top', 'segzs_bottom')
|
var t1 = z.t1
|
||||||
|
var sure = z.is_sure !== false && z.is_sure !== 0
|
||||||
|
if (!sure && lastBarT != null) t1 = Math.max(t1 || 0, lastBarT)
|
||||||
|
if (sure) {
|
||||||
|
fillZs(z.t0, t1, z.high || z.zg, z.low || z.zd, 'segzs_top', 'segzs_bottom')
|
||||||
|
} else {
|
||||||
|
fillZs(z.t0, t1, z.high || z.zg, z.low || z.zd, 'segzs_pending_top', 'segzs_pending_bottom')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +250,10 @@
|
|||||||
{ id: 'zs_bottom', type: 'line' },
|
{ id: 'zs_bottom', type: 'line' },
|
||||||
{ id: 'segzs_top', type: 'line' },
|
{ id: 'segzs_top', type: 'line' },
|
||||||
{ id: 'segzs_bottom', type: 'line' },
|
{ id: 'segzs_bottom', type: 'line' },
|
||||||
|
{ id: 'zs_pending_top', type: 'line' },
|
||||||
|
{ id: 'zs_pending_bottom', type: 'line' },
|
||||||
|
{ id: 'segzs_pending_top', type: 'line' },
|
||||||
|
{ id: 'segzs_pending_bottom', type: 'line' },
|
||||||
]
|
]
|
||||||
|
|
||||||
BSP_SUBTYPES.forEach(function (t) {
|
BSP_SUBTYPES.forEach(function (t) {
|
||||||
@@ -279,6 +300,22 @@
|
|||||||
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||||
transparency: 100, visible: false, color: '#ef6c00', display: 0,
|
transparency: 100, visible: false, color: '#ef6c00', display: 0,
|
||||||
}),
|
}),
|
||||||
|
zs_pending_top: mergeStyle('zs_pending_top', {
|
||||||
|
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||||
|
transparency: 100, visible: false, color: '#f1c40f', display: 0,
|
||||||
|
}),
|
||||||
|
zs_pending_bottom: mergeStyle('zs_pending_bottom', {
|
||||||
|
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||||
|
transparency: 100, visible: false, color: '#f1c40f', display: 0,
|
||||||
|
}),
|
||||||
|
segzs_pending_top: mergeStyle('segzs_pending_top', {
|
||||||
|
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||||
|
transparency: 100, visible: false, color: '#9b59b6', display: 0,
|
||||||
|
}),
|
||||||
|
segzs_pending_bottom: mergeStyle('segzs_pending_bottom', {
|
||||||
|
linestyle: 0, linewidth: 0, plottype: 0, trackPrice: false,
|
||||||
|
transparency: 100, visible: false, color: '#9b59b6', display: 0,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
// BSP 样式
|
// BSP 样式
|
||||||
@@ -311,6 +348,10 @@
|
|||||||
zs_bottom: { title: '中枢下沿', histogramBase: 0, isHidden: true },
|
zs_bottom: { title: '中枢下沿', histogramBase: 0, isHidden: true },
|
||||||
segzs_top: { title: '段中枢上沿', histogramBase: 0, isHidden: true },
|
segzs_top: { title: '段中枢上沿', histogramBase: 0, isHidden: true },
|
||||||
segzs_bottom: { title: '段中枢下沿', histogramBase: 0, isHidden: true },
|
segzs_bottom: { title: '段中枢下沿', histogramBase: 0, isHidden: true },
|
||||||
|
zs_pending_top: { title: '未完成中枢上沿', histogramBase: 0, isHidden: true },
|
||||||
|
zs_pending_bottom: { title: '未完成中枢下沿', histogramBase: 0, isHidden: true },
|
||||||
|
segzs_pending_top: { title: '未完成段中枢上沿', histogramBase: 0, isHidden: true },
|
||||||
|
segzs_pending_bottom: { title: '未完成段中枢下沿', histogramBase: 0, isHidden: true },
|
||||||
}
|
}
|
||||||
|
|
||||||
BSP_SUBTYPES.forEach(function (t) {
|
BSP_SUBTYPES.forEach(function (t) {
|
||||||
@@ -358,7 +399,7 @@
|
|||||||
name: '缠论',
|
name: '缠论',
|
||||||
metainfo: {
|
metainfo: {
|
||||||
_metainfoVersion: 53,
|
_metainfoVersion: 53,
|
||||||
id: 'Chan@tv-basicstudies-5',
|
id: 'Chan@tv-basicstudies-6',
|
||||||
scriptIdPart: '',
|
scriptIdPart: '',
|
||||||
description: 'Chan 缠论',
|
description: 'Chan 缠论',
|
||||||
shortDescription: '缠论',
|
shortDescription: '缠论',
|
||||||
@@ -373,12 +414,18 @@
|
|||||||
title: '中枢', isHidden: false },
|
title: '中枢', isHidden: false },
|
||||||
{ id: 'segzs_fill', objAId: 'segzs_top', objBId: 'segzs_bottom', type: 'plot_plot',
|
{ id: 'segzs_fill', objAId: 'segzs_top', objBId: 'segzs_bottom', type: 'plot_plot',
|
||||||
title: '段中枢', isHidden: false },
|
title: '段中枢', isHidden: false },
|
||||||
|
{ id: 'zs_pending_fill', objAId: 'zs_pending_top', objBId: 'zs_pending_bottom', type: 'plot_plot',
|
||||||
|
title: '未完成中枢', isHidden: false },
|
||||||
|
{ id: 'segzs_pending_fill', objAId: 'segzs_pending_top', objBId: 'segzs_pending_bottom', type: 'plot_plot',
|
||||||
|
title: '未完成段中枢', isHidden: false },
|
||||||
],
|
],
|
||||||
defaults: {
|
defaults: {
|
||||||
styles: styles,
|
styles: styles,
|
||||||
filledAreasStyle: {
|
filledAreasStyle: {
|
||||||
zs_fill: mergeFill('zs_fill', { color: '#f1d96a', visible: true, transparency: 75 }),
|
zs_fill: mergeFill('zs_fill', { color: '#f1d96a', visible: true, transparency: 75 }),
|
||||||
segzs_fill: mergeFill('segzs_fill', { color: '#6361f7', visible: true, transparency: 75 }),
|
segzs_fill: mergeFill('segzs_fill', { color: '#6361f7', visible: true, transparency: 75 }),
|
||||||
|
zs_pending_fill: mergeFill('zs_pending_fill', { color: '#f1c40f', visible: true, transparency: 55 }),
|
||||||
|
segzs_pending_fill: mergeFill('segzs_pending_fill', { color: '#9b59b6', visible: true, transparency: 55 }),
|
||||||
},
|
},
|
||||||
precision: 2,
|
precision: 2,
|
||||||
inputs: { epoch: 0 },
|
inputs: { epoch: 0 },
|
||||||
@@ -394,8 +441,8 @@
|
|||||||
self._context = ctx
|
self._context = ctx
|
||||||
}
|
}
|
||||||
this.main = function (context) {
|
this.main = function (context) {
|
||||||
// 32 个 plot: 8 结构 + 24 BSP
|
// 36 个 plot: 12 结构 + 24 BSP
|
||||||
var NANS = new Array(32).fill(NaN)
|
var NANS = new Array(36).fill(NaN)
|
||||||
// v31: sniffing pass 时 context.symbol.time 为 NaN
|
// v31: sniffing pass 时 context.symbol.time 为 NaN
|
||||||
var t = context.symbol.time
|
var t = context.symbol.time
|
||||||
if (isNaN(t)) return NANS
|
if (isNaN(t)) return NANS
|
||||||
@@ -415,6 +462,10 @@
|
|||||||
e.zs_bottom != null ? e.zs_bottom : NaN,
|
e.zs_bottom != null ? e.zs_bottom : NaN,
|
||||||
e.segzs_top != null ? e.segzs_top : NaN,
|
e.segzs_top != null ? e.segzs_top : NaN,
|
||||||
e.segzs_bottom != null ? e.segzs_bottom : NaN,
|
e.segzs_bottom != null ? e.segzs_bottom : NaN,
|
||||||
|
e.zs_pending_top != null ? e.zs_pending_top : NaN,
|
||||||
|
e.zs_pending_bottom != null ? e.zs_pending_bottom : NaN,
|
||||||
|
e.segzs_pending_top != null ? e.segzs_pending_top : NaN,
|
||||||
|
e.segzs_pending_bottom != null ? e.segzs_pending_bottom : NaN,
|
||||||
]
|
]
|
||||||
|
|
||||||
BSP_SUBTYPES.forEach(function (sub) {
|
BSP_SUBTYPES.forEach(function (sub) {
|
||||||
|
|||||||
@@ -263,8 +263,9 @@ function updateTradingViewData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重新显示笔、线段和中枢等图形
|
// 不再调用 redrawFractalElements():它会全量 initTradingView,
|
||||||
redrawFractalElements();
|
// 与增量更新叠加会导致图表反复重建、内存暴涨。
|
||||||
|
// 笔/段/中枢仍随「手动刷新 / 全量 refreshChart」重建;自动刷新走增量路径。
|
||||||
|
|
||||||
// 更新EMA52显示
|
// 更新EMA52显示
|
||||||
updateEMA52Display(currentData);
|
updateEMA52Display(currentData);
|
||||||
|
|||||||
+54
-271
@@ -1,33 +1,56 @@
|
|||||||
/* chart_tv.js — split from chart.js */
|
/* chart_tv.js — split from chart.js */
|
||||||
|
|
||||||
|
/** 释放 Lightweight Charts 实例、DOM 与全局事件,避免自动刷新内存泄漏 */
|
||||||
|
function disposeTradingViewCharts() {
|
||||||
|
try {
|
||||||
|
if (window._tvInitCleanups && Array.isArray(window._tvInitCleanups)) {
|
||||||
|
window._tvInitCleanups.forEach(function (fn) { try { fn(); } catch (e) {} });
|
||||||
|
}
|
||||||
|
window._tvInitCleanups = [];
|
||||||
|
if (window._bindSyncCleanups && Array.isArray(window._bindSyncCleanups)) {
|
||||||
|
window._bindSyncCleanups.forEach(function (fn) { try { fn(); } catch (e) {} });
|
||||||
|
}
|
||||||
|
window._bindSyncCleanups = [];
|
||||||
|
if (window._tooltipCleanups && Array.isArray(window._tooltipCleanups)) {
|
||||||
|
window._tooltipCleanups.forEach(function (fn) { try { fn(); } catch (e) {} });
|
||||||
|
}
|
||||||
|
window._tooltipCleanups = [];
|
||||||
|
|
||||||
|
document.querySelectorAll(
|
||||||
|
'.volume-crosshair-line, .atr-crosshair-line, .macd-crosshair-line, .chanmacd-crosshair-line'
|
||||||
|
).forEach(function (el) { try { el.remove(); } catch (e) {} });
|
||||||
|
|
||||||
|
if (typeof clearEMA52Series === 'function') {
|
||||||
|
try { clearEMA52Series(); } catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tvWidget) {
|
||||||
|
['mainChart', 'volumeChart', 'macdChart', 'chanMacdChart', 'atrChart'].forEach(function (key) {
|
||||||
|
try {
|
||||||
|
if (tvWidget[key] && typeof tvWidget[key].remove === 'function') {
|
||||||
|
tvWidget[key].remove();
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
tvWidget[key] = null;
|
||||||
|
});
|
||||||
|
if (tvWidget.state) {
|
||||||
|
tvWidget.state.isInitialized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var chartRoot = document.getElementById('tradingview_chart');
|
||||||
|
if (chartRoot) {
|
||||||
|
chartRoot.innerHTML = '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('disposeTradingViewCharts 失败(可忽略):', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function initTradingView(symbol, timeframe) {
|
function initTradingView(symbol, timeframe) {
|
||||||
try {
|
try {
|
||||||
// 在重新初始化前,尝试释放旧图表与系列资源,避免 GPU 内存累积
|
// 每次重建前完整释放,防止自动刷新导致 GPU/监听器泄漏
|
||||||
try {
|
disposeTradingViewCharts();
|
||||||
if (tvWidget && tvWidget.state && tvWidget.state.isInitialized) {
|
|
||||||
// 主图
|
|
||||||
if (tvWidget.mainChart && typeof tvWidget.mainChart.remove === 'function') {
|
|
||||||
tvWidget.mainChart.remove();
|
|
||||||
}
|
|
||||||
// 成交量
|
|
||||||
if (tvWidget.volumeChart && typeof tvWidget.volumeChart.remove === 'function') {
|
|
||||||
tvWidget.volumeChart.remove();
|
|
||||||
}
|
|
||||||
// 旧 MACD(若存在)
|
|
||||||
if (tvWidget.macdChart && typeof tvWidget.macdChart.remove === 'function') {
|
|
||||||
tvWidget.macdChart.remove();
|
|
||||||
}
|
|
||||||
// 新 ChanMACD(若存在)
|
|
||||||
if (tvWidget.chanMacdChart && typeof tvWidget.chanMacdChart.remove === 'function') {
|
|
||||||
tvWidget.chanMacdChart.remove();
|
|
||||||
}
|
|
||||||
// ATR
|
|
||||||
if (tvWidget.atrChart && typeof tvWidget.atrChart.remove === 'function') {
|
|
||||||
tvWidget.atrChart.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('释放旧图表资源失败(可忽略):', e);
|
|
||||||
}
|
|
||||||
console.log('初始化TradingView图表:', symbol, timeframe);
|
console.log('初始化TradingView图表:', symbol, timeframe);
|
||||||
|
|
||||||
// 获取当前交易对的配置
|
// 获取当前交易对的配置
|
||||||
@@ -98,11 +121,7 @@ try {
|
|||||||
candles = filterTradingHours(candles, symbolConfig);
|
candles = filterTradingHours(candles, symbolConfig);
|
||||||
console.log(`A股数据过滤: ${originalLength} -> ${candles.length} 条记录`);
|
console.log(`A股数据过滤: ${originalLength} -> ${candles.length} 条记录`);
|
||||||
}
|
}
|
||||||
// 清除图表容器(释放旧 DOM 与 Canvas)
|
// 重置图表对象(容器已在 disposeTradingViewCharts 清空)
|
||||||
const chartRoot = document.getElementById('tradingview_chart');
|
|
||||||
if (chartRoot) chartRoot.innerHTML = '';
|
|
||||||
|
|
||||||
// 重置图表对象
|
|
||||||
tvWidget = {
|
tvWidget = {
|
||||||
mainChart: null,
|
mainChart: null,
|
||||||
volumeChart: null,
|
volumeChart: null,
|
||||||
@@ -235,8 +254,7 @@ try {
|
|||||||
container.appendChild(chanMacdChartContainer);
|
container.appendChild(chanMacdChartContainer);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 防止同步过程中的无限循环
|
// 防止同步过程中的无限循环(实际同步由 bindSyncEvents 负责)
|
||||||
let syncInProgress = false;
|
|
||||||
|
|
||||||
// 创建统一的图表选项
|
// 创建统一的图表选项
|
||||||
const createChartOptions = (showTimeScale = true, chartType = 'main') => {
|
const createChartOptions = (showTimeScale = true, chartType = 'main') => {
|
||||||
@@ -1052,243 +1070,8 @@ try {
|
|||||||
window.kluDivMarkersSubSub = [];
|
window.kluDivMarkersSubSub = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 实现三图联动滚动
|
// 图表同步事件统一由文末 bindSyncEvents 注册(带 cleanup),此处不再重复 addEventListener,
|
||||||
|
// 否则每次自动刷新/重建都会在 document/window 上堆积监听导致内存泄漏。
|
||||||
// 同步图表的时间范围
|
|
||||||
function syncCharts(sourceChart, sourceContainer) {
|
|
||||||
// 防止无限循环 - 使用更精确的检查
|
|
||||||
if (syncInProgress) {
|
|
||||||
console.log('🔄 同步正在进行中,跳过此次同步');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
syncInProgress = true;
|
|
||||||
console.log('🚀 开始同步图表,来源:',
|
|
||||||
sourceChart === mainChart ? '主图' :
|
|
||||||
sourceChart === volumeChart ? '成交量图' :
|
|
||||||
sourceChart === atrChart ? 'ATR图' :
|
|
||||||
sourceChart === macdChart ? 'MACD图' :
|
|
||||||
sourceChart === chanMacdChart ? 'ChanMACD图' : '未知图表');
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (sourceChart && sourceChart.timeScale) {
|
|
||||||
const logicalRange = sourceChart.timeScale().getVisibleLogicalRange();
|
|
||||||
|
|
||||||
if (logicalRange && logicalRange.from !== undefined && logicalRange.to !== undefined) {
|
|
||||||
console.log('📊 同步时间范围:', logicalRange);
|
|
||||||
|
|
||||||
// 同步主图
|
|
||||||
if (sourceChart !== mainChart && mainChart && mainChart.timeScale) {
|
|
||||||
try {
|
|
||||||
mainChart.timeScale().setVisibleLogicalRange(logicalRange);
|
|
||||||
console.log('✅ 主图同步完成');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('❌ 主图同步失败:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 同步成交量图
|
|
||||||
if (sourceChart !== volumeChart && volumeChart && volumeChart.timeScale) {
|
|
||||||
try {
|
|
||||||
volumeChart.timeScale().setVisibleLogicalRange(logicalRange);
|
|
||||||
console.log('✅ 成交量图同步完成');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('❌ 成交量图同步失败:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 同步ATR图
|
|
||||||
if (sourceChart !== atrChart && atrChart && atrChart.timeScale) {
|
|
||||||
try {
|
|
||||||
atrChart.timeScale().setVisibleLogicalRange(logicalRange);
|
|
||||||
console.log('✅ ATR图同步完成');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('❌ ATR图同步失败:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 同步MACD图
|
|
||||||
if (showMacd && macdChart && sourceChart !== macdChart && macdChart.timeScale) {
|
|
||||||
try {
|
|
||||||
macdChart.timeScale().setVisibleLogicalRange(logicalRange);
|
|
||||||
console.log('✅ MACD图同步完成');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('❌ MACD图同步失败:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 同步ChanMACD图
|
|
||||||
if (showMacd && chanMacdChart && sourceChart !== chanMacdChart && chanMacdChart.timeScale) {
|
|
||||||
try {
|
|
||||||
chanMacdChart.timeScale().setVisibleLogicalRange(logicalRange);
|
|
||||||
console.log('✅ ChanMACD图同步完成');
|
|
||||||
} catch (e) {
|
|
||||||
console.error('❌ ChanMACD图同步失败:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存当前的可见范围到全局状态
|
|
||||||
if (tvWidget && tvWidget.state) {
|
|
||||||
tvWidget.state.logicalRange = logicalRange;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('⚠️ 无效的逻辑范围:', logicalRange);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('⚠️ 无效的源图表或时间刻度');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('💥 同步图表出错:', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 立即重置同步标志,提高响应速度
|
|
||||||
setTimeout(() => {
|
|
||||||
syncInProgress = false;
|
|
||||||
console.log('🔓 同步标志已重置');
|
|
||||||
}, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用于跟踪所有图表的拖动状态
|
|
||||||
let localDragStates = {
|
|
||||||
main: false,
|
|
||||||
volume: false,
|
|
||||||
atr: false,
|
|
||||||
macd: false,
|
|
||||||
chanmacd: false
|
|
||||||
};
|
|
||||||
|
|
||||||
// 全局鼠标抬起事件(只添加一次)
|
|
||||||
document.addEventListener('mouseup', () => {
|
|
||||||
// 重置所有拖动状态
|
|
||||||
Object.keys(localDragStates).forEach(key => {
|
|
||||||
if (localDragStates[key]) {
|
|
||||||
console.log(`全局鼠标抬起,重置${key}图表拖动状态`);
|
|
||||||
localDragStates[key] = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 为每个图表添加事件监听
|
|
||||||
const addChartSyncEvents = (chartContainer, chart) => {
|
|
||||||
console.log('为图表添加同步事件监听:',
|
|
||||||
chart === mainChart ? '主图' :
|
|
||||||
chart === volumeChart ? '成交量图' :
|
|
||||||
chart === atrChart ? 'ATR图' :
|
|
||||||
chart === macdChart ? 'MACD图' :
|
|
||||||
chart === chanMacdChart ? 'ChanMACD图' : '未知图表');
|
|
||||||
|
|
||||||
// 确定当前图表类型
|
|
||||||
const chartType = chart === mainChart ? 'main' :
|
|
||||||
chart === volumeChart ? 'volume' :
|
|
||||||
chart === atrChart ? 'atr' :
|
|
||||||
chart === macdChart ? 'macd' :
|
|
||||||
chart === chanMacdChart ? 'chanmacd' : 'unknown';
|
|
||||||
|
|
||||||
// 使用LightweightCharts内置的时间范围变化事件(这是最可靠的方法)
|
|
||||||
chart.timeScale().subscribeVisibleTimeRangeChange(() => {
|
|
||||||
// 使用图表特定的同步标志防止递归
|
|
||||||
if (!syncInProgress) {
|
|
||||||
console.log('✅ 检测到时间范围变化,触发同步:', chartType, '当前范围:', chart.timeScale().getVisibleLogicalRange());
|
|
||||||
syncCharts(chart, chartContainer);
|
|
||||||
} else {
|
|
||||||
console.log('⏸️ 同步进行中,跳过时间范围变化事件:', chartType);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 备用的DOM事件监听(用于调试和额外保障)
|
|
||||||
let isScrolling = false;
|
|
||||||
|
|
||||||
// 鼠标按下事件
|
|
||||||
chartContainer.addEventListener('mousedown', (e) => {
|
|
||||||
localDragStates[chartType] = true;
|
|
||||||
console.log('鼠标按下开始拖动:', chartType);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 鼠标抬起事件
|
|
||||||
chartContainer.addEventListener('mouseup', (e) => {
|
|
||||||
if (localDragStates[chartType]) {
|
|
||||||
localDragStates[chartType] = false;
|
|
||||||
console.log('鼠标抬起,结束拖动:', chartType);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 鼠标离开事件
|
|
||||||
chartContainer.addEventListener('mouseleave', (e) => {
|
|
||||||
if (localDragStates[chartType]) {
|
|
||||||
localDragStates[chartType] = false;
|
|
||||||
console.log('鼠标离开容器,结束拖动:', chartType);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 滚轮缩放事件(保持原有逻辑)
|
|
||||||
chartContainer.addEventListener('wheel', (e) => {
|
|
||||||
if (!isScrolling) {
|
|
||||||
isScrolling = true;
|
|
||||||
console.log('滚轮缩放:', chartType);
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!syncInProgress) {
|
|
||||||
syncCharts(chart, chartContainer);
|
|
||||||
}
|
|
||||||
isScrolling = false;
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 添加事件监听
|
|
||||||
addChartSyncEvents(mainChartContainer, mainChart);
|
|
||||||
addChartSyncEvents(volumeChartContainer, volumeChart);
|
|
||||||
addChartSyncEvents(atrChartContainer, atrChart);
|
|
||||||
if (showMacd && macdChart) {
|
|
||||||
addChartSyncEvents(macdChartContainer, macdChart);
|
|
||||||
}
|
|
||||||
if (showMacd && chanMacdChart) {
|
|
||||||
addChartSyncEvents(chanMacdChartContainer, chanMacdChart);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 窗口大小变化时重绘图表
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
// 调整主图大小
|
|
||||||
mainChart.applyOptions({
|
|
||||||
width: mainChartContainer.clientWidth,
|
|
||||||
height: mainChartContainer.clientHeight
|
|
||||||
});
|
|
||||||
|
|
||||||
// 调整成交量图大小
|
|
||||||
volumeChart.applyOptions({
|
|
||||||
width: volumeChartContainer.clientWidth,
|
|
||||||
height: volumeChartContainer.clientHeight
|
|
||||||
});
|
|
||||||
|
|
||||||
// 调整ATR图大小
|
|
||||||
atrChart.applyOptions({
|
|
||||||
width: atrChartContainer.clientWidth,
|
|
||||||
height: atrChartContainer.clientHeight
|
|
||||||
});
|
|
||||||
|
|
||||||
// 调整MACD图大小
|
|
||||||
if (showMacd && macdChart && macdChartContainer) {
|
|
||||||
macdChart.applyOptions({
|
|
||||||
width: macdChartContainer.clientWidth,
|
|
||||||
height: macdChartContainer.clientHeight
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调整ChanMACD图大小
|
|
||||||
if (showMacd && chanMacdChart && chanMacdChartContainer) {
|
|
||||||
chanMacdChart.applyOptions({
|
|
||||||
width: chanMacdChartContainer.clientWidth,
|
|
||||||
height: chanMacdChartContainer.clientHeight
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重新同步 - 使用主图作为同步源
|
|
||||||
setTimeout(() => {
|
|
||||||
if (mainChart) {
|
|
||||||
syncCharts(mainChart, mainChartContainer);
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
});
|
|
||||||
// 显示笔的绘制 - 分别处理主周期、次周期和次次周期
|
// 显示笔的绘制 - 分别处理主周期、次周期和次次周期
|
||||||
if ($('#showMainBi').is(':checked') || $('#showElementBi').is(':checked') || $('#showSubSubBi').is(':checked')) {
|
if ($('#showMainBi').is(':checked') || $('#showElementBi').is(':checked') || $('#showSubSubBi').is(':checked')) {
|
||||||
console.log('绘制笔 - 已启用');
|
console.log('绘制笔 - 已启用');
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/* chart_view.js — split from chart.js */
|
/* chart_view.js — split from chart.js */
|
||||||
function updateChart() {
|
function updateChart(options) {
|
||||||
|
options = options || {};
|
||||||
// 只显示旋转加载图标
|
// 只显示旋转加载图标
|
||||||
$('#refreshLoadingSpinner').show();
|
$('#refreshLoadingSpinner').show();
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ function updateChart() {
|
|||||||
const subSubTimeframe = $('#subSubTimeframe').val() || '';
|
const subSubTimeframe = $('#subSubTimeframe').val() || '';
|
||||||
|
|
||||||
// 确保时区参数有效
|
// 确保时区参数有效
|
||||||
console.log('更新图表使用时区:', timezone);
|
console.log('更新图表使用时区:', timezone, 'reason:', options.reason || (options.fromAutoRefresh ? 'auto' : 'manual'));
|
||||||
console.log('数据源:', dataSource, '交易对/股票:', symbol);
|
console.log('数据源:', dataSource, '交易对/股票:', symbol);
|
||||||
|
|
||||||
// 如果symbol为空,不发送请求
|
// 如果symbol为空,不发送请求
|
||||||
@@ -42,9 +43,14 @@ function updateChart() {
|
|||||||
endTimeMs = new Date($('#end_time').val()).getTime();
|
endTimeMs = new Date($('#end_time').val()).getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 自动刷新:取消进行中的上一请求,避免响应堆积
|
||||||
|
if (options.fromAutoRefresh && window._analyzeXhr && window._analyzeXhr.readyState !== 4) {
|
||||||
|
try { window._analyzeXhr.abort(); } catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
// 发送请求
|
// 发送请求
|
||||||
const requestId = ++lastRequestId; // 标记本次请求
|
const requestId = ++lastRequestId; // 标记本次请求
|
||||||
$.ajax({
|
window._analyzeXhr = $.ajax({
|
||||||
url: '/api/analyze',
|
url: '/api/analyze',
|
||||||
data: {
|
data: {
|
||||||
symbol: symbol,
|
symbol: symbol,
|
||||||
@@ -75,15 +81,25 @@ function updateChart() {
|
|||||||
}
|
}
|
||||||
currentData = data;
|
currentData = data;
|
||||||
|
|
||||||
refreshChart(data);
|
refreshChart(data, {
|
||||||
|
incremental: options.incremental !== undefined
|
||||||
|
? !!options.incremental
|
||||||
|
: !!options.fromAutoRefresh
|
||||||
|
});
|
||||||
},
|
},
|
||||||
error: function(jqXHR, textStatus, errorThrown) {
|
error: function(jqXHR, textStatus, errorThrown) {
|
||||||
// 隐藏加载图标
|
// 隐藏加载图标
|
||||||
$('#refreshLoadingSpinner').hide();
|
$('#refreshLoadingSpinner').hide();
|
||||||
|
if (textStatus === 'abort') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 显示错误信息
|
// 显示错误信息
|
||||||
console.error('加载数据失败:', errorThrown);
|
console.error('加载数据失败:', errorThrown);
|
||||||
alert('加载数据失败: ' + (jqXHR.responseJSON?.error || errorThrown));
|
// 自动刷新失败不弹窗打扰
|
||||||
|
if (!options.fromAutoRefresh) {
|
||||||
|
alert('加载数据失败: ' + (jqXHR.responseJSON?.error || errorThrown));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* TradingView Datafeed — 对接 Data Provider 微服务
|
* TradingView Datafeed — 对接 Data Provider 微服务
|
||||||
*
|
*
|
||||||
* 数据源: http://103.179.242.166
|
* REST: https://provider.jackyu66.com
|
||||||
* - GET /timeframes → 可用周期
|
* WS: wss://jackyu66.com/ws (可通过 window.DATA_SERVICE_* 或 URL 参数覆盖)
|
||||||
* - GET /api/candles → 历史 OHLCV
|
|
||||||
* - WS /ws → 实时 K 线推送
|
|
||||||
*
|
*
|
||||||
* 实现 IDatafeedChartApi 核心接口:
|
* 实时:subscribeBars → 收到 kline 后调用 onTick(由 Charting Library 增量更新,不重置缩放)
|
||||||
* onReady, resolveSymbol, getBars, subscribeBars, unsubscribeBars
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
var ChanTVDatafeed = (function () {
|
var ChanTVDatafeed = (function () {
|
||||||
'use strict'
|
'use strict'
|
||||||
|
|
||||||
// 默认 data_provider 地址,可通过 URL param 覆盖
|
function getParam(name) {
|
||||||
var DATA_HOST = 'http://103.179.242.166'
|
try {
|
||||||
|
var m = (new RegExp('[?&]' + name + '=([^&]*)')).exec(location.search)
|
||||||
|
return m ? decodeURIComponent(m[1]) : ''
|
||||||
|
} catch (e) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// REST 与 WS 可分离(nginx 反代)
|
||||||
|
var DATA_HOST = (window.DATA_SERVICE_URL || getParam('data_host') || 'https://provider.jackyu66.com').replace(/\/$/, '')
|
||||||
|
var WS_URL = (window.DATA_SERVICE_WS_URL || getParam('ws_url') || 'wss://jackyu66.com/ws').replace(/\/$/, '')
|
||||||
|
|
||||||
// ---- resolution <-> timeframe 转换 ----
|
// ---- resolution <-> timeframe 转换 ----
|
||||||
var RES_TO_TF = {
|
var RES_TO_TF = {
|
||||||
@@ -36,13 +43,12 @@ var ChanTVDatafeed = (function () {
|
|||||||
var ws = null
|
var ws = null
|
||||||
var wsReconnectTimer = null
|
var wsReconnectTimer = null
|
||||||
var wsSubs = {} // listenerGuid -> { symbol, tf, onTick, lastTickTime }
|
var wsSubs = {} // listenerGuid -> { symbol, tf, onTick, lastTickTime }
|
||||||
var wsUrl = DATA_HOST.replace(/^http/, 'ws') + '/ws'
|
|
||||||
|
|
||||||
function wsConnect() {
|
function wsConnect() {
|
||||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return
|
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ws = new WebSocket(wsUrl)
|
ws = new WebSocket(WS_URL)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[TV Datafeed] WS 连接失败', e)
|
console.warn('[TV Datafeed] WS 连接失败', e)
|
||||||
scheduleReconnect()
|
scheduleReconnect()
|
||||||
@@ -50,7 +56,7 @@ var ChanTVDatafeed = (function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ws.onopen = function () {
|
ws.onopen = function () {
|
||||||
console.log('[TV Datafeed] WS 已连接')
|
console.log('[TV Datafeed] WS 已连接', WS_URL)
|
||||||
// 重新订阅
|
// 重新订阅
|
||||||
Object.keys(wsSubs).forEach(function (guid) {
|
Object.keys(wsSubs).forEach(function (guid) {
|
||||||
var sub = wsSubs[guid]
|
var sub = wsSubs[guid]
|
||||||
@@ -62,28 +68,35 @@ var ChanTVDatafeed = (function () {
|
|||||||
try {
|
try {
|
||||||
var msg = JSON.parse(evt.data)
|
var msg = JSON.parse(evt.data)
|
||||||
var bars = msg.data || msg.bars // data_provider 用 'data' 字段
|
var bars = msg.data || msg.bars // data_provider 用 'data' 字段
|
||||||
|
// 历史快照交给 getBars;实时只走 kline → onTick,避免冲掉缩放
|
||||||
|
if (msg.type === 'snapshot' || msg.type === 'subscribed') return
|
||||||
if ((msg.type === 'kline' || msg.type === 'candles') && bars && bars.length > 0) {
|
if ((msg.type === 'kline' || msg.type === 'candles') && bars && bars.length > 0) {
|
||||||
// 只推送最新一根 bar,避免历史快照造成时间顺序冲突
|
|
||||||
// 按时间升序排列取最后一个
|
|
||||||
var sorted = bars.slice().sort(function (a, b) { return (a.timestamp || 0) - (b.timestamp || 0) })
|
var sorted = bars.slice().sort(function (a, b) { return (a.timestamp || 0) - (b.timestamp || 0) })
|
||||||
var latest = sorted[sorted.length - 1]
|
var latest = sorted[sorted.length - 1]
|
||||||
// 广播给所有匹配的 subscriber
|
if (!latest || latest.timestamp == null) return
|
||||||
Object.keys(wsSubs).forEach(function (guid) {
|
Object.keys(wsSubs).forEach(function (guid) {
|
||||||
var sub = wsSubs[guid]
|
var sub = wsSubs[guid]
|
||||||
if (sub.symbol === msg.symbol && sub.tf === msg.timeframe) {
|
if (sub.symbol === msg.symbol && sub.tf === msg.timeframe) {
|
||||||
// 跳过已处理过的时间戳
|
// 允许同 timestamp 更新未收盘棒(用 < 而不是 <=)
|
||||||
if (sub.lastTickTime && latest.timestamp <= sub.lastTickTime) return
|
if (sub.lastTickTime != null && latest.timestamp < sub.lastTickTime) return
|
||||||
|
var tick = {
|
||||||
|
time: latest.timestamp,
|
||||||
|
open: latest.open,
|
||||||
|
high: latest.high,
|
||||||
|
low: latest.low,
|
||||||
|
close: latest.close,
|
||||||
|
volume: latest.volume,
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
sub.onTick({
|
sub.onTick(tick)
|
||||||
time: latest.timestamp,
|
|
||||||
open: latest.open,
|
|
||||||
high: latest.high,
|
|
||||||
low: latest.low,
|
|
||||||
close: latest.close,
|
|
||||||
volume: latest.volume,
|
|
||||||
})
|
|
||||||
sub.lastTickTime = latest.timestamp
|
sub.lastTickTime = latest.timestamp
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
|
// 通知页面:更新缠论缓存(K 线由 TV onTick 处理,不重置缩放)
|
||||||
|
try {
|
||||||
|
if (window.ChanTvRealtime && typeof window.ChanTvRealtime.onBar === 'function') {
|
||||||
|
window.ChanTvRealtime.onBar(msg.symbol, msg.timeframe, latest)
|
||||||
|
}
|
||||||
|
} catch (e2) { /* ignore */ }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-7
@@ -88,15 +88,13 @@ $(document).ready(function() {
|
|||||||
.always(function() {
|
.always(function() {
|
||||||
loadAStockSymbols();
|
loadAStockSymbols();
|
||||||
startAStockStatusUpdater();
|
startAStockStatusUpdater();
|
||||||
|
// A 股:metadata 完成后再拉数(下方不再重复 updateChart)
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
updateChart();
|
updateChart();
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
setTimeout(function() {
|
|
||||||
updateChart();
|
|
||||||
}, 500);
|
|
||||||
}
|
}
|
||||||
|
// 加密货币:统一在文末单次 updateChart,避免重复请求
|
||||||
|
|
||||||
// 初始化交易对下拉菜单
|
// 初始化交易对下拉菜单
|
||||||
$('#symbol').val('BTC/USDT:USDT');
|
$('#symbol').val('BTC/USDT:USDT');
|
||||||
@@ -245,6 +243,7 @@ $(document).ready(function() {
|
|||||||
// 自动刷新相关变量
|
// 自动刷新相关变量
|
||||||
let autoRefreshTimer = null;
|
let autoRefreshTimer = null;
|
||||||
let nextRefreshTime = null;
|
let nextRefreshTime = null;
|
||||||
|
let autoRefreshTick = 0;
|
||||||
// 初始化自动刷新功能
|
// 初始化自动刷新功能
|
||||||
function initAutoRefresh() {
|
function initAutoRefresh() {
|
||||||
// 监听自动刷新勾选框变化
|
// 监听自动刷新勾选框变化
|
||||||
@@ -281,12 +280,18 @@ function startAutoRefresh() {
|
|||||||
updateNextRefreshTimeDisplay();
|
updateNextRefreshTimeDisplay();
|
||||||
|
|
||||||
// 启动定时器
|
// 启动定时器
|
||||||
|
autoRefreshTick = 0;
|
||||||
autoRefreshTimer = setInterval(function() {
|
autoRefreshTimer = setInterval(function() {
|
||||||
// 更新结束时间为当前时间
|
// 更新结束时间为当前时间
|
||||||
updateEndTimeToNow();
|
updateEndTimeToNow();
|
||||||
|
|
||||||
// 刷新图表
|
// 多数周期增量更新;每隔若干次全量重建以刷新笔/段/中枢(dispose 已防泄漏)
|
||||||
updateChart();
|
autoRefreshTick += 1;
|
||||||
|
const fullRebuild = (autoRefreshTick % 6) === 0;
|
||||||
|
updateChart({
|
||||||
|
fromAutoRefresh: true,
|
||||||
|
incremental: !fullRebuild
|
||||||
|
});
|
||||||
|
|
||||||
// 更新下次刷新时间
|
// 更新下次刷新时间
|
||||||
nextRefreshTime = new Date(Date.now() + intervalMs);
|
nextRefreshTime = new Date(Date.now() + intervalMs);
|
||||||
@@ -512,7 +517,7 @@ function updateFractalTables() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 刷新图表并更新表格
|
// 刷新图表并更新表格
|
||||||
function refreshChart(data) {
|
function refreshChart(data, options) {
|
||||||
// 检查是否接收到数据
|
// 检查是否接收到数据
|
||||||
if (!data) {
|
if (!data) {
|
||||||
console.error('未收到数据,无法刷新图表');
|
console.error('未收到数据,无法刷新图表');
|
||||||
@@ -523,6 +528,31 @@ function refreshChart(data) {
|
|||||||
$('#elementTimeframe').val(data.element_timeframe);
|
$('#elementTimeframe').val(data.element_timeframe);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
options = options || {};
|
||||||
|
const preferIncremental = !!options.incremental;
|
||||||
|
const chartsReady = tvWidget && tvWidget.state && tvWidget.state.isInitialized && tvWidget.mainChart;
|
||||||
|
|
||||||
|
// 自动刷新:增量更新,避免每次销毁/重建 Lightweight Charts
|
||||||
|
if (preferIncremental && chartsReady) {
|
||||||
|
try {
|
||||||
|
if (tvWidget.mainChart) {
|
||||||
|
try {
|
||||||
|
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
|
||||||
|
} catch (e) {
|
||||||
|
window._pendingRestoreView = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateTradingViewData();
|
||||||
|
updateTables(data);
|
||||||
|
if (currentData && currentData.ema52_dict) {
|
||||||
|
updateEMA52Display(currentData);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('增量刷新失败,回退全量重建:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 保存当前缩放(barSpacing)和滚动位置(scrollPosition)到 window
|
// 保存当前缩放(barSpacing)和滚动位置(scrollPosition)到 window
|
||||||
// tvWidget 会在 initTradingView 内被重建,所以必须存到 window 上
|
// tvWidget 会在 initTradingView 内被重建,所以必须存到 window 上
|
||||||
if (tvWidget && tvWidget.mainChart) {
|
if (tvWidget && tvWidget.mainChart) {
|
||||||
|
|||||||
+170
-314
@@ -119,7 +119,7 @@
|
|||||||
|
|
||||||
<!-- 控制栏 -->
|
<!-- 控制栏 -->
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<span class="logo">缠<span>论</span></span>
|
<span class="logo">缠<span>论</span> <small style="font-weight:500;color:#888;font-size:12px;">全版</small></span>
|
||||||
<input id="symbol-input" type="text" value="BTC/USDT:USDT" title="交易对">
|
<input id="symbol-input" type="text" value="BTC/USDT:USDT" title="交易对">
|
||||||
<select id="tf-select" title="周期">
|
<select id="tf-select" title="周期">
|
||||||
<option value="1m">1m</option>
|
<option value="1m">1m</option>
|
||||||
@@ -135,6 +135,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<button id="btn-reload" class="btn btn-reload" title="刷新数据">↻ 刷新</button>
|
<button id="btn-reload" class="btn btn-reload" title="刷新数据">↻ 刷新</button>
|
||||||
<button id="btn-theme" class="btn btn-theme" title="切换主题">🌙</button>
|
<button id="btn-theme" class="btn btn-theme" title="切换主题">🌙</button>
|
||||||
|
<span style="font-size:12px;color:#666;white-space:nowrap;">指标会自动存到本机,刷新后恢复</span>
|
||||||
<span class="status" id="status-bar">
|
<span class="status" id="status-bar">
|
||||||
<span class="dot yellow"></span> 初始化...
|
<span class="dot yellow"></span> 初始化...
|
||||||
</span>
|
</span>
|
||||||
@@ -147,26 +148,28 @@
|
|||||||
<!-- TV charting library -->
|
<!-- TV charting library -->
|
||||||
<script src="/charting_library/charting_library.js"></script>
|
<script src="/charting_library/charting_library.js"></script>
|
||||||
<!-- 自定义模块 -->
|
<!-- 自定义模块 -->
|
||||||
|
<script>
|
||||||
|
window.DATA_SERVICE_URL = "{{ data_service_url }}";
|
||||||
|
window.DATA_SERVICE_WS_URL = "{{ data_service_ws_url }}";
|
||||||
|
</script>
|
||||||
<script src="/static/js/app/api_client.js?v=1"></script>
|
<script src="/static/js/app/api_client.js?v=1"></script>
|
||||||
<script src="/static/js/app/chan_engine.js?v=4"></script>
|
<script src="/static/js/app/chan_engine.js?v=5"></script>
|
||||||
<script src="/static/js/app/datafeed.js?v=6"></script>
|
<script src="/static/js/app/datafeed.js?v=8"></script>
|
||||||
<script src="/static/js/app/chan_indicator.js?v=10"></script>
|
<script src="/static/js/app/chan_indicator.js?v=11"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
'use strict'
|
'use strict'
|
||||||
|
|
||||||
// ---- 配置 ----
|
|
||||||
var DATA_HOST = 'http://103.179.242.166'
|
|
||||||
// WebSocket URL
|
|
||||||
var WS_URL = DATA_HOST.replace(/^http/, 'ws') + '/ws'
|
|
||||||
|
|
||||||
// ---- 读取 URL params ----
|
// ---- 读取 URL params ----
|
||||||
function getParam(name, def) {
|
function getParam(name, def) {
|
||||||
var m = (new RegExp('[?&]' + name + '=([^&]*)')).exec(location.search)
|
var m = (new RegExp('[?&]' + name + '=([^&]*)')).exec(location.search)
|
||||||
return m ? decodeURIComponent(m[1]) : def
|
return m ? decodeURIComponent(m[1]) : def
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 配置(REST / WS 可分离)----
|
||||||
|
var DATA_HOST = (window.DATA_SERVICE_URL || getParam('data_host', 'https://provider.jackyu66.com')).replace(/\/$/, '')
|
||||||
|
|
||||||
var defaultSymbol = getParam('symbol', 'BTC/USDT:USDT')
|
var defaultSymbol = getParam('symbol', 'BTC/USDT:USDT')
|
||||||
var defaultTf = getParam('tf', '5m')
|
var defaultTf = getParam('tf', '5m')
|
||||||
|
|
||||||
@@ -191,7 +194,6 @@
|
|||||||
var lastChanKey = ''
|
var lastChanKey = ''
|
||||||
var fetchPromise = null // 防止并发请求
|
var fetchPromise = null // 防止并发请求
|
||||||
var computePending = false // 标记是否有待处理的计算
|
var computePending = false // 标记是否有待处理的计算
|
||||||
var computeTimeout = null // 防抖,避免 WS 洪水
|
|
||||||
|
|
||||||
// ---- 工具:resolution ↔ timeframe ----
|
// ---- 工具:resolution ↔ timeframe ----
|
||||||
function tfToRes(tf) {
|
function tfToRes(tf) {
|
||||||
@@ -378,6 +380,36 @@
|
|||||||
setStatus('ok')
|
setStatus('ok')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 本地缓存图表布局(含已开指标)----
|
||||||
|
var CHART_STATE_KEY = 'chan_tv_chart_state_v1'
|
||||||
|
|
||||||
|
function loadSavedChartState() {
|
||||||
|
try {
|
||||||
|
var raw = localStorage.getItem(CHART_STATE_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
return JSON.parse(raw)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[布局] 读取本地缓存失败', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistChartState() {
|
||||||
|
if (!widget || typeof widget.save !== 'function') return
|
||||||
|
try {
|
||||||
|
widget.save(function (state) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CHART_STATE_KEY, JSON.stringify(state))
|
||||||
|
console.log('[布局] 已缓存到本地(含指标)')
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[布局] 写入 localStorage 失败', e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[布局] save 失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 初始化 TV Widget ----
|
// ---- 初始化 TV Widget ----
|
||||||
function initTvWidget() {
|
function initTvWidget() {
|
||||||
var symbol = symbolInput.value.trim()
|
var symbol = symbolInput.value.trim()
|
||||||
@@ -394,15 +426,15 @@
|
|||||||
computeAndRefreshChan(true)
|
computeAndRefreshChan(true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// WS 重新订阅新周期
|
// WS 重新订阅由 datafeed.subscribeBars 自动处理
|
||||||
if (window._resubscribeChanWS) window._resubscribeChanWS()
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('setSymbol failed', e)
|
console.warn('setSymbol failed', e)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
widget = new TradingView.widget({
|
var savedState = loadSavedChartState()
|
||||||
|
var widgetOpts = {
|
||||||
container: chartContainer,
|
container: chartContainer,
|
||||||
library_path: '/charting_library/',
|
library_path: '/charting_library/',
|
||||||
datafeed: ChanTVDatafeed,
|
datafeed: ChanTVDatafeed,
|
||||||
@@ -413,31 +445,65 @@
|
|||||||
theme: getTheme(),
|
theme: getTheme(),
|
||||||
timezone: 'Asia/Shanghai',
|
timezone: 'Asia/Shanghai',
|
||||||
locale: 'zh',
|
locale: 'zh',
|
||||||
toolbar_bg: '#f8f9fa',
|
toolbar_bg: getTheme() === 'dark' ? '#131722' : '#f8f9fa',
|
||||||
client_id: 'chan-core',
|
client_id: 'chan-core',
|
||||||
user_id: 'local',
|
user_id: 'local',
|
||||||
|
auto_save_delay: 3, // 秒;变更后自动触发 onAutoSaveNeeded
|
||||||
|
// 仅注入缠论;其余全部走 TradingView 内置指标库(不设 studies_access = 不限制)
|
||||||
custom_indicators_getter: function () {
|
custom_indicators_getter: function () {
|
||||||
return Promise.resolve([makeChanIndicator()])
|
return Promise.resolve([makeChanIndicator()])
|
||||||
},
|
},
|
||||||
|
// 币安式:顶部可点「指标」添加全部内置指标;左侧绘图工具栏
|
||||||
|
// 注意:不要开 study_templates(无 charts_storage_url 会 404 打断工具栏)
|
||||||
disabled_features: [
|
disabled_features: [
|
||||||
'header_compare',
|
|
||||||
'header_saveload',
|
'header_saveload',
|
||||||
'study_templates',
|
'study_templates',
|
||||||
'create_volume_indicator_by_default',
|
'volume_force_overlay', // 关:成交量独立副图,不叠主图
|
||||||
'save_chart_properties_to_local_storage',
|
|
||||||
],
|
],
|
||||||
enabled_features: [
|
enabled_features: [
|
||||||
'hide_left_toolbar_by_default',
|
'header_widget',
|
||||||
|
'header_indicators',
|
||||||
|
'header_fullscreen_button',
|
||||||
|
'header_chart_type',
|
||||||
|
'header_resolutions',
|
||||||
|
'header_settings',
|
||||||
|
'header_undo_redo',
|
||||||
|
'header_screenshot',
|
||||||
|
'left_toolbar',
|
||||||
|
'control_bar',
|
||||||
|
'timeframes_toolbar',
|
||||||
|
'edit_buttons_in_legend',
|
||||||
|
'context_menus',
|
||||||
|
'legend_context_menu',
|
||||||
|
'pane_context_menu',
|
||||||
|
'scales_context_menu',
|
||||||
|
'show_object_tree',
|
||||||
'items_favoriting',
|
'items_favoriting',
|
||||||
|
'insert_indicator_dialog_shortcut',
|
||||||
|
'caption_buttons_text_if_possible',
|
||||||
|
'create_volume_indicator_by_default',
|
||||||
|
// 不用 volume_force_overlay:成交量单独副图,与主图分开
|
||||||
|
'display_legend_on_all_charts',
|
||||||
|
'use_localstorage_for_settings',
|
||||||
],
|
],
|
||||||
favorites: {
|
favorites: {
|
||||||
intervals: ['1', '5', '15', '30', '60', '240', 'D'],
|
intervals: ['1', '3', '5', '15', '30', '60', '240', 'D'],
|
||||||
chartTypes: ['Candles'],
|
chartTypes: ['Candles', 'Line', 'Area'],
|
||||||
|
indicators: [
|
||||||
|
'Moving Average',
|
||||||
|
'EMA Cross',
|
||||||
|
'MACD',
|
||||||
|
'Relative Strength Index',
|
||||||
|
'Bollinger Bands',
|
||||||
|
'Volume',
|
||||||
|
'Stochastic',
|
||||||
|
'Average True Range',
|
||||||
|
'缠论',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
studies_overrides: {
|
studies_overrides: {
|
||||||
'macd.macd.display': 3,
|
'volume.volume.color.0': 'rgba(8, 153, 129, 0.4)',
|
||||||
'macd.signal.display': 3,
|
'volume.volume.color.1': 'rgba(239, 68, 68, 0.4)',
|
||||||
'macd.histogram.display': 3,
|
|
||||||
},
|
},
|
||||||
overrides: {
|
overrides: {
|
||||||
'paneProperties.background': getTheme() === 'light' ? '#ffffff' : '#131722',
|
'paneProperties.background': getTheme() === 'light' ? '#ffffff' : '#131722',
|
||||||
@@ -449,28 +515,54 @@
|
|||||||
'mainSeriesProperties.candleStyle.wickUpColor': '#ef4444',
|
'mainSeriesProperties.candleStyle.wickUpColor': '#ef4444',
|
||||||
'mainSeriesProperties.candleStyle.wickDownColor': '#089981',
|
'mainSeriesProperties.candleStyle.wickDownColor': '#089981',
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
// 有本地缓存则恢复布局(含已开指标);会覆盖构造时的 symbol/interval
|
||||||
|
if (savedState) {
|
||||||
|
widgetOpts.saved_data = savedState
|
||||||
|
console.log('[布局] 从本地恢复上次指标/布局')
|
||||||
|
}
|
||||||
|
|
||||||
|
widget = new TradingView.widget(widgetOpts)
|
||||||
|
|
||||||
widget.onChartReady(function () {
|
widget.onChartReady(function () {
|
||||||
chart = widget.activeChart()
|
chart = widget.activeChart()
|
||||||
window._chanChart = chart // for debugging
|
window._chanChart = chart // for debugging
|
||||||
if (!chart) return
|
if (!chart) return
|
||||||
|
|
||||||
// 创建 MACD
|
// 缓存恢复后,若工具栏交易对/周期与缓存不一致,切回工具栏选择(保留指标)
|
||||||
try {
|
try {
|
||||||
chart.createStudy('MACD', false, false)
|
var curSym = chart.symbol()
|
||||||
} catch (e) {
|
var curRes = chart.resolution()
|
||||||
console.warn('createStudy MACD failed', e)
|
var wantRes = tfToRes(tf)
|
||||||
}
|
if (curSym !== symbol || String(curRes) !== String(wantRes)) {
|
||||||
|
widget.setSymbol(symbol, wantRes, function () {
|
||||||
// 调整 MACD 副图大小
|
chart = widget.activeChart()
|
||||||
try {
|
})
|
||||||
var panes = chart.getPanes()
|
|
||||||
if (panes.length >= 2) {
|
|
||||||
panes[panes.length - 1].setHeight(150)
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('resize MACD pane failed', e)
|
console.warn('同步工具栏 symbol/tf 失败', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动保存:加指标/改设置后写入 localStorage
|
||||||
|
try {
|
||||||
|
widget.subscribe('onAutoSaveNeeded', function () {
|
||||||
|
persistChartState()
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('subscribe onAutoSaveNeeded failed', e)
|
||||||
|
}
|
||||||
|
// 兜底:指标增删时也存一份
|
||||||
|
try {
|
||||||
|
widget.subscribe('study', function () {
|
||||||
|
setTimeout(persistChartState, 500)
|
||||||
|
})
|
||||||
|
} catch (e) { /* 部分版本无此事件 */ }
|
||||||
|
|
||||||
|
// 确保缠论指标挂上(自定义)
|
||||||
|
try {
|
||||||
|
ensureAndPokeChanStudy(chart)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('ensure Chan study failed', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听数据加载完 → 刷新缠论
|
// 监听数据加载完 → 刷新缠论
|
||||||
@@ -479,7 +571,7 @@
|
|||||||
// 监听可视范围变化 → 刷新缠论
|
// 监听可视范围变化 → 刷新缠论
|
||||||
attachVisibleRangeListener(chart)
|
attachVisibleRangeListener(chart)
|
||||||
|
|
||||||
// 监听样式变化 → 持久化
|
// 监听样式变化 → 持久化缠论样式 + 整图布局
|
||||||
try {
|
try {
|
||||||
widget.subscribe('study_properties_changed', function (entityId) {
|
widget.subscribe('study_properties_changed', function (entityId) {
|
||||||
var studies = chart.getAllStudies ? chart.getAllStudies() : []
|
var studies = chart.getAllStudies ? chart.getAllStudies() : []
|
||||||
@@ -491,6 +583,7 @@
|
|||||||
var sv = api ? api.getStyleValues() : null
|
var sv = api ? api.getStyleValues() : null
|
||||||
if (sv) saveChanStyles(sv)
|
if (sv) saveChanStyles(sv)
|
||||||
}
|
}
|
||||||
|
persistChartState()
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('subscribe study_properties_changed failed', e)
|
console.warn('subscribe study_properties_changed failed', e)
|
||||||
@@ -570,290 +663,53 @@
|
|||||||
setStatus('loading');
|
setStatus('loading');
|
||||||
initTvWidget();
|
initTvWidget();
|
||||||
|
|
||||||
// ---- WebSocket 实时 K 线 → 增量缠论更新 ----
|
// ---- 实时:只用 TV datafeed 的 onTick 更新 K(不重置缩放)----
|
||||||
(function () {
|
// 缠论缓存由 datafeed 回调同步;新棒出现(收盘)再重算缠论
|
||||||
var ws = null
|
;(function () {
|
||||||
var reconnectTimer = null
|
var chanDebounce = null
|
||||||
var pendingBars = [] // 收集待处理的新 bar
|
var lastTs = null
|
||||||
var lastWsMsgTime = 0 // 上次收到消息的时间(心跳检测)
|
|
||||||
var heartbeatTimer = null // 心跳超时检测
|
|
||||||
var backfillTimer = null // 回补延迟
|
|
||||||
|
|
||||||
function getTfMs(tf) {
|
window.ChanTvRealtime = {
|
||||||
var map = { '1m':60000, '3m':180000, '5m':300000, '15m':900000, '30m':1800000,
|
onBar: function (symbol, tf, bar) {
|
||||||
'1h':3600000, '2h':7200000, '4h':14400000, '6h':21600000, '8h':28800000,
|
if (!bar || bar.timestamp == null) return
|
||||||
'12h':43200000, '1d':86400000, '3d':259200000, '1w':604800000, '1M':2592000000 }
|
var curSym, curTf
|
||||||
return map[tf] || 300000
|
try { curSym = chart && chart.symbol() } catch (e) { curSym = symbolInput.value.trim() }
|
||||||
}
|
try { curTf = chart && resToTf(chart.resolution()) } catch (e) { curTf = tfSelect.value }
|
||||||
|
if (symbol !== curSym || tf !== curTf) return
|
||||||
|
|
||||||
function startHeartbeat(tf) {
|
var closed = false
|
||||||
stopHeartbeat()
|
if (cachedOhlcvBars && cachedOhlcvBars.length) {
|
||||||
// 期望每根 bar 至少收到一次数据,超时设为 3 倍周期 + 10 秒
|
var last = cachedOhlcvBars[cachedOhlcvBars.length - 1]
|
||||||
var interval = Math.min(getTfMs(tf) * 3 + 10000, 60000) // 最长 60s
|
if (bar.timestamp === last.timestamp) {
|
||||||
heartbeatTimer = setTimeout(function () {
|
last.open = bar.open; last.high = bar.high; last.low = bar.low
|
||||||
console.warn('[WS] 心跳超时,重连...')
|
last.close = bar.close; last.volume = bar.volume || 0
|
||||||
if (ws) { try { ws.close() } catch (e) { /* ignore */ } }
|
} else if (bar.timestamp > last.timestamp) {
|
||||||
reconnectWS()
|
|
||||||
}, interval)
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopHeartbeat() {
|
|
||||||
if (heartbeatTimer) clearTimeout(heartbeatTimer)
|
|
||||||
heartbeatTimer = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function touchHeartbeat() {
|
|
||||||
lastWsMsgTime = Date.now()
|
|
||||||
var tf
|
|
||||||
try { tf = resToTf(chart.resolution()) } catch (e) { tf = tfSelect.value }
|
|
||||||
startHeartbeat(tf)
|
|
||||||
}
|
|
||||||
|
|
||||||
function wsConnect() {
|
|
||||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return
|
|
||||||
|
|
||||||
try { ws = new WebSocket(WS_URL) }
|
|
||||||
catch (e) { return }
|
|
||||||
|
|
||||||
ws.onopen = function () {
|
|
||||||
console.log('[WS] 已连接')
|
|
||||||
setStatus('ok')
|
|
||||||
var sym, tf
|
|
||||||
try { sym = chart.symbol() } catch (e) { sym = symbolInput.value.trim() }
|
|
||||||
try { tf = resToTf(chart.resolution()) } catch (e) { tf = tfSelect.value }
|
|
||||||
var subMsg = JSON.stringify({ action: 'subscribe', symbol: sym, timeframe: tf })
|
|
||||||
console.log('[WS] 发送订阅', subMsg)
|
|
||||||
ws.send(subMsg)
|
|
||||||
touchHeartbeat()
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onmessage = function (evt) {
|
|
||||||
touchHeartbeat()
|
|
||||||
try {
|
|
||||||
var msg = JSON.parse(evt.data)
|
|
||||||
var bars = msg.data || msg.bars
|
|
||||||
if (msg.type === 'subscribed') {
|
|
||||||
console.log('[WS] 已订阅', msg.symbol, msg.timeframe)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (msg.type === 'snapshot' && bars) {
|
|
||||||
console.log('[WS] 收到快照', bars.length, '根 bar')
|
|
||||||
// 仅在 REST 数据尚未到达且没有进行中的请求时才用快照初始化
|
|
||||||
if (cachedOhlcvBars.length === 0 && !fetchPromise) {
|
|
||||||
for (var i = 0; i < bars.length; i++) {
|
|
||||||
var b = bars[i]
|
|
||||||
cachedOhlcvBars.push({
|
|
||||||
timestamp: b.timestamp, datetime: b.datetime || new Date(b.timestamp).toISOString(),
|
|
||||||
open: b.open, high: b.high, low: b.low, close: b.close, volume: b.volume || 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
var dpS, tF
|
|
||||||
try { dpS = chart.symbol() } catch (e) { dpS = symbolInput.value.trim() }
|
|
||||||
try { tF = resToTf(chart.resolution()) } catch (e) { tF = tfSelect.value }
|
|
||||||
doChanCompute(cachedOhlcvBars, dpS, tF)
|
|
||||||
}
|
|
||||||
// 回补缺失数据:检查快照与缓存之间的缺口
|
|
||||||
scheduleBackfill(bars)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if ((msg.type === 'kline' || msg.type === 'candles') && bars && bars.length > 0) {
|
|
||||||
var curSym, curTf
|
|
||||||
try { curSym = chart.symbol() } catch (e) { curSym = symbolInput.value.trim() }
|
|
||||||
try { curTf = resToTf(chart.resolution()) } catch (e) { curTf = tfSelect.value }
|
|
||||||
if (msg.symbol !== curSym || msg.timeframe !== curTf) return
|
|
||||||
|
|
||||||
for (var i = 0; i < bars.length; i++) {
|
|
||||||
var bar = bars[i]
|
|
||||||
var exists = false
|
|
||||||
for (var j = cachedOhlcvBars.length - 1; j >= Math.max(0, cachedOhlcvBars.length - 100); j--) {
|
|
||||||
if (cachedOhlcvBars[j].timestamp === bar.timestamp) { exists = true; break }
|
|
||||||
}
|
|
||||||
if (exists) continue
|
|
||||||
// 也检查 pendingBars 去重
|
|
||||||
var inPending = false
|
|
||||||
for (var k = 0; k < pendingBars.length; k++) {
|
|
||||||
if (pendingBars[k].timestamp === bar.timestamp) { inPending = true; break }
|
|
||||||
}
|
|
||||||
if (inPending) continue
|
|
||||||
|
|
||||||
pendingBars.push({
|
|
||||||
timestamp: bar.timestamp, datetime: bar.datetime || new Date(bar.timestamp).toISOString(),
|
|
||||||
open: bar.open, high: bar.high, low: bar.low, close: bar.close, volume: bar.volume || 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (computeTimeout) clearTimeout(computeTimeout)
|
|
||||||
computeTimeout = setTimeout(flushPendingBars, 100)
|
|
||||||
}
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onclose = function () {
|
|
||||||
ws = null
|
|
||||||
// 断线前立即处理待处理数据
|
|
||||||
if (pendingBars.length > 0) {
|
|
||||||
flushPendingBars()
|
|
||||||
}
|
|
||||||
scheduleReconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.onerror = function () { /* onclose fires next */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleReconnect() {
|
|
||||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
|
||||||
// 自适应重连延迟:短周期用短延迟
|
|
||||||
var tf, delay = 3000
|
|
||||||
try { tf = resToTf(chart.resolution()) } catch (e) { tf = tfSelect.value }
|
|
||||||
if (tf === '1m' || tf === '3m') delay = 1000
|
|
||||||
else if (tf === '5m' || tf === '15m') delay = 2000
|
|
||||||
reconnectTimer = setTimeout(reconnectWS, delay)
|
|
||||||
}
|
|
||||||
|
|
||||||
function reconnectWS() {
|
|
||||||
reconnectTimer = null
|
|
||||||
stopHeartbeat()
|
|
||||||
if (ws) { try { ws.close() } catch (e) { /* ignore */ } ws = null }
|
|
||||||
wsConnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 回补缺失数据 ----
|
|
||||||
function scheduleBackfill(snapshotBars) {
|
|
||||||
if (backfillTimer) clearTimeout(backfillTimer)
|
|
||||||
backfillTimer = setTimeout(function () { backfillGaps(snapshotBars) }, 500)
|
|
||||||
}
|
|
||||||
|
|
||||||
function backfillGaps(snapshotBars) {
|
|
||||||
if (cachedOhlcvBars.length === 0) return
|
|
||||||
|
|
||||||
// 按时间排序缓存
|
|
||||||
cachedOhlcvBars.sort(function (a, b) { return a.timestamp - b.timestamp })
|
|
||||||
var lastTs = cachedOhlcvBars[cachedOhlcvBars.length - 1].timestamp
|
|
||||||
|
|
||||||
var tf
|
|
||||||
try { tf = resToTf(chart.resolution()) } catch (e) { tf = tfSelect.value }
|
|
||||||
var periodMs = getTfMs(tf)
|
|
||||||
|
|
||||||
// 检查最后一根 bar 到当前时间的缺口
|
|
||||||
var now = Date.now()
|
|
||||||
var expectedBars = Math.floor((now - lastTs) / periodMs) - 1
|
|
||||||
if (expectedBars <= 1) return // 缺口 <= 1 根无需回补
|
|
||||||
|
|
||||||
console.log('[WS] 检测到数据缺口:', expectedBars, '根 bar, 回补中...')
|
|
||||||
|
|
||||||
// 从快照中提取缺失的 bar
|
|
||||||
if (snapshotBars && snapshotBars.length > 0) {
|
|
||||||
var filled = 0
|
|
||||||
for (var i = 0; i < snapshotBars.length; i++) {
|
|
||||||
var b = snapshotBars[i]
|
|
||||||
if (b.timestamp <= lastTs) continue
|
|
||||||
// 去重
|
|
||||||
var dup = false
|
|
||||||
for (var j = cachedOhlcvBars.length - 1; j >= Math.max(0, cachedOhlcvBars.length - 200); j--) {
|
|
||||||
if (cachedOhlcvBars[j].timestamp === b.timestamp) { dup = true; break }
|
|
||||||
}
|
|
||||||
if (dup) continue
|
|
||||||
cachedOhlcvBars.push({
|
|
||||||
timestamp: b.timestamp, datetime: b.datetime || new Date(b.timestamp).toISOString(),
|
|
||||||
open: b.open, high: b.high, low: b.low, close: b.close, volume: b.volume || 0,
|
|
||||||
})
|
|
||||||
filled++
|
|
||||||
}
|
|
||||||
if (filled > 0) {
|
|
||||||
console.log('[WS] 从快照回补', filled, '根 bar')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果快照不够,从 REST API 拉取
|
|
||||||
if (expectedBars > 10) {
|
|
||||||
console.log('[WS] 缺口较大,从 REST 回补...')
|
|
||||||
var dpSymbol
|
|
||||||
try { dpSymbol = chart.symbol() } catch (e) { dpSymbol = symbolInput.value.trim() }
|
|
||||||
var start = lastTs + periodMs
|
|
||||||
var url = DATA_HOST + '/api/candles?symbol=' + encodeURIComponent(dpSymbol) +
|
|
||||||
'&tf=' + encodeURIComponent(tf) + '&start=' + start + '&end=' + now + '&limit=500'
|
|
||||||
fetch(url).then(function (r) { return r.json() }).then(function (data) {
|
|
||||||
if (!Array.isArray(data)) return
|
|
||||||
var added = 0
|
|
||||||
for (var i = 0; i < data.length; i++) {
|
|
||||||
var d = data[i]
|
|
||||||
if (d.timestamp <= lastTs) continue
|
|
||||||
var dup = false
|
|
||||||
for (var j = cachedOhlcvBars.length - 1; j >= Math.max(0, cachedOhlcvBars.length - 200); j--) {
|
|
||||||
if (cachedOhlcvBars[j].timestamp === d.timestamp) { dup = true; break }
|
|
||||||
}
|
|
||||||
if (dup) continue
|
|
||||||
cachedOhlcvBars.push({
|
cachedOhlcvBars.push({
|
||||||
timestamp: d.timestamp, datetime: d.datetime || new Date(d.timestamp).toISOString(),
|
timestamp: bar.timestamp,
|
||||||
open: d.open, high: d.high, low: d.low, close: d.close, volume: d.volume || 0,
|
datetime: bar.datetime || new Date(bar.timestamp).toISOString(),
|
||||||
|
open: bar.open, high: bar.high, low: bar.low, close: bar.close,
|
||||||
|
volume: bar.volume || 0,
|
||||||
})
|
})
|
||||||
added++
|
if (cachedOhlcvBars.length > 5000) {
|
||||||
|
cachedOhlcvBars = cachedOhlcvBars.slice(cachedOhlcvBars.length - 5000)
|
||||||
|
}
|
||||||
|
closed = true
|
||||||
}
|
}
|
||||||
if (added > 0) {
|
}
|
||||||
cachedOhlcvBars.sort(function (a, b) { return a.timestamp - b.timestamp })
|
|
||||||
console.log('[WS] REST 回补', added, '根 bar')
|
// 仅在上一根走完时重算缠论(K/MACD 已由 TV 自带更新)
|
||||||
doChanCompute(cachedOhlcvBars, dpSymbol, tf)
|
if (closed || (lastTs != null && bar.timestamp > lastTs)) {
|
||||||
}
|
if (chanDebounce) clearTimeout(chanDebounce)
|
||||||
}).catch(function (err) { console.warn('[WS] REST 回补失败', err.message) })
|
chanDebounce = setTimeout(function () {
|
||||||
} else if (expectedBars > 1) {
|
chanDebounce = null
|
||||||
// 小缺口直接触发重算
|
if (cachedOhlcvBars && cachedOhlcvBars.length) {
|
||||||
cachedOhlcvBars.sort(function (a, b) { return a.timestamp - b.timestamp })
|
doChanCompute(cachedOhlcvBars, curSym, curTf)
|
||||||
var s, t
|
}
|
||||||
try { s = chart.symbol() } catch (e) { s = symbolInput.value.trim() }
|
}, 400)
|
||||||
try { t = resToTf(chart.resolution()) } catch (e) { t = tfSelect.value }
|
}
|
||||||
doChanCompute(cachedOhlcvBars, s, t)
|
lastTs = bar.timestamp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function flushPendingBars() {
|
|
||||||
computeTimeout = null
|
|
||||||
if (pendingBars.length === 0) return
|
|
||||||
if (cachedOhlcvBars.length === 0) {
|
|
||||||
pendingBars = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[WS] 增量更新', pendingBars.length, '根新 bar')
|
|
||||||
|
|
||||||
for (var i = 0; i < pendingBars.length; i++) {
|
|
||||||
cachedOhlcvBars.push(pendingBars[i])
|
|
||||||
}
|
|
||||||
pendingBars = []
|
|
||||||
|
|
||||||
// 去重并排序
|
|
||||||
var seen = {}
|
|
||||||
var deduped = []
|
|
||||||
for (var i = 0; i < cachedOhlcvBars.length; i++) {
|
|
||||||
var ts = cachedOhlcvBars[i].timestamp
|
|
||||||
if (!seen[ts]) { seen[ts] = true; deduped.push(cachedOhlcvBars[i]) }
|
|
||||||
}
|
|
||||||
deduped.sort(function (a, b) { return a.timestamp - b.timestamp })
|
|
||||||
cachedOhlcvBars = deduped
|
|
||||||
|
|
||||||
// 限制缓存大小(保留最近数据)
|
|
||||||
var maxBars = 5000
|
|
||||||
if (cachedOhlcvBars.length > maxBars) {
|
|
||||||
cachedOhlcvBars = cachedOhlcvBars.slice(cachedOhlcvBars.length - maxBars)
|
|
||||||
}
|
|
||||||
|
|
||||||
var dpSymbol, tf
|
|
||||||
try { dpSymbol = chart.symbol() } catch (e) { dpSymbol = null }
|
|
||||||
try { tf = resToTf(chart.resolution()) } catch (e) { tf = null }
|
|
||||||
if (!dpSymbol || !tf) return
|
|
||||||
|
|
||||||
doChanCompute(cachedOhlcvBars, dpSymbol, tf)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 延迟连接
|
|
||||||
setTimeout(wsConnect, 2000)
|
|
||||||
|
|
||||||
// 暴露给 initTvWidget
|
|
||||||
window._resubscribeChanWS = function () {
|
|
||||||
stopHeartbeat()
|
|
||||||
if (ws) { try { ws.close() } catch (e) { /* ignore */ } ws = null }
|
|
||||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
|
||||||
reconnectTimer = setTimeout(reconnectWS, 500)
|
|
||||||
}
|
|
||||||
})()
|
})()
|
||||||
})()
|
})()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1264,11 +1264,11 @@
|
|||||||
<script defer src="{{ url_for('static', filename='js/app/trend.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/app/trend.js') }}"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/app/macd_ui.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/app/macd_ui.js') }}"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/app/chart_format.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/app/chart_format.js') }}"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/app/chart_view.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/app/chart_view.js') }}?v=20260806a"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/app/chart_tv.js') }}?v=20260806a"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/app/chart_sync.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/app/chart_sync.js') }}?v=20260806a"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/app/chart_tables.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/app/chart_tables.js') }}"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/app/ui.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/app/ui.js') }}?v=20260806a"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/app/overlays.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/app/overlays.js') }}"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/app/main.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/app/main.js') }}"></script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,53 @@
|
|||||||
""" /api/analyze 契约冒烟:关键字段存在于契约清单。"""
|
"""ECR-002:加深 /api/analyze 相关契约 —— mock 行情 + analyze_chan 关键字段快照。"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
sys.path.insert(0, str(ROOT))
|
sys.path.insert(0, str(ROOT))
|
||||||
sys.path.insert(0, str(ROOT / "web"))
|
sys.path.insert(0, str(ROOT / "web"))
|
||||||
|
|
||||||
|
from tests.generate_golden import make_ohlcv # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
CONTRACT_KEYS = json.loads(
|
||||||
|
(ROOT / "tests" / "fixtures" / "analyze_contract_keys.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
|
# analyze_chan 直接返回的对象字段(未序列化前)
|
||||||
|
ANALYZE_CHAN_KEYS = {
|
||||||
|
"klc_list",
|
||||||
|
"klu_list",
|
||||||
|
"bi_list",
|
||||||
|
"seg_list",
|
||||||
|
"zs_list",
|
||||||
|
"bi_zs_list",
|
||||||
|
"bsp_list",
|
||||||
|
"klc_fx_info",
|
||||||
|
"chan_macd",
|
||||||
|
"ema52_dict",
|
||||||
|
}
|
||||||
|
|
||||||
|
CHAN_MACD_SERIALIZED_KEYS = {
|
||||||
|
"seg_list",
|
||||||
|
"unittf_list",
|
||||||
|
"histset_list",
|
||||||
|
"high_position_list",
|
||||||
|
"high_empty_list",
|
||||||
|
"low_position_list",
|
||||||
|
"low_empty_list",
|
||||||
|
"return_zero_list",
|
||||||
|
"cross0_up_list",
|
||||||
|
"cross0_down_list",
|
||||||
|
"klu_list",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_analyze_route_registered():
|
def test_analyze_route_registered():
|
||||||
from app import app
|
from app import app
|
||||||
@@ -21,9 +60,60 @@ def test_analyze_route_registered():
|
|||||||
|
|
||||||
|
|
||||||
def test_contract_keys_stable():
|
def test_contract_keys_stable():
|
||||||
keys = json.loads(
|
assert "bi_list" in CONTRACT_KEYS and "seg_list" in CONTRACT_KEYS
|
||||||
(ROOT / "tests" / "fixtures" / "analyze_contract_keys.json").read_text(
|
for k in ("kline_data", "macd", "zs_list", "bsp_list", "chan_macd"):
|
||||||
encoding="utf-8"
|
assert k in CONTRACT_KEYS
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_chan_keys_on_fixture():
|
||||||
|
from services.runtime import add_indicators, analyze_chan
|
||||||
|
|
||||||
|
df = add_indicators(make_ohlcv(400))
|
||||||
|
result = analyze_chan(df, symbol="TEST/USDT:USDT", timeframe="5m")
|
||||||
|
assert set(result.keys()) == ANALYZE_CHAN_KEYS
|
||||||
|
assert isinstance(result["bi_list"], list)
|
||||||
|
assert isinstance(result["seg_list"], list)
|
||||||
|
assert isinstance(result["chan_macd"], dict)
|
||||||
|
for k in ("seg_list", "unittf_list", "histset_list"):
|
||||||
|
assert k in result["chan_macd"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_chan_macd_shape():
|
||||||
|
from pytz import timezone
|
||||||
|
|
||||||
|
from services.runtime import add_indicators, analyze_chan, serialize_chan_macd_data
|
||||||
|
|
||||||
|
df = add_indicators(make_ohlcv(200))
|
||||||
|
result = analyze_chan(df)
|
||||||
|
serialized = serialize_chan_macd_data(result["chan_macd"], timezone("Asia/Shanghai"))
|
||||||
|
assert set(serialized.keys()) == CHAN_MACD_SERIALIZED_KEYS
|
||||||
|
# JSON 可序列化
|
||||||
|
json.dumps(serialized)
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_http_contract_with_mocked_kl():
|
||||||
|
"""Flask 测试客户端:mock get_kl_data,断言响应含契约关键字段。"""
|
||||||
|
from app import app
|
||||||
|
from services.runtime import add_indicators
|
||||||
|
|
||||||
|
df = add_indicators(make_ohlcv(300))
|
||||||
|
df = df.copy()
|
||||||
|
if "timestamp" not in df.columns:
|
||||||
|
df["timestamp"] = (pd.to_datetime(df["date"]).astype("int64") // 10**6).astype("int64")
|
||||||
|
|
||||||
|
# analyze 路由使用 `from services.runtime import *`,须 patch 其模块命名空间
|
||||||
|
with patch("api.analyze.get_kl_data", return_value=df):
|
||||||
|
client = app.test_client()
|
||||||
|
resp = client.get(
|
||||||
|
"/api/analyze",
|
||||||
|
query_string={
|
||||||
|
"symbol": "BTC/USDT:USDT",
|
||||||
|
"timeframe": "5m",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
)
|
assert resp.status_code == 200, resp.data[:500]
|
||||||
assert "bi_list" in keys and "seg_list" in keys
|
payload = resp.get_json()
|
||||||
|
assert payload is not None and "error" not in payload
|
||||||
|
missing = [k for k in CONTRACT_KEYS if k not in payload]
|
||||||
|
assert not missing, f"missing contract keys: {missing}"
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""ECR-002:runtime 门面公开符号 + 子模块可导入。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
sys.path.insert(0, str(ROOT / "web"))
|
||||||
|
|
||||||
|
REQUIRED = [
|
||||||
|
"get_kl_data",
|
||||||
|
"analyze_chan",
|
||||||
|
"add_indicators",
|
||||||
|
"serialize_chan_macd_data",
|
||||||
|
"clean_dataframe_for_json",
|
||||||
|
"classify_trend_stage",
|
||||||
|
"refresh_data_service_metadata",
|
||||||
|
"TIMEFRAMES",
|
||||||
|
"SYMBOLS",
|
||||||
|
"_zone_cache",
|
||||||
|
"macd_fast_period",
|
||||||
|
"is_smaller_or_equal_timeframe",
|
||||||
|
"get_uncompleted_seg_list",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_facade_exports():
|
||||||
|
from services import runtime as R
|
||||||
|
|
||||||
|
for name in REQUIRED:
|
||||||
|
assert hasattr(R, name), f"missing facade export: {name}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_submodules_importable():
|
||||||
|
from services.runtime import state, timeframes, market_data, indicators, analyze, serialize
|
||||||
|
|
||||||
|
assert state.exchange is not None
|
||||||
|
assert callable(timeframes.timeframe_to_minutes)
|
||||||
|
assert callable(market_data.get_kl_data)
|
||||||
|
assert callable(indicators.add_indicators)
|
||||||
|
assert callable(analyze.analyze_chan)
|
||||||
|
assert callable(serialize.convert_direction)
|
||||||
|
|
||||||
|
|
||||||
|
def test_thin_shims_still_reexport():
|
||||||
|
from services import market_data as md
|
||||||
|
from services import chan_analyze as ca
|
||||||
|
from services import serializers as ser
|
||||||
|
from services import timeframes as tf
|
||||||
|
|
||||||
|
assert callable(md.get_kl_data)
|
||||||
|
assert callable(ca.analyze_chan)
|
||||||
|
assert callable(ser.serialize_chan_macd_data)
|
||||||
|
assert callable(tf.timeframe_to_minutes)
|
||||||
Reference in New Issue
Block a user