Merge origin/dev: resolve conflicts in data_provider main.py
This commit is contained in:
@@ -37,3 +37,5 @@ feature_meta
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.DS_Store
|
||||||
|
data_provider/._config.json
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
缠论 (Chan Theory) technical analysis system for Freqtrade. Implements Chan Zhong Shui Chan's theory for crypto/stock trading, including fractal (分型), stroke (笔), segment (线段), pivot/center (中枢), and buy/sell point (买卖点) detection.
|
||||||
|
|
||||||
|
## Core Architecture
|
||||||
|
|
||||||
|
### Chan Theory Engine (`Chan*.py`)
|
||||||
|
|
||||||
|
Data processing pipeline (each step feeds the next):
|
||||||
|
|
||||||
|
1. **`ChanKLU.py`** — Raw K-line unit with TA indicators (EMA, MACD, RSI, Bollinger Bands) and candlestick pattern recognition (`Chan_KLU_PATTERN`)
|
||||||
|
2. **`ChanKLC.py`** — Combined K-line: inclusion processing (包含处理), fractal (分型) detection. Linked-list structure with `.next`/`.pre` pointers
|
||||||
|
3. **`ChanBI.py`** — Stroke (笔): basic trend unit connecting alternating fractals
|
||||||
|
4. **`ChanSBI.py`** — Special Stroke: aggregates multiple BI into higher-level units with fractal detection, feeds into SEG
|
||||||
|
5. **`ChanSEG.py`** — Segment (线段): built from SBI strokes
|
||||||
|
6. **`ChanZS.py`** / **`ChanBIZS.py`** — Center/pivot (中枢): consolidation zones (segment-level and stroke-level)
|
||||||
|
7. **`ChanBSP.py`** — Buy/Sell points (买卖点): Type 1/2/3 signals
|
||||||
|
8. **`ChanLun.py`** — Main orchestrator: ties all steps together, entry point
|
||||||
|
9. **`TF_DF.py`** — Timeframe-aware DataFrame processor: resamples data, runs the full pipeline per timeframe, handles multi-timeframe analysis
|
||||||
|
|
||||||
|
### Support modules
|
||||||
|
|
||||||
|
- **`ChanEnum.py`** — All enumerations: K-line types, fractal types, MACD states, buy/sell point types, EMA position/semantic states, K-line patterns
|
||||||
|
- **`ChanCTime.py`** — Chan theory time utility: auto-adaptive day understanding (e.g. crypto 24h vs stock market hours)
|
||||||
|
- **`ChanMACD.py`** / **`ChanMACDHistSet.py`** / **`ChanMACDSeg.py`** / **`ChanMACDUnitTF.py`** — MACD state analysis and divergence detection
|
||||||
|
- **`ChanPY.py`** — Consolidation (盘整) analysis
|
||||||
|
- **`ChanHeng.py`** — Sideways market analysis
|
||||||
|
- **`Chan_FX_Box.py`** — Fractal box (分型箱体) detection
|
||||||
|
- **`ChanLun_Classifier.py`** — Standalone classifier script: runs full pipeline and classifies market states
|
||||||
|
|
||||||
|
### Services
|
||||||
|
|
||||||
|
- **`data_provider/`** — FastAPI data service: fetches crypto data from Binance via CCXT, caches to CSV, serves REST API + WebSocket. Synthesizes derived timeframes (e.g. 5m/15m/4h from 1m/1h base). Port 9009.
|
||||||
|
- **`web/`** — Flask web UI for interactive chart visualization with Chan theory overlays. Port 8123.
|
||||||
|
- **`strategies/`** — Freqtrade trading strategies using the Chan theory engine (53 strategies)
|
||||||
|
- **`config/`** — Freqtrade JSON config files per pair/timeframe
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Exchange (CCXT) → data_provider (CSV cache) → Freqtrade → Strategy → ChanLun → TF_DF
|
||||||
|
→ KLU → KLC → BI → SBI → SEG → ZS → BSP
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
### Freqtrade Trading
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Live trade
|
||||||
|
freqtrade trade -c ./user_data/Chan/config/<config>.json --strategy <StrategyName> --strategy-path ./user_data/Chan/strategies
|
||||||
|
|
||||||
|
# Backtest
|
||||||
|
freqtrade backtesting -c ./user_data/Chan/config/<config>.json --strategy <StrategyName> --strategy-path ./user_data/Chan/strategies --timerange=20251008-
|
||||||
|
|
||||||
|
# Download data
|
||||||
|
freqtrade download-data -c ./user_data/Chan/config/<config>.json -t 1m 1h 1d --pairs BTC/USDT:USDT --timerange=20240101-
|
||||||
|
|
||||||
|
# Hyperopt
|
||||||
|
freqtrade hyperopt --hyperopt-loss SharpeHyperOptLossDaily --spaces roi --strategy <StrategyName> --strategy-path ./user_data/Chan/strategies -c ./user_data/Chan/config/<config>.json -e 200 --timerange=20250201-20250901
|
||||||
|
|
||||||
|
# Plot
|
||||||
|
freqtrade plot-dataframe --strategy <StrategyName> --datadir user_data/data/binance -c ./user_data/Chan/config/<config>.json --timerange=20250721-
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Provider
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Docker
|
||||||
|
cd data_provider && docker compose up -d
|
||||||
|
|
||||||
|
# Direct
|
||||||
|
cd data_provider && python main.py
|
||||||
|
|
||||||
|
# With custom config
|
||||||
|
CONFIG_PATH=./config.json python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web UI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web && python app.py
|
||||||
|
# or via gunicorn:
|
||||||
|
gunicorn -w 4 -b 0.0.0.0:8123 app:app
|
||||||
|
|
||||||
|
# Deploy scripts:
|
||||||
|
cd web && ./deploy.sh # standard
|
||||||
|
cd web && ./deploy_venv.sh # Ubuntu 22.04+ (venv)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker (Freqtrade)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker compose run --rm chanlun_btc backtesting -c ./user_data/Chan/config/<config>.json --strategy <StrategyName> --strategy-path ./user_data/Chan/strategies --timerange=20250721-
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
- All Chan theory classes are prefixed with `Chan` (e.g., `ChanBI`, `ChanZS`)
|
||||||
|
- Strategies import `ChanLun` and add `sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))` to import from parent
|
||||||
|
- MACD params: `MACD(26, 52, 9)` by default (slow period 52 instead of standard 26)
|
||||||
|
- Enums in `ChanEnum.py` use `auto()` values
|
||||||
|
- `ChanKLC` is a linked-list style data structure with `.next`/`.pre` pointers
|
||||||
|
- The `TF_DF` class is the primary data container per timeframe
|
||||||
|
- K-line direction uses `Chan_KLINE_DIR` (UP/DOWN/COMBINE/INCLUDED)
|
||||||
|
- All text comments/commits are in Chinese
|
||||||
+11
@@ -24,6 +24,7 @@ from decimal import Decimal
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from ChanMACD import ChanMACD
|
from ChanMACD import ChanMACD
|
||||||
from TF_DF import TF_DF
|
from TF_DF import TF_DF
|
||||||
|
from ChanZone import StructureZone, StructureZoneConfig, analyze_structure_zones
|
||||||
|
|
||||||
class ChanLun():
|
class ChanLun():
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -126,6 +127,16 @@ class ChanLun():
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_structure_zones(self, current_price=None, config=None):
|
||||||
|
if config is None:
|
||||||
|
config = StructureZoneConfig()
|
||||||
|
return analyze_structure_zones(
|
||||||
|
self.tf_df_dict,
|
||||||
|
self.ema_symbols,
|
||||||
|
current_price=current_price,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
# TF_DF methods ------------------------------------------
|
# TF_DF methods ------------------------------------------
|
||||||
def get_ema_state(self, dataframe):
|
def get_ema_state(self, dataframe):
|
||||||
return self.tf_df.get_ema_state(dataframe)
|
return self.tf_df.get_ema_state(dataframe)
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ class ChanMACD():
|
|||||||
if self.klu_list:
|
if self.klu_list:
|
||||||
for klu in self.klu_list:
|
for klu in self.klu_list:
|
||||||
hist = klu.macdhist
|
hist = klu.macdhist
|
||||||
singal = False
|
signal = False
|
||||||
if klu.pre and klu.next:
|
if klu.pre and klu.next:
|
||||||
if klu.signal > 0:
|
if klu.signal > 0:
|
||||||
signal = klu.pre.signal > klu.signal and klu.next.signal < klu.signal
|
signal = klu.pre.signal > klu.signal and klu.next.signal < klu.signal
|
||||||
|
|||||||
@@ -96,7 +96,10 @@ class ChanSEG():
|
|||||||
if self.dir == Chan_SEG_DIR.UP:
|
if self.dir == Chan_SEG_DIR.UP:
|
||||||
for index in range(1, len(self.bi_list)):
|
for index in range(1, len(self.bi_list)):
|
||||||
bi = self.bi_list[index]
|
bi = self.bi_list[index]
|
||||||
|
#print(bi.end_time, bi.next,"UP SEG BI ZS Index")
|
||||||
if bi.next == None or bi.next.next == None:
|
if bi.next == None or bi.next.next == None:
|
||||||
|
if last_zs and (bi.low > last_zs.zg or bi.high < last_zs.zd):
|
||||||
|
last_zs.set_end_bi(last_zs.bi_list[-1], last_zs.bi_list[-1].sure_time)
|
||||||
continue
|
continue
|
||||||
bi2 = bi.next
|
bi2 = bi.next
|
||||||
bi3 = bi.next.next
|
bi3 = bi.next.next
|
||||||
@@ -118,6 +121,7 @@ class ChanSEG():
|
|||||||
else:
|
else:
|
||||||
if bi.index > last_zs.bi_list[-1].index and bi.dir == Chan_BI_DIR.DOWN and bi.is_sure:
|
if bi.index > last_zs.bi_list[-1].index and bi.dir == Chan_BI_DIR.DOWN and bi.is_sure:
|
||||||
if bi.low > last_zs.zg or bi.high < last_zs.zd:
|
if bi.low > last_zs.zg or bi.high < last_zs.zd:
|
||||||
|
#print(bi.end_time, "UP SEG BI ZS End")
|
||||||
last_zs.set_end_bi(last_zs.bi_list[-1], last_zs.bi_list[-1].sure_time)
|
last_zs.set_end_bi(last_zs.bi_list[-1], last_zs.bi_list[-1].sure_time)
|
||||||
if bi3.is_sure and bi3.index <= self.bi_list[-1].index and bi.check_bi_zs_overlap() and bi.dir == Chan_BI_DIR.DOWN:
|
if bi3.is_sure and bi3.index <= self.bi_list[-1].index and bi.check_bi_zs_overlap() and bi.dir == Chan_BI_DIR.DOWN:
|
||||||
zg = min(bi.high, bi2.high, bi3.high)
|
zg = min(bi.high, bi2.high, bi3.high)
|
||||||
@@ -143,6 +147,8 @@ class ChanSEG():
|
|||||||
for index in range(1, len(self.bi_list)):
|
for index in range(1, len(self.bi_list)):
|
||||||
bi = self.bi_list[index]
|
bi = self.bi_list[index]
|
||||||
if bi.next == None or bi.next.next == None:
|
if bi.next == None or bi.next.next == None:
|
||||||
|
if last_zs and (bi.low > last_zs.zg or bi.high < last_zs.zd):
|
||||||
|
last_zs.set_end_bi(last_zs.bi_list[-1], last_zs.bi_list[-1].sure_time)
|
||||||
continue
|
continue
|
||||||
bi2 = bi.next
|
bi2 = bi.next
|
||||||
bi3 = bi.next.next
|
bi3 = bi.next.next
|
||||||
|
|||||||
+566
@@ -0,0 +1,566 @@
|
|||||||
|
"""
|
||||||
|
结构价值区 (Structure Zone) 系统
|
||||||
|
|
||||||
|
将多时间周期的 Chan 中枢边界 (ZD/ZG/GG/DD) 和 EMA52 统一表示为带强度评分的价值区对象。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Dict, Optional, Any
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Dataclasses
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RawZonePoint:
|
||||||
|
"""内部中间结构:从 Chan 中枢提取的单个价格点"""
|
||||||
|
price: float
|
||||||
|
timeframe: str # '5m', '1h', '4h' 等
|
||||||
|
structure_type: str # 'bi_zhongshu' | 'xd_zhongshu' | 'ema52'
|
||||||
|
boundary_type: str # 'ZD' | 'ZG' | 'GG' | 'DD' | 'EMA52'
|
||||||
|
source_zs_id: int # 来源 ZS 在列表中的 index(调试用)
|
||||||
|
is_sure: bool # 来源 ZS 是否已完成
|
||||||
|
candle_time: Optional[str] = None # 来源 ZS 的 end_time(用于 recency 计算)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StructureZone:
|
||||||
|
"""统一的价值区对象"""
|
||||||
|
id: int
|
||||||
|
lower: float
|
||||||
|
upper: float
|
||||||
|
center: float # (lower + upper) / 2
|
||||||
|
width_pct: float # (upper - lower) / center * 100
|
||||||
|
zone_type: str # 'support' | 'resistance' | 'neutral'
|
||||||
|
timeframes: List[str] # 参与形成此区间的时间周期
|
||||||
|
structure_types: List[str] # 参与形成的结构类型
|
||||||
|
boundary_types: List[str] # 参与形成的边界类型
|
||||||
|
overlap_count: int # 聚类中的原始点数
|
||||||
|
touch_count: int # MVP: 等于 overlap_count
|
||||||
|
recency_score: float # 0.0 - 1.0, 1.0 = 最近
|
||||||
|
ema52_distance_pct: float # 到最近 EMA52 的距离百分比
|
||||||
|
ema52_aligned: bool # 是否有 EMA52 落在区间内
|
||||||
|
strength_score: float # 0-100 综合评分
|
||||||
|
confidence: float # 0.0 - 1.0
|
||||||
|
first_seen: Optional[str] # 最早的 candle_time
|
||||||
|
last_seen: Optional[str] # 最晚的 candle_time
|
||||||
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StructureZoneConfig:
|
||||||
|
"""StructureZone 提取与评分配置"""
|
||||||
|
cluster_radius_pct: float = 0.5 # 价格聚类半径(百分比)
|
||||||
|
min_overlap_for_zone: int = 2 # 最少重叠点数才能形成区间
|
||||||
|
max_zones: int = 20 # 返回的最大区间数
|
||||||
|
recency_halflife_bars: int = 50 # recency 衰减半衰期(K线数)
|
||||||
|
zone_timeframes: List[str] = field(default_factory=lambda: ['4h', '1h', '30m', '15m', '5m'])
|
||||||
|
kl_lines_per_tf: int = 500 # 每个时间周期使用最近多少根K线
|
||||||
|
structure_weights: Dict[str, float] = field(default_factory=lambda: {
|
||||||
|
'bi_zhongshu': 1.0, # 笔中枢 — 最直接的价格行为
|
||||||
|
'xd_zhongshu': 0.8, # 线段中枢 — 较高级别但粒度较粗
|
||||||
|
'ema52': 0.4, # EMA — 趋势参考,弱于结构
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Extraction
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def extract_raw_points_from_tf_df(
|
||||||
|
tf_df_dict: Dict[str, Any],
|
||||||
|
ema_symbols: List[str],
|
||||||
|
config: StructureZoneConfig,
|
||||||
|
) -> List[RawZonePoint]:
|
||||||
|
"""
|
||||||
|
从 ChanLun.tf_df_dict 中提取所有原始价格点。
|
||||||
|
仅处理 config.zone_timeframes 中存在的时间周期。
|
||||||
|
"""
|
||||||
|
points: List[RawZonePoint] = []
|
||||||
|
|
||||||
|
for tf_name in config.zone_timeframes:
|
||||||
|
if tf_name not in tf_df_dict:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tf_df = tf_df_dict[tf_name]
|
||||||
|
|
||||||
|
# 1. 笔中枢 (ChanBIZS)
|
||||||
|
try:
|
||||||
|
if hasattr(tf_df, 'seg_list') and tf_df.seg_list:
|
||||||
|
bi_zs_result = tf_df.cal_bi_zs(tf_df.seg_list)
|
||||||
|
if bi_zs_result:
|
||||||
|
_extract_from_zs_objects(
|
||||||
|
points, tf_name, 'bi_zhongshu', bi_zs_result, config.kl_lines_per_tf
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2. 线段中枢 (ChanZS)
|
||||||
|
try:
|
||||||
|
zs_list = getattr(tf_df, 'zs_list', None)
|
||||||
|
if zs_list:
|
||||||
|
_extract_from_zs_objects(
|
||||||
|
points, tf_name, 'xd_zhongshu', zs_list, config.kl_lines_per_tf
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3. EMA52 值
|
||||||
|
for tf_name in config.zone_timeframes:
|
||||||
|
if tf_name in tf_df_dict:
|
||||||
|
try:
|
||||||
|
ema_val = tf_df_dict[tf_name].get_ema52()
|
||||||
|
if ema_val is not None and ema_val > 0:
|
||||||
|
points.append(RawZonePoint(
|
||||||
|
price=float(ema_val),
|
||||||
|
timeframe=tf_name,
|
||||||
|
structure_type='ema52',
|
||||||
|
boundary_type='EMA52',
|
||||||
|
source_zs_id=-1,
|
||||||
|
is_sure=True,
|
||||||
|
candle_time=None,
|
||||||
|
))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_from_zs_objects(
|
||||||
|
points: List[RawZonePoint],
|
||||||
|
tf_name: str,
|
||||||
|
structure_type: str,
|
||||||
|
zs_list,
|
||||||
|
kl_limit: int,
|
||||||
|
):
|
||||||
|
"""从 ZS 链表中提取 ZD/ZG/GG/DD 点"""
|
||||||
|
count = 0
|
||||||
|
node = zs_list
|
||||||
|
while hasattr(node, 'next'):
|
||||||
|
node = node.next
|
||||||
|
# 从链表头开始遍历
|
||||||
|
head = zs_list
|
||||||
|
# 收集所有节点
|
||||||
|
all_nodes = []
|
||||||
|
cur = head
|
||||||
|
while cur is not None and hasattr(cur, 'next'):
|
||||||
|
all_nodes.append(cur)
|
||||||
|
cur = cur.next
|
||||||
|
# 只取最近 kl_limit 根K线内的 ZS
|
||||||
|
all_nodes = all_nodes[-kl_limit:] if len(all_nodes) > kl_limit else all_nodes
|
||||||
|
|
||||||
|
for idx, zs in enumerate(all_nodes):
|
||||||
|
if not getattr(zs, 'is_sure', False):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
zg = float(zs.zg)
|
||||||
|
zd = float(zs.zd)
|
||||||
|
gg = float(zs.gg) if getattr(zs, 'gg', 0) else zg
|
||||||
|
dd = float(zs.dd) if getattr(zs, 'dd', 0) else zd
|
||||||
|
end_time = str(zs.end_time) if hasattr(zs, 'end_time') and zs.end_time else None
|
||||||
|
except (ValueError, TypeError, AttributeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if zg <= 0 or zd <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
zs_id = getattr(zs, 'index', idx)
|
||||||
|
points.append(RawZonePoint(price=zg, timeframe=tf_name, structure_type=structure_type,
|
||||||
|
boundary_type='ZG', source_zs_id=zs_id, is_sure=True,
|
||||||
|
candle_time=end_time))
|
||||||
|
points.append(RawZonePoint(price=zd, timeframe=tf_name, structure_type=structure_type,
|
||||||
|
boundary_type='ZD', source_zs_id=zs_id, is_sure=True,
|
||||||
|
candle_time=end_time))
|
||||||
|
points.append(RawZonePoint(price=gg, timeframe=tf_name, structure_type=structure_type,
|
||||||
|
boundary_type='GG', source_zs_id=zs_id, is_sure=True,
|
||||||
|
candle_time=end_time))
|
||||||
|
points.append(RawZonePoint(price=dd, timeframe=tf_name, structure_type=structure_type,
|
||||||
|
boundary_type='DD', source_zs_id=zs_id, is_sure=True,
|
||||||
|
candle_time=end_time))
|
||||||
|
|
||||||
|
|
||||||
|
def extract_raw_points_from_serialized(
|
||||||
|
analyses: Dict[str, Dict],
|
||||||
|
ema52_dict: Dict[str, Optional[float]],
|
||||||
|
config: StructureZoneConfig,
|
||||||
|
) -> List[RawZonePoint]:
|
||||||
|
"""
|
||||||
|
从已序列化的分析结果中提取价格点(用于 web API,避免重复计算)。
|
||||||
|
analyses: {'5m': {'zs_list': [...], 'bi_zs_list': [...]}, '15m': {...}, ...}
|
||||||
|
ema52_dict: {'5m': 123.45, '15m': None, ...}
|
||||||
|
"""
|
||||||
|
points: List[RawZonePoint] = []
|
||||||
|
|
||||||
|
for tf_name in config.zone_timeframes:
|
||||||
|
if tf_name not in analyses:
|
||||||
|
continue
|
||||||
|
|
||||||
|
analysis = analyses[tf_name]
|
||||||
|
|
||||||
|
# 笔中枢
|
||||||
|
bi_zs_items = analysis.get('bi_zs_list', [])
|
||||||
|
for idx, zs in enumerate(bi_zs_items):
|
||||||
|
if not zs.get('is_sure', False):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
zg = float(zs['zg']); zd = float(zs['zd'])
|
||||||
|
gg = float(zs.get('gg', zg)); dd = float(zs.get('dd', zd))
|
||||||
|
end_time = zs.get('end_time')
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
continue
|
||||||
|
if zg <= 0 or zd <= 0:
|
||||||
|
continue
|
||||||
|
points.append(RawZonePoint(price=zg, timeframe=tf_name, structure_type='bi_zhongshu',
|
||||||
|
boundary_type='ZG', source_zs_id=idx, is_sure=True,
|
||||||
|
candle_time=str(end_time) if end_time else None))
|
||||||
|
points.append(RawZonePoint(price=zd, timeframe=tf_name, structure_type='bi_zhongshu',
|
||||||
|
boundary_type='ZD', source_zs_id=idx, is_sure=True,
|
||||||
|
candle_time=str(end_time) if end_time else None))
|
||||||
|
points.append(RawZonePoint(price=gg, timeframe=tf_name, structure_type='bi_zhongshu',
|
||||||
|
boundary_type='GG', source_zs_id=idx, is_sure=True,
|
||||||
|
candle_time=str(end_time) if end_time else None))
|
||||||
|
points.append(RawZonePoint(price=dd, timeframe=tf_name, structure_type='bi_zhongshu',
|
||||||
|
boundary_type='DD', source_zs_id=idx, is_sure=True,
|
||||||
|
candle_time=str(end_time) if end_time else None))
|
||||||
|
|
||||||
|
# 线段中枢
|
||||||
|
zs_items = analysis.get('zs_list', [])
|
||||||
|
for idx, zs in enumerate(zs_items):
|
||||||
|
if not zs.get('is_sure', False):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
zg = float(zs['zg']); zd = float(zs['zd'])
|
||||||
|
gg = float(zs.get('gg', zg)); dd = float(zs.get('dd', zd))
|
||||||
|
end_time = zs.get('end_time')
|
||||||
|
except (ValueError, KeyError):
|
||||||
|
continue
|
||||||
|
if zg <= 0 or zd <= 0:
|
||||||
|
continue
|
||||||
|
points.append(RawZonePoint(price=zg, timeframe=tf_name, structure_type='xd_zhongshu',
|
||||||
|
boundary_type='ZG', source_zs_id=idx, is_sure=True,
|
||||||
|
candle_time=str(end_time) if end_time else None))
|
||||||
|
points.append(RawZonePoint(price=zd, timeframe=tf_name, structure_type='xd_zhongshu',
|
||||||
|
boundary_type='ZD', source_zs_id=idx, is_sure=True,
|
||||||
|
candle_time=str(end_time) if end_time else None))
|
||||||
|
points.append(RawZonePoint(price=gg, timeframe=tf_name, structure_type='xd_zhongshu',
|
||||||
|
boundary_type='GG', source_zs_id=idx, is_sure=True,
|
||||||
|
candle_time=str(end_time) if end_time else None))
|
||||||
|
points.append(RawZonePoint(price=dd, timeframe=tf_name, structure_type='xd_zhongshu',
|
||||||
|
boundary_type='DD', source_zs_id=idx, is_sure=True,
|
||||||
|
candle_time=str(end_time) if end_time else None))
|
||||||
|
|
||||||
|
# EMA52
|
||||||
|
for tf_name in config.zone_timeframes:
|
||||||
|
ema_val = ema52_dict.get(tf_name)
|
||||||
|
if ema_val is not None and ema_val > 0:
|
||||||
|
points.append(RawZonePoint(
|
||||||
|
price=float(ema_val),
|
||||||
|
timeframe=tf_name,
|
||||||
|
structure_type='ema52',
|
||||||
|
boundary_type='EMA52',
|
||||||
|
source_zs_id=-1,
|
||||||
|
is_sure=True,
|
||||||
|
candle_time=None,
|
||||||
|
))
|
||||||
|
|
||||||
|
return points
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Clustering
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def cluster_raw_points(
|
||||||
|
points: List[RawZonePoint],
|
||||||
|
config: StructureZoneConfig,
|
||||||
|
) -> List[List[RawZonePoint]]:
|
||||||
|
"""
|
||||||
|
贪心单通聚类:将价格相近的 RawZonePoint 归为一组。
|
||||||
|
仅在 1D 价格轴上操作,O(n log n)。
|
||||||
|
"""
|
||||||
|
if not points:
|
||||||
|
return []
|
||||||
|
|
||||||
|
sorted_points = sorted(points, key=lambda p: p.price)
|
||||||
|
clusters: List[List[RawZonePoint]] = []
|
||||||
|
|
||||||
|
for p in sorted_points:
|
||||||
|
placed = False
|
||||||
|
for cluster in reversed(clusters):
|
||||||
|
# 检查是否可以放入当前聚类(与聚类均价比较)
|
||||||
|
avg_price = sum(pt.price for pt in cluster) / len(cluster)
|
||||||
|
if abs(p.price - avg_price) / avg_price * 100 <= config.cluster_radius_pct:
|
||||||
|
cluster.append(p)
|
||||||
|
placed = True
|
||||||
|
break
|
||||||
|
if not placed:
|
||||||
|
clusters.append([p])
|
||||||
|
|
||||||
|
# 过滤点数不足的聚类
|
||||||
|
return [c for c in clusters if len(c) >= config.min_overlap_for_zone]
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Scoring & Building
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def build_structure_zones(
|
||||||
|
clusters: List[List[RawZonePoint]],
|
||||||
|
current_price: float,
|
||||||
|
ema52_values: Dict[str, Optional[float]],
|
||||||
|
latest_candle_time: Optional[str],
|
||||||
|
config: StructureZoneConfig,
|
||||||
|
) -> List[StructureZone]:
|
||||||
|
"""
|
||||||
|
从聚类构建 StructureZone 列表,计算所有字段和评分。
|
||||||
|
"""
|
||||||
|
zones: List[StructureZone] = []
|
||||||
|
|
||||||
|
# 收集所有 EMA52 值
|
||||||
|
ema_prices = [v for v in ema52_values.values() if v is not None and v > 0]
|
||||||
|
|
||||||
|
for zone_id, cluster in enumerate(clusters):
|
||||||
|
prices = [p.price for p in cluster]
|
||||||
|
lower = min(prices)
|
||||||
|
upper = max(prices)
|
||||||
|
center = (lower + upper) / 2
|
||||||
|
width_pct = (upper - lower) / center * 100 if center > 0 else 0.0
|
||||||
|
|
||||||
|
# 区间类型
|
||||||
|
if upper < current_price:
|
||||||
|
zone_type = 'support' # 区间在当前价格下方 → 支撑
|
||||||
|
elif lower > current_price:
|
||||||
|
zone_type = 'resistance' # 区间在当前价格上方 → 阻力
|
||||||
|
else:
|
||||||
|
zone_type = 'neutral' # 区间跨越当前价格
|
||||||
|
|
||||||
|
timeframes = sorted(set(p.timeframe for p in cluster))
|
||||||
|
structure_types = sorted(set(p.structure_type for p in cluster))
|
||||||
|
boundary_types = sorted(set(p.boundary_type for p in cluster))
|
||||||
|
overlap_count = len(cluster)
|
||||||
|
|
||||||
|
# Recency
|
||||||
|
times = [p.candle_time for p in cluster if p.candle_time]
|
||||||
|
first_seen = min(times) if times else None
|
||||||
|
last_seen = max(times) if times else None
|
||||||
|
recency_score = _calc_recency(last_seen, latest_candle_time, config.recency_halflife_bars)
|
||||||
|
|
||||||
|
# EMA52 alignment
|
||||||
|
ema52_distance_pct = 999.0
|
||||||
|
ema52_aligned = False
|
||||||
|
if ema_prices:
|
||||||
|
distances = [abs(center - ep) / ep * 100 for ep in ema_prices]
|
||||||
|
ema52_distance_pct = round(min(distances), 2)
|
||||||
|
ema52_aligned = any(lower <= ep <= upper for ep in ema_prices)
|
||||||
|
|
||||||
|
# Strength score
|
||||||
|
strength_score = _calc_strength(cluster, config, recency_score, ema52_aligned, ema52_distance_pct, width_pct)
|
||||||
|
|
||||||
|
# Confidence
|
||||||
|
confidence = _calc_confidence(overlap_count, len(timeframes), cluster)
|
||||||
|
|
||||||
|
zones.append(StructureZone(
|
||||||
|
id=zone_id + 1,
|
||||||
|
lower=round(lower, 2),
|
||||||
|
upper=round(upper, 2),
|
||||||
|
center=round(center, 2),
|
||||||
|
width_pct=round(width_pct, 2),
|
||||||
|
zone_type=zone_type,
|
||||||
|
timeframes=timeframes,
|
||||||
|
structure_types=structure_types,
|
||||||
|
boundary_types=boundary_types,
|
||||||
|
overlap_count=overlap_count,
|
||||||
|
touch_count=overlap_count, # MVP: 等于 overlap_count
|
||||||
|
recency_score=round(recency_score, 3),
|
||||||
|
ema52_distance_pct=ema52_distance_pct,
|
||||||
|
ema52_aligned=ema52_aligned,
|
||||||
|
strength_score=round(strength_score, 1),
|
||||||
|
confidence=round(confidence, 2),
|
||||||
|
first_seen=first_seen,
|
||||||
|
last_seen=last_seen,
|
||||||
|
))
|
||||||
|
|
||||||
|
# 按强度降序排列
|
||||||
|
zones.sort(key=lambda z: z.strength_score, reverse=True)
|
||||||
|
|
||||||
|
# 截断
|
||||||
|
if config.max_zones > 0 and len(zones) > config.max_zones:
|
||||||
|
zones = zones[:config.max_zones]
|
||||||
|
|
||||||
|
return zones
|
||||||
|
|
||||||
|
|
||||||
|
def _calc_recency(
|
||||||
|
last_seen: Optional[str],
|
||||||
|
latest_time: Optional[str],
|
||||||
|
halflife_bars: int,
|
||||||
|
) -> float:
|
||||||
|
"""计算 recency 分数:越近越高"""
|
||||||
|
if not last_seen or not latest_time:
|
||||||
|
return 0.5
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 尝试解析 ISO 格式时间
|
||||||
|
from dateutil import parser
|
||||||
|
t_last = parser.parse(last_seen)
|
||||||
|
t_latest = parser.parse(latest_time)
|
||||||
|
offset_seconds = (t_latest - t_last).total_seconds()
|
||||||
|
if offset_seconds < 0:
|
||||||
|
return 1.0
|
||||||
|
# 假设每根K线平均 5 分钟
|
||||||
|
bar_seconds = 300
|
||||||
|
offset_bars = offset_seconds / bar_seconds
|
||||||
|
# 指数衰减: 2 ^ (-offset / halflife)
|
||||||
|
score = 2.0 ** (-offset_bars / halflife_bars)
|
||||||
|
return float(score)
|
||||||
|
except Exception:
|
||||||
|
return 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def _calc_strength(
|
||||||
|
cluster: List[RawZonePoint],
|
||||||
|
config: StructureZoneConfig,
|
||||||
|
recency_score: float,
|
||||||
|
ema52_aligned: bool,
|
||||||
|
ema52_distance_pct: float,
|
||||||
|
width_pct: float,
|
||||||
|
) -> float:
|
||||||
|
"""计算综合强度评分 (0-100)"""
|
||||||
|
|
||||||
|
# 组件 1: 结构类型多样性 (0-40)
|
||||||
|
structure_type_counts: Dict[str, int] = {}
|
||||||
|
for p in cluster:
|
||||||
|
structure_type_counts[p.structure_type] = structure_type_counts.get(p.structure_type, 0) + 1
|
||||||
|
total = sum(structure_type_counts.values())
|
||||||
|
structure_score = 0.0
|
||||||
|
for st, count in structure_type_counts.items():
|
||||||
|
weight = config.structure_weights.get(st, 0.5)
|
||||||
|
structure_score += weight * count
|
||||||
|
structure_score = min(structure_score / max(1, total), 1.0)
|
||||||
|
c1 = structure_score * 40
|
||||||
|
|
||||||
|
# 组件 2: 多周期确认 (0-25)
|
||||||
|
tf_set = set(p.timeframe for p in cluster)
|
||||||
|
tf_diversity = len(tf_set)
|
||||||
|
c2 = min(tf_diversity / 5, 1.0) * 25
|
||||||
|
|
||||||
|
# 组件 3: 区间紧密度 (0-15) — 越窄越强
|
||||||
|
tightness = max(0.0, 1.0 - (width_pct / 3.0))
|
||||||
|
c3 = tightness * 15
|
||||||
|
|
||||||
|
# 组件 4: Recency (0-10)
|
||||||
|
c4 = recency_score * 10
|
||||||
|
|
||||||
|
# 组件 5: EMA52 共振 (0-10)
|
||||||
|
if ema52_aligned:
|
||||||
|
ema_proximity = max(0.0, 1.0 - (ema52_distance_pct / 2.0))
|
||||||
|
c5 = ema_proximity * 10
|
||||||
|
else:
|
||||||
|
c5 = 0.0
|
||||||
|
|
||||||
|
return c1 + c2 + c3 + c4 + c5
|
||||||
|
|
||||||
|
|
||||||
|
def _calc_confidence(
|
||||||
|
overlap_count: int,
|
||||||
|
tf_count: int,
|
||||||
|
cluster: List[RawZonePoint],
|
||||||
|
) -> float:
|
||||||
|
"""计算置信度 (0-1)"""
|
||||||
|
base = min(overlap_count / 6.0, 0.85)
|
||||||
|
# 多周期加分
|
||||||
|
tf_bonus = min(tf_count / 5.0, 0.1)
|
||||||
|
# 是否所有点都来自 sure 的 ZS
|
||||||
|
all_sure = all(p.is_sure for p in cluster)
|
||||||
|
sure_bonus = 0.05 if all_sure else 0.0
|
||||||
|
return min(base + tf_bonus + sure_bonus, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Top-level pipeline
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def analyze_structure_zones(
|
||||||
|
tf_df_dict: Dict[str, Any],
|
||||||
|
ema_symbols: List[str],
|
||||||
|
current_price: Optional[float] = None,
|
||||||
|
config: Optional[StructureZoneConfig] = None,
|
||||||
|
) -> List[StructureZone]:
|
||||||
|
"""
|
||||||
|
一站式分析:提取 → 聚类 → 评分 → 返回排序后的 StructureZone 列表。
|
||||||
|
"""
|
||||||
|
if config is None:
|
||||||
|
config = StructureZoneConfig()
|
||||||
|
|
||||||
|
# 提取
|
||||||
|
raw_points = extract_raw_points_from_tf_df(tf_df_dict, ema_symbols, config)
|
||||||
|
|
||||||
|
if not raw_points:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 获取当前价格
|
||||||
|
if current_price is None:
|
||||||
|
for tf_name in config.zone_timeframes:
|
||||||
|
if tf_name in tf_df_dict:
|
||||||
|
try:
|
||||||
|
ema_val = tf_df_dict[tf_name].get_ema52()
|
||||||
|
if ema_val and ema_val > 0:
|
||||||
|
current_price = float(ema_val)
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if current_price is None:
|
||||||
|
current_price = 0.0
|
||||||
|
|
||||||
|
# EMA52 值
|
||||||
|
ema52_values = {}
|
||||||
|
for tf_name in config.zone_timeframes:
|
||||||
|
if tf_name in tf_df_dict:
|
||||||
|
try:
|
||||||
|
ema52_values[tf_name] = tf_df_dict[tf_name].get_ema52()
|
||||||
|
except Exception:
|
||||||
|
ema52_values[tf_name] = None
|
||||||
|
|
||||||
|
# 最晚时间
|
||||||
|
latest_time = None
|
||||||
|
times = [p.candle_time for p in raw_points if p.candle_time]
|
||||||
|
if times:
|
||||||
|
latest_time = max(times)
|
||||||
|
|
||||||
|
# 聚类
|
||||||
|
clusters = cluster_raw_points(raw_points, config)
|
||||||
|
|
||||||
|
# 构建 & 评分
|
||||||
|
return build_structure_zones(clusters, current_price, ema52_values, latest_time, config)
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_structure_zones_from_serialized(
|
||||||
|
analyses: Dict[str, Dict],
|
||||||
|
ema52_dict: Dict[str, Optional[float]],
|
||||||
|
current_price: float,
|
||||||
|
config: Optional[StructureZoneConfig] = None,
|
||||||
|
) -> List[StructureZone]:
|
||||||
|
"""
|
||||||
|
从已序列化的分析结果构建 StructureZone(用于 web API)。
|
||||||
|
"""
|
||||||
|
if config is None:
|
||||||
|
config = StructureZoneConfig()
|
||||||
|
|
||||||
|
raw_points = extract_raw_points_from_serialized(analyses, ema52_dict, config)
|
||||||
|
|
||||||
|
if not raw_points:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 最晚时间
|
||||||
|
latest_time = None
|
||||||
|
times = [p.candle_time for p in raw_points if p.candle_time]
|
||||||
|
if times:
|
||||||
|
latest_time = max(times)
|
||||||
|
|
||||||
|
# EMA52 值(用于 alignment 检测)
|
||||||
|
ema_values = {tf: v for tf, v in ema52_dict.items() if v is not None and v > 0}
|
||||||
|
|
||||||
|
clusters = cluster_raw_points(raw_points, config)
|
||||||
|
return build_structure_zones(clusters, current_price, ema_values, latest_time, config)
|
||||||
Binary file not shown.
@@ -0,0 +1,82 @@
|
|||||||
|
# Chan 数据提供商 (Chan Data Provider)
|
||||||
|
|
||||||
|
从 **Binance 期货** 交易所拉取加密货币 K 线数据,提供 HTTP + WebSocket 数据服务。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- **多交易对**:支持 BTC, ETH, SOL, DOGE 等 9 个交易对
|
||||||
|
- **多时间周期**:基础周期 1m/1h/1d/1w,可合成 30+ 种衍生周期(如 5m, 15m, 4h 等)
|
||||||
|
- **本地缓存**:CSV 持久化到磁盘,重启快速加载
|
||||||
|
- **断线恢复**:交易所连接中断时记录断点,自动补拉缺失数据
|
||||||
|
- **实时推送**:WebSocket 订阅最新 K 线更新
|
||||||
|
- **内存服务**:启动即加载本地数据,不阻塞服务
|
||||||
|
|
||||||
|
## 启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Docker
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 直接运行
|
||||||
|
python main.py
|
||||||
|
|
||||||
|
# 或指定配置
|
||||||
|
CONFIG_PATH=./config.json python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
服务默认监听 `http://0.0.0.0:9009`。
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
编辑 `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"exchange": "binance",
|
||||||
|
"symbols": ["BTC/USDT:USDT", "ETH/USDT:USDT"],
|
||||||
|
"start_time": "2024-01-01T00:00:00Z",
|
||||||
|
"timeframes": ["1m", "1h", "1d", "1w"],
|
||||||
|
"data_dir": "./data"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `exchange` | 交易所名称(ccxt 支持即可) |
|
||||||
|
| `symbols` | 交易对列表 |
|
||||||
|
| `start_time` | 历史数据起始时间 |
|
||||||
|
| `timeframes` | 基础周期(从交易所直接拉取) |
|
||||||
|
| `data_dir` | CSV 数据存储目录 |
|
||||||
|
|
||||||
|
## 可用周期
|
||||||
|
|
||||||
|
### 基础周期(交易所直接拉取)
|
||||||
|
`1m`, `1h`, `1d`, `1w`
|
||||||
|
|
||||||
|
### 衍生周期(内存中合成)
|
||||||
|
| 基础周期 | 可合成的衍生周期 |
|
||||||
|
|----------|----------------|
|
||||||
|
| 1m | 2m, 3m, 4m, 5m, 10m, 15m, 20m, 25m, 30m, 45m |
|
||||||
|
| 1h | 2h, 3h, 4h, 5h, 6h, 7h, 8h, 9h, 10h, 11h, 12h, 16h, 20h |
|
||||||
|
| 1d | 2d, 3d, 4d, 5d, 6d |
|
||||||
|
| 1w | 2w, 3w |
|
||||||
|
|
||||||
|
## 数据存储
|
||||||
|
|
||||||
|
数据以 CSV 格式存储,按时间周期分目录:
|
||||||
|
|
||||||
|
```
|
||||||
|
./data/
|
||||||
|
1m/
|
||||||
|
binance_BTC_USDT_USDT_1m.csv
|
||||||
|
binance_ETH_USDT_USDT_1m.csv
|
||||||
|
...
|
||||||
|
1h/
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
每根 K 线包含:`timestamp`, `datetime`, `open`, `high`, `low`, `close`, `volume`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
API 文档请访问 `http://<host>:9009/api/docs`。
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Chan 数据提供商 - API 文档</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0d1117;
|
||||||
|
--surface: #161b22;
|
||||||
|
--border: #30363d;
|
||||||
|
--text: #e6edf3;
|
||||||
|
--text-secondary: #8b949e;
|
||||||
|
--accent: #58a6ff;
|
||||||
|
--green: #3fb950;
|
||||||
|
--orange: #d29922;
|
||||||
|
--red: #f85149;
|
||||||
|
--purple: #bc8cff;
|
||||||
|
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||||
|
--mono: "SF Mono", "Fira Code", "Consolas", monospace;
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: var(--font);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.container { max-width: 960px; margin: 0 auto; padding: 24px 20px; }
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
header {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 32px 0 24px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
header h1 { font-size: 28px; font-weight: 600; margin-bottom: 8px; }
|
||||||
|
header h1 span { color: var(--accent); }
|
||||||
|
header .subtitle { color: var(--text-secondary); font-size: 15px; }
|
||||||
|
header .badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
header .badge span { color: var(--green); }
|
||||||
|
|
||||||
|
/* Section */
|
||||||
|
section { margin-bottom: 40px; }
|
||||||
|
section h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
section h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 20px 0 8px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Endpoint card */
|
||||||
|
.endpoint {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.endpoint-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.endpoint-header:hover { background: rgba(255,255,255,0.03); }
|
||||||
|
.method {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 56px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.method.get { background: #1c3d5a; color: var(--accent); }
|
||||||
|
.method.ws { background: #2d1b5e; color: var(--purple); }
|
||||||
|
.endpoint-path {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.endpoint-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-left: auto;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.endpoint-body {
|
||||||
|
padding: 0 16px 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.endpoint.open .endpoint-body { display: block; }
|
||||||
|
.endpoint-body > div { margin-top: 12px; }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
th { color: var(--text-secondary); font-weight: 500; font-size: 12px; text-transform: uppercase; }
|
||||||
|
td { font-family: var(--mono); font-size: 13px; }
|
||||||
|
td.optional { color: var(--text-secondary); font-size: 12px; }
|
||||||
|
td .type { color: var(--orange); }
|
||||||
|
td .type-num { color: var(--accent); }
|
||||||
|
|
||||||
|
/* Code block */
|
||||||
|
pre {
|
||||||
|
background: #010409;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
code { font-family: var(--mono); font-size: 13px; }
|
||||||
|
pre .comment { color: #8b949e; }
|
||||||
|
pre .string { color: #a5d6ff; }
|
||||||
|
pre .key { color: #79c0ff; }
|
||||||
|
pre .num { color: #79c0ff; }
|
||||||
|
pre .null { color: #d2a8ff; }
|
||||||
|
pre .bool { color: #d2a8ff; }
|
||||||
|
|
||||||
|
/* WS message box */
|
||||||
|
.ws-box {
|
||||||
|
background: #010409;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.ws-box .label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p { font-size: 14px; color: var(--text-secondary); margin-bottom: 8px; }
|
||||||
|
ul { padding-left: 20px; font-size: 14px; color: var(--text-secondary); }
|
||||||
|
li { margin-bottom: 4px; }
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.note {
|
||||||
|
background: rgba(210, 153, 34, 0.1);
|
||||||
|
border: 1px solid rgba(210, 153, 34, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--orange);
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc { margin-bottom: 32px; }
|
||||||
|
.toc a {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
margin: 2px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1><span>Chan</span> 数据提供商</h1>
|
||||||
|
<p class="subtitle">加密货币 K 线数据 HTTP + WebSocket API</p>
|
||||||
|
<div class="badge">v1.0.0 | <span>binance</span> | port 9009</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="toc">
|
||||||
|
<a href="#root">GET /</a>
|
||||||
|
<a href="#health">GET /health</a>
|
||||||
|
<a href="#timeframes">GET /timeframes</a>
|
||||||
|
<a href="#candles">GET /api/candles</a>
|
||||||
|
<a href="#websocket">WebSocket /ws</a>
|
||||||
|
<a href="#timeframes-ref">时间周期参考</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- ============ GET / ============ -->
|
||||||
|
<section id="root">
|
||||||
|
<h2>服务信息</h2>
|
||||||
|
<div class="endpoint open">
|
||||||
|
<div class="endpoint-header" onclick="this.parentElement.classList.toggle('open')">
|
||||||
|
<span class="method get">GET</span>
|
||||||
|
<span class="endpoint-path">/</span>
|
||||||
|
<span class="endpoint-desc">服务基本信息</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint-body">
|
||||||
|
<p>返回服务名称、交易所、交易对列表、可用周期及就绪状态。</p>
|
||||||
|
<h3>响应</h3>
|
||||||
|
<pre>{
|
||||||
|
<span class="key">"service"</span>: <span class="string">"Data Provider"</span>,
|
||||||
|
<span class="key">"exchange"</span>: <span class="string">"binance"</span>,
|
||||||
|
<span class="key">"symbols"</span>: [<span class="string">"BTC/USDT:USDT"</span>, <span class="string">"ETH/USDT:USDT"</span>, ...],
|
||||||
|
<span class="key">"base_timeframes"</span>: [<span class="string">"1m"</span>, <span class="string">"1h"</span>, <span class="string">"1d"</span>, <span class="string">"1w"</span>],
|
||||||
|
<span class="key">"derived_timeframes"</span>: [<span class="string">"5m"</span>, <span class="string">"15m"</span>, <span class="string">"4h"</span>, ...],
|
||||||
|
<span class="key">"timeframes"</span>: [<span class="string">"1m"</span>, <span class="string">"1h"</span>, ..., <span class="string">"5m"</span>, <span class="string">"15m"</span>, ...],
|
||||||
|
<span class="key">"ready"</span>: <span class="bool">true</span>
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ GET /health ============ -->
|
||||||
|
<section id="health">
|
||||||
|
<h2>健康检查</h2>
|
||||||
|
<div class="endpoint open">
|
||||||
|
<div class="endpoint-header" onclick="this.parentElement.classList.toggle('open')">
|
||||||
|
<span class="method get">GET</span>
|
||||||
|
<span class="endpoint-path">/health</span>
|
||||||
|
<span class="endpoint-desc">存活检查</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint-body">
|
||||||
|
<p>返回服务健康状态,与 <code>/</code> 相同结构,适合负载均衡探测器。</p>
|
||||||
|
<h3>响应</h3>
|
||||||
|
<pre>{
|
||||||
|
<span class="key">"status"</span>: <span class="string">"ok"</span>,
|
||||||
|
<span class="key">"exchange"</span>: <span class="string">"binance"</span>,
|
||||||
|
<span class="key">"symbols"</span>: [<span class="string">"BTC/USDT:USDT"</span>, ...],
|
||||||
|
<span class="key">"ready"</span>: <span class="bool">true</span>,
|
||||||
|
...
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ GET /timeframes ============ -->
|
||||||
|
<section id="timeframes">
|
||||||
|
<h2>可用周期</h2>
|
||||||
|
<div class="endpoint open">
|
||||||
|
<div class="endpoint-header" onclick="this.parentElement.classList.toggle('open')">
|
||||||
|
<span class="method get">GET</span>
|
||||||
|
<span class="endpoint-path">/timeframes</span>
|
||||||
|
<span class="endpoint-desc">列出所有时间周期</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint-body">
|
||||||
|
<p>返回基础周期(交易所直接拉取)和衍生周期(合成生成)的完整列表。</p>
|
||||||
|
<h3>响应</h3>
|
||||||
|
<pre>{
|
||||||
|
<span class="key">"base_timeframes"</span>: [<span class="string">"1m"</span>, <span class="string">"1h"</span>, <span class="string">"1d"</span>, <span class="string">"1w"</span>],
|
||||||
|
<span class="key">"derived_timeframes"</span>: [<span class="string">"5m"</span>, <span class="string">"15m"</span>, <span class="string">"4h"</span>, ...],
|
||||||
|
<span class="key">"timeframes"</span>: [<span class="string">"1m"</span>, <span class="string">"1h"</span>, ..., <span class="string">"5m"</span>, <span class="string">"15m"</span>, ...]
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ GET /api/candles ============ -->
|
||||||
|
<section id="candles">
|
||||||
|
<h2>查询 K 线</h2>
|
||||||
|
<div class="endpoint open">
|
||||||
|
<div class="endpoint-header" onclick="this.parentElement.classList.toggle('open')">
|
||||||
|
<span class="method get">GET</span>
|
||||||
|
<span class="endpoint-path">/api/candles</span>
|
||||||
|
<span class="endpoint-desc">获取 OHLCV K 线数据</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint-body">
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr><th>参数</th><th>类型</th><th>必填</th><th>说明</th></tr>
|
||||||
|
<tr>
|
||||||
|
<td>symbol</td>
|
||||||
|
<td><span class="type">string</span></td>
|
||||||
|
<td>是</td>
|
||||||
|
<td>交易对,如 <code>BTC/USDT:USDT</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>tf</td>
|
||||||
|
<td><span class="type">string</span></td>
|
||||||
|
<td>否</td>
|
||||||
|
<td>时间周期,默认 <code>1m</code>。支持基础及衍生周期</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>start</td>
|
||||||
|
<td><span class="type-num">int</span></td>
|
||||||
|
<td class="optional">可选</td>
|
||||||
|
<td>开始时间戳(毫秒)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>end</td>
|
||||||
|
<td><span class="type-num">int</span></td>
|
||||||
|
<td class="optional">可选</td>
|
||||||
|
<td>结束时间戳(毫秒)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>limit</td>
|
||||||
|
<td><span class="type-num">int</span></td>
|
||||||
|
<td class="optional">可选</td>
|
||||||
|
<td>限制返回的 K 线数量(返回最后 N 根)</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="note">若不传 start/end,返回内存中全部数据(可能很多),建议搭配 limit 使用。</div>
|
||||||
|
|
||||||
|
<h3>请求示例</h3>
|
||||||
|
<pre><span class="comment"># 获取 BTC 最近 100 根 5 分钟 K 线</span>
|
||||||
|
GET /api/candles?symbol=BTC/USDT:USDT&tf=5m&limit=100
|
||||||
|
|
||||||
|
<span class="comment"># 指定时间范围</span>
|
||||||
|
GET /api/candles?symbol=ETH/USDT:USDT&tf=1h&start=1704067200000&end=1704153600000
|
||||||
|
|
||||||
|
<span class="comment"># 获取 4 小时周期(衍生周期)</span>
|
||||||
|
GET /api/candles?symbol=SOL/USDT:USDT&tf=4h&limit=50</pre>
|
||||||
|
|
||||||
|
<h3>响应</h3>
|
||||||
|
<p>返回 OHLCV 对象数组:</p>
|
||||||
|
<pre>[
|
||||||
|
{
|
||||||
|
<span class="key">"timestamp"</span>: <span class="num">1704067200000</span>,
|
||||||
|
<span class="key">"datetime"</span>: <span class="string">"2024-01-01T00:00:00Z"</span>,
|
||||||
|
<span class="key">"open"</span>: <span class="num">42850.12</span>,
|
||||||
|
<span class="key">"high"</span>: <span class="num">43100.00</span>,
|
||||||
|
<span class="key">"low"</span>: <span class="num">42780.50</span>,
|
||||||
|
<span class="key">"close"</span>: <span class="num">43050.80</span>,
|
||||||
|
<span class="key">"volume"</span>: <span class="num">125.34</span>
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]</pre>
|
||||||
|
|
||||||
|
<h3>字段说明</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
|
||||||
|
<tr><td>timestamp</td><td><span class="type-num">int</span></td><td>UTC 毫秒时间戳</td></tr>
|
||||||
|
<tr><td>datetime</td><td><span class="type">string</span></td><td>ISO 8601 格式(末尾 Z)</td></tr>
|
||||||
|
<tr><td>open</td><td><span class="type-num">float</span></td><td>开盘价</td></tr>
|
||||||
|
<tr><td>high</td><td><span class="type-num">float</span></td><td>最高价</td></tr>
|
||||||
|
<tr><td>low</td><td><span class="type-num">float</span></td><td>最低价</td></tr>
|
||||||
|
<tr><td>close</td><td><span class="type-num">float</span></td><td>收盘价</td></tr>
|
||||||
|
<tr><td>volume</td><td><span class="type-num">float</span></td><td>成交量</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ WebSocket ============ -->
|
||||||
|
<section id="websocket">
|
||||||
|
<h2>WebSocket 实时推送</h2>
|
||||||
|
<div class="endpoint open">
|
||||||
|
<div class="endpoint-header" onclick="this.parentElement.classList.toggle('open')">
|
||||||
|
<span class="method ws">WS</span>
|
||||||
|
<span class="endpoint-path">/ws</span>
|
||||||
|
<span class="endpoint-desc">实时 K 线订阅</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint-body">
|
||||||
|
|
||||||
|
<p>连接 WebSocket 后,通过 JSON 消息进行订阅管理。服务端在数据更新时主动推送最新 K 线。</p>
|
||||||
|
|
||||||
|
<h3>客户端 → 服务端</h3>
|
||||||
|
|
||||||
|
<div class="ws-box">
|
||||||
|
<div class="label">订阅 K 线</div>
|
||||||
|
<pre>{
|
||||||
|
<span class="key">"action"</span>: <span class="string">"subscribe"</span>,
|
||||||
|
<span class="key">"symbol"</span>: <span class="string">"BTC/USDT:USDT"</span>,
|
||||||
|
<span class="key">"timeframe"</span>: <span class="string">"1m"</span>
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ws-box">
|
||||||
|
<div class="label">取消订阅</div>
|
||||||
|
<pre>{
|
||||||
|
<span class="key">"action"</span>: <span class="string">"unsubscribe"</span>,
|
||||||
|
<span class="key">"symbol"</span>: <span class="string">"BTC/USDT:USDT"</span>,
|
||||||
|
<span class="key">"timeframe"</span>: <span class="string">"1m"</span>
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ws-box">
|
||||||
|
<div class="label">心跳 Ping</div>
|
||||||
|
<pre>{ <span class="key">"action"</span>: <span class="string">"ping"</span> }</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>服务端 → 客户端</h3>
|
||||||
|
|
||||||
|
<div class="ws-box">
|
||||||
|
<div class="label">订阅确认</div>
|
||||||
|
<pre>{
|
||||||
|
<span class="key">"type"</span>: <span class="string">"subscribed"</span>,
|
||||||
|
<span class="key">"symbol"</span>: <span class="string">"BTC/USDT:USDT"</span>,
|
||||||
|
<span class="key">"timeframe"</span>: <span class="string">"1m"</span>
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ws-box">
|
||||||
|
<div class="label">初始快照(订阅后立即推送最近 500 根 K 线)</div>
|
||||||
|
<pre>{
|
||||||
|
<span class="key">"type"</span>: <span class="string">"snapshot"</span>,
|
||||||
|
<span class="key">"symbol"</span>: <span class="string">"BTC/USDT:USDT"</span>,
|
||||||
|
<span class="key">"timeframe"</span>: <span class="string">"1m"</span>,
|
||||||
|
<span class="key">"data"</span>: [ ... ]
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ws-box">
|
||||||
|
<div class="label">K 线更新(增量推送最近 2 根)</div>
|
||||||
|
<pre>{
|
||||||
|
<span class="key">"type"</span>: <span class="string">"kline"</span>,
|
||||||
|
<span class="key">"symbol"</span>: <span class="string">"BTC/USDT:USDT"</span>,
|
||||||
|
<span class="key">"timeframe"</span>: <span class="string">"1m"</span>,
|
||||||
|
<span class="key">"data"</span>: [ ... ]
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ws-box">
|
||||||
|
<div class="label">Pong 响应</div>
|
||||||
|
<pre>{ <span class="key">"type"</span>: <span class="string">"pong"</span> }</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ws-box">
|
||||||
|
<div class="label">错误消息</div>
|
||||||
|
<pre>{ <span class="key">"type"</span>: <span class="string">"error"</span>, <span class="key">"message"</span>: <span class="string">"..."</span> }</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>JavaScript 示例</h3>
|
||||||
|
<pre><span class="comment">// 连接</span>
|
||||||
|
<span class="key">const</span> ws = <span class="string">new WebSocket("ws://localhost:9009/ws")</span>;
|
||||||
|
|
||||||
|
ws.<span class="key">onopen</span> = () => {
|
||||||
|
<span class="comment">// 订阅 BTC 1m K 线</span>
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
action: <span class="string">"subscribe"</span>,
|
||||||
|
symbol: <span class="string">"BTC/USDT:USDT"</span>,
|
||||||
|
timeframe: <span class="string">"1m"</span>
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.<span class="key">onmessage</span> = (event) => {
|
||||||
|
<span class="key">const</span> msg = JSON.parse(event.data);
|
||||||
|
<span class="key">if</span> (msg.type === <span class="string">"kline"</span>) {
|
||||||
|
console.log(msg.data); <span class="comment">// 最新 K 线数组</span>
|
||||||
|
}
|
||||||
|
};</pre>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ 时间周期参考 ============ -->
|
||||||
|
<section id="timeframes-ref">
|
||||||
|
<h2>时间周期参考</h2>
|
||||||
|
<p>以下是完整的周期对照表:</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr><th>基础周期</th><th>合成衍生周期</th></tr>
|
||||||
|
<tr><td><code>1m</code></td><td><code>2m, 3m, 4m, 5m, 10m, 15m, 20m, 25m, 30m, 45m</code></td></tr>
|
||||||
|
<tr><td><code>1h</code></td><td><code>2h, 3h, 4h, 5h, 6h, 7h, 8h, 9h, 10h, 11h, 12h, 16h, 20h</code></td></tr>
|
||||||
|
<tr><td><code>1d</code></td><td><code>2d, 3d, 4d, 5d, 6d</code></td></tr>
|
||||||
|
<tr><td><code>1w</code></td><td><code>2w, 3w</code></td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p>衍生周期由对应基础周期的 K 线通过 OHLCV 聚合合成,查询方式与基础周期完全一致。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
Chan Data Provider — Built with FastAPI + ccxt + pandas
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
<span class="comment">// 展开/折叠端点详情</span>
|
||||||
|
document.querySelectorAll('.endpoint-header').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
el.parentElement.classList.toggle('open');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
<span class="comment">// 默认展开所有端点</span>
|
||||||
|
document.querySelectorAll('.endpoint').forEach(el => el.classList.add('open'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Chan 数据提供商</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0d1117;
|
||||||
|
--surface: #161b22;
|
||||||
|
--border: #30363d;
|
||||||
|
--text: #e6edf3;
|
||||||
|
--text-secondary: #8b949e;
|
||||||
|
--accent: #58a6ff;
|
||||||
|
--green: #3fb950;
|
||||||
|
--orange: #d29922;
|
||||||
|
--red: #f85149;
|
||||||
|
--purple: #bc8cff;
|
||||||
|
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||||
|
--mono: "SF Mono", "Fira Code", "Consolas", monospace;
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: var(--font);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.6;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.container { max-width: 1000px; margin: 0 auto; padding: 32px 24px; }
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
header .brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
header .logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px; height: 44px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, #1c3d5a 0%, #2d1b5e 100%);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 20px; font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
header .brand h1 { font-size: 20px; font-weight: 600; }
|
||||||
|
header .brand h1 span { color: var(--accent); }
|
||||||
|
header .brand .sub { font-size: 12px; color: var(--text-secondary); }
|
||||||
|
.header-time {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overview cards */
|
||||||
|
.overview {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.ov-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.ov-card .ov-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; }
|
||||||
|
.ov-card .ov-value {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ov-card .ov-value.green { color: var(--green); }
|
||||||
|
.ov-card .ov-value.orange { color: var(--orange); }
|
||||||
|
.ov-card .ov-value.accent { color: var(--accent); }
|
||||||
|
.ov-card .ov-value.purple { color: var(--purple); }
|
||||||
|
|
||||||
|
/* Section */
|
||||||
|
.section {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.section-header:hover { background: rgba(255,255,255,0.02); }
|
||||||
|
.section-header h2 { font-size: 14px; font-weight: 600; }
|
||||||
|
.section-header .count-badge {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.section-body { padding: 8px 16px 12px; }
|
||||||
|
|
||||||
|
/* Status dot */
|
||||||
|
.dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px; height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dot.green { background: var(--green); box-shadow: 0 0 6px #3fb95060; }
|
||||||
|
.dot.orange { background: var(--orange); box-shadow: 0 0 6px #d2992260; }
|
||||||
|
.dot.red { background: var(--red); box-shadow: 0 0 6px #f8514960; }
|
||||||
|
.dot.gray { background: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* Symbol row */
|
||||||
|
.symbol-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid rgba(48,54,61,0.5);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.symbol-row:last-child { border-bottom: none; }
|
||||||
|
.symbol-row .name { font-family: var(--mono); font-size: 13px; min-width: 140px; }
|
||||||
|
.symbol-row .tag {
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(88,166,255,0.1);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TF chips */
|
||||||
|
.tf-list { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||||
|
.tf-chip {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.tf-chip.base { border-color: #58a6ff40; color: var(--accent); }
|
||||||
|
.tf-chip.derived { border-color: #bc8cff40; color: var(--purple); }
|
||||||
|
|
||||||
|
/* Links bar */
|
||||||
|
.links-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 16px 0 24px;
|
||||||
|
}
|
||||||
|
.links-bar a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
text-decoration: none;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.links-bar a:hover { border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
footer {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 16px 0;
|
||||||
|
margin-top: 32px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in { animation: fadeIn 0.3s ease-in; }
|
||||||
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<div class="brand">
|
||||||
|
<div class="logo">C</div>
|
||||||
|
<div>
|
||||||
|
<h1><span>Chan</span> 数据提供商</h1>
|
||||||
|
<div class="sub">加密货币 K 线数据服务</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-time" id="header-time">—</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Overview -->
|
||||||
|
<div class="overview" id="overview">
|
||||||
|
<div class="ov-card"><div class="ov-label">服务状态</div><div class="ov-value" id="ov-status">加载中...</div></div>
|
||||||
|
<div class="ov-card"><div class="ov-label">交易所</div><div class="ov-value accent" id="ov-exchange">—</div></div>
|
||||||
|
<div class="ov-card"><div class="ov-label">交易对</div><div class="ov-value" id="ov-symbols">—</div></div>
|
||||||
|
<div class="ov-card"><div class="ov-label">基础周期</div><div class="ov-value accent" id="ov-base-tf">—</div></div>
|
||||||
|
<div class="ov-card"><div class="ov-label">衍生周期</div><div class="ov-value purple" id="ov-derived-tf">—</div></div>
|
||||||
|
<div class="ov-card"><div class="ov-label">数据就绪</div><div class="ov-value" id="ov-ready">—</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Links -->
|
||||||
|
<div class="links-bar">
|
||||||
|
<a href="/api/docs">📖 API 文档</a>
|
||||||
|
<a href="/docs">📋 Swagger UI</a>
|
||||||
|
<a href="/redoc">📄 ReDoc</a>
|
||||||
|
<a href="/api/candles?symbol=BTC/USDT:USDT&tf=1m&limit=5" target="_blank">📊 BTC 1m 示例</a>
|
||||||
|
<a href="/api/candles?symbol=ETH/USDT:USDT&tf=4h&limit=10" target="_blank">📊 ETH 4h 示例</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Symbols -->
|
||||||
|
<div class="section" id="section-symbols">
|
||||||
|
<div class="section-header" onclick="this.parentElement.classList.toggle('collapsed')">
|
||||||
|
<h2>📈 交易对</h2>
|
||||||
|
<span class="count-badge" id="sym-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="section-body" id="symbol-list">
|
||||||
|
<div style="color:var(--text-secondary);font-size:13px;">加载中...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeframes -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header" onclick="this.parentElement.classList.toggle('collapsed')">
|
||||||
|
<h2>⏱ 时间周期</h2>
|
||||||
|
<span class="count-badge" id="tf-total">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<div style="margin-bottom:8px;font-size:13px;color:var(--text-secondary);">基础周期(交易所直拉)</div>
|
||||||
|
<div class="tf-list" id="base-tf-list"></div>
|
||||||
|
<div style="margin:10px 0 8px;font-size:13px;color:var(--text-secondary);">衍生周期(内存合成)</div>
|
||||||
|
<div class="tf-list" id="derived-tf-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick query -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>⚡ 快速查询</h2>
|
||||||
|
</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:end;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:4px;">交易对</div>
|
||||||
|
<select id="q-symbol" style="background:var(--bg);border:1px solid var(--border);color:var(--text);padding:6px 10px;border-radius:6px;font-family:var(--mono);font-size:13px;"></select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:4px;">周期</div>
|
||||||
|
<select id="q-tf" style="background:var(--bg);border:1px solid var(--border);color:var(--text);padding:6px 10px;border-radius:6px;font-family:var(--mono);font-size:13px;"></select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:4px;">数量</div>
|
||||||
|
<select id="q-limit" style="background:var(--bg);border:1px solid var(--border);color:var(--text);padding:6px 10px;border-radius:6px;font-family:var(--mono);font-size:13px;">
|
||||||
|
<option>5</option><option selected>10</option><option>20</option><option>50</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button onclick="quickQuery()" style="background:var(--accent);color:#fff;border:none;padding:6px 18px;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600;">查询</button>
|
||||||
|
</div>
|
||||||
|
<pre id="q-result" style="margin-top:10px;display:none;"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
Chan Data Provider · Built with FastAPI + ccxt + pandas ·
|
||||||
|
更新于 <span id="footer-time">—</span>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let healthData = null;
|
||||||
|
|
||||||
|
function fmtTime(ts) {
|
||||||
|
return new Date(ts).toLocaleString('zh-CN', { timeZone: 'UTC', hour12: false }) + ' UTC';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHealth() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/health');
|
||||||
|
healthData = await res.json();
|
||||||
|
renderHealth(healthData);
|
||||||
|
} catch {
|
||||||
|
document.getElementById('ov-status').textContent = '无法连接';
|
||||||
|
document.getElementById('ov-status').style.color = 'var(--red)';
|
||||||
|
document.getElementById('ov-ready').textContent = '断开';
|
||||||
|
document.getElementById('ov-ready').style.color = 'var(--red)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHealth(d) {
|
||||||
|
const now = Date.now();
|
||||||
|
document.getElementById('header-time').textContent = fmtTime(now);
|
||||||
|
document.getElementById('footer-time').textContent = fmtTime(now);
|
||||||
|
|
||||||
|
// Overview
|
||||||
|
const statusEl = document.getElementById('ov-status');
|
||||||
|
statusEl.textContent = '运行中';
|
||||||
|
statusEl.style.color = 'var(--green)';
|
||||||
|
|
||||||
|
document.getElementById('ov-exchange').textContent = d.exchange || '—';
|
||||||
|
|
||||||
|
const symCount = (d.symbols || []).length;
|
||||||
|
const symEl = document.getElementById('ov-symbols');
|
||||||
|
symEl.textContent = symCount + ' 个';
|
||||||
|
symEl.style.color = 'var(--accent)';
|
||||||
|
|
||||||
|
document.getElementById('ov-base-tf').textContent = (d.base_timeframes || []).length + ' 个';
|
||||||
|
document.getElementById('ov-derived-tf').textContent = (d.derived_timeframes || []).length + ' 个';
|
||||||
|
|
||||||
|
const readyEl = document.getElementById('ov-ready');
|
||||||
|
if (d.ready) {
|
||||||
|
readyEl.textContent = '已就绪';
|
||||||
|
readyEl.style.color = 'var(--green)';
|
||||||
|
} else {
|
||||||
|
readyEl.textContent = '同步中...';
|
||||||
|
readyEl.style.color = 'var(--orange)';
|
||||||
|
setTimeout(loadHealth, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Symbols
|
||||||
|
const symList = document.getElementById('symbol-list');
|
||||||
|
const symCountEl = document.getElementById('sym-count');
|
||||||
|
symCountEl.textContent = symCount;
|
||||||
|
if (d.symbols && d.symbols.length > 0) {
|
||||||
|
symList.innerHTML = d.symbols.map(s => `
|
||||||
|
<div class="symbol-row">
|
||||||
|
<span class="dot green"></span>
|
||||||
|
<span class="name">${s}</span>
|
||||||
|
<span class="tag">${d.exchange || '—'}</span>
|
||||||
|
<a href="/api/candles?symbol=${encodeURIComponent(s)}&tf=1m&limit=5" target="_blank" style="margin-left:auto;font-size:12px;color:var(--accent);text-decoration:none;">1m →</a>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} else {
|
||||||
|
symList.innerHTML = '<div style="color:var(--text-secondary);font-size:13px;">暂无交易对</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeframes
|
||||||
|
document.getElementById('tf-total').textContent = (d.timeframes || []).length;
|
||||||
|
|
||||||
|
const baseList = document.getElementById('base-tf-list');
|
||||||
|
if (d.base_timeframes) {
|
||||||
|
baseList.innerHTML = d.base_timeframes.map(t => `<span class="tf-chip base">${t}</span>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const derivedList = document.getElementById('derived-tf-list');
|
||||||
|
if (d.derived_timeframes) {
|
||||||
|
derivedList.innerHTML = d.derived_timeframes.map(t => `<span class="tf-chip derived">${t}</span>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate query selects
|
||||||
|
const symSelect = document.getElementById('q-symbol');
|
||||||
|
if (d.symbols && symSelect.options.length === 0) {
|
||||||
|
d.symbols.forEach(s => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = s;
|
||||||
|
opt.textContent = s;
|
||||||
|
symSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tfSelect = document.getElementById('q-tf');
|
||||||
|
if (d.timeframes && tfSelect.options.length === 0) {
|
||||||
|
d.timeframes.forEach(t => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = t;
|
||||||
|
opt.textContent = t;
|
||||||
|
tfSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function quickQuery() {
|
||||||
|
const symbol = document.getElementById('q-symbol').value;
|
||||||
|
const tf = document.getElementById('q-tf').value;
|
||||||
|
const limit = document.getElementById('q-limit').value;
|
||||||
|
const url = `/api/candles?symbol=${encodeURIComponent(symbol)}&tf=${tf}&limit=${limit}`;
|
||||||
|
const pre = document.getElementById('q-result');
|
||||||
|
pre.style.display = 'block';
|
||||||
|
pre.textContent = '查询中...';
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
const data = await res.json();
|
||||||
|
pre.textContent = JSON.stringify(data, null, 2);
|
||||||
|
} catch {
|
||||||
|
pre.textContent = '查询失败';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadHealth();
|
||||||
|
setInterval(loadHealth, 10000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+24
-13
@@ -499,7 +499,7 @@ class DataProvider:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
def initialize(self) -> None:
|
def initialize(self) -> None:
|
||||||
"""快速启动:仅加载本地 CSV 到内存,立即设 _ready 让服务可用。后台再补拉交易所数据。"""
|
"""快速启动:仅加载本地磁盘已有数据到内存,立即设 _ready 让服务可用。后台再补拉交易所数据。"""
|
||||||
logger.info("开始初始化数据提供商(仅加载本地数据)")
|
logger.info("开始初始化数据提供商(仅加载本地数据)")
|
||||||
for symbol in self.symbols:
|
for symbol in self.symbols:
|
||||||
for timeframe in self.timeframes:
|
for timeframe in self.timeframes:
|
||||||
@@ -922,8 +922,15 @@ def create_app(provider: DataProvider) -> FastAPI:
|
|||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
ws_manager.set_loop(loop)
|
ws_manager.set_loop(loop)
|
||||||
provider.on_update(_on_data_update)
|
provider.on_update(_on_data_update)
|
||||||
|
# 快速加载本地数据后立即就绪,不阻塞服务启动
|
||||||
await loop.run_in_executor(None, provider.initialize)
|
await loop.run_in_executor(None, provider.initialize)
|
||||||
provider.start_background_workers()
|
provider.start_background_workers()
|
||||||
|
# 后台拉取历史数据补齐(不阻塞 HTTP/WS 服务)
|
||||||
|
threading.Thread(
|
||||||
|
target=provider.run_initial_history_fetch,
|
||||||
|
name="initial-history-fetch",
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
@@ -974,18 +981,15 @@ def create_app(provider: DataProvider) -> FastAPI:
|
|||||||
data = provider.get_klines(symbol=symbol, timeframe=tf, start_time=start, end_time=end, limit=limit)
|
data = provider.get_klines(symbol=symbol, timeframe=tf, start_time=start, end_time=end, limit=limit)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@app.get("/")
|
homepage_path = Path(__file__).resolve().parent / "homepage.html"
|
||||||
async def root() -> Dict[str, object]:
|
docs_path = Path(__file__).resolve().parent / "api_docs.html"
|
||||||
"""根路径:服务名、交易所、交易对与可用周期(含 ready 标志)。"""
|
|
||||||
return {
|
@app.get("/", response_class=HTMLResponse)
|
||||||
"service": "Data Provider",
|
async def root():
|
||||||
"exchange": provider.exchange_name,
|
"""服务主页。"""
|
||||||
"symbols": provider.symbols,
|
if homepage_path.exists():
|
||||||
"base_timeframes": provider.timeframes,
|
return HTMLResponse(content=homepage_path.read_text(encoding="utf-8"))
|
||||||
"derived_timeframes": provider.get_derived_timeframes(),
|
return HTMLResponse(content="<h1>主页页面未找到</h1>", status_code=404)
|
||||||
"timeframes": provider.get_available_timeframes(),
|
|
||||||
"ready": provider.is_ready(),
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.get("/api-manual", response_class=HTMLResponse)
|
@app.get("/api-manual", response_class=HTMLResponse)
|
||||||
async def api_manual():
|
async def api_manual():
|
||||||
@@ -1581,6 +1585,13 @@ document.querySelectorAll('#manualTabs .nav-link').forEach(btn => {{
|
|||||||
finally:
|
finally:
|
||||||
await ws_manager.disconnect(ws)
|
await ws_manager.disconnect(ws)
|
||||||
|
|
||||||
|
@app.get("/api/docs", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
async def api_docs():
|
||||||
|
"""返回自定义 API 文档页面。"""
|
||||||
|
if docs_path.exists():
|
||||||
|
return HTMLResponse(content=docs_path.read_text(encoding="utf-8"))
|
||||||
|
return HTMLResponse(content="<h1>API 文档页面未找到</h1>", status_code=404)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""
|
||||||
|
StructureZone 系统单元测试
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from ChanZone import (
|
||||||
|
RawZonePoint, StructureZone, StructureZoneConfig,
|
||||||
|
cluster_raw_points, build_structure_zones, _calc_strength, _calc_confidence, _calc_recency,
|
||||||
|
extract_raw_points_from_serialized, analyze_structure_zones_from_serialized,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestClusterRawPoints:
|
||||||
|
"""聚类算法测试"""
|
||||||
|
|
||||||
|
def test_empty_points(self):
|
||||||
|
config = StructureZoneConfig()
|
||||||
|
result = cluster_raw_points([], config)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_single_point_filtered(self):
|
||||||
|
"""单点被 min_overlap 过滤"""
|
||||||
|
config = StructureZoneConfig(min_overlap_for_zone=2)
|
||||||
|
points = [RawZonePoint(price=100, timeframe='5m', structure_type='bi_zhongshu',
|
||||||
|
boundary_type='ZG', source_zs_id=0, is_sure=True)]
|
||||||
|
result = cluster_raw_points(points, config)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_two_nearby_points_merge(self):
|
||||||
|
"""相邻价格点归为一类"""
|
||||||
|
config = StructureZoneConfig(cluster_radius_pct=1.0, min_overlap_for_zone=2)
|
||||||
|
points = [
|
||||||
|
RawZonePoint(price=100, timeframe='5m', structure_type='bi_zhongshu',
|
||||||
|
boundary_type='ZG', source_zs_id=0, is_sure=True),
|
||||||
|
RawZonePoint(price=100.5, timeframe='15m', structure_type='xd_zhongshu',
|
||||||
|
boundary_type='ZD', source_zs_id=0, is_sure=True),
|
||||||
|
]
|
||||||
|
result = cluster_raw_points(points, config)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert len(result[0]) == 2
|
||||||
|
|
||||||
|
def test_two_distant_points_separate(self):
|
||||||
|
"""远离的价格点不归为一类"""
|
||||||
|
config = StructureZoneConfig(cluster_radius_pct=0.1, min_overlap_for_zone=1) # 先用 1 看聚类
|
||||||
|
points = [
|
||||||
|
RawZonePoint(price=100, timeframe='5m', structure_type='bi_zhongshu',
|
||||||
|
boundary_type='ZG', source_zs_id=0, is_sure=True),
|
||||||
|
RawZonePoint(price=110, timeframe='15m', structure_type='xd_zhongshu',
|
||||||
|
boundary_type='ZD', source_zs_id=0, is_sure=True),
|
||||||
|
]
|
||||||
|
# 先用 min_overlap=2 确认被过滤
|
||||||
|
config2 = StructureZoneConfig(cluster_radius_pct=0.1, min_overlap_for_zone=2)
|
||||||
|
result = cluster_raw_points(points, config2)
|
||||||
|
assert result == [] # 两个单独点,都不够 min_overlap
|
||||||
|
|
||||||
|
def test_multi_tf_convergence(self):
|
||||||
|
"""多个时间周期在相同价格区间聚合"""
|
||||||
|
config = StructureZoneConfig(cluster_radius_pct=1.0, min_overlap_for_zone=2)
|
||||||
|
points = []
|
||||||
|
for tf in ['5m', '15m', '30m', '1h']:
|
||||||
|
for btype in ['ZG', 'ZD']:
|
||||||
|
points.append(RawZonePoint(price=100 + abs(hash(tf + btype)) % 3 * 0.1,
|
||||||
|
timeframe=tf, structure_type='bi_zhongshu',
|
||||||
|
boundary_type=btype, source_zs_id=0, is_sure=True))
|
||||||
|
result = cluster_raw_points(points, config)
|
||||||
|
assert len(result) >= 1
|
||||||
|
# 所有点应该聚合在一起(价差很小)
|
||||||
|
total = sum(len(c) for c in result)
|
||||||
|
assert total == len(points)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildStructureZones:
|
||||||
|
"""评分和构建测试"""
|
||||||
|
|
||||||
|
def _make_cluster(self, prices, tf='5m', st='bi_zhongshu'):
|
||||||
|
return [RawZonePoint(price=p, timeframe=tf, structure_type=st,
|
||||||
|
boundary_type='ZG', source_zs_id=0, is_sure=True,
|
||||||
|
candle_time='2025-01-01T00:00:00')
|
||||||
|
for p in prices]
|
||||||
|
|
||||||
|
def test_zone_type_support(self):
|
||||||
|
"""当前价上方区间是阻力,下方是支撑"""
|
||||||
|
config = StructureZoneConfig()
|
||||||
|
cluster = self._make_cluster([90, 92])
|
||||||
|
ema52 = {'5m': 100}
|
||||||
|
zones = build_structure_zones([cluster], current_price=100, ema52_values=ema52,
|
||||||
|
latest_candle_time='2025-01-01T01:00:00', config=config)
|
||||||
|
assert len(zones) == 1
|
||||||
|
assert zones[0].zone_type == 'support' # 在价格下方
|
||||||
|
|
||||||
|
def test_zone_type_resistance(self):
|
||||||
|
config = StructureZoneConfig()
|
||||||
|
cluster = self._make_cluster([110, 112])
|
||||||
|
ema52 = {'5m': 100}
|
||||||
|
zones = build_structure_zones([cluster], current_price=100, ema52_values=ema52,
|
||||||
|
latest_candle_time='2025-01-01T01:00:00', config=config)
|
||||||
|
assert len(zones) == 1
|
||||||
|
assert zones[0].zone_type == 'resistance'
|
||||||
|
|
||||||
|
def test_zone_type_neutral(self):
|
||||||
|
config = StructureZoneConfig()
|
||||||
|
cluster = self._make_cluster([95, 105])
|
||||||
|
ema52 = {'5m': 100}
|
||||||
|
zones = build_structure_zones([cluster], current_price=100, ema52_values=ema52,
|
||||||
|
latest_candle_time='2025-01-01T01:00:00', config=config)
|
||||||
|
assert len(zones) == 1
|
||||||
|
assert zones[0].zone_type == 'neutral'
|
||||||
|
|
||||||
|
def test_strength_score_range(self):
|
||||||
|
"""评分在 0-100 之间"""
|
||||||
|
config = StructureZoneConfig()
|
||||||
|
cluster = self._make_cluster([100, 102, 104], '5m', 'bi_zhongshu')
|
||||||
|
cluster += self._make_cluster([100.5, 102.5], '15m', 'xd_zhongshu')
|
||||||
|
ema52 = {'5m': 0, '15m': 0}
|
||||||
|
zones = build_structure_zones([cluster], current_price=110, ema52_values=ema52,
|
||||||
|
latest_candle_time='2025-01-01T01:00:00', config=config)
|
||||||
|
assert len(zones) == 1
|
||||||
|
assert 0 <= zones[0].strength_score <= 100
|
||||||
|
|
||||||
|
def test_ema52_aligned_true(self):
|
||||||
|
"""EMA52 落在区间内"""
|
||||||
|
config = StructureZoneConfig()
|
||||||
|
cluster = self._make_cluster([95, 105])
|
||||||
|
ema52 = {'5m': 100}
|
||||||
|
zones = build_structure_zones([cluster], current_price=110, ema52_values=ema52,
|
||||||
|
latest_candle_time='2025-01-01T01:00:00', config=config)
|
||||||
|
assert zones[0].ema52_aligned is True
|
||||||
|
|
||||||
|
def test_ema52_aligned_false(self):
|
||||||
|
"""EMA52 不在区间内"""
|
||||||
|
config = StructureZoneConfig()
|
||||||
|
cluster = self._make_cluster([95, 105])
|
||||||
|
ema52 = {'5m': 120}
|
||||||
|
zones = build_structure_zones([cluster], current_price=110, ema52_values=ema52,
|
||||||
|
latest_candle_time='2025-01-01T01:00:00', config=config)
|
||||||
|
assert zones[0].ema52_aligned is False
|
||||||
|
|
||||||
|
def test_confidence_range(self):
|
||||||
|
"""置信度在 0-1 之间"""
|
||||||
|
config = StructureZoneConfig()
|
||||||
|
cluster = self._make_cluster([100, 101, 102, 103])
|
||||||
|
ema52 = {}
|
||||||
|
zones = build_structure_zones([cluster], current_price=110, ema52_values=ema52,
|
||||||
|
latest_candle_time='2025-01-01T01:00:00', config=config)
|
||||||
|
assert 0 <= zones[0].confidence <= 1
|
||||||
|
|
||||||
|
def test_max_zones_cap(self):
|
||||||
|
"""max_zones 限制返回数量"""
|
||||||
|
config = StructureZoneConfig(max_zones=3)
|
||||||
|
clusters = [self._make_cluster([100 + i * 10, 100 + i * 10 + 2]) for i in range(10)]
|
||||||
|
ema52 = {}
|
||||||
|
zones = build_structure_zones(clusters, current_price=150, ema52_values=ema52,
|
||||||
|
latest_candle_time='2025-01-01T01:00:00', config=config)
|
||||||
|
assert len(zones) <= 3
|
||||||
|
|
||||||
|
def test_sorted_by_strength(self):
|
||||||
|
"""按 strength 降序排列"""
|
||||||
|
config = StructureZoneConfig(max_zones=0)
|
||||||
|
# 创建一个有更多重叠的聚类(更强)和一个较弱的聚类
|
||||||
|
cluster_strong = self._make_cluster([100, 101, 102, 103, 104]) # 5 点
|
||||||
|
cluster_weak = self._make_cluster([200, 201]) # 2 点
|
||||||
|
ema52 = {'5m': 0}
|
||||||
|
zones = build_structure_zones([cluster_weak, cluster_strong], current_price=150,
|
||||||
|
ema52_values=ema52,
|
||||||
|
latest_candle_time='2025-01-01T01:00:00', config=config)
|
||||||
|
assert zones[0].strength_score >= zones[-1].strength_score
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractFromSerialized:
|
||||||
|
"""从序列化数据提取测试"""
|
||||||
|
|
||||||
|
def test_empty_analyses(self):
|
||||||
|
config = StructureZoneConfig()
|
||||||
|
points = extract_raw_points_from_serialized({}, {}, config)
|
||||||
|
assert points == []
|
||||||
|
|
||||||
|
def test_basic_extraction(self):
|
||||||
|
analyses = {
|
||||||
|
'5m': {
|
||||||
|
'bi_zs_list': [
|
||||||
|
{'zg': 100, 'zd': 95, 'gg': 102, 'dd': 93, 'is_sure': True, 'end_time': '2025-01-01T00:00'},
|
||||||
|
],
|
||||||
|
'zs_list': [],
|
||||||
|
},
|
||||||
|
'15m': {
|
||||||
|
'bi_zs_list': [],
|
||||||
|
'zs_list': [
|
||||||
|
{'zg': 105, 'zd': 98, 'gg': 107, 'dd': 96, 'is_sure': True, 'end_time': '2025-01-01T00:00'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ema52 = {'5m': 101, '15m': 103}
|
||||||
|
config = StructureZoneConfig(zone_timeframes=['5m', '15m'])
|
||||||
|
points = extract_raw_points_from_serialized(analyses, ema52, config)
|
||||||
|
# bi_zs: 4 points (ZG/ZD/GG/DD) + zs_list: 4 points + 2 ema52 = 10
|
||||||
|
assert len(points) == 10
|
||||||
|
|
||||||
|
def test_unsure_filtered(self):
|
||||||
|
analyses = {
|
||||||
|
'5m': {
|
||||||
|
'bi_zs_list': [
|
||||||
|
{'zg': 100, 'zd': 95, 'gg': 102, 'dd': 93, 'is_sure': False},
|
||||||
|
],
|
||||||
|
'zs_list': [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ema52 = {}
|
||||||
|
config = StructureZoneConfig(zone_timeframes=['5m'])
|
||||||
|
points = extract_raw_points_from_serialized(analyses, ema52, config)
|
||||||
|
assert len(points) == 0 # is_sure=False 被过滤
|
||||||
|
|
||||||
|
def test_timeframe_filtering(self):
|
||||||
|
"""仅提取 config.zone_timeframes 中的周期"""
|
||||||
|
analyses = {
|
||||||
|
'5m': {
|
||||||
|
'bi_zs_list': [
|
||||||
|
{'zg': 100, 'zd': 95, 'gg': 102, 'dd': 93, 'is_sure': True},
|
||||||
|
],
|
||||||
|
'zs_list': [],
|
||||||
|
},
|
||||||
|
'1h': {
|
||||||
|
'bi_zs_list': [
|
||||||
|
{'zg': 200, 'zd': 190, 'gg': 205, 'dd': 188, 'is_sure': True},
|
||||||
|
],
|
||||||
|
'zs_list': [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ema52 = {'5m': 101, '1h': 195}
|
||||||
|
config = StructureZoneConfig(zone_timeframes=['5m']) # 只取 5m
|
||||||
|
points = extract_raw_points_from_serialized(analyses, ema52, config)
|
||||||
|
# 只有 5m: 4 bi_zs + 1 ema52 = 5
|
||||||
|
assert len(points) == 5
|
||||||
|
assert all(p.timeframe == '5m' for p in points)
|
||||||
|
|
||||||
|
|
||||||
|
class TestScoringHelpers:
|
||||||
|
"""评分辅助函数测试"""
|
||||||
|
|
||||||
|
def test_calc_recency_same_time(self):
|
||||||
|
"""同一时间的 recency = 1.0"""
|
||||||
|
score = _calc_recency('2025-01-01T00:00:00', '2025-01-01T00:00:00', 50)
|
||||||
|
assert score == 1.0
|
||||||
|
|
||||||
|
def test_calc_recency_invalid(self):
|
||||||
|
"""无效时间的 recency = 0.5"""
|
||||||
|
score = _calc_recency(None, '2025-01-01T00:00:00', 50)
|
||||||
|
assert score == 0.5
|
||||||
|
|
||||||
|
def test_calc_confidence_high(self):
|
||||||
|
"""高重叠数 = 高置信度"""
|
||||||
|
conf = _calc_confidence(6, 3, [])
|
||||||
|
assert conf > 0.7
|
||||||
|
|
||||||
|
def test_calc_confidence_low(self):
|
||||||
|
"""低重叠数 = 低置信度"""
|
||||||
|
conf = _calc_confidence(2, 1, [])
|
||||||
|
assert conf < 0.7
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnalyzeFromSerialized:
|
||||||
|
"""端到端测试(从序列化数据到 StructureZone)"""
|
||||||
|
|
||||||
|
def test_end_to_end(self):
|
||||||
|
analyses = {
|
||||||
|
'5m': {
|
||||||
|
'bi_zs_list': [
|
||||||
|
{'zg': 100, 'zd': 95, 'gg': 102, 'dd': 93, 'is_sure': True, 'end_time': '2025-01-01T00:00'},
|
||||||
|
],
|
||||||
|
'zs_list': [],
|
||||||
|
},
|
||||||
|
'15m': {
|
||||||
|
'bi_zs_list': [
|
||||||
|
{'zg': 101, 'zd': 96, 'gg': 103, 'dd': 94, 'is_sure': True, 'end_time': '2025-01-01T00:01'},
|
||||||
|
],
|
||||||
|
'zs_list': [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ema52_dict = {'5m': 100.5, '15m': 99.5}
|
||||||
|
config = StructureZoneConfig(zone_timeframes=['5m', '15m'], cluster_radius_pct=2.0)
|
||||||
|
zones = analyze_structure_zones_from_serialized(analyses, ema52_dict, 110, config)
|
||||||
|
# 两个 TF 的 BI_ZS 价格接近,应聚合成一个区间
|
||||||
|
assert len(zones) >= 1
|
||||||
|
zone = zones[0]
|
||||||
|
assert zone.zone_type == 'support' # 价格在 93-103,current_price=110
|
||||||
|
assert '5m' in zone.timeframes
|
||||||
|
assert '15m' in zone.timeframes
|
||||||
|
assert zone.structure_types == ['bi_zhongshu']
|
||||||
|
assert 0 <= zone.strength_score <= 100
|
||||||
|
assert 0 <= zone.confidence <= 1
|
||||||
|
|
||||||
|
def test_empty_returns_empty(self):
|
||||||
|
zones = analyze_structure_zones_from_serialized({}, {}, 100)
|
||||||
|
assert zones == []
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
pytest.main([__file__, '-v'])
|
||||||
+130
-2
@@ -12,6 +12,7 @@ import io
|
|||||||
import base64
|
import base64
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from pytz import timezone
|
from pytz import timezone
|
||||||
import talib.abstract as ta
|
import talib.abstract as ta
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -22,6 +23,7 @@ from ChanLun import ChanLun, TF_DF
|
|||||||
from ChanEnum import Chan_BI_DIR, Chan_SEG_DIR, Chan_KLC_FX, Chan_FX_TYPE, Chan_MACDSEG_DIR, Chan_MACDHISTSET_DIR
|
from ChanEnum import Chan_BI_DIR, Chan_SEG_DIR, Chan_KLC_FX, Chan_FX_TYPE, Chan_MACDSEG_DIR, Chan_MACDHISTSET_DIR
|
||||||
from cn_stock_data import ChinaStockData
|
from cn_stock_data import ChinaStockData
|
||||||
from ChanMACD import ChanMACD
|
from ChanMACD import ChanMACD
|
||||||
|
from ChanZone import StructureZoneConfig, analyze_structure_zones_from_serialized
|
||||||
|
|
||||||
# 添加买卖点枚举类型
|
# 添加买卖点枚举类型
|
||||||
class TRADE_POINT_TYPE:
|
class TRADE_POINT_TYPE:
|
||||||
@@ -48,8 +50,23 @@ china_stock = ChinaStockData()
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DATA_SERVICE_URL = os.environ.get("DATA_SERVICE_URL", os.environ.get("DATASVC_URL", "http://127.0.0.1:9009"))
|
# 结构价值区缓存: {tf_name: {'data': ..., 'expires': timestamp}}
|
||||||
#DATA_SERVICE_URL = os.environ.get("DATA_SERVICE_URL", os.environ.get("DATASVC_URL", "http://192.168.1.9:9009"))
|
_zone_cache = {}
|
||||||
|
|
||||||
|
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分钟
|
||||||
|
|
||||||
|
DATA_SERVICE_URL = os.environ.get("DATA_SERVICE_URL", os.environ.get("DATASVC_URL", "http://103.179.242.166"))
|
||||||
|
|
||||||
DEFAULT_TIMEFRAME_LABELS = OrderedDict([
|
DEFAULT_TIMEFRAME_LABELS = OrderedDict([
|
||||||
("1m", "1分钟"),
|
("1m", "1分钟"),
|
||||||
("3m", "3分钟"),
|
("3m", "3分钟"),
|
||||||
@@ -183,6 +200,8 @@ def _fetch_kl_from_datasvc(symbol, timeframe, start_ms=None, end_ms=None, limit=
|
|||||||
params["start"] = int(start_ms)
|
params["start"] = int(start_ms)
|
||||||
if end_ms is not None:
|
if end_ms is not None:
|
||||||
params["end"] = int(end_ms)
|
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 = requests.get(f"{DATA_SERVICE_URL}/api/candles", params=params, timeout=10)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
@@ -1776,6 +1795,115 @@ def analyze():
|
|||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# 结构价值区分析(Structure Zone)—— 独立拉取多周期数据,缓存避免重复请求
|
||||||
|
zone_timeframes_str = request.args.get('zone_timeframes', '')
|
||||||
|
zone_kl_lines = int(request.args.get('zone_kl_lines', 1000))
|
||||||
|
try:
|
||||||
|
zone_config = StructureZoneConfig(kl_lines_per_tf=zone_kl_lines)
|
||||||
|
if zone_timeframes_str:
|
||||||
|
zone_config.zone_timeframes = [t.strip() for t in zone_timeframes_str.split(',') if t.strip()]
|
||||||
|
analyses = {}
|
||||||
|
ema52_dict = {}
|
||||||
|
latest_close = 0.0
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
def _fetch_single_tf_zone(tf_name):
|
||||||
|
"""单个时间周期的结构区数据拉取(线程安全)"""
|
||||||
|
cache_key = f"{symbol}:{tf_name}:{zone_kl_lines}"
|
||||||
|
cached = _zone_cache.get(cache_key)
|
||||||
|
if cached and cached['expires'] > now:
|
||||||
|
print(f" 结构区缓存命中: {tf_name}")
|
||||||
|
return {
|
||||||
|
'tf_name': tf_name,
|
||||||
|
'analyses': cached['analyses'],
|
||||||
|
'ema52': cached['ema52'],
|
||||||
|
'close': cached.get('close', 0.0),
|
||||||
|
'cached': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
tf_df = get_kl_data(symbol, tf_name, limit=zone_kl_lines)
|
||||||
|
if tf_df is None or len(tf_df) == 0:
|
||||||
|
return None
|
||||||
|
tf_df = add_indicators(tf_df)
|
||||||
|
tf_analysis = analyze_chan(tf_df, symbol, tf_name)
|
||||||
|
zs_serialized = [{
|
||||||
|
'start_time': (zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat()) if zs.start_klc else None,
|
||||||
|
'end_time': (zs.end_klc.end_time if isinstance(zs.end_klc.end_time, str) else zs.end_klc.end_time.astimezone(client_tz).isoformat()) if zs.end_klc else None,
|
||||||
|
'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd,
|
||||||
|
'is_sure': zs.is_sure
|
||||||
|
} for zs in tf_analysis.get('zs_list', []) if zs.is_sure]
|
||||||
|
bi_zs_serialized = [{
|
||||||
|
'start_time': ((zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat()) if getattr(zs.start_klc, 'end_time', None) else (zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat())),
|
||||||
|
'end_time': (zs.end_time if isinstance(zs.end_time, str) else zs.end_time.astimezone(client_tz).isoformat()) if getattr(zs, 'end_time', None) else None,
|
||||||
|
'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd,
|
||||||
|
'is_sure': bool(getattr(zs, 'is_sure', False))
|
||||||
|
} for zs in tf_analysis.get('bi_zs_list', []) if getattr(zs, 'is_sure', False)]
|
||||||
|
last_ema = tf_df['ema52'].iloc[-1] if 'ema52' in tf_df.columns else 0
|
||||||
|
ema_val = float(last_ema) if last_ema and last_ema > 0 else None
|
||||||
|
last_close = float(tf_df['close'].iloc[-1])
|
||||||
|
tf_result = {
|
||||||
|
'tf_name': tf_name,
|
||||||
|
'analyses': {'zs_list': zs_serialized, 'bi_zs_list': bi_zs_serialized},
|
||||||
|
'ema52': ema_val,
|
||||||
|
'close': last_close,
|
||||||
|
'cached': False,
|
||||||
|
}
|
||||||
|
# 写入缓存
|
||||||
|
_zone_cache[cache_key] = {
|
||||||
|
'analyses': tf_result['analyses'],
|
||||||
|
'ema52': ema_val,
|
||||||
|
'close': last_close,
|
||||||
|
'expires': now + _zone_cache_ttl(tf_name),
|
||||||
|
}
|
||||||
|
print(f" 结构区数据: {tf_name} -> zs={len(zs_serialized)}, bi_zs={len(bi_zs_serialized)}, ema52={ema_val}")
|
||||||
|
return tf_result
|
||||||
|
except Exception as e:
|
||||||
|
print(f" 结构区 {tf_name} 拉取失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=len(zone_config.zone_timeframes)) as executor:
|
||||||
|
futures = {executor.submit(_fetch_single_tf_zone, tf): tf for tf in zone_config.zone_timeframes}
|
||||||
|
for future in as_completed(futures):
|
||||||
|
tf_result = future.result()
|
||||||
|
if tf_result is None:
|
||||||
|
continue
|
||||||
|
tf_name = tf_result['tf_name']
|
||||||
|
analyses[tf_name] = tf_result['analyses']
|
||||||
|
ema52_dict[tf_name] = tf_result['ema52']
|
||||||
|
if tf_result['close'] and (not latest_close or latest_close == 0.0):
|
||||||
|
latest_close = tf_result['close']
|
||||||
|
|
||||||
|
structure_zones = analyze_structure_zones_from_serialized(
|
||||||
|
analyses, ema52_dict, latest_close, config=zone_config
|
||||||
|
)
|
||||||
|
result['structure_zones'] = [{
|
||||||
|
'id': z.id,
|
||||||
|
'lower': z.lower,
|
||||||
|
'upper': z.upper,
|
||||||
|
'center': z.center,
|
||||||
|
'width_pct': z.width_pct,
|
||||||
|
'zone_type': z.zone_type,
|
||||||
|
'timeframes': z.timeframes,
|
||||||
|
'structure_types': z.structure_types,
|
||||||
|
'boundary_types': z.boundary_types,
|
||||||
|
'overlap_count': z.overlap_count,
|
||||||
|
'touch_count': z.touch_count,
|
||||||
|
'recency_score': z.recency_score,
|
||||||
|
'ema52_distance_pct': z.ema52_distance_pct,
|
||||||
|
'ema52_aligned': z.ema52_aligned,
|
||||||
|
'strength_score': z.strength_score,
|
||||||
|
'confidence': z.confidence,
|
||||||
|
'first_seen': z.first_seen,
|
||||||
|
'last_seen': z.last_seen,
|
||||||
|
'metadata': z.metadata,
|
||||||
|
} for z in structure_zones]
|
||||||
|
except Exception as e:
|
||||||
|
print(f"StructureZone 分析出错: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
result['structure_zones'] = []
|
||||||
|
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
@app.route('/api/symbols')
|
@app.route('/api/symbols')
|
||||||
|
|||||||
@@ -958,6 +958,11 @@
|
|||||||
<input class="form-check-input" type="checkbox" id="autoRefresh">
|
<input class="form-check-input" type="checkbox" id="autoRefresh">
|
||||||
<label class="form-check-label" for="autoRefresh">启用</label>
|
<label class="form-check-label" for="autoRefresh">启用</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-check form-check-inline me-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="showMainStructureZone">
|
||||||
|
<label class="form-check-label" for="showMainStructureZone">结构区</label>
|
||||||
|
</div>
|
||||||
|
<input type="number" id="zoneKlLines" class="form-control form-control-sm" value="1000" min="100" max="5000" step="100" style="width:80px;" title="结构区K线数量">
|
||||||
<span id="nextRefreshTime" class="text-muted" style="display:none;font-size:0.85rem;"></span>
|
<span id="nextRefreshTime" class="text-muted" style="display:none;font-size:0.85rem;"></span>
|
||||||
<div id="refreshLoadingSpinner" class="loading-spinner ms-2" style="display:none;"></div>
|
<div id="refreshLoadingSpinner" class="loading-spinner ms-2" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1911,6 +1916,11 @@
|
|||||||
console.log('主BI中枢切换为:', $('#showMainBiZs').is(':checked'));
|
console.log('主BI中枢切换为:', $('#showMainBiZs').is(':checked'));
|
||||||
updateChartDisplay();
|
updateChartDisplay();
|
||||||
});
|
});
|
||||||
|
// 结构价值区复选框变更事件
|
||||||
|
$(document).on('change', '#showMainStructureZone', function() {
|
||||||
|
console.log('结构区切换为:', $('#showMainStructureZone').is(':checked'));
|
||||||
|
updateChartDisplay();
|
||||||
|
});
|
||||||
|
|
||||||
// 添加趋势显示复选框变更事件(主/元素),变更后刷新主图
|
// 添加趋势显示复选框变更事件(主/元素),变更后刷新主图
|
||||||
$('#showMainTrend').change(function() {
|
$('#showMainTrend').change(function() {
|
||||||
@@ -2179,7 +2189,8 @@
|
|||||||
sub_sub_timeframe: subSubTimeframe || undefined,
|
sub_sub_timeframe: subSubTimeframe || undefined,
|
||||||
start_time: startTimeMs,
|
start_time: startTimeMs,
|
||||||
end_time: endTimeMs,
|
end_time: endTimeMs,
|
||||||
elements_only: false
|
elements_only: false,
|
||||||
|
zone_kl_lines: parseInt($('#zoneKlLines').val()) || 1000
|
||||||
},
|
},
|
||||||
success: function(data) {
|
success: function(data) {
|
||||||
// 隐藏加载图标
|
// 隐藏加载图标
|
||||||
@@ -4634,8 +4645,60 @@
|
|||||||
} catch (e) { console.error('次周期BI中枢处理出错:', e); }
|
} catch (e) { console.error('次周期BI中枢处理出错:', e); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// 结构价值区绘制(半透明填充区 + 边框)
|
||||||
|
if ($('#showMainStructureZone').is(':checked') && currentData.structure_zones && currentData.structure_zones.length > 0) {
|
||||||
|
try {
|
||||||
|
const kd = currentData.kline_data || [];
|
||||||
|
if (kd.length > 0) {
|
||||||
|
const chartStart = Math.floor(new Date(kd[0].date).getTime() / 1000);
|
||||||
|
const chartEnd = Math.floor(new Date(kd[kd.length-1].date).getTime() / 1000);
|
||||||
|
// 计算可见价格范围,过滤超出范围的区间
|
||||||
|
let priceMin = Infinity, priceMax = -Infinity;
|
||||||
|
kd.forEach(function(k) {
|
||||||
|
const hi = parseFloat(k.high), lo = parseFloat(k.low);
|
||||||
|
if (!isNaN(hi) && hi > priceMax) priceMax = hi;
|
||||||
|
if (!isNaN(lo) && lo < priceMin) priceMin = lo;
|
||||||
|
});
|
||||||
|
const priceMargin = (priceMax - priceMin) * 0.05;
|
||||||
|
priceMin -= priceMargin;
|
||||||
|
priceMax += priceMargin;
|
||||||
|
let drawnCount = 0;
|
||||||
|
currentData.structure_zones.forEach(function(zone) {
|
||||||
|
try {
|
||||||
|
// 跳过完全超出可视价格范围的区间
|
||||||
|
if (zone.upper < priceMin || zone.lower > priceMax) return;
|
||||||
|
const fillColor = zone.zone_type === 'support' ? 'rgba(46, 204, 113, 0.08)' :
|
||||||
|
zone.zone_type === 'resistance' ? 'rgba(231, 76, 60, 0.08)' :
|
||||||
|
'rgba(149, 165, 166, 0.06)';
|
||||||
|
const borderColor = zone.zone_type === 'support' ? 'rgba(46, 204, 113, 0.7)' :
|
||||||
|
zone.zone_type === 'resistance' ? 'rgba(231, 76, 60, 0.7)' :
|
||||||
|
'rgba(149, 165, 166, 0.6)';
|
||||||
|
// 填充区:在上下边界之间画多条半透明线模拟填充
|
||||||
|
const fillLines = 8;
|
||||||
|
const step = (zone.upper - zone.lower) / (fillLines + 1);
|
||||||
|
for (let fi = 1; fi <= fillLines; fi++) {
|
||||||
|
const fy = zone.lower + step * fi;
|
||||||
|
mainChart.addLineSeries({ color: fillColor, lineWidth: 2, lineStyle: 0, lastValueVisible: false, priceLineVisible: false })
|
||||||
|
.setData([{ time: chartStart, value: fy }, { time: chartEnd, value: fy }]);
|
||||||
|
}
|
||||||
|
// 上边界(粗线)
|
||||||
|
mainChart.addLineSeries({ color: borderColor, lineWidth: 2, lineStyle: 0, lastValueVisible: false, priceLineVisible: false })
|
||||||
|
.setData([{ time: chartStart, value: zone.upper }, { time: chartEnd, value: zone.upper }]);
|
||||||
|
// 下边界(粗线)
|
||||||
|
mainChart.addLineSeries({ color: borderColor, lineWidth: 2, lineStyle: 0, lastValueVisible: false, priceLineVisible: false })
|
||||||
|
.setData([{ time: chartStart, value: zone.lower }, { time: chartEnd, value: zone.lower }]);
|
||||||
|
// 中心线(虚线)
|
||||||
|
mainChart.addLineSeries({ color: borderColor, lineWidth: 1, lineStyle: 2, lastValueVisible: false, priceLineVisible: false })
|
||||||
|
.setData([{ time: chartStart, value: zone.center }, { time: chartEnd, value: zone.center }]);
|
||||||
|
drawnCount++;
|
||||||
|
} catch (e) { console.error('结构区绘制出错:', e); }
|
||||||
|
});
|
||||||
|
console.log(`结构区: 共${currentData.structure_zones.length}个, 绘制${drawnCount}个 (可见价格范围: ${priceMin.toFixed(0)}-${priceMax.toFixed(0)})`);
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('结构区整体绘制出错:', e); }
|
||||||
|
}
|
||||||
// 显示未完成中枢 - 分别处理主周期、次周期和次次周期
|
// 显示未完成中枢 - 分别处理主周期、次周期和次次周期
|
||||||
if ($('#showMainZs').is(':checked') || $('#showElementZs').is(':checked') || $('#showSubSubZs').is(':checked')) {
|
if ($('#showMainZs').is(':checked') || $('#showElementZs').is(':checked') || $('#showSubSubZs').is(':checked') || $('#showSubSubBiZs').is(':checked')) {
|
||||||
console.log('绘制未完成中枢 - 已启用');
|
console.log('绘制未完成中枢 - 已启用');
|
||||||
// 显示BI中枢绘制(沿用中枢样式)
|
// 显示BI中枢绘制(沿用中枢样式)
|
||||||
if ($('#showMainBiZs').is(':checked') || $('#showElementBiZs').is(':checked') || $('#showSubSubBiZs').is(':checked')) {
|
if ($('#showMainBiZs').is(':checked') || $('#showElementBiZs').is(':checked') || $('#showSubSubBiZs').is(':checked')) {
|
||||||
@@ -6770,8 +6833,8 @@
|
|||||||
|
|
||||||
const defaultMAs = [
|
const defaultMAs = [
|
||||||
{ type: 'EMA', length: 13, color: '#800080', name: 'EMA13', visible: true }, // 紫色
|
{ type: 'EMA', length: 13, color: '#800080', name: 'EMA13', visible: true }, // 紫色
|
||||||
{ type: 'EMA', length: 26, color: '#000000', name: 'EMA26', visible: true }, // 黑色
|
{ type: 'EMA', length: 26, color: '#FF8C00', name: 'EMA26', visible: true }, // 橙色
|
||||||
{ type: 'EMA', length: 52, color: '#FF8C00', name: 'EMA52', visible: false }, // 橙色
|
{ type: 'EMA', length: 52, color: '#000000', name: 'EMA52', visible: false }, // 黑色
|
||||||
{ type: 'EMA', length: 104, color: '#1E90FF', name: 'EMA104', visible: false }, // 蓝色
|
{ type: 'EMA', length: 104, color: '#1E90FF', name: 'EMA104', visible: false }, // 蓝色
|
||||||
{ type: 'EMA', length: 156, color: '#F700FF', name: 'EMA156', visible: false } // 粉色
|
{ type: 'EMA', length: 156, color: '#F700FF', name: 'EMA156', visible: false } // 粉色
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user