添加k线动能理论

This commit is contained in:
jackyu66git
2025-08-14 02:29:27 +08:00
parent 285f62f1ab
commit 15a3df55db
16 changed files with 1400 additions and 170 deletions
+17
View File
@@ -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"]
+48
View File
@@ -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` 后重启。
+207
View File
@@ -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-DDUTC 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,
}
+57
View File
@@ -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])
+19
View File
@@ -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
+7
View File
@@ -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