Initial commit: 首次建仓,建立目录结构
This commit is contained in:
499
dashboard/main.py
Normal file
499
dashboard/main.py
Normal file
@ -0,0 +1,499 @@
|
||||
"""
|
||||
Beast Trader API — 市场结构、信号诊断、交易监测数据
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import hmac
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
import ccxt
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
from fastapi import FastAPI, HTTPException, Request, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
import uvicorn
|
||||
|
||||
from indicators import compute_all_indicators
|
||||
|
||||
# =============================================
|
||||
# 配置
|
||||
# =============================================
|
||||
API_KEY = os.environ.get("BEAST_API_KEY", "beast2025")
|
||||
BINANCE_RATE_LIMIT_DELAY = 1.0 # 秒
|
||||
DATA_CACHE_SECONDS = 30 # 缓存时间
|
||||
|
||||
# Freqtrade 数据库路径(只读)
|
||||
FREQTRADE_DB = os.environ.get(
|
||||
"FREQTRADE_DB",
|
||||
os.path.expanduser("~/freqtrade/user_data/tradesv3.sqlite")
|
||||
)
|
||||
INITIAL_WALLET = float(os.environ.get("INITIAL_WALLET", 10000))
|
||||
|
||||
app = FastAPI(title="Beast Trader API", version="1.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# =============================================
|
||||
# 缓存
|
||||
# =============================================
|
||||
_cache = {"data": None, "timestamp": 0}
|
||||
|
||||
# =============================================
|
||||
# 认证中间件
|
||||
# =============================================
|
||||
@app.middleware("http")
|
||||
async def auth_middleware(request: Request, call_next):
|
||||
if request.method == "OPTIONS" or request.url.path in ["/api/health", "/", "/index.html"]:
|
||||
return await call_next(request)
|
||||
|
||||
auth = request.headers.get("X-API-Key")
|
||||
if not auth or auth != API_KEY:
|
||||
raise HTTPException(status_code=401, detail="Invalid API Key")
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# =============================================
|
||||
# 数据获取
|
||||
# =============================================
|
||||
def fetch_binance_data():
|
||||
"""获取 BTC 和 ETH 的 D1/4H/1H K线数据 + 实时ticker"""
|
||||
exchange = ccxt.binance({
|
||||
"enableRateLimit": True,
|
||||
})
|
||||
|
||||
now = exchange.milliseconds()
|
||||
since_1d = now - 180 * 24 * 60 * 60 * 1000 # 180天 D1
|
||||
since_4h = now - 60 * 24 * 60 * 60 * 1000 # 60天 4H
|
||||
since_1h = now - 14 * 24 * 60 * 60 * 1000 # 14天 1H
|
||||
|
||||
result = {}
|
||||
|
||||
for symbol in ["ETH/USDT", "BTC/USDT"]:
|
||||
try:
|
||||
ohlcv_1d = exchange.fetch_ohlcv(symbol, "1d", since_1d, 200)
|
||||
ohlcv_4h = exchange.fetch_ohlcv(symbol, "4h", since_4h, 400)
|
||||
ohlcv_1h = exchange.fetch_ohlcv(symbol, "1h", since_1h, 400)
|
||||
ticker = exchange.fetch_ticker(symbol)
|
||||
|
||||
df_1d = pd.DataFrame(ohlcv_1d, columns=["timestamp", "open", "high", "low", "close", "volume"])
|
||||
df_1d["timestamp"] = pd.to_datetime(df_1d["timestamp"], unit="ms")
|
||||
df_1d.set_index("timestamp", inplace=True)
|
||||
|
||||
df_4h = pd.DataFrame(ohlcv_4h, columns=["timestamp", "open", "high", "low", "close", "volume"])
|
||||
df_4h["timestamp"] = pd.to_datetime(df_4h["timestamp"], unit="ms")
|
||||
df_4h.set_index("timestamp", inplace=True)
|
||||
|
||||
df_1h = pd.DataFrame(ohlcv_1h, columns=["timestamp", "open", "high", "low", "close", "volume"])
|
||||
df_1h["timestamp"] = pd.to_datetime(df_1h["timestamp"], unit="ms")
|
||||
df_1h.set_index("timestamp", inplace=True)
|
||||
|
||||
result[symbol] = {"1d": df_1d, "4h": df_4h, "1h": df_1h, "ticker": ticker}
|
||||
time.sleep(0.5) # rate limit
|
||||
except Exception as e:
|
||||
print(f"Error fetching {symbol}: {e}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_cached_data():
|
||||
"""获取缓存数据或刷新"""
|
||||
now = time.time()
|
||||
if now - _cache["timestamp"] < DATA_CACHE_SECONDS and _cache["data"] is not None:
|
||||
return _cache["data"]
|
||||
|
||||
raw = fetch_binance_data()
|
||||
result = {}
|
||||
for symbol, frames in raw.items():
|
||||
try:
|
||||
computed = compute_all_indicators(frames["1h"], frames["4h"], frames["1d"])
|
||||
# 用实时ticker覆盖current_price(指标里用的是1H open,不实时)
|
||||
price = frames.get("ticker", {}).get("last")
|
||||
if price is not None:
|
||||
computed["current_price"] = round(float(price), 2)
|
||||
result[symbol] = computed
|
||||
# 附加最近K线数据用于图表
|
||||
result[symbol]["price_history"] = {
|
||||
"1h": {
|
||||
"timestamps": frames["1h"].index[-48:].strftime("%m-%d %H:%M").tolist(),
|
||||
"close": [round(float(x), 2) for x in frames["1h"]["close"].iloc[-48:].tolist()],
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error computing {symbol}: {e}")
|
||||
result[symbol] = {"error": str(e)}
|
||||
|
||||
_cache["data"] = result
|
||||
_cache["timestamp"] = now
|
||||
return result
|
||||
|
||||
|
||||
# =============================================
|
||||
# API 路由
|
||||
# =============================================
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"status": "ok", "time": datetime.now(timezone.utc).isoformat()}
|
||||
|
||||
|
||||
@app.get("/api/market-structure")
|
||||
def market_structure():
|
||||
"""
|
||||
市场结构分析:S/R 位、Swing Point、趋势方向、供需区
|
||||
"""
|
||||
data = get_cached_data()
|
||||
eth = data.get("ETH/USDT", {})
|
||||
btc = data.get("BTC/USDT", {})
|
||||
|
||||
if "error" in eth:
|
||||
raise HTTPException(status_code=500, detail=eth["error"])
|
||||
|
||||
return {
|
||||
"eth": {
|
||||
"current_price": eth.get("current_price"),
|
||||
"support": eth.get("diagnosis", {}).get("support"),
|
||||
"resistance": eth.get("diagnosis", {}).get("resistance"),
|
||||
"zone_width_pct": eth.get("diagnosis", {}).get("zone_width_pct"),
|
||||
"price_position_pct": eth.get("diagnosis", {}).get("price_position_in_zone"),
|
||||
"trend_1d": eth.get("trend_1d"),
|
||||
"trend_strength_4h": eth.get("trend_strength_4h"),
|
||||
"swing_points": eth.get("swing_points"),
|
||||
"price_history_1h": eth.get("price_history", {}).get("1h"),
|
||||
},
|
||||
"btc": {
|
||||
"current_price": btc.get("current_price") if "error" not in btc else None,
|
||||
"support_4h": btc.get("diagnosis", {}).get("support_4h"),
|
||||
"resistance_4h": btc.get("diagnosis", {}).get("resistance_4h"),
|
||||
"trend_1d": btc.get("trend_1d"),
|
||||
},
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/signal-diagnosis")
|
||||
def signal_diagnosis():
|
||||
"""
|
||||
信号诊断:8 层过滤条件逐一检查
|
||||
"""
|
||||
data = get_cached_data()
|
||||
eth = data.get("ETH/USDT", {})
|
||||
btc = data.get("BTC/USDT", {})
|
||||
|
||||
if "error" in eth:
|
||||
raise HTTPException(status_code=500, detail=eth["error"])
|
||||
|
||||
return {
|
||||
"eth": {
|
||||
"can_enter_long": eth.get("can_enter_long"),
|
||||
"can_enter_short": eth.get("can_enter_short"),
|
||||
"diagnosis": eth.get("diagnosis"),
|
||||
"filters": eth.get("filters"),
|
||||
"candle_1h": eth.get("candle_1h"),
|
||||
"trend_strength_4h": eth.get("trend_strength_4h"),
|
||||
},
|
||||
"btc": {
|
||||
"can_enter_long": btc.get("can_enter_long"),
|
||||
"can_enter_short": btc.get("can_enter_short"),
|
||||
"diagnosis": btc.get("diagnosis"),
|
||||
"filters": btc.get("filters"),
|
||||
} if "error" not in btc else {"error": btc.get("error")},
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# =============================================
|
||||
# 静态文件服务(前端)
|
||||
# =============================================
|
||||
FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "..", "frontend")
|
||||
|
||||
@app.get("/")
|
||||
def serve_index():
|
||||
return FileResponse(os.path.join(FRONTEND_DIR, "index.html"))
|
||||
|
||||
|
||||
# =============================================
|
||||
# 交易监测 API(读取 freqtrade SQLite 数据库)
|
||||
# =============================================
|
||||
|
||||
def _get_db():
|
||||
"""获取只读数据库连接"""
|
||||
conn = sqlite3.connect(FREQTRADE_DB)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def _row_to_dict(row):
|
||||
"""将 sqlite3.Row 转为 dict,处理 datetime 和 boolean"""
|
||||
if row is None:
|
||||
return None
|
||||
d = {}
|
||||
for k in row.keys():
|
||||
v = row[k]
|
||||
if isinstance(v, bytes):
|
||||
d[k] = v.decode("utf-8", errors="replace")
|
||||
elif hasattr(v, "isoformat"):
|
||||
d[k] = v.isoformat() if v else None
|
||||
else:
|
||||
d[k] = v
|
||||
return d
|
||||
|
||||
|
||||
@app.get("/api/account")
|
||||
def account_overview():
|
||||
"""
|
||||
账户概览:当前余额、盈亏统计、策略信息
|
||||
数据来源:freqtrade SQLite 数据库 (wallet_history + trades)
|
||||
"""
|
||||
try:
|
||||
conn = _get_db()
|
||||
|
||||
# 最新钱包余额
|
||||
wallet = conn.execute(
|
||||
"SELECT * FROM wallet_history ORDER BY timestamp DESC LIMIT 1"
|
||||
).fetchone()
|
||||
|
||||
# 交易统计
|
||||
stats = conn.execute("""
|
||||
SELECT
|
||||
COUNT(*) as total_trades,
|
||||
SUM(CASE WHEN is_open = 1 THEN 1 ELSE 0 END) as open_trades,
|
||||
SUM(CASE WHEN is_open = 0 THEN 1 ELSE 0 END) as closed_trades,
|
||||
SUM(CASE WHEN is_open = 0 AND close_profit > 0 THEN 1 ELSE 0 END) as winning_trades,
|
||||
SUM(CASE WHEN is_open = 0 AND close_profit <= 0 THEN 1 ELSE 0 END) as losing_trades,
|
||||
ROUND(SUM(close_profit_abs), 8) as total_profit_abs,
|
||||
ROUND(AVG(CASE WHEN is_open = 0 THEN close_profit ELSE NULL END), 6) as avg_profit_pct,
|
||||
ROUND(MAX(CASE WHEN is_open = 0 THEN close_profit ELSE NULL END), 6) as best_trade_pct,
|
||||
ROUND(MIN(CASE WHEN is_open = 0 THEN close_profit ELSE NULL END), 6) as worst_trade_pct
|
||||
FROM trades
|
||||
""").fetchone()
|
||||
|
||||
# 当前持仓详情
|
||||
open_positions = conn.execute("""
|
||||
SELECT id, pair, is_short, open_rate, stake_amount, amount,
|
||||
open_date, stop_loss, max_rate, min_rate,
|
||||
ROUND((open_rate - ?) / open_rate * 100, 4) as unrealized_pnl_pct
|
||||
FROM trades
|
||||
WHERE is_open = 1
|
||||
ORDER BY open_date DESC
|
||||
""", [0]).fetchall() # placeholder, real current price handled in response
|
||||
|
||||
# 策略名称
|
||||
strategy_row = conn.execute(
|
||||
"SELECT DISTINCT strategy FROM trades WHERE strategy IS NOT NULL ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
strategy_name = strategy_row["strategy"] if strategy_row else "v2.2c"
|
||||
|
||||
# 近期每日盈亏(近30天)
|
||||
daily_pnl = conn.execute("""
|
||||
SELECT DATE(close_date) as day,
|
||||
ROUND(SUM(close_profit_abs), 8) as pnl_abs,
|
||||
COUNT(*) as trade_count,
|
||||
SUM(CASE WHEN close_profit > 0 THEN 1 ELSE 0 END) as wins
|
||||
FROM trades
|
||||
WHERE is_open = 0 AND close_date IS NOT NULL
|
||||
GROUP BY DATE(close_date)
|
||||
ORDER BY day DESC
|
||||
LIMIT 30
|
||||
""").fetchall()
|
||||
|
||||
conn.close()
|
||||
|
||||
# 计算当前余额
|
||||
if wallet:
|
||||
current_balance = wallet["total_quote"] or wallet["balance"]
|
||||
last_update = _row_to_dict(wallet)["timestamp"]
|
||||
else:
|
||||
current_balance = INITIAL_WALLET
|
||||
last_update = None
|
||||
|
||||
# 计算总收益
|
||||
total_pnl_pct = round((current_balance - INITIAL_WALLET) / INITIAL_WALLET * 100, 2) if current_balance else 0
|
||||
|
||||
# 胜率
|
||||
closed = stats["closed_trades"] or 0
|
||||
wins = stats["winning_trades"] or 0
|
||||
win_rate = round(wins / closed * 100, 1) if closed > 0 else 0
|
||||
|
||||
return {
|
||||
"account": {
|
||||
"initial_wallet": INITIAL_WALLET,
|
||||
"current_balance": round(current_balance, 2),
|
||||
"total_pnl_pct": total_pnl_pct,
|
||||
"total_pnl_abs": round(current_balance - INITIAL_WALLET, 2),
|
||||
"last_update": last_update,
|
||||
"strategy": strategy_name,
|
||||
},
|
||||
"stats": {
|
||||
"total_trades": stats["total_trades"] or 0,
|
||||
"open_trades": stats["open_trades"] or 0,
|
||||
"closed_trades": closed,
|
||||
"win_rate": win_rate,
|
||||
"wins": wins,
|
||||
"losses": stats["losing_trades"] or 0,
|
||||
"avg_profit_pct": stats["avg_profit_pct"],
|
||||
"best_trade_pct": stats["best_trade_pct"],
|
||||
"worst_trade_pct": stats["worst_trade_pct"],
|
||||
"total_profit_abs": stats["total_profit_abs"] or 0,
|
||||
},
|
||||
"open_positions": [_row_to_dict(r) for r in open_positions],
|
||||
"daily_pnl": [_row_to_dict(r) for r in daily_pnl],
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
||||
|
||||
|
||||
@app.get("/api/trades")
|
||||
def trade_list(
|
||||
limit: int = Query(default=30, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
status: str = Query(default="all", pattern="^(all|open|closed)$"),
|
||||
sort: str = Query(default="desc", pattern="^(asc|desc)$"),
|
||||
):
|
||||
"""
|
||||
交易列表:支持分页和状态筛选
|
||||
"""
|
||||
try:
|
||||
conn = _get_db()
|
||||
|
||||
where_clause = ""
|
||||
if status == "open":
|
||||
where_clause = "WHERE is_open = 1"
|
||||
elif status == "closed":
|
||||
where_clause = "WHERE is_open = 0"
|
||||
|
||||
order = "DESC" if sort == "desc" else "ASC"
|
||||
|
||||
rows = conn.execute(f"""
|
||||
SELECT id, pair, is_open, is_short, open_rate, close_rate,
|
||||
close_profit, close_profit_abs, stake_amount, amount,
|
||||
open_date, close_date, exit_reason, strategy,
|
||||
max_rate, min_rate, stop_loss, initial_stop_loss,
|
||||
enter_tag, leverage
|
||||
FROM trades
|
||||
{where_clause}
|
||||
ORDER BY open_date {order}
|
||||
LIMIT ? OFFSET ?
|
||||
""", [limit, offset]).fetchall()
|
||||
|
||||
total = conn.execute(f"SELECT COUNT(*) as cnt FROM trades {where_clause}").fetchone()
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"trades": [_row_to_dict(r) for r in rows],
|
||||
"pagination": {
|
||||
"total": total["cnt"],
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": offset + limit < total["cnt"],
|
||||
},
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
||||
|
||||
|
||||
@app.get("/api/trades/stats")
|
||||
def trade_stats():
|
||||
"""
|
||||
交易统计:按方向/时间/策略维度的聚合数据
|
||||
"""
|
||||
try:
|
||||
conn = _get_db()
|
||||
|
||||
# 按方向
|
||||
by_direction = conn.execute("""
|
||||
SELECT
|
||||
CASE WHEN is_short = 1 THEN 'SHORT' ELSE 'LONG' END as direction,
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN is_open = 0 AND close_profit > 0 THEN 1 ELSE 0 END) as wins,
|
||||
SUM(CASE WHEN is_open = 0 AND close_profit <= 0 THEN 1 ELSE 0 END) as losses,
|
||||
ROUND(AVG(CASE WHEN is_open = 0 THEN close_profit ELSE NULL END), 6) as avg_profit_pct,
|
||||
ROUND(SUM(CASE WHEN is_open = 0 THEN close_profit_abs ELSE 0 END), 8) as total_pnl_abs
|
||||
FROM trades
|
||||
WHERE is_open = 0
|
||||
GROUP BY is_short
|
||||
""").fetchall()
|
||||
|
||||
# 按退出原因
|
||||
by_exit = conn.execute("""
|
||||
SELECT
|
||||
exit_reason,
|
||||
COUNT(*) as count,
|
||||
ROUND(AVG(close_profit), 6) as avg_profit_pct,
|
||||
ROUND(SUM(close_profit_abs), 8) as total_pnl_abs
|
||||
FROM trades
|
||||
WHERE is_open = 0 AND exit_reason IS NOT NULL
|
||||
GROUP BY exit_reason
|
||||
ORDER BY count DESC
|
||||
LIMIT 10
|
||||
""").fetchall()
|
||||
|
||||
# 月度表现
|
||||
monthly = conn.execute("""
|
||||
SELECT
|
||||
strftime('%Y-%m', open_date) as month,
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN close_profit > 0 THEN 1 ELSE 0 END) as wins,
|
||||
ROUND(AVG(close_profit), 6) as avg_profit_pct,
|
||||
ROUND(SUM(close_profit_abs), 8) as total_pnl_abs
|
||||
FROM trades
|
||||
WHERE is_open = 0
|
||||
GROUP BY strftime('%Y-%m', open_date)
|
||||
ORDER BY month DESC
|
||||
LIMIT 12
|
||||
""").fetchall()
|
||||
|
||||
# 盈亏分布(区间统计)
|
||||
distribution = conn.execute("""
|
||||
SELECT
|
||||
CASE
|
||||
WHEN close_profit <= -0.10 THEN '≤ -10%'
|
||||
WHEN close_profit <= -0.05 THEN '-10% ~ -5%'
|
||||
WHEN close_profit <= -0.02 THEN '-5% ~ -2%'
|
||||
WHEN close_profit < 0 THEN '-2% ~ 0%'
|
||||
WHEN close_profit = 0 THEN '0%'
|
||||
WHEN close_profit <= 0.02 THEN '0% ~ 2%'
|
||||
WHEN close_profit <= 0.05 THEN '2% ~ 5%'
|
||||
WHEN close_profit <= 0.10 THEN '5% ~ 10%'
|
||||
WHEN close_profit <= 0.20 THEN '10% ~ 20%'
|
||||
ELSE '> 20%'
|
||||
END as profit_range,
|
||||
COUNT(*) as count
|
||||
FROM trades
|
||||
WHERE is_open = 0
|
||||
GROUP BY 1
|
||||
ORDER BY MIN(close_profit)
|
||||
""").fetchall()
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"by_direction": [_row_to_dict(r) for r in by_direction],
|
||||
"by_exit_reason": [_row_to_dict(r) for r in by_exit],
|
||||
"monthly": [_row_to_dict(r) for r in monthly],
|
||||
"profit_distribution": [_row_to_dict(r) for r in distribution],
|
||||
"cached_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
||||
|
||||
|
||||
# =============================================
|
||||
# 启动
|
||||
# =============================================
|
||||
if __name__ == "__main__":
|
||||
port = int(os.environ.get("PORT", 8000))
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=port, reload=False)
|
||||
Reference in New Issue
Block a user