From e3ecaf12327bc3be490a0948e64c5fd4e3764e6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=87=E8=96=87=E5=AE=89?= Date: Sun, 18 Jan 2026 19:44:24 +0800 Subject: [PATCH] a --- backend/api/routes/account.py | 23 +++- backend/api/routes/trades.py | 5 + .../add_trade_size_and_risk_fields.sql | 105 ++++++++++++++++++ backend/database/init.sql | 16 ++- backend/database/models.py | 101 +++++++++++++---- frontend/src/components/TradeList.jsx | 47 ++++++-- trading_system/position_manager.py | 41 +++++-- 7 files changed, 290 insertions(+), 48 deletions(-) create mode 100644 backend/database/add_trade_size_and_risk_fields.sql diff --git a/backend/api/routes/account.py b/backend/api/routes/account.py index 253b878..0ef1fa1 100644 --- a/backend/api/routes/account.py +++ b/backend/api/routes/account.py @@ -275,10 +275,15 @@ async def get_realtime_positions(): if margin > 0: pnl_percent = (unrealized_pnl / margin) * 100 - # 尝试从数据库获取开仓时间、止损止盈价格 + # 尝试从数据库获取开仓时间、止损止盈价格(以及交易规模字段) entry_time = None stop_loss_price = None take_profit_price = None + take_profit_1 = None + take_profit_2 = None + atr_value = None + db_margin_usdt = None + db_notional_usdt = None try: from database.models import Trade db_trades = Trade.get_by_symbol(pos.get('symbol'), status='open') @@ -290,6 +295,11 @@ async def get_realtime_positions(): # 尝试从数据库获取止损止盈价格(如果存储了) stop_loss_price = db_trade.get('stop_loss_price') take_profit_price = db_trade.get('take_profit_price') + take_profit_1 = db_trade.get('take_profit_1') + take_profit_2 = db_trade.get('take_profit_2') + atr_value = db_trade.get('atr') + db_margin_usdt = db_trade.get('margin_usdt') + db_notional_usdt = db_trade.get('notional_usdt') break except Exception as e: logger.debug(f"获取数据库信息失败: {e}") @@ -302,14 +312,21 @@ async def get_realtime_positions(): "side": "BUY" if position_amt > 0 else "SELL", "quantity": abs(position_amt), "entry_price": entry_price, - "entry_value_usdt": entry_value_usdt, # 开仓时的USDT数量 + # 兼容旧字段:entry_value_usdt 仍保留(前端已有使用) + "entry_value_usdt": entry_value_usdt, + # 新字段:名义/保证金(若DB有则优先使用DB;否则使用实时计算) + "notional_usdt": db_notional_usdt if db_notional_usdt is not None else entry_value_usdt, + "margin_usdt": db_margin_usdt if db_margin_usdt is not None else margin, "mark_price": mark_price, "pnl": unrealized_pnl, "pnl_percent": pnl_percent, # 基于保证金的盈亏百分比 "leverage": int(pos.get('leverage', 1)), "entry_time": entry_time, # 开仓时间 "stop_loss_price": stop_loss_price, # 止损价格(如果可用) - "take_profit_price": take_profit_price # 止盈价格(如果可用) + "take_profit_price": take_profit_price, # 止盈价格(如果可用) + "take_profit_1": take_profit_1, + "take_profit_2": take_profit_2, + "atr": atr_value, }) logger.info(f"格式化后 {len(formatted_positions)} 个有效持仓") diff --git a/backend/api/routes/trades.py b/backend/api/routes/trades.py index e28d269..50156e0 100644 --- a/backend/api/routes/trades.py +++ b/backend/api/routes/trades.py @@ -231,6 +231,11 @@ async def get_trade_stats( "win_rate": len(win_trades) / len(meaningful_trades) * 100 if meaningful_trades else 0, # 基于有意义的交易计算胜率 "total_pnl": sum(float(t['pnl']) for t in closed_trades), "avg_pnl": sum(float(t['pnl']) for t in closed_trades) / len(closed_trades) if closed_trades else 0, + # 总交易量(名义下单量口径):优先使用 notional_usdt(新字段),否则回退 entry_price * quantity + "total_notional_usdt": sum( + float(t.get('notional_usdt') or (float(t.get('entry_price', 0)) * float(t.get('quantity', 0)))) + for t in trades + ), "filters": { "start_timestamp": start_timestamp, "end_timestamp": end_timestamp, diff --git a/backend/database/add_trade_size_and_risk_fields.sql b/backend/database/add_trade_size_and_risk_fields.sql new file mode 100644 index 0000000..5484b90 --- /dev/null +++ b/backend/database/add_trade_size_and_risk_fields.sql @@ -0,0 +1,105 @@ +-- 为 trades 表添加“交易规模/风险”字段: +-- 1) 保证金(margin_usdt) +-- 2) 名义下单量(notional_usdt) +-- 3) 实际止损/止盈/分步止盈与ATR(用于仪表板展示真实值) +-- +-- 说明: +-- - 可重复执行:列已存在会自动跳过 +-- - 默认统计口径使用“入场时”的名义/保证金 +-- + +SET @dbname = DATABASE(); +SET @tablename = 'trades'; + +-- 1) notional_usdt(名义下单量) +SET @columnname = 'notional_usdt'; +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE (TABLE_SCHEMA = @dbname) AND (TABLE_NAME = @tablename) AND (COLUMN_NAME = @columnname) + ) > 0, + "SELECT 'Column notional_usdt already exists.' AS result;", + CONCAT( + "ALTER TABLE `", @tablename, "` ", + "ADD COLUMN `", @columnname, "` DECIMAL(20, 8) NULL ", + "COMMENT '名义下单量(USDT):入场价×数量(用于统计总交易量)';" + ) +)); +PREPARE alterIfNotExists FROM @preparedStatement; +EXECUTE alterIfNotExists; +DEALLOCATE PREPARE alterIfNotExists; + +-- 2) margin_usdt(保证金) +SET @columnname2 = 'margin_usdt'; +SET @preparedStatement2 = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE (TABLE_SCHEMA = @dbname) AND (TABLE_NAME = @tablename) AND (COLUMN_NAME = @columnname2) + ) > 0, + "SELECT 'Column margin_usdt already exists.' AS result;", + CONCAT( + "ALTER TABLE `", @tablename, "` ", + "ADD COLUMN `", @columnname2, "` DECIMAL(20, 8) NULL ", + "COMMENT '保证金(USDT):名义下单量/杠杆(用于统计与盈亏口径统一)';" + ) +)); +PREPARE alterIfNotExists2 FROM @preparedStatement2; +EXECUTE alterIfNotExists2; +DEALLOCATE PREPARE alterIfNotExists2; + +-- 3) take_profit_1(分步止盈1) +SET @columnname3 = 'take_profit_1'; +SET @preparedStatement3 = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE (TABLE_SCHEMA = @dbname) AND (TABLE_NAME = @tablename) AND (COLUMN_NAME = @columnname3) + ) > 0, + "SELECT 'Column take_profit_1 already exists.' AS result;", + CONCAT( + "ALTER TABLE `", @tablename, "` ", + "ADD COLUMN `", @columnname3, "` DECIMAL(20, 8) NULL ", + "COMMENT '第一目标止盈价(用于展示与分步止盈)';" + ) +)); +PREPARE alterIfNotExists3 FROM @preparedStatement3; +EXECUTE alterIfNotExists3; +DEALLOCATE PREPARE alterIfNotExists3; + +-- 4) take_profit_2(分步止盈2) +SET @columnname4 = 'take_profit_2'; +SET @preparedStatement4 = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE (TABLE_SCHEMA = @dbname) AND (TABLE_NAME = @tablename) AND (COLUMN_NAME = @columnname4) + ) > 0, + "SELECT 'Column take_profit_2 already exists.' AS result;", + CONCAT( + "ALTER TABLE `", @tablename, "` ", + "ADD COLUMN `", @columnname4, "` DECIMAL(20, 8) NULL ", + "COMMENT '第二目标止盈价(用于展示与分步止盈)';" + ) +)); +PREPARE alterIfNotExists4 FROM @preparedStatement4; +EXECUTE alterIfNotExists4; +DEALLOCATE PREPARE alterIfNotExists4; + +-- 5) atr(开仓时使用的ATR值) +SET @columnname5 = 'atr'; +SET @preparedStatement5 = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE (TABLE_SCHEMA = @dbname) AND (TABLE_NAME = @tablename) AND (COLUMN_NAME = @columnname5) + ) > 0, + "SELECT 'Column atr already exists.' AS result;", + CONCAT( + "ALTER TABLE `", @tablename, "` ", + "ADD COLUMN `", @columnname5, "` DECIMAL(20, 8) NULL ", + "COMMENT '开仓时使用的ATR(用于展示动态止损止盈依据)';" + ) +)); +PREPARE alterIfNotExists5 FROM @preparedStatement5; +EXECUTE alterIfNotExists5; +DEALLOCATE PREPARE alterIfNotExists5; + +SELECT 'Migration completed: Added notional_usdt, margin_usdt, take_profit_1, take_profit_2, atr to trades table.' AS result; + diff --git a/backend/database/init.sql b/backend/database/init.sql index d60ebe9..c5400e2 100644 --- a/backend/database/init.sql +++ b/backend/database/init.sql @@ -24,20 +24,34 @@ CREATE TABLE IF NOT EXISTS `trades` ( `side` VARCHAR(10) NOT NULL COMMENT 'BUY, SELL', `quantity` DECIMAL(20, 8) NOT NULL, `entry_price` DECIMAL(20, 8) NOT NULL, + `notional_usdt` DECIMAL(20, 8) NULL COMMENT '名义下单量(USDT):入场价×数量(用于统计总交易量)', + `margin_usdt` DECIMAL(20, 8) NULL COMMENT '保证金(USDT):名义下单量/杠杆', `exit_price` DECIMAL(20, 8), `entry_time` INT UNSIGNED NOT NULL COMMENT '入场时间(Unix时间戳秒数)', `exit_time` INT UNSIGNED NULL COMMENT '平仓时间(Unix时间戳秒数)', `pnl` DECIMAL(20, 8) DEFAULT 0, `pnl_percent` DECIMAL(10, 4) DEFAULT 0, `leverage` INT DEFAULT 10, + `entry_order_id` BIGINT NULL COMMENT '币安开仓订单号(用于对账)', + `exit_order_id` BIGINT NULL COMMENT '币安平仓订单号(用于对账)', `entry_reason` TEXT, `exit_reason` VARCHAR(50) COMMENT 'stop_loss, take_profit, trailing_stop, manual', + `strategy_type` VARCHAR(50) COMMENT '策略类型: trend_following, mean_reversion', + `duration_minutes` INT COMMENT '持仓持续时间(分钟)', + `atr` DECIMAL(20, 8) NULL COMMENT '开仓时使用的ATR(用于展示动态止损止盈依据)', + `stop_loss_price` DECIMAL(20, 8) NULL COMMENT '实际使用的止损价格(考虑了ATR等动态计算)', + `take_profit_price` DECIMAL(20, 8) NULL COMMENT '实际使用的止盈价格(考虑了ATR等动态计算)', + `take_profit_1` DECIMAL(20, 8) NULL COMMENT '第一目标止盈价(用于展示与分步止盈)', + `take_profit_2` DECIMAL(20, 8) NULL COMMENT '第二目标止盈价(用于展示与分步止盈)', `status` VARCHAR(20) DEFAULT 'open' COMMENT 'open, closed, cancelled', `created_at` INT UNSIGNED NOT NULL DEFAULT (UNIX_TIMESTAMP()) COMMENT '创建时间(Unix时间戳秒数)', INDEX `idx_symbol` (`symbol`), INDEX `idx_entry_time` (`entry_time`), INDEX `idx_status` (`status`), - INDEX `idx_symbol_status` (`symbol`, `status`) + INDEX `idx_symbol_status` (`symbol`, `status`), + INDEX `idx_entry_order_id` (`entry_order_id`), + INDEX `idx_exit_order_id` (`exit_order_id`), + INDEX `idx_strategy_type` (`strategy_type`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='交易记录表'; -- 账户快照表 diff --git a/backend/database/models.py b/backend/database/models.py index 96fed02..2a705ac 100644 --- a/backend/database/models.py +++ b/backend/database/models.py @@ -88,7 +88,22 @@ class Trade: """交易记录模型""" @staticmethod - def create(symbol, side, quantity, entry_price, leverage=10, entry_reason=None, entry_order_id=None, stop_loss_price=None, take_profit_price=None): + def create( + symbol, + side, + quantity, + entry_price, + leverage=10, + entry_reason=None, + entry_order_id=None, + stop_loss_price=None, + take_profit_price=None, + take_profit_1=None, + take_profit_2=None, + atr=None, + notional_usdt=None, + margin_usdt=None, + ): """创建交易记录(使用北京时间) Args: @@ -101,31 +116,73 @@ class Trade: entry_order_id: 币安开仓订单号(可选,用于对账) stop_loss_price: 实际使用的止损价格(考虑了ATR等动态计算) take_profit_price: 实际使用的止盈价格(考虑了ATR等动态计算) + take_profit_1: 第一目标止盈价(可选) + take_profit_2: 第二目标止盈价(可选) + atr: 开仓时使用的ATR值(可选) + notional_usdt: 名义下单量(USDT,可选) + margin_usdt: 保证金(USDT,可选) """ entry_time = get_beijing_time() - # 检查字段是否存在(兼容旧数据库schema) + # 自动计算 notional/margin(若调用方没传) try: - db.execute_one("SELECT stop_loss_price FROM trades LIMIT 1") - has_stop_take_fields = True - except: - has_stop_take_fields = False - - if has_stop_take_fields: - db.execute_update( - """INSERT INTO trades - (symbol, side, quantity, entry_price, leverage, entry_reason, status, entry_time, entry_order_id, stop_loss_price, take_profit_price) - VALUES (%s, %s, %s, %s, %s, %s, 'open', %s, %s, %s, %s)""", - (symbol, side, quantity, entry_price, leverage, entry_reason, entry_time, entry_order_id, stop_loss_price, take_profit_price) - ) - else: - # 兼容旧schema - db.execute_update( - """INSERT INTO trades - (symbol, side, quantity, entry_price, leverage, entry_reason, status, entry_time, entry_order_id) - VALUES (%s, %s, %s, %s, %s, %s, 'open', %s, %s)""", - (symbol, side, quantity, entry_price, leverage, entry_reason, entry_time, entry_order_id) - ) + if notional_usdt is None and quantity is not None and entry_price is not None: + notional_usdt = float(quantity) * float(entry_price) + except Exception: + pass + try: + if margin_usdt is None and notional_usdt is not None: + lv = float(leverage) if leverage else 0 + margin_usdt = (float(notional_usdt) / lv) if lv and lv > 0 else float(notional_usdt) + except Exception: + pass + + def _has_column(col: str) -> bool: + try: + db.execute_one(f"SELECT {col} FROM trades LIMIT 1") + return True + except Exception: + return False + + # 动态构建 INSERT(兼容不同schema) + columns = ["symbol", "side", "quantity", "entry_price", "leverage", "entry_reason", "status", "entry_time"] + values = [symbol, side, quantity, entry_price, leverage, entry_reason, "open", entry_time] + + if _has_column("entry_order_id"): + columns.append("entry_order_id") + values.append(entry_order_id) + + if _has_column("notional_usdt"): + columns.append("notional_usdt") + values.append(notional_usdt) + + if _has_column("margin_usdt"): + columns.append("margin_usdt") + values.append(margin_usdt) + + if _has_column("atr"): + columns.append("atr") + values.append(atr) + + if _has_column("stop_loss_price"): + columns.append("stop_loss_price") + values.append(stop_loss_price) + + if _has_column("take_profit_price"): + columns.append("take_profit_price") + values.append(take_profit_price) + + if _has_column("take_profit_1"): + columns.append("take_profit_1") + values.append(take_profit_1) + + if _has_column("take_profit_2"): + columns.append("take_profit_2") + values.append(take_profit_2) + + placeholders = ", ".join(["%s"] * len(columns)) + sql = f"INSERT INTO trades ({', '.join(columns)}) VALUES ({placeholders})" + db.execute_update(sql, tuple(values)) return db.execute_one("SELECT LAST_INSERT_ID() as id")['id'] @staticmethod diff --git a/frontend/src/components/TradeList.jsx b/frontend/src/components/TradeList.jsx index d9a2f83..903666c 100644 --- a/frontend/src/components/TradeList.jsx +++ b/frontend/src/components/TradeList.jsx @@ -266,6 +266,15 @@ const TradeList = () => { {stats.avg_pnl.toFixed(2)} USDT + {"total_notional_usdt" in stats && ( +
+
总交易量(名义)
+
{Number(stats.total_notional_usdt || 0).toFixed(2)} USDT
+
+ (口径:入场价×数量) +
+
+ )} )} @@ -281,6 +290,7 @@ const TradeList = () => { 交易对 方向 数量 + 名义 保证金 入场价 出场价 @@ -295,12 +305,18 @@ const TradeList = () => { {trades.map(trade => { - // 计算保证金:如果有entry_value_usdt和leverage,则计算;否则使用名义价值/杠杆 - const entryValue = trade.entry_value_usdt !== undefined - ? parseFloat(trade.entry_value_usdt) - : (parseFloat(trade.quantity || 0) * parseFloat(trade.entry_price || 0)) + // 名义/保证金:优先使用后端返回字段(notional_usdt / margin_usdt),否则回退计算 + const notional = trade.notional_usdt !== undefined && trade.notional_usdt !== null + ? parseFloat(trade.notional_usdt) + : ( + trade.entry_value_usdt !== undefined && trade.entry_value_usdt !== null + ? parseFloat(trade.entry_value_usdt) + : (parseFloat(trade.quantity || 0) * parseFloat(trade.entry_price || 0)) + ) const leverage = parseFloat(trade.leverage || 10) - const margin = leverage > 0 ? entryValue / leverage : 0 + const margin = trade.margin_usdt !== undefined && trade.margin_usdt !== null + ? parseFloat(trade.margin_usdt) + : (leverage > 0 ? notional / leverage : 0) // 计算盈亏比例(盈亏/保证金) const pnl = parseFloat(trade.pnl || 0) @@ -349,6 +365,7 @@ const TradeList = () => { {trade.symbol} {trade.side} {parseFloat(trade.quantity).toFixed(4)} + {notional >= 0.01 ? notional.toFixed(2) : notional.toFixed(4)} USDT {margin >= 0.01 ? margin.toFixed(2) : margin.toFixed(4)} USDT {parseFloat(trade.entry_price).toFixed(4)} {trade.exit_price ? parseFloat(trade.exit_price).toFixed(4) : '-'} @@ -374,12 +391,18 @@ const TradeList = () => { {/* 移动端卡片 */}
{trades.map(trade => { - // 计算保证金和盈亏比例 - const entryValue = trade.entry_value_usdt !== undefined - ? parseFloat(trade.entry_value_usdt) - : (parseFloat(trade.quantity || 0) * parseFloat(trade.entry_price || 0)) + // 名义/保证金:优先后端字段 + const notional = trade.notional_usdt !== undefined && trade.notional_usdt !== null + ? parseFloat(trade.notional_usdt) + : ( + trade.entry_value_usdt !== undefined && trade.entry_value_usdt !== null + ? parseFloat(trade.entry_value_usdt) + : (parseFloat(trade.quantity || 0) * parseFloat(trade.entry_price || 0)) + ) const leverage = parseFloat(trade.leverage || 10) - const margin = leverage > 0 ? entryValue / leverage : 0 + const margin = trade.margin_usdt !== undefined && trade.margin_usdt !== null + ? parseFloat(trade.margin_usdt) + : (leverage > 0 ? notional / leverage : 0) const pnl = parseFloat(trade.pnl || 0) const pnlPercent = margin > 0 ? (pnl / margin) * 100 : 0 @@ -422,6 +445,10 @@ const TradeList = () => { 数量 {parseFloat(trade.quantity).toFixed(4)}
+
+ 名义 + {notional >= 0.01 ? notional.toFixed(2) : notional.toFixed(4)} USDT +
保证金 {margin >= 0.01 ? margin.toFixed(2) : margin.toFixed(4)} USDT diff --git a/trading_system/position_manager.py b/trading_system/position_manager.py index bb3b34d..e9734b5 100644 --- a/trading_system/position_manager.py +++ b/trading_system/position_manager.py @@ -316,6 +316,23 @@ class PositionManager: entry_price = actual_entry_price quantity = filled_quantity # 使用实际成交数量 logger.info(f"{symbol} [开仓] ✓ 使用实际成交价格: {entry_price:.4f} USDT (下单时价格: {original_entry_price:.4f}), 成交数量: {quantity:.4f}") + + # 分步止盈(基于“实际成交价 + 已计算的止损/止盈”) + if side == 'BUY': + take_profit_1 = entry_price + (entry_price - stop_loss_price) # 盈亏比1:1 + else: + take_profit_1 = entry_price - (stop_loss_price - entry_price) # 盈亏比1:1 + take_profit_2 = take_profit_price + + # 交易规模:名义/保证金(用于统计总交易量与UI展示) + try: + notional_usdt = float(entry_price) * float(quantity) + except Exception: + notional_usdt = None + try: + margin_usdt = (float(notional_usdt) / float(leverage)) if notional_usdt is not None and float(leverage) > 0 else notional_usdt + except Exception: + margin_usdt = None # 记录到数据库(只有在订单真正成交后才保存) trade_id = None @@ -329,7 +346,14 @@ class PositionManager: entry_price=entry_price, # 使用实际成交价格 leverage=leverage, entry_reason=entry_reason, - entry_order_id=entry_order_id # 保存币安订单号 + entry_order_id=entry_order_id, # 保存币安订单号 + stop_loss_price=stop_loss_price, + take_profit_price=take_profit_price, + take_profit_1=take_profit_1, + take_profit_2=take_profit_2, + atr=atr, + notional_usdt=notional_usdt, + margin_usdt=margin_usdt, ) logger.info(f"✓ {symbol} 交易记录已保存到数据库 (ID: {trade_id}, 订单号: {entry_order_id}, 成交价: {entry_price:.4f}, 成交数量: {quantity:.4f})") except Exception as e: @@ -343,16 +367,6 @@ class PositionManager: elif not Trade: logger.warning(f"Trade模型未导入,无法保存 {symbol} 交易记录") - # 计算分步止盈价格 - # 第一目标:盈亏比1:1(保守,了结50%仓位) - if side == 'BUY': - take_profit_1 = entry_price + (entry_price - stop_loss_price) # 盈亏比1:1 - else: - take_profit_1 = entry_price - (stop_loss_price - entry_price) # 盈亏比1:1 - - # 第二目标:原始止盈价(激进,剩余50%仓位) - take_profit_2 = take_profit_price - # 记录持仓信息(包含动态止损止盈和分步止盈) from datetime import datetime position_info = { @@ -1483,7 +1497,10 @@ class PositionManager: quantity=quantity, entry_price=entry_price, leverage=binance_position.get('leverage', 10), - entry_reason='manual_entry' # 标记为手动开仓 + entry_reason='manual_entry', # 标记为手动开仓 + # 手动开仓无法拿到策略侧ATR/分步止盈,这里尽量补齐“规模字段” + notional_usdt=(float(entry_price) * float(quantity)) if entry_price and quantity else None, + margin_usdt=((float(entry_price) * float(quantity)) / float(binance_position.get('leverage', 10))) if entry_price and quantity and float(binance_position.get('leverage', 10) or 0) > 0 else None, ) logger.info(f"{symbol} [状态同步] ✓ 数据库记录已创建 (ID: {trade_id})")