update
This commit is contained in:
parent
1ec9030d1e
commit
6813a4abe0
@ -28,8 +28,9 @@ COPY config ./config
|
||||
COPY analysis ./analysis
|
||||
COPY signals ./signals
|
||||
COPY notifiers ./notifiers
|
||||
COPY trading ./trading
|
||||
COPY web ./web
|
||||
COPY scheduler.py .
|
||||
COPY .env.example .env
|
||||
|
||||
# Create output directory
|
||||
RUN mkdir -p /app/output
|
||||
|
||||
@ -31,6 +31,61 @@ services:
|
||||
max-size: "10m"
|
||||
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:
|
||||
tradus-network:
|
||||
driver: bridge
|
||||
|
||||
@ -1,32 +1,32 @@
|
||||
{
|
||||
"aggregated_signal": {
|
||||
"timestamp": "2025-12-04T01:26:44.257404",
|
||||
"timestamp": "2025-12-09T11:59:50.752872",
|
||||
"final_signal": "HOLD",
|
||||
"final_confidence": 0.28,
|
||||
"final_confidence": 0.55,
|
||||
"consensus": "CONSENSUS_HOLD",
|
||||
"agreement_score": 0.28,
|
||||
"agreement_score": 0.55,
|
||||
"quantitative_signal": {
|
||||
"signal_type": "HOLD",
|
||||
"signal": "HOLD",
|
||||
"confidence": 0.0,
|
||||
"composite_score": 33.2,
|
||||
"confidence": 0.5,
|
||||
"composite_score": -31.8,
|
||||
"scores": {
|
||||
"trend": -23.1,
|
||||
"trend": -66.0,
|
||||
"momentum": 65,
|
||||
"orderflow": 100,
|
||||
"orderflow": -100,
|
||||
"breakout": 0
|
||||
}
|
||||
},
|
||||
"llm_signal": {
|
||||
"signal_type": "HOLD",
|
||||
"signal": "HOLD",
|
||||
"confidence": 0.55,
|
||||
"reasoning": "多周期综合分析显示市场处于关键抉择期。短期(5m-1h)陷入无序震荡,缺乏交易价值。中期(4h-1d)在$90,156-$93,932构建震荡平台,MACD有修复迹象,倾向于在支撑位附近寻找低吸机会。长期(1d-1w)仍处于自11月高点以来的大级别盘整中,周线上涨趋势未改但动能减弱。当前核心矛盾是中期震荡与长期趋势的共振点尚未出现,需等待更明确的突破信号。风险主要来自震荡区间内的假突破和低成交量下的价格异动。",
|
||||
"confidence": 0.6,
|
||||
"reasoning": "多周期综合分析显示市场处于震荡格局。短期(5m/15m/1h)有超跌反弹的技术条件,MACD金叉和RSI位置提供日内做多机会。中期(4h/1d)方向不明,指标中性,需等待区间突破。长期(1d/1w)周线趋势虽强,但日线处于调整中,需更佳的风险回报比。当前价格90382.5接近短期支撑,优先关注日内机会。",
|
||||
"key_factors": [
|
||||
"4小时及日线级别宽幅震荡区间的突破方向",
|
||||
"成交量能否在关键价位有效放大",
|
||||
"日线MACD能否形成金叉确认反弹",
|
||||
"周线RSI(40.7)能否回升至50中性区域以上"
|
||||
"短期技术指标出现反弹信号",
|
||||
"价格处于关键支撑区域",
|
||||
"中期趋势方向不明朗",
|
||||
"成交量正常无明显放量"
|
||||
],
|
||||
"opportunities": {
|
||||
"short_term_5m_15m_1h": {
|
||||
@ -35,15 +35,15 @@
|
||||
"entry_price": 0,
|
||||
"stop_loss": 0,
|
||||
"take_profit": 0,
|
||||
"reasoning": "当前价格$92,488.90位于短期震荡区间中部。5分钟和15分钟周期趋势为弱势下跌,但1小时周期为强势上涨,多周期信号矛盾。RSI和MACD指标均呈中性或弱信号,成交量缩量,缺乏明确的短期方向性突破动能。预期盈利空间不足1%,建议观望。"
|
||||
"reasoning": "盈利空间不足1% (仅0.91%),建议观望"
|
||||
},
|
||||
"medium_term_4h_1d": {
|
||||
"exists": true,
|
||||
"direction": "LONG",
|
||||
"entry_price": 91500.0,
|
||||
"stop_loss": 90100.0,
|
||||
"take_profit": 94500.0,
|
||||
"reasoning": "4小时和日线图显示价格在$90,156-$93,932区间内宽幅震荡。当前价格接近区间中下部。日线MACD死叉收窄,有潜在底背离迹象。若价格能回踩并站稳$91,500(近期多次反弹的支撑位)上方,可视为中期做多机会,目标看向区间上沿$94,500附近,盈利空间约3.2%。"
|
||||
"exists": false,
|
||||
"direction": null,
|
||||
"entry_price": 0,
|
||||
"stop_loss": 0,
|
||||
"take_profit": 0,
|
||||
"reasoning": "4小时和日线周期趋势不明,RSI中性,MACD死叉收窄但未转强。价格在89550-92262区间震荡,缺乏明确的中期方向信号和足够的盈利空间。"
|
||||
},
|
||||
"long_term_1d_1w": {
|
||||
"exists": false,
|
||||
@ -51,12 +51,12 @@
|
||||
"entry_price": 0,
|
||||
"stop_loss": 0,
|
||||
"take_profit": 0,
|
||||
"reasoning": "周线趋势虽为上涨,但RSI(40.7)偏弱,且价格仍处于11月以来的宽幅震荡区间($83,786 - $101,450)内。日线级别趋势不明,缺乏明确的长期趋势启动信号。当前价格位于区间中部,直接追涨或杀跌的风险回报比不佳,建议等待更明确的突破信号。"
|
||||
"reasoning": "周线显示上涨趋势,但日线处于高位震荡。当前价格接近震荡区间中轨,长期方向需等待突破。RSI弱势,MACD死叉,短期不具备明确的长期建仓机会。"
|
||||
},
|
||||
"ambush": {
|
||||
"exists": true,
|
||||
"price_level": 90100.0,
|
||||
"reasoning": "基于4小时和日线K线数据,$90,100-$90,156区域是近期多次测试的关键支撑区间(12月1日、12月3日低点)。若价格因市场情绪再次回落至此区域并出现企稳迹象(如长下影线、成交量放大),是风险可控的埋伏做多点位,止损可设在$88,900下方。"
|
||||
"price_level": 89550.0,
|
||||
"reasoning": "该位置是近期多次测试的强支撑(4小时和日线级别),也是12月8日低点区域。若价格回调至此并出现企稳信号,是较好的中期埋伏做多点位。"
|
||||
},
|
||||
"intraday": {
|
||||
"exists": false,
|
||||
@ -64,104 +64,85 @@
|
||||
"entry_price": 0,
|
||||
"stop_loss": 0,
|
||||
"take_profit": 0,
|
||||
"reasoning": "当前价格$92,488.90位于短期震荡区间中部。5分钟和15分钟周期趋势为弱势下跌,但1小时周期为强势上涨,多周期信号矛盾。RSI和MACD指标均呈中性或弱信号,成交量缩量,缺乏明确的短期方向性突破动能。预期盈利空间不足1%,建议观望。"
|
||||
"reasoning": "盈利空间不足1% (仅0.91%),建议观望"
|
||||
},
|
||||
"swing": {
|
||||
"exists": true,
|
||||
"direction": "LONG",
|
||||
"entry_price": 91500.0,
|
||||
"stop_loss": 90100.0,
|
||||
"take_profit": 94500.0,
|
||||
"reasoning": "4小时和日线图显示价格在$90,156-$93,932区间内宽幅震荡。当前价格接近区间中下部。日线MACD死叉收窄,有潜在底背离迹象。若价格能回踩并站稳$91,500(近期多次反弹的支撑位)上方,可视为中期做多机会,目标看向区间上沿$94,500附近,盈利空间约3.2%。"
|
||||
"exists": false,
|
||||
"direction": null,
|
||||
"entry_price": 0,
|
||||
"stop_loss": 0,
|
||||
"take_profit": 0,
|
||||
"reasoning": "4小时和日线周期趋势不明,RSI中性,MACD死叉收窄但未转强。价格在89550-92262区间震荡,缺乏明确的中期方向信号和足够的盈利空间。"
|
||||
}
|
||||
},
|
||||
"recommendations_by_timeframe": {
|
||||
"short_term": "短期(5m/15m/1h)建议观望。价格处于无趋势震荡中,技术指标矛盾,日内交易缺乏明确的、盈利空间≥1%的机会。避免在$92,000-$93,000区间内频繁操作。",
|
||||
"medium_term": "中期(4h/1d)可关注回调做多机会。等待价格回落至$91,500附近企稳后分批布局,止损设于$90,100下方,目标看向$94,500。若直接向上突破$93,000并站稳,可轻仓追多,目标$94,500。",
|
||||
"long_term": "长期(1d/1w)建议继续持有现有仓位或保持观望。需等待价格有效突破$94,000(确认短期强势)或跌破$89,000(确认转弱)来明确大方向。在方向明确前,不宜进行大规模长期仓位调整。"
|
||||
"short_term": "可在90380附近轻仓试多,止损90000,目标91200。日内交易,快进快出。",
|
||||
"medium_term": "观望为主,等待价格突破92000或跌破89550后选择方向。可关注89550支撑位的埋伏机会。",
|
||||
"long_term": "周线趋势向上,但日线调整未结束。长期投资者可等待价格回调至87688-88000强支撑区域再考虑分批建仓。"
|
||||
},
|
||||
"trade_type": "MULTI_TIMEFRAME",
|
||||
"risk_level": "MEDIUM"
|
||||
},
|
||||
"levels": {
|
||||
"current_price": 92485.5,
|
||||
"entry": 91991.05,
|
||||
"stop_loss": 91291.05,
|
||||
"take_profit_1": 93491.05,
|
||||
"take_profit_2": 93491.05,
|
||||
"take_profit_3": 93491.05,
|
||||
"entry_range": {
|
||||
"quant": 92482.1,
|
||||
"llm": 91500.0,
|
||||
"diff_pct": 1.07
|
||||
"current_price": 90382.5,
|
||||
"entry": 90382.5,
|
||||
"stop_loss": 90382.5,
|
||||
"take_profit_1": 90382.5,
|
||||
"take_profit_2": 90382.5,
|
||||
"take_profit_3": 90382.5
|
||||
},
|
||||
"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分析均建议观望,等待更好的机会",
|
||||
"warnings": [
|
||||
"⚠️ 量化和AI信号严重分歧,建议观望",
|
||||
"⚠️ 量化信号置信度较低",
|
||||
"⚠️ stop_loss建议差异较大: 量化$92482.10 vs AI$90100.00 (2.6%)"
|
||||
]
|
||||
"warnings": []
|
||||
},
|
||||
"market_analysis": {
|
||||
"price": 92482.1,
|
||||
"price": 90382.5,
|
||||
"trend": {
|
||||
"direction": "下跌",
|
||||
"strength": "weak",
|
||||
"strength": "moderate",
|
||||
"phase": "下跌后反弹",
|
||||
"adx": 9.8,
|
||||
"adx": 20.4,
|
||||
"ema_alignment": "bearish"
|
||||
},
|
||||
"momentum": {
|
||||
"rsi": 51.8,
|
||||
"rsi": 59.2,
|
||||
"rsi_status": "中性偏强",
|
||||
"rsi_trend": "上升中",
|
||||
"macd_signal": "金叉扩大",
|
||||
"macd_hist": 24.4447
|
||||
"macd_hist": 49.9748
|
||||
}
|
||||
},
|
||||
"quantitative_signal": {
|
||||
"timestamp": "2025-12-04T01:26:02.011873",
|
||||
"timestamp": "2025-12-09T11:59:16.429268",
|
||||
"signal_type": "HOLD",
|
||||
"signal_strength": 0.33,
|
||||
"composite_score": 33.2,
|
||||
"confidence": 0.0,
|
||||
"consensus_score": 0.55,
|
||||
"signal_strength": 0.32,
|
||||
"composite_score": -31.8,
|
||||
"confidence": 0.5,
|
||||
"consensus_score": 0.65,
|
||||
"profit_pct": 0,
|
||||
"scores": {
|
||||
"trend": -23.1,
|
||||
"trend": -66.0,
|
||||
"momentum": 65,
|
||||
"orderflow": 100,
|
||||
"orderflow": -100,
|
||||
"breakout": 0
|
||||
},
|
||||
"levels": {
|
||||
"current_price": 92482.1,
|
||||
"entry": 92482.1,
|
||||
"stop_loss": 92482.1,
|
||||
"take_profit_1": 92482.1,
|
||||
"take_profit_2": 92482.1,
|
||||
"take_profit_3": 92482.1
|
||||
"current_price": 90382.5,
|
||||
"entry": 90382.5,
|
||||
"stop_loss": 90382.5,
|
||||
"take_profit_1": 90382.5,
|
||||
"take_profit_2": 90382.5,
|
||||
"take_profit_3": 90382.5
|
||||
},
|
||||
"risk_reward_ratio": 0,
|
||||
"reasoning": "趋势下跌 (weak); RSI=52; MACD 金叉扩大; 订单流: 强买方主导"
|
||||
"reasoning": "趋势下跌 (moderate); RSI=59; MACD 金叉扩大; 订单流: 强卖方主导"
|
||||
},
|
||||
"llm_signal": {
|
||||
"timestamp": "2025-12-04T01:26:44.257201",
|
||||
"timestamp": "2025-12-09T11:59:50.751310",
|
||||
"signal_type": "HOLD",
|
||||
"confidence": 0.55,
|
||||
"confidence": 0.6,
|
||||
"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": {
|
||||
"short_term_5m_15m_1h": {
|
||||
"exists": false,
|
||||
@ -169,15 +150,15 @@
|
||||
"entry_price": 0,
|
||||
"stop_loss": 0,
|
||||
"take_profit": 0,
|
||||
"reasoning": "当前价格$92,488.90位于短期震荡区间中部。5分钟和15分钟周期趋势为弱势下跌,但1小时周期为强势上涨,多周期信号矛盾。RSI和MACD指标均呈中性或弱信号,成交量缩量,缺乏明确的短期方向性突破动能。预期盈利空间不足1%,建议观望。"
|
||||
"reasoning": "盈利空间不足1% (仅0.91%),建议观望"
|
||||
},
|
||||
"medium_term_4h_1d": {
|
||||
"exists": true,
|
||||
"direction": "LONG",
|
||||
"entry_price": 91500.0,
|
||||
"stop_loss": 90100.0,
|
||||
"take_profit": 94500.0,
|
||||
"reasoning": "4小时和日线图显示价格在$90,156-$93,932区间内宽幅震荡。当前价格接近区间中下部。日线MACD死叉收窄,有潜在底背离迹象。若价格能回踩并站稳$91,500(近期多次反弹的支撑位)上方,可视为中期做多机会,目标看向区间上沿$94,500附近,盈利空间约3.2%。"
|
||||
"exists": false,
|
||||
"direction": null,
|
||||
"entry_price": 0,
|
||||
"stop_loss": 0,
|
||||
"take_profit": 0,
|
||||
"reasoning": "4小时和日线周期趋势不明,RSI中性,MACD死叉收窄但未转强。价格在89550-92262区间震荡,缺乏明确的中期方向信号和足够的盈利空间。"
|
||||
},
|
||||
"long_term_1d_1w": {
|
||||
"exists": false,
|
||||
@ -185,12 +166,12 @@
|
||||
"entry_price": 0,
|
||||
"stop_loss": 0,
|
||||
"take_profit": 0,
|
||||
"reasoning": "周线趋势虽为上涨,但RSI(40.7)偏弱,且价格仍处于11月以来的宽幅震荡区间($83,786 - $101,450)内。日线级别趋势不明,缺乏明确的长期趋势启动信号。当前价格位于区间中部,直接追涨或杀跌的风险回报比不佳,建议等待更明确的突破信号。"
|
||||
"reasoning": "周线显示上涨趋势,但日线处于高位震荡。当前价格接近震荡区间中轨,长期方向需等待突破。RSI弱势,MACD死叉,短期不具备明确的长期建仓机会。"
|
||||
},
|
||||
"ambush": {
|
||||
"exists": true,
|
||||
"price_level": 90100.0,
|
||||
"reasoning": "基于4小时和日线K线数据,$90,100-$90,156区域是近期多次测试的关键支撑区间(12月1日、12月3日低点)。若价格因市场情绪再次回落至此区域并出现企稳迹象(如长下影线、成交量放大),是风险可控的埋伏做多点位,止损可设在$88,900下方。"
|
||||
"price_level": 89550.0,
|
||||
"reasoning": "该位置是近期多次测试的强支撑(4小时和日线级别),也是12月8日低点区域。若价格回调至此并出现企稳信号,是较好的中期埋伏做多点位。"
|
||||
},
|
||||
"intraday": {
|
||||
"exists": false,
|
||||
@ -198,38 +179,38 @@
|
||||
"entry_price": 0,
|
||||
"stop_loss": 0,
|
||||
"take_profit": 0,
|
||||
"reasoning": "当前价格$92,488.90位于短期震荡区间中部。5分钟和15分钟周期趋势为弱势下跌,但1小时周期为强势上涨,多周期信号矛盾。RSI和MACD指标均呈中性或弱信号,成交量缩量,缺乏明确的短期方向性突破动能。预期盈利空间不足1%,建议观望。"
|
||||
"reasoning": "盈利空间不足1% (仅0.91%),建议观望"
|
||||
},
|
||||
"swing": {
|
||||
"exists": true,
|
||||
"direction": "LONG",
|
||||
"entry_price": 91500.0,
|
||||
"stop_loss": 90100.0,
|
||||
"take_profit": 94500.0,
|
||||
"reasoning": "4小时和日线图显示价格在$90,156-$93,932区间内宽幅震荡。当前价格接近区间中下部。日线MACD死叉收窄,有潜在底背离迹象。若价格能回踩并站稳$91,500(近期多次反弹的支撑位)上方,可视为中期做多机会,目标看向区间上沿$94,500附近,盈利空间约3.2%。"
|
||||
"exists": false,
|
||||
"direction": null,
|
||||
"entry_price": 0,
|
||||
"stop_loss": 0,
|
||||
"take_profit": 0,
|
||||
"reasoning": "4小时和日线周期趋势不明,RSI中性,MACD死叉收窄但未转强。价格在89550-92262区间震荡,缺乏明确的中期方向信号和足够的盈利空间。"
|
||||
}
|
||||
},
|
||||
"recommendations_by_timeframe": {
|
||||
"short_term": "短期(5m/15m/1h)建议观望。价格处于无趋势震荡中,技术指标矛盾,日内交易缺乏明确的、盈利空间≥1%的机会。避免在$92,000-$93,000区间内频繁操作。",
|
||||
"medium_term": "中期(4h/1d)可关注回调做多机会。等待价格回落至$91,500附近企稳后分批布局,止损设于$90,100下方,目标看向$94,500。若直接向上突破$93,000并站稳,可轻仓追多,目标$94,500。",
|
||||
"long_term": "长期(1d/1w)建议继续持有现有仓位或保持观望。需等待价格有效突破$94,000(确认短期强势)或跌破$89,000(确认转弱)来明确大方向。在方向明确前,不宜进行大规模长期仓位调整。"
|
||||
"short_term": "可在90380附近轻仓试多,止损90000,目标91200。日内交易,快进快出。",
|
||||
"medium_term": "观望为主,等待价格突破92000或跌破89550后选择方向。可关注89550支撑位的埋伏机会。",
|
||||
"long_term": "周线趋势向上,但日线调整未结束。长期投资者可等待价格回调至87688-88000强支撑区域再考虑分批建仓。"
|
||||
},
|
||||
"levels": {
|
||||
"current_price": 92488.9,
|
||||
"entry": 91500.0,
|
||||
"stop_loss": 90100.0,
|
||||
"take_profit_1": 94500.0,
|
||||
"take_profit_2": 94500.0,
|
||||
"take_profit_3": 94500.0
|
||||
"current_price": 90382.5,
|
||||
"entry": 90382.5,
|
||||
"stop_loss": 0,
|
||||
"take_profit_1": 0,
|
||||
"take_profit_2": 0,
|
||||
"take_profit_3": 0
|
||||
},
|
||||
"risk_level": "MEDIUM",
|
||||
"key_factors": [
|
||||
"4小时及日线级别宽幅震荡区间的突破方向",
|
||||
"成交量能否在关键价位有效放大",
|
||||
"日线MACD能否形成金叉确认反弹",
|
||||
"周线RSI(40.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\": \"周线趋势虽为上涨,但RSI(40.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能否形成金叉确认反弹\", \"周线RSI(40.7)能否回升至50中性区域以上\"]\n}\n```",
|
||||
"risk_reward_ratio": 2.14
|
||||
"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": 0
|
||||
}
|
||||
}
|
||||
18
output/paper_trading_state.json
Normal file
18
output/paper_trading_state.json
Normal 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"
|
||||
}
|
||||
@ -20,4 +20,11 @@ openai==1.58.1
|
||||
# HTTP client for notifications
|
||||
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
|
||||
|
||||
24
run_dashboard.sh
Executable file
24
run_dashboard.sh
Executable 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
21
run_paper_trading.sh
Executable 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
3
trading/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .paper_trading import PaperTrader, Position, Trade
|
||||
|
||||
__all__ = ['PaperTrader', 'Position', 'Trade']
|
||||
803
trading/paper_trading.py
Normal file
803
trading/paper_trading.py
Normal 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
354
trading/realtime_trader.py
Normal 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
1
web/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Web module for paper trading dashboard
|
||||
242
web/api.py
Normal file
242
web/api.py
Normal 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
781
web/static/index.html
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user