From 6813a4abe04e807b28f66f628e6f50f5b11553b9 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Tue, 9 Dec 2025 12:27:47 +0800 Subject: [PATCH] update --- Dockerfile | 3 +- docker-compose.yml | 55 +++ output/latest_signal.json | 209 ++++----- output/paper_trading_state.json | 18 + requirements.txt | 7 + run_dashboard.sh | 24 + run_paper_trading.sh | 21 + trading/__init__.py | 3 + trading/paper_trading.py | 803 ++++++++++++++++++++++++++++++++ trading/realtime_trader.py | 354 ++++++++++++++ web/__init__.py | 1 + web/api.py | 242 ++++++++++ web/static/index.html | 781 +++++++++++++++++++++++++++++++ 13 files changed, 2406 insertions(+), 115 deletions(-) create mode 100644 output/paper_trading_state.json create mode 100755 run_dashboard.sh create mode 100755 run_paper_trading.sh create mode 100644 trading/__init__.py create mode 100644 trading/paper_trading.py create mode 100644 trading/realtime_trader.py create mode 100644 web/__init__.py create mode 100644 web/api.py create mode 100644 web/static/index.html diff --git a/Dockerfile b/Dockerfile index 924deda..a8f6d78 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index b0e988b..414132e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/output/latest_signal.json b/output/latest_signal.json index ebd5de0..1601fa1 100755 --- a/output/latest_signal.json +++ b/output/latest_signal.json @@ -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 - }, - "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 - } + "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": 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 } } \ No newline at end of file diff --git a/output/paper_trading_state.json b/output/paper_trading_state.json new file mode 100644 index 0000000..ce27d86 --- /dev/null +++ b/output/paper_trading_state.json @@ -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" +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 8422077..73c2d48 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/run_dashboard.sh b/run_dashboard.sh new file mode 100755 index 0000000..d12abf9 --- /dev/null +++ b/run_dashboard.sh @@ -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 diff --git a/run_paper_trading.sh b/run_paper_trading.sh new file mode 100755 index 0000000..3f44e02 --- /dev/null +++ b/run_paper_trading.sh @@ -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 diff --git a/trading/__init__.py b/trading/__init__.py new file mode 100644 index 0000000..e088252 --- /dev/null +++ b/trading/__init__.py @@ -0,0 +1,3 @@ +from .paper_trading import PaperTrader, Position, Trade + +__all__ = ['PaperTrader', 'Position', 'Trade'] diff --git a/trading/paper_trading.py b/trading/paper_trading.py new file mode 100644 index 0000000..5759a49 --- /dev/null +++ b/trading/paper_trading.py @@ -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") diff --git a/trading/realtime_trader.py b/trading/realtime_trader.py new file mode 100644 index 0000000..d39930d --- /dev/null +++ b/trading/realtime_trader.py @@ -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()) diff --git a/web/__init__.py b/web/__init__.py new file mode 100644 index 0000000..bf24fd7 --- /dev/null +++ b/web/__init__.py @@ -0,0 +1 @@ +# Web module for paper trading dashboard diff --git a/web/api.py b/web/api.py new file mode 100644 index 0000000..801c9b8 --- /dev/null +++ b/web/api.py @@ -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("
Static files not found
") + + +@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) diff --git a/web/static/index.html b/web/static/index.html new file mode 100644 index 0000000..0617229 --- /dev/null +++ b/web/static/index.html @@ -0,0 +1,781 @@ + + + + + +BTC/USDT Perpetual
+No Position
+Waiting for signal...
+Loading Signal
+Fetching latest analysis...
+| ID | +Side | +Entry | +Exit | +Size | +PnL | +Reason | +Time | +
|---|---|---|---|---|---|---|---|
|
+
+ No trades yet + |
+ |||||||