添加k线动能理论
This commit is contained in:
+21
-2
@@ -72,8 +72,27 @@ class Chan_KLC_FX(Enum):
|
||||
BOTTOM3 = auto()
|
||||
BOTTOM4 = auto()
|
||||
UNKNOWN = auto()
|
||||
|
||||
|
||||
class Chan_MACD_STATE(Enum):
|
||||
GW = auto()
|
||||
GWK = auto()
|
||||
UP = auto()
|
||||
DOWN = auto()
|
||||
CROSS0 = auto()
|
||||
UNKNOWN = auto()
|
||||
START = auto()
|
||||
NEAR0 = auto()
|
||||
class Chan_MACDSEG_DIR(Enum):
|
||||
ABOVE = auto()
|
||||
UNDER = auto()
|
||||
class Chan_MACDHISTSET_DIR(Enum):
|
||||
ABOVE = auto()
|
||||
UNDER = auto()
|
||||
class Chan_MACDHIST_STATE(Enum):
|
||||
UP = auto()
|
||||
DOWN = auto()
|
||||
PEAK = auto()
|
||||
UNKNOWN = auto()
|
||||
|
||||
class Chan_BI_DIR(Enum):
|
||||
UP = auto()
|
||||
DOWN = auto()
|
||||
|
||||
+3
-3
@@ -71,11 +71,11 @@ class ChanKLC():
|
||||
if self.high >= klu.bbup302 and klu.bbup302 > 0 and (self.klc_fx_type == Chan_KLC_FX.TOP1 or self.klc_fx_type == Chan_KLC_FX.TOP2):
|
||||
self.bb_out = True
|
||||
#print(self.end_time, self.high, klu.bbup302, self.klc_fx_type)
|
||||
if self.high >= klu.bbup30 and klu.bbup30 > 0:
|
||||
if self.high >= klu.bbup30 and klu.bbup30 > 0 and self.next and (self.next.macd - self.macd) < 0:
|
||||
self.klc_fx_type = Chan_KLC_FX.TOP4
|
||||
if self.low <= klu.bblow302 and klu.bblow302 > 0 and (self.klc_fx_type == Chan_KLC_FX.BOTTOM1 or self.klc_fx_type == Chan_KLC_FX.BOTTOM2):
|
||||
self.bb_out = True
|
||||
if self.low <= klu.bblow30 and klu.bblow30 > 0:
|
||||
if self.low <= klu.bblow30 and klu.bblow30 > 0 and self.next and (self.macd - self.next.macd) < 0:
|
||||
self.klc_fx_type = Chan_KLC_FX.BOTTOM4
|
||||
if self.fx ==Chan_FX_TYPE.TOP:
|
||||
if self.macd > 0:
|
||||
@@ -85,7 +85,7 @@ class ChanKLC():
|
||||
if self.macd < 0:
|
||||
self.bb_out = True
|
||||
def cal_macd_state(self, dir):
|
||||
|
||||
macd_state = 0
|
||||
return macd_state
|
||||
def cal_indicators(self):
|
||||
for index in range(1, len(self.klus)):
|
||||
|
||||
+42
-11
@@ -1,4 +1,4 @@
|
||||
from ChanEnum import Chan_FX_TYPE, Chan_KLU_TYPE, Chan_K_DIR
|
||||
from ChanEnum import Chan_FX_TYPE, Chan_KLU_TYPE, Chan_K_DIR, Chan_MACD_STATE, Chan_MACDHIST_STATE
|
||||
class ChanKLU:
|
||||
def __init__(self, time, open, high, low, close, volume):
|
||||
# _time, _close, _open, _high, _low, _extra_info={}
|
||||
@@ -42,15 +42,24 @@ class ChanKLU:
|
||||
self.fx_confirmed = False # 分型是否确认
|
||||
self.klu_type = None
|
||||
self.cal_klu_min_max()
|
||||
self.range = self.high - self.low
|
||||
self.body = abs(self.close - self.open)
|
||||
self.upper_shadow = self.high - max(self.close, self.open)
|
||||
self.lower_shadow = min(self.close, self.open) - self.low
|
||||
self.body_ratio = self.body / self.open
|
||||
self.upper_shadow_ratio = self.upper_shadow / self.open
|
||||
self.lower_shadow_ratio = self.lower_shadow / self.open
|
||||
self.body_ratio = self.body / self.range
|
||||
self.upper_shadow_ratio = self.upper_shadow / self.body
|
||||
self.lower_shadow_ratio = self.lower_shadow / self.body
|
||||
self.candle_dir = Chan_K_DIR.CROSS if self.close == self.open else Chan_K_DIR.BULL if self.close > self.open else Chan_K_DIR.BEAR
|
||||
self.range = self.high - self.low
|
||||
self.strength = 0 if self.candle_dir == Chan_K_DIR.CROSS else self.cal_klu_strength()
|
||||
|
||||
self.ema52 = 0
|
||||
self.ema24 = 0
|
||||
self.macd_slop = 0
|
||||
self.signal_slop = 0
|
||||
self.hist_slop = 0
|
||||
self.hist_state = Chan_MACDHIST_STATE.UNKNOWN
|
||||
self.macd_state = Chan_MACD_STATE.UNKNOWN
|
||||
self.macd_hist_gap = 0
|
||||
#print(self.open, self.close, self.high, self.low, self.candle_dir, self.strength)
|
||||
def cal_klu_strength(self):
|
||||
strength = 0
|
||||
@@ -599,13 +608,35 @@ class ChanKLU:
|
||||
self.bblow365 = float(item['bblow365']) if 'bblow365' in item and item['bblow365'] else 0
|
||||
# 设置指标后更新实时分析
|
||||
self.update_realtime_analysis()
|
||||
|
||||
self.cal_macd_state()
|
||||
def cal_macd_state(self):
|
||||
if self.pre:
|
||||
pre_macd_slop = self.macd - self.pre.macd
|
||||
pre_signal_slop = self.signal - self.pre.signal
|
||||
pre_hist_slop = self.macdhist - self.pre.macdhist
|
||||
|
||||
if self.macd == 0 and self.signal == 0 and self.macdhist == 0:
|
||||
return Chan_MACD_STATE.UNKNOWN
|
||||
if self.pre and self.pre.macd_state != Chan_MACD_STATE.UNKNOWN:
|
||||
self.macd_slop = self.macd - self.pre.macd
|
||||
self.signal_slop = self.signal - self.pre.signal
|
||||
self.hist_slop = self.macdhist - self.pre.macdhist
|
||||
self.macd_hist_gap = abs(self.macdhist - self.macd)
|
||||
if self.pre.signal >= 0 and self.signal < 0 and self.ema52 > self.close and self.next and self.next.signal < 0:
|
||||
self.macd_state = Chan_MACD_STATE.CROSS0
|
||||
elif self.pre.signal <= 0 and self.signal > 0 and self.ema52 < self.close and self.next and self.next.signal > 0:
|
||||
self.macd_state = Chan_MACD_STATE.CROSS0
|
||||
elif self.macd > 0 and self.signal > 0 and self.macdhist > 0:
|
||||
if self.signal > self.macdhist:
|
||||
self.macd_state = Chan_MACD_STATE.GW
|
||||
if self.macdhist < self.pre.macdhist and self.macd_slop > 0 and self.signal_slop > 0 and self.macd_slop < self.pre.macd_slop and self.signal_slop < self.pre.signal_slop and self.macd_hist_gap > self.pre.macd_hist_gap:
|
||||
self.macd_state = Chan_MACD_STATE.GWK
|
||||
elif self.macd_slop > 0 and self.signal_slop > 0:
|
||||
self.macd_state = Chan_MACD_STATE.UP
|
||||
elif self.macd < 0 and self.signal < 0 and self.macdhist < 0:
|
||||
if self.signal < self.macdhist:
|
||||
self.macd_state = Chan_MACD_STATE.GW
|
||||
if self.macdhist > self.pre.macdhist and self.macd_slop < 0 and self.signal_slop < 0 and self.macd_slop > self.pre.macd_slop and self.signal_slop > self.pre.signal_slop and self.macd_hist_gap > self.pre.macd_hist_gap:
|
||||
self.macd_state = Chan_MACD_STATE.GWK
|
||||
elif self.macd_slop < 0 and self.signal_slop < 0:
|
||||
self.macd_state = Chan_MACD_STATE.DOWN
|
||||
else:
|
||||
self.macd_state = Chan_MACD_STATE.START
|
||||
return self.macd_state
|
||||
|
||||
def get_feature_data(self):
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
from ChanKLU import ChanKLU
|
||||
from ChanEnum import Chan_MACD_STATE, Chan_MACDSEG_DIR, Chan_MACDHISTSET_DIR
|
||||
from ChanMACDSeg import ChanMACDSeg
|
||||
from ChanMACDUnitTF import ChanMACDUnitTF
|
||||
from ChanMACDHistSet import ChanMACDHistSet
|
||||
|
||||
class ChanMACD():
|
||||
def __init__(self, klu_list: list[ChanKLU]):
|
||||
self.klu_list = klu_list
|
||||
self.seg_list = []
|
||||
self.unittf_list = []
|
||||
self.histset_list = []
|
||||
self.cal_macd()
|
||||
def cal_macd(self):
|
||||
last_seg = None
|
||||
last_unittf = None
|
||||
last_histset = None
|
||||
|
||||
last_klu = None
|
||||
for klu in self.klu_list:
|
||||
klu.cal_macd_state()
|
||||
|
||||
# 1) 只有当 MACD 已可用(非 UNKNOWN)时,才开始初始化段/单元
|
||||
if last_seg is None:
|
||||
if klu.macd_state != Chan_MACD_STATE.UNKNOWN:
|
||||
# 初始化首段与首单元
|
||||
seg_dir = Chan_MACDSEG_DIR.ABOVE if klu.macd >= 0 else Chan_MACDSEG_DIR.UNDER
|
||||
seg = ChanMACDSeg(klu.time, klu, None, seg_dir)
|
||||
self.seg_list.append(seg)
|
||||
last_seg = seg
|
||||
|
||||
unittf = ChanMACDUnitTF(klu.time, klu, None, None)
|
||||
self.unittf_list.append(unittf)
|
||||
last_unittf = unittf
|
||||
|
||||
# 初始化首个直方图集合(根据当前柱体正负)
|
||||
if klu.macdhist >= 0:
|
||||
histset = ChanMACDHistSet(klu.time, klu, last_unittf, Chan_MACDHISTSET_DIR.ABOVE)
|
||||
else:
|
||||
histset = ChanMACDHistSet(klu.time, klu, last_unittf, Chan_MACDHISTSET_DIR.UNDER)
|
||||
self.histset_list.append(histset)
|
||||
last_histset = histset
|
||||
last_seg.add_histset(histset)
|
||||
last_unittf.add_histset(histset)
|
||||
last_seg.add_unittf(unittf)
|
||||
# 未就绪则继续等下一根;已就绪亦已完成首个结构初始化,继续下一根
|
||||
last_klu = klu
|
||||
continue
|
||||
|
||||
# 2) 过零切段/切单元
|
||||
if klu.macd_state == Chan_MACD_STATE.CROSS0:
|
||||
# 收尾旧段
|
||||
last_seg.end_klu = last_klu
|
||||
last_seg.end_time = last_klu.time
|
||||
# 新段方向取反
|
||||
new_dir = Chan_MACDSEG_DIR.UNDER if last_seg.seg_dir == Chan_MACDSEG_DIR.ABOVE else Chan_MACDSEG_DIR.ABOVE
|
||||
seg = ChanMACDSeg(klu.time, klu, last_seg, new_dir)
|
||||
self.seg_list.append(seg)
|
||||
last_seg.set_next(seg)
|
||||
last_seg = seg
|
||||
|
||||
# 切换 unit tf(以当前histset收尾为边界)
|
||||
unittf = ChanMACDUnitTF(klu.time, klu, last_histset, None)
|
||||
self.unittf_list.append(unittf)
|
||||
if last_unittf:
|
||||
last_unittf.end_klu = last_klu
|
||||
last_unittf.end_time = last_klu.time
|
||||
last_unittf.set_next(unittf)
|
||||
last_unittf = unittf
|
||||
|
||||
# 3) 直方图集合(基于当前 unittf)
|
||||
if klu.macdhist >= 0:
|
||||
if last_histset and last_histset.histset_dir == Chan_MACDHISTSET_DIR.ABOVE:
|
||||
last_histset.add_klu(klu)
|
||||
else:
|
||||
# 结束旧 histset(以前一根结束更合理)
|
||||
if last_histset:
|
||||
end_klu = getattr(klu, 'pre', None) or klu
|
||||
last_histset.end_klu = end_klu
|
||||
last_histset.end_time = end_klu.time
|
||||
histset = ChanMACDHistSet(klu.time, klu, last_unittf, Chan_MACDHISTSET_DIR.ABOVE)
|
||||
self.histset_list.append(histset)
|
||||
if last_histset:
|
||||
last_histset.set_next(histset)
|
||||
last_histset = histset
|
||||
if last_seg:
|
||||
last_seg.add_histset(histset)
|
||||
if last_unittf:
|
||||
last_unittf.add_histset(histset)
|
||||
else:
|
||||
if last_histset and last_histset.histset_dir == Chan_MACDHISTSET_DIR.UNDER:
|
||||
last_histset.add_klu(klu)
|
||||
else:
|
||||
if last_histset:
|
||||
end_klu = getattr(klu, 'pre', None) or klu
|
||||
last_histset.end_klu = end_klu
|
||||
last_histset.end_time = end_klu.time
|
||||
histset = ChanMACDHistSet(klu.time, klu, last_unittf, Chan_MACDHISTSET_DIR.UNDER)
|
||||
self.histset_list.append(histset)
|
||||
if last_histset:
|
||||
last_histset.set_next(histset)
|
||||
last_histset = histset
|
||||
if last_seg:
|
||||
last_seg.add_histset(histset)
|
||||
if last_unittf:
|
||||
last_unittf.add_histset(histset)
|
||||
last_klu = klu
|
||||
# 循环结束后,收尾当前打开的结构
|
||||
if last_seg and getattr(last_seg, 'end_klu', None) is None:
|
||||
last_seg.end_klu = last_klu
|
||||
last_seg.end_time = last_klu.time
|
||||
if last_unittf and getattr(last_unittf, 'end_klu', None) is None:
|
||||
last_unittf.end_klu = last_klu
|
||||
last_unittf.end_time = last_klu.time
|
||||
if last_histset and getattr(last_histset, 'end_klu', None) is None:
|
||||
last_histset.end_klu = last_klu
|
||||
last_histset.end_time = last_klu.time
|
||||
@@ -0,0 +1,15 @@
|
||||
|
||||
|
||||
class ChanMACDHistSet():
|
||||
def __init__(self, start_time, start_klu, hist_set_dir, pre_histset):
|
||||
self.klu_list = []
|
||||
self.klu_list.append(start_klu)
|
||||
self.ref_klu = None
|
||||
self.hist_set_dir = hist_set_dir
|
||||
self.next = None
|
||||
self.pre = pre_histset
|
||||
def set_next(self, next_histset):
|
||||
self.next = next_histset
|
||||
def add_klu(self, klu):
|
||||
self.klu_list.append(klu)
|
||||
klu.set_histset(self)
|
||||
@@ -0,0 +1,27 @@
|
||||
|
||||
|
||||
|
||||
class ChanMACDSeg():
|
||||
def __init__(self, start_time, start_klu, pre_seg, seg_dir):
|
||||
self.start_time = start_time
|
||||
self.end_time = None
|
||||
self.start_klu = start_klu
|
||||
self.end_klu = None
|
||||
self.klu_list = []
|
||||
self.klu_list.append(start_klu)
|
||||
self.unittf_list = []
|
||||
self.hist_set = []
|
||||
self.seg_dir = seg_dir
|
||||
self.pre = pre_seg
|
||||
self.next = None
|
||||
def set_next(self, next_seg):
|
||||
self.next = next_seg
|
||||
def add_klu(self, klu):
|
||||
self.klu_list.append(klu)
|
||||
klu.set_seg(self)
|
||||
def add_unittf(self, unittf):
|
||||
self.unittf_list.append(unittf)
|
||||
unittf.set_next(self)
|
||||
def add_histset(self, histset):
|
||||
self.hist_set.append(histset)
|
||||
histset.set_next(self)
|
||||
@@ -0,0 +1,24 @@
|
||||
|
||||
|
||||
|
||||
class ChanMACDUnitTF():
|
||||
def __init__(self, start_time, start_klu, start_histset, pre_unittf, dir):
|
||||
self.start_time = start_time
|
||||
self.end_time = None
|
||||
self.start_klu = start_klu
|
||||
self.end_klu = None
|
||||
self.klu_list = []
|
||||
self.klu_list.append(start_klu)
|
||||
self.histset_list = []
|
||||
self.histset_list.append(start_histset)
|
||||
self.next = None
|
||||
self.pre = pre_unittf
|
||||
self.uinttf_dir = dir
|
||||
def set_next(self, next_unittf):
|
||||
self.next = next_unittf
|
||||
def add_histset(self, histset):
|
||||
self.histset_list.append(histset)
|
||||
histset.set_next(self)
|
||||
def add_klu(self, klu):
|
||||
self.klu_list.append(klu)
|
||||
klu.set_unittf(self)
|
||||
@@ -0,0 +1,17 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt /app/requirements.txt
|
||||
RUN pip install -r /app/requirements.txt
|
||||
|
||||
COPY app /app/app
|
||||
|
||||
EXPOSE 9000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "9000"]
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Local Data Service (REST + WebSocket)
|
||||
|
||||
一键部署、跨平台的本地行情数据服务。默认抓取 Binance 永续合约 `BTC/USDT:USDT, ETH/USDT:USDT` 的 `1m/5m/15m/1h` K 线,增量写入本地 Parquet 并通过 WebSocket 推送。
|
||||
|
||||
## 快速开始(方式B:已安装 Docker)
|
||||
|
||||
```bash
|
||||
cd user_data/Chan/datasvc
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
- REST: http://localhost:9000/api/candles?symbol=BTC/USDT:USDT&tf=1m
|
||||
- WS: ws://localhost:9000/ws?symbol=BTC/USDT:USDT&tf=1m&since=1690000000000
|
||||
- Swagger: http://localhost:9000/docs
|
||||
|
||||
## 环境变量(docker-compose.yml)
|
||||
- EXCHANGE: 交易所,默认 binance
|
||||
- SYMBOLS: 逗号分隔交易对
|
||||
- TIMEFRAMES: 逗号分隔周期
|
||||
- START_DAYS: 首次启动回补最近 N 天
|
||||
- POLL_FACTOR: 轮询因子,间隔=周期毫秒*factor
|
||||
- DATA_DIR: 容器内数据目录(已映射到 `./data`)
|
||||
|
||||
## 数据位置
|
||||
- 本地缓存:`user_data/Chan/datasvc/data/{timeframe}/{symbol}.parquet`
|
||||
|
||||
## 常用命令
|
||||
```bash
|
||||
docker compose logs -f
|
||||
|
||||
docker compose down
|
||||
```
|
||||
|
||||
## 接口说明
|
||||
- GET /api/candles
|
||||
- 参数:symbol, tf, start(ms), end(ms)
|
||||
- 返回:[{timestamp, open, high, low, close, volume}]
|
||||
- WS /ws
|
||||
- 参数:symbol, tf, since(ms)
|
||||
- 消息:
|
||||
- snapshot: 初始快照数组
|
||||
- upsert: 单根K线增量(尾部修正)
|
||||
|
||||
## 注意
|
||||
- 默认未带交易所 API Key,仅公共行情。
|
||||
- 如需更多交易对/周期,修改 `docker-compose.yml` 后重启。
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import os
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import ccxt
|
||||
import pandas as pd
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .storage import (
|
||||
ensure_storage,
|
||||
read_candles,
|
||||
upsert_candles,
|
||||
get_last_timestamp,
|
||||
)
|
||||
|
||||
|
||||
DATA_DIR = os.environ.get("DATA_DIR", "/data")
|
||||
EXCHANGE = os.environ.get("EXCHANGE", "binance")
|
||||
SYMBOLS = [s.strip() for s in os.environ.get("SYMBOLS", "BTC/USDT:USDT,ETH/USDT:USDT").split(",") if s.strip()]
|
||||
TIMEFRAMES = [t.strip() for t in os.environ.get("TIMEFRAMES", "1m,5m,15m,1h").split(",") if t.strip()]
|
||||
START_FROM = os.environ.get("START_FROM", "2025-01-01") # 首次启动拉取起始日期(UTC)
|
||||
POLL_FACTOR = float(os.environ.get("POLL_FACTOR", "0.5")) # 轮询间隔 = tf_ms * factor
|
||||
|
||||
ensure_storage(DATA_DIR)
|
||||
|
||||
app = FastAPI(title="Local Data Service", version="0.1.0")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
def tf_to_ms(tf: str) -> int:
|
||||
table = {
|
||||
"1m": 60_000,
|
||||
"3m": 3 * 60_000,
|
||||
"5m": 5 * 60_000,
|
||||
"15m": 15 * 60_000,
|
||||
"30m": 30 * 60_000,
|
||||
"1h": 60 * 60_000,
|
||||
"2h": 2 * 60 * 60_000,
|
||||
"4h": 4 * 60 * 60_000,
|
||||
"1d": 24 * 60 * 60_000,
|
||||
}
|
||||
return table.get(tf, 60_000)
|
||||
|
||||
|
||||
def parse_start_from_ms(val: str) -> int:
|
||||
"""将 START_FROM 解析成毫秒级时间戳。
|
||||
支持两种格式:
|
||||
- YYYY-MM-DD(UTC 00:00:00)
|
||||
- 整型毫秒时间戳字符串
|
||||
"""
|
||||
try:
|
||||
return int(val)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
dt = datetime.fromisoformat(val) # 允许 '2025-01-01' 或 '2025-01-01T00:00:00'
|
||||
except Exception:
|
||||
# 回退到固定日期
|
||||
dt = datetime(2025, 1, 1)
|
||||
return int(dt.timestamp() * 1000)
|
||||
|
||||
|
||||
class Hub:
|
||||
def __init__(self) -> None:
|
||||
self.subscribers: Dict[str, List[WebSocket]] = {}
|
||||
|
||||
def topic(self, symbol: str, timeframe: str) -> str:
|
||||
return f"candles::{symbol}::{timeframe}"
|
||||
|
||||
async def subscribe(self, ws: WebSocket, symbol: str, timeframe: str):
|
||||
topic = self.topic(symbol, timeframe)
|
||||
await ws.accept()
|
||||
self.subscribers.setdefault(topic, []).append(ws)
|
||||
|
||||
def _clean(self, topic: str):
|
||||
conns = self.subscribers.get(topic, [])
|
||||
self.subscribers[topic] = [w for w in conns if not w.client_state.name == "DISCONNECTED"]
|
||||
|
||||
async def publish(self, symbol: str, timeframe: str, payload: dict):
|
||||
topic = self.topic(symbol, timeframe)
|
||||
conns = self.subscribers.get(topic, [])
|
||||
if not conns:
|
||||
return
|
||||
message = json.dumps(payload, ensure_ascii=False)
|
||||
dead: List[WebSocket] = []
|
||||
for ws in conns:
|
||||
try:
|
||||
await ws.send_text(message)
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
if dead:
|
||||
self.subscribers[topic] = [w for w in conns if w not in dead]
|
||||
|
||||
|
||||
hub = Hub()
|
||||
|
||||
|
||||
def build_exchange():
|
||||
if EXCHANGE.lower() == "binance":
|
||||
return ccxt.binance({"enableRateLimit": True})
|
||||
raise RuntimeError(f"Unsupported EXCHANGE: {EXCHANGE}")
|
||||
|
||||
|
||||
async def fetch_loop(symbol: str, timeframe: str):
|
||||
"""持续增量抓取并广播。"""
|
||||
exchange = build_exchange()
|
||||
tf_ms = tf_to_ms(timeframe)
|
||||
start_since = parse_start_from_ms(START_FROM)
|
||||
last_ts = get_last_timestamp(DATA_DIR, symbol, timeframe)
|
||||
since = max(start_since, (last_ts + tf_ms) if last_ts else start_since)
|
||||
|
||||
while True:
|
||||
try:
|
||||
candles = exchange.fetch_ohlcv(symbol, timeframe, since=since, limit=1000)
|
||||
if candles:
|
||||
upsert_candles(DATA_DIR, symbol, timeframe, candles)
|
||||
for row in candles[-3:]:
|
||||
payload = {
|
||||
"topic": f"candles.{symbol}.{timeframe}",
|
||||
"type": "upsert",
|
||||
"data": {
|
||||
"t": row[0],
|
||||
"o": row[1],
|
||||
"h": row[2],
|
||||
"l": row[3],
|
||||
"c": row[4],
|
||||
"v": row[5],
|
||||
},
|
||||
}
|
||||
await hub.publish(symbol, timeframe, payload)
|
||||
since = candles[-1][0] + tf_ms
|
||||
await asyncio.sleep(max(1.0, tf_ms * POLL_FACTOR / 1000.0))
|
||||
except Exception:
|
||||
await asyncio.sleep(3.0)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def on_start():
|
||||
ensure_storage(DATA_DIR)
|
||||
for s in SYMBOLS:
|
||||
for tf in TIMEFRAMES:
|
||||
asyncio.create_task(fetch_loop(s, tf))
|
||||
|
||||
|
||||
@app.get("/api/candles")
|
||||
def api_candles(
|
||||
symbol: str = Query(..., description="如 BTC/USDT:USDT"),
|
||||
tf: str = Query("1m", description="时间周期"),
|
||||
start: Optional[int] = Query(None, description="开始时间戳(ms)"),
|
||||
end: Optional[int] = Query(None, description="结束时间戳(ms)"),
|
||||
):
|
||||
try:
|
||||
df = read_candles(DATA_DIR, symbol, tf, start, end)
|
||||
records = df.to_dict("records") if not df.empty else []
|
||||
return JSONResponse(records)
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def ws_endpoint(websocket: WebSocket, symbol: str, tf: str, since: Optional[int] = None):
|
||||
await hub.subscribe(websocket, symbol, tf)
|
||||
try:
|
||||
snap = read_candles(DATA_DIR, symbol, tf, since, None)
|
||||
await websocket.send_text(
|
||||
json.dumps(
|
||||
{
|
||||
"topic": f"candles.{symbol}.{tf}",
|
||||
"type": "snapshot",
|
||||
"data": [
|
||||
{"t": int(r["timestamp"]), "o": r["open"], "h": r["high"], "l": r["low"], "c": r["close"], "v": r["volume"]}
|
||||
for _, r in snap.iterrows()
|
||||
],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
await websocket.send_text(json.dumps({"type": "ping", "ts": int(datetime.utcnow().timestamp() * 1000)}))
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {
|
||||
"service": "Local Data Service",
|
||||
"exchange": EXCHANGE,
|
||||
"symbols": SYMBOLS,
|
||||
"timeframes": TIMEFRAMES,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import os
|
||||
import threading
|
||||
from typing import List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def ensure_storage(base_dir: str):
|
||||
os.makedirs(base_dir, exist_ok=True)
|
||||
|
||||
|
||||
def _path(base_dir: str, symbol: str, timeframe: str) -> str:
|
||||
safe_symbol = symbol.replace("/", "_").replace(":", "_")
|
||||
d = os.path.join(base_dir, timeframe)
|
||||
os.makedirs(d, exist_ok=True)
|
||||
return os.path.join(d, f"{safe_symbol}.parquet")
|
||||
|
||||
|
||||
def read_candles(base_dir: str, symbol: str, timeframe: str, start: Optional[int], end: Optional[int]) -> pd.DataFrame:
|
||||
p = _path(base_dir, symbol, timeframe)
|
||||
if not os.path.exists(p):
|
||||
return pd.DataFrame(columns=["timestamp", "open", "high", "low", "close", "volume"]) # empty
|
||||
df = pd.read_parquet(p)
|
||||
if start is not None:
|
||||
df = df[df["timestamp"] >= int(start)]
|
||||
if end is not None:
|
||||
df = df[df["timestamp"] <= int(end)]
|
||||
df = df.sort_values("timestamp")
|
||||
return df
|
||||
|
||||
|
||||
def upsert_candles(base_dir: str, symbol: str, timeframe: str, candles: List[List[float]]):
|
||||
p = _path(base_dir, symbol, timeframe)
|
||||
new_df = pd.DataFrame(candles, columns=["timestamp", "open", "high", "low", "close", "volume"])
|
||||
with _lock:
|
||||
if os.path.exists(p):
|
||||
old = pd.read_parquet(p)
|
||||
merged = pd.concat([old, new_df], ignore_index=True)
|
||||
merged = merged.drop_duplicates(subset=["timestamp"], keep="last").sort_values("timestamp")
|
||||
else:
|
||||
merged = new_df.sort_values("timestamp")
|
||||
merged.to_parquet(p, index=False)
|
||||
|
||||
|
||||
def get_last_timestamp(base_dir: str, symbol: str, timeframe: str) -> Optional[int]:
|
||||
p = _path(base_dir, symbol, timeframe)
|
||||
if not os.path.exists(p):
|
||||
return None
|
||||
df = pd.read_parquet(p)
|
||||
if df.empty:
|
||||
return None
|
||||
return int(df["timestamp"].iloc[-1])
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
version: "3.9"
|
||||
services:
|
||||
datasvc:
|
||||
build: .
|
||||
container_name: datasvc
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- EXCHANGE=binance
|
||||
- SYMBOLS=BTC/USDT:USDT,ETH/USDT:USDT
|
||||
- TIMEFRAMES=1m,5m,15m,1h
|
||||
- START_FROM=2025-01-01
|
||||
- POLL_FACTOR=0.5
|
||||
- DATA_DIR=/data
|
||||
- TZ=Asia/Shanghai
|
||||
ports:
|
||||
- "9000:9000"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.29.0
|
||||
ccxt==4.4.27
|
||||
pandas==2.2.2
|
||||
pyarrow==16.1.0
|
||||
orjson==3.10.3
|
||||
|
||||
+54
-147
@@ -67,15 +67,15 @@ class ChanLun_BTC_30(IStrategy):
|
||||
can_short = True
|
||||
lev = 1.0
|
||||
stoploss = -0.3 # 设置为很大的负值,让custom_stoploss来控制
|
||||
use_custom_stoploss = False # 启用自定义止损
|
||||
use_custom_stoploss = True # 启用自定义止损
|
||||
|
||||
trailing_stop = False
|
||||
trailing_stop_positive = 0.025
|
||||
trailing_stop_positive_offset = 0.045
|
||||
trailing_only_offset_is_reached = False
|
||||
|
||||
# 启用仓位调整功能以支持分批止盈
|
||||
position_adjustment_enable = True
|
||||
# 关闭分批止盈/仓位调整
|
||||
position_adjustment_enable = False
|
||||
startup_candle_count = 2880
|
||||
|
||||
time5 = 5
|
||||
@@ -119,9 +119,14 @@ class ChanLun_BTC_30(IStrategy):
|
||||
#self.chan.plot_dual(dataframe_5, dataframe_30)
|
||||
#chanpy_state = self.chanpy.get_bsp_state(dataframe_5)
|
||||
#dataframe_5['chanpy_state'] = chanpy_state
|
||||
state_list = self.chan.get_klc_state_list(dataframe_5)
|
||||
dataframe_5['state'] = state_list
|
||||
state_list = self.chan.get_klc_state_list(dataframe_15)
|
||||
dataframe_15['state'] = state_list
|
||||
state_list = self.chan.get_klc_state_list(dataframe_30)
|
||||
dataframe_30['state'] = state_list
|
||||
state_list = self.chan.get_klc_state_list(dataframe_60)
|
||||
dataframe_60['state'] = state_list
|
||||
dataframe_60['fx'] = state_list
|
||||
#bi_list_1 = self.chan.get_bi_list(dataframe)
|
||||
#bi_list_5 = self.chan.get_bi_list(dataframe_5)
|
||||
#bi_list_15 = self.chan.get_bi_list(dataframe_15)
|
||||
@@ -138,7 +143,7 @@ class ChanLun_BTC_30(IStrategy):
|
||||
self.last_time = datetime.now()
|
||||
dataframe = resampled_merge(dataframe, dataframe_3)
|
||||
dataframe = resampled_merge(dataframe, dataframe_5)
|
||||
#dataframe = resampled_merge(dataframe, dataframe_15)
|
||||
dataframe = resampled_merge(dataframe, dataframe_15)
|
||||
#dataframe = resampled_merge(dataframe, dataframe_30)
|
||||
dataframe = resampled_merge(dataframe, dataframe_60)
|
||||
#dataframe = resampled_merge(dataframe, dataframe_4h)
|
||||
@@ -166,7 +171,8 @@ class ChanLun_BTC_30(IStrategy):
|
||||
bb120 = ta.BBANDS(df, timeperiod=120, nbdevup=3.0, nbdevdn=3.0, matype=0)
|
||||
bb30 = ta.BBANDS(df, timeperiod=41, nbdevup=2.3, nbdevdn=2.3, matype=0)
|
||||
bb302 = ta.BBANDS(df, timeperiod=41, nbdevup=2.0, nbdevdn=2.0, matype=0)
|
||||
|
||||
bb30 = ta.BBANDS(df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, matype=0)
|
||||
bb302 = ta.BBANDS(df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, matype=0)
|
||||
# 计算布林带中轨(移动平均线)
|
||||
bb30_middle = ta.SMA(df, timeperiod=90)
|
||||
|
||||
@@ -230,162 +236,63 @@ class ChanLun_BTC_30(IStrategy):
|
||||
new_exitprice = proposed_rate - 50
|
||||
return new_exitprice
|
||||
|
||||
def adjust_trade_position1(self, trade: Trade, current_time: datetime,
|
||||
def adjust_trade_position(self, trade: Trade, current_time: datetime,
|
||||
current_rate: float, current_profit: float,
|
||||
min_stake: Optional[float], max_stake: float,
|
||||
current_entry_rate: float, current_exit_rate: float,
|
||||
current_entry_profit: float, current_exit_profit: float,
|
||||
**kwargs) -> Optional[float]:
|
||||
"""
|
||||
基于布林带的分批止盈逻辑
|
||||
"""
|
||||
# 获取当前数据
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(trade.pair, self.timeframe)
|
||||
if dataframe is None or len(dataframe) == 0:
|
||||
return None
|
||||
|
||||
last_candle = dataframe.iloc[-1]
|
||||
|
||||
# 获取布林带数据
|
||||
bb30_middle = last_candle['bbmiddle30']
|
||||
bb30_upper = last_candle['bbup30']
|
||||
bb30_lower = last_candle['bblow30']
|
||||
bb302_upper = last_candle['bbup302']
|
||||
bb302_lower = last_candle['bblow302']
|
||||
|
||||
# 获取交易的状态标记
|
||||
first_tp_triggered = trade.get_custom_data(key="first_tp_triggered", default=False)
|
||||
second_tp_triggered = trade.get_custom_data(key="second_tp_triggered", default=False)
|
||||
|
||||
if trade.is_short:
|
||||
# 做空逻辑
|
||||
if not first_tp_triggered and current_rate <= bb30_middle:
|
||||
# 第一次止盈:价格跌到bb30中轨,止盈50%
|
||||
logger.info(f"做空第一次止盈触发:价格{current_rate} <= BB30中轨{bb30_middle}")
|
||||
trade.set_custom_data(key="first_tp_triggered", value=True)
|
||||
trade.set_custom_data(key="new_stoploss", value=trade.open_rate) # 设置止损为开仓价
|
||||
return -(trade.amount * 0.5) # 减少50%仓位
|
||||
|
||||
elif first_tp_triggered and not second_tp_triggered and current_rate <= bb302_lower:
|
||||
# 第二次止盈:继续跌到bb302下轨,止盈剩余仓位的60%
|
||||
logger.info(f"做空第二次止盈触发:价格{current_rate} <= BB302下轨{bb302_lower}")
|
||||
trade.set_custom_data(key="second_tp_triggered", value=True)
|
||||
trade.set_custom_data(key="new_stoploss", value=bb30_middle) # 移动止损到bb30中轨
|
||||
remaining_amount = trade.amount * 0.5 # 剩余50%
|
||||
return -(remaining_amount * 0.6) # 减少剩余仓位的60%
|
||||
|
||||
else:
|
||||
# 做多逻辑
|
||||
if not first_tp_triggered and current_rate >= bb30_middle:
|
||||
# 第一次止盈:价格涨到bb30中轨,止盈50%
|
||||
logger.info(f"做多第一次止盈触发:价格{current_rate} >= BB30中轨{bb30_middle}")
|
||||
trade.set_custom_data(key="first_tp_triggered", value=True)
|
||||
trade.set_custom_data(key="new_stoploss", value=trade.open_rate) # 设置止损为开仓价
|
||||
return -(trade.amount * 0.5) # 减少50%仓位
|
||||
|
||||
elif first_tp_triggered and not second_tp_triggered and current_rate >= bb302_upper:
|
||||
# 第二次止盈:继续涨到bb302上轨,止盈剩余仓位的60%
|
||||
logger.info(f"做多第二次止盈触发:价格{current_rate} >= BB302上轨{bb302_upper}")
|
||||
trade.set_custom_data(key="second_tp_triggered", value=True)
|
||||
trade.set_custom_data(key="new_stoploss", value=bb30_middle) # 移动止损到bb30中轨
|
||||
remaining_amount = trade.amount * 0.5 # 剩余50%
|
||||
return -(remaining_amount * 0.6) # 减少剩余仓位的60%
|
||||
|
||||
# 关闭分批止盈,始终不调整仓位
|
||||
return None
|
||||
|
||||
def custom_stoploss(self, pair: str, trade: Trade, current_time: datetime,
|
||||
current_rate: float, current_profit: float, after_fill: bool,
|
||||
**kwargs) -> float | None:
|
||||
"""
|
||||
动态止损逻辑
|
||||
止损 = 开仓价 ± 1 * ATR(开仓时的ATR)。
|
||||
多单: 开仓价 - ATR;空单: 开仓价 + ATR。
|
||||
"""
|
||||
# 检查是否有自定义的新止损价格(分批止盈后的动态止损)
|
||||
new_stoploss_price = trade.get_custom_data(key="new_stoploss")
|
||||
if new_stoploss_price:
|
||||
logger.info(f"使用动态止损价格: {new_stoploss_price}")
|
||||
return stoploss_from_absolute(new_stoploss_price, current_rate, is_short=trade.is_short)
|
||||
|
||||
|
||||
# 如果没有ATR数据,使用固定的5%止损作为备用
|
||||
logger.warning(f"未找到开仓时ATR数据,使用默认5%止损")
|
||||
return -0.05
|
||||
entry_atr = trade.get_custom_data(key="entry_atr")
|
||||
if entry_atr is None:
|
||||
# 回退:取当前数据的 ATR 估算
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(trade.pair, self.timeframe)
|
||||
if dataframe is not None and len(dataframe) > 0 and 'atr' in dataframe.columns:
|
||||
entry_atr = float(dataframe.iloc[-1]['atr'])
|
||||
else:
|
||||
# 最保守的回退:5%
|
||||
return -0.05
|
||||
|
||||
if trade.is_short:
|
||||
stop_price = trade.open_rate + float(entry_atr)
|
||||
else:
|
||||
stop_price = trade.open_rate - float(entry_atr)
|
||||
return stoploss_from_absolute(stop_price, current_rate, is_short=trade.is_short)
|
||||
|
||||
def custom_exit(self, pair: str, trade: Trade, current_time: datetime, current_rate: float,
|
||||
current_profit: float, **kwargs):
|
||||
"""
|
||||
自定义退出逻辑 - 处理最终止盈条件
|
||||
"""
|
||||
# 获取当前数据
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if dataframe is None or len(dataframe) == 0:
|
||||
return None
|
||||
|
||||
last_candle = dataframe.iloc[-1]
|
||||
|
||||
# 获取布林带数据
|
||||
bb30_upper = last_candle['bbup30']
|
||||
bb30_lower = last_candle['bblow30']
|
||||
|
||||
# 检查是否已经触发过前两次止盈
|
||||
first_tp_triggered = trade.get_custom_data(key="first_tp_triggered", default=False)
|
||||
second_tp_triggered = trade.get_custom_data(key="second_tp_triggered", default=False)
|
||||
|
||||
if trade.is_short:
|
||||
# 做空:如果价格跌到bb30下轨,全部止盈
|
||||
if first_tp_triggered and second_tp_triggered and current_rate <= bb30_lower:
|
||||
logger.info(f"做空最终止盈触发:价格{current_rate} <= BB30下轨{bb30_lower}")
|
||||
return "short_final_tp_bb30_lower"
|
||||
else:
|
||||
# 做多:如果价格涨到bb30上轨,全部止盈
|
||||
if first_tp_triggered and second_tp_triggered and current_rate >= bb30_upper:
|
||||
logger.info(f"做多最终止盈触发:价格{current_rate} >= BB30上轨{bb30_upper}")
|
||||
return "long_final_tp_bb30_upper"
|
||||
|
||||
# 原有退出逻辑
|
||||
if trade.is_short:
|
||||
last_high = trade.get_custom_data(key="entry_candle_high")
|
||||
if last_high and current_rate > last_high:
|
||||
return "Relay Top FX exit"
|
||||
else:
|
||||
last_low = trade.get_custom_data(key="entry_candle_low")
|
||||
if last_low and current_rate < last_low:
|
||||
return "Relay Bottom FX exit"
|
||||
|
||||
# 不做分批止盈/最终止盈处理,退出由策略信号/ROI/止损决定
|
||||
return None
|
||||
|
||||
def confirm_trade_entry1(self, pair: str, order_type: str, amount: float, rate: float,
|
||||
|
||||
def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float,
|
||||
time_in_force: str, current_time: datetime, entry_tag: str | None,
|
||||
side: str, **kwargs) -> bool:
|
||||
if self.last_trade:
|
||||
if self.last_trade.is_short:
|
||||
if side == 'short':
|
||||
if self.last_trade.open_date + timedelta(minutes=30) > current_time:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
else:
|
||||
if side == 'long':
|
||||
if self.last_trade.open_date + timedelta(minutes=30) > current_time:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
#if self.last_trade:
|
||||
#print(self.last_trade.open_date, current_time, self.last_trade.open_date + timedelta(minutes=self.time5))
|
||||
return True
|
||||
def custom_stoploss1(self, pair: str, trade: Trade, current_time: datetime,
|
||||
current_rate: float, current_profit: float, after_fill: bool,
|
||||
**kwargs) -> float | None:
|
||||
|
||||
last_high = trade.get_custom_data(key="entry_candle_high")
|
||||
last_low = trade.get_custom_data(key="entry_candle_low")
|
||||
|
||||
# Convert absolute price to percentage relative to current_rate
|
||||
if last_high:
|
||||
return stoploss_from_absolute(last_high, current_rate, is_short=trade.is_short)
|
||||
if last_low:
|
||||
return stoploss_from_absolute(last_low, current_rate, is_short=trade.is_short)
|
||||
# return maximum stoploss value, keeping current stoploss price unchanged
|
||||
return None
|
||||
"""
|
||||
ATR 过滤:atr < 100 不开单。
|
||||
"""
|
||||
try:
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if dataframe is None or len(dataframe) == 0:
|
||||
return False
|
||||
last = dataframe.iloc[-1]
|
||||
atr_str = 'resample_{}_atr'.format(self.get_ticker_indicator()*self.time30)
|
||||
atr_val = float(last.get(atr_str, 0) or 0)
|
||||
if atr_val < 100:
|
||||
logger.info(f"ATR过滤:atr={atr_val:.2f} < 100, 拒绝进场 {pair}")
|
||||
return False
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"confirm_trade_entry 异常: {e}")
|
||||
return True
|
||||
|
||||
def order_filled(self, pair: str, trade: Trade, order: Order, current_time: datetime, **kwargs) -> None:
|
||||
"""
|
||||
@@ -400,10 +307,10 @@ class ChanLun_BTC_30(IStrategy):
|
||||
# Obtain pair dataframe (just to show how to access it)
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(trade.pair, self.timeframe)
|
||||
last_candle = dataframe.iloc[-1].squeeze()
|
||||
|
||||
atr_str = 'resample_{}_atr'.format(self.get_ticker_indicator()*self.time30)
|
||||
# 保存开仓时的ATR值用于止损计算
|
||||
if (trade.nr_of_successful_entries == 1) and (order.ft_order_side == trade.entry_side):
|
||||
entry_atr = last_candle['atr']
|
||||
entry_atr = last_candle[atr_str] * 4
|
||||
trade.set_custom_data(key="entry_atr", value=entry_atr)
|
||||
logger.info(f"保存开仓时ATR值: {entry_atr}")
|
||||
return None
|
||||
@@ -427,7 +334,7 @@ class ChanLun_BTC_30(IStrategy):
|
||||
['enter_long', 'enter_tag']] = (1, 'long_signal_chan')
|
||||
dataframe.loc[
|
||||
(
|
||||
(dataframe[state_str].shift(shift_time) == "10")
|
||||
(dataframe[state_str].shift(shift_time) == "10")
|
||||
#(dataframe[fx_str].shift(shift_time) == 1)
|
||||
#(dataframe[chanpy_state_str].shift(shift_time+30) == -1)
|
||||
#(dataframe['resample_{}_state'.format(self.get_ticker_indicator()*self.time5)].shift(self.time5) == "-10") &
|
||||
|
||||
+245
-3
@@ -267,6 +267,18 @@ def add_indicators(df):
|
||||
df['ma10'] = (ta.MA(df, timeperiod=10)).fillna(0)
|
||||
df['ma30'] = (ta.EMA(df, timeperiod=30)).fillna(0)
|
||||
df['ma250'] = (ta.MA(df, timeperiod=250)).fillna(0)
|
||||
# 新增 EMA 指标
|
||||
df['ema5'] = (ta.EMA(df, timeperiod=5)).fillna(0)
|
||||
df['ema10'] = (ta.EMA(df, timeperiod=10)).fillna(0)
|
||||
df['ema24'] = (ta.EMA(df, timeperiod=24)).fillna(0)
|
||||
df['ema52'] = (ta.EMA(df, timeperiod=52)).fillna(0)
|
||||
# 常用SMA 24/52
|
||||
try:
|
||||
df['sma24'] = (ta.SMA(df, timeperiod=24)).fillna(0)
|
||||
df['sma52'] = (ta.SMA(df, timeperiod=52)).fillna(0)
|
||||
except Exception:
|
||||
df['sma24'] = 0
|
||||
df['sma52'] = 0
|
||||
df['rsi'] = ta.RSI(df, timeperiod=14)
|
||||
|
||||
# 计算布林带 (当前周期 - 20周期,2标准差)
|
||||
@@ -275,9 +287,11 @@ def add_indicators(df):
|
||||
df['bb_middle'] = bb['middleband'].fillna(0)
|
||||
df['bb_lower'] = bb['lowerband'].fillna(0)
|
||||
bb30 = ta.BBANDS(df, timeperiod=41, nbdevup=2.3, nbdevdn=2.3, matype=0)
|
||||
bb30 = ta.BBANDS(df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, matype=0)
|
||||
df['bbup30'] = bb30['upperband'].fillna(0)
|
||||
df['bblow30'] = bb30['lowerband'].fillna(0)
|
||||
bb302 = ta.BBANDS(df, timeperiod=41, nbdevup=2.0, nbdevdn=2.0, matype=0)
|
||||
bb302 = ta.BBANDS(df, timeperiod=20, nbdevup=2.0, nbdevdn=2.0, matype=0)
|
||||
df['bbup302'] = bb302['upperband'].fillna(0)
|
||||
df['bblow302'] = bb302['lowerband'].fillna(0)
|
||||
# 计算次周期布林带 (14周期,2标准差)
|
||||
@@ -293,6 +307,12 @@ def add_indicators(df):
|
||||
df['ma10'] = df['ma10'].fillna(0)
|
||||
df['ma30'] = df['ma30'].fillna(0)
|
||||
df['ma250'] = df['ma250'].fillna(0)
|
||||
df['ema5'] = df['ema5'].fillna(0)
|
||||
df['ema10'] = df['ema10'].fillna(0)
|
||||
df['ema24'] = df['ema24'].fillna(0)
|
||||
df['ema52'] = df['ema52'].fillna(0)
|
||||
df['sma24'] = df['sma24'].fillna(0)
|
||||
df['sma52'] = df['sma52'].fillna(0)
|
||||
df['rsi'] = df['rsi'].fillna(0)
|
||||
df['avg_volume'] = df['volume'].rolling(10).mean()
|
||||
# 计算量比,避免产生Infinity值
|
||||
@@ -312,10 +332,10 @@ def add_indicators(df):
|
||||
|
||||
def calculate_macd(df):
|
||||
"""计算MACD指标"""
|
||||
exp1 = df['close'].ewm(span=24, adjust=False).mean()
|
||||
exp2 = df['close'].ewm(span=52, adjust=False).mean()
|
||||
exp1 = df['close'].ewm(span=12, adjust=False).mean()
|
||||
exp2 = df['close'].ewm(span=26, adjust=False).mean()
|
||||
macd = exp1 - exp2
|
||||
signal = macd.ewm(span=18, adjust=False).mean()
|
||||
signal = macd.ewm(span=9, adjust=False).mean()
|
||||
histogram = macd - signal
|
||||
|
||||
return {
|
||||
@@ -1035,6 +1055,228 @@ def clean_dataframe_for_json(df):
|
||||
|
||||
return clean_df
|
||||
|
||||
# ====== 趋势判定与趋势筛选(币对) ======
|
||||
|
||||
def classify_trend_stage(df):
|
||||
"""根据 EMA 斜率与多空排列判断趋势方向与阶段
|
||||
返回: direction in {"bull","bear","sideways"}, stage in {"early","mid","late"}, strength_score (0-100)
|
||||
"""
|
||||
if df is None or len(df) < 60:
|
||||
return "sideways", "early", 0
|
||||
|
||||
# 使用 EMA5/10/24/52
|
||||
closes = df['close'].values
|
||||
ema5 = df['ema5'].values if 'ema5' in df else ta.EMA(df, timeperiod=5)
|
||||
ema10 = df['ema10'].values if 'ema10' in df else ta.EMA(df, timeperiod=10)
|
||||
ema24 = df['ema24'].values if 'ema24' in df else ta.EMA(df, timeperiod=24)
|
||||
ema52 = df['ema52'].values if 'ema52' in df else ta.EMA(df, timeperiod=52)
|
||||
|
||||
# 最近N根用于斜率与排列判定
|
||||
lookback = min(30, len(df) - 1)
|
||||
if lookback <= 5:
|
||||
return "sideways", "early", 0
|
||||
|
||||
# 简单斜率: 最近k根的线性变化率近似
|
||||
def slope(arr, k=10):
|
||||
k = min(k, len(arr) - 1)
|
||||
if k < 2:
|
||||
return 0.0
|
||||
y = arr[-k:]
|
||||
x = np.arange(k)
|
||||
# 最小二乘拟合斜率
|
||||
denom = np.dot(x - x.mean(), x - x.mean())
|
||||
if denom == 0:
|
||||
return 0.0
|
||||
m = np.dot(y - y.mean(), x - x.mean()) / denom
|
||||
return float(m)
|
||||
|
||||
k_slope = 12 # 斜率窗口
|
||||
s5 = slope(ema5, k_slope)
|
||||
s10 = slope(ema10, k_slope)
|
||||
s24 = slope(ema24, k_slope)
|
||||
s52 = slope(ema52, k_slope)
|
||||
|
||||
# 多空排列
|
||||
last5, last10, last24, last52 = ema5[-1], ema10[-1], ema24[-1], ema52[-1]
|
||||
bull_stack = last5 > last10 > last24 > last52
|
||||
bear_stack = last5 < last10 < last24 < last52
|
||||
|
||||
# 波动性与动量增强: MACD 柱体最近均值
|
||||
macdhist = df['macdhist'].values if 'macdhist' in df else calculate_macd(df)['histogram']
|
||||
hist_recent = macdhist[-lookback:]
|
||||
hist_power = float(np.mean(np.abs(hist_recent))) if len(hist_recent) else 0.0
|
||||
|
||||
# 方向
|
||||
if bull_stack and s24 > 0 and s52 > 0:
|
||||
direction = "bull"
|
||||
elif bear_stack and s24 < 0 and s52 < 0:
|
||||
direction = "bear"
|
||||
else:
|
||||
# 用价格相对 EMA52 辅助
|
||||
if closes[-1] > last52 and (s24 + s52) > 0:
|
||||
direction = "bull"
|
||||
elif closes[-1] < last52 and (s24 + s52) < 0:
|
||||
direction = "bear"
|
||||
else:
|
||||
direction = "sideways"
|
||||
|
||||
# 阶段: 依据(斜率大小、与EMA52距离、MACD柱体扩张/收敛)
|
||||
dist52 = float((closes[-1] - last52) / last52) if last52 else 0.0
|
||||
slope_score = max(0.0, (abs(s24) + abs(s52)) * 1000.0) # 归一化
|
||||
dist_score = min(50.0, abs(dist52) * 200.0)
|
||||
hist_score = min(30.0, hist_power * 10.0)
|
||||
strength = float(min(100.0, slope_score + dist_score + hist_score))
|
||||
|
||||
# 简单阶段判定
|
||||
if direction == "sideways":
|
||||
stage = "early"
|
||||
strength = min(strength, 30.0)
|
||||
else:
|
||||
# 查看最近 hist 是否在扩大或收敛
|
||||
if len(hist_recent) >= 6:
|
||||
recent_growth = np.mean(np.abs(hist_recent[-3:])) - np.mean(np.abs(hist_recent[-6:-3]))
|
||||
else:
|
||||
recent_growth = 0.0
|
||||
|
||||
if recent_growth > 0 and abs(dist52) < 0.05:
|
||||
stage = "early"
|
||||
elif recent_growth > 0 and abs(dist52) >= 0.05:
|
||||
stage = "mid"
|
||||
else:
|
||||
stage = "late"
|
||||
|
||||
return direction, stage, strength
|
||||
|
||||
|
||||
def load_crypto_symbols(limit=200):
|
||||
"""加载常见USDT永续合约交易对,返回列表"""
|
||||
try:
|
||||
markets = exchange.load_markets()
|
||||
symbols = [s for s in markets.keys() if '/USDT' in s and ':USDT' in s]
|
||||
return symbols[:limit]
|
||||
except Exception:
|
||||
return SYMBOLS
|
||||
|
||||
|
||||
@app.route('/api/trend_filter', methods=['GET'])
|
||||
def trend_filter():
|
||||
"""趋势筛选接口(币对)
|
||||
参数:
|
||||
timeframe: K线周期
|
||||
start_time, end_time: 毫秒时间戳,可选
|
||||
direction: bull/bear/sideways 可选
|
||||
stage: early/mid/late 可选
|
||||
min_strength: 0-100 可选
|
||||
symbols: 逗号分隔列表,可选;不传则自动加载部分USDT币对
|
||||
返回符合条件的币对与简要统计
|
||||
"""
|
||||
timeframe = request.args.get('timeframe', '1h')
|
||||
start_time = request.args.get('start_time')
|
||||
end_time = request.args.get('end_time')
|
||||
want_direction = request.args.get('direction') # 可为 None
|
||||
want_stage = request.args.get('stage') # 可为 None
|
||||
try:
|
||||
min_strength = float(request.args.get('min_strength', '0'))
|
||||
except ValueError:
|
||||
min_strength = 0.0
|
||||
|
||||
symbols_param = request.args.get('symbols')
|
||||
if symbols_param:
|
||||
symbols_list = [s.strip() for s in symbols_param.split(',') if s.strip()]
|
||||
else:
|
||||
symbols_list = load_crypto_symbols(limit=150)
|
||||
|
||||
results = []
|
||||
for sym in symbols_list:
|
||||
try:
|
||||
df = get_crypto_kl_data(sym, timeframe, start_time=start_time, end_time=end_time)
|
||||
if df is None or len(df) < 60:
|
||||
continue
|
||||
df = add_indicators(df)
|
||||
direction, stage, strength = classify_trend_stage(df)
|
||||
|
||||
if want_direction and direction != want_direction:
|
||||
continue
|
||||
if want_stage and stage != want_stage:
|
||||
continue
|
||||
if strength < min_strength:
|
||||
continue
|
||||
|
||||
last_row = df.iloc[-1]
|
||||
results.append({
|
||||
'symbol': sym,
|
||||
'time': int(last_row['timestamp']),
|
||||
'close': float(last_row['close']),
|
||||
'direction': direction,
|
||||
'stage': stage,
|
||||
'strength': float(round(strength, 2)),
|
||||
'ema5': float(last_row['ema5']),
|
||||
'ema10': float(last_row['ema10']),
|
||||
'ema24': float(last_row['ema24']),
|
||||
'ema52': float(last_row['ema52'])
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 按强度降序
|
||||
results.sort(key=lambda x: x['strength'], reverse=True)
|
||||
return jsonify({
|
||||
'count': len(results),
|
||||
'results': results
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/trend_detail', methods=['GET'])
|
||||
def trend_detail():
|
||||
"""返回单个币对的K线与EMA、用于前端绘制趋势线
|
||||
参数: symbol, timeframe, start_time, end_time
|
||||
"""
|
||||
symbol = request.args.get('symbol')
|
||||
timeframe = request.args.get('timeframe', '1h')
|
||||
start_time = request.args.get('start_time')
|
||||
end_time = request.args.get('end_time')
|
||||
timezone_name = request.args.get('timezone', 'Asia/Shanghai')
|
||||
|
||||
if not symbol:
|
||||
return jsonify({'error': 'symbol不能为空'})
|
||||
|
||||
df = get_crypto_kl_data(symbol, timeframe, start_time=start_time, end_time=end_time)
|
||||
if df is None or len(df) == 0:
|
||||
return jsonify({'error': '获取数据失败'})
|
||||
|
||||
df = add_indicators(df)
|
||||
direction, stage, strength = classify_trend_stage(df)
|
||||
|
||||
# 简单趋势线: 用最近N根收盘价做线性拟合
|
||||
N = min(80, len(df))
|
||||
sub = df.tail(N)
|
||||
y = sub['close'].values
|
||||
x = np.arange(len(y))
|
||||
denom = np.dot(x - x.mean(), x - x.mean())
|
||||
if denom != 0:
|
||||
m = float(np.dot(y - y.mean(), x - x.mean()) / denom)
|
||||
b = float(y.mean() - m * x.mean())
|
||||
else:
|
||||
m, b = 0.0, float(y[-1])
|
||||
|
||||
client_tz = timezone(timezone_name)
|
||||
|
||||
return jsonify({
|
||||
'symbol': symbol,
|
||||
'timeframe': timeframe,
|
||||
'timezone': timezone_name,
|
||||
'direction': direction,
|
||||
'stage': stage,
|
||||
'strength': float(round(strength, 2)),
|
||||
'kline_data': clean_dataframe_for_json(df)[['timestamp','open','high','low','close','volume','ema5','ema10','ema24','ema52']].to_dict('records'),
|
||||
'trend_line': {
|
||||
'offset': int(df.index[-N]),
|
||||
'slope': m,
|
||||
'intercept': b,
|
||||
'length': int(N)
|
||||
}
|
||||
})
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""主页"""
|
||||
|
||||
+497
-4
@@ -1057,6 +1057,9 @@
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="trade-points-tab" data-bs-toggle="tab" data-bs-target="#trade-points" type="button" role="tab">买卖点</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="trend-filter-tab" data-bs-toggle="tab" data-bs-target="#trend-filter" type="button" role="tab">趋势筛选(币对)</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="stock-filter-tab" data-bs-toggle="tab" data-bs-target="#stock-filter" type="button" role="tab">股票筛选</button>
|
||||
</li>
|
||||
@@ -1177,6 +1180,125 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="trend-filter" role="tabpanel">
|
||||
<div class="container-fluid">
|
||||
<div class="row g-3 mb-2">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">K线周期</label>
|
||||
<select id="trendTimeframe" class="form-select">
|
||||
<option value="1m">1m</option>
|
||||
<option value="5m">5m</option>
|
||||
<option value="15m" selected>15m</option>
|
||||
<option value="30m">30m</option>
|
||||
<option value="1h">1h</option>
|
||||
<option value="4h">4h</option>
|
||||
<option value="1d">1d</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">方向</label>
|
||||
<select id="trendDirection" class="form-select">
|
||||
<option value="">全部</option>
|
||||
<option value="bull">多头</option>
|
||||
<option value="bear">空头</option>
|
||||
<option value="sideways">盘整</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">阶段</label>
|
||||
<select id="trendStage" class="form-select">
|
||||
<option value="">全部</option>
|
||||
<option value="early">初期</option>
|
||||
<option value="mid">中期</option>
|
||||
<option value="late">末期</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">强度 (0-100)</label>
|
||||
<div class="d-flex align-items-center">
|
||||
<input type="range" id="trendMinStrength" class="form-range me-2" min="0" max="100" value="30">
|
||||
<input type="number" id="trendMinStrengthNum" class="form-control" style="width:90px" min="0" max="100" value="30">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">自选币对(逗号分隔)</label>
|
||||
<input id="trendSymbols" class="form-control" placeholder="例如: BTC/USDT:USDT,ETH/USDT:USDT,可留空">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">开始时间</label>
|
||||
<input type="datetime-local" id="trendStart" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">结束时间</label>
|
||||
<input type="datetime-local" id="trendEnd" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<button class="btn btn-primary w-100" id="btnTrendFilter">开始筛选</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-container mb-3">
|
||||
<div id="trendFilterStatus" class="alert alert-info py-2" style="display:none;">
|
||||
<span class="loading-spinner"></span>
|
||||
<span class="ms-2">正在筛选,请稍候...</span>
|
||||
</div>
|
||||
<table id="trendFilterTable" class="display compact" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>交易对</th>
|
||||
<th>时间</th>
|
||||
<th>方向</th>
|
||||
<th>阶段</th>
|
||||
<th>强度</th>
|
||||
<th>收盘</th>
|
||||
<th>EMA5</th>
|
||||
<th>EMA10</th>
|
||||
<th>EMA24</th>
|
||||
<th>EMA52</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
趋势详情
|
||||
<span id="trendDetailStatus" class="text-muted ms-3" style="display:none;">
|
||||
<span class="loading-spinner"></span>
|
||||
<span class="ms-2">正在加载详情...</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="trendChartContainer" style="height:420px;"></div>
|
||||
<div class="table-container mt-3">
|
||||
<table id="trendDetailTable" class="display compact" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>开</th>
|
||||
<th>高</th>
|
||||
<th>低</th>
|
||||
<th>收</th>
|
||||
<th>成交量</th>
|
||||
<th>EMA5</th>
|
||||
<th>EMA10</th>
|
||||
<th>EMA24</th>
|
||||
<th>EMA52</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="stock-filter" role="tabpanel">
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-3">
|
||||
@@ -1259,6 +1381,377 @@
|
||||
<script>
|
||||
let currentData = null;
|
||||
const tables = {};
|
||||
// ======= 趋势筛选(币对) =======
|
||||
let trendTable = null;
|
||||
let trendDetailTable = null;
|
||||
let trendChart = null;
|
||||
|
||||
function initTrendTables() {
|
||||
if (!trendTable) {
|
||||
trendTable = $('#trendFilterTable').DataTable({
|
||||
paging: true,
|
||||
searching: false,
|
||||
info: true,
|
||||
order: [[4, 'desc']],
|
||||
});
|
||||
}
|
||||
if (!trendDetailTable) {
|
||||
trendDetailTable = $('#trendDetailTable').DataTable({
|
||||
paging: true,
|
||||
searching: false,
|
||||
info: true,
|
||||
order: [[0, 'desc']],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function bindTrendControls() {
|
||||
// 双向绑定强度滑块与数字框
|
||||
$('#trendMinStrength').on('input change', function(){
|
||||
$('#trendMinStrengthNum').val($(this).val());
|
||||
});
|
||||
$('#trendMinStrengthNum').on('input change', function(){
|
||||
let v = Math.max(0, Math.min(100, parseFloat($(this).val()||0)));
|
||||
$(this).val(v);
|
||||
$('#trendMinStrength').val(v);
|
||||
});
|
||||
|
||||
// 周期变化时,自动填充当前时间回溯300根K线的时间范围
|
||||
$('#trendTimeframe').on('change', function(){
|
||||
const tf = $(this).val();
|
||||
const tfToMs = {
|
||||
'1m': 60*1000, '5m': 5*60*1000, '15m': 15*60*1000, '30m': 30*60*1000,
|
||||
'1h': 60*60*1000, '4h': 4*60*60*1000, '1d': 24*60*60*1000
|
||||
};
|
||||
const step = tfToMs[tf] || (60*60*1000);
|
||||
const now = new Date();
|
||||
const endMs = now.getTime();
|
||||
const startMs = endMs - 300 * step;
|
||||
const toLocal = (ms) => new Date(ms - new Date(ms).getTimezoneOffset()*60000).toISOString().slice(0,16);
|
||||
$('#trendEnd').val(toLocal(endMs));
|
||||
$('#trendStart').val(toLocal(startMs));
|
||||
});
|
||||
|
||||
$('#btnTrendFilter').on('click', async function(){
|
||||
await runTrendFilter();
|
||||
});
|
||||
}
|
||||
|
||||
async function runTrendFilter() {
|
||||
initTrendTables();
|
||||
trendTable.clear().draw();
|
||||
|
||||
const timeframe = $('#trendTimeframe').val();
|
||||
const direction = $('#trendDirection').val();
|
||||
const stage = $('#trendStage').val();
|
||||
const minStrength = $('#trendMinStrength').val();
|
||||
const symbols = $('#trendSymbols').val();
|
||||
let start = $('#trendStart').val();
|
||||
let end = $('#trendEnd').val();
|
||||
|
||||
// 前端必须提供时间范围:若为空,自动以当前时间回溯300根
|
||||
if (!start || !end) {
|
||||
const tfToMs = {
|
||||
'1m': 60*1000, '5m': 5*60*1000, '15m': 15*60*1000, '30m': 30*60*1000,
|
||||
'1h': 60*60*1000, '4h': 4*60*60*1000, '1d': 24*60*60*1000
|
||||
};
|
||||
const step = tfToMs[timeframe] || (60*60*1000);
|
||||
const now = Date.now();
|
||||
const startMsAuto = now - 300 * step;
|
||||
const toLocal = (ms) => new Date(ms - new Date(ms).getTimezoneOffset()*60000).toISOString().slice(0,16);
|
||||
if (!end) $('#trendEnd').val(toLocal(now));
|
||||
if (!start) $('#trendStart').val(toLocal(startMsAuto));
|
||||
start = $('#trendStart').val();
|
||||
end = $('#trendEnd').val();
|
||||
}
|
||||
|
||||
let startMs = start ? new Date(start).getTime() : '';
|
||||
let endMs = end ? new Date(end).getTime() : '';
|
||||
|
||||
const params = $.param({
|
||||
timeframe: timeframe,
|
||||
direction: direction || '',
|
||||
stage: stage || '',
|
||||
min_strength: minStrength,
|
||||
symbols: symbols || '',
|
||||
start_time: startMs || '',
|
||||
end_time: endMs || ''
|
||||
});
|
||||
|
||||
// 显示筛选状态
|
||||
$('#trendFilterStatus').show();
|
||||
try {
|
||||
const res = await $.getJSON(`/api/trend_filter?${params}`);
|
||||
// 初筛后端结果,再次用前端方向筛选(避免后端噪声)
|
||||
const dirVal = $('#trendDirection').val();
|
||||
const rows = (res.results || [])
|
||||
.filter(r => {
|
||||
if (!dirVal) return true;
|
||||
return r.direction === dirVal;
|
||||
})
|
||||
.map(r => [
|
||||
r.symbol,
|
||||
new Date(r.time).toLocaleString('zh-CN', { timeZone: $('#timezone').val() || 'Asia/Shanghai' }),
|
||||
r.direction === 'bull' ? '多头' : (r.direction === 'bear' ? '空头' : '盘整'),
|
||||
r.stage === 'early' ? '初期' : (r.stage === 'mid' ? '中期' : '末期'),
|
||||
r.strength,
|
||||
r.close,
|
||||
r.ema5,
|
||||
r.ema10,
|
||||
r.ema24,
|
||||
r.ema52,
|
||||
`<button class="btn btn-sm btn-outline-primary" data-symbol="${r.symbol}" data-timeframe="${timeframe}">查看</button>`
|
||||
]);
|
||||
trendTable.rows.add(rows).draw();
|
||||
|
||||
// 绑定查看按钮
|
||||
$('#trendFilterTable').off('click', 'button').on('click', 'button', function(){
|
||||
const sym = $(this).data('symbol');
|
||||
const tf = $(this).data('timeframe');
|
||||
loadTrendDetail(sym, tf, startMs, endMs);
|
||||
});
|
||||
|
||||
// 精细化阶段判定(前端基于明细重算)
|
||||
refineTrendStages(Array.from(new Set((res.results||[]).map(r => r.symbol))).slice(0, 20), timeframe, startMs, endMs);
|
||||
} catch (e) {
|
||||
alert('趋势筛选失败: ' + e);
|
||||
} finally {
|
||||
$('#trendFilterStatus').hide();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTrendDetail(symbol, timeframe, startMs, endMs) {
|
||||
const params = $.param({
|
||||
symbol: symbol,
|
||||
timeframe: timeframe,
|
||||
start_time: startMs || '',
|
||||
end_time: endMs || '',
|
||||
timezone: $('#timezone').val() || 'Asia/Shanghai'
|
||||
});
|
||||
|
||||
// 显示详情加载状态
|
||||
$('#trendDetailStatus').show();
|
||||
try {
|
||||
const data = await $.getJSON(`/api/trend_detail?${params}`);
|
||||
// 填表
|
||||
trendDetailTable.clear();
|
||||
(data.kline_data || []).forEach(row => {
|
||||
trendDetailTable.row.add([
|
||||
new Date(row.timestamp).toLocaleString('zh-CN', { timeZone: data.timezone }),
|
||||
row.open, row.high, row.low, row.close, row.volume,
|
||||
row.ema5, row.ema10, row.ema24, row.ema52
|
||||
]);
|
||||
});
|
||||
trendDetailTable.draw();
|
||||
|
||||
// 画图
|
||||
drawTrendChart(data);
|
||||
} catch (e) {
|
||||
alert('加载趋势详情失败: ' + e);
|
||||
} finally {
|
||||
$('#trendDetailStatus').hide();
|
||||
}
|
||||
}
|
||||
|
||||
function drawTrendChart(data) {
|
||||
const container = document.getElementById('trendChartContainer');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
|
||||
const chart = LightweightCharts.createChart(container, {
|
||||
layout: { background: { color: '#ffffff' }, textColor: '#333' },
|
||||
rightPriceScale: { visible: true },
|
||||
timeScale: { timeVisible: true, secondsVisible: false },
|
||||
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
|
||||
grid: { vertLines: { color: '#eee' }, horzLines: { color: '#eee' } },
|
||||
autoSize: true
|
||||
});
|
||||
trendChart = chart;
|
||||
|
||||
const candle = chart.addCandlestickSeries();
|
||||
// 关闭均线的价格线与最后值标签,仅保留K线的当前价格虚线
|
||||
const ema5 = chart.addLineSeries({ color: '#ff0000', lineWidth: 2, lastValueVisible: false, priceLineVisible: false });
|
||||
const ema10 = chart.addLineSeries({ color: '#2962FF', lineWidth: 2, lastValueVisible: false, priceLineVisible: false });
|
||||
const ema24 = chart.addLineSeries({ color: '#008000', lineWidth: 2, lastValueVisible: false, priceLineVisible: false });
|
||||
const ema52 = chart.addLineSeries({ color: '#800080', lineWidth: 2, lastValueVisible: false, priceLineVisible: false });
|
||||
|
||||
const k = (data.kline_data || []).map(r => ({
|
||||
time: Math.floor(r.timestamp / 1000),
|
||||
open: Number(r.open), high: Number(r.high), low: Number(r.low), close: Number(r.close)
|
||||
}));
|
||||
candle.setData(k);
|
||||
|
||||
// 前端过滤均线前导缺失/无效值,避免绘制为0
|
||||
const sanitizeMA = (field) => {
|
||||
const rows = data.kline_data || [];
|
||||
const out = [];
|
||||
let started = false;
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const r = rows[i];
|
||||
const raw = r[field];
|
||||
const v = Number(raw);
|
||||
const valid = Number.isFinite(v) && v > 0;
|
||||
if (!started) {
|
||||
if (!valid) continue;
|
||||
started = true;
|
||||
}
|
||||
if (!valid) continue;
|
||||
out.push({ time: Math.floor(r.timestamp / 1000), value: v });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
ema5.setData(sanitizeMA('ema5'));
|
||||
ema10.setData(sanitizeMA('ema10'));
|
||||
ema24.setData(sanitizeMA('ema24'));
|
||||
ema52.setData(sanitizeMA('ema52'));
|
||||
|
||||
// 趋势线(使用返回的拟合参数)
|
||||
const trend = data.trend_line || null;
|
||||
if (trend && k.length > 1) {
|
||||
const L = Math.min(trend.length, k.length);
|
||||
const startIdx = k.length - L;
|
||||
const lineData = [];
|
||||
for (let i = 0; i < L; i++) {
|
||||
const y = trend.slope * i + trend.intercept;
|
||||
const point = { time: k[startIdx + i].time, value: y };
|
||||
lineData.push(point);
|
||||
}
|
||||
const trendSeries = chart.addLineSeries({ color: '#ffa500', lineWidth: 2, lineStyle: 0, lastValueVisible: false, priceLineVisible: false });
|
||||
trendSeries.setData(lineData);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 前端精细化阶段判定 =====
|
||||
function computeEMA(arr, period) {
|
||||
const k = 2 / (period + 1);
|
||||
const out = [];
|
||||
let emaPrev = null;
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
const price = arr[i];
|
||||
if (price == null || !isFinite(price)) { out.push(null); continue; }
|
||||
if (emaPrev == null) {
|
||||
// 取首个可用SMA种子
|
||||
const start = Math.max(0, i - period + 1);
|
||||
const window = arr.slice(start, i + 1).filter(v => v != null && isFinite(v));
|
||||
const sma = window.length ? window.reduce((a,b)=>a+b,0)/window.length : price;
|
||||
emaPrev = sma;
|
||||
}
|
||||
const ema = price * k + emaPrev * (1 - k);
|
||||
out.push(ema);
|
||||
emaPrev = ema;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function computeMACDSeries(close) {
|
||||
const ema12 = computeEMA(close, 12);
|
||||
const ema26 = computeEMA(close, 26);
|
||||
const macd = close.map((_, i) => (ema12[i] != null && ema26[i] != null) ? (ema12[i] - ema26[i]) : null);
|
||||
const signal = computeEMA(macd.map(v => v ?? null), 9);
|
||||
const hist = macd.map((v, i) => (v != null && signal[i] != null) ? (v - signal[i]) : null);
|
||||
return { macd, signal, hist };
|
||||
}
|
||||
|
||||
function slope(series, win) {
|
||||
const n = series.length;
|
||||
const k = Math.min(win, n);
|
||||
if (k < 3) return 0;
|
||||
const y = series.slice(n - k).filter(v => v != null && isFinite(v));
|
||||
if (y.length < 3) return 0;
|
||||
const x = [...Array(y.length).keys()];
|
||||
const xm = x.reduce((a,b)=>a+b,0)/x.length;
|
||||
const ym = y.reduce((a,b)=>a+b,0)/y.length;
|
||||
let num = 0, den = 0;
|
||||
for (let i=0;i<x.length;i++){ num += (x[i]-xm)*(y[i]-ym); den += (x[i]-xm)*(x[i]-xm); }
|
||||
return den ? num/den : 0;
|
||||
}
|
||||
|
||||
function classifyStageFrontend(kline, directionHint) {
|
||||
const close = kline.map(r => Number(r.close));
|
||||
const ema24 = computeEMA(close, 24);
|
||||
const ema52 = computeEMA(close, 52);
|
||||
const last = close[close.length-1];
|
||||
const e24 = ema24[ema24.length-1];
|
||||
const e52 = ema52[ema52.length-1];
|
||||
const s24 = slope(ema24, 20);
|
||||
const s52 = slope(ema52, 30);
|
||||
const dist52 = (e52 && isFinite(e52)) ? (last - e52)/e52 : 0;
|
||||
const { hist } = computeMACDSeries(close);
|
||||
const recent = hist.slice(-9).filter(v => v != null);
|
||||
const earlier = hist.slice(-18, -9).filter(v => v != null);
|
||||
const growth = (recent.length && earlier.length) ? (avgAbs(recent) - avgAbs(earlier)) : 0;
|
||||
|
||||
function avgAbs(arr){ return arr.reduce((a,b)=>a+Math.abs(b),0)/arr.length; }
|
||||
|
||||
let direction = directionHint;
|
||||
if (!direction) {
|
||||
if (e24 > e52 && s24 > 0 && s52 > 0) direction = 'bull';
|
||||
else if (e24 < e52 && s24 < 0 && s52 < 0) direction = 'bear';
|
||||
else direction = 'sideways';
|
||||
}
|
||||
|
||||
let stage = 'early';
|
||||
const ad = Math.abs(dist52);
|
||||
if (direction === 'bull') {
|
||||
if (ad < 0.03 && growth > 0) stage = 'early';
|
||||
else if (ad < 0.10 && (growth >= 0 || s24 > 0)) stage = 'mid';
|
||||
else stage = 'late';
|
||||
} else if (direction === 'bear') {
|
||||
if (ad < 0.03 && growth > 0) stage = 'early';
|
||||
else if (ad < 0.10 && (growth >= 0 || s24 < 0)) stage = 'mid';
|
||||
else stage = 'late';
|
||||
} else {
|
||||
stage = 'early';
|
||||
}
|
||||
return { direction, stage };
|
||||
}
|
||||
|
||||
async function refineTrendStages(symbols, timeframe, startMs, endMs) {
|
||||
if (!symbols || symbols.length === 0) return;
|
||||
// 在表头上方提示
|
||||
const info = $('<div class="text-muted mb-2" id="refineInfo">正在优化阶段判定...</div>');
|
||||
$('#trendFilterTable').before(info);
|
||||
const tz = $('#timezone').val() || 'Asia/Shanghai';
|
||||
const selectedDir = $('#trendDirection').val(); // bull/bear/sideways/''
|
||||
for (const sym of symbols) {
|
||||
try {
|
||||
const params = $.param({ symbol: sym, timeframe, start_time: startMs, end_time: endMs, timezone: tz });
|
||||
const data = await $.getJSON(`/api/trend_detail?${params}`);
|
||||
const { direction, stage } = classifyStageFrontend(data.kline_data || [], null);
|
||||
// 若与选择的方向不一致,则在前端移除该行,避免“选择多头仍出现空头/盘整”
|
||||
if (selectedDir && direction !== selectedDir) {
|
||||
if (trendTable) {
|
||||
trendTable.rows().every(function(){
|
||||
const rowData = this.data();
|
||||
if (rowData && rowData[0] === sym) {
|
||||
this.remove();
|
||||
}
|
||||
});
|
||||
trendTable.draw(false);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 否则更新该行方向与阶段展示
|
||||
$('#trendFilterTable tbody tr').each(function(){
|
||||
const tds = $(this).find('td');
|
||||
if (tds.eq(0).text() === sym) {
|
||||
tds.eq(2).text(direction === 'bull' ? '多头' : (direction === 'bear' ? '空头' : '盘整'));
|
||||
tds.eq(3).text(stage === 'early' ? '初期' : stage === 'mid' ? '中期' : '末期');
|
||||
}
|
||||
});
|
||||
} catch(e) {
|
||||
// 忽略单个失败
|
||||
}
|
||||
}
|
||||
info.remove();
|
||||
}
|
||||
|
||||
// 页面初始化时绑定控件
|
||||
$(function(){
|
||||
initTrendTables();
|
||||
bindTrendControls();
|
||||
});
|
||||
let tvWidget = {
|
||||
mainChart: null,
|
||||
volumeChart: null,
|
||||
@@ -4314,7 +4807,7 @@
|
||||
if (fx.fx_strength < 1.0) { // 降低阈值,让更多分型显示
|
||||
displayText = fx.fx_strength >= 0.8 ? '' : '' // 0.8以上显示点,0.8以下不显示文本
|
||||
}
|
||||
displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("11", "").replace("21", "").replace("31", "");
|
||||
displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("1", "").replace("2", "").replace("3", "");
|
||||
// 添加标记配置
|
||||
const markerConfig = {
|
||||
time: timestamp,
|
||||
@@ -4378,7 +4871,7 @@
|
||||
if (fx.fx_strength < 1.0) { // 降低阈值,让更多分型显示
|
||||
displayText = fx.fx_strength >= 1.5 ? '' : '' // 0.8以上显示点,0.8以下不显示文本
|
||||
}
|
||||
displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("11", "").replace("12", "").replace("13", "");
|
||||
displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("1", "").replace("2", "").replace("3", "");
|
||||
// 添加标记配置
|
||||
const markerConfig = {
|
||||
time: timestamp,
|
||||
@@ -4462,7 +4955,7 @@
|
||||
if (fx.fx_strength < 1.0){ // 调整小周期阈值
|
||||
displayText = fx.fx_strength >= 0.6 ? '' : '' // 0.6以上显示点
|
||||
}
|
||||
displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("11", "").replace("21", "").replace("31", "");
|
||||
displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("1", "").replace("2", "").replace("3", "");
|
||||
// 小周期分型标记配置
|
||||
const markerConfig = {
|
||||
time: timestamp,
|
||||
@@ -4518,7 +5011,7 @@
|
||||
if (fx.fx_strength < 2.0){ // 调整小周期阈值
|
||||
displayText = fx.fx_strength >= 1.5 ? '' : '' // 0.6以上显示点
|
||||
}
|
||||
displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("11", "").replace("21", "").replace("13", "");
|
||||
displayText = fx.fx_type.replace("TOP", "").replace("BOTTOM", "").replace("1", "").replace("2", "").replace("3", "");
|
||||
// 小周期KLU分型标记配置
|
||||
const markerConfig = {
|
||||
time: timestamp,
|
||||
|
||||
Reference in New Issue
Block a user