feat(database): 添加交易统计模型和聚合逻辑
在数据库模型中新增了 `TradeStats` 类,包含交易统计功能,支持按交易对和日期聚合数据。实现了从 `binance_trades` 和 `trades` 表中提取交易数据的逻辑,并创建了相应的统计表 `trade_stats_daily` 和 `trade_stats_time_bucket`。此改动旨在增强交易数据分析能力,为后续的风险控制和决策提供支持。
This commit is contained in:
parent
30c5635570
commit
e2e7effca2
|
|
@ -1245,6 +1245,247 @@ class Trade:
|
||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
|
|
||||||
|
class TradeStats:
|
||||||
|
"""
|
||||||
|
交易统计:按交易对+日期、按小时聚合,写入 trade_stats_daily / trade_stats_time_bucket。
|
||||||
|
数据源优先 binance_trades(定时同步的币安成交),无表或无数据时回退到 trades。
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _binance_trades_exists():
|
||||||
|
try:
|
||||||
|
db.execute_one("SELECT 1 FROM binance_trades LIMIT 1")
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ensure_tables():
|
||||||
|
db.execute_update(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS trade_stats_daily (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
account_id INT NOT NULL,
|
||||||
|
trade_date DATE NOT NULL,
|
||||||
|
symbol VARCHAR(50) NOT NULL,
|
||||||
|
trade_count INT NOT NULL,
|
||||||
|
win_count INT NOT NULL,
|
||||||
|
loss_count INT NOT NULL,
|
||||||
|
gross_pnl DECIMAL(20,8) NOT NULL,
|
||||||
|
net_pnl DECIMAL(20,8) NOT NULL,
|
||||||
|
total_commission DECIMAL(20,8) NOT NULL,
|
||||||
|
avg_pnl_per_trade DECIMAL(20,8) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uniq_account_date_symbol (account_id, trade_date, symbol),
|
||||||
|
KEY idx_trade_date (trade_date),
|
||||||
|
KEY idx_symbol (symbol)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
db.execute_update(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS trade_stats_time_bucket (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
account_id INT NOT NULL,
|
||||||
|
trade_date DATE NOT NULL,
|
||||||
|
hour TINYINT NOT NULL,
|
||||||
|
trade_count INT NOT NULL,
|
||||||
|
win_count INT NOT NULL,
|
||||||
|
loss_count INT NOT NULL,
|
||||||
|
gross_pnl DECIMAL(20,8) NOT NULL,
|
||||||
|
net_pnl DECIMAL(20,8) NOT NULL,
|
||||||
|
total_commission DECIMAL(20,8) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uniq_account_date_hour (account_id, trade_date, hour),
|
||||||
|
KEY idx_trade_date (trade_date)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _aggregate_from_binance_trades(aid: int, start_ts: int, end_ts: int):
|
||||||
|
"""从 binance_trades 聚合,返回 (daily, hourly) 或 (None, None)。"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
start_ms = start_ts * 1000
|
||||||
|
end_ms = end_ts * 1000
|
||||||
|
try:
|
||||||
|
rows = db.execute_query(
|
||||||
|
"""SELECT symbol, trade_time, realized_pnl, commission
|
||||||
|
FROM binance_trades
|
||||||
|
WHERE account_id = %s AND trade_time >= %s AND trade_time <= %s
|
||||||
|
LIMIT 100000""",
|
||||||
|
(aid, start_ms, end_ms),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[TradeStats] 查询 binance_trades 失败: {e}")
|
||||||
|
return None, None
|
||||||
|
if not rows:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def to_date_hour(ts_ms):
|
||||||
|
try:
|
||||||
|
dt = datetime.fromtimestamp(int(ts_ms) // 1000, timezone.utc).astimezone(BEIJING_TZ)
|
||||||
|
return dt.date(), dt.hour
|
||||||
|
except Exception:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
daily, hourly = {}, {}
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
tm = r.get("trade_time")
|
||||||
|
if tm is None:
|
||||||
|
continue
|
||||||
|
date, hour = to_date_hour(tm)
|
||||||
|
if date is None:
|
||||||
|
continue
|
||||||
|
sym = (r.get("symbol") or "").strip()
|
||||||
|
if not sym:
|
||||||
|
continue
|
||||||
|
pnl = float(r.get("realized_pnl") or 0)
|
||||||
|
comm = float(r.get("commission") or 0)
|
||||||
|
dkey, hkey = (date, sym), (date, hour)
|
||||||
|
if dkey not in daily:
|
||||||
|
daily[dkey] = {"trade_count": 0, "win_count": 0, "loss_count": 0, "gross_pnl": 0.0, "total_commission": 0.0}
|
||||||
|
daily[dkey]["trade_count"] += 1
|
||||||
|
if pnl > 0:
|
||||||
|
daily[dkey]["win_count"] += 1
|
||||||
|
elif pnl < 0:
|
||||||
|
daily[dkey]["loss_count"] += 1
|
||||||
|
daily[dkey]["gross_pnl"] += pnl
|
||||||
|
daily[dkey]["total_commission"] += comm
|
||||||
|
if hkey not in hourly:
|
||||||
|
hourly[hkey] = {"trade_count": 0, "win_count": 0, "loss_count": 0, "gross_pnl": 0.0, "total_commission": 0.0}
|
||||||
|
hourly[hkey]["trade_count"] += 1
|
||||||
|
if pnl > 0:
|
||||||
|
hourly[hkey]["win_count"] += 1
|
||||||
|
elif pnl < 0:
|
||||||
|
hourly[hkey]["loss_count"] += 1
|
||||||
|
hourly[hkey]["gross_pnl"] += pnl
|
||||||
|
hourly[hkey]["total_commission"] += comm
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[TradeStats] 处理行失败: {e}")
|
||||||
|
return daily, hourly
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _aggregate_from_trades(aid: int, start_ts: int, end_ts: int):
|
||||||
|
"""从 trades 表聚合,返回 (daily, hourly)。"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
try:
|
||||||
|
rows = Trade.get_all(
|
||||||
|
start_timestamp=start_ts, end_timestamp=end_ts, account_id=aid,
|
||||||
|
time_filter="exit", limit=100000, reconciled_only=False,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[TradeStats] Trade.get_all 失败: {e}")
|
||||||
|
return {}, {}
|
||||||
|
if not rows:
|
||||||
|
return {}, {}
|
||||||
|
|
||||||
|
def to_date_hour(ts):
|
||||||
|
try:
|
||||||
|
dt = datetime.fromtimestamp(int(ts), timezone.utc).astimezone(BEIJING_TZ)
|
||||||
|
return dt.date(), dt.hour
|
||||||
|
except Exception:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
daily, hourly = {}, {}
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
ts = r.get("exit_time") or r.get("entry_time")
|
||||||
|
if not ts:
|
||||||
|
continue
|
||||||
|
date, hour = to_date_hour(ts)
|
||||||
|
if date is None:
|
||||||
|
continue
|
||||||
|
sym = (r.get("symbol") or "").strip()
|
||||||
|
if not sym:
|
||||||
|
continue
|
||||||
|
pnl = float(r.get("pnl") or 0)
|
||||||
|
comm = float(r.get("commission") or 0) if "commission" in r else 0
|
||||||
|
dkey, hkey = (date, sym), (date, hour)
|
||||||
|
if dkey not in daily:
|
||||||
|
daily[dkey] = {"trade_count": 0, "win_count": 0, "loss_count": 0, "gross_pnl": 0.0, "total_commission": 0.0}
|
||||||
|
daily[dkey]["trade_count"] += 1
|
||||||
|
if pnl > 0:
|
||||||
|
daily[dkey]["win_count"] += 1
|
||||||
|
elif pnl < 0:
|
||||||
|
daily[dkey]["loss_count"] += 1
|
||||||
|
daily[dkey]["gross_pnl"] += pnl
|
||||||
|
daily[dkey]["total_commission"] += comm
|
||||||
|
if hkey not in hourly:
|
||||||
|
hourly[hkey] = {"trade_count": 0, "win_count": 0, "loss_count": 0, "gross_pnl": 0.0, "total_commission": 0.0}
|
||||||
|
hourly[hkey]["trade_count"] += 1
|
||||||
|
if pnl > 0:
|
||||||
|
hourly[hkey]["win_count"] += 1
|
||||||
|
elif pnl < 0:
|
||||||
|
hourly[hkey]["loss_count"] += 1
|
||||||
|
hourly[hkey]["gross_pnl"] += pnl
|
||||||
|
hourly[hkey]["total_commission"] += comm
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"[TradeStats] 处理 trades 行失败: {e}")
|
||||||
|
return daily, hourly
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _upsert_stats(aid: int, daily: dict, hourly: dict):
|
||||||
|
"""把 daily/hourly 聚合结果写入 trade_stats_daily / trade_stats_time_bucket。"""
|
||||||
|
for (trade_date, symbol), v in daily.items():
|
||||||
|
tc = v["trade_count"]
|
||||||
|
net = v["gross_pnl"] - v["total_commission"]
|
||||||
|
avg = (net / tc) if tc > 0 else 0.0
|
||||||
|
try:
|
||||||
|
db.execute_update(
|
||||||
|
"""INSERT INTO trade_stats_daily (
|
||||||
|
account_id, trade_date, symbol, trade_count, win_count, loss_count,
|
||||||
|
gross_pnl, net_pnl, total_commission, avg_pnl_per_trade
|
||||||
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
trade_count = VALUES(trade_count), win_count = VALUES(win_count),
|
||||||
|
loss_count = VALUES(loss_count), gross_pnl = VALUES(gross_pnl),
|
||||||
|
net_pnl = VALUES(net_pnl), total_commission = VALUES(total_commission),
|
||||||
|
avg_pnl_per_trade = VALUES(avg_pnl_per_trade)""",
|
||||||
|
(aid, trade_date, symbol, tc, v["win_count"], v["loss_count"],
|
||||||
|
v["gross_pnl"], net, v["total_commission"], avg),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[TradeStats] 写入 daily 失败 {symbol} {trade_date}: {e}")
|
||||||
|
for (trade_date, hour), v in hourly.items():
|
||||||
|
net = v["gross_pnl"] - v["total_commission"]
|
||||||
|
try:
|
||||||
|
db.execute_update(
|
||||||
|
"""INSERT INTO trade_stats_time_bucket (
|
||||||
|
account_id, trade_date, hour, trade_count, win_count, loss_count,
|
||||||
|
gross_pnl, net_pnl, total_commission
|
||||||
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
trade_count = VALUES(trade_count), win_count = VALUES(win_count),
|
||||||
|
loss_count = VALUES(loss_count), gross_pnl = VALUES(gross_pnl),
|
||||||
|
net_pnl = VALUES(net_pnl), total_commission = VALUES(total_commission)""",
|
||||||
|
(aid, trade_date, int(hour), v["trade_count"], v["win_count"], v["loss_count"],
|
||||||
|
v["gross_pnl"], net, v["total_commission"]),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[TradeStats] 写入 time_bucket 失败 {trade_date} h={hour}: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def aggregate_recent_days(days: int = 7, account_id: int = None):
|
||||||
|
"""聚合最近 N 天到统计表。优先 binance_trades,无数据则用 trades。"""
|
||||||
|
if days <= 0:
|
||||||
|
return
|
||||||
|
TradeStats._ensure_tables()
|
||||||
|
aid = int(account_id or DEFAULT_ACCOUNT_ID)
|
||||||
|
now_ts = get_beijing_time()
|
||||||
|
start_ts = now_ts - int(days) * 86400
|
||||||
|
daily, hourly = None, None
|
||||||
|
if TradeStats._binance_trades_exists():
|
||||||
|
daily, hourly = TradeStats._aggregate_from_binance_trades(aid, start_ts, now_ts)
|
||||||
|
if daily is None or hourly is None:
|
||||||
|
daily, hourly = TradeStats._aggregate_from_trades(aid, start_ts, now_ts)
|
||||||
|
if daily or hourly:
|
||||||
|
TradeStats._upsert_stats(aid, daily or {}, hourly or {})
|
||||||
|
|
||||||
|
|
||||||
class AccountSnapshot:
|
class AccountSnapshot:
|
||||||
"""账户快照模型"""
|
"""账户快照模型"""
|
||||||
|
|
||||||
|
|
|
||||||
39
scripts/aggregate_trade_stats.py
Normal file
39
scripts/aggregate_trade_stats.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
定时任务:将最近 N 天的交易数据聚合到 trade_stats_daily / trade_stats_time_bucket。
|
||||||
|
优先从 binance_trades 读取(需先跑 sync_binance_orders.py),无数据时用 trades 表。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python scripts/aggregate_trade_stats.py # 默认 7 天、默认账号
|
||||||
|
python scripts/aggregate_trade_stats.py -d 30 # 最近 30 天
|
||||||
|
python scripts/aggregate_trade_stats.py -a 2 # 指定账号
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
proj = Path(__file__).resolve().parent.parent
|
||||||
|
if (proj / "backend").exists():
|
||||||
|
sys.path.insert(0, str(proj / "backend"))
|
||||||
|
sys.path.insert(0, str(proj))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="聚合交易统计到 trade_stats_* 表")
|
||||||
|
parser.add_argument("-a", "--account", type=int, default=None, help="账号 ID,不传则用默认")
|
||||||
|
parser.add_argument("-d", "--days", type=int, default=7, help="聚合最近 N 天,默认 7")
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.days <= 0:
|
||||||
|
print("days 须 > 0")
|
||||||
|
sys.exit(1)
|
||||||
|
try:
|
||||||
|
from database.models import TradeStats
|
||||||
|
TradeStats.aggregate_recent_days(days=args.days, account_id=args.account)
|
||||||
|
print(f"已聚合最近 {args.days} 天统计 (account_id={args.account or 'default'})")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"聚合失败: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Reference in New Issue
Block a user