This commit is contained in:
薇薇安 2026-01-30 11:03:30 +08:00
parent 9490207537
commit 4f21240116
6 changed files with 88 additions and 30 deletions

View File

@ -5,6 +5,7 @@ from fastapi import APIRouter, Query, HTTPException, Header, Depends
from typing import Optional from typing import Optional
from datetime import datetime, timedelta from datetime import datetime, timedelta
from collections import Counter from collections import Counter
import json
import sys import sys
from pathlib import Path from pathlib import Path
import logging import logging
@ -146,6 +147,13 @@ async def get_trades(
else: else:
formatted_trade['exit_reason_display'] = '' formatted_trade['exit_reason_display'] = ''
# 入场思路 entry_context 可能从 DB 以 JSON 字符串返回,解析为对象便于前端/分析使用
if formatted_trade.get('entry_context') is not None and isinstance(formatted_trade['entry_context'], str):
try:
formatted_trade['entry_context'] = json.loads(formatted_trade['entry_context'])
except Exception:
pass
formatted_trades.append(formatted_trade) formatted_trades.append(formatted_trade)
result = { result = {

View File

@ -393,6 +393,7 @@ class Trade:
notional_usdt=None, notional_usdt=None,
margin_usdt=None, margin_usdt=None,
account_id: int = None, account_id: int = None,
entry_context=None,
): ):
"""创建交易记录(使用北京时间) """创建交易记录(使用北京时间)
@ -402,7 +403,7 @@ class Trade:
quantity: 数量 quantity: 数量
entry_price: 入场价 entry_price: 入场价
leverage: 杠杆 leverage: 杠杆
entry_reason: 入场原因 entry_reason: 入场原因(简短文本)
entry_order_id: 币安开仓订单号(可选,用于对账) entry_order_id: 币安开仓订单号(可选,用于对账)
stop_loss_price: 实际使用的止损价格(考虑了ATR等动态计算) stop_loss_price: 实际使用的止损价格(考虑了ATR等动态计算)
take_profit_price: 实际使用的止盈价格(考虑了ATR等动态计算) take_profit_price: 实际使用的止盈价格(考虑了ATR等动态计算)
@ -411,6 +412,7 @@ class Trade:
atr: 开仓时使用的ATR值(可选) atr: 开仓时使用的ATR值(可选)
notional_usdt: 名义下单量(USDT,可选) notional_usdt: 名义下单量(USDT,可选)
margin_usdt: 保证金(USDT,可选) margin_usdt: 保证金(USDT,可选)
entry_context: 入场思路/过程(dict,将存为 JSON):信号强度、市场状态、趋势、过滤通过情况等,便于事后分析策略执行效果
""" """
entry_time = get_beijing_time() entry_time = get_beijing_time()
@ -474,6 +476,15 @@ class Trade:
columns.append("take_profit_2") columns.append("take_profit_2")
values.append(take_profit_2) values.append(take_profit_2)
if _has_column("entry_context") and entry_context is not None:
try:
entry_context_str = json.dumps(entry_context, ensure_ascii=False) if isinstance(entry_context, dict) else str(entry_context)
except Exception:
entry_context_str = None
if entry_context_str is not None:
columns.append("entry_context")
values.append(entry_context_str)
placeholders = ", ".join(["%s"] * len(columns)) placeholders = ", ".join(["%s"] * len(columns))
sql = f"INSERT INTO trades ({', '.join(columns)}) VALUES ({placeholders})" sql = f"INSERT INTO trades ({', '.join(columns)}) VALUES ({placeholders})"
db.execute_update(sql, tuple(values)) db.execute_update(sql, tuple(values))

View File

@ -282,11 +282,15 @@
color: #e74c3c; color: #e74c3c;
} }
/* 表格横向滚动:避免整页过宽,内容区域可左右滑动 */
.table-wrapper { .table-wrapper {
width: 100%; width: 100%;
max-width: 100%;
overflow-x: auto; overflow-x: auto;
margin-top: 1rem; margin-top: 1rem;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
border-radius: 8px;
border: 1px solid #e9ecef;
} }
.table-wrapper::-webkit-scrollbar { .table-wrapper::-webkit-scrollbar {
@ -311,7 +315,7 @@
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
display: none; display: none;
min-width: 1200px; /* 确保表格有最小宽度,避免列被压缩 */ min-width: 1100px; /* 表格最小宽度,超出时由 table-wrapper 横向滚动 */
} }
@media (min-width: 768px) { @media (min-width: 768px) {
@ -323,10 +327,10 @@
.trades-table th { .trades-table th {
background-color: #34495e; background-color: #34495e;
color: white; color: white;
padding: 0.75rem 0.5rem; padding: 0.5rem 0.4rem;
text-align: left; text-align: left;
font-weight: 500; font-weight: 500;
font-size: 0.85rem; font-size: 0.8rem;
white-space: nowrap; white-space: nowrap;
position: sticky; position: sticky;
top: 0; top: 0;
@ -334,89 +338,88 @@
} }
.trades-table td { .trades-table td {
padding: 0.6rem 0.5rem; padding: 0.5rem 0.4rem;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
font-size: 0.85rem; font-size: 0.8rem;
white-space: nowrap; white-space: nowrap;
} }
/* 优化特定列的宽度 */ /* 列宽适度收紧,减少横向占用,仍保证可读 */
.trades-table th:nth-child(1), .trades-table th:nth-child(1),
.trades-table td:nth-child(1) { .trades-table td:nth-child(1) {
min-width: 60px; min-width: 52px;
max-width: 80px;
} }
.trades-table th:nth-child(2), .trades-table th:nth-child(2),
.trades-table td:nth-child(2) { .trades-table td:nth-child(2) {
min-width: 90px; min-width: 88px;
} }
.trades-table th:nth-child(3), .trades-table th:nth-child(3),
.trades-table td:nth-child(3) { .trades-table td:nth-child(3) {
min-width: 60px; min-width: 52px;
} }
.trades-table th:nth-child(4), .trades-table th:nth-child(4),
.trades-table td:nth-child(4) { .trades-table td:nth-child(4) {
min-width: 90px; min-width: 78px;
} }
.trades-table th:nth-child(5), .trades-table th:nth-child(5),
.trades-table td:nth-child(5) { .trades-table td:nth-child(5) {
min-width: 90px; min-width: 82px;
} }
.trades-table th:nth-child(6), .trades-table th:nth-child(6),
.trades-table td:nth-child(6) { .trades-table td:nth-child(6) {
min-width: 90px; min-width: 82px;
} }
.trades-table th:nth-child(7), .trades-table th:nth-child(7),
.trades-table td:nth-child(7) { .trades-table td:nth-child(7) {
min-width: 90px; min-width: 78px;
} }
.trades-table th:nth-child(8), .trades-table th:nth-child(8),
.trades-table td:nth-child(8) { .trades-table td:nth-child(8) {
min-width: 90px; min-width: 78px;
} }
.trades-table th:nth-child(9), .trades-table th:nth-child(9),
.trades-table td:nth-child(9) { .trades-table td:nth-child(9) {
min-width: 100px; min-width: 88px;
} }
.trades-table th:nth-child(10), .trades-table th:nth-child(10),
.trades-table td:nth-child(10) { .trades-table td:nth-child(10) {
min-width: 100px; min-width: 88px;
} }
.trades-table th:nth-child(11), .trades-table th:nth-child(11),
.trades-table td:nth-child(11) { .trades-table td:nth-child(11) {
min-width: 80px; min-width: 72px;
} }
.trades-table th:nth-child(12), .trades-table th:nth-child(12),
.trades-table td:nth-child(12) { .trades-table td:nth-child(12) {
min-width: 100px; min-width: 88px;
} }
.trades-table th:nth-child(13), .trades-table th:nth-child(13),
.trades-table td:nth-child(13) { .trades-table td:nth-child(13) {
min-width: 200px; min-width: 160px;
white-space: normal; white-space: normal;
word-break: break-all; word-break: break-all;
} }
.trades-table th:nth-child(14), .trades-table th:nth-child(14),
.trades-table td:nth-child(14) { .trades-table td:nth-child(14) {
min-width: 140px; min-width: 120px;
} }
.trades-table th:nth-child(15), .trades-table th:nth-child(15),
.trades-table td:nth-child(15) { .trades-table td:nth-child(15) {
min-width: 140px; min-width: 120px;
} }
.trades-table tr:hover { .trades-table tr:hover {

View File

@ -81,14 +81,13 @@ const TradeList = () => {
setUseCustomDate(false) setUseCustomDate(false)
} }
// 导出当前订单数据 // 导出当前订单数据(含入场/离场原因、入场思路等完整字段,便于后续分析)
const handleExport = () => { const handleExport = () => {
if (trades.length === 0) { if (trades.length === 0) {
alert('暂无数据可导出') alert('暂无数据可导出')
return return
} }
// 准备导出数据
const exportData = trades.map(trade => { const exportData = trades.map(trade => {
const notional = trade.notional_usdt !== undefined && trade.notional_usdt !== null const notional = trade.notional_usdt !== undefined && trade.notional_usdt !== null
? parseFloat(trade.notional_usdt) ? parseFloat(trade.notional_usdt)
@ -102,7 +101,7 @@ const TradeList = () => {
const pnl = parseFloat(trade.pnl || 0) const pnl = parseFloat(trade.pnl || 0)
const pnlPercent = margin > 0 ? (pnl / margin) * 100 : 0 const pnlPercent = margin > 0 ? (pnl / margin) * 100 : 0
return { const row = {
交易ID: trade.id, 交易ID: trade.id,
交易对: trade.symbol, 交易对: trade.symbol,
方向: trade.side, 方向: trade.side,
@ -120,7 +119,21 @@ const TradeList = () => {
平仓订单号: trade.exit_order_id || '-', 平仓订单号: trade.exit_order_id || '-',
入场时间: trade.entry_time, 入场时间: trade.entry_time,
平仓时间: trade.exit_time || null, 平仓时间: trade.exit_time || null,
// 以下为分析用完整字段
入场原因: trade.entry_reason ?? null,
离场原因: trade.exit_reason ?? null,
持仓时长分钟: trade.duration_minutes ?? null,
止损价: trade.stop_loss_price != null ? parseFloat(trade.stop_loss_price) : null,
止盈价: trade.take_profit_price != null ? parseFloat(trade.take_profit_price) : null,
第一目标止盈价: trade.take_profit_1 != null ? parseFloat(trade.take_profit_1) : null,
第二目标止盈价: trade.take_profit_2 != null ? parseFloat(trade.take_profit_2) : null,
ATR: trade.atr != null ? parseFloat(trade.atr) : null,
策略类型: trade.strategy_type ?? null,
} }
if (trade.entry_context != null) {
row.入场思路 = trade.entry_context
}
return row
}) })
// 生成文件名 // 生成文件名
@ -288,7 +301,7 @@ const TradeList = () => {
重置 重置
</button> </button>
{trades.length > 0 && ( {trades.length > 0 && (
<button className="btn-export" onClick={handleExport} title="导出当前显示的订单数据"> <button className="btn-export" onClick={handleExport} title="导出完整数据(含入场/离场原因、入场思路等),便于后续分析">
导出数据 ({trades.length}) 导出数据 ({trades.length})
</button> </button>
)} )}
@ -430,7 +443,8 @@ const TradeList = () => {
<div className="no-data">暂无交易记录</div> <div className="no-data">暂无交易记录</div>
) : ( ) : (
<> <>
{/* 桌面端表格 */} {/* 桌面端表格:用横向滚动包裹,避免整页过宽 */}
<div className="table-wrapper">
<table className="trades-table"> <table className="trades-table">
<thead> <thead>
<tr> <tr>
@ -535,6 +549,7 @@ const TradeList = () => {
})} })}
</tbody> </tbody>
</table> </table>
</div>
{/* 移动端卡片 */} {/* 移动端卡片 */}
<div className="trades-cards"> <div className="trades-cards">

View File

@ -160,7 +160,8 @@ class PositionManager:
trend_4h: Optional[str] = None, trend_4h: Optional[str] = None,
atr: Optional[float] = None, atr: Optional[float] = None,
klines: Optional[List] = None, klines: Optional[List] = None,
bollinger: Optional[Dict] = None bollinger: Optional[Dict] = None,
entry_context: Optional[Dict] = None,
) -> Optional[Dict]: ) -> Optional[Dict]:
""" """
开仓 开仓
@ -630,6 +631,7 @@ class PositionManager:
atr=atr, atr=atr,
notional_usdt=notional_usdt, notional_usdt=notional_usdt,
margin_usdt=margin_usdt, margin_usdt=margin_usdt,
entry_context=entry_context, # 入场思路与过程(便于事后分析策略执行效果)
) )
logger.info(f"✓ {symbol} 交易记录已保存到数据库 (ID: {trade_id}, 订单号: {entry_order_id}, 成交价: {entry_price:.4f}, 成交数量: {quantity:.4f})") logger.info(f"✓ {symbol} 交易记录已保存到数据库 (ID: {trade_id}, 订单号: {entry_order_id}, 成交价: {entry_price:.4f}, 成交数量: {quantity:.4f})")
except Exception as e: except Exception as e:

View File

@ -192,6 +192,24 @@ class TradingStrategy:
f"(信号强度: {signal_strength}/10)" f"(信号强度: {signal_strength}/10)"
) )
# 构建「入场思路/过程」并写入订单,便于事后综合分析策略执行效果
entry_context = {
'signal_strength': signal_strength,
'market_regime': market_regime,
'trend_4h': trade_signal.get('trend_4h'),
'change_percent': change_percent,
'direction': trade_direction,
'reason': entry_reason,
'rsi': symbol_info.get('rsi'),
'volume_confirmed': True, # 已通过 _check_volume_confirmation
'filters_passed': ['only_trending', 'should_trade', 'volume_ok', 'signal_ok'],
}
macd_hist = symbol_info.get('macd', {}).get('histogram') if isinstance(symbol_info.get('macd'), dict) else None
if macd_hist is not None:
entry_context['macd_histogram'] = macd_hist
if symbol_info.get('atr') is not None:
entry_context['atr'] = symbol_info.get('atr')
# 开仓(使用改进的仓位管理) # 开仓(使用改进的仓位管理)
position = await self.position_manager.open_position( position = await self.position_manager.open_position(
symbol=symbol, symbol=symbol,
@ -204,7 +222,8 @@ class TradingStrategy:
trend_4h=trade_signal.get('trend_4h'), trend_4h=trade_signal.get('trend_4h'),
atr=symbol_info.get('atr'), atr=symbol_info.get('atr'),
klines=symbol_info.get('klines'), # 传递K线数据用于动态止损 klines=symbol_info.get('klines'), # 传递K线数据用于动态止损
bollinger=symbol_info.get('bollinger') # 传递布林带数据用于动态止损 bollinger=symbol_info.get('bollinger'), # 传递布林带数据用于动态止损
entry_context=entry_context,
) )
if position: if position: