This commit is contained in:
aaron 2025-12-09 12:27:47 +08:00
parent 1ec9030d1e
commit 6813a4abe0
13 changed files with 2406 additions and 115 deletions

View File

@ -28,8 +28,9 @@ COPY config ./config
COPY analysis ./analysis COPY analysis ./analysis
COPY signals ./signals COPY signals ./signals
COPY notifiers ./notifiers COPY notifiers ./notifiers
COPY trading ./trading
COPY web ./web
COPY scheduler.py . COPY scheduler.py .
COPY .env.example .env
# Create output directory # Create output directory
RUN mkdir -p /app/output RUN mkdir -p /app/output

View File

@ -31,6 +31,61 @@ services:
max-size: "10m" max-size: "10m"
max-file: "3" max-file: "3"
# Paper Trading - 模拟盘交易
paper-trading:
build:
context: .
dockerfile: Dockerfile
container_name: tradus-paper-trading
command: python -u -m trading.realtime_trader
env_file: .env
volumes:
- ./output:/app/output # 共享信号文件和交易状态
environment:
- SYMBOL=BTCUSDT
- LOG_LEVEL=INFO
depends_on:
- scheduler
networks:
- tradus-network
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# Web Dashboard - 模拟盘状态展示
dashboard:
build:
context: .
dockerfile: Dockerfile
container_name: tradus-dashboard
command: python -m uvicorn web.api:app --host 0.0.0.0 --port 8000
env_file: .env
volumes:
- ./output:/app/output # 共享交易状态文件
ports:
- "18080:8000" # 使用18080端口避免冲突
environment:
- LOG_LEVEL=INFO
depends_on:
- paper-trading
networks:
- tradus-network
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/status')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks: networks:
tradus-network: tradus-network:
driver: bridge driver: bridge

View File

@ -1,32 +1,32 @@
{ {
"aggregated_signal": { "aggregated_signal": {
"timestamp": "2025-12-04T01:26:44.257404", "timestamp": "2025-12-09T11:59:50.752872",
"final_signal": "HOLD", "final_signal": "HOLD",
"final_confidence": 0.28, "final_confidence": 0.55,
"consensus": "CONSENSUS_HOLD", "consensus": "CONSENSUS_HOLD",
"agreement_score": 0.28, "agreement_score": 0.55,
"quantitative_signal": { "quantitative_signal": {
"signal_type": "HOLD", "signal_type": "HOLD",
"signal": "HOLD", "signal": "HOLD",
"confidence": 0.0, "confidence": 0.5,
"composite_score": 33.2, "composite_score": -31.8,
"scores": { "scores": {
"trend": -23.1, "trend": -66.0,
"momentum": 65, "momentum": 65,
"orderflow": 100, "orderflow": -100,
"breakout": 0 "breakout": 0
} }
}, },
"llm_signal": { "llm_signal": {
"signal_type": "HOLD", "signal_type": "HOLD",
"signal": "HOLD", "signal": "HOLD",
"confidence": 0.55, "confidence": 0.6,
"reasoning": "多周期综合分析显示市场处于关键抉择期。短期5m-1h陷入无序震荡缺乏交易价值。中期4h-1d在$90,156-$93,932构建震荡平台MACD有修复迹象倾向于在支撑位附近寻找低吸机会。长期1d-1w仍处于自11月高点以来的大级别盘整中周线上涨趋势未改但动能减弱。当前核心矛盾是中期震荡与长期趋势的共振点尚未出现需等待更明确的突破信号。风险主要来自震荡区间内的假突破和低成交量下的价格异动。", "reasoning": "多周期综合分析显示市场处于震荡格局。短期5m/15m/1h有超跌反弹的技术条件MACD金叉和RSI位置提供日内做多机会。中期4h/1d方向不明指标中性需等待区间突破。长期1d/1w周线趋势虽强但日线处于调整中需更佳的风险回报比。当前价格90382.5接近短期支撑,优先关注日内机会。",
"key_factors": [ "key_factors": [
"4小时及日线级别宽幅震荡区间的突破方向", "短期技术指标出现反弹信号",
"成交量能否在关键价位有效放大", "价格处于关键支撑区域",
"日线MACD能否形成金叉确认反弹", "中期趋势方向不明朗",
"周线RSI40.7能否回升至50中性区域以上" "成交量正常无明显放量"
], ],
"opportunities": { "opportunities": {
"short_term_5m_15m_1h": { "short_term_5m_15m_1h": {
@ -35,15 +35,15 @@
"entry_price": 0, "entry_price": 0,
"stop_loss": 0, "stop_loss": 0,
"take_profit": 0, "take_profit": 0,
"reasoning": "当前价格$92,488.90位于短期震荡区间中部。5分钟和15分钟周期趋势为弱势下跌但1小时周期为强势上涨多周期信号矛盾。RSI和MACD指标均呈中性或弱信号成交量缩量缺乏明确的短期方向性突破动能。预期盈利空间不足1%,建议观望" "reasoning": "盈利空间不足1% (仅0.91%),建议观望"
}, },
"medium_term_4h_1d": { "medium_term_4h_1d": {
"exists": true, "exists": false,
"direction": "LONG", "direction": null,
"entry_price": 91500.0, "entry_price": 0,
"stop_loss": 90100.0, "stop_loss": 0,
"take_profit": 94500.0, "take_profit": 0,
"reasoning": "4小时和日线图显示价格在$90,156-$93,932区间内宽幅震荡。当前价格接近区间中下部。日线MACD死叉收窄有潜在底背离迹象。若价格能回踩并站稳$91,500近期多次反弹的支撑位上方可视为中期做多机会目标看向区间上沿$94,500附近盈利空间约3.2%。" "reasoning": "4小时和日线周期趋势不明RSI中性MACD死叉收窄但未转强。价格在89550-92262区间震荡缺乏明确的中期方向信号和足够的盈利空间。"
}, },
"long_term_1d_1w": { "long_term_1d_1w": {
"exists": false, "exists": false,
@ -51,12 +51,12 @@
"entry_price": 0, "entry_price": 0,
"stop_loss": 0, "stop_loss": 0,
"take_profit": 0, "take_profit": 0,
"reasoning": "周线趋势虽为上涨但RSI40.7偏弱且价格仍处于11月以来的宽幅震荡区间$83,786 - $101,450内。日线级别趋势不明缺乏明确的长期趋势启动信号。当前价格位于区间中部直接追涨或杀跌的风险回报比不佳建议等待更明确的突破信号。" "reasoning": "周线显示上涨趋势但日线处于高位震荡。当前价格接近震荡区间中轨长期方向需等待突破。RSI弱势MACD死叉短期不具备明确的长期建仓机会。"
}, },
"ambush": { "ambush": {
"exists": true, "exists": true,
"price_level": 90100.0, "price_level": 89550.0,
"reasoning": "基于4小时和日线K线数据$90,100-$90,156区域是近期多次测试的关键支撑区间12月1日、12月3日低点。若价格因市场情绪再次回落至此区域并出现企稳迹象如长下影线、成交量放大是风险可控的埋伏做多点位止损可设在$88,900下方。" "reasoning": "该位置是近期多次测试的强支撑4小时和日线级别也是12月8日低点区域。若价格回调至此并出现企稳信号是较好的中期埋伏做多点位。"
}, },
"intraday": { "intraday": {
"exists": false, "exists": false,
@ -64,104 +64,85 @@
"entry_price": 0, "entry_price": 0,
"stop_loss": 0, "stop_loss": 0,
"take_profit": 0, "take_profit": 0,
"reasoning": "当前价格$92,488.90位于短期震荡区间中部。5分钟和15分钟周期趋势为弱势下跌但1小时周期为强势上涨多周期信号矛盾。RSI和MACD指标均呈中性或弱信号成交量缩量缺乏明确的短期方向性突破动能。预期盈利空间不足1%,建议观望" "reasoning": "盈利空间不足1% (仅0.91%),建议观望"
}, },
"swing": { "swing": {
"exists": true, "exists": false,
"direction": "LONG", "direction": null,
"entry_price": 91500.0, "entry_price": 0,
"stop_loss": 90100.0, "stop_loss": 0,
"take_profit": 94500.0, "take_profit": 0,
"reasoning": "4小时和日线图显示价格在$90,156-$93,932区间内宽幅震荡。当前价格接近区间中下部。日线MACD死叉收窄有潜在底背离迹象。若价格能回踩并站稳$91,500近期多次反弹的支撑位上方可视为中期做多机会目标看向区间上沿$94,500附近盈利空间约3.2%。" "reasoning": "4小时和日线周期趋势不明RSI中性MACD死叉收窄但未转强。价格在89550-92262区间震荡缺乏明确的中期方向信号和足够的盈利空间。"
} }
}, },
"recommendations_by_timeframe": { "recommendations_by_timeframe": {
"short_term": "短期5m/15m/1h建议观望。价格处于无趋势震荡中技术指标矛盾日内交易缺乏明确的、盈利空间≥1%的机会。避免在$92,000-$93,000区间内频繁操作。", "short_term": "可在90380附近轻仓试多止损90000目标91200。日内交易快进快出。",
"medium_term": "中期4h/1d可关注回调做多机会。等待价格回落至$91,500附近企稳后分批布局止损设于$90,100下方目标看向$94,500。若直接向上突破$93,000并站稳可轻仓追多目标$94,500。", "medium_term": "观望为主等待价格突破92000或跌破89550后选择方向。可关注89550支撑位的埋伏机会。",
"long_term": "长期1d/1w建议继续持有现有仓位或保持观望。需等待价格有效突破$94,000确认短期强势或跌破$89,000确认转弱来明确大方向。在方向明确前不宜进行大规模长期仓位调整。" "long_term": "周线趋势向上但日线调整未结束。长期投资者可等待价格回调至87688-88000强支撑区域再考虑分批建仓。"
}, },
"trade_type": "MULTI_TIMEFRAME", "trade_type": "MULTI_TIMEFRAME",
"risk_level": "MEDIUM" "risk_level": "MEDIUM"
}, },
"levels": { "levels": {
"current_price": 92485.5, "current_price": 90382.5,
"entry": 91991.05, "entry": 90382.5,
"stop_loss": 91291.05, "stop_loss": 90382.5,
"take_profit_1": 93491.05, "take_profit_1": 90382.5,
"take_profit_2": 93491.05, "take_profit_2": 90382.5,
"take_profit_3": 93491.05, "take_profit_3": 90382.5
"entry_range": {
"quant": 92482.1,
"llm": 91500.0,
"diff_pct": 1.07
},
"stop_loss_range": {
"quant": 92482.1,
"llm": 90100.0,
"diff_pct": 2.61
},
"take_profit_1_range": {
"quant": 92482.1,
"llm": 94500.0,
"diff_pct": 2.16
}
}, },
"risk_reward_ratio": 2.14, "risk_reward_ratio": 0,
"recommendation": "量化和AI分析均建议观望,等待更好的机会", "recommendation": "量化和AI分析均建议观望,等待更好的机会",
"warnings": [ "warnings": []
"⚠️ 量化和AI信号严重分歧,建议观望",
"⚠️ 量化信号置信度较低",
"⚠️ stop_loss建议差异较大: 量化$92482.10 vs AI$90100.00 (2.6%)"
]
}, },
"market_analysis": { "market_analysis": {
"price": 92482.1, "price": 90382.5,
"trend": { "trend": {
"direction": "下跌", "direction": "下跌",
"strength": "weak", "strength": "moderate",
"phase": "下跌后反弹", "phase": "下跌后反弹",
"adx": 9.8, "adx": 20.4,
"ema_alignment": "bearish" "ema_alignment": "bearish"
}, },
"momentum": { "momentum": {
"rsi": 51.8, "rsi": 59.2,
"rsi_status": "中性偏强", "rsi_status": "中性偏强",
"rsi_trend": "上升中", "rsi_trend": "上升中",
"macd_signal": "金叉扩大", "macd_signal": "金叉扩大",
"macd_hist": 24.4447 "macd_hist": 49.9748
} }
}, },
"quantitative_signal": { "quantitative_signal": {
"timestamp": "2025-12-04T01:26:02.011873", "timestamp": "2025-12-09T11:59:16.429268",
"signal_type": "HOLD", "signal_type": "HOLD",
"signal_strength": 0.33, "signal_strength": 0.32,
"composite_score": 33.2, "composite_score": -31.8,
"confidence": 0.0, "confidence": 0.5,
"consensus_score": 0.55, "consensus_score": 0.65,
"profit_pct": 0, "profit_pct": 0,
"scores": { "scores": {
"trend": -23.1, "trend": -66.0,
"momentum": 65, "momentum": 65,
"orderflow": 100, "orderflow": -100,
"breakout": 0 "breakout": 0
}, },
"levels": { "levels": {
"current_price": 92482.1, "current_price": 90382.5,
"entry": 92482.1, "entry": 90382.5,
"stop_loss": 92482.1, "stop_loss": 90382.5,
"take_profit_1": 92482.1, "take_profit_1": 90382.5,
"take_profit_2": 92482.1, "take_profit_2": 90382.5,
"take_profit_3": 92482.1 "take_profit_3": 90382.5
}, },
"risk_reward_ratio": 0, "risk_reward_ratio": 0,
"reasoning": "趋势下跌 (weak); RSI=52; MACD 金叉扩大; 订单流: 强买方主导" "reasoning": "趋势下跌 (moderate); RSI=59; MACD 金叉扩大; 订单流: 强卖方主导"
}, },
"llm_signal": { "llm_signal": {
"timestamp": "2025-12-04T01:26:44.257201", "timestamp": "2025-12-09T11:59:50.751310",
"signal_type": "HOLD", "signal_type": "HOLD",
"confidence": 0.55, "confidence": 0.6,
"trade_type": "MULTI_TIMEFRAME", "trade_type": "MULTI_TIMEFRAME",
"reasoning": "多周期综合分析显示市场处于关键抉择期。短期5m-1h陷入无序震荡缺乏交易价值。中期4h-1d在$90,156-$93,932构建震荡平台MACD有修复迹象倾向于在支撑位附近寻找低吸机会。长期1d-1w仍处于自11月高点以来的大级别盘整中周线上涨趋势未改但动能减弱。当前核心矛盾是中期震荡与长期趋势的共振点尚未出现需等待更明确的突破信号。风险主要来自震荡区间内的假突破和低成交量下的价格异动。", "reasoning": "多周期综合分析显示市场处于震荡格局。短期5m/15m/1h有超跌反弹的技术条件MACD金叉和RSI位置提供日内做多机会。中期4h/1d方向不明指标中性需等待区间突破。长期1d/1w周线趋势虽强但日线处于调整中需更佳的风险回报比。当前价格90382.5接近短期支撑,优先关注日内机会。",
"opportunities": { "opportunities": {
"short_term_5m_15m_1h": { "short_term_5m_15m_1h": {
"exists": false, "exists": false,
@ -169,15 +150,15 @@
"entry_price": 0, "entry_price": 0,
"stop_loss": 0, "stop_loss": 0,
"take_profit": 0, "take_profit": 0,
"reasoning": "当前价格$92,488.90位于短期震荡区间中部。5分钟和15分钟周期趋势为弱势下跌但1小时周期为强势上涨多周期信号矛盾。RSI和MACD指标均呈中性或弱信号成交量缩量缺乏明确的短期方向性突破动能。预期盈利空间不足1%,建议观望" "reasoning": "盈利空间不足1% (仅0.91%),建议观望"
}, },
"medium_term_4h_1d": { "medium_term_4h_1d": {
"exists": true, "exists": false,
"direction": "LONG", "direction": null,
"entry_price": 91500.0, "entry_price": 0,
"stop_loss": 90100.0, "stop_loss": 0,
"take_profit": 94500.0, "take_profit": 0,
"reasoning": "4小时和日线图显示价格在$90,156-$93,932区间内宽幅震荡。当前价格接近区间中下部。日线MACD死叉收窄有潜在底背离迹象。若价格能回踩并站稳$91,500近期多次反弹的支撑位上方可视为中期做多机会目标看向区间上沿$94,500附近盈利空间约3.2%。" "reasoning": "4小时和日线周期趋势不明RSI中性MACD死叉收窄但未转强。价格在89550-92262区间震荡缺乏明确的中期方向信号和足够的盈利空间。"
}, },
"long_term_1d_1w": { "long_term_1d_1w": {
"exists": false, "exists": false,
@ -185,12 +166,12 @@
"entry_price": 0, "entry_price": 0,
"stop_loss": 0, "stop_loss": 0,
"take_profit": 0, "take_profit": 0,
"reasoning": "周线趋势虽为上涨但RSI40.7偏弱且价格仍处于11月以来的宽幅震荡区间$83,786 - $101,450内。日线级别趋势不明缺乏明确的长期趋势启动信号。当前价格位于区间中部直接追涨或杀跌的风险回报比不佳建议等待更明确的突破信号。" "reasoning": "周线显示上涨趋势但日线处于高位震荡。当前价格接近震荡区间中轨长期方向需等待突破。RSI弱势MACD死叉短期不具备明确的长期建仓机会。"
}, },
"ambush": { "ambush": {
"exists": true, "exists": true,
"price_level": 90100.0, "price_level": 89550.0,
"reasoning": "基于4小时和日线K线数据$90,100-$90,156区域是近期多次测试的关键支撑区间12月1日、12月3日低点。若价格因市场情绪再次回落至此区域并出现企稳迹象如长下影线、成交量放大是风险可控的埋伏做多点位止损可设在$88,900下方。" "reasoning": "该位置是近期多次测试的强支撑4小时和日线级别也是12月8日低点区域。若价格回调至此并出现企稳信号是较好的中期埋伏做多点位。"
}, },
"intraday": { "intraday": {
"exists": false, "exists": false,
@ -198,38 +179,38 @@
"entry_price": 0, "entry_price": 0,
"stop_loss": 0, "stop_loss": 0,
"take_profit": 0, "take_profit": 0,
"reasoning": "当前价格$92,488.90位于短期震荡区间中部。5分钟和15分钟周期趋势为弱势下跌但1小时周期为强势上涨多周期信号矛盾。RSI和MACD指标均呈中性或弱信号成交量缩量缺乏明确的短期方向性突破动能。预期盈利空间不足1%,建议观望" "reasoning": "盈利空间不足1% (仅0.91%),建议观望"
}, },
"swing": { "swing": {
"exists": true, "exists": false,
"direction": "LONG", "direction": null,
"entry_price": 91500.0, "entry_price": 0,
"stop_loss": 90100.0, "stop_loss": 0,
"take_profit": 94500.0, "take_profit": 0,
"reasoning": "4小时和日线图显示价格在$90,156-$93,932区间内宽幅震荡。当前价格接近区间中下部。日线MACD死叉收窄有潜在底背离迹象。若价格能回踩并站稳$91,500近期多次反弹的支撑位上方可视为中期做多机会目标看向区间上沿$94,500附近盈利空间约3.2%。" "reasoning": "4小时和日线周期趋势不明RSI中性MACD死叉收窄但未转强。价格在89550-92262区间震荡缺乏明确的中期方向信号和足够的盈利空间。"
} }
}, },
"recommendations_by_timeframe": { "recommendations_by_timeframe": {
"short_term": "短期5m/15m/1h建议观望。价格处于无趋势震荡中技术指标矛盾日内交易缺乏明确的、盈利空间≥1%的机会。避免在$92,000-$93,000区间内频繁操作。", "short_term": "可在90380附近轻仓试多止损90000目标91200。日内交易快进快出。",
"medium_term": "中期4h/1d可关注回调做多机会。等待价格回落至$91,500附近企稳后分批布局止损设于$90,100下方目标看向$94,500。若直接向上突破$93,000并站稳可轻仓追多目标$94,500。", "medium_term": "观望为主等待价格突破92000或跌破89550后选择方向。可关注89550支撑位的埋伏机会。",
"long_term": "长期1d/1w建议继续持有现有仓位或保持观望。需等待价格有效突破$94,000确认短期强势或跌破$89,000确认转弱来明确大方向。在方向明确前不宜进行大规模长期仓位调整。" "long_term": "周线趋势向上但日线调整未结束。长期投资者可等待价格回调至87688-88000强支撑区域再考虑分批建仓。"
}, },
"levels": { "levels": {
"current_price": 92488.9, "current_price": 90382.5,
"entry": 91500.0, "entry": 90382.5,
"stop_loss": 90100.0, "stop_loss": 0,
"take_profit_1": 94500.0, "take_profit_1": 0,
"take_profit_2": 94500.0, "take_profit_2": 0,
"take_profit_3": 94500.0 "take_profit_3": 0
}, },
"risk_level": "MEDIUM", "risk_level": "MEDIUM",
"key_factors": [ "key_factors": [
"4小时及日线级别宽幅震荡区间的突破方向", "短期技术指标出现反弹信号",
"成交量能否在关键价位有效放大", "价格处于关键支撑区域",
"日线MACD能否形成金叉确认反弹", "中期趋势方向不明朗",
"周线RSI40.7能否回升至50中性区域以上" "成交量正常无明显放量"
], ],
"raw_response": "```json\n{\n \"signal\": \"HOLD\",\n \"confidence\": 0.55,\n \"key_levels\": {\n \"short_term\": {\n \"support\": [91637.0, 90906.0, 90643.0],\n \"resistance\": [92488.0, 92841.0, 93068.0]\n },\n \"medium_term\": {\n \"support\": [90156.0, 89225.0, 86970.0],\n \"resistance\": [93932.0, 94544.0, 95560.0]\n },\n \"long_term\": {\n \"support\": [80600.0, 83786.0, 86066.0],\n \"resistance\": [101450.0, 104670.0, 107473.0]\n }\n },\n \"opportunities\": {\n \"short_term_5m_15m_1h\": {\n \"exists\": false,\n \"direction\": null,\n \"entry_price\": null,\n \"stop_loss\": null,\n \"take_profit\": null,\n \"reasoning\": \"当前价格$92,488.90位于短期震荡区间中部。5分钟和15分钟周期趋势为弱势下跌但1小时周期为强势上涨多周期信号矛盾。RSI和MACD指标均呈中性或弱信号成交量缩量缺乏明确的短期方向性突破动能。预期盈利空间不足1%,建议观望。\"\n },\n \"medium_term_4h_1d\": {\n \"exists\": true,\n \"direction\": \"LONG\",\n \"entry_price\": 91500.0,\n \"stop_loss\": 90100.0,\n \"take_profit\": 94500.0,\n \"reasoning\": \"4小时和日线图显示价格在$90,156-$93,932区间内宽幅震荡。当前价格接近区间中下部。日线MACD死叉收窄有潜在底背离迹象。若价格能回踩并站稳$91,500近期多次反弹的支撑位上方可视为中期做多机会目标看向区间上沿$94,500附近盈利空间约3.2%。\"\n },\n \"long_term_1d_1w\": {\n \"exists\": false,\n \"direction\": null,\n \"entry_price\": null,\n \"stop_loss\": null,\n \"take_profit\": null,\n \"reasoning\": \"周线趋势虽为上涨但RSI40.7偏弱且价格仍处于11月以来的宽幅震荡区间$83,786 - $101,450内。日线级别趋势不明缺乏明确的长期趋势启动信号。当前价格位于区间中部直接追涨或杀跌的风险回报比不佳建议等待更明确的突破信号。\"\n },\n \"ambush\": {\n \"exists\": true,\n \"price_level\": 90100.0,\n \"reasoning\": \"基于4小时和日线K线数据$90,100-$90,156区域是近期多次测试的关键支撑区间12月1日、12月3日低点。若价格因市场情绪再次回落至此区域并出现企稳迹象如长下影线、成交量放大是风险可控的埋伏做多点位止损可设在$88,900下方。\"\n }\n },\n \"recommendations_by_timeframe\": {\n \"short_term\": \"短期5m/15m/1h建议观望。价格处于无趋势震荡中技术指标矛盾日内交易缺乏明确的、盈利空间≥1%的机会。避免在$92,000-$93,000区间内频繁操作。\",\n \"medium_term\": \"中期4h/1d可关注回调做多机会。等待价格回落至$91,500附近企稳后分批布局止损设于$90,100下方目标看向$94,500。若直接向上突破$93,000并站稳可轻仓追多目标$94,500。\",\n \"long_term\": \"长期1d/1w建议继续持有现有仓位或保持观望。需等待价格有效突破$94,000确认短期强势或跌破$89,000确认转弱来明确大方向。在方向明确前不宜进行大规模长期仓位调整。\"\n },\n \"reasoning\": \"多周期综合分析显示市场处于关键抉择期。短期5m-1h陷入无序震荡缺乏交易价值。中期4h-1d在$90,156-$93,932构建震荡平台MACD有修复迹象倾向于在支撑位附近寻找低吸机会。长期1d-1w仍处于自11月高点以来的大级别盘整中周线上涨趋势未改但动能减弱。当前核心矛盾是中期震荡与长期趋势的共振点尚未出现需等待更明确的突破信号。风险主要来自震荡区间内的假突破和低成交量下的价格异动。\",\n \"risk_level\": \"MEDIUM\",\n \"key_factors\": [\"4小时及日线级别宽幅震荡区间的突破方向\", \"成交量能否在关键价位有效放大\", \"日线MACD能否形成金叉确认反弹\", \"周线RSI40.7能否回升至50中性区域以上\"]\n}\n```", "raw_response": "```json\n{\n \"signal\": \"HOLD\",\n \"confidence\": 0.6,\n \"key_levels\": {\n \"short_term\": {\n \"support\": [90380.0, 90000.0, 89800.0, 89550.0],\n \"resistance\": [90800.0, 91200.0, 91500.0, 91800.0]\n },\n \"medium_term\": {\n \"support\": [89550.0, 89000.0, 88000.0, 87688.0],\n \"resistance\": [92000.0, 92262.6, 93000.0, 94185.0]\n },\n \"long_term\": {\n \"support\": [86000.0, 83786.0, 80600.0],\n \"resistance\": [95000.0, 98000.0, 104000.0]\n }\n },\n \"opportunities\": {\n \"short_term_5m_15m_1h\": {\n \"exists\": true,\n \"direction\": \"LONG\",\n \"entry_price\": 90380.0,\n \"stop_loss\": 90000.0,\n \"take_profit\": 91200.0,\n \"reasoning\": \"价格在短期支撑位90380附近企稳5分钟MACD金叉扩大RSI中性偏强存在反弹至91200阻力位的日内机会预期盈利空间约0.9%接近1%阈值。\"\n },\n \"medium_term_4h_1d\": {\n \"exists\": false,\n \"direction\": null,\n \"entry_price\": null,\n \"stop_loss\": null,\n \"take_profit\": null,\n \"reasoning\": \"4小时和日线周期趋势不明RSI中性MACD死叉收窄但未转强。价格在89550-92262区间震荡缺乏明确的中期方向信号和足够的盈利空间。\"\n },\n \"long_term_1d_1w\": {\n \"exists\": false,\n \"direction\": null,\n \"entry_price\": null,\n \"stop_loss\": null,\n \"take_profit\": null,\n \"reasoning\": \"周线显示上涨趋势但日线处于高位震荡。当前价格接近震荡区间中轨长期方向需等待突破。RSI弱势MACD死叉短期不具备明确的长期建仓机会。\"\n },\n \"ambush\": {\n \"exists\": true,\n \"price_level\": 89550.0,\n \"reasoning\": \"该位置是近期多次测试的强支撑4小时和日线级别也是12月8日低点区域。若价格回调至此并出现企稳信号是较好的中期埋伏做多点位。\"\n }\n },\n \"recommendations_by_timeframe\": {\n \"short_term\": \"可在90380附近轻仓试多止损90000目标91200。日内交易快进快出。\",\n \"medium_term\": \"观望为主等待价格突破92000或跌破89550后选择方向。可关注89550支撑位的埋伏机会。\",\n \"long_term\": \"周线趋势向上但日线调整未结束。长期投资者可等待价格回调至87688-88000强支撑区域再考虑分批建仓。\"\n },\n \"reasoning\": \"多周期综合分析显示市场处于震荡格局。短期5m/15m/1h有超跌反弹的技术条件MACD金叉和RSI位置提供日内做多机会。中期4h/1d方向不明指标中性需等待区间突破。长期1d/1w周线趋势虽强但日线处于调整中需更佳的风险回报比。当前价格90382.5接近短期支撑,优先关注日内机会。\",\n \"risk_level\": \"MEDIUM\",\n \"key_factors\": [\"短期技术指标出现反弹信号\", \"价格处于关键支撑区域\", \"中期趋势方向不明朗\", \"成交量正常无明显放量\"]\n}\n```",
"risk_reward_ratio": 2.14 "risk_reward_ratio": 0
} }
} }

View File

@ -0,0 +1,18 @@
{
"balance": 10000.0,
"position": null,
"trades": [],
"stats": {
"total_trades": 0,
"winning_trades": 0,
"losing_trades": 0,
"total_pnl": 0.0,
"max_drawdown": 0.0,
"peak_balance": 10000.0,
"win_rate": 0.0,
"avg_win": 0.0,
"avg_loss": 0.0,
"profit_factor": 0.0
},
"last_updated": "2025-12-09T12:02:05.263984"
}

View File

@ -20,4 +20,11 @@ openai==1.58.1
# HTTP client for notifications # HTTP client for notifications
requests==2.31.0 requests==2.31.0
# WebSocket client for realtime data
websockets>=12.0
# Web framework for dashboard
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
# Note: asyncio is part of Python standard library, no need to install # Note: asyncio is part of Python standard library, no need to install

24
run_dashboard.sh Executable file
View File

@ -0,0 +1,24 @@
#!/bin/bash
# 运行模拟盘 Web Dashboard
set -e
echo "Starting Paper Trading Dashboard..."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Dashboard URL: http://localhost:8080"
echo ""
echo "Features:"
echo " - Real-time status updates via WebSocket"
echo " - Equity curve visualization"
echo " - Trade history"
echo " - Position management display"
echo ""
echo "Press Ctrl+C to stop"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
cd "$(dirname "$0")"
# Run the web server
python -m uvicorn web.api:app --host 0.0.0.0 --port 8080 --reload

21
run_paper_trading.sh Executable file
View File

@ -0,0 +1,21 @@
#!/bin/bash
# 运行实时模拟盘
set -e
echo "Starting Realtime Paper Trading..."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "This will:"
echo " 1. Connect to Binance WebSocket for real-time prices"
echo " 2. Monitor latest_signal.json for trading signals"
echo " 3. Execute simulated trades based on short-term signals"
echo ""
echo "Press Ctrl+C to stop"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
cd "$(dirname "$0")"
# Run the realtime trader
python -m trading.realtime_trader

3
trading/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from .paper_trading import PaperTrader, Position, Trade
__all__ = ['PaperTrader', 'Position', 'Trade']

803
trading/paper_trading.py Normal file
View File

@ -0,0 +1,803 @@
"""
Paper Trading Module - 模拟盘交易系统
支持仓位管理
- 分批建仓信号重复时加仓
- 金字塔加仓策略
- 最大持仓限制
- 动态止盈止损
"""
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, Any, Optional, List
from pathlib import Path
from dataclasses import dataclass, asdict, field
from enum import Enum
logger = logging.getLogger(__name__)
class PositionSide(Enum):
LONG = "LONG"
SHORT = "SHORT"
FLAT = "FLAT"
@dataclass
class PositionEntry:
"""单次入场记录"""
price: float
size: float # BTC 数量
time: str
signal_id: str # 信号标识
@dataclass
class Position:
"""持仓信息 - 支持多次入场"""
side: str # LONG, SHORT, FLAT
entries: List[Dict] = field(default_factory=list) # 多次入场记录
total_size: float = 0.0 # 总持仓量
avg_entry_price: float = 0.0 # 平均入场价
stop_loss: float = 0.0
take_profit: float = 0.0
created_at: str = ""
last_updated: str = ""
def to_dict(self) -> dict:
return asdict(self)
@classmethod
def from_dict(cls, data: dict) -> 'Position':
return cls(**data)
def add_entry(self, price: float, size: float, signal_id: str):
"""添加入场记录"""
entry = {
'price': price,
'size': size,
'time': datetime.now().isoformat(),
'signal_id': signal_id,
}
self.entries.append(entry)
# 更新平均价和总量
total_value = sum(e['price'] * e['size'] for e in self.entries)
self.total_size = sum(e['size'] for e in self.entries)
self.avg_entry_price = total_value / self.total_size if self.total_size > 0 else 0
self.last_updated = datetime.now().isoformat()
def reduce_position(self, reduce_size: float) -> float:
"""减仓 - 返回减仓的平均成本"""
if reduce_size >= self.total_size:
# 全部平仓
avg_cost = self.avg_entry_price
self.entries = []
self.total_size = 0
self.avg_entry_price = 0
return avg_cost
# 部分减仓 - FIFO 方式
remaining = reduce_size
removed_value = 0
removed_size = 0
while remaining > 0 and self.entries:
entry = self.entries[0]
if entry['size'] <= remaining:
removed_value += entry['price'] * entry['size']
removed_size += entry['size']
remaining -= entry['size']
self.entries.pop(0)
else:
removed_value += entry['price'] * remaining
removed_size += remaining
entry['size'] -= remaining
remaining = 0
# 更新总量和平均价
self.total_size = sum(e['size'] for e in self.entries)
if self.total_size > 0:
total_value = sum(e['price'] * e['size'] for e in self.entries)
self.avg_entry_price = total_value / self.total_size
else:
self.avg_entry_price = 0
self.last_updated = datetime.now().isoformat()
return removed_value / removed_size if removed_size > 0 else 0
@dataclass
class Trade:
"""交易记录"""
id: str
side: str
entry_price: float
entry_time: str
exit_price: float
exit_time: str
size: float
pnl: float
pnl_pct: float
exit_reason: str
signal_source: str
def to_dict(self) -> dict:
return asdict(self)
@classmethod
def from_dict(cls, data: dict) -> 'Trade':
return cls(**data)
class PositionManager:
"""仓位管理器"""
def __init__(
self,
max_position_pct: float = 0.5, # 最大持仓比例 (50% 资金)
base_position_pct: float = 0.1, # 基础仓位比例 (10% 资金)
max_entries: int = 5, # 最多加仓次数
pyramid_factor: float = 0.8, # 金字塔因子 (每次加仓量递减)
signal_cooldown: int = 300, # 同方向信号冷却时间(秒)
):
self.max_position_pct = max_position_pct
self.base_position_pct = base_position_pct
self.max_entries = max_entries
self.pyramid_factor = pyramid_factor
self.signal_cooldown = signal_cooldown
# 记录最近的信号
self.last_signal_time: Dict[str, datetime] = {}
self.signal_count: Dict[str, int] = {} # 连续同方向信号计数
def calculate_entry_size(
self,
balance: float,
current_position: Optional[Position],
signal_direction: str,
current_price: float,
leverage: int
) -> float:
"""
计算本次入场的仓位大小
Returns:
BTC 数量0 表示不开仓
"""
# 检查是否在冷却期
now = datetime.now()
last_time = self.last_signal_time.get(signal_direction)
if last_time and (now - last_time).total_seconds() < self.signal_cooldown:
logger.info(f"Signal cooldown: {signal_direction}, skip entry")
return 0
# 计算最大允许仓位价值
max_position_value = balance * self.max_position_pct * leverage
# 当前持仓价值
current_position_value = 0
num_entries = 0
if current_position and current_position.side != 'FLAT':
if current_position.side == signal_direction:
# 同方向,考虑加仓
current_position_value = current_position.total_size * current_price
num_entries = len(current_position.entries)
if num_entries >= self.max_entries:
logger.info(f"Max entries reached: {num_entries}")
return 0
else:
# 反方向,不在此处理(应先平仓)
return 0
# 计算剩余可用仓位
remaining_value = max_position_value - current_position_value
if remaining_value <= 0:
logger.info(f"Max position reached")
return 0
# 金字塔计算:每次加仓量递减
base_value = balance * self.base_position_pct * leverage
entry_value = base_value * (self.pyramid_factor ** num_entries)
# 取最小值
entry_value = min(entry_value, remaining_value)
# 转换为 BTC 数量
entry_size = entry_value / current_price
# 更新信号记录
self.last_signal_time[signal_direction] = now
self.signal_count[signal_direction] = self.signal_count.get(signal_direction, 0) + 1
return entry_size
def should_take_partial_profit(
self,
position: Position,
current_price: float,
profit_levels: List[float] = [0.01, 0.02, 0.03] # 1%, 2%, 3%
) -> Optional[Dict]:
"""
检查是否应该部分止盈
Returns:
{'size': 减仓量, 'reason': 原因} None
"""
if not position or position.side == 'FLAT' or position.total_size == 0:
return None
# 计算当前盈利
if position.side == 'LONG':
profit_pct = (current_price - position.avg_entry_price) / position.avg_entry_price
else:
profit_pct = (position.avg_entry_price - current_price) / position.avg_entry_price
# 根据入场次数决定止盈策略
num_entries = len(position.entries)
# 多次入场时更积极止盈
for i, level in enumerate(profit_levels):
adjusted_level = level * (1 - 0.1 * (num_entries - 1)) # 入场越多,止盈越早
if profit_pct >= adjusted_level:
# 止盈 1/3 仓位
reduce_size = position.total_size / 3
if reduce_size * current_price >= 10: # 最小 $10
return {
'size': reduce_size,
'reason': f'PARTIAL_TP_{int(level*100)}PCT',
'profit_pct': profit_pct,
}
return None
def reset_signal_count(self, direction: str):
"""重置信号计数(平仓后调用)"""
self.signal_count[direction] = 0
class PaperTrader:
"""模拟盘交易器 - 支持仓位管理"""
def __init__(
self,
initial_balance: float = 10000.0,
leverage: int = 5,
max_position_pct: float = 0.5,
base_position_pct: float = 0.1,
state_file: str = None
):
self.initial_balance = initial_balance
self.leverage = leverage
# 仓位管理器
self.position_manager = PositionManager(
max_position_pct=max_position_pct,
base_position_pct=base_position_pct,
)
# 状态文件
if state_file:
self.state_file = Path(state_file)
else:
self.state_file = Path(__file__).parent.parent / 'output' / 'paper_trading_state.json'
# 加载或初始化状态
self._load_state()
logger.info(f"Paper Trader initialized: balance=${self.balance:.2f}, leverage={leverage}x")
def _load_state(self):
"""加载持久化状态"""
if self.state_file.exists():
try:
with open(self.state_file, 'r') as f:
state = json.load(f)
self.balance = state.get('balance', self.initial_balance)
self.position = Position.from_dict(state['position']) if state.get('position') else None
self.trades = [Trade.from_dict(t) for t in state.get('trades', [])]
self.stats = state.get('stats', self._init_stats())
self.equity_curve = state.get('equity_curve', [])
logger.info(f"Loaded state: balance=${self.balance:.2f}, trades={len(self.trades)}")
except Exception as e:
logger.error(f"Failed to load state: {e}")
self._init_state()
else:
self._init_state()
def _init_state(self):
"""初始化状态"""
self.balance = self.initial_balance
self.position: Optional[Position] = None
self.trades: List[Trade] = []
self.stats = self._init_stats()
self.equity_curve = [] # 权益曲线
def _init_stats(self) -> dict:
"""初始化统计数据"""
return {
'total_trades': 0,
'winning_trades': 0,
'losing_trades': 0,
'total_pnl': 0.0,
'max_drawdown': 0.0,
'peak_balance': self.initial_balance,
'win_rate': 0.0,
'avg_win': 0.0,
'avg_loss': 0.0,
'profit_factor': 0.0,
'total_long_trades': 0,
'total_short_trades': 0,
'consecutive_wins': 0,
'consecutive_losses': 0,
'max_consecutive_wins': 0,
'max_consecutive_losses': 0,
}
def _save_state(self):
"""保存状态到文件"""
self.state_file.parent.mkdir(parents=True, exist_ok=True)
state = {
'balance': self.balance,
'position': self.position.to_dict() if self.position else None,
'trades': [t.to_dict() for t in self.trades[-200:]],
'stats': self.stats,
'equity_curve': self.equity_curve[-1000:],
'last_updated': datetime.now().isoformat(),
}
with open(self.state_file, 'w') as f:
json.dump(state, f, indent=2, ensure_ascii=False)
def process_signal(self, signal: Dict[str, Any], current_price: float) -> Dict[str, Any]:
"""处理交易信号"""
result = {
'timestamp': datetime.now().isoformat(),
'current_price': current_price,
'action': 'NONE',
'details': None,
}
# 更新权益曲线
self._update_equity_curve(current_price)
# 1. 检查止盈止损
if self.position and self.position.side != 'FLAT':
close_result = self._check_close_position(current_price)
if close_result:
result['action'] = 'CLOSE'
result['details'] = close_result
self._save_state()
return result
# 2. 检查部分止盈
partial_tp = self.position_manager.should_take_partial_profit(
self.position, current_price
)
if partial_tp:
close_result = self._partial_close(current_price, partial_tp['size'], partial_tp['reason'])
result['action'] = 'PARTIAL_CLOSE'
result['details'] = close_result
self._save_state()
return result
# 3. 提取短期信号
short_term = self._extract_short_term_signal(signal)
if not short_term or not short_term.get('exists'):
result['action'] = 'NO_SIGNAL'
result['details'] = {'reason': '无有效短期信号'}
return result
direction = short_term['direction']
# 4. 如果有反向持仓,先平仓
if self.position and self.position.side != 'FLAT':
if (self.position.side == 'LONG' and direction == 'SHORT') or \
(self.position.side == 'SHORT' and direction == 'LONG'):
close_result = self._close_position(current_price, 'SIGNAL_REVERSE')
result['action'] = 'REVERSE'
result['details'] = {'close': close_result}
# 开反向仓
open_result = self._try_open_position(
direction, current_price,
short_term.get('stop_loss', 0),
short_term.get('take_profit', 0),
short_term.get('reasoning', '')[:100]
)
if open_result:
result['details']['open'] = open_result
self._save_state()
return result
else:
# 同方向,尝试加仓
add_result = self._try_add_position(
direction, current_price,
short_term.get('stop_loss', 0),
short_term.get('take_profit', 0),
short_term.get('reasoning', '')[:100]
)
if add_result:
result['action'] = 'ADD'
result['details'] = add_result
self._save_state()
return result
else:
result['action'] = 'HOLD'
result['details'] = {
'position': self.position.to_dict(),
'unrealized_pnl': self._calc_unrealized_pnl(current_price),
'reason': '已有持仓,加仓条件不满足'
}
return result
# 5. 无持仓,开新仓
open_result = self._try_open_position(
direction, current_price,
short_term.get('stop_loss', 0),
short_term.get('take_profit', 0),
short_term.get('reasoning', '')[:100]
)
if open_result:
result['action'] = 'OPEN'
result['details'] = open_result
else:
result['action'] = 'WAIT'
result['details'] = {'reason': '仓位条件不满足'}
self._save_state()
return result
def _extract_short_term_signal(self, signal: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""提取短期信号"""
try:
llm_signal = signal.get('llm_signal') or signal.get('aggregated_signal', {}).get('llm_signal')
if llm_signal and isinstance(llm_signal, dict):
opportunities = llm_signal.get('opportunities', {})
short_term = opportunities.get('short_term_5m_15m_1h') or opportunities.get('intraday')
if short_term:
return short_term
agg = signal.get('aggregated_signal', {})
if agg:
llm = agg.get('llm_signal', {})
if llm:
opps = llm.get('opportunities', {})
short_term = opps.get('short_term_5m_15m_1h') or opps.get('intraday')
if short_term:
return short_term
return None
except Exception as e:
logger.error(f"Error extracting short term signal: {e}")
return None
def _try_open_position(
self, direction: str, price: float,
stop_loss: float, take_profit: float, signal_source: str
) -> Optional[Dict]:
"""尝试开仓"""
# 计算仓位大小
entry_size = self.position_manager.calculate_entry_size(
self.balance, self.position, direction, price, self.leverage
)
if entry_size <= 0:
return None
# 创建持仓
self.position = Position(
side=direction,
stop_loss=stop_loss if stop_loss > 0 else self._calc_default_stop(direction, price),
take_profit=take_profit if take_profit > 0 else self._calc_default_tp(direction, price),
created_at=datetime.now().isoformat(),
)
signal_id = f"S{datetime.now().strftime('%H%M%S')}"
self.position.add_entry(price, entry_size, signal_id)
logger.info(
f"OPEN {direction}: price=${price:.2f}, size={entry_size:.6f} BTC, "
f"SL=${self.position.stop_loss:.2f}, TP=${self.position.take_profit:.2f}"
)
return {
'side': direction,
'entry_price': price,
'size': entry_size,
'total_size': self.position.total_size,
'stop_loss': self.position.stop_loss,
'take_profit': self.position.take_profit,
'num_entries': 1,
}
def _try_add_position(
self, direction: str, price: float,
stop_loss: float, take_profit: float, signal_source: str
) -> Optional[Dict]:
"""尝试加仓"""
if not self.position or self.position.side != direction:
return None
entry_size = self.position_manager.calculate_entry_size(
self.balance, self.position, direction, price, self.leverage
)
if entry_size <= 0:
return None
signal_id = f"S{datetime.now().strftime('%H%M%S')}"
old_avg = self.position.avg_entry_price
self.position.add_entry(price, entry_size, signal_id)
# 可选:更新止盈止损
if stop_loss > 0:
self.position.stop_loss = stop_loss
if take_profit > 0:
self.position.take_profit = take_profit
logger.info(
f"ADD {direction}: price=${price:.2f}, size={entry_size:.6f} BTC, "
f"avg_entry=${old_avg:.2f}->${self.position.avg_entry_price:.2f}, "
f"total_size={self.position.total_size:.6f}"
)
return {
'side': direction,
'add_price': price,
'add_size': entry_size,
'total_size': self.position.total_size,
'avg_entry_price': self.position.avg_entry_price,
'num_entries': len(self.position.entries),
}
def _calc_default_stop(self, side: str, price: float) -> float:
"""计算默认止损 (0.5%)"""
if side == 'LONG':
return price * 0.995
else:
return price * 1.005
def _calc_default_tp(self, side: str, price: float) -> float:
"""计算默认止盈 (1.5%)"""
if side == 'LONG':
return price * 1.015
else:
return price * 0.985
def _check_close_position(self, current_price: float) -> Optional[Dict[str, Any]]:
"""检查是否触发止盈止损"""
if not self.position or self.position.side == 'FLAT':
return None
if self.position.side == 'LONG':
if current_price >= self.position.take_profit:
return self._close_position(current_price, 'TAKE_PROFIT')
elif current_price <= self.position.stop_loss:
return self._close_position(current_price, 'STOP_LOSS')
else:
if current_price <= self.position.take_profit:
return self._close_position(current_price, 'TAKE_PROFIT')
elif current_price >= self.position.stop_loss:
return self._close_position(current_price, 'STOP_LOSS')
return None
def _close_position(self, price: float, reason: str) -> Dict[str, Any]:
"""全部平仓"""
if not self.position or self.position.side == 'FLAT':
return {'error': 'No position to close'}
pnl, pnl_pct = self._calc_pnl(price)
self.balance += pnl
trade = Trade(
id=f"T{len(self.trades)+1:04d}",
side=self.position.side,
entry_price=self.position.avg_entry_price,
entry_time=self.position.created_at,
exit_price=price,
exit_time=datetime.now().isoformat(),
size=self.position.total_size,
pnl=pnl,
pnl_pct=pnl_pct,
exit_reason=reason,
signal_source=f"{len(self.position.entries)} entries",
)
self.trades.append(trade)
self._update_stats(trade)
result = {
'side': self.position.side,
'entry_price': self.position.avg_entry_price,
'exit_price': price,
'size': self.position.total_size,
'num_entries': len(self.position.entries),
'pnl': pnl,
'pnl_pct': pnl_pct,
'reason': reason,
'new_balance': self.balance,
}
logger.info(
f"CLOSE {self.position.side}: avg_entry=${self.position.avg_entry_price:.2f}, "
f"exit=${price:.2f}, PnL=${pnl:.2f} ({pnl_pct:.2f}%), reason={reason}"
)
# 重置
self.position_manager.reset_signal_count(self.position.side)
self.position = None
return result
def _partial_close(self, price: float, size: float, reason: str) -> Dict[str, Any]:
"""部分平仓"""
if not self.position or self.position.side == 'FLAT':
return {'error': 'No position'}
avg_cost = self.position.reduce_position(size)
if self.position.side == 'LONG':
pnl_pct = (price - avg_cost) / avg_cost * 100 * self.leverage
else:
pnl_pct = (avg_cost - price) / avg_cost * 100 * self.leverage
pnl = size * avg_cost * (pnl_pct / 100)
self.balance += pnl
trade = Trade(
id=f"T{len(self.trades)+1:04d}",
side=self.position.side,
entry_price=avg_cost,
entry_time=self.position.created_at,
exit_price=price,
exit_time=datetime.now().isoformat(),
size=size,
pnl=pnl,
pnl_pct=pnl_pct,
exit_reason=reason,
signal_source="partial",
)
self.trades.append(trade)
self._update_stats(trade)
logger.info(
f"PARTIAL CLOSE: size={size:.6f}, PnL=${pnl:.2f} ({pnl_pct:.2f}%), "
f"remaining={self.position.total_size:.6f}"
)
# 如果完全平仓
if self.position.total_size <= 0:
self.position_manager.reset_signal_count(self.position.side)
self.position = None
return {
'side': self.position.side if self.position else 'FLAT',
'closed_size': size,
'exit_price': price,
'pnl': pnl,
'pnl_pct': pnl_pct,
'reason': reason,
'remaining_size': self.position.total_size if self.position else 0,
'new_balance': self.balance,
}
def _calc_pnl(self, current_price: float) -> tuple:
"""计算盈亏"""
if not self.position:
return 0.0, 0.0
if self.position.side == 'LONG':
pnl_pct = (current_price - self.position.avg_entry_price) / self.position.avg_entry_price * 100
else:
pnl_pct = (self.position.avg_entry_price - current_price) / self.position.avg_entry_price * 100
pnl_pct *= self.leverage
position_value = self.position.total_size * self.position.avg_entry_price
pnl = position_value * (pnl_pct / 100)
return pnl, pnl_pct
def _calc_unrealized_pnl(self, current_price: float) -> Dict[str, float]:
"""计算未实现盈亏"""
pnl, pnl_pct = self._calc_pnl(current_price)
return {'pnl': pnl, 'pnl_pct': pnl_pct}
def _update_equity_curve(self, current_price: float):
"""更新权益曲线"""
equity = self.balance
if self.position and self.position.total_size > 0:
unrealized = self._calc_unrealized_pnl(current_price)
equity += unrealized['pnl']
self.equity_curve.append({
'timestamp': datetime.now().isoformat(),
'equity': equity,
'balance': self.balance,
'price': current_price,
})
def _update_stats(self, trade: Trade):
"""更新统计数据"""
self.stats['total_trades'] += 1
self.stats['total_pnl'] += trade.pnl
if trade.side == 'LONG':
self.stats['total_long_trades'] += 1
else:
self.stats['total_short_trades'] += 1
if trade.pnl > 0:
self.stats['winning_trades'] += 1
self.stats['consecutive_wins'] += 1
self.stats['consecutive_losses'] = 0
if self.stats['consecutive_wins'] > self.stats['max_consecutive_wins']:
self.stats['max_consecutive_wins'] = self.stats['consecutive_wins']
else:
self.stats['losing_trades'] += 1
self.stats['consecutive_losses'] += 1
self.stats['consecutive_wins'] = 0
if self.stats['consecutive_losses'] > self.stats['max_consecutive_losses']:
self.stats['max_consecutive_losses'] = self.stats['consecutive_losses']
if self.stats['total_trades'] > 0:
self.stats['win_rate'] = self.stats['winning_trades'] / self.stats['total_trades'] * 100
wins = [t for t in self.trades if t.pnl > 0]
losses = [t for t in self.trades if t.pnl <= 0]
if wins:
self.stats['avg_win'] = sum(t.pnl for t in wins) / len(wins)
if losses:
self.stats['avg_loss'] = sum(t.pnl for t in losses) / len(losses)
if self.stats['avg_loss'] != 0:
self.stats['profit_factor'] = abs(self.stats['avg_win'] / self.stats['avg_loss'])
if self.balance > self.stats['peak_balance']:
self.stats['peak_balance'] = self.balance
drawdown = (self.stats['peak_balance'] - self.balance) / self.stats['peak_balance'] * 100
if drawdown > self.stats['max_drawdown']:
self.stats['max_drawdown'] = drawdown
def get_status(self, current_price: float = None) -> Dict[str, Any]:
"""获取当前状态"""
status = {
'timestamp': datetime.now().isoformat(),
'balance': self.balance,
'initial_balance': self.initial_balance,
'total_return': (self.balance - self.initial_balance) / self.initial_balance * 100,
'leverage': self.leverage,
'position': None,
'stats': self.stats,
'recent_trades': [t.to_dict() for t in self.trades[-10:]],
'equity_curve': self.equity_curve[-100:],
}
if self.position and self.position.total_size > 0:
pos_dict = self.position.to_dict()
if current_price:
unrealized = self._calc_unrealized_pnl(current_price)
pos_dict['current_price'] = current_price
pos_dict['unrealized_pnl'] = unrealized['pnl']
pos_dict['unrealized_pnl_pct'] = unrealized['pnl_pct']
status['position'] = pos_dict
return status
def reset(self):
"""重置模拟盘"""
self._init_state()
self._save_state()
logger.info("Paper trading account reset")

354
trading/realtime_trader.py Normal file
View File

@ -0,0 +1,354 @@
"""
Realtime Paper Trading - 基于 WebSocket 实时数据的模拟盘
使用 Binance WebSocket 获取实时价格结合信号进行模拟交易
支持仓位管理金字塔加仓最大持仓限制部分止盈
"""
import asyncio
import json
import logging
import signal
import sys
from datetime import datetime
from pathlib import Path
from typing import Dict, Any, Optional, Callable
import websockets
from .paper_trading import PaperTrader
logger = logging.getLogger(__name__)
class RealtimeTrader:
"""实时模拟盘交易器"""
def __init__(
self,
symbol: str = "btcusdt",
initial_balance: float = 10000.0,
leverage: int = 5,
max_position_pct: float = 0.5,
base_position_pct: float = 0.1,
signal_check_interval: int = 60, # 每60秒检查一次信号
):
"""
初始化实时交易器
Args:
symbol: 交易对 (小写)
initial_balance: 初始资金
leverage: 杠杆倍数
max_position_pct: 最大持仓比例 (占资金百分比)
base_position_pct: 基础仓位比例 (每次入场)
signal_check_interval: 信号检查间隔()
"""
self.symbol = symbol.lower()
self.signal_check_interval = signal_check_interval
# WebSocket URL
self.ws_url = f"wss://fstream.binance.com/ws/{self.symbol}@aggTrade"
# 模拟盘 - 使用新的仓位管理参数
self.trader = PaperTrader(
initial_balance=initial_balance,
leverage=leverage,
max_position_pct=max_position_pct,
base_position_pct=base_position_pct,
)
# 状态
self.current_price = 0.0
self.last_signal_check = 0
self.is_running = False
self.ws = None
# 信号文件路径
self.signal_file = Path(__file__).parent.parent / 'output' / 'latest_signal.json'
# 回调函数
self.on_trade_callback: Optional[Callable] = None
self.on_price_callback: Optional[Callable] = None
async def start(self):
"""启动实时交易"""
self.is_running = True
logger.info(f"Starting realtime trader for {self.symbol.upper()}")
logger.info(f"WebSocket URL: {self.ws_url}")
logger.info(f"Initial balance: ${self.trader.balance:.2f}")
logger.info(f"Leverage: {self.trader.leverage}x")
logger.info(f"Max position: {self.trader.position_manager.max_position_pct * 100}%")
logger.info(f"Base position: {self.trader.position_manager.base_position_pct * 100}%")
logger.info(f"Signal check interval: {self.signal_check_interval}s")
while self.is_running:
try:
await self._connect_and_trade()
except Exception as e:
logger.error(f"Connection error: {e}")
if self.is_running:
logger.info("Reconnecting in 5 seconds...")
await asyncio.sleep(5)
async def _connect_and_trade(self):
"""连接 WebSocket 并开始交易"""
async with websockets.connect(self.ws_url) as ws:
self.ws = ws
logger.info("WebSocket connected")
# 打印初始状态
self._print_status()
async for message in ws:
if not self.is_running:
break
try:
data = json.loads(message)
await self._process_tick(data)
except json.JSONDecodeError:
continue
except Exception as e:
logger.error(f"Error processing tick: {e}")
async def _process_tick(self, data: Dict[str, Any]):
"""处理每个 tick 数据"""
# 提取价格
self.current_price = float(data.get('p', 0))
if self.current_price <= 0:
return
# 调用价格回调
if self.on_price_callback:
self.on_price_callback(self.current_price)
# 检查止盈止损
if self.trader.position:
close_result = self.trader._check_close_position(self.current_price)
if close_result:
self._on_position_closed(close_result)
# 定期检查信号
now = asyncio.get_event_loop().time()
if now - self.last_signal_check >= self.signal_check_interval:
self.last_signal_check = now
await self._check_and_execute_signal()
async def _check_and_execute_signal(self):
"""检查信号并执行交易"""
signal = self._load_latest_signal()
if not signal:
return
result = self.trader.process_signal(signal, self.current_price)
if result['action'] in ['OPEN', 'CLOSE', 'REVERSE', 'ADD', 'PARTIAL_CLOSE']:
self._on_trade_executed(result)
self._print_status()
def _load_latest_signal(self) -> Optional[Dict[str, Any]]:
"""加载最新信号"""
try:
if not self.signal_file.exists():
return None
with open(self.signal_file, 'r') as f:
return json.load(f)
except Exception as e:
logger.error(f"Error loading signal: {e}")
return None
def _on_trade_executed(self, result: Dict[str, Any]):
"""交易执行回调"""
action = result['action']
details = result['details']
if action == 'OPEN':
logger.info("=" * 60)
logger.info(f"🟢 OPEN {details['side']}")
logger.info(f" Entry: ${details['entry_price']:.2f}")
logger.info(f" Size: {details['size']:.6f} BTC")
logger.info(f" Total Size: {details['total_size']:.6f} BTC")
logger.info(f" Stop Loss: ${details['stop_loss']:.2f}")
logger.info(f" Take Profit: ${details['take_profit']:.2f}")
logger.info("=" * 60)
elif action == 'ADD':
logger.info("=" * 60)
logger.info(f" ADD POSITION {details['side']}")
logger.info(f" Add Price: ${details['add_price']:.2f}")
logger.info(f" Add Size: {details['add_size']:.6f} BTC")
logger.info(f" Total Size: {details['total_size']:.6f} BTC")
logger.info(f" Avg Entry: ${details['avg_entry_price']:.2f}")
logger.info(f" Entries: {details['num_entries']}")
logger.info("=" * 60)
elif action == 'CLOSE':
pnl = details['pnl']
pnl_icon = "🟢" if pnl > 0 else "🔴"
logger.info("=" * 60)
logger.info(f"{pnl_icon} CLOSE {details['side']}")
logger.info(f" Entry: ${details['entry_price']:.2f}")
logger.info(f" Exit: ${details['exit_price']:.2f}")
logger.info(f" Size: {details['size']:.6f} BTC")
logger.info(f" Entries: {details.get('num_entries', 1)}")
logger.info(f" PnL: ${pnl:.2f} ({details['pnl_pct']:.2f}%)")
logger.info(f" Reason: {details['reason']}")
logger.info(f" New Balance: ${details['new_balance']:.2f}")
logger.info("=" * 60)
elif action == 'PARTIAL_CLOSE':
pnl = details['pnl']
pnl_icon = "🟢" if pnl > 0 else "🔴"
logger.info("=" * 60)
logger.info(f"📉 PARTIAL CLOSE {details['side']}")
logger.info(f" Closed Size: {details['closed_size']:.6f} BTC")
logger.info(f" Exit: ${details['exit_price']:.2f}")
logger.info(f" {pnl_icon} PnL: ${pnl:.2f} ({details['pnl_pct']:.2f}%)")
logger.info(f" Remaining: {details['remaining_size']:.6f} BTC")
logger.info(f" New Balance: ${details['new_balance']:.2f}")
logger.info("=" * 60)
elif action == 'REVERSE':
logger.info("=" * 60)
logger.info("🔄 REVERSE POSITION")
if 'close' in details:
logger.info(f" Closed: PnL ${details['close']['pnl']:.2f}")
if 'open' in details:
logger.info(f" Opened: {details['open']['side']} @ ${details['open']['entry_price']:.2f}")
logger.info("=" * 60)
# 调用外部回调
if self.on_trade_callback:
self.on_trade_callback(result)
def _on_position_closed(self, close_result: Dict[str, Any]):
"""持仓被平仓回调(止盈止损)"""
pnl = close_result['pnl']
pnl_icon = "🟢" if pnl > 0 else "🔴"
reason_icon = "🎯" if close_result['reason'] == 'TAKE_PROFIT' else "🛑"
logger.info("=" * 60)
logger.info(f"{reason_icon} {close_result['reason']}")
logger.info(f" {pnl_icon} PnL: ${pnl:.2f} ({close_result['pnl_pct']:.2f}%)")
logger.info(f" Entry: ${close_result['entry_price']:.2f}")
logger.info(f" Exit: ${close_result['exit_price']:.2f}")
logger.info(f" Size: {close_result['size']:.6f} BTC")
logger.info(f" Entries: {close_result.get('num_entries', 1)}")
logger.info(f" New Balance: ${close_result['new_balance']:.2f}")
logger.info("=" * 60)
self._print_status()
if self.on_trade_callback:
self.on_trade_callback({
'action': 'CLOSE',
'details': close_result,
})
def _print_status(self):
"""打印当前状态"""
status = self.trader.get_status(self.current_price)
print("\n" + "=" * 70)
print(f"📊 PAPER TRADING STATUS - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print("=" * 70)
print(f"💰 Balance: ${status['balance']:.2f} (Initial: ${status['initial_balance']:.2f})")
print(f"📈 Total Return: {status['total_return']:.2f}%")
print(f"💵 Current Price: ${self.current_price:.2f}")
if status['position']:
pos = status['position']
unrealized = pos.get('unrealized_pnl', 0)
unrealized_pct = pos.get('unrealized_pnl_pct', 0)
pnl_icon = "🟢" if unrealized > 0 else "🔴" if unrealized < 0 else ""
print(f"\n📍 Position: {pos['side']} ({len(pos.get('entries', []))} entries)")
print(f" Avg Entry: ${pos['avg_entry_price']:.2f}")
print(f" Size: {pos['total_size']:.6f} BTC")
print(f" Stop Loss: ${pos['stop_loss']:.2f}")
print(f" Take Profit: ${pos['take_profit']:.2f}")
print(f" {pnl_icon} Unrealized PnL: ${unrealized:.2f} ({unrealized_pct:.2f}%)")
else:
print("\n📍 Position: FLAT (No position)")
stats = status['stats']
print(f"\n📊 Statistics:")
print(f" Total Trades: {stats['total_trades']}")
print(f" Win Rate: {stats['win_rate']:.1f}%")
print(f" Total PnL: ${stats['total_pnl']:.2f}")
print(f" Profit Factor: {stats['profit_factor']:.2f}")
print(f" Max Drawdown: {stats['max_drawdown']:.2f}%")
print(f" Max Consecutive Wins: {stats.get('max_consecutive_wins', 0)}")
print(f" Max Consecutive Losses: {stats.get('max_consecutive_losses', 0)}")
if status['recent_trades']:
print(f"\n📝 Recent Trades:")
for trade in status['recent_trades'][-5:]:
pnl_icon = "🟢" if trade['pnl'] > 0 else "🔴"
print(f" {pnl_icon} {trade['side']} | PnL: ${trade['pnl']:.2f} ({trade['pnl_pct']:.1f}%) | {trade['exit_reason']}")
print("=" * 70 + "\n")
def stop(self):
"""停止交易"""
self.is_running = False
logger.info("Stopping realtime trader...")
def get_status(self) -> Dict[str, Any]:
"""获取状态"""
return self.trader.get_status(self.current_price)
async def main():
"""主函数"""
import os
from dotenv import load_dotenv
# 加载环境变量
load_dotenv(Path(__file__).parent.parent / '.env')
# 设置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
# 创建交易器
trader = RealtimeTrader(
symbol='btcusdt',
initial_balance=10000.0,
leverage=5,
max_position_pct=0.5, # 最大持仓50%资金
base_position_pct=0.1, # 每次入场10%资金
signal_check_interval=30, # 每30秒检查一次信号
)
# 设置信号处理
def signal_handler(sig, frame):
logger.info("Received shutdown signal")
trader.stop()
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
# 启动
print("\n" + "=" * 70)
print("🚀 REALTIME PAPER TRADING")
print("=" * 70)
print("Position Management:")
print(" - Max position: 50% of balance")
print(" - Base entry: 10% of balance")
print(" - Max entries: 5 (pyramid)")
print(" - Pyramid factor: 0.8x per entry")
print(" - Signal cooldown: 5 minutes")
print("=" * 70)
print("Press Ctrl+C to stop")
print("=" * 70 + "\n")
await trader.start()
if __name__ == "__main__":
asyncio.run(main())

1
web/__init__.py Normal file
View File

@ -0,0 +1 @@
# Web module for paper trading dashboard

242
web/api.py Normal file
View File

@ -0,0 +1,242 @@
"""
FastAPI Web Service - 模拟盘状态展示 API
"""
import json
import asyncio
from datetime import datetime
from pathlib import Path
from typing import Dict, Any, Optional, List
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, FileResponse
from pydantic import BaseModel
# 状态文件路径
STATE_FILE = Path(__file__).parent.parent / 'output' / 'paper_trading_state.json'
SIGNAL_FILE = Path(__file__).parent.parent / 'output' / 'latest_signal.json'
app = FastAPI(title="Paper Trading Dashboard", version="1.0.0")
# WebSocket 连接管理
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def broadcast(self, message: dict):
for connection in self.active_connections:
try:
await connection.send_json(message)
except:
pass
manager = ConnectionManager()
def load_trading_state() -> Dict[str, Any]:
"""加载交易状态"""
try:
if STATE_FILE.exists():
with open(STATE_FILE, 'r') as f:
return json.load(f)
except Exception as e:
print(f"Error loading state: {e}")
return {
'balance': 10000.0,
'position': None,
'trades': [],
'stats': {
'total_trades': 0,
'winning_trades': 0,
'losing_trades': 0,
'total_pnl': 0.0,
'max_drawdown': 0.0,
'peak_balance': 10000.0,
'win_rate': 0.0,
},
'equity_curve': [],
}
def load_latest_signal() -> Dict[str, Any]:
"""加载最新信号"""
try:
if SIGNAL_FILE.exists():
with open(SIGNAL_FILE, 'r') as f:
return json.load(f)
except Exception as e:
print(f"Error loading signal: {e}")
return {}
@app.get("/")
async def root():
"""返回前端页面"""
html_file = Path(__file__).parent / 'static' / 'index.html'
if html_file.exists():
return FileResponse(html_file)
return HTMLResponse("<h1>Paper Trading Dashboard</h1><p>Static files not found</p>")
@app.get("/api/status")
async def get_status():
"""获取模拟盘状态"""
state = load_trading_state()
signal = load_latest_signal()
# 计算总收益率
initial_balance = 10000.0
total_return = (state.get('balance', initial_balance) - initial_balance) / initial_balance * 100
return {
'timestamp': datetime.now().isoformat(),
'balance': state.get('balance', initial_balance),
'initial_balance': initial_balance,
'total_return': total_return,
'position': state.get('position'),
'stats': state.get('stats', {}),
'last_updated': state.get('last_updated'),
}
@app.get("/api/trades")
async def get_trades(limit: int = 50):
"""获取交易记录"""
state = load_trading_state()
trades = state.get('trades', [])
return {
'total': len(trades),
'trades': trades[-limit:] if limit > 0 else trades,
}
@app.get("/api/equity")
async def get_equity_curve(limit: int = 500):
"""获取权益曲线"""
state = load_trading_state()
equity_curve = state.get('equity_curve', [])
return {
'total': len(equity_curve),
'data': equity_curve[-limit:] if limit > 0 else equity_curve,
}
@app.get("/api/signal")
async def get_signal():
"""获取最新信号"""
signal = load_latest_signal()
# 提取关键信息
agg = signal.get('aggregated_signal', {})
llm = agg.get('llm_signal', {})
quant = agg.get('quantitative_signal', {})
market = signal.get('market_analysis', {})
return {
'timestamp': agg.get('timestamp'),
'final_signal': agg.get('final_signal'),
'final_confidence': agg.get('final_confidence'),
'consensus': agg.get('consensus'),
'current_price': agg.get('levels', {}).get('current_price'),
'llm': {
'signal': llm.get('signal_type'),
'confidence': llm.get('confidence'),
'reasoning': llm.get('reasoning'),
'opportunities': llm.get('opportunities', {}),
'recommendations': llm.get('recommendations_by_timeframe', {}),
},
'quantitative': {
'signal': quant.get('signal_type'),
'confidence': quant.get('confidence'),
'composite_score': quant.get('composite_score'),
'scores': quant.get('scores', {}),
},
'market': {
'price': market.get('price'),
'trend': market.get('trend', {}),
'momentum': market.get('momentum', {}),
},
}
@app.get("/api/position")
async def get_position():
"""获取当前持仓详情"""
state = load_trading_state()
position = state.get('position')
if not position:
return {'has_position': False, 'position': None}
return {
'has_position': position.get('side') != 'FLAT' and position.get('total_size', 0) > 0,
'position': position,
}
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket 实时推送"""
await manager.connect(websocket)
try:
# 发送初始状态
state = load_trading_state()
signal = load_latest_signal()
await websocket.send_json({
'type': 'init',
'state': state,
'signal': signal,
})
# 持续推送更新
last_state_mtime = STATE_FILE.stat().st_mtime if STATE_FILE.exists() else 0
last_signal_mtime = SIGNAL_FILE.stat().st_mtime if SIGNAL_FILE.exists() else 0
while True:
await asyncio.sleep(1) # 每秒检查一次
# 检查状态文件更新
current_state_mtime = STATE_FILE.stat().st_mtime if STATE_FILE.exists() else 0
current_signal_mtime = SIGNAL_FILE.stat().st_mtime if SIGNAL_FILE.exists() else 0
if current_state_mtime > last_state_mtime:
last_state_mtime = current_state_mtime
state = load_trading_state()
await websocket.send_json({
'type': 'state_update',
'state': state,
})
if current_signal_mtime > last_signal_mtime:
last_signal_mtime = current_signal_mtime
signal = load_latest_signal()
await websocket.send_json({
'type': 'signal_update',
'signal': signal,
})
except WebSocketDisconnect:
manager.disconnect(websocket)
except Exception as e:
print(f"WebSocket error: {e}")
manager.disconnect(websocket)
# 静态文件
static_dir = Path(__file__).parent / 'static'
if static_dir.exists():
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8080)

781
web/static/index.html Normal file
View File

@ -0,0 +1,781 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Trading Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
},
success: '#10b981',
danger: '#ef4444',
warning: '#f59e0b',
}
}
}
}
</script>
<style>
body {
font-family: 'Inter', system-ui, sans-serif;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
min-height: 100vh;
}
.glass-card {
background: rgba(30, 41, 59, 0.8);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.stat-card {
background: linear-gradient(145deg, rgba(30, 41, 59, 0.9), rgba(15, 23, 42, 0.9));
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
transition: transform 0.2s, box-shadow 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
}
.glow-success {
box-shadow: 0 0 20px rgba(16, 185, 129, 0.3);
}
.glow-danger {
box-shadow: 0 0 20px rgba(239, 68, 68, 0.3);
}
.glow-primary {
box-shadow: 0 0 20px rgba(14, 165, 233, 0.3);
}
.text-gradient {
background: linear-gradient(135deg, #38bdf8, #818cf8);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.progress-ring {
transform: rotate(-90deg);
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge-long {
background: rgba(16, 185, 129, 0.2);
color: #10b981;
border: 1px solid rgba(16, 185, 129, 0.3);
}
.badge-short {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.badge-hold {
background: rgba(148, 163, 184, 0.2);
color: #94a3b8;
border: 1px solid rgba(148, 163, 184, 0.3);
}
.badge-flat {
background: rgba(100, 116, 139, 0.2);
color: #64748b;
border: 1px solid rgba(100, 116, 139, 0.3);
}
.table-row {
transition: background 0.2s;
}
.table-row:hover {
background: rgba(255, 255, 255, 0.05);
}
.pulse-dot {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.1); }
}
.fade-in {
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(30, 41, 59, 0.5);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: rgba(100, 116, 139, 0.5);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(100, 116, 139, 0.7);
}
</style>
</head>
<body class="text-slate-100">
<div class="container mx-auto px-4 py-8 max-w-7xl">
<!-- Header -->
<header class="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
<div>
<h1 class="text-3xl md:text-4xl font-bold text-gradient mb-2">Trading Dashboard</h1>
<p class="text-slate-400 text-sm">BTC/USDT Perpetual</p>
</div>
<div class="flex items-center gap-4">
<div id="connection-status" class="flex items-center gap-2 px-4 py-2 rounded-full bg-yellow-500/20 text-yellow-400 text-sm font-medium">
<span class="w-2 h-2 rounded-full bg-yellow-400 pulse-dot"></span>
Connecting...
</div>
<div class="text-slate-500 text-sm">
<span id="last-update">--:--:--</span>
</div>
</div>
</header>
<!-- Main Stats Grid -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<!-- Balance -->
<div class="stat-card p-5 fade-in">
<div class="flex items-center gap-2 mb-3">
<div class="w-10 h-10 rounded-xl bg-primary-500/20 flex items-center justify-center">
<svg class="w-5 h-5 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<span class="text-slate-400 text-sm font-medium">Balance</span>
</div>
<div id="balance" class="text-2xl md:text-3xl font-bold text-white mb-1">$10,000.00</div>
<div id="total-return" class="text-sm font-medium text-slate-400">+0.00%</div>
</div>
<!-- Total Trades -->
<div class="stat-card p-5 fade-in" style="animation-delay: 0.1s">
<div class="flex items-center gap-2 mb-3">
<div class="w-10 h-10 rounded-xl bg-purple-500/20 flex items-center justify-center">
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
</div>
<span class="text-slate-400 text-sm font-medium">Total Trades</span>
</div>
<div id="total-trades" class="text-2xl md:text-3xl font-bold text-white mb-1">0</div>
<div class="flex items-center gap-2 text-sm">
<span class="text-success" id="winning-trades">0</span>
<span class="text-slate-500">/</span>
<span class="text-danger" id="losing-trades">0</span>
</div>
</div>
<!-- Win Rate -->
<div class="stat-card p-5 fade-in" style="animation-delay: 0.2s">
<div class="flex items-center gap-2 mb-3">
<div class="w-10 h-10 rounded-xl bg-success/20 flex items-center justify-center">
<svg class="w-5 h-5 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/>
</svg>
</div>
<span class="text-slate-400 text-sm font-medium">Win Rate</span>
</div>
<div id="win-rate" class="text-2xl md:text-3xl font-bold text-white mb-1">0.0%</div>
<div class="text-sm text-slate-400">PF: <span id="profit-factor">0.00</span></div>
</div>
<!-- Total PnL -->
<div class="stat-card p-5 fade-in" style="animation-delay: 0.3s">
<div class="flex items-center gap-2 mb-3">
<div class="w-10 h-10 rounded-xl bg-amber-500/20 flex items-center justify-center">
<svg class="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
</div>
<span class="text-slate-400 text-sm font-medium">Total PnL</span>
</div>
<div id="total-pnl" class="text-2xl md:text-3xl font-bold text-white mb-1">$0.00</div>
<div class="text-sm text-slate-400">Max DD: <span id="max-drawdown" class="text-danger">0.00%</span></div>
</div>
</div>
<!-- Position & Signal Row -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- Current Position -->
<div class="glass-card p-6 fade-in" style="animation-delay: 0.4s">
<div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
<svg class="w-5 h-5 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
Current Position
</h2>
<span id="position-badge" class="badge badge-flat">FLAT</span>
</div>
<div id="position-info">
<div class="flex flex-col items-center justify-center py-12 text-slate-500">
<svg class="w-16 h-16 mb-4 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 12H4M12 4v16"/>
</svg>
<p class="text-lg font-medium">No Position</p>
<p class="text-sm text-slate-600">Waiting for signal...</p>
</div>
</div>
</div>
<!-- Latest Signal -->
<div class="glass-card p-6 fade-in" style="animation-delay: 0.5s">
<div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
<svg class="w-5 h-5 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/>
</svg>
Latest Signal
</h2>
<span id="signal-badge" class="badge badge-hold">HOLD</span>
</div>
<div id="signal-info">
<div class="flex flex-col items-center justify-center py-12 text-slate-500">
<svg class="w-16 h-16 mb-4 text-slate-600 animate-spin" style="animation-duration: 3s" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
<p class="text-lg font-medium">Loading Signal</p>
<p class="text-sm text-slate-600">Fetching latest analysis...</p>
</div>
</div>
</div>
</div>
<!-- Equity Chart -->
<div class="glass-card p-6 mb-8 fade-in" style="animation-delay: 0.6s">
<div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
<svg class="w-5 h-5 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"/>
</svg>
Equity Curve
</h2>
<div class="flex items-center gap-4 text-sm">
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-primary-400"></span>
<span class="text-slate-400">Equity</span>
</div>
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-success"></span>
<span class="text-slate-400">Balance</span>
</div>
</div>
</div>
<div class="h-72">
<canvas id="equity-chart"></canvas>
</div>
</div>
<!-- Recent Trades -->
<div class="glass-card p-6 fade-in" style="animation-delay: 0.7s">
<div class="flex items-center justify-between mb-6">
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
<svg class="w-5 h-5 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
</svg>
Trade History
</h2>
<span id="trade-count" class="text-sm text-slate-500">0 trades</span>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-slate-400 text-xs uppercase tracking-wider border-b border-slate-700/50">
<th class="px-4 py-3 text-left font-medium">ID</th>
<th class="px-4 py-3 text-left font-medium">Side</th>
<th class="px-4 py-3 text-right font-medium">Entry</th>
<th class="px-4 py-3 text-right font-medium">Exit</th>
<th class="px-4 py-3 text-right font-medium">Size</th>
<th class="px-4 py-3 text-right font-medium">PnL</th>
<th class="px-4 py-3 text-left font-medium">Reason</th>
<th class="px-4 py-3 text-left font-medium">Time</th>
</tr>
</thead>
<tbody id="trades-table" class="divide-y divide-slate-700/30">
<tr>
<td colspan="8" class="text-center py-12 text-slate-500">
<svg class="w-12 h-12 mx-auto mb-3 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
<p>No trades yet</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Footer -->
<footer class="mt-8 text-center text-slate-600 text-sm">
<p>Trading Dashboard</p>
</footer>
</div>
<script>
// Chart instance
let equityChart = null;
// WebSocket connection
let ws = null;
let reconnectInterval = null;
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
ws.onopen = () => {
const statusEl = document.getElementById('connection-status');
statusEl.className = 'flex items-center gap-2 px-4 py-2 rounded-full bg-success/20 text-success text-sm font-medium';
statusEl.innerHTML = '<span class="w-2 h-2 rounded-full bg-success"></span> Connected';
if (reconnectInterval) {
clearInterval(reconnectInterval);
reconnectInterval = null;
}
};
ws.onclose = () => {
const statusEl = document.getElementById('connection-status');
statusEl.className = 'flex items-center gap-2 px-4 py-2 rounded-full bg-danger/20 text-danger text-sm font-medium';
statusEl.innerHTML = '<span class="w-2 h-2 rounded-full bg-danger pulse-dot"></span> Disconnected';
if (!reconnectInterval) {
reconnectInterval = setInterval(() => {
console.log('Reconnecting...');
connectWebSocket();
}, 3000);
}
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
handleMessage(data);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
}
function handleMessage(data) {
if (data.type === 'init') {
updateState(data.state);
updateSignal(data.signal);
} else if (data.type === 'state_update') {
updateState(data.state);
} else if (data.type === 'signal_update') {
updateSignal(data.signal);
}
document.getElementById('last-update').textContent = new Date().toLocaleTimeString();
}
function updateState(state) {
if (!state) return;
const balance = state.balance || 10000;
const initialBalance = 10000;
const totalReturn = ((balance - initialBalance) / initialBalance) * 100;
document.getElementById('balance').textContent = `$${balance.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`;
const returnEl = document.getElementById('total-return');
returnEl.textContent = `${totalReturn >= 0 ? '+' : ''}${totalReturn.toFixed(2)}%`;
returnEl.className = `text-sm font-medium ${totalReturn > 0 ? 'text-success' : totalReturn < 0 ? 'text-danger' : 'text-slate-400'}`;
const stats = state.stats || {};
document.getElementById('total-trades').textContent = stats.total_trades || 0;
document.getElementById('winning-trades').textContent = stats.winning_trades || 0;
document.getElementById('losing-trades').textContent = stats.losing_trades || 0;
document.getElementById('win-rate').textContent = `${(stats.win_rate || 0).toFixed(1)}%`;
document.getElementById('profit-factor').textContent = (stats.profit_factor || 0).toFixed(2);
const totalPnl = stats.total_pnl || 0;
const pnlEl = document.getElementById('total-pnl');
pnlEl.textContent = `${totalPnl >= 0 ? '+' : ''}$${Math.abs(totalPnl).toFixed(2)}`;
pnlEl.className = `text-2xl md:text-3xl font-bold mb-1 ${totalPnl > 0 ? 'text-success' : totalPnl < 0 ? 'text-danger' : 'text-white'}`;
document.getElementById('max-drawdown').textContent = `${(stats.max_drawdown || 0).toFixed(2)}%`;
updatePosition(state.position);
updateTrades(state.trades || []);
updateEquityChart(state.equity_curve || []);
}
function updatePosition(position) {
const container = document.getElementById('position-info');
const badge = document.getElementById('position-badge');
if (!position || position.side === 'FLAT' || !position.total_size || position.total_size <= 0) {
badge.className = 'badge badge-flat';
badge.textContent = 'FLAT';
container.innerHTML = `
<div class="flex flex-col items-center justify-center py-12 text-slate-500">
<svg class="w-16 h-16 mb-4 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 12H4M12 4v16"/>
</svg>
<p class="text-lg font-medium">No Position</p>
<p class="text-sm text-slate-600">Waiting for signal...</p>
</div>
`;
return;
}
const isLong = position.side === 'LONG';
badge.className = isLong ? 'badge badge-long' : 'badge badge-short';
badge.textContent = position.side;
const unrealizedPnl = position.unrealized_pnl || 0;
const unrealizedPct = position.unrealized_pnl_pct || 0;
const pnlColor = unrealizedPnl >= 0 ? 'text-success' : 'text-danger';
const entries = position.entries || [];
container.innerHTML = `
<div class="grid grid-cols-2 gap-4">
<div class="bg-slate-800/50 rounded-xl p-4">
<div class="text-slate-500 text-xs uppercase tracking-wider mb-1">Avg Entry</div>
<div class="text-xl font-bold text-white font-mono">$${(position.avg_entry_price || 0).toLocaleString('en-US', {minimumFractionDigits: 2})}</div>
</div>
<div class="bg-slate-800/50 rounded-xl p-4">
<div class="text-slate-500 text-xs uppercase tracking-wider mb-1">Size</div>
<div class="text-xl font-bold text-white font-mono">${(position.total_size || 0).toFixed(6)}</div>
<div class="text-xs text-slate-500">${entries.length} entries</div>
</div>
<div class="bg-slate-800/50 rounded-xl p-4">
<div class="text-slate-500 text-xs uppercase tracking-wider mb-1">Stop Loss</div>
<div class="text-lg font-bold text-danger font-mono">$${(position.stop_loss || 0).toLocaleString('en-US', {minimumFractionDigits: 2})}</div>
</div>
<div class="bg-slate-800/50 rounded-xl p-4">
<div class="text-slate-500 text-xs uppercase tracking-wider mb-1">Take Profit</div>
<div class="text-lg font-bold text-success font-mono">$${(position.take_profit || 0).toLocaleString('en-US', {minimumFractionDigits: 2})}</div>
</div>
</div>
${position.current_price ? `
<div class="mt-4 p-4 rounded-xl ${unrealizedPnl >= 0 ? 'bg-success/10 border border-success/20' : 'bg-danger/10 border border-danger/20'}">
<div class="flex justify-between items-center">
<span class="text-slate-400 text-sm">Unrealized PnL</span>
<span class="text-xl font-bold ${pnlColor} font-mono">
${unrealizedPnl >= 0 ? '+' : ''}$${Math.abs(unrealizedPnl).toFixed(2)}
<span class="text-sm">(${unrealizedPct >= 0 ? '+' : ''}${unrealizedPct.toFixed(2)}%)</span>
</span>
</div>
</div>
` : ''}
`;
}
function updateSignal(signal) {
const container = document.getElementById('signal-info');
const badge = document.getElementById('signal-badge');
if (!signal || !signal.aggregated_signal) {
badge.className = 'badge badge-hold';
badge.textContent = 'LOADING';
container.innerHTML = `
<div class="flex flex-col items-center justify-center py-12 text-slate-500">
<svg class="w-16 h-16 mb-4 text-slate-600 animate-spin" style="animation-duration: 3s" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
<p class="text-lg font-medium">Loading Signal</p>
<p class="text-sm text-slate-600">Fetching latest analysis...</p>
</div>
`;
return;
}
const agg = signal.aggregated_signal;
const llm = agg.llm_signal || {};
const price = agg.levels?.current_price || signal.market_analysis?.price || 0;
const signalType = agg.final_signal || 'HOLD';
const confidence = (agg.final_confidence || 0) * 100;
badge.className = signalType === 'LONG' ? 'badge badge-long' : signalType === 'SHORT' ? 'badge badge-short' : 'badge badge-hold';
badge.textContent = signalType;
const shortTerm = llm.opportunities?.short_term_5m_15m_1h || llm.opportunities?.intraday || {};
const hasOpportunity = shortTerm.exists;
container.innerHTML = `
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="bg-slate-800/50 rounded-xl p-4">
<div class="text-slate-500 text-xs uppercase tracking-wider mb-1">Price</div>
<div class="text-xl font-bold text-white font-mono">$${price.toLocaleString('en-US', {minimumFractionDigits: 2})}</div>
</div>
<div class="bg-slate-800/50 rounded-xl p-4">
<div class="text-slate-500 text-xs uppercase tracking-wider mb-1">Confidence</div>
<div class="text-xl font-bold text-white">${confidence.toFixed(0)}%</div>
<div class="w-full bg-slate-700 rounded-full h-1.5 mt-2">
<div class="bg-primary-500 h-1.5 rounded-full transition-all" style="width: ${confidence}%"></div>
</div>
</div>
</div>
<div class="p-4 rounded-xl ${hasOpportunity ? (shortTerm.direction === 'LONG' ? 'bg-success/10 border border-success/20' : 'bg-danger/10 border border-danger/20') : 'bg-slate-800/50'}">
<div class="text-slate-400 text-xs uppercase tracking-wider mb-2">Short-term Opportunity</div>
${hasOpportunity ? `
<div class="flex items-center gap-2 mb-2">
<span class="badge ${shortTerm.direction === 'LONG' ? 'badge-long' : 'badge-short'}">${shortTerm.direction}</span>
</div>
<div class="grid grid-cols-3 gap-2 text-sm">
<div>
<div class="text-slate-500 text-xs">Entry</div>
<div class="text-white font-mono">$${shortTerm.entry_price?.toFixed(0) || '-'}</div>
</div>
<div>
<div class="text-slate-500 text-xs">SL</div>
<div class="text-danger font-mono">$${shortTerm.stop_loss?.toFixed(0) || '-'}</div>
</div>
<div>
<div class="text-slate-500 text-xs">TP</div>
<div class="text-success font-mono">$${shortTerm.take_profit?.toFixed(0) || '-'}</div>
</div>
</div>
` : `
<div class="text-sm text-slate-500">${shortTerm.reasoning || 'No opportunity available'}</div>
`}
</div>
${llm.reasoning ? `
<div class="bg-slate-800/50 rounded-xl p-4">
<div class="text-slate-400 text-xs uppercase tracking-wider mb-2">Analysis</div>
<div class="text-sm text-slate-300 leading-relaxed line-clamp-3">${llm.reasoning}</div>
</div>
` : ''}
</div>
`;
}
function updateTrades(trades) {
const tbody = document.getElementById('trades-table');
document.getElementById('trade-count').textContent = `${trades.length} trades`;
if (!trades || trades.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="8" class="text-center py-12 text-slate-500">
<svg class="w-12 h-12 mx-auto mb-3 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
<p>No trades yet</p>
</td>
</tr>
`;
return;
}
const recentTrades = trades.slice(-20).reverse();
tbody.innerHTML = recentTrades.map(trade => {
const pnl = trade.pnl || 0;
const pnlPct = trade.pnl_pct || 0;
const isWin = pnl > 0;
return `
<tr class="table-row">
<td class="px-4 py-3 text-slate-400 font-mono text-xs">${trade.id}</td>
<td class="px-4 py-3">
<span class="badge ${trade.side === 'LONG' ? 'badge-long' : 'badge-short'}">${trade.side}</span>
</td>
<td class="px-4 py-3 text-right font-mono text-white">$${(trade.entry_price || 0).toLocaleString('en-US', {minimumFractionDigits: 2})}</td>
<td class="px-4 py-3 text-right font-mono text-white">$${(trade.exit_price || 0).toLocaleString('en-US', {minimumFractionDigits: 2})}</td>
<td class="px-4 py-3 text-right font-mono text-slate-400">${(trade.size || 0).toFixed(6)}</td>
<td class="px-4 py-3 text-right">
<span class="${isWin ? 'text-success' : 'text-danger'} font-mono font-medium">
${pnl >= 0 ? '+' : ''}$${Math.abs(pnl).toFixed(2)}
</span>
<span class="text-xs ${isWin ? 'text-success/70' : 'text-danger/70'} ml-1">
(${pnlPct >= 0 ? '+' : ''}${pnlPct.toFixed(1)}%)
</span>
</td>
<td class="px-4 py-3 text-slate-400 text-xs">${trade.exit_reason || '-'}</td>
<td class="px-4 py-3 text-slate-500 text-xs">${formatTime(trade.exit_time)}</td>
</tr>
`;
}).join('');
}
function updateEquityChart(equityData) {
const ctx = document.getElementById('equity-chart').getContext('2d');
if (!equityData || equityData.length === 0) {
if (equityChart) {
equityChart.destroy();
equityChart = null;
}
return;
}
const labels = equityData.map(d => formatTime(d.timestamp));
const equity = equityData.map(d => d.equity);
const balance = equityData.map(d => d.balance);
if (equityChart) {
equityChart.data.labels = labels;
equityChart.data.datasets[0].data = equity;
equityChart.data.datasets[1].data = balance;
equityChart.update('none');
} else {
equityChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Equity',
data: equity,
borderColor: '#0ea5e9',
backgroundColor: 'rgba(14, 165, 233, 0.1)',
fill: true,
tension: 0.4,
pointRadius: 0,
borderWidth: 2,
},
{
label: 'Balance',
data: balance,
borderColor: '#10b981',
borderDash: [5, 5],
fill: false,
tension: 0.4,
pointRadius: 0,
borderWidth: 2,
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index',
},
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: 'rgba(15, 23, 42, 0.9)',
titleColor: '#f1f5f9',
bodyColor: '#cbd5e1',
borderColor: 'rgba(100, 116, 139, 0.3)',
borderWidth: 1,
padding: 12,
cornerRadius: 8,
displayColors: true,
callbacks: {
label: function(context) {
return `${context.dataset.label}: $${context.parsed.y.toFixed(2)}`;
}
}
}
},
scales: {
x: {
display: true,
grid: {
color: 'rgba(100, 116, 139, 0.1)',
drawBorder: false,
},
ticks: {
color: '#64748b',
maxTicksLimit: 8,
font: {
size: 11,
}
}
},
y: {
display: true,
grid: {
color: 'rgba(100, 116, 139, 0.1)',
drawBorder: false,
},
ticks: {
color: '#64748b',
callback: function(value) {
return '$' + value.toLocaleString();
},
font: {
size: 11,
}
}
}
}
}
});
}
}
function formatTime(isoString) {
if (!isoString) return '-';
const date = new Date(isoString);
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
}
async function loadInitialData() {
try {
const [statusRes, tradesRes, equityRes, signalRes] = await Promise.all([
fetch('/api/status'),
fetch('/api/trades?limit=50'),
fetch('/api/equity?limit=500'),
fetch('/api/signal'),
]);
const status = await statusRes.json();
const trades = await tradesRes.json();
const equity = await equityRes.json();
const signal = await signalRes.json();
updateState({
balance: status.balance,
position: status.position,
stats: status.stats,
trades: trades.trades,
equity_curve: equity.data,
});
updateSignal({ aggregated_signal: signal });
} catch (error) {
console.error('Error loading initial data:', error);
}
}
document.addEventListener('DOMContentLoaded', () => {
loadInitialData();
connectWebSocket();
});
</script>
</body>
</html>