From f4ece051e78c18450035ceb88bedd864d3b1ee84 Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Sat, 6 Dec 2025 01:04:26 +0800 Subject: [PATCH] =?UTF-8?q?Refactor/trading=20actions=20(#1169)=20*=20refa?= =?UTF-8?q?ctor:=20=E7=AE=80=E5=8C=96=E4=BA=A4=E6=98=93=E5=8A=A8=E4=BD=9C?= =?UTF-8?q?=EF=BC=8C=E7=A7=BB=E9=99=A4=20update=5Fstop=5Floss/update=5Ftak?= =?UTF-8?q?e=5Fprofit/partial=5Fclose=20-=20=E7=A7=BB=E9=99=A4=20Decision?= =?UTF-8?q?=20=E7=BB=93=E6=9E=84=E4=BD=93=E4=B8=AD=E7=9A=84=20NewStopLoss,?= =?UTF-8?q?=20NewTakeProfit,=20ClosePercentage=20=E5=AD=97=E6=AE=B5=20-=20?= =?UTF-8?q?=E5=88=A0=E9=99=A4=20executeUpdateStopLossWithRecord,=20execute?= =?UTF-8?q?UpdateTakeProfitWithRecord,=20executePartialCloseWithRecord=20?= =?UTF-8?q?=E5=87=BD=E6=95=B0=20-=20=E7=AE=80=E5=8C=96=20logger=20?= =?UTF-8?q?=E4=B8=AD=E7=9A=84=20partial=5Fclose=20=E8=81=9A=E5=90=88?= =?UTF-8?q?=E9=80=BB=E8=BE=91=20-=20=E6=9B=B4=E6=96=B0=20AI=20prompt=20?= =?UTF-8?q?=E5=92=8C=E9=AA=8C=E8=AF=81=E9=80=BB=E8=BE=91=EF=BC=8C=E5=8F=AA?= =?UTF-8?q?=E4=BF=9D=E7=95=99=206=20=E4=B8=AA=E6=A0=B8=E5=BF=83=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=20-=20=E6=B8=85=E7=90=86=E7=9B=B8=E5=85=B3=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E4=BB=A3=E7=A0=81=20=E4=BF=9D=E7=95=99=E7=9A=84?= =?UTF-8?q?=E4=BA=A4=E6=98=93=E5=8A=A8=E4=BD=9C:=20open=5Flong,=20open=5Fs?= =?UTF-8?q?hort,=20close=5Flong,=20close=5Fshort,=20hold,=20wait=20*=20ref?= =?UTF-8?q?actor:=20=E7=A7=BB=E9=99=A4=20AI=E5=AD=A6=E4=B9=A0=E4=B8=8E?= =?UTF-8?q?=E5=8F=8D=E6=80=9D=20=E6=A8=A1=E5=9D=97=20-=20=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E5=89=8D=E7=AB=AF=20AILearning.tsx=20=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E5=92=8C=E7=9B=B8=E5=85=B3=E5=BC=95=E7=94=A8=20-=20?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=90=8E=E7=AB=AF=20/performance=20API=20?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=20-=20=E5=88=A0=E9=99=A4=20logger=20?= =?UTF-8?q?=E4=B8=AD=20AnalyzePerformance=E3=80=81calculateSharpeRatio=20?= =?UTF-8?q?=E7=AD=89=E5=87=BD=E6=95=B0=20-=20=E5=88=A0=E9=99=A4=20Performa?= =?UTF-8?q?nceAnalysis=E3=80=81TradeOutcome=E3=80=81SymbolPerformance=20?= =?UTF-8?q?=E7=AD=89=E7=BB=93=E6=9E=84=E4=BD=93=20-=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=20Context=20=E4=B8=AD=E7=9A=84=20Performance=20=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=20-=20=E7=A7=BB=E9=99=A4=20AI=20prompt=20=E4=B8=AD?= =?UTF-8?q?=E5=A4=8F=E6=99=AE=E6=AF=94=E7=8E=87=E8=87=AA=E6=88=91=E8=BF=9B?= =?UTF-8?q?=E5=8C=96=E7=9B=B8=E5=85=B3=E5=86=85=E5=AE=B9=20-=20=E6=B8=85?= =?UTF-8?q?=E7=90=86=20i18n=20=E7=BF=BB=E8=AF=91=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E4=B8=AD=E7=9A=84=E7=9B=B8=E5=85=B3=E6=9D=A1=E7=9B=AE=20?= =?UTF-8?q?=E8=AF=A5=E6=A8=A1=E5=9D=97=E5=9F=BA=E4=BA=8E=E7=A3=81=E7=9B=98?= =?UTF-8?q?=E5=AD=98=E5=82=A8=E8=AE=A1=E7=AE=97=EF=BC=8C=E7=BB=8F=E5=B8=B8?= =?UTF-8?q?=E5=87=BA=E9=94=99=EF=BC=8C=E5=81=9A=E5=87=8F=E6=B3=95=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=20*=20refactor:=20=E5=B0=86=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=BA=93=E6=93=8D=E4=BD=9C=E7=BB=9F=E4=B8=80=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E5=88=B0=20store=20=E5=8C=85=20-=20=E6=96=B0=E5=A2=9E=20store/?= =?UTF-8?q?=20=E5=8C=85=EF=BC=8C=E7=BB=9F=E4=B8=80=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E6=89=80=E6=9C=89=E6=95=B0=E6=8D=AE=E5=BA=93=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=20=20=20-=20store.go:=20=E4=B8=BB=20Store=20=E7=BB=93=E6=9E=84?= =?UTF-8?q?=EF=BC=8C=E6=87=92=E5=8A=A0=E8=BD=BD=E5=90=84=E5=AD=90=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=20=20=20-=20user.go,=20ai=5Fmodel.go,=20exchange.go,?= =?UTF-8?q?=20trader.go=20=E7=AD=89=E5=AD=90=E6=A8=A1=E5=9D=97=20=20=20-?= =?UTF-8?q?=20=E6=94=AF=E6=8C=81=E5=8A=A0=E5=AF=86/=E8=A7=A3=E5=AF=86?= =?UTF-8?q?=E5=87=BD=E6=95=B0=E6=B3=A8=E5=85=A5=20(SetCryptoFuncs)=20-=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20main.go=20=E4=BD=BF=E7=94=A8=20store.New()?= =?UTF-8?q?=20=E6=9B=BF=E4=BB=A3=20config.NewDatabase()=20-=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20api/server.go=20=E4=BD=BF=E7=94=A8=20*store.Store?= =?UTF-8?q?=20=E6=9B=BF=E4=BB=A3=20*config.Database=20-=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20manager/trader=5Fmanager.go:=20=20=20-=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20LoadTradersFromStore,=20LoadUserTradersFromStore=20?= =?UTF-8?q?=E6=96=B9=E6=B3=95=20=20=20-=20=E5=88=A0=E9=99=A4=E6=97=A7?= =?UTF-8?q?=E7=89=88=20LoadUserTraders,=20LoadTraderByID,=20loadSingleTrad?= =?UTF-8?q?er=20=E7=AD=89=E6=96=B9=E6=B3=95=20=20=20-=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=20nofx/config=20=E4=BE=9D=E8=B5=96=20-=20=E5=88=A0=E9=99=A4=20?= =?UTF-8?q?config/database.go=20=E5=92=8C=20config/database=5Ftest.go=20-?= =?UTF-8?q?=20=E6=9B=B4=E6=96=B0=20api/server=5Ftest.go=20=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=20store.Trader=20=E7=B1=BB=E5=9E=8B=20-=20=E6=B8=85?= =?UTF-8?q?=E7=90=86=20logger/=20=E5=8C=85=E4=B8=AD=E6=9C=AA=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E7=9A=84=20telegram=20=E7=9B=B8=E5=85=B3=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=20*=20refactor:=20unify=20encryption=20key=20manageme?= =?UTF-8?q?nt=20via=20.env=20-=20Remove=20redundant=20EncryptionManager=20?= =?UTF-8?q?and=20SecureStorage=20-=20Simplify=20CryptoService=20to=20load?= =?UTF-8?q?=20keys=20from=20environment=20variables=20only=20=20=20-=20RSA?= =?UTF-8?q?=5FPRIVATE=5FKEY:=20RSA=20private=20key=20for=20client-server?= =?UTF-8?q?=20encryption=20=20=20-=20DATA=5FENCRYPTION=5FKEY:=20AES-256=20?= =?UTF-8?q?key=20for=20database=20encryption=20=20=20-=20JWT=5FSECRET:=20J?= =?UTF-8?q?WT=20signing=20key=20for=20authentication=20-=20Update=20start.?= =?UTF-8?q?sh=20to=20auto-generate=20missing=20keys=20on=20first=20run=20-?= =?UTF-8?q?=20Remove=20secrets/=20directory=20and=20file-based=20key=20sto?= =?UTF-8?q?rage=20-=20Delete=20obsolete=20encryption=20setup=20scripts=20-?= =?UTF-8?q?=20Update=20.env.example=20with=20all=20required=20keys=20*=20r?= =?UTF-8?q?efactor:=20unify=20logger=20usage=20across=20mcp=20package=20-?= =?UTF-8?q?=20Add=20MCPLogger=20adapter=20in=20logger=20package=20to=20imp?= =?UTF-8?q?lement=20mcp.Logger=20interface=20-=20Update=20mcp/config.go=20?= =?UTF-8?q?to=20use=20global=20logger=20by=20default=20-=20Remove=20redund?= =?UTF-8?q?ant=20defaultLogger=20from=20mcp/logger.go=20-=20Keep=20noopLog?= =?UTF-8?q?ger=20for=20testing=20purposes=20*=20chore:=20remove=20leftover?= =?UTF-8?q?=20test=20RSA=20key=20file=20*=20chore:=20remove=20unused=20boo?= =?UTF-8?q?tstrap=20package=20*=20refactor:=20unify=20logging=20to=20use?= =?UTF-8?q?=20logger=20package=20instead=20of=20fmt/log=20-=20Replace=20al?= =?UTF-8?q?l=20fmt.Print/log.Print=20calls=20with=20logger=20package=20-?= =?UTF-8?q?=20Add=20auto-initialization=20in=20logger=20package=20init()?= =?UTF-8?q?=20for=20test=20compatibility=20-=20Update=20main.go=20to=20ini?= =?UTF-8?q?tialize=20logger=20at=20startup=20-=20Migrate=20all=20packages:?= =?UTF-8?q?=20api,=20backtest,=20config,=20decision,=20manager,=20market,?= =?UTF-8?q?=20store,=20trader=20*=20refactor:=20rename=20database=20file?= =?UTF-8?q?=20from=20config.db=20to=20data.db=20-=20Update=20main.go,=20st?= =?UTF-8?q?art.sh,=20docker-compose.yml=20-=20Update=20migration=20script?= =?UTF-8?q?=20and=20documentation=20-=20Update=20.gitignore=20and=20transl?= =?UTF-8?q?ations=20*=20fix:=20add=20RSA=5FPRIVATE=5FKEY=20to=20docker-com?= =?UTF-8?q?pose=20environment=20*=20fix:=20add=20registration=5Fenabled=20?= =?UTF-8?q?to=20/api/config=20response=20*=20fix:=20Fix=20navigation=20bet?= =?UTF-8?q?ween=20login=20and=20register=20pages=20Use=20window.location.h?= =?UTF-8?q?ref=20instead=20of=20react-router's=20navigate()=20to=20fix=20t?= =?UTF-8?q?he=20issue=20where=20URL=20changes=20but=20the=20page=20doesn't?= =?UTF-8?q?=20reload=20due=20to=20App.tsx=20using=20custom=20route=20state?= =?UTF-8?q?=20management.=20*=20fix:=20Switch=20SQLite=20from=20WAL=20to?= =?UTF-8?q?=20DELETE=20mode=20for=20Docker=20compatibility=20WAL=20mode=20?= =?UTF-8?q?causes=20data=20sync=20issues=20with=20Docker=20bind=20mounts?= =?UTF-8?q?=20on=20macOS=20due=20to=20incompatible=20file=20locking=20mech?= =?UTF-8?q?anisms=20between=20the=20container=20and=20host.=20DELETE=20mod?= =?UTF-8?q?e=20(traditional=20journaling)=20ensures=20data=20is=20written?= =?UTF-8?q?=20directly=20to=20the=20main=20database=20file.=20*=20refactor?= =?UTF-8?q?:=20Remove=20default=20user=20from=20database=20initialization?= =?UTF-8?q?=20The=20default=20user=20was=20a=20legacy=20placeholder=20that?= =?UTF-8?q?=20is=20no=20longer=20needed=20now=20that=20proper=20user=20reg?= =?UTF-8?q?istration=20is=20in=20place.=20*=20feat:=20Add=20order=20tracki?= =?UTF-8?q?ng=20system=20with=20centralized=20status=20sync=20-=20Add=20tr?= =?UTF-8?q?ader=5Forders=20table=20for=20tracking=20all=20order=20lifecycl?= =?UTF-8?q?e=20-=20Implement=20GetOrderStatus=20interface=20for=20all=20ex?= =?UTF-8?q?changes=20(Binance,=20Bybit,=20Hyperliquid,=20Aster,=20Lighter)?= =?UTF-8?q?=20-=20Create=20OrderSyncManager=20for=20centralized=20order=20?= =?UTF-8?q?status=20polling=20-=20Add=20trading=20statistics=20(Sharpe=20r?= =?UTF-8?q?atio,=20win=20rate,=20profit=20factor)=20to=20AI=20context=20-?= =?UTF-8?q?=20Include=20recent=20completed=20orders=20in=20AI=20decision?= =?UTF-8?q?=20input=20-=20Remove=20per-order=20goroutine=20polling=20in=20?= =?UTF-8?q?favor=20of=20global=20sync=20manager=20*=20feat:=20Add=20Tradin?= =?UTF-8?q?gView=20K-line=20chart=20to=20dashboard=20-=20Create=20TradingV?= =?UTF-8?q?iewChart=20component=20with=20exchange/symbol=20selectors=20-?= =?UTF-8?q?=20Support=20Binance,=20Bybit,=20OKX,=20Coinbase,=20Kraken,=20K?= =?UTF-8?q?uCoin=20exchanges=20-=20Add=20popular=20symbols=20quick=20selec?= =?UTF-8?q?tion=20-=20Support=20multiple=20timeframes=20(1m=20to=201W)=20-?= =?UTF-8?q?=20Add=20fullscreen=20mode=20-=20Integrate=20with=20Dashboard?= =?UTF-8?q?=20page=20below=20equity=20chart=20-=20Add=20i18n=20translation?= =?UTF-8?q?s=20for=20zh/en=20*=20refactor:=20Replace=20separate=20charts?= =?UTF-8?q?=20with=20tabbed=20ChartTabs=20component=20-=20Create=20ChartTa?= =?UTF-8?q?bs=20component=20with=20tab=20switching=20between=20equity=20cu?= =?UTF-8?q?rve=20and=20K-line=20-=20Add=20embedded=20mode=20support=20for?= =?UTF-8?q?=20EquityChart=20and=20TradingViewChart=20-=20User=20can=20now?= =?UTF-8?q?=20switch=20between=20account=20equity=20and=20market=20chart?= =?UTF-8?q?=20in=20same=20area=20*=20fix:=20Use=20ChartTabs=20in=20App.tsx?= =?UTF-8?q?=20and=20fix=20embedded=20mode=20in=20EquityChart=20-=20Replace?= =?UTF-8?q?=20EquityChart=20with=20ChartTabs=20in=20App.tsx=20(the=20actua?= =?UTF-8?q?l=20dashboard=20renderer)=20-=20Fix=20EquityChart=20embedded=20?= =?UTF-8?q?mode=20for=20error=20and=20empty=20data=20states=20-=20Rename?= =?UTF-8?q?=20interval=20state=20to=20timeInterval=20to=20avoid=20shadowin?= =?UTF-8?q?g=20window.setInterval=20-=20Add=20debug=20logging=20to=20Chart?= =?UTF-8?q?Tabs=20component=20*=20feat:=20Add=20position=20tracking=20syst?= =?UTF-8?q?em=20for=20accurate=20trade=20history=20-=20Add=20trader=5Fposi?= =?UTF-8?q?tions=20table=20to=20track=20complete=20open/close=20trades=20-?= =?UTF-8?q?=20Add=20PositionSyncManager=20to=20detect=20manual=20closes=20?= =?UTF-8?q?via=20polling=20-=20Record=20position=20on=20open,=20update=20o?= =?UTF-8?q?n=20close=20with=20PnL=20calculation=20-=20Use=20positions=20ta?= =?UTF-8?q?ble=20for=20trading=20stats=20and=20recent=20trades=20(replacin?= =?UTF-8?q?g=20orders=20table)=20-=20Fix=20TradingView=20chart=20symbol=20?= =?UTF-8?q?format=20(add=20.P=20suffix=20for=20futures)=20-=20Fix=20Decisi?= =?UTF-8?q?onCard=20wait/hold=20action=20color=20(gray=20instead=20of=20re?= =?UTF-8?q?d)=20-=20Auto-append=20USDT=20suffix=20for=20custom=20symbol=20?= =?UTF-8?q?input=20*=20update=20---------?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 42 +- .gitignore | 3 +- ENCRYPTION_README.md | 2 +- api/backtest.go | 15 +- api/server.go | 403 +++--- api/server_test.go | 10 +- backtest/manager.go | 18 +- backtest/retention.go | 10 +- backtest/runner.go | 58 +- backtest/storage.go | 18 +- backtest/storage_db_impl.go | 14 +- bootstrap/README.md | 455 ------ bootstrap/bootstrap.go | 169 --- bootstrap/context.go | 49 - bootstrap/hook_builder.go | 27 - bootstrap/init_hook.go | 22 - config/config.go | 17 +- config/database.go | 1735 ----------------------- config/database_test.go | 850 ----------- config/test_rsa_key.pem.pub | 9 - crypto/crypto.go | 278 ++-- crypto/encryption.go | 373 ----- crypto/encryption_test.go | 159 --- crypto/secure_storage.go | 302 ---- decision/engine.go | 165 +-- decision/prompt_test.go | 21 - decision/validate_test.go | 179 --- deploy_encryption.sh | 286 ---- docker-compose.yml | 4 +- logger/config.go | 53 +- logger/config.telegram.json | 33 - logger/decision_logger.go | 768 ---------- logger/logger.go | 129 +- logger/telegram_hook.go | 158 --- logger/telegram_sender.go | 120 -- main.go | 241 ++-- manager/trader_manager.go | 794 +++-------- market/data.go | 8 +- mcp/config.go | 6 +- mcp/logger.go | 46 +- screenshots/competition-page.png | Bin 389583 -> 0 bytes scripts/ENCRYPTION_README.md | 2 +- scripts/generate_data_key.sh | 143 -- scripts/generate_rsa_keys.sh | 149 -- scripts/migrate_encryption.go | 96 +- scripts/setup_encryption.sh | 319 ----- start.sh | 302 ++-- store/ai_model.go | 294 ++++ store/backtest.go | 583 ++++++++ store/beta_code.go | 121 ++ store/decision.go | 530 +++++++ store/exchange.go | 245 ++++ store/order.go | 511 +++++++ store/position.go | 473 ++++++ store/signal_source.go | 86 ++ store/store.go | 319 +++++ store/system_config.go | 70 + store/trader.go | 344 +++++ store/user.go | 164 +++ trader/aster_trader.go | 115 +- trader/auto_trader.go | 776 +++++----- trader/auto_trader_test.go | 271 +--- trader/binance_futures.go | 143 +- trader/bybit_trader.go | 77 +- trader/hyperliquid_trader.go | 183 ++- trader/interface.go | 4 + trader/lighter_orders.go | 43 +- trader/lighter_trader.go | 16 +- trader/lighter_trader_v2.go | 24 +- trader/lighter_trader_v2_orders.go | 104 +- trader/lighter_trader_v2_trading.go | 40 +- trader/lighter_trading.go | 20 +- trader/order_sync.go | 309 ++++ trader/partial_close_test.go | 393 ----- trader/position_sync.go | 318 +++++ web/package-lock.json | 35 +- web/src/App.tsx | 11 +- web/src/components/AILearning.tsx | 1142 --------------- web/src/components/ChartTabs.tsx | 89 ++ web/src/components/DecisionCard.tsx | 5 + web/src/components/EquityChart.tsx | 31 +- web/src/components/LoginPage.tsx | 10 +- web/src/components/RegisterPage.tsx | 6 +- web/src/components/TradingViewChart.tsx | 377 +++++ web/src/i18n/translations.ts | 91 +- web/src/lib/api.ts | 10 - web/src/pages/TraderDashboard.tsx | 11 +- 87 files changed, 6870 insertions(+), 10584 deletions(-) delete mode 100644 bootstrap/README.md delete mode 100644 bootstrap/bootstrap.go delete mode 100644 bootstrap/context.go delete mode 100644 bootstrap/hook_builder.go delete mode 100644 bootstrap/init_hook.go delete mode 100644 config/database.go delete mode 100644 config/database_test.go delete mode 100644 config/test_rsa_key.pem.pub delete mode 100644 crypto/encryption.go delete mode 100644 crypto/encryption_test.go delete mode 100644 crypto/secure_storage.go delete mode 100755 deploy_encryption.sh delete mode 100644 logger/config.telegram.json delete mode 100644 logger/decision_logger.go delete mode 100644 logger/telegram_hook.go delete mode 100644 logger/telegram_sender.go delete mode 100644 screenshots/competition-page.png delete mode 100755 scripts/generate_data_key.sh delete mode 100755 scripts/generate_rsa_keys.sh delete mode 100755 scripts/setup_encryption.sh create mode 100644 store/ai_model.go create mode 100644 store/backtest.go create mode 100644 store/beta_code.go create mode 100644 store/decision.go create mode 100644 store/exchange.go create mode 100644 store/order.go create mode 100644 store/position.go create mode 100644 store/signal_source.go create mode 100644 store/store.go create mode 100644 store/system_config.go create mode 100644 store/trader.go create mode 100644 store/user.go create mode 100644 trader/order_sync.go delete mode 100644 trader/partial_close_test.go create mode 100644 trader/position_sync.go delete mode 100644 web/src/components/AILearning.tsx create mode 100644 web/src/components/ChartTabs.tsx create mode 100644 web/src/components/TradingViewChart.tsx diff --git a/.env.example b/.env.example index cd64fe4e..3c88c0ce 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,46 @@ # NOFX Environment Variables Template # Copy this file to .env and modify the values as needed -# Ports Configuration -# Backend API server port (internal: 8080, external: configurable) +# =========================================== +# Server Configuration +# =========================================== + +# Backend API server port NOFX_BACKEND_PORT=8080 -# Frontend web interface port (Nginx listens on port 80 internally) +# Frontend web interface port NOFX_FRONTEND_PORT=3000 -# Timezone Setting -# System timezone for container time synchronization +# Timezone NOFX_TIMEZONE=Asia/Shanghai +# =========================================== +# Authentication (Required) +# =========================================== + +# JWT signing secret (any random string, at least 32 characters) +# Generate with: openssl rand -base64 32 +JWT_SECRET=your-jwt-secret-change-this-in-production + +# =========================================== +# Encryption Keys (Required) +# =========================================== + +# AES-256 data encryption key (Base64 encoded, 32 bytes) +# Used for encrypting sensitive data in database (API keys, secrets) +# Generate with: openssl rand -base64 32 +DATA_ENCRYPTION_KEY=your-base64-encoded-32-byte-key + +# RSA private key for client-server encryption (PEM format) +# Used for end-to-end encryption of sensitive data from browser +# Generate with: openssl genrsa 2048 +# Note: Replace newlines with \n for single-line format +RSA_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----\nYOUR_KEY_HERE\n-----END RSA PRIVATE KEY----- + +# =========================================== +# Optional: External Services +# =========================================== + +# Telegram notifications (optional) +# TELEGRAM_BOT_TOKEN=your-bot-token +# TELEGRAM_CHAT_ID=your-chat-id diff --git a/.gitignore b/.gitignore index 05dd17f4..a9ab8f41 100644 --- a/.gitignore +++ b/.gitignore @@ -30,8 +30,7 @@ Thumbs.db # 环境变量 .env config.json -config.db* -nofx.db +data.db* configbak.json # 决策日志 diff --git a/ENCRYPTION_README.md b/ENCRYPTION_README.md index 78655876..c2893b85 100644 --- a/ENCRYPTION_README.md +++ b/ENCRYPTION_README.md @@ -116,7 +116,7 @@ If needed, rollback is simple: ```bash # Restore backup -cp config.db.backup config.db +cp data.db.backup data.db # Comment out 3 lines in main.go # (encryption initialization) diff --git a/api/backtest.go b/api/backtest.go index 5cf04796..9e000dda 100644 --- a/api/backtest.go +++ b/api/backtest.go @@ -12,8 +12,8 @@ import ( "time" "nofx/backtest" - "nofx/config" "nofx/decision" + "nofx/store" "github.com/gin-gonic/gin" ) @@ -486,9 +486,6 @@ func (s *Server) ensureBacktestRunOwnership(runID, userID string) (*backtest.Run if owner == "" { return meta, nil } - if owner == "default" && userID == "admin" { - return meta, nil - } if owner != userID { return nil, errBacktestForbidden } @@ -514,7 +511,7 @@ func (s *Server) resolveBacktestAIConfig(cfg *backtest.BacktestConfig, userID st if cfg == nil { return fmt.Errorf("config is nil") } - if s.database == nil { + if s.store == nil { return fmt.Errorf("系统数据库未就绪,无法加载AI模型配置") } @@ -527,7 +524,7 @@ func (s *Server) hydrateBacktestAIConfig(cfg *backtest.BacktestConfig) error { if cfg == nil { return fmt.Errorf("config is nil") } - if s.database == nil { + if s.store == nil { return fmt.Errorf("系统数据库未就绪,无法加载AI模型配置") } @@ -535,17 +532,17 @@ func (s *Server) hydrateBacktestAIConfig(cfg *backtest.BacktestConfig) error { modelID := strings.TrimSpace(cfg.AIModelID) var ( - model *config.AIModelConfig + model *store.AIModel err error ) if modelID != "" { - model, err = s.database.GetAIModel(cfg.UserID, modelID) + model, err = s.store.AIModel().Get(cfg.UserID, modelID) if err != nil { return fmt.Errorf("加载AI模型失败: %w", err) } } else { - model, err = s.database.GetDefaultAIModel(cfg.UserID) + model, err = s.store.AIModel().GetDefault(cfg.UserID) if err != nil { return fmt.Errorf("未找到可用的AI模型: %w", err) } diff --git a/api/server.go b/api/server.go index 89b6013f..d71b8955 100644 --- a/api/server.go +++ b/api/server.go @@ -4,15 +4,15 @@ import ( "context" "encoding/json" "fmt" - "log" + "nofx/logger" "net" "net/http" "nofx/auth" "nofx/backtest" - "nofx/config" "nofx/crypto" "nofx/decision" "nofx/manager" + "nofx/store" "nofx/trader" "strconv" "strings" @@ -26,14 +26,15 @@ import ( type Server struct { router *gin.Engine traderManager *manager.TraderManager - database *config.Database + store *store.Store cryptoHandler *CryptoHandler backtestManager *backtest.Manager httpServer *http.Server port int } + // NewServer 创建API服务器 -func NewServer(traderManager *manager.TraderManager, database *config.Database, cryptoService *crypto.CryptoService, backtestManager *backtest.Manager, port int) *Server { +func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoService *crypto.CryptoService, backtestManager *backtest.Manager, port int) *Server { // 设置为Release模式(减少日志输出) gin.SetMode(gin.ReleaseMode) @@ -48,7 +49,7 @@ func NewServer(traderManager *manager.TraderManager, database *config.Database, s := &Server{ router: router, traderManager: traderManager, - database: database, + store: st, cryptoHandler: cryptoHandler, backtestManager: backtestManager, port: port, @@ -154,7 +155,6 @@ func (s *Server) setupRoutes() { protected.GET("/decisions", s.handleDecisions) protected.GET("/decisions/latest", s.handleLatestDecisions) protected.GET("/statistics", s.handleStatistics) - protected.GET("/performance", s.handlePerformance) } } } @@ -170,7 +170,7 @@ func (s *Server) handleHealth(c *gin.Context) { // handleGetSystemConfig 获取系统配置(客户端需要知道的配置) func (s *Server) handleGetSystemConfig(c *gin.Context) { // 获取默认币种 - defaultCoinsStr, _ := s.database.GetSystemConfig("default_coins") + defaultCoinsStr, _ := s.store.SystemConfig().Get("default_coins") var defaultCoins []string if defaultCoinsStr != "" { json.Unmarshal([]byte(defaultCoinsStr), &defaultCoins) @@ -181,8 +181,8 @@ func (s *Server) handleGetSystemConfig(c *gin.Context) { } // 获取杠杆配置 - btcEthLeverageStr, _ := s.database.GetSystemConfig("btc_eth_leverage") - altcoinLeverageStr, _ := s.database.GetSystemConfig("altcoin_leverage") + btcEthLeverageStr, _ := s.store.SystemConfig().Get("btc_eth_leverage") + altcoinLeverageStr, _ := s.store.SystemConfig().Get("altcoin_leverage") btcEthLeverage := 5 if val, err := strconv.Atoi(btcEthLeverageStr); err == nil && val > 0 { @@ -195,14 +195,19 @@ func (s *Server) handleGetSystemConfig(c *gin.Context) { } // 获取内测模式配置 - betaModeStr, _ := s.database.GetSystemConfig("beta_mode") + betaModeStr, _ := s.store.SystemConfig().Get("beta_mode") betaMode := betaModeStr == "true" + // 获取注册开关配置(默认开启) + registrationEnabledStr, _ := s.store.SystemConfig().Get("registration_enabled") + registrationEnabled := registrationEnabledStr != "false" + c.JSON(http.StatusOK, gin.H{ - "beta_mode": betaMode, - "default_coins": defaultCoins, - "btc_eth_leverage": btcEthLeverage, - "altcoin_leverage": altcoinLeverage, + "beta_mode": betaMode, + "registration_enabled": registrationEnabled, + "default_coins": defaultCoins, + "btc_eth_leverage": btcEthLeverage, + "altcoin_leverage": altcoinLeverage, }) } @@ -339,9 +344,9 @@ func (s *Server) getTraderFromQuery(c *gin.Context) (*manager.TraderManager, str traderID := c.Query("trader_id") // 确保用户的交易员已加载到内存中 - err := s.traderManager.LoadUserTraders(s.database, userID) + err := s.traderManager.LoadUserTradersFromStore(s.store, userID) if err != nil { - log.Printf("⚠️ 加载用户 %s 的交易员失败: %v", userID, err) + logger.Infof("⚠️ 加载用户 %s 的交易员失败: %v", userID, err) } if traderID == "" { @@ -352,7 +357,7 @@ func (s *Server) getTraderFromQuery(c *gin.Context) (*manager.TraderManager, str } // 获取用户的交易员列表,优先返回用户自己的交易员 - userTraders, err := s.database.GetTraders(userID) + userTraders, err := s.store.Trader().List(userID) if err == nil && len(userTraders) > 0 { traderID = userTraders[0].ID } else { @@ -493,7 +498,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) { btcEthLeverage = req.BTCETHLeverage } else { // 从系统配置获取默认值 - if btcEthLeverageStr, _ := s.database.GetSystemConfig("btc_eth_leverage"); btcEthLeverageStr != "" { + if btcEthLeverageStr, _ := s.store.SystemConfig().Get("btc_eth_leverage"); btcEthLeverageStr != "" { if val, err := strconv.Atoi(btcEthLeverageStr); err == nil && val > 0 { btcEthLeverage = val } @@ -503,7 +508,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) { altcoinLeverage = req.AltcoinLeverage } else { // 从系统配置获取默认值 - if altcoinLeverageStr, _ := s.database.GetSystemConfig("altcoin_leverage"); altcoinLeverageStr != "" { + if altcoinLeverageStr, _ := s.store.SystemConfig().Get("altcoin_leverage"); altcoinLeverageStr != "" { if val, err := strconv.Atoi(altcoinLeverageStr); err == nil && val > 0 { altcoinLeverage = val } @@ -524,13 +529,13 @@ func (s *Server) handleCreateTrader(c *gin.Context) { // ✨ 查询交易所实际余额,覆盖用户输入 actualBalance := req.InitialBalance // 默认使用用户输入 - exchanges, err := s.database.GetExchanges(userID) + exchanges, err := s.store.Exchange().List(userID) if err != nil { - log.Printf("⚠️ 获取交易所配置失败,使用用户输入的初始资金: %v", err) + logger.Infof("⚠️ 获取交易所配置失败,使用用户输入的初始资金: %v", err) } // 查找匹配的交易所配置 - var exchangeCfg *config.ExchangeConfig + var exchangeCfg *store.Exchange for _, ex := range exchanges { if ex.ID == req.ExchangeID { exchangeCfg = ex @@ -539,9 +544,9 @@ func (s *Server) handleCreateTrader(c *gin.Context) { } if exchangeCfg == nil { - log.Printf("⚠️ 未找到交易所 %s 的配置,使用用户输入的初始资金", req.ExchangeID) + logger.Infof("⚠️ 未找到交易所 %s 的配置,使用用户输入的初始资金", req.ExchangeID) } else if !exchangeCfg.Enabled { - log.Printf("⚠️ 交易所 %s 未启用,使用用户输入的初始资金", req.ExchangeID) + logger.Infof("⚠️ 交易所 %s 未启用,使用用户输入的初始资金", req.ExchangeID) } else { // 根据交易所类型创建临时 trader 查询余额 var tempTrader trader.Trader @@ -568,44 +573,44 @@ func (s *Server) handleCreateTrader(c *gin.Context) { exchangeCfg.SecretKey, ) default: - log.Printf("⚠️ 不支持的交易所类型: %s,使用用户输入的初始资金", req.ExchangeID) + logger.Infof("⚠️ 不支持的交易所类型: %s,使用用户输入的初始资金", req.ExchangeID) } if createErr != nil { - log.Printf("⚠️ 创建临时 trader 失败,使用用户输入的初始资金: %v", createErr) + logger.Infof("⚠️ 创建临时 trader 失败,使用用户输入的初始资金: %v", createErr) } else if tempTrader != nil { // 查询实际余额 balanceInfo, balanceErr := tempTrader.GetBalance() if balanceErr != nil { - log.Printf("⚠️ 查询交易所余额失败,使用用户输入的初始资金: %v", balanceErr) + logger.Infof("⚠️ 查询交易所余额失败,使用用户输入的初始资金: %v", balanceErr) } else { // 提取可用余额 - 支持多种字段名格式 if availableBalance, ok := balanceInfo["availableBalance"].(float64); ok && availableBalance > 0 { // Binance 格式: availableBalance (camelCase) actualBalance = availableBalance - log.Printf("✓ 查询到交易所实际余额: %.2f USDT (用户输入: %.2f USDT)", actualBalance, req.InitialBalance) + logger.Infof("✓ 查询到交易所实际余额: %.2f USDT (用户输入: %.2f USDT)", actualBalance, req.InitialBalance) } else if availableBalance, ok := balanceInfo["available_balance"].(float64); ok && availableBalance > 0 { // 其他格式: available_balance (snake_case) actualBalance = availableBalance - log.Printf("✓ 查询到交易所实际余额: %.2f USDT (用户输入: %.2f USDT)", actualBalance, req.InitialBalance) + logger.Infof("✓ 查询到交易所实际余额: %.2f USDT (用户输入: %.2f USDT)", actualBalance, req.InitialBalance) } else if totalBalance, ok := balanceInfo["totalWalletBalance"].(float64); ok && totalBalance > 0 { // Binance 格式: totalWalletBalance (camelCase) actualBalance = totalBalance - log.Printf("✓ 查询到交易所总余额: %.2f USDT (用户输入: %.2f USDT)", actualBalance, req.InitialBalance) + logger.Infof("✓ 查询到交易所总余额: %.2f USDT (用户输入: %.2f USDT)", actualBalance, req.InitialBalance) } else if totalBalance, ok := balanceInfo["balance"].(float64); ok && totalBalance > 0 { // 其他格式: balance actualBalance = totalBalance - log.Printf("✓ 查询到交易所实际余额: %.2f USDT (用户输入: %.2f USDT)", actualBalance, req.InitialBalance) + logger.Infof("✓ 查询到交易所实际余额: %.2f USDT (用户输入: %.2f USDT)", actualBalance, req.InitialBalance) } else { - log.Printf("⚠️ 无法从余额信息中提取可用余额,balanceInfo=%v,使用用户输入的初始资金", balanceInfo) + logger.Infof("⚠️ 无法从余额信息中提取可用余额,balanceInfo=%v,使用用户输入的初始资金", balanceInfo) } } } } // 创建交易员配置(数据库实体) - log.Printf("🔧 DEBUG: 开始创建交易员配置, ID=%s, Name=%s, AIModel=%s, Exchange=%s", traderID, req.Name, req.AIModelID, req.ExchangeID) - trader := &config.TraderRecord{ + logger.Infof("🔧 DEBUG: 开始创建交易员配置, ID=%s, Name=%s, AIModel=%s, Exchange=%s", traderID, req.Name, req.AIModelID, req.ExchangeID) + traderRecord := &store.Trader{ ID: traderID, UserID: userID, Name: req.Name, @@ -626,25 +631,25 @@ func (s *Server) handleCreateTrader(c *gin.Context) { } // 保存到数据库 - log.Printf("🔧 DEBUG: 准备调用 CreateTrader") - err = s.database.CreateTrader(trader) + logger.Infof("🔧 DEBUG: 准备调用 CreateTrader") + err = s.store.Trader().Create(traderRecord) if err != nil { - log.Printf("❌ 创建交易员失败: %v", err) + logger.Infof("❌ 创建交易员失败: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("创建交易员失败: %v", err)}) return } - log.Printf("🔧 DEBUG: CreateTrader 成功") + logger.Infof("🔧 DEBUG: CreateTrader 成功") // 立即将新交易员加载到TraderManager中 - log.Printf("🔧 DEBUG: 准备调用 LoadUserTraders") - err = s.traderManager.LoadUserTraders(s.database, userID) + logger.Infof("🔧 DEBUG: 准备调用 LoadUserTraders") + err = s.traderManager.LoadUserTradersFromStore(s.store, userID) if err != nil { - log.Printf("⚠️ 加载用户交易员到内存失败: %v", err) + logger.Infof("⚠️ 加载用户交易员到内存失败: %v", err) // 这里不返回错误,因为交易员已经成功创建到数据库 } - log.Printf("🔧 DEBUG: LoadUserTraders 完成") + logger.Infof("🔧 DEBUG: LoadUserTraders 完成") - log.Printf("✓ 创建交易员成功: %s (模型: %s, 交易所: %s)", req.Name, req.AIModelID, req.ExchangeID) + logger.Infof("✓ 创建交易员成功: %s (模型: %s, 交易所: %s)", req.Name, req.AIModelID, req.ExchangeID) c.JSON(http.StatusCreated, gin.H{ "trader_id": traderID, @@ -656,17 +661,18 @@ func (s *Server) handleCreateTrader(c *gin.Context) { // UpdateTraderRequest 更新交易员请求 type UpdateTraderRequest struct { - Name string `json:"name" binding:"required"` - AIModelID string `json:"ai_model_id" binding:"required"` - ExchangeID string `json:"exchange_id" binding:"required"` - InitialBalance float64 `json:"initial_balance"` - ScanIntervalMinutes int `json:"scan_interval_minutes"` - BTCETHLeverage int `json:"btc_eth_leverage"` - AltcoinLeverage int `json:"altcoin_leverage"` - TradingSymbols string `json:"trading_symbols"` - CustomPrompt string `json:"custom_prompt"` - OverrideBasePrompt bool `json:"override_base_prompt"` - IsCrossMargin *bool `json:"is_cross_margin"` + Name string `json:"name" binding:"required"` + AIModelID string `json:"ai_model_id" binding:"required"` + ExchangeID string `json:"exchange_id" binding:"required"` + InitialBalance float64 `json:"initial_balance"` + ScanIntervalMinutes int `json:"scan_interval_minutes"` + BTCETHLeverage int `json:"btc_eth_leverage"` + AltcoinLeverage int `json:"altcoin_leverage"` + TradingSymbols string `json:"trading_symbols"` + CustomPrompt string `json:"custom_prompt"` + OverrideBasePrompt bool `json:"override_base_prompt"` + SystemPromptTemplate string `json:"system_prompt_template"` + IsCrossMargin *bool `json:"is_cross_margin"` } // handleUpdateTrader 更新交易员配置 @@ -681,16 +687,16 @@ func (s *Server) handleUpdateTrader(c *gin.Context) { } // 检查交易员是否存在且属于当前用户 - traders, err := s.database.GetTraders(userID) + traders, err := s.store.Trader().List(userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "获取交易员列表失败"}) return } - var existingTrader *config.TraderRecord - for _, trader := range traders { - if trader.ID == traderID { - existingTrader = trader + var existingTrader *store.Trader + for _, t := range traders { + if t.ID == traderID { + existingTrader = t break } } @@ -724,8 +730,14 @@ func (s *Server) handleUpdateTrader(c *gin.Context) { scanIntervalMinutes = 3 } + // 设置系统提示词模板 + systemPromptTemplate := req.SystemPromptTemplate + if systemPromptTemplate == "" { + systemPromptTemplate = existingTrader.SystemPromptTemplate // 保持原值 + } + // 更新交易员配置 - trader := &config.TraderRecord{ + traderRecord := &store.Trader{ ID: traderID, UserID: userID, Name: req.Name, @@ -737,26 +749,26 @@ func (s *Server) handleUpdateTrader(c *gin.Context) { TradingSymbols: req.TradingSymbols, CustomPrompt: req.CustomPrompt, OverrideBasePrompt: req.OverrideBasePrompt, - SystemPromptTemplate: existingTrader.SystemPromptTemplate, // 保持原值 + SystemPromptTemplate: systemPromptTemplate, IsCrossMargin: isCrossMargin, ScanIntervalMinutes: scanIntervalMinutes, IsRunning: existingTrader.IsRunning, // 保持原值 } // 更新数据库 - err = s.database.UpdateTrader(trader) + err = s.store.Trader().Update(traderRecord) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("更新交易员失败: %v", err)}) return } // 重新加载交易员到内存 - err = s.traderManager.LoadUserTraders(s.database, userID) + err = s.traderManager.LoadUserTradersFromStore(s.store, userID) if err != nil { - log.Printf("⚠️ 重新加载用户交易员到内存失败: %v", err) + logger.Infof("⚠️ 重新加载用户交易员到内存失败: %v", err) } - log.Printf("✓ 更新交易员成功: %s (模型: %s, 交易所: %s)", req.Name, req.AIModelID, req.ExchangeID) + logger.Infof("✓ 更新交易员成功: %s (模型: %s, 交易所: %s)", req.Name, req.AIModelID, req.ExchangeID) c.JSON(http.StatusOK, gin.H{ "trader_id": traderID, @@ -772,7 +784,7 @@ func (s *Server) handleDeleteTrader(c *gin.Context) { traderID := c.Param("id") // 从数据库删除 - err := s.database.DeleteTrader(userID, traderID) + err := s.store.Trader().Delete(userID, traderID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("删除交易员失败: %v", err)}) return @@ -783,11 +795,11 @@ func (s *Server) handleDeleteTrader(c *gin.Context) { status := trader.GetStatus() if isRunning, ok := status["is_running"].(bool); ok && isRunning { trader.Stop() - log.Printf("⏹ 已停止运行中的交易员: %s", traderID) + logger.Infof("⏹ 已停止运行中的交易员: %s", traderID) } } - log.Printf("✓ 交易员已删除: %s", traderID) + logger.Infof("✓ 交易员已删除: %s", traderID) c.JSON(http.StatusOK, gin.H{"message": "交易员已删除"}) } @@ -797,7 +809,7 @@ func (s *Server) handleStartTrader(c *gin.Context) { traderID := c.Param("id") // 校验交易员是否属于当前用户 - _, _, _, err := s.database.GetTraderConfig(userID, traderID) + _, err := s.store.Trader().GetFullConfig(userID, traderID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "交易员不存在或无访问权限"}) return @@ -818,19 +830,19 @@ func (s *Server) handleStartTrader(c *gin.Context) { // 启动交易员 go func() { - log.Printf("▶️ 启动交易员 %s (%s)", traderID, trader.GetName()) + logger.Infof("▶️ 启动交易员 %s (%s)", traderID, trader.GetName()) if err := trader.Run(); err != nil { - log.Printf("❌ 交易员 %s 运行错误: %v", trader.GetName(), err) + logger.Infof("❌ 交易员 %s 运行错误: %v", trader.GetName(), err) } }() // 更新数据库中的运行状态 - err = s.database.UpdateTraderStatus(userID, traderID, true) + err = s.store.Trader().UpdateStatus(userID, traderID, true) if err != nil { - log.Printf("⚠️ 更新交易员状态失败: %v", err) + logger.Infof("⚠️ 更新交易员状态失败: %v", err) } - log.Printf("✓ 交易员 %s 已启动", trader.GetName()) + logger.Infof("✓ 交易员 %s 已启动", trader.GetName()) c.JSON(http.StatusOK, gin.H{"message": "交易员已启动"}) } @@ -840,7 +852,7 @@ func (s *Server) handleStopTrader(c *gin.Context) { traderID := c.Param("id") // 校验交易员是否属于当前用户 - _, _, _, err := s.database.GetTraderConfig(userID, traderID) + _, err := s.store.Trader().GetFullConfig(userID, traderID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "交易员不存在或无访问权限"}) return @@ -863,12 +875,12 @@ func (s *Server) handleStopTrader(c *gin.Context) { trader.Stop() // 更新数据库中的运行状态 - err = s.database.UpdateTraderStatus(userID, traderID, false) + err = s.store.Trader().UpdateStatus(userID, traderID, false) if err != nil { - log.Printf("⚠️ 更新交易员状态失败: %v", err) + logger.Infof("⚠️ 更新交易员状态失败: %v", err) } - log.Printf("⏹ 交易员 %s 已停止", trader.GetName()) + logger.Infof("⏹ 交易员 %s 已停止", trader.GetName()) c.JSON(http.StatusOK, gin.H{"message": "交易员已停止"}) } @@ -888,7 +900,7 @@ func (s *Server) handleUpdateTraderPrompt(c *gin.Context) { } // 更新数据库 - err := s.database.UpdateTraderCustomPrompt(userID, traderID, req.CustomPrompt, req.OverrideBasePrompt) + err := s.store.Trader().UpdateCustomPrompt(userID, traderID, req.CustomPrompt, req.OverrideBasePrompt) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("更新自定义prompt失败: %v", err)}) return @@ -899,7 +911,7 @@ func (s *Server) handleUpdateTraderPrompt(c *gin.Context) { if err == nil { trader.SetCustomPrompt(req.CustomPrompt) trader.SetOverrideBasePrompt(req.OverrideBasePrompt) - log.Printf("✓ 已更新交易员 %s 的自定义prompt (覆盖基础=%v)", trader.GetName(), req.OverrideBasePrompt) + logger.Infof("✓ 已更新交易员 %s 的自定义prompt (覆盖基础=%v)", trader.GetName(), req.OverrideBasePrompt) } c.JSON(http.StatusOK, gin.H{"message": "自定义prompt已更新"}) @@ -910,15 +922,18 @@ func (s *Server) handleSyncBalance(c *gin.Context) { userID := c.GetString("user_id") traderID := c.Param("id") - log.Printf("🔄 用户 %s 请求同步交易员 %s 的余额", userID, traderID) + logger.Infof("🔄 用户 %s 请求同步交易员 %s 的余额", userID, traderID) // 从数据库获取交易员配置(包含交易所信息) - traderConfig, _, exchangeCfg, err := s.database.GetTraderConfig(userID, traderID) + fullConfig, err := s.store.Trader().GetFullConfig(userID, traderID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "交易员不存在"}) return } + traderConfig := fullConfig.Trader + exchangeCfg := fullConfig.Exchange + if exchangeCfg == nil || !exchangeCfg.Enabled { c.JSON(http.StatusBadRequest, gin.H{"error": "交易所未配置或未启用"}) return @@ -954,7 +969,7 @@ func (s *Server) handleSyncBalance(c *gin.Context) { } if createErr != nil { - log.Printf("⚠️ 创建临时 trader 失败: %v", createErr) + logger.Infof("⚠️ 创建临时 trader 失败: %v", createErr) c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("连接交易所失败: %v", createErr)}) return } @@ -962,7 +977,7 @@ func (s *Server) handleSyncBalance(c *gin.Context) { // 查询实际余额 balanceInfo, balanceErr := tempTrader.GetBalance() if balanceErr != nil { - log.Printf("⚠️ 查询交易所余额失败: %v", balanceErr) + logger.Infof("⚠️ 查询交易所余额失败: %v", balanceErr) c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("查询余额失败: %v", balanceErr)}) return } @@ -989,24 +1004,24 @@ func (s *Server) handleSyncBalance(c *gin.Context) { changeType = "减少" } - log.Printf("✓ 查询到交易所实际余额: %.2f USDT (当前配置: %.2f USDT, 变化: %.2f%%)", + logger.Infof("✓ 查询到交易所实际余额: %.2f USDT (当前配置: %.2f USDT, 变化: %.2f%%)", actualBalance, oldBalance, changePercent) // 更新数据库中的 initial_balance - err = s.database.UpdateTraderInitialBalance(userID, traderID, actualBalance) + err = s.store.Trader().UpdateInitialBalance(userID, traderID, actualBalance) if err != nil { - log.Printf("❌ 更新initial_balance失败: %v", err) + logger.Infof("❌ 更新initial_balance失败: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "更新余额失败"}) return } // 重新加载交易员到内存 - err = s.traderManager.LoadUserTraders(s.database, userID) + err = s.traderManager.LoadUserTradersFromStore(s.store, userID) if err != nil { - log.Printf("⚠️ 重新加载用户交易员到内存失败: %v", err) + logger.Infof("⚠️ 重新加载用户交易员到内存失败: %v", err) } - log.Printf("✅ 已同步余额: %.2f → %.2f USDT (%s %.2f%%)", oldBalance, actualBalance, changeType, changePercent) + logger.Infof("✅ 已同步余额: %.2f → %.2f USDT (%s %.2f%%)", oldBalance, actualBalance, changeType, changePercent) c.JSON(http.StatusOK, gin.H{ "message": "余额同步成功", @@ -1020,14 +1035,14 @@ func (s *Server) handleSyncBalance(c *gin.Context) { // handleGetModelConfigs 获取AI模型配置 func (s *Server) handleGetModelConfigs(c *gin.Context) { userID := c.GetString("user_id") - log.Printf("🔍 查询用户 %s 的AI模型配置", userID) - models, err := s.database.GetAIModels(userID) + logger.Infof("🔍 查询用户 %s 的AI模型配置", userID) + models, err := s.store.AIModel().List(userID) if err != nil { - log.Printf("❌ 获取AI模型配置失败: %v", err) + logger.Infof("❌ 获取AI模型配置失败: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("获取AI模型配置失败: %v", err)}) return } - log.Printf("✅ 找到 %d 个AI模型配置", len(models)) + logger.Infof("✅ 找到 %d 个AI模型配置", len(models)) // 转换为安全的响应结构,移除敏感信息 safeModels := make([]SafeModelConfig, len(models)) @@ -1059,14 +1074,14 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) { // 解析加密的 payload var encryptedPayload crypto.EncryptedPayload if err := json.Unmarshal(bodyBytes, &encryptedPayload); err != nil { - log.Printf("❌ 解析加密载荷失败: %v", err) + logger.Infof("❌ 解析加密载荷失败: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误,必须使用加密传输"}) return } // 验证是否为加密数据 if encryptedPayload.WrappedKey == "" { - log.Printf("❌ 检测到非加密请求 (UserID: %s)", userID) + logger.Infof("❌ 检测到非加密请求 (UserID: %s)", userID) c.JSON(http.StatusBadRequest, gin.H{ "error": "此接口仅支持加密传输,请使用加密客户端", "code": "ENCRYPTION_REQUIRED", @@ -1078,7 +1093,7 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) { // 解密数据 decrypted, err := s.cryptoHandler.cryptoService.DecryptSensitiveData(&encryptedPayload) if err != nil { - log.Printf("❌ 解密模型配置失败 (UserID: %s): %v", userID, err) + logger.Infof("❌ 解密模型配置失败 (UserID: %s): %v", userID, err) c.JSON(http.StatusBadRequest, gin.H{"error": "解密数据失败"}) return } @@ -1086,15 +1101,15 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) { // 解析解密后的数据 var req UpdateModelConfigRequest if err := json.Unmarshal([]byte(decrypted), &req); err != nil { - log.Printf("❌ 解析解密数据失败: %v", err) + logger.Infof("❌ 解析解密数据失败: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "解析解密数据失败"}) return } - log.Printf("🔓 已解密模型配置数据 (UserID: %s)", userID) + logger.Infof("🔓 已解密模型配置数据 (UserID: %s)", userID) // 更新每个模型的配置 for modelID, modelData := range req.Models { - err := s.database.UpdateAIModel(userID, modelID, modelData.Enabled, modelData.APIKey, modelData.CustomAPIURL, modelData.CustomModelName) + err := s.store.AIModel().Update(userID, modelID, modelData.Enabled, modelData.APIKey, modelData.CustomAPIURL, modelData.CustomModelName) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("更新模型 %s 失败: %v", modelID, err)}) return @@ -1102,27 +1117,27 @@ func (s *Server) handleUpdateModelConfigs(c *gin.Context) { } // 重新加载该用户的所有交易员,使新配置立即生效 - err = s.traderManager.LoadUserTraders(s.database, userID) + err = s.traderManager.LoadUserTradersFromStore(s.store, userID) if err != nil { - log.Printf("⚠️ 重新加载用户交易员到内存失败: %v", err) + logger.Infof("⚠️ 重新加载用户交易员到内存失败: %v", err) // 这里不返回错误,因为模型配置已经成功更新到数据库 } - log.Printf("✓ AI模型配置已更新: %+v", req.Models) + logger.Infof("✓ AI模型配置已更新: %+v", req.Models) c.JSON(http.StatusOK, gin.H{"message": "模型配置已更新"}) } // handleGetExchangeConfigs 获取交易所配置 func (s *Server) handleGetExchangeConfigs(c *gin.Context) { userID := c.GetString("user_id") - log.Printf("🔍 查询用户 %s 的交易所配置", userID) - exchanges, err := s.database.GetExchanges(userID) + logger.Infof("🔍 查询用户 %s 的交易所配置", userID) + exchanges, err := s.store.Exchange().List(userID) if err != nil { - log.Printf("❌ 获取交易所配置失败: %v", err) + logger.Infof("❌ 获取交易所配置失败: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("获取交易所配置失败: %v", err)}) return } - log.Printf("✅ 找到 %d 个交易所配置", len(exchanges)) + logger.Infof("✅ 找到 %d 个交易所配置", len(exchanges)) // 调试:输出配置详情(脱敏) for _, ex := range exchanges { @@ -1134,12 +1149,12 @@ func (s *Server) handleGetExchangeConfigs(c *gin.Context) { if len(ex.SecretKey) > 8 { secretKeyMasked = ex.SecretKey[:8] + "..." } - log.Printf(" └─ 交易所: %s, APIKey: %s, SecretKey: %s", ex.ID, apiKeyMasked, secretKeyMasked) + logger.Infof(" └─ 交易所: %s, APIKey: %s, SecretKey: %s", ex.ID, apiKeyMasked, secretKeyMasked) } // 打印完整JSON响应用于调试 jsonData, _ := json.Marshal(exchanges) - log.Printf("📤 完整JSON响应: %s", string(jsonData)) + logger.Infof("📤 完整JSON响应: %s", string(jsonData)) // 转换为安全的响应结构,移除敏感信息 safeExchanges := make([]SafeExchangeConfig, len(exchanges)) @@ -1173,14 +1188,14 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) { // 解析加密的 payload var encryptedPayload crypto.EncryptedPayload if err := json.Unmarshal(bodyBytes, &encryptedPayload); err != nil { - log.Printf("❌ 解析加密载荷失败: %v", err) + logger.Infof("❌ 解析加密载荷失败: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误,必须使用加密传输"}) return } // 验证是否为加密数据 if encryptedPayload.WrappedKey == "" { - log.Printf("❌ 检测到非加密请求 (UserID: %s)", userID) + logger.Infof("❌ 检测到非加密请求 (UserID: %s)", userID) c.JSON(http.StatusBadRequest, gin.H{ "error": "此接口仅支持加密传输,请使用加密客户端", "code": "ENCRYPTION_REQUIRED", @@ -1192,7 +1207,7 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) { // 解密数据 decrypted, err := s.cryptoHandler.cryptoService.DecryptSensitiveData(&encryptedPayload) if err != nil { - log.Printf("❌ 解密交易所配置失败 (UserID: %s): %v", userID, err) + logger.Infof("❌ 解密交易所配置失败 (UserID: %s): %v", userID, err) c.JSON(http.StatusBadRequest, gin.H{"error": "解密数据失败"}) return } @@ -1200,15 +1215,15 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) { // 解析解密后的数据 var req UpdateExchangeConfigRequest if err := json.Unmarshal([]byte(decrypted), &req); err != nil { - log.Printf("❌ 解析解密数据失败: %v", err) + logger.Infof("❌ 解析解密数据失败: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "解析解密数据失败"}) return } - log.Printf("🔓 已解密交易所配置数据 (UserID: %s)", userID) + logger.Infof("🔓 已解密交易所配置数据 (UserID: %s)", userID) // 更新每个交易所的配置 for exchangeID, exchangeData := range req.Exchanges { - err := s.database.UpdateExchange(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, exchangeData.LighterPrivateKey) + err := s.store.Exchange().Update(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, exchangeData.LighterPrivateKey) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("更新交易所 %s 失败: %v", exchangeID, err)}) return @@ -1216,20 +1231,20 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) { } // 重新加载该用户的所有交易员,使新配置立即生效 - err = s.traderManager.LoadUserTraders(s.database, userID) + err = s.traderManager.LoadUserTradersFromStore(s.store, userID) if err != nil { - log.Printf("⚠️ 重新加载用户交易员到内存失败: %v", err) + logger.Infof("⚠️ 重新加载用户交易员到内存失败: %v", err) // 这里不返回错误,因为交易所配置已经成功更新到数据库 } - log.Printf("✓ 交易所配置已更新: %+v", req.Exchanges) + logger.Infof("✓ 交易所配置已更新: %+v", req.Exchanges) c.JSON(http.StatusOK, gin.H{"message": "交易所配置已更新"}) } // handleGetUserSignalSource 获取用户信号源配置 func (s *Server) handleGetUserSignalSource(c *gin.Context) { userID := c.GetString("user_id") - source, err := s.database.GetUserSignalSource(userID) + source, err := s.store.SignalSource().Get(userID) if err != nil { // 如果配置不存在,返回空配置而不是404错误 c.JSON(http.StatusOK, gin.H{ @@ -1258,20 +1273,20 @@ func (s *Server) handleSaveUserSignalSource(c *gin.Context) { return } - err := s.database.CreateUserSignalSource(userID, req.CoinPoolURL, req.OITopURL) + err := s.store.SignalSource().Create(userID, req.CoinPoolURL, req.OITopURL) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("保存用户信号源配置失败: %v", err)}) return } - log.Printf("✓ 用户信号源配置已保存: user=%s, coin_pool=%s, oi_top=%s", userID, req.CoinPoolURL, req.OITopURL) + logger.Infof("✓ 用户信号源配置已保存: user=%s, coin_pool=%s, oi_top=%s", userID, req.CoinPoolURL, req.OITopURL) c.JSON(http.StatusOK, gin.H{"message": "用户信号源配置已保存"}) } // handleTraderList trader列表 func (s *Server) handleTraderList(c *gin.Context) { userID := c.GetString("user_id") - traders, err := s.database.GetTraders(userID) + traders, err := s.store.Trader().List(userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("获取交易员列表失败: %v", err)}) return @@ -1313,11 +1328,12 @@ func (s *Server) handleGetTraderConfig(c *gin.Context) { return } - traderConfig, _, _, err := s.database.GetTraderConfig(userID, traderID) + fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("获取交易员配置失败: %v", err)}) return } + traderConfig := fullCfg.Trader // 获取实时运行状态 isRunning := traderConfig.IsRunning @@ -1384,17 +1400,17 @@ func (s *Server) handleAccount(c *gin.Context) { return } - log.Printf("📊 收到账户信息请求 [%s]", trader.GetName()) + logger.Infof("📊 收到账户信息请求 [%s]", trader.GetName()) account, err := trader.GetAccountInfo() if err != nil { - log.Printf("❌ 获取账户信息失败 [%s]: %v", trader.GetName(), err) + logger.Infof("❌ 获取账户信息失败 [%s]: %v", trader.GetName(), err) c.JSON(http.StatusInternalServerError, gin.H{ "error": fmt.Sprintf("获取账户信息失败: %v", err), }) return } - log.Printf("✓ 返回账户信息 [%s]: 净值=%.2f, 可用=%.2f, 盈亏=%.2f (%.2f%%)", + logger.Infof("✓ 返回账户信息 [%s]: 净值=%.2f, 可用=%.2f, 盈亏=%.2f (%.2f%%)", trader.GetName(), account["total_equity"], account["available_balance"], @@ -1443,7 +1459,7 @@ func (s *Server) handleDecisions(c *gin.Context) { } // 获取所有历史决策记录(无限制) - records, err := trader.GetDecisionLogger().GetLatestRecords(10000) + records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), 10000) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": fmt.Sprintf("获取决策日志失败: %v", err), @@ -1468,7 +1484,7 @@ func (s *Server) handleLatestDecisions(c *gin.Context) { return } - records, err := trader.GetDecisionLogger().GetLatestRecords(5) + records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), 5) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": fmt.Sprintf("获取决策日志失败: %v", err), @@ -1499,7 +1515,7 @@ func (s *Server) handleStatistics(c *gin.Context) { return } - stats, err := trader.GetDecisionLogger().GetStatistics() + stats, err := trader.GetStore().Decision().GetStatistics(trader.GetID()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": fmt.Sprintf("获取统计信息失败: %v", err), @@ -1515,9 +1531,9 @@ func (s *Server) handleCompetition(c *gin.Context) { userID := c.GetString("user_id") // 确保用户的交易员已加载到内存中 - err := s.traderManager.LoadUserTraders(s.database, userID) + err := s.traderManager.LoadUserTradersFromStore(s.store, userID) if err != nil { - log.Printf("⚠️ 加载用户 %s 的交易员失败: %v", userID, err) + logger.Infof("⚠️ 加载用户 %s 的交易员失败: %v", userID, err) } competition, err := s.traderManager.GetCompetitionData() @@ -1547,7 +1563,7 @@ func (s *Server) handleEquityHistory(c *gin.Context) { // 获取尽可能多的历史数据(几天的数据) // 每3分钟一个周期:10000条 = 约20天的数据 - records, err := trader.GetDecisionLogger().GetLatestRecords(10000) + records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), 10000) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": fmt.Sprintf("获取历史数据失败: %v", err), @@ -1617,33 +1633,6 @@ func (s *Server) handleEquityHistory(c *gin.Context) { c.JSON(http.StatusOK, history) } -// handlePerformance AI历史表现分析(用于展示AI学习和反思) -func (s *Server) handlePerformance(c *gin.Context) { - _, traderID, err := s.getTraderFromQuery(c) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - trader, err := s.traderManager.GetTrader(traderID) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) - return - } - - // 分析最近100个周期的交易表现(避免长期持仓的交易记录丢失) - // 假设每3分钟一个周期,100个周期 = 5小时,足够覆盖大部分交易 - performance, err := trader.GetDecisionLogger().AnalyzePerformance(100) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": fmt.Sprintf("分析历史表现失败: %v", err), - }) - return - } - - c.JSON(http.StatusOK, performance) -} - // authMiddleware JWT认证中间件 func (s *Server) authMiddleware() gin.HandlerFunc { return func(c *gin.Context) { @@ -1730,7 +1719,7 @@ func (s *Server) handleRegister(c *gin.Context) { } // 检查是否开启了内测模式 - betaModeStr, _ := s.database.GetSystemConfig("beta_mode") + betaModeStr, _ := s.store.SystemConfig().Get("beta_mode") if betaModeStr == "true" { // 内测模式下必须提供有效的内测码 if req.BetaCode == "" { @@ -1739,7 +1728,7 @@ func (s *Server) handleRegister(c *gin.Context) { } // 验证内测码 - isValid, err := s.database.ValidateBetaCode(req.BetaCode) + isValid, err := s.store.BetaCode().Validate(req.BetaCode) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "验证内测码失败"}) return @@ -1751,7 +1740,7 @@ func (s *Server) handleRegister(c *gin.Context) { } // 检查邮箱是否已存在 - _, err := s.database.GetUserByEmail(req.Email) + _, err := s.store.User().GetByEmail(req.Email) if err == nil { c.JSON(http.StatusConflict, gin.H{"error": "邮箱已被注册"}) return @@ -1773,7 +1762,7 @@ func (s *Server) handleRegister(c *gin.Context) { // 创建用户(未验证OTP状态) userID := uuid.New().String() - user := &config.User{ + user := &store.User{ ID: userID, Email: req.Email, PasswordHash: passwordHash, @@ -1781,21 +1770,21 @@ func (s *Server) handleRegister(c *gin.Context) { OTPVerified: false, } - err = s.database.CreateUser(user) + err = s.store.User().Create(user) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "创建用户失败: " + err.Error()}) return } // 如果是内测模式,标记内测码为已使用 - betaModeStr2, _ := s.database.GetSystemConfig("beta_mode") + betaModeStr2, _ := s.store.SystemConfig().Get("beta_mode") if betaModeStr2 == "true" && req.BetaCode != "" { - err := s.database.UseBetaCode(req.BetaCode, req.Email) + err := s.store.BetaCode().Use(req.BetaCode, req.Email) if err != nil { - log.Printf("⚠️ 标记内测码为已使用失败: %v", err) + logger.Infof("⚠️ 标记内测码为已使用失败: %v", err) // 这里不返回错误,因为用户已经创建成功 } else { - log.Printf("✓ 内测码 %s 已被用户 %s 使用", req.BetaCode, req.Email) + logger.Infof("✓ 内测码 %s 已被用户 %s 使用", req.BetaCode, req.Email) } } @@ -1823,7 +1812,7 @@ func (s *Server) handleCompleteRegistration(c *gin.Context) { } // 获取用户信息 - user, err := s.database.GetUserByID(req.UserID) + user, err := s.store.User().GetByID(req.UserID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) return @@ -1836,7 +1825,7 @@ func (s *Server) handleCompleteRegistration(c *gin.Context) { } // 更新用户OTP验证状态 - err = s.database.UpdateUserOTPVerified(req.UserID, true) + err = s.store.User().UpdateOTPVerified(req.UserID, true) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "更新用户状态失败"}) return @@ -1852,7 +1841,7 @@ func (s *Server) handleCompleteRegistration(c *gin.Context) { // 初始化用户的默认模型和交易所配置 err = s.initUserDefaultConfigs(user.ID) if err != nil { - log.Printf("初始化用户默认配置失败: %v", err) + logger.Infof("初始化用户默认配置失败: %v", err) } c.JSON(http.StatusOK, gin.H{ @@ -1876,7 +1865,7 @@ func (s *Server) handleLogin(c *gin.Context) { } // 获取用户信息 - user, err := s.database.GetUserByEmail(req.Email) + user, err := s.store.User().GetByEmail(req.Email) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "邮箱或密码错误"}) return @@ -1920,7 +1909,7 @@ func (s *Server) handleVerifyOTP(c *gin.Context) { } // 获取用户信息 - user, err := s.database.GetUserByID(req.UserID) + user, err := s.store.User().GetByID(req.UserID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) return @@ -1961,7 +1950,7 @@ func (s *Server) handleResetPassword(c *gin.Context) { } // 查询用户 - user, err := s.database.GetUserByEmail(req.Email) + user, err := s.store.User().GetByEmail(req.Email) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "邮箱不存在"}) return @@ -1981,13 +1970,13 @@ func (s *Server) handleResetPassword(c *gin.Context) { } // 更新密码 - err = s.database.UpdateUserPassword(user.ID, newPasswordHash) + err = s.store.User().UpdatePassword(user.ID, newPasswordHash) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "密码更新失败"}) return } - log.Printf("✓ 用户 %s 密码已重置", user.Email) + logger.Infof("✓ 用户 %s 密码已重置", user.Email) c.JSON(http.StatusOK, gin.H{"message": "密码重置成功,请使用新密码登录"}) } @@ -1995,16 +1984,16 @@ func (s *Server) handleResetPassword(c *gin.Context) { func (s *Server) initUserDefaultConfigs(userID string) error { // 注释掉自动创建默认配置,让用户手动添加 // 这样新用户注册后不会自动有配置项 - log.Printf("用户 %s 注册完成,等待手动配置AI模型和交易所", userID) + logger.Infof("用户 %s 注册完成,等待手动配置AI模型和交易所", userID) return nil } // handleGetSupportedModels 获取系统支持的AI模型列表 func (s *Server) handleGetSupportedModels(c *gin.Context) { // 返回系统支持的AI模型(从default用户获取) - models, err := s.database.GetAIModels("default") + models, err := s.store.AIModel().List("default") if err != nil { - log.Printf("❌ 获取支持的AI模型失败: %v", err) + logger.Infof("❌ 获取支持的AI模型失败: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "获取支持的AI模型失败"}) return } @@ -2015,9 +2004,9 @@ func (s *Server) handleGetSupportedModels(c *gin.Context) { // handleGetSupportedExchanges 获取系统支持的交易所列表 func (s *Server) handleGetSupportedExchanges(c *gin.Context) { // 返回系统支持的交易所(从default用户获取) - exchanges, err := s.database.GetExchanges("default") + exchanges, err := s.store.Exchange().List("default") if err != nil { - log.Printf("❌ 获取支持的交易所失败: %v", err) + logger.Infof("❌ 获取支持的交易所失败: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "获取支持的交易所失败"}) return } @@ -2043,31 +2032,31 @@ func (s *Server) handleGetSupportedExchanges(c *gin.Context) { // Start 启动服务器 func (s *Server) Start() error { addr := fmt.Sprintf(":%d", s.port) - log.Printf("🌐 API服务器启动在 http://localhost%s", addr) - log.Printf("📊 API文档:") - log.Printf(" • GET /api/health - 健康检查") - log.Printf(" • GET /api/traders - 公开的AI交易员排行榜前50名(无需认证)") - log.Printf(" • GET /api/competition - 公开的竞赛数据(无需认证)") - log.Printf(" • GET /api/top-traders - 前5名交易员数据(无需认证,表现对比用)") - log.Printf(" • GET /api/equity-history?trader_id=xxx - 公开的收益率历史数据(无需认证,竞赛用)") - log.Printf(" • GET /api/equity-history-batch?trader_ids=a,b,c - 批量获取历史数据(无需认证,表现对比优化)") - log.Printf(" • GET /api/traders/:id/public-config - 公开的交易员配置(无需认证,不含敏感信息)") - log.Printf(" • POST /api/traders - 创建新的AI交易员") - log.Printf(" • DELETE /api/traders/:id - 删除AI交易员") - log.Printf(" • POST /api/traders/:id/start - 启动AI交易员") - log.Printf(" • POST /api/traders/:id/stop - 停止AI交易员") - log.Printf(" • GET /api/models - 获取AI模型配置") - log.Printf(" • PUT /api/models - 更新AI模型配置") - log.Printf(" • GET /api/exchanges - 获取交易所配置") - log.Printf(" • PUT /api/exchanges - 更新交易所配置") - log.Printf(" • GET /api/status?trader_id=xxx - 指定trader的系统状态") - log.Printf(" • GET /api/account?trader_id=xxx - 指定trader的账户信息") - log.Printf(" • GET /api/positions?trader_id=xxx - 指定trader的持仓列表") - log.Printf(" • GET /api/decisions?trader_id=xxx - 指定trader的决策日志") - log.Printf(" • GET /api/decisions/latest?trader_id=xxx - 指定trader的最新决策") - log.Printf(" • GET /api/statistics?trader_id=xxx - 指定trader的统计信息") - log.Printf(" • GET /api/performance?trader_id=xxx - 指定trader的AI学习表现分析") - log.Println() + logger.Infof("🌐 API服务器启动在 http://localhost%s", addr) + logger.Infof("📊 API文档:") + logger.Infof(" • GET /api/health - 健康检查") + logger.Infof(" • GET /api/traders - 公开的AI交易员排行榜前50名(无需认证)") + logger.Infof(" • GET /api/competition - 公开的竞赛数据(无需认证)") + logger.Infof(" • GET /api/top-traders - 前5名交易员数据(无需认证,表现对比用)") + logger.Infof(" • GET /api/equity-history?trader_id=xxx - 公开的收益率历史数据(无需认证,竞赛用)") + logger.Infof(" • GET /api/equity-history-batch?trader_ids=a,b,c - 批量获取历史数据(无需认证,表现对比优化)") + logger.Infof(" • GET /api/traders/:id/public-config - 公开的交易员配置(无需认证,不含敏感信息)") + logger.Infof(" • POST /api/traders - 创建新的AI交易员") + logger.Infof(" • DELETE /api/traders/:id - 删除AI交易员") + logger.Infof(" • POST /api/traders/:id/start - 启动AI交易员") + logger.Infof(" • POST /api/traders/:id/stop - 停止AI交易员") + logger.Infof(" • GET /api/models - 获取AI模型配置") + logger.Infof(" • PUT /api/models - 更新AI模型配置") + logger.Infof(" • GET /api/exchanges - 获取交易所配置") + logger.Infof(" • PUT /api/exchanges - 更新交易所配置") + logger.Infof(" • GET /api/status?trader_id=xxx - 指定trader的系统状态") + logger.Infof(" • GET /api/account?trader_id=xxx - 指定trader的账户信息") + logger.Infof(" • GET /api/positions?trader_id=xxx - 指定trader的持仓列表") + logger.Infof(" • GET /api/decisions?trader_id=xxx - 指定trader的决策日志") + logger.Infof(" • GET /api/decisions/latest?trader_id=xxx - 指定trader的最新决策") + logger.Infof(" • GET /api/statistics?trader_id=xxx - 指定trader的统计信息") + logger.Infof(" • GET /api/performance?trader_id=xxx - 指定trader的AI学习表现分析") + logger.Info() s.httpServer = &http.Server{ Addr: addr, @@ -2265,7 +2254,7 @@ func (s *Server) getEquityHistoryForTraders(traderIDs []string) map[string]inter } // 获取历史数据(用于对比展示,限制数据量) - records, err := trader.GetDecisionLogger().GetLatestRecords(500) + records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), 500) if err != nil { errors[traderID] = fmt.Sprintf("获取历史数据失败: %v", err) continue diff --git a/api/server_test.go b/api/server_test.go index f59817bd..0b9997a2 100644 --- a/api/server_test.go +++ b/api/server_test.go @@ -4,7 +4,7 @@ import ( "encoding/json" "testing" - "nofx/config" + "nofx/store" ) // TestUpdateTraderRequest_SystemPromptTemplate 测试更新交易员时 SystemPromptTemplate 字段是否存在 @@ -100,12 +100,12 @@ func TestUpdateTraderRequest_SystemPromptTemplate(t *testing.T) { func TestGetTraderConfigResponse_SystemPromptTemplate(t *testing.T) { tests := []struct { name string - traderConfig *config.TraderRecord + traderConfig *store.Trader expectedTemplate string }{ { name: "获取配置应该返回 system_prompt_template=nof1", - traderConfig: &config.TraderRecord{ + traderConfig: &store.Trader{ ID: "trader-123", UserID: "user-1", Name: "Test Trader", @@ -126,7 +126,7 @@ func TestGetTraderConfigResponse_SystemPromptTemplate(t *testing.T) { }, { name: "获取配置应该返回 system_prompt_template=default", - traderConfig: &config.TraderRecord{ + traderConfig: &store.Trader{ ID: "trader-456", UserID: "user-1", Name: "Test Trader 2", @@ -229,7 +229,7 @@ func TestUpdateTraderRequest_CompleteFields(t *testing.T) { // TestTraderListResponse_SystemPromptTemplate 测试 handleTraderList API 返回的 trader 对象是否包含 system_prompt_template 字段 func TestTraderListResponse_SystemPromptTemplate(t *testing.T) { // 模拟 handleTraderList 中的 trader 对象构造 - trader := &config.TraderRecord{ + trader := &store.Trader{ ID: "trader-001", UserID: "user-1", Name: "My Trader", diff --git a/backtest/manager.go b/backtest/manager.go index 6a0a4199..e0359cac 100644 --- a/backtest/manager.go +++ b/backtest/manager.go @@ -4,14 +4,14 @@ import ( "context" "errors" "fmt" - "log" + "nofx/logger" "os" "sort" "strings" "sync" - "nofx/logger" "nofx/mcp" + "nofx/store" ) type Manager struct { @@ -377,7 +377,7 @@ func (m *Manager) Status(runID string) *StatusPayload { func (m *Manager) launchWatcher(runID string, runner *Runner) { go func() { if err := runner.Wait(); err != nil { - log.Printf("backtest run %s finished with error: %v", runID, err) + logger.Infof("backtest run %s finished with error: %v", runID, err) } runner.PersistMetadata() meta := runner.CurrentMetadata() @@ -419,7 +419,7 @@ func (m *Manager) storeMetadata(runID string, meta *RunMetadata) { m.mu.Unlock() _ = SaveRunMetadata(meta) if err := updateRunIndex(meta, nil); err != nil { - log.Printf("failed to update run index for %s: %v", runID, err) + logger.Infof("failed to update run index for %s: %v", runID, err) } } @@ -445,7 +445,7 @@ func (m *Manager) resolveAIConfig(cfg *BacktestConfig) error { return resolver(cfg) } -func (m *Manager) GetTrace(runID string, cycle int) (*logger.DecisionRecord, error) { +func (m *Manager) GetTrace(runID string, cycle int) (*store.DecisionRecord, error) { return LoadDecisionTrace(runID, cycle) } @@ -462,18 +462,18 @@ func (m *Manager) RestoreRuns() error { for _, runID := range runIDs { meta, err := LoadRunMetadata(runID) if err != nil { - log.Printf("skip run %s: %v", runID, err) + logger.Infof("skip run %s: %v", runID, err) continue } if meta.State == RunStateRunning { lock, err := loadRunLock(runID) if err != nil || lockIsStale(lock) { if err := deleteRunLock(runID); err != nil { - log.Printf("failed to cleanup lock for %s: %v", runID, err) + logger.Infof("failed to cleanup lock for %s: %v", runID, err) } meta.State = RunStatePaused if err := SaveRunMetadata(meta); err != nil { - log.Printf("failed to mark %s paused: %v", runID, err) + logger.Infof("failed to mark %s paused: %v", runID, err) } } } @@ -481,7 +481,7 @@ func (m *Manager) RestoreRuns() error { m.metadata[runID] = meta m.mu.Unlock() if err := updateRunIndex(meta, nil); err != nil { - log.Printf("failed to sync index for %s: %v", runID, err) + logger.Infof("failed to sync index for %s: %v", runID, err) } } return nil diff --git a/backtest/retention.go b/backtest/retention.go index 3201bdce..55395c97 100644 --- a/backtest/retention.go +++ b/backtest/retention.go @@ -1,7 +1,7 @@ package backtest import ( - "log" + "nofx/logger" "os" "sort" "time" @@ -56,13 +56,13 @@ func enforceRetention(maxRuns int) { for i := 0; i < toRemove; i++ { runID := candidates[i].entry.RunID if err := os.RemoveAll(runDir(runID)); err != nil { - log.Printf("failed to prune run %s: %v", runID, err) + logger.Infof("failed to prune run %s: %v", runID, err) continue } delete(idx.Runs, runID) } if err := saveRunIndex(idx); err != nil { - log.Printf("failed to save index after pruning: %v", err) + logger.Infof("failed to save index after pruning: %v", err) } } @@ -91,11 +91,11 @@ func enforceRetentionDB(maxRuns int) { continue } if err := deleteRunDB(runID); err != nil { - log.Printf("failed to remove run %s: %v", runID, err) + logger.Infof("failed to remove run %s: %v", runID, err) continue } if err := os.RemoveAll(runDir(runID)); err != nil { - log.Printf("failed to remove run dir %s: %v", runID, err) + logger.Infof("failed to remove run dir %s: %v", runID, err) } } } diff --git a/backtest/runner.go b/backtest/runner.go index fafcd676..2c94954b 100644 --- a/backtest/runner.go +++ b/backtest/runner.go @@ -5,7 +5,7 @@ import ( "encoding/json" "errors" "fmt" - "log" + "nofx/logger" "os" "path/filepath" "sort" @@ -14,9 +14,9 @@ import ( "time" "nofx/decision" - "nofx/logger" "nofx/market" "nofx/mcp" + "nofx/store" ) var ( @@ -35,7 +35,7 @@ type Runner struct { feed *DataFeed account *BacktestAccount - decisionLogger logger.IDecisionLogger + decisionLogDir string mcpClient mcp.AIClient statusMu sync.RWMutex @@ -83,7 +83,7 @@ func NewRunner(cfg BacktestConfig, mcpClient mcp.AIClient) (*Runner, error) { return nil, err } - dLog := logger.NewDecisionLogger(decisionLogDir(cfg.RunID)) + dLogDir := decisionLogDir(cfg.RunID) account := NewBacktestAccount(cfg.InitialBalance, cfg.FeeBps, cfg.SlippageBps) createdAt := time.Now().UTC() @@ -119,7 +119,7 @@ func NewRunner(cfg BacktestConfig, mcpClient mcp.AIClient) (*Runner, error) { cfg: cfg, feed: feed, account: account, - decisionLogger: dLog, + decisionLogDir: dLogDir, mcpClient: client, status: RunStateCreated, state: state, @@ -160,7 +160,7 @@ func (r *Runner) lockHeartbeatLoop() { select { case <-ticker.C: if err := updateRunLockHeartbeat(r.lockInfo); err != nil { - log.Printf("failed to update lock heartbeat for %s: %v", r.cfg.RunID, err) + logger.Infof("failed to update lock heartbeat for %s: %v", r.cfg.RunID, err) } case <-r.lockStop: return @@ -174,7 +174,7 @@ func (r *Runner) releaseLock() { r.lockStop = nil } if err := deleteRunLock(r.cfg.RunID); err != nil { - log.Printf("failed to release lock for %s: %v", r.cfg.RunID, err) + logger.Infof("failed to release lock for %s: %v", r.cfg.RunID, err) } r.lockInfo = nil } @@ -279,8 +279,8 @@ func (r *Runner) stepOnce() error { shouldDecide := r.shouldTriggerDecision(state.BarIndex) var ( - record *logger.DecisionRecord - decisionActions []logger.DecisionAction + record *store.DecisionRecord + decisionActions []store.DecisionAction tradeEvents = make([]TradeEvent, 0) execLog []string hadError bool @@ -317,7 +317,7 @@ func (r *Runner) stepOnce() error { return decisionErr } } else { - log.Printf("failed to compute ai cache key: %v", err) + logger.Infof("failed to compute ai cache key: %v", err) } } @@ -334,7 +334,7 @@ func (r *Runner) stepOnce() error { fullDecision = fd if r.cfg.CacheAI && r.aiCache != nil && cacheKey != "" { if err := r.aiCache.Put(cacheKey, r.cfg.PromptVariant, ts, fullDecision); err != nil { - log.Printf("failed to persist ai cache for %s: %v", r.cfg.RunID, err) + logger.Infof("failed to persist ai cache for %s: %v", r.cfg.RunID, err) } } } @@ -346,7 +346,7 @@ func (r *Runner) stepOnce() error { sorted := sortDecisionsByPriority(fullDecision.Decisions) prevLogs := execLog - decisionActions = make([]logger.DecisionAction, 0, len(sorted)) + decisionActions = make([]store.DecisionAction, 0, len(sorted)) execLog = make([]string, 0, len(sorted)+len(prevLogs)) if len(prevLogs) > 0 { execLog = append(execLog, prevLogs...) @@ -464,7 +464,7 @@ func (r *Runner) stepOnce() error { return nil } -func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Data, multiTF map[string]map[string]*market.Data, priceMap map[string]float64, callCount int) (*decision.Context, *logger.DecisionRecord, error) { +func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Data, multiTF map[string]map[string]*market.Data, priceMap map[string]float64, callCount int) (*decision.Context, *store.DecisionRecord, error) { equity, unrealized, _ := r.account.TotalEquity(priceMap) available := r.account.Cash() marginUsed := r.totalMarginUsed() @@ -505,8 +505,8 @@ func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Da AltcoinLeverage: r.cfg.Leverage.AltcoinLeverage, } - record := &logger.DecisionRecord{ - AccountState: logger.AccountSnapshot{ + record := &store.DecisionRecord{ + AccountState: store.AccountSnapshot{ TotalBalance: accountInfo.TotalEquity, AvailableBalance: accountInfo.AvailableBalance, TotalUnrealizedProfit: unrealized, @@ -524,7 +524,7 @@ func (r *Runner) buildDecisionContext(ts int64, marketData map[string]*market.Da return ctx, record, nil } -func (r *Runner) fillDecisionRecord(record *logger.DecisionRecord, full *decision.FullDecision) { +func (r *Runner) fillDecisionRecord(record *store.DecisionRecord, full *decision.FullDecision) { record.InputPrompt = full.UserPrompt record.CoTTrace = full.CoTTrace if len(full.Decisions) > 0 { @@ -554,10 +554,10 @@ func (r *Runner) invokeAIWithRetry(ctx *decision.Context) (*decision.FullDecisio return nil, lastErr } -func (r *Runner) executeDecision(dec decision.Decision, priceMap map[string]float64, ts int64, cycle int) (logger.DecisionAction, []TradeEvent, string, error) { +func (r *Runner) executeDecision(dec decision.Decision, priceMap map[string]float64, ts int64, cycle int) (store.DecisionAction, []TradeEvent, string, error) { symbol := dec.Symbol usedLeverage := r.resolveLeverage(dec.Leverage, symbol) - actionRecord := logger.DecisionAction{ + actionRecord := store.DecisionAction{ Action: dec.Action, Symbol: symbol, Leverage: usedLeverage, @@ -748,12 +748,12 @@ func (r *Runner) remainingPosition(symbol, side string) float64 { return 0 } -func (r *Runner) snapshotPositions(priceMap map[string]float64) []logger.PositionSnapshot { +func (r *Runner) snapshotPositions(priceMap map[string]float64) []store.PositionSnapshot { positions := r.account.Positions() - list := make([]logger.PositionSnapshot, 0, len(positions)) + list := make([]store.PositionSnapshot, 0, len(positions)) for _, pos := range positions { price := priceMap[pos.Symbol] - list = append(list, logger.PositionSnapshot{ + list = append(list, store.PositionSnapshot{ Symbol: pos.Symbol, Side: pos.Side, PositionAmt: pos.Quantity, @@ -1124,21 +1124,18 @@ func (r *Runner) persistMetadata() { meta := r.buildMetadata(state, r.Status()) meta.CreatedAt = r.createdAt if err := SaveRunMetadata(meta); err != nil { - log.Printf("failed to save run metadata for %s: %v", r.cfg.RunID, err) + logger.Infof("failed to save run metadata for %s: %v", r.cfg.RunID, err) } else { if err := updateRunIndex(meta, &r.cfg); err != nil { - log.Printf("failed to update index for %s: %v", r.cfg.RunID, err) + logger.Infof("failed to update index for %s: %v", r.cfg.RunID, err) } } } -func (r *Runner) logDecision(record *logger.DecisionRecord) error { +func (r *Runner) logDecision(record *store.DecisionRecord) error { if record == nil { return nil } - if err := r.decisionLogger.LogDecision(record); err != nil { - return err - } persistDecisionRecord(r.cfg.RunID, record) return nil } @@ -1157,14 +1154,14 @@ func (r *Runner) persistMetrics(force bool) { state := r.snapshotState() metrics, err := CalculateMetrics(r.cfg.RunID, &r.cfg, &state) if err != nil { - log.Printf("failed to compute metrics for %s: %v", r.cfg.RunID, err) + logger.Infof("failed to compute metrics for %s: %v", r.cfg.RunID, err) return } if metrics == nil { return } if err := PersistMetrics(r.cfg.RunID, metrics); err != nil { - log.Printf("failed to persist metrics for %s: %v", r.cfg.RunID, err) + logger.Infof("failed to persist metrics for %s: %v", r.cfg.RunID, err) return } r.lastMetricsWrite = time.Now() @@ -1264,7 +1261,7 @@ func (r *Runner) saveCheckpoint(state BacktestState) error { func (r *Runner) forceCheckpoint() { state := r.snapshotState() if err := r.saveCheckpoint(state); err != nil { - log.Printf("failed to save checkpoint for %s: %v", r.cfg.RunID, err) + logger.Infof("failed to save checkpoint for %s: %v", r.cfg.RunID, err) } } @@ -1281,7 +1278,6 @@ func (r *Runner) applyCheckpoint(ckpt *Checkpoint) error { return fmt.Errorf("checkpoint is nil") } r.account.RestoreFromSnapshots(ckpt.Cash, ckpt.RealizedPnL, ckpt.Positions) - r.decisionLogger.SetCycleNumber(ckpt.DecisionCycle) r.stateMu.Lock() defer r.stateMu.Unlock() r.state.BarIndex = ckpt.BarIndex diff --git a/backtest/storage.go b/backtest/storage.go index 7949655d..c5bf1405 100644 --- a/backtest/storage.go +++ b/backtest/storage.go @@ -13,7 +13,7 @@ import ( "strings" "time" - "nofx/logger" + "nofx/store" ) const ( @@ -380,7 +380,7 @@ func PersistMetrics(runID string, metrics *Metrics) error { return saveMetrics(runID, metrics) } -func LoadDecisionTrace(runID string, cycle int) (*logger.DecisionRecord, error) { +func LoadDecisionTrace(runID string, cycle int) (*store.DecisionRecord, error) { if usingDB() { return loadDecisionTraceDB(runID, cycle) } @@ -418,7 +418,7 @@ func LoadDecisionTrace(runID string, cycle int) (*logger.DecisionRecord, error) if err != nil { continue } - var record logger.DecisionRecord + var record store.DecisionRecord if err := json.Unmarshal(data, &record); err != nil { continue } @@ -429,7 +429,7 @@ func LoadDecisionTrace(runID string, cycle int) (*logger.DecisionRecord, error) return nil, fmt.Errorf("decision trace not found for run %s cycle %d", runID, cycle) } -func LoadDecisionRecords(runID string, limit, offset int) ([]*logger.DecisionRecord, error) { +func LoadDecisionRecords(runID string, limit, offset int) ([]*store.DecisionRecord, error) { if limit <= 0 { limit = 20 } @@ -443,7 +443,7 @@ func LoadDecisionRecords(runID string, limit, offset int) ([]*logger.DecisionRec entries, err := os.ReadDir(dir) if err != nil { if errors.Is(err, os.ErrNotExist) { - return []*logger.DecisionRecord{}, nil + return []*store.DecisionRecord{}, nil } return nil, err } @@ -471,19 +471,19 @@ func LoadDecisionRecords(runID string, limit, offset int) ([]*logger.DecisionRec return infoI.ModTime().After(infoJ.ModTime()) }) if offset >= len(files) { - return []*logger.DecisionRecord{}, nil + return []*store.DecisionRecord{}, nil } end := offset + limit if end > len(files) { end = len(files) } - records := make([]*logger.DecisionRecord, 0, end-offset) + records := make([]*store.DecisionRecord, 0, end-offset) for _, file := range files[offset:end] { data, err := os.ReadFile(file.path) if err != nil { continue } - var record logger.DecisionRecord + var record store.DecisionRecord if err := json.Unmarshal(data, &record); err != nil { continue } @@ -553,7 +553,7 @@ func CreateRunExport(runID string) (string, error) { return tmpFile.Name(), nil } -func persistDecisionRecord(runID string, record *logger.DecisionRecord) { +func persistDecisionRecord(runID string, record *store.DecisionRecord) { if !usingDB() || record == nil { return } diff --git a/backtest/storage_db_impl.go b/backtest/storage_db_impl.go index 3f7eb508..67cc0831 100644 --- a/backtest/storage_db_impl.go +++ b/backtest/storage_db_impl.go @@ -9,7 +9,7 @@ import ( "os" "time" - "nofx/logger" + "nofx/store" ) func saveCheckpointDB(runID string, ckpt *Checkpoint) error { @@ -273,7 +273,7 @@ func saveProgressDB(runID string, payload progressPayload) error { return err } -func loadDecisionTraceDB(runID string, cycle int) (*logger.DecisionRecord, error) { +func loadDecisionTraceDB(runID string, cycle int) (*store.DecisionRecord, error) { query := `SELECT payload FROM backtest_decisions WHERE run_id = ?` var rows *sql.Rows var err error @@ -293,14 +293,14 @@ func loadDecisionTraceDB(runID string, cycle int) (*logger.DecisionRecord, error if err := rows.Scan(&payload); err != nil { return nil, err } - var record logger.DecisionRecord + var record store.DecisionRecord if err := json.Unmarshal(payload, &record); err != nil { return nil, err } return &record, nil } -func saveDecisionRecordDB(runID string, record *logger.DecisionRecord) error { +func saveDecisionRecordDB(runID string, record *store.DecisionRecord) error { if record == nil { return nil } @@ -315,7 +315,7 @@ func saveDecisionRecordDB(runID string, record *logger.DecisionRecord) error { return err } -func loadDecisionRecordsDB(runID string, limit, offset int) ([]*logger.DecisionRecord, error) { +func loadDecisionRecordsDB(runID string, limit, offset int) ([]*store.DecisionRecord, error) { rows, err := persistenceDB.Query(` SELECT payload FROM backtest_decisions WHERE run_id = ? @@ -326,13 +326,13 @@ func loadDecisionRecordsDB(runID string, limit, offset int) ([]*logger.DecisionR return nil, err } defer rows.Close() - records := make([]*logger.DecisionRecord, 0, limit) + records := make([]*store.DecisionRecord, 0, limit) for rows.Next() { var payload []byte if err := rows.Scan(&payload); err != nil { return nil, err } - var record logger.DecisionRecord + var record store.DecisionRecord if err := json.Unmarshal(payload, &record); err != nil { return nil, err } diff --git a/bootstrap/README.md b/bootstrap/README.md deleted file mode 100644 index 4db4b260..00000000 --- a/bootstrap/README.md +++ /dev/null @@ -1,455 +0,0 @@ -# Bootstrap 模块初始化框架 - -## 概述 - -Bootstrap 是一个模块化的初始化框架,允许各个模块通过注册钩子的方式自动完成初始化,支持优先级控制、条件初始化、错误策略等高级特性。 - -## 核心特性 - -- ✅ **优先级排序** - 保证模块按正确的顺序初始化 -- ✅ **钩子命名** - 每个钩子都有清晰的名称,便于日志追踪和错误定位 -- ✅ **上下文传递** - 模块之间可以共享数据(如数据库实例) -- ✅ **条件初始化** - 根据配置动态决定是否初始化某个模块 -- ✅ **灵活的错误处理** - 支持快速失败、继续执行、警告三种策略 -- ✅ **详细日志** - 显示初始化进度、耗时统计 -- ✅ **线程安全** - 使用互斥锁保护全局状态 -- ✅ **测试友好** - 提供 Clear() 方法清除钩子 - -## 快速开始 - -### 1. 在模块中注册初始化钩子 - -在你的模块包中创建 `init.go` 文件: - -```go -// proxy/init.go -package proxy - -import ( - "nofx/bootstrap" - "nofx/config" -) - -func init() { - // 注册初始化钩子 - bootstrap.Register("Proxy模块", bootstrap.PriorityCore, initProxyModule) -} - -func initProxyModule(ctx *bootstrap.Context) error { - // 从配置中读取 proxy 配置 - proxyConfig := ctx.Config.Proxy - - // 初始化代理管理器 - if err := InitGlobalProxyManager(proxyConfig); err != nil { - return err - } - - // 将实例存储到上下文,供其他模块使用 - ctx.Set("proxy_manager", GetGlobalProxyManager()) - - return nil -} -``` - -### 2. 在 main.go 中运行初始化 - -```go -package main - -import ( - "log" - "nofx/bootstrap" - "nofx/config" - - // 导入需要初始化的模块(触发 init() 注册) - _ "nofx/proxy" - _ "nofx/market" - _ "nofx/trader" -) - -func main() { - // 加载配置 - cfg, err := config.LoadConfig("config.json") - if err != nil { - log.Fatalf("加载配置失败: %v", err) - } - - // 创建初始化上下文 - ctx := bootstrap.NewContext(cfg) - - // 执行所有初始化钩子 - if err := bootstrap.Run(ctx); err != nil { - log.Fatalf("初始化失败: %v", err) - } - - // 启动业务逻辑... -} -``` - -### 3. 运行效果 - -``` -🔄 开始初始化 3 个模块... - [1/3] 初始化: Database模块 (优先级: 20) - ✓ 完成: Database模块 (耗时: 120ms) - [2/3] 初始化: Proxy模块 (优先级: 50) - ↳ 代理自动刷新已启动 (间隔: 30m0s) - ↳ 代理池状态: 总计=5, 黑名单=0, 可用=5 - ✓ 完成: Proxy模块 (耗时: 35ms) - [3/3] 初始化: Market模块 (优先级: 100) - ✓ 完成: Market模块 (耗时: 200ms) -✅ 所有模块初始化完成 (总耗时: 355ms) -📊 统计: 成功=3, 跳过=0 -``` - -## 优先级常量 - -系统预定义了以下优先级常量(数值越小越先执行): - -| 常量 | 值 | 用途 | 示例 | -|------|-----|------|------| -| `PriorityInfrastructure` | 10 | 基础设施 | 日志系统、配置加载 | -| `PriorityDatabase` | 20 | 数据库连接 | SQLite、Redis | -| `PriorityCore` | 50 | 核心模块 | Proxy、Market Monitor | -| `PriorityBusiness` | 100 | 业务模块 | Trader、API Server | -| `PriorityBackground` | 200 | 后台任务 | 定时任务、监控 | - -### 使用示例 - -```go -// 数据库模块(最先初始化) -bootstrap.Register("Database", bootstrap.PriorityDatabase, initDatabase) - -// 代理模块(核心模块) -bootstrap.Register("Proxy", bootstrap.PriorityCore, initProxy) - -// Trader模块(依赖数据库和代理) -bootstrap.Register("Trader", bootstrap.PriorityBusiness, initTrader) -``` - -## 高级特性 - -### 1. 条件初始化 - -某些模块只在特定条件下才需要初始化: - -```go -bootstrap.Register("Proxy模块", bootstrap.PriorityCore, initProxy). - EnabledIf(func(ctx *bootstrap.Context) bool { - // 只在配置中启用 proxy 时才初始化 - return ctx.Config.Proxy != nil && ctx.Config.Proxy.Enabled - }) -``` - -**输出**: -``` - [2/5] 跳过: Proxy模块 (条件未满足) -``` - -### 2. 错误处理策略 - -支持三种错误处理策略: - -#### FailFast(默认)- 遇到错误立即停止 - -```go -bootstrap.Register("Database", bootstrap.PriorityDatabase, initDatabase) -// 默认就是 FailFast,无需显式设置 -``` - -**效果**:Database 初始化失败,整个系统停止启动 - -#### ContinueOnError - 继续执行,收集所有错误 - -```go -bootstrap.Register("Proxy", bootstrap.PriorityCore, initProxy). - OnError(bootstrap.ContinueOnError) -``` - -**效果**:Proxy 失败不影响其他模块,最后汇总所有错误 - -#### WarnOnError - 继续执行,只打印警告 - -```go -bootstrap.Register("Proxy", bootstrap.PriorityCore, initProxy). - OnError(bootstrap.WarnOnError) -``` - -**效果**:Proxy 失败只打印警告,不影响系统运行 - -**输出**: -``` - [2/5] 初始化: Proxy模块 (优先级: 50) - ⚠️ 警告: Proxy模块 (耗时: 15ms) - 连接代理服务器超时 -``` - -### 3. 上下文数据共享 - -模块之间可以通过 Context 共享数据: - -```go -// database/init.go - 存储数据库实例 -func initDatabase(ctx *bootstrap.Context) error { - db, err := sql.Open("sqlite", "config.db") - if err != nil { - return err - } - - // 存储到上下文 - ctx.Set("database", db) - return nil -} - -// trader/init.go - 获取数据库实例 -func initTrader(ctx *bootstrap.Context) error { - // 从上下文获取数据库实例 - db, ok := ctx.Get("database") - if !ok { - return fmt.Errorf("database 未初始化") - } - - database := db.(*sql.DB) - // 使用 database 初始化 trader... - return nil -} -``` - -**安全获取**: -```go -// 使用 MustGet,不存在会 panic(适合必需的依赖) -db := ctx.MustGet("database").(*sql.DB) -``` - -### 4. 链式调用 - -支持流畅的链式调用: - -```go -bootstrap.Register("Proxy", bootstrap.PriorityCore, initProxy). - EnabledIf(func(ctx *bootstrap.Context) bool { - return ctx.Config.Proxy != nil && ctx.Config.Proxy.Enabled - }). - OnError(bootstrap.WarnOnError) -``` - -### 5. 自定义错误策略 - -在 Run 时可以指定全局默认错误策略: - -```go -// 所有钩子默认使用 ContinueOnError,除非钩子自己指定了 FailFast -err := bootstrap.RunWithPolicy(ctx, bootstrap.ContinueOnError) -``` - -## 完整示例 - -### 示例1:Database 模块 - -```go -// database/init.go -package database - -import ( - "database/sql" - "nofx/bootstrap" -) - -func init() { - bootstrap.Register("Database", bootstrap.PriorityDatabase, initDatabase) -} - -func initDatabase(ctx *bootstrap.Context) error { - db, err := sql.Open("sqlite", "config.db") - if err != nil { - return err - } - - // 测试连接 - if err := db.Ping(); err != nil { - return err - } - - // 存储到上下文 - ctx.Set("database", db) - return nil -} -``` - -### 示例2:Proxy 模块(条件初始化 + 警告策略) - -```go -// proxy/init.go -package proxy - -import ( - "nofx/bootstrap" - "nofx/config" -) - -func init() { - bootstrap.Register("Proxy", bootstrap.PriorityCore, initProxy). - EnabledIf(func(ctx *bootstrap.Context) bool { - return ctx.Config.Proxy != nil && ctx.Config.Proxy.Enabled - }). - OnError(bootstrap.WarnOnError) // Proxy 失败不影响系统 -} - -func initProxy(ctx *bootstrap.Context) error { - proxyConfig := convertConfig(ctx.Config.Proxy) - - if err := InitGlobalProxyManager(proxyConfig); err != nil { - return err - } - - ctx.Set("proxy_manager", GetGlobalProxyManager()) - return nil -} -``` - -### 示例3:Trader 模块(依赖其他模块) - -```go -// trader/init.go -package trader - -import ( - "nofx/bootstrap" -) - -func init() { - bootstrap.Register("Trader", bootstrap.PriorityBusiness, initTrader) -} - -func initTrader(ctx *bootstrap.Context) error { - // 获取依赖 - db := ctx.MustGet("database").(*sql.DB) - - // 可选依赖 - var proxyMgr *proxy.ProxyManager - if pm, ok := ctx.Get("proxy_manager"); ok { - proxyMgr = pm.(*proxy.ProxyManager) - } - - // 使用依赖初始化 trader... - return nil -} -``` - -## 调试和测试 - -### 查看已注册的钩子 - -```go -hooks := bootstrap.GetRegistered() -for _, hook := range hooks { - fmt.Printf("钩子: %s, 优先级: %d\n", hook.Name, hook.Priority) -} -``` - -### 清除钩子(用于测试) - -```go -func TestMyModule(t *testing.T) { - // 清除之前注册的钩子 - bootstrap.Clear() - - // 注册测试钩子 - bootstrap.Register("Test", 10, func(ctx *bootstrap.Context) error { - return nil - }) - - // 运行测试... -} -``` - -### 统计钩子数量 - -```go -count := bootstrap.Count() -fmt.Printf("已注册 %d 个初始化钩子\n", count) -``` - -## 错误处理最佳实践 - -### 1. 关键模块使用 FailFast - -```go -// 数据库是关键依赖,失败必须停止 -bootstrap.Register("Database", bootstrap.PriorityDatabase, initDatabase) -// 默认是 FailFast,无需显式设置 -``` - -### 2. 可选模块使用 WarnOnError - -```go -// Proxy 是可选的,失败可以使用直连 -bootstrap.Register("Proxy", bootstrap.PriorityCore, initProxy). - OnError(bootstrap.WarnOnError) -``` - -### 3. 批量初始化使用 ContinueOnError - -```go -// 批量加载插件,希望看到所有失败的插件 -for _, plugin := range plugins { - bootstrap.Register(plugin.Name, 150, plugin.Init). - OnError(bootstrap.ContinueOnError) -} -``` - -## 常见问题 - -### Q1: 如何保证模块A在模块B之前初始化? - -使用优先级控制: -```go -bootstrap.Register("ModuleA", 50, initA) // 先执行 -bootstrap.Register("ModuleB", 100, initB) // 后执行 -``` - -### Q2: 如何在初始化失败时获取详细信息? - -钩子名称会自动包含在错误信息中: -``` -Error: [Proxy模块] 初始化失败: 连接代理服务器超时 -``` - -### Q3: 可以动态注册钩子吗? - -可以,但建议在 `init()` 函数中注册: -```go -// 推荐:在 init() 中注册(包加载时自动执行) -func init() { - bootstrap.Register("MyModule", 100, initModule) -} - -// 不推荐:在运行时注册(可能导致顺序问题) -func main() { - bootstrap.Register("MyModule", 100, initModule) -} -``` - -### Q4: 如何在钩子中访问命令行参数? - -通过 Context 的 Data 字段传递: -```go -// main.go -ctx := bootstrap.NewContext(cfg) -ctx.Set("args", os.Args) - -// module/init.go -func initModule(ctx *bootstrap.Context) error { - args := ctx.MustGet("args").([]string) - // 使用 args... -} -``` -## 性能考虑 - -- 钩子注册是线程安全的,但注册本身有轻微的锁开销 -- 建议在 `init()` 函数中注册,避免运行时动态注册 -- 钩子执行是顺序的,不会并发执行 -- 每个钩子的耗时会被记录并显示 - -## 许可证 - -本模块为 NOFX 项目内部模块,遵循项目整体许可证。 diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go deleted file mode 100644 index ee756113..00000000 --- a/bootstrap/bootstrap.go +++ /dev/null @@ -1,169 +0,0 @@ -package bootstrap - -import ( - "fmt" - "log" - "nofx/logger" - "sort" - "sync" - "time" -) - -// Priority 初始化优先级常量 -const ( - PriorityInfrastructure = 10 // 基础设施(日志、配置等) - PriorityDatabase = 20 // 数据库连接 - PriorityCore = 50 // 核心模块(Proxy、Market等) - PriorityBusiness = 100 // 业务模块(Trader、API等) - PriorityBackground = 200 // 后台任务 -) - -// ErrorPolicy 错误处理策略 -type ErrorPolicy int - -const ( - // FailFast 遇到错误立即停止(默认) - FailFast ErrorPolicy = iota - // ContinueOnError 继续执行,收集所有错误 - ContinueOnError - // WarnOnError 继续执行,只打印警告 - WarnOnError -) - -var ( - hooks []Hook - hooksMu sync.Mutex -) - -// Register 注册初始化钩子 -// name: 模块名称(如 "Proxy", "Database") -// priority: 优先级(建议使用常量:PriorityCore、PriorityBusiness等) -// fn: 初始化函数 -func Register(name string, priority int, fn func(*Context) error) *HookBuilder { - hooksMu.Lock() - defer hooksMu.Unlock() - - hook := Hook{ - Name: name, - Priority: priority, - Func: fn, - Enabled: nil, // 默认启用 - ErrorPolicy: FailFast, - } - - hooks = append(hooks, hook) - - return &HookBuilder{hook: &hooks[len(hooks)-1]} -} - -// Run 执行所有已注册的钩子 -func Run(ctx *Context) error { - return RunWithPolicy(ctx, FailFast) -} - -// RunWithPolicy 使用指定的默认错误策略执行所有钩子 -func RunWithPolicy(ctx *Context, defaultPolicy ErrorPolicy) error { - hooksMu.Lock() - hooksCopy := make([]Hook, len(hooks)) - copy(hooksCopy, hooks) - hooksMu.Unlock() - - if len(hooksCopy) == 0 { - log.Printf("⚠️ 没有注册任何初始化钩子") - return nil - } - - // 按优先级排序 - sort.Slice(hooksCopy, func(i, j int) bool { - return hooksCopy[i].Priority < hooksCopy[j].Priority - }) - - log.Printf("🔄 开始初始化 %d 个模块...", len(hooksCopy)) - startTime := time.Now() - - var errors []error - successCount := 0 - skippedCount := 0 - - for i, hook := range hooksCopy { - // 检查是否启用 - if hook.Enabled != nil && !hook.Enabled(ctx) { - log.Printf(" [%d/%d] 跳过: %s (条件未满足)", - i+1, len(hooksCopy), hook.Name) - skippedCount++ - continue - } - - log.Printf(" [%d/%d] 初始化: %s (优先级: %d)", - i+1, len(hooksCopy), hook.Name, hook.Priority) - - hookStart := time.Now() - err := hook.Func(ctx) - elapsed := time.Since(hookStart) - - if err != nil { - errMsg := fmt.Errorf("[%s] 初始化失败: %w", hook.Name, err) - - // 根据错误策略处理 - policy := hook.ErrorPolicy - if policy == FailFast && defaultPolicy != FailFast { - policy = defaultPolicy - } - - switch policy { - case FailFast: - log.Printf(" ❌ 失败: %s (耗时: %v)", hook.Name, elapsed) - return errMsg - case ContinueOnError: - log.Printf(" ❌ 失败: %s (耗时: %v) - 继续执行", hook.Name, elapsed) - errors = append(errors, errMsg) - case WarnOnError: - log.Printf(" ⚠️ 警告: %s (耗时: %v) - %v", hook.Name, elapsed, err) - } - } else { - log.Printf(" ✓ 完成: %s (耗时: %v)", hook.Name, elapsed) - successCount++ - } - } - - totalElapsed := time.Since(startTime) - - // 汇总结果 - if len(errors) > 0 { - logger.Log.Warnf("⚠️ 初始化完成,但有 %d 个模块失败 (总耗时: %v)", - len(errors), totalElapsed) - log.Printf("📊 统计: 成功=%d, 失败=%d, 跳过=%d", - successCount, len(errors), skippedCount) - - // 返回合并的错误 - return fmt.Errorf("以下模块初始化失败: %v", errors) - } - - log.Printf("✅ 所有模块初始化完成 (总耗时: %v)", totalElapsed) - log.Printf("📊 统计: 成功=%d, 跳过=%d", successCount, skippedCount) - return nil -} - -// GetRegistered 获取已注册的钩子列表(用于调试) -func GetRegistered() []Hook { - hooksMu.Lock() - defer hooksMu.Unlock() - - hooksCopy := make([]Hook, len(hooks)) - copy(hooksCopy, hooks) - return hooksCopy -} - -// Clear 清除所有钩子(用于测试) -func Clear() { - hooksMu.Lock() - defer hooksMu.Unlock() - hooks = nil -} - -// Count 返回已注册的钩子数量 -func Count() int { - hooksMu.Lock() - defer hooksMu.Unlock() - return len(hooks) -} diff --git a/bootstrap/context.go b/bootstrap/context.go deleted file mode 100644 index 3616d004..00000000 --- a/bootstrap/context.go +++ /dev/null @@ -1,49 +0,0 @@ -package bootstrap - -import ( - "context" - "fmt" - "nofx/config" - "sync" -) - -// Context 初始化上下文,用于在钩子之间传递数据 -type Context struct { - Config *config.Config - Data map[string]interface{} // 存储模块之间共享的数据(如数据库实例) - ctx context.Context - mu sync.RWMutex -} - -// NewContext 创建新的初始化上下文 -func NewContext(cfg *config.Config) *Context { - return &Context{ - Config: cfg, - Data: make(map[string]interface{}), - ctx: context.Background(), - } -} - -// Set 存储数据到上下文 -func (c *Context) Set(key string, value interface{}) { - c.mu.Lock() - defer c.mu.Unlock() - c.Data[key] = value -} - -// Get 从上下文获取数据 -func (c *Context) Get(key string) (interface{}, bool) { - c.mu.RLock() - defer c.mu.RUnlock() - val, ok := c.Data[key] - return val, ok -} - -// MustGet 从上下文获取数据,不存在则 panic -func (c *Context) MustGet(key string) interface{} { - val, ok := c.Get(key) - if !ok { - panic(fmt.Sprintf("context key '%s' not found", key)) - } - return val -} diff --git a/bootstrap/hook_builder.go b/bootstrap/hook_builder.go deleted file mode 100644 index 5d88d175..00000000 --- a/bootstrap/hook_builder.go +++ /dev/null @@ -1,27 +0,0 @@ -package bootstrap - -// Hook 初始化钩子 -type Hook struct { - Name string // 钩子名称(模块名) - Priority int // 优先级(越小越先执行) - Func func(*Context) error // 初始化函数 - Enabled func(*Context) bool // 条件函数,返回 false 则跳过 - ErrorPolicy ErrorPolicy // 错误处理策略 -} - -// HookBuilder 钩子构建器(用于链式调用) -type HookBuilder struct { - hook *Hook -} - -// EnabledIf 设置条件函数(链式调用) -func (b *HookBuilder) EnabledIf(fn func(*Context) bool) *HookBuilder { - b.hook.Enabled = fn - return b -} - -// OnError 设置错误处理策略(链式调用) -func (b *HookBuilder) OnError(policy ErrorPolicy) *HookBuilder { - b.hook.ErrorPolicy = policy - return b -} diff --git a/bootstrap/init_hook.go b/bootstrap/init_hook.go deleted file mode 100644 index d31283c5..00000000 --- a/bootstrap/init_hook.go +++ /dev/null @@ -1,22 +0,0 @@ -package bootstrap - -import "nofx/config" - -type InitHook func(config *config.Config) error - -var InitHooks []InitHook - -// RegisterInitHook 注册初始化钩子 -func RegisterInitHook(hook InitHook) { - InitHooks = append(InitHooks, hook) -} - -// RunInitHooks 运行所有注册的初始化钩子 -func RunInitHooks(c *config.Config) error { - for _, hookF := range InitHooks { - if err := hookF(c); err != nil { - return err - } - } - return nil -} diff --git a/config/config.go b/config/config.go index 81ff3cea..26d89cd1 100644 --- a/config/config.go +++ b/config/config.go @@ -3,7 +3,7 @@ package config import ( "encoding/json" "fmt" - "log" + "nofx/logger" "os" ) @@ -15,16 +15,7 @@ type LeverageConfig struct { // LogConfig 日志配置 type LogConfig struct { - Level string `json:"level"` // 日志级别: debug, info, warn, error (默认: info) - Telegram *TelegramConfig `json:"telegram"` // Telegram推送配置(可选) -} - -// TelegramConfig Telegram推送配置(简化版,只保留必需字段) -type TelegramConfig struct { - Enabled bool `json:"enabled"` // 是否启用(默认: false) - BotToken string `json:"bot_token"` // Bot Token - ChatID int64 `json:"chat_id"` // Chat ID - MinLevel string `json:"min_level"` // 最低日志级别,该级别及以上的日志会推送到Telegram(可选,默认: error) + Level string `json:"level"` // 日志级别: debug, info, warn, error (默认: info) } // Config 总配置 @@ -41,14 +32,14 @@ type Config struct { Leverage LeverageConfig `json:"leverage"` JWTSecret string `json:"jwt_secret"` DataKLineTime string `json:"data_k_line_time"` - Log *LogConfig `json:"log"` // 日志配置 + Log *LogConfig `json:"nofx/logger"` // 日志配置 } // LoadConfig 从文件加载配置 func LoadConfig(filename string) (*Config, error) { // 检查filename是否存在 if _, err := os.Stat(filename); os.IsNotExist(err) { - log.Printf("📄 %s不存在,使用默认配置", filename) + logger.Infof("📄 %s不存在,使用默认配置", filename) return &Config{}, nil } diff --git a/config/database.go b/config/database.go deleted file mode 100644 index 466550f3..00000000 --- a/config/database.go +++ /dev/null @@ -1,1735 +0,0 @@ -package config - -import ( - "crypto/rand" - "database/sql" - "encoding/base32" - "encoding/json" - "errors" - "fmt" - "log" - "nofx/crypto" - "nofx/market" - "os" - "slices" - "strings" - "time" - - _ "modernc.org/sqlite" -) - -// DatabaseInterface 定义了数据库实现需要提供的方法集合 -type DatabaseInterface interface { - SetCryptoService(cs *crypto.CryptoService) - CreateUser(user *User) error - GetUserByEmail(email string) (*User, error) - GetUserByID(userID string) (*User, error) - GetAllUsers() ([]string, error) - UpdateUserOTPVerified(userID string, verified bool) error - GetAIModels(userID string) ([]*AIModelConfig, error) - UpdateAIModel(userID, id string, enabled bool, apiKey, customAPIURL, customModelName string) error - GetExchanges(userID string) ([]*ExchangeConfig, error) - UpdateExchange(userID, id string, enabled bool, apiKey, secretKey string, testnet bool, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey string) error - CreateAIModel(userID, id, name, provider string, enabled bool, apiKey, customAPIURL string) error - CreateExchange(userID, id, name, typ string, enabled bool, apiKey, secretKey string, testnet bool, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey string) error - CreateTrader(trader *TraderRecord) error - GetTraders(userID string) ([]*TraderRecord, error) - UpdateTraderStatus(userID, id string, isRunning bool) error - UpdateTrader(trader *TraderRecord) error - UpdateTraderInitialBalance(userID, id string, newBalance float64) error - UpdateTraderCustomPrompt(userID, id string, customPrompt string, overrideBase bool) error - DeleteTrader(userID, id string) error - GetTraderConfig(userID, traderID string) (*TraderRecord, *AIModelConfig, *ExchangeConfig, error) - GetSystemConfig(key string) (string, error) - SetSystemConfig(key, value string) error - CreateUserSignalSource(userID, coinPoolURL, oiTopURL string) error - GetUserSignalSource(userID string) (*UserSignalSource, error) - UpdateUserSignalSource(userID, coinPoolURL, oiTopURL string) error - GetCustomCoins() []string - LoadBetaCodesFromFile(filePath string) error - ValidateBetaCode(code string) (bool, error) - UseBetaCode(code, userEmail string) error - GetBetaCodeStats() (total, used int, err error) - Close() error -} - -// Database 配置数据库 -type Database struct { - db *sql.DB - cryptoService *crypto.CryptoService -} - -// NewDatabase 创建配置数据库 -func NewDatabase(dbPath string) (*Database, error) { - db, err := sql.Open("sqlite", dbPath) - if err != nil { - return nil, fmt.Errorf("打开数据库失败: %w", err) - } - db.SetMaxOpenConns(1) - db.SetMaxIdleConns(1) - if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil { - return nil, fmt.Errorf("启用外键失败: %w", err) - } - if err := tuneSQLiteConnection(db); err != nil { - return nil, err - } - - // 🔒 启用 WAL 模式,提高并发性能和崩溃恢复能力 - // WAL (Write-Ahead Logging) 模式的优势: - // 1. 更好的并发性能:读操作不会被写操作阻塞 - // 2. 崩溃安全:即使在断电或强制终止时也能保证数据完整性 - // 3. 更快的写入:不需要每次都写入主数据库文件 - if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { - db.Close() - return nil, fmt.Errorf("启用WAL模式失败: %w", err) - } - - // 🔒 设置 synchronous=FULL 确保数据持久性 - // FULL (2) 模式: 确保数据在关键时刻完全写入磁盘 - // 配合 WAL 模式,在保证数据安全的同时获得良好性能 - if _, err := db.Exec("PRAGMA synchronous=FULL"); err != nil { - db.Close() - return nil, fmt.Errorf("设置synchronous失败: %w", err) - } - - database := &Database{db: db} - if err := database.createTables(); err != nil { - return nil, fmt.Errorf("创建表失败: %w", err) - } - if err := database.ensureBacktestRunColumns(); err != nil { - return nil, fmt.Errorf("初始化回测表结构失败: %w", err) - } - - // 确保存在默认用户(用于外键约束和默认配置种子) - if _, err := db.Exec(` - INSERT OR IGNORE INTO users (id, email, password_hash, otp_secret, otp_verified) - VALUES ('default', 'default@local', '__default__', '', 1) - `); err != nil { - return nil, fmt.Errorf("创建默认用户失败: %w", err) - } - - if err := database.initDefaultData(); err != nil { - return nil, fmt.Errorf("初始化默认数据失败: %w", err) - } - - log.Printf("✅ 数据库已启用 WAL 模式和 FULL 同步,数据持久性得到保证") - return database, nil -} - -// createTables 创建数据库表 -func (d *Database) createTables() error { - queries := []string{ - // AI模型配置表 - `CREATE TABLE IF NOT EXISTS ai_models ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL DEFAULT 'default', - name TEXT NOT NULL, - provider TEXT NOT NULL, - enabled BOOLEAN DEFAULT 0, - api_key TEXT DEFAULT '', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - )`, - - // 交易所配置表 - `CREATE TABLE IF NOT EXISTS exchanges ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL DEFAULT 'default', - name TEXT NOT NULL, - type TEXT NOT NULL, -- 'cex' or 'dex' - enabled BOOLEAN DEFAULT 0, - api_key TEXT DEFAULT '', - secret_key TEXT DEFAULT '', - testnet BOOLEAN DEFAULT 0, - -- Hyperliquid 特定字段 - hyperliquid_wallet_addr TEXT DEFAULT '', - -- Aster 特定字段 - aster_user TEXT DEFAULT '', - aster_signer TEXT DEFAULT '', - aster_private_key TEXT DEFAULT '', - -- LIGHTER 特定字段 - lighter_wallet_addr TEXT DEFAULT '', - lighter_private_key TEXT DEFAULT '', - lighter_api_key_private_key TEXT DEFAULT '', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - )`, - - // 用户信号源配置表 - `CREATE TABLE IF NOT EXISTS user_signal_sources ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - coin_pool_url TEXT DEFAULT '', - oi_top_url TEXT DEFAULT '', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - UNIQUE(user_id) - )`, - - // 交易员配置表 - `CREATE TABLE IF NOT EXISTS traders ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL DEFAULT 'default', - name TEXT NOT NULL, - ai_model_id TEXT NOT NULL, - exchange_id TEXT NOT NULL, - initial_balance REAL NOT NULL, - scan_interval_minutes INTEGER DEFAULT 3, - is_running BOOLEAN DEFAULT 0, - btc_eth_leverage INTEGER DEFAULT 5, - altcoin_leverage INTEGER DEFAULT 5, - trading_symbols TEXT DEFAULT '', - use_coin_pool BOOLEAN DEFAULT 0, - use_oi_top BOOLEAN DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - )`, - - // 用户表 - `CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - email TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - otp_secret TEXT, - otp_verified BOOLEAN DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`, - - // 系统配置表 - `CREATE TABLE IF NOT EXISTS system_config ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`, - - // 回测运行主表 - `CREATE TABLE IF NOT EXISTS backtest_runs ( - run_id TEXT PRIMARY KEY, - user_id TEXT NOT NULL DEFAULT 'default', - config_json TEXT NOT NULL DEFAULT '', - state TEXT NOT NULL DEFAULT 'created', - label TEXT DEFAULT '', - symbol_count INTEGER DEFAULT 0, - decision_tf TEXT DEFAULT '', - processed_bars INTEGER DEFAULT 0, - progress_pct REAL DEFAULT 0, - equity_last REAL DEFAULT 0, - max_drawdown_pct REAL DEFAULT 0, - liquidated BOOLEAN DEFAULT 0, - liquidation_note TEXT DEFAULT '', - prompt_template TEXT DEFAULT '', - custom_prompt TEXT DEFAULT '', - override_prompt BOOLEAN DEFAULT 0, - ai_provider TEXT DEFAULT '', - ai_model TEXT DEFAULT '', - last_error TEXT DEFAULT '', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`, - - // 回测检查点 - `CREATE TABLE IF NOT EXISTS backtest_checkpoints ( - run_id TEXT PRIMARY KEY, - payload BLOB NOT NULL, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (run_id) REFERENCES backtest_runs(run_id) ON DELETE CASCADE - )`, - - // 回测权益曲线 - `CREATE TABLE IF NOT EXISTS backtest_equity ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - run_id TEXT NOT NULL, - ts INTEGER NOT NULL, - equity REAL NOT NULL, - available REAL NOT NULL, - pnl REAL NOT NULL, - pnl_pct REAL NOT NULL, - dd_pct REAL NOT NULL, - cycle INTEGER NOT NULL, - FOREIGN KEY (run_id) REFERENCES backtest_runs(run_id) ON DELETE CASCADE - )`, - - // 回测交易记录 - `CREATE TABLE IF NOT EXISTS backtest_trades ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - run_id TEXT NOT NULL, - ts INTEGER NOT NULL, - symbol TEXT NOT NULL, - action TEXT NOT NULL, - side TEXT DEFAULT '', - qty REAL DEFAULT 0, - price REAL DEFAULT 0, - fee REAL DEFAULT 0, - slippage REAL DEFAULT 0, - order_value REAL DEFAULT 0, - realized_pnl REAL DEFAULT 0, - leverage INTEGER DEFAULT 0, - cycle INTEGER DEFAULT 0, - position_after REAL DEFAULT 0, - liquidation BOOLEAN DEFAULT 0, - note TEXT DEFAULT '', - FOREIGN KEY (run_id) REFERENCES backtest_runs(run_id) ON DELETE CASCADE - )`, - - // 回测指标 - `CREATE TABLE IF NOT EXISTS backtest_metrics ( - run_id TEXT PRIMARY KEY, - payload BLOB NOT NULL, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (run_id) REFERENCES backtest_runs(run_id) ON DELETE CASCADE - )`, - - // 回测决策日志 - `CREATE TABLE IF NOT EXISTS backtest_decisions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - run_id TEXT NOT NULL, - cycle INTEGER NOT NULL, - payload BLOB NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (run_id) REFERENCES backtest_runs(run_id) ON DELETE CASCADE - )`, - - // 索引 - `CREATE INDEX IF NOT EXISTS idx_backtest_runs_state ON backtest_runs(state, updated_at)`, - `CREATE INDEX IF NOT EXISTS idx_backtest_equity_run_ts ON backtest_equity(run_id, ts)`, - `CREATE INDEX IF NOT EXISTS idx_backtest_trades_run_ts ON backtest_trades(run_id, ts)`, - `CREATE INDEX IF NOT EXISTS idx_backtest_decisions_run_cycle ON backtest_decisions(run_id, cycle)`, - - // 内测码表 - `CREATE TABLE IF NOT EXISTS beta_codes ( - code TEXT PRIMARY KEY, - used BOOLEAN DEFAULT 0, - used_by TEXT DEFAULT '', - used_at DATETIME DEFAULT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`, - - // 触发器:自动更新 updated_at - `CREATE TRIGGER IF NOT EXISTS update_users_updated_at - AFTER UPDATE ON users - BEGIN - UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; - END`, - - `CREATE TRIGGER IF NOT EXISTS update_ai_models_updated_at - AFTER UPDATE ON ai_models - BEGIN - UPDATE ai_models SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; - END`, - - `CREATE TRIGGER IF NOT EXISTS update_exchanges_updated_at - AFTER UPDATE ON exchanges - BEGIN - UPDATE exchanges SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; - END`, - - `CREATE TRIGGER IF NOT EXISTS update_traders_updated_at - AFTER UPDATE ON traders - BEGIN - UPDATE traders SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; - END`, - - `CREATE TRIGGER IF NOT EXISTS update_user_signal_sources_updated_at - AFTER UPDATE ON user_signal_sources - BEGIN - UPDATE user_signal_sources SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; - END`, - - `CREATE TRIGGER IF NOT EXISTS update_system_config_updated_at - AFTER UPDATE ON system_config - BEGIN - UPDATE system_config SET updated_at = CURRENT_TIMESTAMP WHERE key = NEW.key; - END`, - } - - for _, query := range queries { - if _, err := d.db.Exec(query); err != nil { - return fmt.Errorf("执行SQL失败 [%s]: %w", query, err) - } - } - - // 为现有数据库添加新字段(向后兼容) - alterQueries := []string{ - `ALTER TABLE exchanges ADD COLUMN hyperliquid_wallet_addr TEXT DEFAULT ''`, - `ALTER TABLE exchanges ADD COLUMN aster_user TEXT DEFAULT ''`, - `ALTER TABLE exchanges ADD COLUMN aster_signer TEXT DEFAULT ''`, - `ALTER TABLE exchanges ADD COLUMN aster_private_key TEXT DEFAULT ''`, - `ALTER TABLE exchanges ADD COLUMN lighter_wallet_addr TEXT DEFAULT ''`, - `ALTER TABLE exchanges ADD COLUMN lighter_private_key TEXT DEFAULT ''`, - `ALTER TABLE exchanges ADD COLUMN lighter_api_key_private_key TEXT DEFAULT ''`, - `ALTER TABLE traders ADD COLUMN custom_prompt TEXT DEFAULT ''`, - `ALTER TABLE traders ADD COLUMN override_base_prompt BOOLEAN DEFAULT 0`, - `ALTER TABLE traders ADD COLUMN is_cross_margin BOOLEAN DEFAULT 1`, // 默认为全仓模式 - `ALTER TABLE traders ADD COLUMN use_default_coins BOOLEAN DEFAULT 1`, // 默认使用默认币种 - `ALTER TABLE traders ADD COLUMN custom_coins TEXT DEFAULT ''`, // 自定义币种列表(JSON格式) - `ALTER TABLE traders ADD COLUMN btc_eth_leverage INTEGER DEFAULT 5`, // BTC/ETH杠杆倍数 - `ALTER TABLE traders ADD COLUMN altcoin_leverage INTEGER DEFAULT 5`, // 山寨币杠杆倍数 - `ALTER TABLE traders ADD COLUMN trading_symbols TEXT DEFAULT ''`, // 交易币种,逗号分隔 - `ALTER TABLE traders ADD COLUMN use_coin_pool BOOLEAN DEFAULT 0`, // 是否使用COIN POOL信号源 - `ALTER TABLE traders ADD COLUMN use_oi_top BOOLEAN DEFAULT 0`, // 是否使用OI TOP信号源 - `ALTER TABLE traders ADD COLUMN system_prompt_template TEXT DEFAULT 'default'`, // 系统提示词模板名称 - `ALTER TABLE ai_models ADD COLUMN custom_api_url TEXT DEFAULT ''`, // 自定义API地址 - `ALTER TABLE ai_models ADD COLUMN custom_model_name TEXT DEFAULT ''`, // 自定义模型名称 - } - - for _, query := range alterQueries { - // 忽略已存在字段的错误 - d.db.Exec(query) - } - - // 检查是否需要迁移exchanges表的主键结构 - err := d.migrateExchangesTable() - if err != nil { - log.Printf("⚠️ 迁移exchanges表失败: %v", err) - } - - // 修复traders表的外键约束问题 - err = d.migrateTradersTable() - if err != nil { - log.Printf("⚠️ 迁移traders表失败: %v", err) - } - - return nil -} - -func (d *Database) ensureBacktestRunColumns() error { - addColumn := func(table, column, definition string) error { - exists, err := columnExists(d.db, table, column) - if err != nil { - return err - } - if exists { - return nil - } - _, err = d.db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition)) - return err - } - if err := addColumn("backtest_runs", "label", "TEXT DEFAULT ''"); err != nil { - return err - } - if err := addColumn("backtest_runs", "last_error", "TEXT DEFAULT ''"); err != nil { - return err - } - if err := addColumn("backtest_trades", "leverage", "INTEGER DEFAULT 0"); err != nil { - return err - } - return nil -} - -func columnExists(db *sql.DB, table, column string) (bool, error) { - rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table)) - if err != nil { - return false, err - } - defer rows.Close() - for rows.Next() { - var ( - cid int - name string - ctype string - notnull int - dfltValue any - primaryKey int - ) - if err := rows.Scan(&cid, &name, &ctype, ¬null, &dfltValue, &primaryKey); err != nil { - return false, err - } - if name == column { - return true, nil - } - } - return false, rows.Err() -} - -func tuneSQLiteConnection(db *sql.DB) error { - if db == nil { - return fmt.Errorf("db is nil") - } - statements := []string{ - `PRAGMA busy_timeout = 5000`, - `PRAGMA journal_mode = WAL`, - `PRAGMA synchronous = NORMAL`, - } - for _, stmt := range statements { - if _, err := db.Exec(stmt); err != nil { - return fmt.Errorf("执行 %s 失败: %w", stmt, err) - } - } - return nil -} - -// initDefaultData 初始化默认数据 -func (d *Database) initDefaultData() error { - // 初始化AI模型(使用default用户) - aiModels := []struct { - id, name, provider string - }{ - {"deepseek", "DeepSeek", "deepseek"}, - {"qwen", "Qwen", "qwen"}, - } - - for _, model := range aiModels { - _, err := d.db.Exec(` - INSERT OR IGNORE INTO ai_models (id, user_id, name, provider, enabled) - VALUES (?, 'default', ?, ?, 0) - `, model.id, model.name, model.provider) - if err != nil { - return fmt.Errorf("初始化AI模型失败: %w", err) - } - } - - // 初始化交易所(使用default用户) - exchanges := []struct { - id, name, typ string - }{ - {"binance", "Binance Futures", "binance"}, - {"bybit", "Bybit Futures", "bybit"}, - {"hyperliquid", "Hyperliquid", "hyperliquid"}, - {"aster", "Aster DEX", "aster"}, - {"lighter", "LIGHTER DEX", "lighter"}, - } - - for _, exchange := range exchanges { - _, err := d.db.Exec(` - INSERT OR IGNORE INTO exchanges (id, user_id, name, type, enabled) - VALUES (?, 'default', ?, ?, 0) - `, exchange.id, exchange.name, exchange.typ) - if err != nil { - return fmt.Errorf("初始化交易所失败: %w", err) - } - } - - // 初始化系统配置 - 创建所有字段,设置默认值,后续由config.json同步更新 - systemConfigs := map[string]string{ - "beta_mode": "false", // 默认关闭内测模式 - "api_server_port": "8080", // 默认API端口 - "use_default_coins": "true", // 默认使用内置币种列表 - "default_coins": `["BTCUSDT","ETHUSDT","SOLUSDT","BNBUSDT","XRPUSDT","DOGEUSDT","ADAUSDT","HYPEUSDT"]`, // 默认币种列表(JSON格式) - "max_daily_loss": "10.0", // 最大日损失百分比 - "max_drawdown": "20.0", // 最大回撤百分比 - "stop_trading_minutes": "60", // 停止交易时间(分钟) - "btc_eth_leverage": "5", // BTC/ETH杠杆倍数 - "altcoin_leverage": "5", // 山寨币杠杆倍数 - "jwt_secret": "", // JWT密钥,默认为空,由config.json或系统生成 - "registration_enabled": "true", // 默认允许注册 - } - - for key, value := range systemConfigs { - _, err := d.db.Exec(` - INSERT OR IGNORE INTO system_config (key, value) - VALUES (?, ?) - `, key, value) - if err != nil { - return fmt.Errorf("初始化系统配置失败: %w", err) - } - } - - return nil -} - -// migrateExchangesTable 迁移exchanges表支持多用户 -func (d *Database) migrateExchangesTable() error { - // 检查是否已经迁移过 - var count int - err := d.db.QueryRow(` - SELECT COUNT(*) FROM sqlite_master - WHERE type='table' AND name='exchanges_new' - `).Scan(&count) - if err != nil { - return err - } - - // 如果已经迁移过,直接返回 - if count > 0 { - return nil - } - - log.Printf("🔄 开始迁移exchanges表...") - - // 创建新的exchanges表,使用复合主键 - _, err = d.db.Exec(` - CREATE TABLE exchanges_new ( - id TEXT NOT NULL, - user_id TEXT NOT NULL DEFAULT 'default', - name TEXT NOT NULL, - type TEXT NOT NULL, - enabled BOOLEAN DEFAULT 0, - api_key TEXT DEFAULT '', - secret_key TEXT DEFAULT '', - testnet BOOLEAN DEFAULT 0, - hyperliquid_wallet_addr TEXT DEFAULT '', - aster_user TEXT DEFAULT '', - aster_signer TEXT DEFAULT '', - aster_private_key TEXT DEFAULT '', - lighter_wallet_addr TEXT DEFAULT '', - lighter_private_key TEXT DEFAULT '', - lighter_api_key_private_key TEXT DEFAULT '', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (id, user_id), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - `) - if err != nil { - return fmt.Errorf("创建新exchanges表失败: %w", err) - } - - // 复制数据到新表 - _, err = d.db.Exec(` - INSERT INTO exchanges_new - SELECT * FROM exchanges - `) - if err != nil { - return fmt.Errorf("复制数据失败: %w", err) - } - - // 删除旧表 - _, err = d.db.Exec(`DROP TABLE exchanges`) - if err != nil { - return fmt.Errorf("删除旧表失败: %w", err) - } - - // 重命名新表 - _, err = d.db.Exec(`ALTER TABLE exchanges_new RENAME TO exchanges`) - if err != nil { - return fmt.Errorf("重命名表失败: %w", err) - } - - // 重新创建触发器 - _, err = d.db.Exec(` - CREATE TRIGGER IF NOT EXISTS update_exchanges_updated_at - AFTER UPDATE ON exchanges - BEGIN - UPDATE exchanges SET updated_at = CURRENT_TIMESTAMP - WHERE id = NEW.id AND user_id = NEW.user_id; - END - `) - if err != nil { - return fmt.Errorf("创建触发器失败: %w", err) - } - - log.Printf("✅ exchanges表迁移完成") - return nil -} - -// migrateTradersTable 迁移traders表,移除外键约束 -func (d *Database) migrateTradersTable() error { - // 检查traders表是否存在外键约束(通过尝试创建一个测试记录来判断) - // 如果表已经没有外键约束,则跳过迁移 - var tableSQL string - err := d.db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='traders'`).Scan(&tableSQL) - if err != nil { - // 表不存在,无需迁移 - return nil - } - - // 检查是否包含 FOREIGN KEY (exchange_id) 或 FOREIGN KEY (ai_model_id) - if !strings.Contains(tableSQL, "FOREIGN KEY (exchange_id)") && !strings.Contains(tableSQL, "FOREIGN KEY (ai_model_id)") { - // 已经没有这些外键约束,无需迁移 - return nil - } - - log.Printf("🔄 开始迁移traders表,移除外键约束...") - - // 创建新的traders表,不包含exchange_id和ai_model_id的外键约束 - _, err = d.db.Exec(` - CREATE TABLE traders_new ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL DEFAULT 'default', - name TEXT NOT NULL, - ai_model_id TEXT NOT NULL, - exchange_id TEXT NOT NULL, - initial_balance REAL NOT NULL, - scan_interval_minutes INTEGER DEFAULT 3, - is_running BOOLEAN DEFAULT 0, - btc_eth_leverage INTEGER DEFAULT 5, - altcoin_leverage INTEGER DEFAULT 5, - trading_symbols TEXT DEFAULT '', - use_coin_pool BOOLEAN DEFAULT 0, - use_oi_top BOOLEAN DEFAULT 0, - custom_prompt TEXT DEFAULT '', - override_base_prompt BOOLEAN DEFAULT 0, - system_prompt_template TEXT DEFAULT 'default', - is_cross_margin BOOLEAN DEFAULT 1, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - `) - if err != nil { - return fmt.Errorf("创建新traders表失败: %w", err) - } - - // 复制数据到新表 - _, err = d.db.Exec(` - INSERT INTO traders_new (id, user_id, name, ai_model_id, exchange_id, initial_balance, - scan_interval_minutes, is_running, btc_eth_leverage, altcoin_leverage, trading_symbols, - use_coin_pool, use_oi_top, custom_prompt, override_base_prompt, system_prompt_template, - is_cross_margin, created_at, updated_at) - SELECT id, user_id, name, ai_model_id, exchange_id, initial_balance, - scan_interval_minutes, is_running, - COALESCE(btc_eth_leverage, 5), COALESCE(altcoin_leverage, 5), - COALESCE(trading_symbols, ''), COALESCE(use_coin_pool, 0), COALESCE(use_oi_top, 0), - COALESCE(custom_prompt, ''), COALESCE(override_base_prompt, 0), - COALESCE(system_prompt_template, 'default'), COALESCE(is_cross_margin, 1), - created_at, updated_at - FROM traders - `) - if err != nil { - // 如果复制失败,删除新表 - d.db.Exec(`DROP TABLE traders_new`) - return fmt.Errorf("复制traders数据失败: %w", err) - } - - // 删除旧表 - _, err = d.db.Exec(`DROP TABLE traders`) - if err != nil { - return fmt.Errorf("删除旧traders表失败: %w", err) - } - - // 重命名新表 - _, err = d.db.Exec(`ALTER TABLE traders_new RENAME TO traders`) - if err != nil { - return fmt.Errorf("重命名traders表失败: %w", err) - } - - log.Printf("✅ traders表迁移完成,已移除外键约束") - return nil -} - -// User 用户配置 -type User struct { - ID string `json:"id"` - Email string `json:"email"` - PasswordHash string `json:"-"` // 不返回到前端 - OTPSecret string `json:"-"` // 不返回到前端 - OTPVerified bool `json:"otp_verified"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// AIModelConfig AI模型配置 -type AIModelConfig struct { - ID string `json:"id"` - UserID string `json:"user_id"` - Name string `json:"name"` - Provider string `json:"provider"` - Enabled bool `json:"enabled"` - APIKey string `json:"apiKey"` - CustomAPIURL string `json:"customApiUrl"` - CustomModelName string `json:"customModelName"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// ExchangeConfig 交易所配置 -type ExchangeConfig struct { - ID string `json:"id"` - UserID string `json:"user_id"` - Name string `json:"name"` - Type string `json:"type"` - Enabled bool `json:"enabled"` - APIKey string `json:"apiKey"` // For Binance: API Key; For Hyperliquid: Agent Private Key (should have ~0 balance) - SecretKey string `json:"secretKey"` // For Binance: Secret Key; Not used for Hyperliquid - Testnet bool `json:"testnet"` - // Hyperliquid Agent Wallet configuration (following official best practices) - // Reference: https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/nonces-and-api-wallets - HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Main Wallet Address (holds funds, never expose private key) - // Aster 特定字段 - AsterUser string `json:"asterUser"` - AsterSigner string `json:"asterSigner"` - AsterPrivateKey string `json:"asterPrivateKey"` - // LIGHTER 特定字段 - LighterWalletAddr string `json:"lighterWalletAddr"` // Ethereum 钱包地址 (L1) - LighterPrivateKey string `json:"lighterPrivateKey"` // L1私钥(用于识别账户) - LighterAPIKeyPrivateKey string `json:"lighterAPIKeyPrivateKey"` // API Key私钥(40字节,用于签名交易) - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// TraderRecord 交易员配置(数据库实体) -type TraderRecord struct { - ID string `json:"id"` - UserID string `json:"user_id"` - Name string `json:"name"` - AIModelID string `json:"ai_model_id"` - ExchangeID string `json:"exchange_id"` - InitialBalance float64 `json:"initial_balance"` - ScanIntervalMinutes int `json:"scan_interval_minutes"` - IsRunning bool `json:"is_running"` - BTCETHLeverage int `json:"btc_eth_leverage"` // BTC/ETH杠杆倍数 - AltcoinLeverage int `json:"altcoin_leverage"` // 山寨币杠杆倍数 - TradingSymbols string `json:"trading_symbols"` // 交易币种,逗号分隔 - UseCoinPool bool `json:"use_coin_pool"` // 是否使用COIN POOL信号源 - UseOITop bool `json:"use_oi_top"` // 是否使用OI TOP信号源 - CustomPrompt string `json:"custom_prompt"` // 自定义交易策略prompt - OverrideBasePrompt bool `json:"override_base_prompt"` // 是否覆盖基础prompt - SystemPromptTemplate string `json:"system_prompt_template"` // 系统提示词模板名称 - IsCrossMargin bool `json:"is_cross_margin"` // 是否为全仓模式(true=全仓,false=逐仓) - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// UserSignalSource 用户信号源配置 -type UserSignalSource struct { - ID int `json:"id"` - UserID string `json:"user_id"` - CoinPoolURL string `json:"coin_pool_url"` - OITopURL string `json:"oi_top_url"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// GenerateOTPSecret 生成OTP密钥 -func GenerateOTPSecret() (string, error) { - secret := make([]byte, 20) - _, err := rand.Read(secret) - if err != nil { - return "", err - } - return base32.StdEncoding.EncodeToString(secret), nil -} - -// CreateUser 创建用户 -func (d *Database) CreateUser(user *User) error { - _, err := d.db.Exec(` - INSERT INTO users (id, email, password_hash, otp_secret, otp_verified) - VALUES (?, ?, ?, ?, ?) - `, user.ID, user.Email, user.PasswordHash, user.OTPSecret, user.OTPVerified) - return err -} - -// EnsureAdminUser 确保admin用户存在(用于管理员模式) -func (d *Database) EnsureAdminUser() error { - // 检查admin用户是否已存在 - var count int - err := d.db.QueryRow(`SELECT COUNT(*) FROM users WHERE id = 'admin'`).Scan(&count) - if err != nil { - return err - } - - // 如果已存在,直接返回 - if count > 0 { - return nil - } - - // 创建admin用户(密码为空,因为管理员模式下不需要密码) - adminUser := &User{ - ID: "admin", - Email: "admin@localhost", - PasswordHash: "", // 管理员模式下不使用密码 - OTPSecret: "", - OTPVerified: true, - } - - return d.CreateUser(adminUser) -} - -// GetUserByEmail 通过邮箱获取用户 -func (d *Database) GetUserByEmail(email string) (*User, error) { - var user User - var createdAt, updatedAt string - err := d.db.QueryRow(` - SELECT id, email, password_hash, otp_secret, otp_verified, created_at, updated_at - FROM users WHERE email = ? - `, email).Scan( - &user.ID, &user.Email, &user.PasswordHash, &user.OTPSecret, - &user.OTPVerified, &createdAt, &updatedAt, - ) - if err != nil { - return nil, err - } - user.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) - user.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) - return &user, nil -} - -// GetUserByID 通过ID获取用户 -func (d *Database) GetUserByID(userID string) (*User, error) { - var user User - var createdAt, updatedAt string - err := d.db.QueryRow(` - SELECT id, email, password_hash, otp_secret, otp_verified, created_at, updated_at - FROM users WHERE id = ? - `, userID).Scan( - &user.ID, &user.Email, &user.PasswordHash, &user.OTPSecret, - &user.OTPVerified, &createdAt, &updatedAt, - ) - if err != nil { - return nil, err - } - user.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) - user.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) - return &user, nil -} - -// GetAllUsers 获取所有用户ID列表 -func (d *Database) GetAllUsers() ([]string, error) { - rows, err := d.db.Query(`SELECT id FROM users ORDER BY id`) - if err != nil { - return nil, err - } - defer rows.Close() - - var userIDs []string - for rows.Next() { - var userID string - if err := rows.Scan(&userID); err != nil { - return nil, err - } - userIDs = append(userIDs, userID) - } - return userIDs, nil -} - -// UpdateUserOTPVerified 更新用户OTP验证状态 -func (d *Database) UpdateUserOTPVerified(userID string, verified bool) error { - _, err := d.db.Exec(`UPDATE users SET otp_verified = ? WHERE id = ?`, verified, userID) - return err -} - -// UpdateUserPassword 更新用户密码 -func (d *Database) UpdateUserPassword(userID, passwordHash string) error { - _, err := d.db.Exec(` - UPDATE users - SET password_hash = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? - `, passwordHash, userID) - return err -} - -// GetAIModels 获取用户的AI模型配置 -func (d *Database) GetAIModels(userID string) ([]*AIModelConfig, error) { - rows, err := d.db.Query(` - SELECT id, user_id, name, provider, enabled, api_key, - COALESCE(custom_api_url, '') as custom_api_url, - COALESCE(custom_model_name, '') as custom_model_name, - created_at, updated_at - FROM ai_models WHERE user_id = ? ORDER BY id - `, userID) - if err != nil { - return nil, err - } - defer rows.Close() - - // 初始化为空切片而不是nil,确保JSON序列化为[]而不是null - models := make([]*AIModelConfig, 0) - for rows.Next() { - var model AIModelConfig - var createdAt, updatedAt string - err := rows.Scan( - &model.ID, &model.UserID, &model.Name, &model.Provider, - &model.Enabled, &model.APIKey, &model.CustomAPIURL, &model.CustomModelName, - &createdAt, &updatedAt, - ) - if err != nil { - return nil, err - } - // 解析时间字符串 - model.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) - model.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) - // 解密API Key - model.APIKey = d.decryptSensitiveData(model.APIKey) - models = append(models, &model) - } - - return models, nil -} - -// GetAIModel 根据模型ID和用户ID获取单个AI模型配置,若用户下不存在则回退到default用户。 -func (d *Database) GetAIModel(userID, modelID string) (*AIModelConfig, error) { - if modelID == "" { - return nil, fmt.Errorf("模型ID不能为空") - } - - candidates := []string{} - if userID != "" { - candidates = append(candidates, userID) - } - if userID != "default" { - candidates = append(candidates, "default") - } - if len(candidates) == 0 { - candidates = append(candidates, "default") - } - - for _, uid := range candidates { - var model AIModelConfig - var createdAt, updatedAt string - err := d.db.QueryRow(` - SELECT id, user_id, name, provider, enabled, api_key, - COALESCE(custom_api_url, ''), COALESCE(custom_model_name, ''), created_at, updated_at - FROM ai_models - WHERE user_id = ? AND id = ? - LIMIT 1 - `, uid, modelID).Scan( - &model.ID, - &model.UserID, - &model.Name, - &model.Provider, - &model.Enabled, - &model.APIKey, - &model.CustomAPIURL, - &model.CustomModelName, - &createdAt, - &updatedAt, - ) - if err == nil { - // 解析时间字符串 - model.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) - model.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) - // 解密API Key(与 GetAIModels 行为保持一致) - model.APIKey = d.decryptSensitiveData(model.APIKey) - return &model, nil - } - if !errors.Is(err, sql.ErrNoRows) { - return nil, err - } - } - - return nil, sql.ErrNoRows -} - -// GetDefaultAIModel 获取指定用户(或默认用户)的首个启用的AI模型。 -func (d *Database) GetDefaultAIModel(userID string) (*AIModelConfig, error) { - if userID == "" { - userID = "default" - } - model, err := d.firstEnabledAIModel(userID) - if err == nil { - return model, nil - } - if !errors.Is(err, sql.ErrNoRows) { - return nil, err - } - if userID != "default" { - return d.firstEnabledAIModel("default") - } - return nil, fmt.Errorf("请先在系统中配置可用的AI模型") -} - -func (d *Database) firstEnabledAIModel(userID string) (*AIModelConfig, error) { - var model AIModelConfig - var createdAt, updatedAt string - err := d.db.QueryRow(` - SELECT id, user_id, name, provider, enabled, api_key, - COALESCE(custom_api_url, ''), COALESCE(custom_model_name, ''), created_at, updated_at - FROM ai_models - WHERE user_id = ? AND enabled = 1 - ORDER BY datetime(updated_at) DESC, id ASC - LIMIT 1 - `, userID).Scan( - &model.ID, - &model.UserID, - &model.Name, - &model.Provider, - &model.Enabled, - &model.APIKey, - &model.CustomAPIURL, - &model.CustomModelName, - &createdAt, - &updatedAt, - ) - if err != nil { - return nil, err - } - // 解析时间字符串 - model.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) - model.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) - // 解密API Key,避免上层拿到加密串导致下游认证失败 - model.APIKey = d.decryptSensitiveData(model.APIKey) - return &model, nil -} - -// UpdateAIModel 更新AI模型配置,如果不存在则创建用户特定配置 -func (d *Database) UpdateAIModel(userID, id string, enabled bool, apiKey, customAPIURL, customModelName string) error { - // 先尝试精确匹配 ID(新版逻辑,支持多个相同 provider 的模型) - var existingID string - err := d.db.QueryRow(` - SELECT id FROM ai_models WHERE user_id = ? AND id = ? LIMIT 1 - `, userID, id).Scan(&existingID) - - if err == nil { - // 找到了现有配置(精确匹配 ID),更新它 - encryptedAPIKey := d.encryptSensitiveData(apiKey) - _, err = d.db.Exec(` - UPDATE ai_models SET enabled = ?, api_key = ?, custom_api_url = ?, custom_model_name = ?, updated_at = datetime('now') - WHERE id = ? AND user_id = ? - `, enabled, encryptedAPIKey, customAPIURL, customModelName, existingID, userID) - return err - } - - // ID 不存在,尝试兼容旧逻辑:将 id 作为 provider 查找 - provider := id - err = d.db.QueryRow(` - SELECT id FROM ai_models WHERE user_id = ? AND provider = ? LIMIT 1 - `, userID, provider).Scan(&existingID) - - if err == nil { - // 找到了现有配置(通过 provider 匹配,兼容旧版),更新它 - log.Printf("⚠️ 使用旧版 provider 匹配更新模型: %s -> %s", provider, existingID) - encryptedAPIKey := d.encryptSensitiveData(apiKey) - _, err = d.db.Exec(` - UPDATE ai_models SET enabled = ?, api_key = ?, custom_api_url = ?, custom_model_name = ?, updated_at = datetime('now') - WHERE id = ? AND user_id = ? - `, enabled, encryptedAPIKey, customAPIURL, customModelName, existingID, userID) - return err - } - - // 没有找到任何现有配置,创建新的 - // 推断 provider(从 id 中提取,或者直接使用 id) - if provider == id && (provider == "deepseek" || provider == "qwen") { - // id 本身就是 provider - provider = id - } else { - // 从 id 中提取 provider(假设格式是 userID_provider 或 timestamp_userID_provider) - parts := strings.Split(id, "_") - if len(parts) >= 2 { - provider = parts[len(parts)-1] // 取最后一部分作为 provider - } else { - provider = id - } - } - - // 获取模型的基本信息 - var name string - err = d.db.QueryRow(` - SELECT name FROM ai_models WHERE provider = ? LIMIT 1 - `, provider).Scan(&name) - if err != nil { - // 如果找不到基本信息,使用默认值 - if provider == "deepseek" { - name = "DeepSeek AI" - } else if provider == "qwen" { - name = "Qwen AI" - } else { - name = provider + " AI" - } - } - - // 如果传入的 ID 已经是完整格式(如 "admin_deepseek_custom1"),直接使用 - // 否则生成新的 ID - newModelID := id - if id == provider { - // id 就是 provider,生成新的用户特定 ID - newModelID = fmt.Sprintf("%s_%s", userID, provider) - } - - log.Printf("✓ 创建新的 AI 模型配置: ID=%s, Provider=%s, Name=%s", newModelID, provider, name) - encryptedAPIKey := d.encryptSensitiveData(apiKey) - _, err = d.db.Exec(` - INSERT INTO ai_models (id, user_id, name, provider, enabled, api_key, custom_api_url, custom_model_name, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) - `, newModelID, userID, name, provider, enabled, encryptedAPIKey, customAPIURL, customModelName) - - return err -} - -// GetExchanges 获取用户的交易所配置 -func (d *Database) GetExchanges(userID string) ([]*ExchangeConfig, error) { - rows, err := d.db.Query(` - SELECT id, user_id, name, type, enabled, api_key, secret_key, testnet, - COALESCE(hyperliquid_wallet_addr, '') as hyperliquid_wallet_addr, - COALESCE(aster_user, '') as aster_user, - COALESCE(aster_signer, '') as aster_signer, - COALESCE(aster_private_key, '') as aster_private_key, - COALESCE(lighter_wallet_addr, '') as lighter_wallet_addr, - COALESCE(lighter_private_key, '') as lighter_private_key, - COALESCE(lighter_api_key_private_key, '') as lighter_api_key_private_key, - created_at, updated_at - FROM exchanges WHERE user_id = ? ORDER BY id - `, userID) - if err != nil { - return nil, err - } - defer rows.Close() - - // 初始化为空切片而不是nil,确保JSON序列化为[]而不是null - exchanges := make([]*ExchangeConfig, 0) - for rows.Next() { - var exchange ExchangeConfig - var createdAt, updatedAt string - err := rows.Scan( - &exchange.ID, &exchange.UserID, &exchange.Name, &exchange.Type, - &exchange.Enabled, &exchange.APIKey, &exchange.SecretKey, &exchange.Testnet, - &exchange.HyperliquidWalletAddr, &exchange.AsterUser, - &exchange.AsterSigner, &exchange.AsterPrivateKey, - &exchange.LighterWalletAddr, &exchange.LighterPrivateKey, - &exchange.LighterAPIKeyPrivateKey, - &createdAt, &updatedAt, - ) - if err != nil { - return nil, err - } - - // 解析时间字符串 - exchange.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) - exchange.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) - - // 解密敏感字段 - exchange.APIKey = d.decryptSensitiveData(exchange.APIKey) - exchange.SecretKey = d.decryptSensitiveData(exchange.SecretKey) - exchange.AsterPrivateKey = d.decryptSensitiveData(exchange.AsterPrivateKey) - exchange.LighterPrivateKey = d.decryptSensitiveData(exchange.LighterPrivateKey) - exchange.LighterAPIKeyPrivateKey = d.decryptSensitiveData(exchange.LighterAPIKeyPrivateKey) - - exchanges = append(exchanges, &exchange) - } - - return exchanges, nil -} - -// UpdateExchange 更新交易所配置,如果不存在则创建用户特定配置 -// 🔒 安全特性:空值不会覆盖现有的敏感字段(api_key, secret_key, aster_private_key, lighter_private_key) -func (d *Database) UpdateExchange(userID, id string, enabled bool, apiKey, secretKey string, testnet bool, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey string) error { - log.Printf("🔧 UpdateExchange: userID=%s, id=%s, enabled=%v", userID, id, enabled) - - // 构建动态 UPDATE SET 子句 - // 基础字段:总是更新 - setClauses := []string{ - "enabled = ?", - "testnet = ?", - "hyperliquid_wallet_addr = ?", - "aster_user = ?", - "aster_signer = ?", - "lighter_wallet_addr = ?", - "updated_at = datetime('now')", - } - args := []interface{}{enabled, testnet, hyperliquidWalletAddr, asterUser, asterSigner, lighterWalletAddr} - - // 🔒 敏感字段:只在非空时更新(保护现有数据) - if apiKey != "" { - encryptedAPIKey := d.encryptSensitiveData(apiKey) - setClauses = append(setClauses, "api_key = ?") - args = append(args, encryptedAPIKey) - } - - if secretKey != "" { - encryptedSecretKey := d.encryptSensitiveData(secretKey) - setClauses = append(setClauses, "secret_key = ?") - args = append(args, encryptedSecretKey) - } - - if asterPrivateKey != "" { - encryptedAsterPrivateKey := d.encryptSensitiveData(asterPrivateKey) - setClauses = append(setClauses, "aster_private_key = ?") - args = append(args, encryptedAsterPrivateKey) - } - - if lighterPrivateKey != "" { - encryptedLighterPrivateKey := d.encryptSensitiveData(lighterPrivateKey) - setClauses = append(setClauses, "lighter_private_key = ?") - args = append(args, encryptedLighterPrivateKey) - } - - // WHERE 条件 - args = append(args, id, userID) - - // 构建完整的 UPDATE 语句 - query := fmt.Sprintf(` - UPDATE exchanges SET %s - WHERE id = ? AND user_id = ? - `, strings.Join(setClauses, ", ")) - - // 执行更新 - result, err := d.db.Exec(query, args...) - if err != nil { - log.Printf("❌ UpdateExchange: 更新失败: %v", err) - return err - } - - // 检查是否有行被更新 - rowsAffected, err := result.RowsAffected() - if err != nil { - log.Printf("❌ UpdateExchange: 获取影响行数失败: %v", err) - return err - } - - log.Printf("📊 UpdateExchange: 影响行数 = %d", rowsAffected) - - // 如果没有行被更新,说明用户没有这个交易所的配置,需要创建 - if rowsAffected == 0 { - log.Printf("💡 UpdateExchange: 没有现有记录,创建新记录") - - // 根据交易所ID确定基本信息 - var name, typ string - if id == "binance" { - name = "Binance Futures" - typ = "cex" - } else if id == "bybit" { - name = "Bybit Futures" - typ = "cex" - } else if id == "hyperliquid" { - name = "Hyperliquid" - typ = "dex" - } else if id == "aster" { - name = "Aster DEX" - typ = "dex" - } else if id == "lighter" { - name = "LIGHTER DEX" - typ = "dex" - } else { - name = id + " Exchange" - typ = "cex" - } - - log.Printf("🆕 UpdateExchange: 创建新记录 ID=%s, name=%s, type=%s", id, name, typ) - - // 加密敏感字段 - encryptedAPIKey := d.encryptSensitiveData(apiKey) - encryptedSecretKey := d.encryptSensitiveData(secretKey) - encryptedAsterPrivateKey := d.encryptSensitiveData(asterPrivateKey) - encryptedLighterPrivateKey := d.encryptSensitiveData(lighterPrivateKey) - - // 创建用户特定的配置,使用原始的交易所ID - _, err = d.db.Exec(` - INSERT INTO exchanges (id, user_id, name, type, enabled, api_key, secret_key, testnet, - hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key, - lighter_wallet_addr, lighter_private_key, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) - `, id, userID, name, typ, enabled, encryptedAPIKey, encryptedSecretKey, testnet, hyperliquidWalletAddr, asterUser, asterSigner, encryptedAsterPrivateKey, lighterWalletAddr, encryptedLighterPrivateKey) - - if err != nil { - log.Printf("❌ UpdateExchange: 创建记录失败: %v", err) - } else { - log.Printf("✅ UpdateExchange: 创建记录成功") - } - return err - } - - log.Printf("✅ UpdateExchange: 更新现有记录成功") - return nil -} - -// CreateAIModel 创建AI模型配置 -func (d *Database) CreateAIModel(userID, id, name, provider string, enabled bool, apiKey, customAPIURL string) error { - _, err := d.db.Exec(` - INSERT OR IGNORE INTO ai_models (id, user_id, name, provider, enabled, api_key, custom_api_url) - VALUES (?, ?, ?, ?, ?, ?, ?) - `, id, userID, name, provider, enabled, apiKey, customAPIURL) - return err -} - -// CreateExchange 创建交易所配置 -func (d *Database) CreateExchange(userID, id, name, typ string, enabled bool, apiKey, secretKey string, testnet bool, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey string) error { - // 加密敏感字段 - encryptedAPIKey := d.encryptSensitiveData(apiKey) - encryptedSecretKey := d.encryptSensitiveData(secretKey) - encryptedAsterPrivateKey := d.encryptSensitiveData(asterPrivateKey) - - _, err := d.db.Exec(` - INSERT OR IGNORE INTO exchanges (id, user_id, name, type, enabled, api_key, secret_key, testnet, hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key, lighter_wallet_addr, lighter_private_key) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', '') - `, id, userID, name, typ, enabled, encryptedAPIKey, encryptedSecretKey, testnet, hyperliquidWalletAddr, asterUser, asterSigner, encryptedAsterPrivateKey) - return err -} - -// CreateTrader 创建交易员 -func (d *Database) CreateTrader(trader *TraderRecord) error { - _, err := d.db.Exec(` - INSERT INTO traders (id, user_id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, is_running, btc_eth_leverage, altcoin_leverage, trading_symbols, use_coin_pool, use_oi_top, custom_prompt, override_base_prompt, system_prompt_template, is_cross_margin) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, trader.ID, trader.UserID, trader.Name, trader.AIModelID, trader.ExchangeID, trader.InitialBalance, trader.ScanIntervalMinutes, trader.IsRunning, trader.BTCETHLeverage, trader.AltcoinLeverage, trader.TradingSymbols, trader.UseCoinPool, trader.UseOITop, trader.CustomPrompt, trader.OverrideBasePrompt, trader.SystemPromptTemplate, trader.IsCrossMargin) - return err -} - -// GetTraders 获取用户的交易员 -func (d *Database) GetTraders(userID string) ([]*TraderRecord, error) { - rows, err := d.db.Query(` - SELECT id, user_id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, is_running, - COALESCE(btc_eth_leverage, 5) as btc_eth_leverage, COALESCE(altcoin_leverage, 5) as altcoin_leverage, - COALESCE(trading_symbols, '') as trading_symbols, - COALESCE(use_coin_pool, 0) as use_coin_pool, COALESCE(use_oi_top, 0) as use_oi_top, - COALESCE(custom_prompt, '') as custom_prompt, COALESCE(override_base_prompt, 0) as override_base_prompt, - COALESCE(system_prompt_template, 'default') as system_prompt_template, - COALESCE(is_cross_margin, 1) as is_cross_margin, created_at, updated_at - FROM traders WHERE user_id = ? ORDER BY created_at DESC - `, userID) - if err != nil { - return nil, err - } - defer rows.Close() - - var traders []*TraderRecord - for rows.Next() { - var trader TraderRecord - var createdAt, updatedAt string - err := rows.Scan( - &trader.ID, &trader.UserID, &trader.Name, &trader.AIModelID, &trader.ExchangeID, - &trader.InitialBalance, &trader.ScanIntervalMinutes, &trader.IsRunning, - &trader.BTCETHLeverage, &trader.AltcoinLeverage, &trader.TradingSymbols, - &trader.UseCoinPool, &trader.UseOITop, - &trader.CustomPrompt, &trader.OverrideBasePrompt, &trader.SystemPromptTemplate, - &trader.IsCrossMargin, - &createdAt, &updatedAt, - ) - if err != nil { - return nil, err - } - // 解析时间字符串 - trader.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) - trader.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) - traders = append(traders, &trader) - } - - return traders, nil -} - -// UpdateTraderStatus 更新交易员状态 -func (d *Database) UpdateTraderStatus(userID, id string, isRunning bool) error { - _, err := d.db.Exec(`UPDATE traders SET is_running = ? WHERE id = ? AND user_id = ?`, isRunning, id, userID) - return err -} - -// UpdateTrader 更新交易员配置 -func (d *Database) UpdateTrader(trader *TraderRecord) error { - _, err := d.db.Exec(` - UPDATE traders SET - name = ?, ai_model_id = ?, exchange_id = ?, - scan_interval_minutes = ?, btc_eth_leverage = ?, altcoin_leverage = ?, - trading_symbols = ?, custom_prompt = ?, override_base_prompt = ?, - system_prompt_template = ?, is_cross_margin = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? AND user_id = ? - `, trader.Name, trader.AIModelID, trader.ExchangeID, - trader.ScanIntervalMinutes, trader.BTCETHLeverage, trader.AltcoinLeverage, - trader.TradingSymbols, trader.CustomPrompt, trader.OverrideBasePrompt, - trader.SystemPromptTemplate, trader.IsCrossMargin, trader.ID, trader.UserID) - return err -} - -// UpdateTraderCustomPrompt 更新交易员自定义Prompt -func (d *Database) UpdateTraderCustomPrompt(userID, id string, customPrompt string, overrideBase bool) error { - _, err := d.db.Exec(`UPDATE traders SET custom_prompt = ?, override_base_prompt = ? WHERE id = ? AND user_id = ?`, customPrompt, overrideBase, id, userID) - return err -} - -// UpdateTraderInitialBalance 更新交易员初始余额(仅支持手动更新) -// ⚠️ 注意:系统不会自动调用此方法,仅供用户在充值/提现后手动同步使用 -func (d *Database) UpdateTraderInitialBalance(userID, id string, newBalance float64) error { - _, err := d.db.Exec(`UPDATE traders SET initial_balance = ? WHERE id = ? AND user_id = ?`, newBalance, id, userID) - return err -} - -// DeleteTrader 删除交易员 -func (d *Database) DeleteTrader(userID, id string) error { - _, err := d.db.Exec(`DELETE FROM traders WHERE id = ? AND user_id = ?`, id, userID) - return err -} - -// GetTraderConfig 获取交易员完整配置(包含AI模型和交易所信息) -func (d *Database) GetTraderConfig(userID, traderID string) (*TraderRecord, *AIModelConfig, *ExchangeConfig, error) { - var trader TraderRecord - var aiModel AIModelConfig - var exchange ExchangeConfig - var traderCreatedAt, traderUpdatedAt string - var aiModelCreatedAt, aiModelUpdatedAt string - var exchangeCreatedAt, exchangeUpdatedAt string - - err := d.db.QueryRow(` - SELECT - t.id, t.user_id, t.name, t.ai_model_id, t.exchange_id, t.initial_balance, t.scan_interval_minutes, t.is_running, - COALESCE(t.btc_eth_leverage, 5) as btc_eth_leverage, - COALESCE(t.altcoin_leverage, 5) as altcoin_leverage, - COALESCE(t.trading_symbols, '') as trading_symbols, - COALESCE(t.use_coin_pool, 0) as use_coin_pool, - COALESCE(t.use_oi_top, 0) as use_oi_top, - COALESCE(t.custom_prompt, '') as custom_prompt, - COALESCE(t.override_base_prompt, 0) as override_base_prompt, - COALESCE(t.system_prompt_template, 'default') as system_prompt_template, - COALESCE(t.is_cross_margin, 1) as is_cross_margin, - t.created_at, t.updated_at, - a.id, a.user_id, a.name, a.provider, a.enabled, a.api_key, - COALESCE(a.custom_api_url, '') as custom_api_url, - COALESCE(a.custom_model_name, '') as custom_model_name, - a.created_at, a.updated_at, - e.id, e.user_id, e.name, e.type, e.enabled, e.api_key, e.secret_key, e.testnet, - COALESCE(e.hyperliquid_wallet_addr, '') as hyperliquid_wallet_addr, - COALESCE(e.aster_user, '') as aster_user, - COALESCE(e.aster_signer, '') as aster_signer, - COALESCE(e.aster_private_key, '') as aster_private_key, - COALESCE(e.lighter_wallet_addr, '') as lighter_wallet_addr, - COALESCE(e.lighter_private_key, '') as lighter_private_key, - COALESCE(e.lighter_api_key_private_key, '') as lighter_api_key_private_key, - e.created_at, e.updated_at - FROM traders t - JOIN ai_models a ON t.ai_model_id = a.id AND t.user_id = a.user_id - JOIN exchanges e ON t.exchange_id = e.id AND t.user_id = e.user_id - WHERE t.id = ? AND t.user_id = ? - `, traderID, userID).Scan( - &trader.ID, &trader.UserID, &trader.Name, &trader.AIModelID, &trader.ExchangeID, - &trader.InitialBalance, &trader.ScanIntervalMinutes, &trader.IsRunning, - &trader.BTCETHLeverage, &trader.AltcoinLeverage, &trader.TradingSymbols, - &trader.UseCoinPool, &trader.UseOITop, - &trader.CustomPrompt, &trader.OverrideBasePrompt, &trader.SystemPromptTemplate, - &trader.IsCrossMargin, - &traderCreatedAt, &traderUpdatedAt, - &aiModel.ID, &aiModel.UserID, &aiModel.Name, &aiModel.Provider, &aiModel.Enabled, &aiModel.APIKey, - &aiModel.CustomAPIURL, &aiModel.CustomModelName, - &aiModelCreatedAt, &aiModelUpdatedAt, - &exchange.ID, &exchange.UserID, &exchange.Name, &exchange.Type, &exchange.Enabled, - &exchange.APIKey, &exchange.SecretKey, &exchange.Testnet, - &exchange.HyperliquidWalletAddr, &exchange.AsterUser, &exchange.AsterSigner, &exchange.AsterPrivateKey, - &exchange.LighterWalletAddr, &exchange.LighterPrivateKey, &exchange.LighterAPIKeyPrivateKey, - &exchangeCreatedAt, &exchangeUpdatedAt, - ) - - if err != nil { - return nil, nil, nil, err - } - - // 解析时间字符串 - trader.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", traderCreatedAt) - trader.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", traderUpdatedAt) - aiModel.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", aiModelCreatedAt) - aiModel.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", aiModelUpdatedAt) - exchange.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", exchangeCreatedAt) - exchange.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", exchangeUpdatedAt) - - // 解密敏感数据 - aiModel.APIKey = d.decryptSensitiveData(aiModel.APIKey) - exchange.APIKey = d.decryptSensitiveData(exchange.APIKey) - exchange.SecretKey = d.decryptSensitiveData(exchange.SecretKey) - exchange.AsterPrivateKey = d.decryptSensitiveData(exchange.AsterPrivateKey) - exchange.LighterPrivateKey = d.decryptSensitiveData(exchange.LighterPrivateKey) - exchange.LighterAPIKeyPrivateKey = d.decryptSensitiveData(exchange.LighterAPIKeyPrivateKey) - - return &trader, &aiModel, &exchange, nil -} - -// GetSystemConfig 获取系统配置 -func (d *Database) GetSystemConfig(key string) (string, error) { - var value string - err := d.db.QueryRow(`SELECT value FROM system_config WHERE key = ?`, key).Scan(&value) - return value, err -} - -// SetSystemConfig 设置系统配置 -func (d *Database) SetSystemConfig(key, value string) error { - _, err := d.db.Exec(` - INSERT OR REPLACE INTO system_config (key, value) VALUES (?, ?) - `, key, value) - return err -} - -// CreateUserSignalSource 创建用户信号源配置 -func (d *Database) CreateUserSignalSource(userID, coinPoolURL, oiTopURL string) error { - _, err := d.db.Exec(` - INSERT OR REPLACE INTO user_signal_sources (user_id, coin_pool_url, oi_top_url, updated_at) - VALUES (?, ?, ?, CURRENT_TIMESTAMP) - `, userID, coinPoolURL, oiTopURL) - return err -} - -// GetUserSignalSource 获取用户信号源配置 -func (d *Database) GetUserSignalSource(userID string) (*UserSignalSource, error) { - var source UserSignalSource - var createdAt, updatedAt string - err := d.db.QueryRow(` - SELECT id, user_id, coin_pool_url, oi_top_url, created_at, updated_at - FROM user_signal_sources WHERE user_id = ? - `, userID).Scan( - &source.ID, &source.UserID, &source.CoinPoolURL, &source.OITopURL, - &createdAt, &updatedAt, - ) - if err != nil { - return nil, err - } - source.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) - source.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) - return &source, nil -} - -// UpdateUserSignalSource 更新用户信号源配置 -func (d *Database) UpdateUserSignalSource(userID, coinPoolURL, oiTopURL string) error { - _, err := d.db.Exec(` - UPDATE user_signal_sources SET coin_pool_url = ?, oi_top_url = ?, updated_at = CURRENT_TIMESTAMP - WHERE user_id = ? - `, coinPoolURL, oiTopURL, userID) - return err -} - -// GetCustomCoins 获取所有交易员自定义币种 / Get all trader-customized currencies -func (d *Database) GetCustomCoins() []string { - var symbol string - var symbols []string - _ = d.db.QueryRow(` - SELECT GROUP_CONCAT(custom_coins , ',') as symbol - FROM main.traders where custom_coins != '' - `).Scan(&symbol) - // 检测用户是否未配置币种 - 兼容性 - if symbol == "" { - symbolJSON, _ := d.GetSystemConfig("default_coins") - if err := json.Unmarshal([]byte(symbolJSON), &symbols); err != nil { - log.Printf("⚠️ 解析default_coins配置失败: %v,使用硬编码默认值", err) - symbols = []string{"BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT"} - } - } - // filter Symbol - for _, s := range strings.Split(symbol, ",") { - if s == "" { - continue - } - coin := market.Normalize(s) - if !slices.Contains(symbols, coin) { - symbols = append(symbols, coin) - } - } - return symbols -} - -// Close 关闭数据库连接 -// Conn 返回底层 *sql.DB,供需要执行自定义查询的模块使用。 -func (d *Database) Conn() *sql.DB { - return d.db -} - -func (d *Database) Close() error { - return d.db.Close() -} - -// LoadBetaCodesFromFile 从文件加载内测码到数据库 -func (d *Database) LoadBetaCodesFromFile(filePath string) error { - // 读取文件内容 - content, err := os.ReadFile(filePath) - if err != nil { - return fmt.Errorf("读取内测码文件失败: %w", err) - } - - // 按行分割内测码 - lines := strings.Split(string(content), "\n") - var codes []string - for _, line := range lines { - code := strings.TrimSpace(line) - if code != "" && !strings.HasPrefix(code, "#") { - codes = append(codes, code) - } - } - - // 批量插入内测码 - tx, err := d.db.Begin() - if err != nil { - return fmt.Errorf("开始事务失败: %w", err) - } - defer tx.Rollback() - - stmt, err := tx.Prepare(`INSERT OR IGNORE INTO beta_codes (code) VALUES (?)`) - if err != nil { - return fmt.Errorf("准备语句失败: %w", err) - } - defer stmt.Close() - - insertedCount := 0 - for _, code := range codes { - result, err := stmt.Exec(code) - if err != nil { - log.Printf("插入内测码 %s 失败: %v", code, err) - continue - } - - if rowsAffected, _ := result.RowsAffected(); rowsAffected > 0 { - insertedCount++ - } - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("提交事务失败: %w", err) - } - - log.Printf("✅ 成功加载 %d 个内测码到数据库 (总计 %d 个)", insertedCount, len(codes)) - return nil -} - -// ValidateBetaCode 验证内测码是否有效且未使用 -func (d *Database) ValidateBetaCode(code string) (bool, error) { - var used bool - err := d.db.QueryRow(`SELECT used FROM beta_codes WHERE code = ?`, code).Scan(&used) - if err != nil { - if err == sql.ErrNoRows { - return false, nil // 内测码不存在 - } - return false, err - } - return !used, nil // 内测码存在且未使用 -} - -// UseBetaCode 使用内测码(标记为已使用) -func (d *Database) UseBetaCode(code, userEmail string) error { - result, err := d.db.Exec(` - UPDATE beta_codes SET used = 1, used_by = ?, used_at = CURRENT_TIMESTAMP - WHERE code = ? AND used = 0 - `, userEmail, code) - if err != nil { - return err - } - - rowsAffected, err := result.RowsAffected() - if err != nil { - return err - } - - if rowsAffected == 0 { - return fmt.Errorf("内测码无效或已被使用") - } - - return nil -} - -// GetBetaCodeStats 获取内测码统计信息 -func (d *Database) GetBetaCodeStats() (total, used int, err error) { - err = d.db.QueryRow(`SELECT COUNT(*) FROM beta_codes`).Scan(&total) - if err != nil { - return 0, 0, err - } - - err = d.db.QueryRow(`SELECT COUNT(*) FROM beta_codes WHERE used = 1`).Scan(&used) - if err != nil { - return 0, 0, err - } - - return total, used, nil -} - -// SetCryptoService 设置加密服务 -func (d *Database) SetCryptoService(cs *crypto.CryptoService) { - d.cryptoService = cs -} - -// encryptSensitiveData 加密敏感数据用于存储 -func (d *Database) encryptSensitiveData(plaintext string) string { - if d.cryptoService == nil || plaintext == "" { - return plaintext - } - - encrypted, err := d.cryptoService.EncryptForStorage(plaintext) - if err != nil { - log.Printf("⚠️ 加密失败: %v", err) - return plaintext // 返回明文作为降级处理 - } - - return encrypted -} - -// decryptSensitiveData 解密敏感数据 -func (d *Database) decryptSensitiveData(encrypted string) string { - if d.cryptoService == nil || encrypted == "" { - return encrypted - } - - // 如果不是加密格式,直接返回 - if !d.cryptoService.IsEncryptedStorageValue(encrypted) { - return encrypted - } - - decrypted, err := d.cryptoService.DecryptFromStorage(encrypted) - if err != nil { - log.Printf("⚠️ 解密失败: %v", err) - return encrypted // 返回加密文本作为降级处理 - } - - return decrypted -} diff --git a/config/database_test.go b/config/database_test.go deleted file mode 100644 index b3a009d8..00000000 --- a/config/database_test.go +++ /dev/null @@ -1,850 +0,0 @@ -package config - -import ( - "nofx/crypto" - "os" - "testing" - "time" -) - -// TestUpdateExchange_EmptyValuesShouldNotOverwrite 测试空值不应覆盖现有数据 -// 这是 Bug 的核心:当前实现会用空字符串覆盖现有的私钥 -func TestUpdateExchange_EmptyValuesShouldNotOverwrite(t *testing.T) { - // 准备测试数据库 - db, cleanup := setupTestDB(t) - defer cleanup() - - userID := "test-user-001" - - // 步骤 1: 创建初始配置(包含私钥) - initialAPIKey := "initial-api-key-12345" - initialSecretKey := "initial-secret-key-67890" - - err := db.UpdateExchange( - userID, - "hyperliquid", - true, // enabled - initialAPIKey, - initialSecretKey, - false, // testnet - "0xWalletAddress", - "", - "", - "", - "", // lighter_wallet_addr - "", // lighter_private_key - ) - if err != nil { - t.Fatalf("初始化失败: %v", err) - } - - // 步骤 2: 验证初始数据已保存 - exchanges, err := db.GetExchanges(userID) - if err != nil { - t.Fatalf("获取配置失败: %v", err) - } - if len(exchanges) == 0 { - t.Fatal("未找到配置") - } - - // 解密后应该能看到原始值 - if exchanges[0].APIKey != initialAPIKey { - t.Errorf("初始 APIKey 不正确,期望 %s,实际 %s", initialAPIKey, exchanges[0].APIKey) - } - - // 步骤 3: 用空值更新(模拟前端发送空值的场景) - // 🐛 Bug 重现:这应该 NOT 覆盖现有的私钥,但当前实现会覆盖 - err = db.UpdateExchange( - userID, - "hyperliquid", - false, // 只改变 enabled 状态 - "", // 空 apiKey - 不应该覆盖 - "", // 空 secretKey - 不应该覆盖 - true, // 改变 testnet 状态 - "0xWalletAddress", - "", - "", - "", // 空 aster_private_key - 不应该覆盖 - "", - "", - ) - if err != nil { - t.Fatalf("更新失败: %v", err) - } - - // 步骤 4: 验证私钥没有被空值覆盖 - exchanges, err = db.GetExchanges(userID) - if err != nil { - t.Fatalf("获取更新后配置失败: %v", err) - } - - // 🎯 关键断言:私钥应该保持不变 - if exchanges[0].APIKey != initialAPIKey { - t.Errorf("❌ Bug 确认:APIKey 被空值覆盖了!期望 %s,实际 %s", initialAPIKey, exchanges[0].APIKey) - } - if exchanges[0].SecretKey != initialSecretKey { - t.Errorf("❌ Bug 确认:SecretKey 被空值覆盖了!期望 %s,实际 %s", initialSecretKey, exchanges[0].SecretKey) - } - - // 验证非敏感字段正常更新 - if exchanges[0].Enabled { - t.Error("enabled 应该被更新为 false") - } - if !exchanges[0].Testnet { - t.Error("testnet 应该被更新为 true") - } -} - -// TestUpdateExchange_AsterEmptyValuesShouldNotOverwrite 测试 Aster 私钥不被空值覆盖 -func TestUpdateExchange_AsterEmptyValuesShouldNotOverwrite(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - userID := "test-user-002" - - // 步骤 1: 创建 Aster 配置 - initialAsterKey := "aster-private-key-xyz123" - - err := db.UpdateExchange( - userID, - "aster", - true, - "", - "", - false, - "", - "0xAsterUser", - "0xAsterSigner", - initialAsterKey, - "", - "", - ) - if err != nil { - t.Fatalf("初始化 Aster 失败: %v", err) - } - - // 步骤 2: 用空值更新 - err = db.UpdateExchange( - userID, - "aster", - false, // 只改 enabled - "", - "", - false, - "", - "0xAsterUser", - "0xAsterSigner", - "", // 空 aster_private_key - "", - "", - ) - if err != nil { - t.Fatalf("更新失败: %v", err) - } - - // 步骤 3: 验证 aster_private_key 没有被覆盖 - exchanges, err := db.GetExchanges(userID) - if err != nil { - t.Fatalf("获取配置失败: %v", err) - } - - if exchanges[0].AsterPrivateKey != initialAsterKey { - t.Errorf("❌ Bug 确认:AsterPrivateKey 被空值覆盖了!期望 %s,实际 %s", initialAsterKey, exchanges[0].AsterPrivateKey) - } -} - -// TestUpdateExchange_NonEmptyValuesShouldUpdate 测试非空值应该正常更新 -func TestUpdateExchange_NonEmptyValuesShouldUpdate(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - userID := "test-user-003" - - // 步骤 1: 创建初始配置 - err := db.UpdateExchange( - userID, - "hyperliquid", - true, - "old-api-key", - "old-secret-key", - false, - "0xOldWallet", - "", - "", - "", - "", - "", - ) - if err != nil { - t.Fatalf("初始化失败: %v", err) - } - - // 步骤 2: 用非空值更新 - newAPIKey := "new-api-key-456" - newSecretKey := "new-secret-key-789" - - err = db.UpdateExchange( - userID, - "hyperliquid", - true, - newAPIKey, - newSecretKey, - false, - "0xNewWallet", - "", - "", - "", - "", - "", - ) - if err != nil { - t.Fatalf("更新失败: %v", err) - } - - // 步骤 3: 验证新值已更新 - exchanges, err := db.GetExchanges(userID) - if err != nil { - t.Fatalf("获取配置失败: %v", err) - } - - if exchanges[0].APIKey != newAPIKey { - t.Errorf("APIKey 未更新,期望 %s,实际 %s", newAPIKey, exchanges[0].APIKey) - } - if exchanges[0].SecretKey != newSecretKey { - t.Errorf("SecretKey 未更新,期望 %s,实际 %s", newSecretKey, exchanges[0].SecretKey) - } - if exchanges[0].HyperliquidWalletAddr != "0xNewWallet" { - t.Errorf("WalletAddr 未更新") - } -} - -// TestUpdateExchange_PartialUpdateShouldWork 测试部分字段更新 -func TestUpdateExchange_PartialUpdateShouldWork(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - userID := "test-user-005" - - // 创建初始配置 - err := db.UpdateExchange( - userID, - "hyperliquid", - true, - "api-key-123", - "secret-key-456", - false, - "0xWallet1", - "", - "", - "", - "", - "", - ) - if err != nil { - t.Fatalf("初始化失败: %v", err) - } - - // 只更新 enabled 和 testnet,私钥留空 - err = db.UpdateExchange( - userID, - "hyperliquid", - false, - "", // 留空 - "", // 留空 - true, - "0xWallet2", - "", - "", - "", - "", - "", - ) - if err != nil { - t.Fatalf("部分更新失败: %v", err) - } - - // 验证 - exchanges, err := db.GetExchanges(userID) - if err != nil { - t.Fatalf("获取配置失败: %v", err) - } - - // 私钥应该保持不变 - if exchanges[0].APIKey != "api-key-123" { - t.Errorf("APIKey 不应改变,期望 api-key-123,实际 %s", exchanges[0].APIKey) - } - if exchanges[0].SecretKey != "secret-key-456" { - t.Errorf("SecretKey 不应改变,期望 secret-key-456,实际 %s", exchanges[0].SecretKey) - } - - // 其他字段应该更新 - if exchanges[0].Enabled { - t.Error("enabled 应该更新为 false") - } - if !exchanges[0].Testnet { - t.Error("testnet 应该更新为 true") - } - if exchanges[0].HyperliquidWalletAddr != "0xWallet2" { - t.Error("wallet 地址应该更新") - } -} - -// TestUpdateExchange_MultipleExchangeTypes 测试不同交易所类型 -func TestUpdateExchange_MultipleExchangeTypes(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - userID := "test-user-006" - - testCases := []struct { - exchangeID string - name string - typ string - }{ - {"binance", "Binance Futures", "cex"}, - {"hyperliquid", "Hyperliquid", "dex"}, - {"aster", "Aster DEX", "dex"}, - {"unknown-exchange", "unknown-exchange Exchange", "cex"}, - } - - for _, tc := range testCases { - t.Run(tc.exchangeID, func(t *testing.T) { - err := db.UpdateExchange( - userID, - tc.exchangeID, - true, - "api-key-"+tc.exchangeID, - "secret-key-"+tc.exchangeID, - false, - "", - "", - "", - "", - "", - "", - ) - if err != nil { - t.Fatalf("创建 %s 失败: %v", tc.exchangeID, err) - } - - // 验证创建成功 - exchanges, err := db.GetExchanges(userID) - if err != nil { - t.Fatalf("获取配置失败: %v", err) - } - - found := false - for _, ex := range exchanges { - if ex.ID == tc.exchangeID { - found = true - if ex.Name != tc.name { - t.Errorf("交易所名称不正确,期望 %s,实际 %s", tc.name, ex.Name) - } - if ex.Type != tc.typ { - t.Errorf("交易所类型不正确,期望 %s,实际 %s", tc.typ, ex.Type) - } - if ex.APIKey != "api-key-"+tc.exchangeID { - t.Errorf("APIKey 不正确") - } - break - } - } - - if !found { - t.Errorf("未找到交易所 %s", tc.exchangeID) - } - }) - } -} - -// TestUpdateExchange_MixedSensitiveFields 测试混合更新敏感和非敏感字段 -func TestUpdateExchange_MixedSensitiveFields(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - userID := "test-user-007" - - // 创建初始配置 - err := db.UpdateExchange( - userID, - "hyperliquid", - true, - "old-api-key", - "old-secret-key", - false, - "0xOldWallet", - "", - "", - "", - "", - "", - ) - if err != nil { - t.Fatalf("初始化失败: %v", err) - } - - // 场景1: 只更新 apiKey,secretKey 留空 - err = db.UpdateExchange( - userID, - "hyperliquid", - false, - "new-api-key", - "", // 留空 - true, - "0xNewWallet", - "", - "", - "", - "", - "", - ) - if err != nil { - t.Fatalf("更新1失败: %v", err) - } - - exchanges, _ := db.GetExchanges(userID) - if exchanges[0].APIKey != "new-api-key" { - t.Error("APIKey 应该更新") - } - if exchanges[0].SecretKey != "old-secret-key" { - t.Error("SecretKey 应该保持不变") - } - - // 场景2: 只更新 secretKey,apiKey 留空 - err = db.UpdateExchange( - userID, - "hyperliquid", - true, - "", // 留空 - "new-secret-key", - false, - "0xFinalWallet", - "", - "", - "", - "", - "", - ) - if err != nil { - t.Fatalf("更新2失败: %v", err) - } - - exchanges, _ = db.GetExchanges(userID) - if exchanges[0].APIKey != "new-api-key" { - t.Error("APIKey 应该保持不变") - } - if exchanges[0].SecretKey != "new-secret-key" { - t.Error("SecretKey 应该更新") - } - if exchanges[0].Enabled != true { - t.Error("Enabled 应该更新为 true") - } - if exchanges[0].HyperliquidWalletAddr != "0xFinalWallet" { - t.Error("WalletAddr 应该更新") - } -} - -// TestUpdateExchange_OnlyNonSensitiveFields 测试只更新非敏感字段 -func TestUpdateExchange_OnlyNonSensitiveFields(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - userID := "test-user-008" - - // 创建初始配置(包含所有私钥) - err := db.UpdateExchange( - userID, - "aster", - true, - "binance-api", - "binance-secret", - false, - "", - "0xUser1", - "0xSigner1", - "aster-private-key-1", - "", - "", - ) - if err != nil { - t.Fatalf("初始化失败: %v", err) - } - - // 只更新非敏感字段(所有私钥字段留空) - err = db.UpdateExchange( - userID, - "aster", - false, - "", - "", - true, - "", - "0xUser2", - "0xSigner2", - "", - "", - "", - ) - if err != nil { - t.Fatalf("更新失败: %v", err) - } - - // 验证所有私钥保持不变 - exchanges, _ := db.GetExchanges(userID) - if exchanges[0].APIKey != "binance-api" { - t.Errorf("APIKey 应该保持不变,实际 %s", exchanges[0].APIKey) - } - if exchanges[0].SecretKey != "binance-secret" { - t.Errorf("SecretKey 应该保持不变,实际 %s", exchanges[0].SecretKey) - } - if exchanges[0].AsterPrivateKey != "aster-private-key-1" { - t.Errorf("AsterPrivateKey 应该保持不变,实际 %s", exchanges[0].AsterPrivateKey) - } - - // 验证非敏感字段已更新 - if exchanges[0].Enabled != false { - t.Error("Enabled 应该更新为 false") - } - if exchanges[0].Testnet != true { - t.Error("Testnet 应该更新为 true") - } - if exchanges[0].AsterUser != "0xUser2" { - t.Error("AsterUser 应该更新") - } - if exchanges[0].AsterSigner != "0xSigner2" { - t.Error("AsterSigner 应该更新") - } -} - -// TestUpdateExchange_AllSensitiveFieldsUpdate 测试同时更新所有敏感字段 -func TestUpdateExchange_AllSensitiveFieldsUpdate(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - userID := "test-user-009" - - // 创建初始配置 - err := db.UpdateExchange( - userID, - "binance", - true, - "old-api", - "old-secret", - false, - "", - "", - "", - "old-aster-key", - "", - "", - ) - if err != nil { - t.Fatalf("初始化失败: %v", err) - } - - // 同时更新所有敏感字段 - err = db.UpdateExchange( - userID, - "binance", - false, - "new-api", - "new-secret", - true, - "0xWallet", - "0xUser", - "0xSigner", - "new-aster-key", - "", - "", - ) - if err != nil { - t.Fatalf("更新失败: %v", err) - } - - // 验证所有字段都更新了 - exchanges, _ := db.GetExchanges(userID) - if exchanges[0].APIKey != "new-api" { - t.Error("APIKey 应该更新") - } - if exchanges[0].SecretKey != "new-secret" { - t.Error("SecretKey 应该更新") - } - if exchanges[0].AsterPrivateKey != "new-aster-key" { - t.Error("AsterPrivateKey 应该更新") - } - if !exchanges[0].Testnet { - t.Error("Testnet 应该更新为 true") - } -} - -// setupTestDB 创建测试数据库 -func setupTestDB(t *testing.T) (*Database, func()) { - // 创建临时数据库文件 - tmpFile := t.TempDir() + "/test.db" - - db, err := NewDatabase(tmpFile) - if err != nil { - t.Fatalf("创建测试数据库失败: %v", err) - } - - // 创建测试用户 - testUsers := []string{ - "test-user-001", "test-user-002", "test-user-003", "test-user-004", "test-user-005", - "test-user-006", "test-user-007", "test-user-008", "test-user-009", - "test-user-persistence", "user1", "user2", - } - for _, userID := range testUsers { - user := &User{ - ID: userID, - Email: userID + "@test.com", - PasswordHash: "hash", - OTPSecret: "", - OTPVerified: false, - } - _ = db.CreateUser(user) - } - - // 设置加密服务(用于测试加密功能) - // 创建临时 RSA 密钥 - rsaKeyPath := t.TempDir() + "/test_rsa_key" - cryptoService, err := crypto.NewCryptoService(rsaKeyPath) - if err != nil { - // 如果创建失败,继续测试但不使用加密 - t.Logf("警告:无法创建加密服务,将在无加密模式下测试: %v", err) - } else { - db.SetCryptoService(cryptoService) - } - - cleanup := func() { - db.Close() - os.RemoveAll(tmpFile) - os.RemoveAll(rsaKeyPath) - } - - return db, cleanup -} - -// TestWALModeEnabled 测试 WAL 模式是否启用 -// TDD: 这个测试应该失败,因为当前代码没有启用 WAL 模式 -func TestWALModeEnabled(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - // 查询当前的 journal_mode - var journalMode string - err := db.db.QueryRow("PRAGMA journal_mode").Scan(&journalMode) - if err != nil { - t.Fatalf("查询 journal_mode 失败: %v", err) - } - - // 期望是 WAL 模式 - if journalMode != "wal" { - t.Errorf("期望 journal_mode=wal,实际是 %s", journalMode) - } -} - -// TestSynchronousMode 测试 synchronous 模式设置 -// TDD: 验证数据持久性设置 -func TestSynchronousMode(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - // 查询 synchronous 设置 - var synchronous int - err := db.db.QueryRow("PRAGMA synchronous").Scan(&synchronous) - if err != nil { - t.Fatalf("查询 synchronous 失败: %v", err) - } - - // 期望是 FULL (2) 以确保数据持久性 - if synchronous != 2 { - t.Errorf("期望 synchronous=2 (FULL),实际是 %d", synchronous) - } -} - -// TestDataPersistenceAcrossReopen 测试数据在数据库关闭并重新打开后是否持久化 -// TDD: 模拟 Docker restart 场景 -func TestDataPersistenceAcrossReopen(t *testing.T) { - // 创建临时数据库文件 - tmpFile, err := os.CreateTemp("", "test_persistence_*.db") - if err != nil { - t.Fatalf("创建临时文件失败: %v", err) - } - tmpFile.Close() - dbPath := tmpFile.Name() - defer os.Remove(dbPath) - - // 设置加密服务 - rsaKeyPath := "test_rsa_key.pem" - cryptoService, err := crypto.NewCryptoService(rsaKeyPath) - if err != nil { - t.Fatalf("初始化加密服务失败: %v", err) - } - defer os.RemoveAll(rsaKeyPath) - - userID := "test-user-persistence" - testAPIKey := "test-api-key-should-persist" - testSecretKey := "test-secret-key-should-persist" - - // 第一次打开数据库并写入数据 - { - db, err := NewDatabase(dbPath) - if err != nil { - t.Fatalf("第一次创建数据库失败: %v", err) - } - db.SetCryptoService(cryptoService) - - // 创建持久化测试用户,避免外键约束失败 - _ = db.CreateUser(&User{ - ID: userID, - Email: userID + "@test.com", - PasswordHash: "hash", - OTPSecret: "", - OTPVerified: true, - }) - - // 写入交易所配置 - err = db.UpdateExchange( - userID, - "binance", - true, - testAPIKey, - testSecretKey, - false, - "", - "", - "", - "", - "", - "", - ) - if err != nil { - t.Fatalf("写入数据失败: %v", err) - } - - // 模拟正常关闭 - if err := db.Close(); err != nil { - t.Fatalf("关闭数据库失败: %v", err) - } - } - - // 第二次打开数据库并验证数据是否还在 - { - db, err := NewDatabase(dbPath) - if err != nil { - t.Fatalf("第二次打开数据库失败: %v", err) - } - db.SetCryptoService(cryptoService) - defer db.Close() - - // 读取数据 - exchanges, err := db.GetExchanges(userID) - if err != nil { - t.Fatalf("读取数据失败: %v", err) - } - - if len(exchanges) == 0 { - t.Fatal("数据丢失:没有找到任何交易所配置") - } - - // 验证数据完整性 - found := false - for _, ex := range exchanges { - if ex.ID == "binance" { - found = true - if ex.APIKey != testAPIKey { - t.Errorf("API Key 丢失或损坏,期望 %s,实际 %s", testAPIKey, ex.APIKey) - } - if ex.SecretKey != testSecretKey { - t.Errorf("Secret Key 丢失或损坏,期望 %s,实际 %s", testSecretKey, ex.SecretKey) - } - } - } - - if !found { - t.Error("数据丢失:找不到 binance 配置") - } - } -} - -// TestConcurrentWritesWithWAL 测试 WAL 模式下的并发写入 -// TDD: WAL 模式应该支持更好的并发性能 -func TestConcurrentWritesWithWAL(t *testing.T) { - db, cleanup := setupTestDB(t) - defer cleanup() - - // 这个测试验证多个并发写入可以成功 - // WAL 模式下并发性能更好,但 SQLite 仍然可能出现短暂的锁 - done := make(chan bool, 2) - errors := make(chan error, 10) - - // 并发写入1 - go func() { - for i := 0; i < 3; i++ { - err := db.UpdateExchange( - "user1", - "binance", - true, - "key1", - "secret1", - false, - "", - "", - "", - "", - "", - "", - ) - if err != nil { - errors <- err - } - // 小延迟减少锁冲突 - time.Sleep(10 * time.Millisecond) - } - done <- true - }() - - // 并发写入2 - go func() { - for i := 0; i < 3; i++ { - err := db.UpdateExchange( - "user2", - "hyperliquid", - true, - "key2", - "secret2", - false, - "0xWallet", - "", - "", - "", - "", - "", - ) - if err != nil { - errors <- err - } - // 小延迟减少锁冲突 - time.Sleep(10 * time.Millisecond) - } - done <- true - }() - - // 等待两个 goroutine 完成 - <-done - <-done - close(errors) - - // 检查是否有错误 - errorCount := 0 - for err := range errors { - t.Logf("并发写入错误: %v", err) - errorCount++ - } - - // WAL 模式下应该能处理并发,但可能有少量锁错误 - // 我们允许最多 2 个错误 - if errorCount > 2 { - t.Errorf("并发写入失败次数过多: %d", errorCount) - } -} diff --git a/config/test_rsa_key.pem.pub b/config/test_rsa_key.pem.pub deleted file mode 100644 index a9f89eeb..00000000 --- a/config/test_rsa_key.pem.pub +++ /dev/null @@ -1,9 +0,0 @@ ------BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4Y666RzY5LLi6PiYL+vC -7+fcr122Fd8BC7IdqUSYKQ33Nsi9J7J5fDgcMf7ZAnIBpxMV7+e1KEoiwtGmxwHj -mYo0ZV0E6JXdiK26S052+Shquri0IXkwGFraDuNKqmGrj6vZuXtq2L2gdSyZCxrI -veN9g6LxBvLBP1Rx7UEmZeyokRYvChcxAQXuS/0br44BOHGtwAElk6AGLISz55AG -oM40b3ktiza+8THKMz3GiylQQYpBltbM3yAXPlnXJ2MtUZiaHNhEQI4++PMvEErN -Izm8cIgcvUAXJ5vBfa4kD0kSgBJFuEQ2im3qcWTuEPRKztEeJDY7XAVHc1Xy6d4N -vQIDAQAB ------END PUBLIC KEY----- diff --git a/crypto/crypto.go b/crypto/crypto.go index df543efb..9c33cfe6 100644 --- a/crypto/crypto.go +++ b/crypto/crypto.go @@ -13,10 +13,7 @@ import ( "encoding/pem" "errors" "fmt" - "io/ioutil" - "log" "os" - "path/filepath" "strings" "time" ) @@ -24,8 +21,12 @@ import ( const ( storagePrefix = "ENC:v1:" storageDelimiter = ":" - dataKeyEnvName = "DATA_ENCRYPTION_KEY" - dataKeyFilePath = "secrets/data_key" +) + +// 环境变量名称 +const ( + EnvDataEncryptionKey = "DATA_ENCRYPTION_KEY" // AES 数据加密密钥 (Base64) + EnvRSAPrivateKey = "RSA_PRIVATE_KEY" // RSA 私钥 (PEM 格式,换行用 \n) ) type EncryptedPayload struct { @@ -50,29 +51,18 @@ type CryptoService struct { dataKey []byte } -func NewCryptoService(privateKeyPath string) (*CryptoService, error) { - // 读取私钥文件 - privateKeyPEM, err := ioutil.ReadFile(privateKeyPath) +// NewCryptoService 创建加密服务(从环境变量加载密钥) +func NewCryptoService() (*CryptoService, error) { + // 1. 加载 RSA 私钥 + privateKey, err := loadRSAPrivateKeyFromEnv() if err != nil { - // 如果私钥文件不存在,生成新的密钥对 - if err := GenerateRSAKeyPair(privateKeyPath); err != nil { - return nil, fmt.Errorf("failed to generate RSA key pair: %w", err) - } - privateKeyPEM, err = ioutil.ReadFile(privateKeyPath) - if err != nil { - return nil, fmt.Errorf("failed to read generated private key: %w", err) - } + return nil, fmt.Errorf("RSA 私钥加载失败: %w", err) } - // 解析私钥 - privateKey, err := ParseRSAPrivateKeyFromPEM(privateKeyPEM) + // 2. 加载 AES 数据加密密钥 + dataKey, err := loadDataKeyFromEnv() if err != nil { - return nil, fmt.Errorf("failed to parse private key: %w", err) - } - - dataKey, err := resolveDataKey() - if err != nil { - return nil, fmt.Errorf("failed to load data encryption key: %w", err) + return nil, fmt.Errorf("数据加密密钥加载失败: %w", err) } return &CryptoService{ @@ -82,56 +72,43 @@ func NewCryptoService(privateKeyPath string) (*CryptoService, error) { }, nil } -func GenerateRSAKeyPair(privateKeyPath string) error { - // 确保目录存在 - dir := filepath.Dir(privateKeyPath) - if dir != "." { - if err := os.MkdirAll(dir, 0700); err != nil { - return fmt.Errorf("failed to create directory %s: %w", dir, err) - } +// loadRSAPrivateKeyFromEnv 从环境变量加载 RSA 私钥 +func loadRSAPrivateKeyFromEnv() (*rsa.PrivateKey, error) { + keyPEM := os.Getenv(EnvRSAPrivateKey) + if keyPEM == "" { + return nil, fmt.Errorf("环境变量 %s 未设置,请在 .env 中配置 RSA 私钥", EnvRSAPrivateKey) } - // 生成 RSA 密钥对 - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - return err - } + // 处理环境变量中的换行符(\n -> 实际换行) + keyPEM = strings.ReplaceAll(keyPEM, "\\n", "\n") - // 编码私钥 - privateKeyPEM := pem.EncodeToMemory(&pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(privateKey), - }) - - // 保存私钥 - if err := ioutil.WriteFile(privateKeyPath, privateKeyPEM, 0600); err != nil { - return err - } - - // 编码公钥 - publicKeyDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) - if err != nil { - return err - } - - publicKeyPEM := pem.EncodeToMemory(&pem.Block{ - Type: "PUBLIC KEY", - Bytes: publicKeyDER, - }) - - // 保存公钥 - publicKeyPath := privateKeyPath + ".pub" - if err := ioutil.WriteFile(publicKeyPath, publicKeyPEM, 0644); err != nil { - return err - } - - return nil + return ParseRSAPrivateKeyFromPEM([]byte(keyPEM)) } +// loadDataKeyFromEnv 从环境变量加载 AES 数据加密密钥 +func loadDataKeyFromEnv() ([]byte, error) { + keyStr := strings.TrimSpace(os.Getenv(EnvDataEncryptionKey)) + if keyStr == "" { + return nil, fmt.Errorf("环境变量 %s 未设置,请在 .env 中配置数据加密密钥", EnvDataEncryptionKey) + } + + // 尝试解码 + if key, ok := decodePossibleKey(keyStr); ok { + return key, nil + } + + // 如果无法解码,使用 SHA256 哈希作为密钥 + sum := sha256.Sum256([]byte(keyStr)) + key := make([]byte, len(sum)) + copy(key, sum[:]) + return key, nil +} + +// ParseRSAPrivateKeyFromPEM 解析 PEM 格式的 RSA 私钥 func ParseRSAPrivateKeyFromPEM(pemBytes []byte) (*rsa.PrivateKey, error) { block, _ := pem.Decode(pemBytes) if block == nil { - return nil, errors.New("no PEM block found") + return nil, errors.New("无效的 PEM 格式") } switch block.Type { @@ -144,100 +121,15 @@ func ParseRSAPrivateKeyFromPEM(pemBytes []byte) (*rsa.PrivateKey, error) { } rsaKey, ok := key.(*rsa.PrivateKey) if !ok { - return nil, errors.New("not an RSA key") + return nil, errors.New("不是 RSA 密钥") } return rsaKey, nil default: - return nil, errors.New("unsupported key type: " + block.Type) + return nil, errors.New("不支持的密钥类型: " + block.Type) } } -func resolveDataKey() ([]byte, error) { - if key, ok := loadDataKeyFromEnv(); ok { - return key, nil - } - - key, _, err := loadOrCreateDataKeyFile(dataKeyFilePath) - return key, err -} - -func loadDataKeyFromEnv() ([]byte, bool) { - keyStr := strings.TrimSpace(os.Getenv(dataKeyEnvName)) - if keyStr == "" { - return nil, false - } - - if key, ok := decodePossibleKey(keyStr); ok { - return key, true - } - - sum := sha256.Sum256([]byte(keyStr)) - key := make([]byte, len(sum)) - copy(key, sum[:]) - return key, true -} - -var errInvalidDataKeyMaterial = errors.New("invalid data encryption key material") - -func loadOrCreateDataKeyFile(path string) ([]byte, bool, error) { - key, err := readDataKeyFromFile(path) - if err == nil { - log.Printf("🔐 使用本地数据加密密钥: %s", path) - return key, false, nil - } - - if !errors.Is(err, os.ErrNotExist) && !errors.Is(err, errInvalidDataKeyMaterial) { - log.Printf("⚠️ 无法读取数据加密密钥文件 (%s): %v,尝试重新生成", path, err) - } - - key, err = generateAndPersistDataKey(path) - if err != nil { - return nil, false, err - } - return key, true, nil -} - -func readDataKeyFromFile(path string) ([]byte, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - - encoded := strings.TrimSpace(string(data)) - if encoded == "" { - return nil, errInvalidDataKeyMaterial - } - - if key, ok := decodePossibleKey(encoded); ok { - return key, nil - } - - return nil, errInvalidDataKeyMaterial -} - -func generateAndPersistDataKey(path string) ([]byte, error) { - raw := make([]byte, 32) - if _, err := rand.Read(raw); err != nil { - return nil, err - } - - dir := filepath.Dir(path) - if dir != "" && dir != "." { - if err := os.MkdirAll(dir, 0700); err != nil { - return nil, err - } - } - - encoded := base64.StdEncoding.EncodeToString(raw) - if err := os.WriteFile(path, []byte(encoded+"\n"), 0600); err != nil { - return nil, err - } - - log.Printf("🆕 已生成新的数据加密密钥并保存到 %s", path) - log.Printf(" 若需在生产或容器环境复用,请设置 %s 为该值", dataKeyEnvName) - return raw, nil -} - +// decodePossibleKey 尝试用多种编码方式解码密钥 func decodePossibleKey(value string) ([]byte, bool) { decoders := []func(string) ([]byte, error){ base64.StdEncoding.DecodeString, @@ -256,6 +148,7 @@ func decodePossibleKey(value string) ([]byte, bool) { return nil, false } +// normalizeAESKey 标准化 AES 密钥长度 func normalizeAESKey(raw []byte) ([]byte, bool) { switch len(raw) { case 16, 24, 32: @@ -293,7 +186,7 @@ func (cs *CryptoService) EncryptForStorage(plaintext string, aadParts ...string) return "", nil } if !cs.HasDataKey() { - return "", errors.New("data encryption key not configured") + return "", errors.New("数据加密密钥未配置") } if isEncryptedStorageValue(plaintext) { return plaintext, nil @@ -327,26 +220,26 @@ func (cs *CryptoService) DecryptFromStorage(value string, aadParts ...string) (s return "", nil } if !cs.HasDataKey() { - return "", errors.New("data encryption key not configured") + return "", errors.New("数据加密密钥未配置") } if !isEncryptedStorageValue(value) { - return "", errors.New("value is not encrypted") + return "", errors.New("数据未加密") } payload := strings.TrimPrefix(value, storagePrefix) parts := strings.SplitN(payload, storageDelimiter, 2) if len(parts) != 2 { - return "", errors.New("invalid encrypted payload format") + return "", errors.New("无效的加密数据格式") } nonce, err := base64.StdEncoding.DecodeString(parts[0]) if err != nil { - return "", fmt.Errorf("decode nonce failed: %w", err) + return "", fmt.Errorf("解码 nonce 失败: %w", err) } ciphertext, err := base64.StdEncoding.DecodeString(parts[1]) if err != nil { - return "", fmt.Errorf("decode ciphertext failed: %w", err) + return "", fmt.Errorf("解码密文失败: %w", err) } block, err := aes.NewCipher(cs.dataKey) @@ -360,13 +253,13 @@ func (cs *CryptoService) DecryptFromStorage(value string, aadParts ...string) (s } if len(nonce) != gcm.NonceSize() { - return "", fmt.Errorf("invalid nonce size: expected %d, got %d", gcm.NonceSize(), len(nonce)) + return "", fmt.Errorf("无效的 nonce 长度: 期望 %d, 实际 %d", gcm.NonceSize(), len(nonce)) } aad := composeAAD(aadParts) plaintext, err := gcm.Open(nil, nonce, ciphertext, aad) if err != nil { - return "", fmt.Errorf("decryption failed: %w", err) + return "", fmt.Errorf("解密失败: %w", err) } return string(plaintext), nil @@ -392,66 +285,63 @@ func (cs *CryptoService) DecryptPayload(payload *EncryptedPayload) ([]byte, erro if payload.TS != 0 { elapsed := time.Since(time.Unix(payload.TS, 0)) if elapsed > 5*time.Minute || elapsed < -1*time.Minute { - return nil, errors.New("timestamp invalid or expired") + return nil, errors.New("时间戳无效或已过期") } } // 2. 解码 base64url wrappedKey, err := base64.RawURLEncoding.DecodeString(payload.WrappedKey) if err != nil { - return nil, fmt.Errorf("failed to decode wrapped key: %w", err) + return nil, fmt.Errorf("解码 wrapped key 失败: %w", err) } iv, err := base64.RawURLEncoding.DecodeString(payload.IV) if err != nil { - return nil, fmt.Errorf("failed to decode IV: %w", err) + return nil, fmt.Errorf("解码 IV 失败: %w", err) } ciphertext, err := base64.RawURLEncoding.DecodeString(payload.Ciphertext) if err != nil { - return nil, fmt.Errorf("failed to decode ciphertext: %w", err) + return nil, fmt.Errorf("解码密文失败: %w", err) } var aad []byte if payload.AAD != "" { aad, err = base64.RawURLEncoding.DecodeString(payload.AAD) if err != nil { - return nil, fmt.Errorf("failed to decode AAD: %w", err) + return nil, fmt.Errorf("解码 AAD 失败: %w", err) } - // 验证 AAD var aadData AADData if err := json.Unmarshal(aad, &aadData); err == nil { // 可以在这里添加额外的验证逻辑 - // 例如:验证 sessionID、userID 等 } } // 3. 使用 RSA-OAEP 解密 AES 密钥 aesKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, cs.privateKey, wrappedKey, nil) if err != nil { - return nil, fmt.Errorf("failed to unwrap AES key: %w", err) + return nil, fmt.Errorf("RSA 解密失败: %w", err) } // 4. 使用 AES-GCM 解密数据 block, err := aes.NewCipher(aesKey) if err != nil { - return nil, fmt.Errorf("failed to create AES cipher: %w", err) + return nil, fmt.Errorf("创建 AES cipher 失败: %w", err) } gcm, err := cipher.NewGCM(block) if err != nil { - return nil, fmt.Errorf("failed to create GCM: %w", err) + return nil, fmt.Errorf("创建 GCM 失败: %w", err) } if len(iv) != gcm.NonceSize() { - return nil, fmt.Errorf("invalid IV size: expected %d, got %d", gcm.NonceSize(), len(iv)) + return nil, fmt.Errorf("无效的 IV 长度: 期望 %d, 实际 %d", gcm.NonceSize(), len(iv)) } - // 解密并验证认证标签 plaintext, err := gcm.Open(nil, iv, ciphertext, aad) if err != nil { - return nil, fmt.Errorf("authentication/decryption failed: %w", err) + return nil, fmt.Errorf("解密验证失败: %w", err) } return plaintext, nil @@ -464,3 +354,41 @@ func (cs *CryptoService) DecryptSensitiveData(payload *EncryptedPayload) (string } return string(plaintext), nil } + +// GenerateKeyPair 生成 RSA 密钥对(用于初始化时生成密钥) +// 返回 PEM 格式的私钥和公钥 +func GenerateKeyPair() (privateKeyPEM, publicKeyPEM string, err error) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return "", "", err + } + + // 编码私钥 + privPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + }) + + // 编码公钥 + publicKeyDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) + if err != nil { + return "", "", err + } + + pubPEM := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: publicKeyDER, + }) + + return string(privPEM), string(pubPEM), nil +} + +// GenerateDataKey 生成 AES 数据加密密钥 +// 返回 Base64 编码的 32 字节密钥 +func GenerateDataKey() (string, error) { + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(key), nil +} diff --git a/crypto/encryption.go b/crypto/encryption.go deleted file mode 100644 index 73d1b5ba..00000000 --- a/crypto/encryption.go +++ /dev/null @@ -1,373 +0,0 @@ -package crypto - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "crypto/rsa" - "crypto/sha256" - "crypto/x509" - "encoding/base64" - "encoding/binary" - "encoding/pem" - "errors" - "fmt" - "io" - "log" - "os" - "sync" -) - -// EncryptionManager 加密管理器(單例模式) -type EncryptionManager struct { - privateKey *rsa.PrivateKey - publicKeyPEM string - masterKey []byte // 用於數據庫加密的主密鑰 - mu sync.RWMutex -} - -var ( - instance *EncryptionManager - once sync.Once -) - -// GetEncryptionManager 獲取加密管理器實例 -func GetEncryptionManager() (*EncryptionManager, error) { - var initErr error - once.Do(func() { - instance, initErr = newEncryptionManager() - }) - return instance, initErr -} - -// newEncryptionManager 初始化加密管理器 -func newEncryptionManager() (*EncryptionManager, error) { - em := &EncryptionManager{} - - // 1. 加載或生成 RSA 密鑰對 - if err := em.loadOrGenerateRSAKeyPair(); err != nil { - return nil, fmt.Errorf("初始化 RSA 密鑰失敗: %w", err) - } - - // 2. 加載或生成數據庫主密鑰 - if err := em.loadOrGenerateMasterKey(); err != nil { - return nil, fmt.Errorf("初始化主密鑰失敗: %w", err) - } - - log.Println("🔐 加密管理器初始化成功") - return em, nil -} - -// ==================== RSA 密鑰管理 ==================== - -const ( - rsaKeySize = 4096 - rsaPrivateKeyFile = ".secrets/rsa_private.pem" - rsaPublicKeyFile = ".secrets/rsa_public.pem" - masterKeyFile = ".secrets/master.key" -) - -// loadOrGenerateRSAKeyPair 加載或生成 RSA 密鑰對 -func (em *EncryptionManager) loadOrGenerateRSAKeyPair() error { - // 確保 .secrets 目錄存在 - if err := os.MkdirAll(".secrets", 0700); err != nil { - return err - } - - // 嘗試加載現有密鑰 - if _, err := os.Stat(rsaPrivateKeyFile); err == nil { - return em.loadRSAKeyPair() - } - - // 生成新密鑰對 - log.Println("🔑 生成新的 RSA-4096 密鑰對...") - privateKey, err := rsa.GenerateKey(rand.Reader, rsaKeySize) - if err != nil { - return err - } - - em.privateKey = privateKey - - // 保存私鑰 - privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey) - privateKeyPEM := pem.EncodeToMemory(&pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: privateKeyBytes, - }) - if err := os.WriteFile(rsaPrivateKeyFile, privateKeyPEM, 0600); err != nil { - return err - } - - // 保存公鑰 - publicKeyBytes, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) - if err != nil { - return err - } - publicKeyPEM := pem.EncodeToMemory(&pem.Block{ - Type: "PUBLIC KEY", - Bytes: publicKeyBytes, - }) - if err := os.WriteFile(rsaPublicKeyFile, publicKeyPEM, 0644); err != nil { - return err - } - - em.publicKeyPEM = string(publicKeyPEM) - log.Println("✅ RSA 密鑰對已生成並保存") - return nil -} - -// loadRSAKeyPair 加載 RSA 密鑰對 -func (em *EncryptionManager) loadRSAKeyPair() error { - // 加載私鑰 - privateKeyPEM, err := os.ReadFile(rsaPrivateKeyFile) - if err != nil { - return err - } - - block, _ := pem.Decode(privateKeyPEM) - if block == nil || block.Type != "RSA PRIVATE KEY" { - return errors.New("無效的私鑰 PEM 格式") - } - - privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) - if err != nil { - return err - } - em.privateKey = privateKey - - // 加載公鑰 - publicKeyPEM, err := os.ReadFile(rsaPublicKeyFile) - if err != nil { - return err - } - em.publicKeyPEM = string(publicKeyPEM) - - log.Println("✅ RSA 密鑰對已加載") - return nil -} - -// GetPublicKeyPEM 獲取公鑰 (PEM 格式) -func (em *EncryptionManager) GetPublicKeyPEM() string { - em.mu.RLock() - defer em.mu.RUnlock() - return em.publicKeyPEM -} - -// ==================== 混合解密 (RSA + AES) ==================== - -// DecryptWithPrivateKey 使用私鑰解密數據 -// 數據格式: [加密的 AES 密鑰長度(4字節)] + [加密的 AES 密鑰] + [IV(12字節)] + [加密數據] -func (em *EncryptionManager) DecryptWithPrivateKey(encryptedBase64 string) (string, error) { - em.mu.RLock() - defer em.mu.RUnlock() - - // Base64 解碼 - encryptedData, err := base64.StdEncoding.DecodeString(encryptedBase64) - if err != nil { - return "", fmt.Errorf("Base64 解碼失敗: %w", err) - } - - if len(encryptedData) < 4+256+12 { // 最小長度檢查 - return "", errors.New("加密數據長度不足") - } - - // 1. 讀取加密的 AES 密鑰長度 - aesKeyLen := binary.BigEndian.Uint32(encryptedData[:4]) - if aesKeyLen > 1024 { // 防止過大的長度值 - return "", errors.New("無效的 AES 密鑰長度") - } - - offset := 4 - // 2. 提取加密的 AES 密鑰 - encryptedAESKey := encryptedData[offset : offset+int(aesKeyLen)] - offset += int(aesKeyLen) - - // 3. 使用 RSA 私鑰解密 AES 密鑰 - aesKey, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, em.privateKey, encryptedAESKey, nil) - if err != nil { - return "", fmt.Errorf("RSA 解密失敗: %w", err) - } - - // 4. 提取 IV - iv := encryptedData[offset : offset+12] - offset += 12 - - // 5. 提取加密數據 - ciphertext := encryptedData[offset:] - - // 6. 使用 AES-GCM 解密 - block, err := aes.NewCipher(aesKey) - if err != nil { - return "", err - } - - aesGCM, err := cipher.NewGCM(block) - if err != nil { - return "", err - } - - plaintext, err := aesGCM.Open(nil, iv, ciphertext, nil) - if err != nil { - return "", fmt.Errorf("AES 解密失敗: %w", err) - } - - // 清除敏感數據 - for i := range aesKey { - aesKey[i] = 0 - } - - return string(plaintext), nil -} - -// ==================== 數據庫加密 (AES-256-GCM) ==================== - -// loadOrGenerateMasterKey 加載或生成數據庫主密鑰 -func (em *EncryptionManager) loadOrGenerateMasterKey() error { - // 優先從環境變數加載 - if envKey := os.Getenv("NOFX_MASTER_KEY"); envKey != "" { - decoded, err := base64.StdEncoding.DecodeString(envKey) - if err == nil && len(decoded) == 32 { - em.masterKey = decoded - log.Println("✅ 從環境變數加載主密鑰") - return nil - } - log.Println("⚠️ 環境變數中的主密鑰無效,使用文件密鑰") - } - - // 嘗試從文件加載 - if _, err := os.Stat(masterKeyFile); err == nil { - keyBytes, err := os.ReadFile(masterKeyFile) - if err != nil { - return err - } - decoded, err := base64.StdEncoding.DecodeString(string(keyBytes)) - if err != nil || len(decoded) != 32 { - return errors.New("主密鑰文件損壞") - } - em.masterKey = decoded - log.Println("✅ 從文件加載主密鑰") - return nil - } - - // 生成新主密鑰 - log.Println("🔑 生成新的數據庫主密鑰 (AES-256)...") - masterKey := make([]byte, 32) - if _, err := io.ReadFull(rand.Reader, masterKey); err != nil { - return err - } - - em.masterKey = masterKey - - // 保存到文件 - encoded := base64.StdEncoding.EncodeToString(masterKey) - if err := os.WriteFile(masterKeyFile, []byte(encoded), 0600); err != nil { - return err - } - - log.Println("✅ 主密鑰已生成並保存") - log.Printf("📁 主密鑰文件位置: %s (權限: 0600)", masterKeyFile) - log.Println("🔐 生產環境請設置環境變數: NOFX_MASTER_KEY=<從文件讀取>") - log.Println("⚠️ 請妥善保管 .secrets 目錄,切勿將密鑰提交到版本控制系統") - return nil -} - -// EncryptForDatabase 使用主密鑰加密數據(用於數據庫存儲) -func (em *EncryptionManager) EncryptForDatabase(plaintext string) (string, error) { - em.mu.RLock() - defer em.mu.RUnlock() - - block, err := aes.NewCipher(em.masterKey) - if err != nil { - return "", err - } - - aesGCM, err := cipher.NewGCM(block) - if err != nil { - return "", err - } - - nonce := make([]byte, aesGCM.NonceSize()) - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - return "", err - } - - ciphertext := aesGCM.Seal(nonce, nonce, []byte(plaintext), nil) - return base64.StdEncoding.EncodeToString(ciphertext), nil -} - -// DecryptFromDatabase 使用主密鑰解密數據(從數據庫讀取) -func (em *EncryptionManager) DecryptFromDatabase(encryptedBase64 string) (string, error) { - em.mu.RLock() - defer em.mu.RUnlock() - - // 處理空字符串(未加密的舊數據) - if encryptedBase64 == "" { - return "", nil - } - - ciphertext, err := base64.StdEncoding.DecodeString(encryptedBase64) - if err != nil { - return "", err - } - - block, err := aes.NewCipher(em.masterKey) - if err != nil { - return "", err - } - - aesGCM, err := cipher.NewGCM(block) - if err != nil { - return "", err - } - - nonceSize := aesGCM.NonceSize() - if len(ciphertext) < nonceSize { - return "", errors.New("加密數據過短") - } - - nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] - plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil) - if err != nil { - return "", err - } - - return string(plaintext), nil -} - -// ==================== 密鑰輪換 ==================== - -// RotateMasterKey 輪換主密鑰(需要重新加密所有數據) -func (em *EncryptionManager) RotateMasterKey() error { - em.mu.Lock() - defer em.mu.Unlock() - - log.Println("🔄 開始輪換主密鑰...") - - // 生成新主密鑰 - newMasterKey := make([]byte, 32) - if _, err := io.ReadFull(rand.Reader, newMasterKey); err != nil { - return err - } - - // 備份舊密鑰 - oldMasterKey := em.masterKey - - // 更新密鑰 - em.masterKey = newMasterKey - - // 保存新密鑰 - encoded := base64.StdEncoding.EncodeToString(newMasterKey) - backupFile := fmt.Sprintf("%s.backup.%d", masterKeyFile, os.Getpid()) - if err := os.WriteFile(backupFile, []byte(base64.StdEncoding.EncodeToString(oldMasterKey)), 0600); err != nil { - return err - } - if err := os.WriteFile(masterKeyFile, []byte(encoded), 0600); err != nil { - return err - } - - log.Println("✅ 主密鑰已輪換") - log.Printf("⚠️ 舊密鑰已備份到: %s", backupFile) - log.Printf("🔐 新主密鑰: %s", encoded) - - return nil -} diff --git a/crypto/encryption_test.go b/crypto/encryption_test.go deleted file mode 100644 index 1e65a962..00000000 --- a/crypto/encryption_test.go +++ /dev/null @@ -1,159 +0,0 @@ -package crypto - -import ( - "testing" -) - -// TestRSAKeyPairGeneration 測試 RSA 密鑰對生成 -func TestRSAKeyPairGeneration(t *testing.T) { - em, err := GetEncryptionManager() - if err != nil { - t.Fatalf("初始化加密管理器失敗: %v", err) - } - - publicKey := em.GetPublicKeyPEM() - if publicKey == "" { - t.Fatal("公鑰為空") - } - - if len(publicKey) < 100 { - t.Fatal("公鑰長度異常") - } - - t.Logf("✅ RSA 密鑰對生成成功,公鑰長度: %d", len(publicKey)) -} - -// TestDatabaseEncryption 測試數據庫加密/解密 -func TestDatabaseEncryption(t *testing.T) { - em, err := GetEncryptionManager() - if err != nil { - t.Fatalf("初始化加密管理器失敗: %v", err) - } - - testCases := []string{ - "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - "test_api_key_12345", - "very_secret_password", - "", - } - - for _, plaintext := range testCases { - // 加密 - encrypted, err := em.EncryptForDatabase(plaintext) - if err != nil { - t.Fatalf("加密失敗: %v (明文: %s)", err, plaintext) - } - - // 驗證加密後不等於明文 - if encrypted == plaintext && plaintext != "" { - t.Fatalf("加密失敗:加密後仍為明文") - } - - // 解密 - decrypted, err := em.DecryptFromDatabase(encrypted) - if err != nil { - t.Fatalf("解密失敗: %v (密文: %s)", err, encrypted) - } - - // 驗證解密後等於明文 - if decrypted != plaintext { - t.Fatalf("解密結果不匹配: 期望 %s, 得到 %s", plaintext, decrypted) - } - - t.Logf("✅ 加密/解密測試通過: %s", plaintext[:min(len(plaintext), 20)]) - } -} - -// TestHybridEncryption 測試混合加密(前端 → 後端場景) -func TestHybridEncryption(t *testing.T) { - _, err := GetEncryptionManager() - if err != nil { - t.Fatalf("初始化加密管理器失敗: %v", err) - } - // 模擬前端加密私鑰 - // plaintext := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" - // 注意:這裡需要前端的 encryptWithServerPublicKey 實現 - // 為了測試,我們直接使用後端的加密函數(實際前端使用 Web Crypto API) - - // 由於前端加密邏輯較複雜,這裡僅測試解密流程 - // 實際測試需要端到端測試 - t.Log("⚠️ 混合加密測試需要完整的前後端環境,請執行端到端測試") -} - -// TestEmptyString 測試空字串處理 -func TestEmptyString(t *testing.T) { - em, err := GetEncryptionManager() - if err != nil { - t.Fatalf("初始化加密管理器失敗: %v", err) - } - - encrypted, err := em.EncryptForDatabase("") - if err != nil { - t.Fatalf("加密空字串失敗: %v", err) - } - - decrypted, err := em.DecryptFromDatabase(encrypted) - if err != nil { - t.Fatalf("解密空字串失敗: %v", err) - } - - if decrypted != "" { - t.Fatalf("空字串處理錯誤: 期望空字串, 得到 %s", decrypted) - } - - t.Log("✅ 空字串處理正確") -} - -// TestInvalidCiphertext 測試無效密文處理 -func TestInvalidCiphertext(t *testing.T) { - em, err := GetEncryptionManager() - if err != nil { - t.Fatalf("初始化加密管理器失敗: %v", err) - } - - invalidCiphertexts := []string{ - "not_base64!@#$%", - "dGVzdA==", // 有效 Base64,但內容太短 - "", - } - - for _, ciphertext := range invalidCiphertexts { - _, err := em.DecryptFromDatabase(ciphertext) - if err == nil && ciphertext != "" { - t.Fatalf("應該拒絕無效密文: %s", ciphertext) - } - } - - t.Log("✅ 無效密文處理正確") -} - -// BenchmarkEncryption 性能測試:加密 -func BenchmarkEncryption(b *testing.B) { - em, _ := GetEncryptionManager() - plaintext := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _ = em.EncryptForDatabase(plaintext) - } -} - -// BenchmarkDecryption 性能測試:解密 -func BenchmarkDecryption(b *testing.B) { - em, _ := GetEncryptionManager() - plaintext := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" - encrypted, _ := em.EncryptForDatabase(plaintext) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _ = em.DecryptFromDatabase(encrypted) - } -} - -// min 工具函數 -func min(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/crypto/secure_storage.go b/crypto/secure_storage.go deleted file mode 100644 index b168f9f8..00000000 --- a/crypto/secure_storage.go +++ /dev/null @@ -1,302 +0,0 @@ -package crypto - -import ( - "database/sql" - "fmt" - "log" - "time" -) - -// SecureStorage 安全存儲層(自動加密/解密數據庫中的敏感字段) -type SecureStorage struct { - db *sql.DB - em *EncryptionManager -} - -// NewSecureStorage 創建安全存儲實例 -func NewSecureStorage(db *sql.DB) (*SecureStorage, error) { - em, err := GetEncryptionManager() - if err != nil { - return nil, err - } - - ss := &SecureStorage{ - db: db, - em: em, - } - - // 初始化審計日誌表 - if err := ss.initAuditLog(); err != nil { - return nil, fmt.Errorf("初始化審計日誌失敗: %w", err) - } - - return ss, nil -} - -// ==================== 交易所配置加密存儲 ==================== - -// SaveEncryptedExchangeConfig 保存加密的交易所配置 -func (ss *SecureStorage) SaveEncryptedExchangeConfig(userID, exchangeID, apiKey, secretKey, asterPrivateKey string) error { - // 加密敏感字段 - encryptedAPIKey, err := ss.em.EncryptForDatabase(apiKey) - if err != nil { - return fmt.Errorf("加密 API Key 失敗: %w", err) - } - - encryptedSecretKey, err := ss.em.EncryptForDatabase(secretKey) - if err != nil { - return fmt.Errorf("加密 Secret Key 失敗: %w", err) - } - - encryptedPrivateKey := "" - if asterPrivateKey != "" { - encryptedPrivateKey, err = ss.em.EncryptForDatabase(asterPrivateKey) - if err != nil { - return fmt.Errorf("加密 Private Key 失敗: %w", err) - } - } - - // 更新數據庫 - _, err = ss.db.Exec(` - UPDATE exchanges - SET api_key = ?, secret_key = ?, aster_private_key = ?, updated_at = datetime('now') - WHERE user_id = ? AND id = ? - `, encryptedAPIKey, encryptedSecretKey, encryptedPrivateKey, userID, exchangeID) - - if err != nil { - return err - } - - // 記錄審計日誌 - ss.logAudit(userID, "exchange_config_update", exchangeID, "密鑰已更新") - - log.Printf("🔐 [%s] 交易所 %s 的密鑰已加密保存", userID, exchangeID) - return nil -} - -// LoadDecryptedExchangeConfig 加載並解密交易所配置 -func (ss *SecureStorage) LoadDecryptedExchangeConfig(userID, exchangeID string) (apiKey, secretKey, asterPrivateKey string, err error) { - var encryptedAPIKey, encryptedSecretKey, encryptedPrivateKey sql.NullString - - err = ss.db.QueryRow(` - SELECT api_key, secret_key, aster_private_key - FROM exchanges - WHERE user_id = ? AND id = ? - `, userID, exchangeID).Scan(&encryptedAPIKey, &encryptedSecretKey, &encryptedPrivateKey) - - if err != nil { - return "", "", "", err - } - - // 解密 API Key - if encryptedAPIKey.Valid && encryptedAPIKey.String != "" { - apiKey, err = ss.em.DecryptFromDatabase(encryptedAPIKey.String) - if err != nil { - return "", "", "", fmt.Errorf("解密 API Key 失敗: %w", err) - } - } - - // 解密 Secret Key - if encryptedSecretKey.Valid && encryptedSecretKey.String != "" { - secretKey, err = ss.em.DecryptFromDatabase(encryptedSecretKey.String) - if err != nil { - return "", "", "", fmt.Errorf("解密 Secret Key 失敗: %w", err) - } - } - - // 解密 Private Key - if encryptedPrivateKey.Valid && encryptedPrivateKey.String != "" { - asterPrivateKey, err = ss.em.DecryptFromDatabase(encryptedPrivateKey.String) - if err != nil { - return "", "", "", fmt.Errorf("解密 Private Key 失敗: %w", err) - } - } - - // 記錄審計日誌 - ss.logAudit(userID, "exchange_config_read", exchangeID, "密鑰已讀取") - - return apiKey, secretKey, asterPrivateKey, nil -} - -// ==================== AI 模型配置加密存儲 ==================== - -// SaveEncryptedAIModelConfig 保存加密的 AI 模型 API Key -func (ss *SecureStorage) SaveEncryptedAIModelConfig(userID, modelID, apiKey string) error { - encryptedAPIKey, err := ss.em.EncryptForDatabase(apiKey) - if err != nil { - return fmt.Errorf("加密 API Key 失敗: %w", err) - } - - _, err = ss.db.Exec(` - UPDATE ai_models - SET api_key = ?, updated_at = datetime('now') - WHERE user_id = ? AND id = ? - `, encryptedAPIKey, userID, modelID) - - if err != nil { - return err - } - - ss.logAudit(userID, "ai_model_config_update", modelID, "API Key 已更新") - log.Printf("🔐 [%s] AI 模型 %s 的 API Key 已加密保存", userID, modelID) - return nil -} - -// LoadDecryptedAIModelConfig 加載並解密 AI 模型配置 -func (ss *SecureStorage) LoadDecryptedAIModelConfig(userID, modelID string) (string, error) { - var encryptedAPIKey sql.NullString - - err := ss.db.QueryRow(` - SELECT api_key FROM ai_models WHERE user_id = ? AND id = ? - `, userID, modelID).Scan(&encryptedAPIKey) - - if err != nil { - return "", err - } - - if !encryptedAPIKey.Valid || encryptedAPIKey.String == "" { - return "", nil - } - - apiKey, err := ss.em.DecryptFromDatabase(encryptedAPIKey.String) - if err != nil { - return "", fmt.Errorf("解密 API Key 失敗: %w", err) - } - - ss.logAudit(userID, "ai_model_config_read", modelID, "API Key 已讀取") - return apiKey, nil -} - -// ==================== 審計日誌 ==================== - -// initAuditLog 初始化審計日誌表 -func (ss *SecureStorage) initAuditLog() error { - _, err := ss.db.Exec(` - CREATE TABLE IF NOT EXISTS audit_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - action TEXT NOT NULL, - resource TEXT NOT NULL, - details TEXT, - ip_address TEXT, - user_agent TEXT, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - INDEX idx_user_time (user_id, timestamp), - INDEX idx_action (action) - ) - `) - return err -} - -// logAudit 記錄審計日誌 -func (ss *SecureStorage) logAudit(userID, action, resource, details string) { - _, err := ss.db.Exec(` - INSERT INTO audit_logs (user_id, action, resource, details) - VALUES (?, ?, ?, ?) - `, userID, action, resource, details) - - if err != nil { - log.Printf("⚠️ 審計日誌記錄失敗: %v", err) - } -} - -// GetAuditLogs 查詢審計日誌 -func (ss *SecureStorage) GetAuditLogs(userID string, limit int) ([]AuditLog, error) { - rows, err := ss.db.Query(` - SELECT id, user_id, action, resource, details, timestamp - FROM audit_logs - WHERE user_id = ? - ORDER BY timestamp DESC - LIMIT ? - `, userID, limit) - - if err != nil { - return nil, err - } - defer rows.Close() - - var logs []AuditLog - for rows.Next() { - var log AuditLog - err := rows.Scan(&log.ID, &log.UserID, &log.Action, &log.Resource, &log.Details, &log.Timestamp) - if err != nil { - return nil, err - } - logs = append(logs, log) - } - - return logs, nil -} - -// AuditLog 審計日誌結構 -type AuditLog struct { - ID int64 `json:"id"` - UserID string `json:"user_id"` - Action string `json:"action"` - Resource string `json:"resource"` - Details string `json:"details"` - Timestamp time.Time `json:"timestamp"` -} - -// ==================== 數據遷移工具 ==================== - -// MigrateToEncrypted 將舊的明文數據遷移到加密格式 -func (ss *SecureStorage) MigrateToEncrypted() error { - log.Println("🔄 開始遷移明文數據到加密格式...") - - tx, err := ss.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() - - // 遷移交易所配置 - rows, err := tx.Query(` - SELECT user_id, id, api_key, secret_key, aster_private_key - FROM exchanges - WHERE api_key != '' AND api_key NOT LIKE '%==%' -- 過濾已加密數據 - `) - if err != nil { - return err - } - - var count int - for rows.Next() { - var userID, exchangeID, apiKey, secretKey string - var asterPrivateKey sql.NullString - if err := rows.Scan(&userID, &exchangeID, &apiKey, &secretKey, &asterPrivateKey); err != nil { - rows.Close() - return err - } - - // 加密 - encAPIKey, _ := ss.em.EncryptForDatabase(apiKey) - encSecretKey, _ := ss.em.EncryptForDatabase(secretKey) - encPrivateKey := "" - if asterPrivateKey.Valid && asterPrivateKey.String != "" { - encPrivateKey, _ = ss.em.EncryptForDatabase(asterPrivateKey.String) - } - - // 更新 - _, err = tx.Exec(` - UPDATE exchanges - SET api_key = ?, secret_key = ?, aster_private_key = ? - WHERE user_id = ? AND id = ? - `, encAPIKey, encSecretKey, encPrivateKey, userID, exchangeID) - - if err != nil { - rows.Close() - return err - } - - count++ - } - rows.Close() - - if err := tx.Commit(); err != nil { - return err - } - - log.Printf("✅ 已遷移 %d 個交易所配置到加密格式", count) - return nil -} diff --git a/decision/engine.go b/decision/engine.go index 2f96a15e..470d43a9 100644 --- a/decision/engine.go +++ b/decision/engine.go @@ -3,7 +3,7 @@ package decision import ( "encoding/json" "fmt" - "log" + "nofx/logger" "math" "nofx/market" "nofx/mcp" @@ -72,6 +72,29 @@ type OITopData struct { NetShort float64 // 净空仓 } +// TradingStats 交易统计(用于AI输入) +type TradingStats struct { + TotalTrades int `json:"total_trades"` // 总交易数(已平仓) + WinRate float64 `json:"win_rate"` // 胜率 (%) + ProfitFactor float64 `json:"profit_factor"` // 盈亏比 + SharpeRatio float64 `json:"sharpe_ratio"` // 夏普比 + TotalPnL float64 `json:"total_pnl"` // 总盈亏 + AvgWin float64 `json:"avg_win"` // 平均盈利 + AvgLoss float64 `json:"avg_loss"` // 平均亏损 + MaxDrawdownPct float64 `json:"max_drawdown_pct"` // 最大回撤 (%) +} + +// RecentOrder 最近完成的订单(用于AI输入) +type RecentOrder struct { + Symbol string `json:"symbol"` // 交易对 + Side string `json:"side"` // long/short + EntryPrice float64 `json:"entry_price"` // 开仓价 + ExitPrice float64 `json:"exit_price"` // 平仓价 + RealizedPnL float64 `json:"realized_pnl"` // 已实现盈亏 + PnLPct float64 `json:"pnl_pct"` // 盈亏百分比 + FilledAt string `json:"filled_at"` // 成交时间 +} + // Context 交易上下文(传递给AI的完整信息) type Context struct { CurrentTime string `json:"current_time"` @@ -81,10 +104,11 @@ type Context struct { Positions []PositionInfo `json:"positions"` CandidateCoins []CandidateCoin `json:"candidate_coins"` PromptVariant string `json:"prompt_variant,omitempty"` - MarketDataMap map[string]*market.Data `json:"-"` // 不序列化,但内部使用 + TradingStats *TradingStats `json:"trading_stats,omitempty"` // 交易统计指标 + RecentOrders []RecentOrder `json:"recent_orders,omitempty"` // 最近完成的订单(10条) + MarketDataMap map[string]*market.Data `json:"-"` // 不序列化,但内部使用 MultiTFMarket map[string]map[string]*market.Data `json:"-"` OITopDataMap map[string]*OITopData `json:"-"` // OI Top数据映射 - Performance interface{} `json:"-"` // 历史表现分析(logger.PerformanceAnalysis) BTCETHLeverage int `json:"-"` // BTC/ETH杠杆倍数(从配置读取) AltcoinLeverage int `json:"-"` // 山寨币杠杆倍数(从配置读取) } @@ -92,7 +116,7 @@ type Context struct { // Decision AI的交易决策 type Decision struct { Symbol string `json:"symbol"` - Action string `json:"action"` // "open_long", "open_short", "close_long", "close_short", "update_stop_loss", "update_take_profit", "partial_close", "hold", "wait" + Action string `json:"action"` // "open_long", "open_short", "close_long", "close_short", "hold", "wait" // 开仓参数 Leverage int `json:"leverage,omitempty"` @@ -100,11 +124,6 @@ type Decision struct { StopLoss float64 `json:"stop_loss,omitempty"` TakeProfit float64 `json:"take_profit,omitempty"` - // 调整参数(新增) - NewStopLoss float64 `json:"new_stop_loss,omitempty"` // 用于 update_stop_loss - NewTakeProfit float64 `json:"new_take_profit,omitempty"` // 用于 update_take_profit - ClosePercentage float64 `json:"close_percentage,omitempty"` // 用于 partial_close (0-100) - // 通用参数 Confidence int `json:"confidence,omitempty"` // 信心度 (0-100) RiskUSD float64 `json:"risk_usd,omitempty"` // 最大美元风险 @@ -232,7 +251,7 @@ func fetchMarketDataForContext(ctx *Context) error { oiValue := data.OpenInterest.Latest * data.CurrentPrice oiValueInMillions := oiValue / 1_000_000 // 转换为百万美元单位 if oiValueInMillions < minOIThresholdMillions { - log.Printf("⚠️ %s 持仓价值过低(%.2fM USD < %.1fM),跳过此币种 [持仓量:%.0f × 价格:%.4f]", + logger.Infof("⚠️ %s 持仓价值过低(%.2fM USD < %.1fM),跳过此币种 [持仓量:%.0f × 价格:%.4f]", symbol, oiValueInMillions, minOIThresholdMillions, data.OpenInterest.Latest, data.CurrentPrice) continue } @@ -329,11 +348,11 @@ func buildSystemPrompt(accountEquity float64, btcEthLeverage, altcoinLeverage in template, err := GetPromptTemplate(templateName) if err != nil { // 如果模板不存在,记录错误并使用 default - log.Printf("⚠️ 提示词模板 '%s' 不存在,使用 default: %v", templateName, err) + logger.Infof("⚠️ 提示词模板 '%s' 不存在,使用 default: %v", templateName, err) template, err = GetPromptTemplate("default") if err != nil { // 如果连 default 都不存在,使用内置的简化版本 - log.Printf("❌ 无法加载任何提示词模板,使用内置简化版本") + logger.Infof("❌ 无法加载任何提示词模板,使用内置简化版本") sb.WriteString("你是专业的加密货币交易AI。请根据市场数据做出交易决策。\n\n") } else { sb.WriteString(template.Content) @@ -379,19 +398,11 @@ func buildSystemPrompt(accountEquity float64, btcEthLeverage, altcoinLeverage in sb.WriteString("- AI500 / OI_Top 筛选标签(若有)\n\n") sb.WriteString("自由运用任何有效的分析方法,但**信心度 ≥75** 才能开仓;避免单一指标、信号矛盾、横盘震荡、刚平仓即重启等低质量行为。\n\n") - // 5. 夏普比率驱动的自适应 - sb.WriteString("# 🧬 夏普比率自我进化\n\n") - sb.WriteString("- Sharpe < -0.5:立即停止交易,至少观望6个周期并深度复盘\n") - sb.WriteString("- -0.5 ~ 0:只做信心度>80的交易,并降低频率\n") - sb.WriteString("- 0 ~ 0.7:保持当前策略\n") - sb.WriteString("- >0.7:允许适度加仓,但仍遵守风控\n\n") - - // 6. 决策流程提示 + // 5. 决策流程提示 sb.WriteString("# 📋 决策流程\n\n") - sb.WriteString("1. 回顾夏普比率/盈亏 → 是否需要降频或暂停\n") - sb.WriteString("2. 检查持仓 → 是否该止盈/止损/调整\n") - sb.WriteString("3. 扫描候选币 + 多时间框 → 是否存在强信号\n") - sb.WriteString("4. 先写思维链,再输出结构化JSON\n\n") + sb.WriteString("1. 检查持仓 → 是否该止盈/止损\n") + sb.WriteString("2. 扫描候选币 + 多时间框 → 是否存在强信号\n") + sb.WriteString("3. 先写思维链,再输出结构化JSON\n\n") // 7. 输出格式 - 动态生成 sb.WriteString("# 输出格式 (严格遵守)\n\n") @@ -405,17 +416,13 @@ func buildSystemPrompt(accountEquity float64, btcEthLeverage, altcoinLeverage in sb.WriteString("第二步: JSON决策数组\n\n") sb.WriteString("```json\n[\n") sb.WriteString(fmt.Sprintf(" {\"symbol\": \"BTCUSDT\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 97000, \"take_profit\": 91000, \"confidence\": 85, \"risk_usd\": 300},\n", btcEthLeverage, accountEquity*5)) - sb.WriteString(" {\"symbol\": \"SOLUSDT\", \"action\": \"update_stop_loss\", \"new_stop_loss\": 155},\n") sb.WriteString(" {\"symbol\": \"ETHUSDT\", \"action\": \"close_long\"}\n") sb.WriteString("]\n```\n") sb.WriteString("\n\n") sb.WriteString("## 字段说明\n\n") - sb.WriteString("- `action`: open_long | open_short | close_long | close_short | update_stop_loss | update_take_profit | partial_close | hold | wait\n") + sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n") sb.WriteString("- `confidence`: 0-100(开仓建议≥75)\n") - sb.WriteString("- 开仓时必填: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n") - sb.WriteString("- update_stop_loss 时必填: new_stop_loss (注意是 new_stop_loss,不是 stop_loss)\n") - sb.WriteString("- update_take_profit 时必填: new_take_profit (注意是 new_take_profit,不是 take_profit)\n") - sb.WriteString("- partial_close 时必填: close_percentage (0-100)\n\n") + sb.WriteString("- 开仓时必填: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n\n") return sb.String() } @@ -462,7 +469,7 @@ func buildUserPrompt(ctx *Context) string { } } - // 计算仓位价值(用于 partial_close 检查) + // 计算仓位价值 positionValue := math.Abs(pos.Quantity) * pos.MarkPrice sb.WriteString(fmt.Sprintf("%d. %s %s | 入场价%.4f 当前价%.4f | 数量%.4f | 仓位价值%.2f USDT | 盈亏%+.2f%% | 盈亏金额%+.2f USDT | 最高收益率%.2f%% | 杠杆%dx | 保证金%.0f | 强平价%.4f%s\n\n", @@ -480,6 +487,38 @@ func buildUserPrompt(ctx *Context) string { sb.WriteString("当前持仓: 无\n\n") } + // 交易统计(如果有) + if ctx.TradingStats != nil && ctx.TradingStats.TotalTrades > 0 { + sb.WriteString("## 历史交易统计\n") + sb.WriteString(fmt.Sprintf("总交易数: %d | 胜率: %.1f%% | 盈亏比: %.2f | 夏普比: %.2f\n", + ctx.TradingStats.TotalTrades, + ctx.TradingStats.WinRate, + ctx.TradingStats.ProfitFactor, + ctx.TradingStats.SharpeRatio)) + sb.WriteString(fmt.Sprintf("总盈亏: %.2f USDT | 平均盈利: %.2f | 平均亏损: %.2f | 最大回撤: %.1f%%\n\n", + ctx.TradingStats.TotalPnL, + ctx.TradingStats.AvgWin, + ctx.TradingStats.AvgLoss, + ctx.TradingStats.MaxDrawdownPct)) + } + + // 最近完成的订单(如果有) + if len(ctx.RecentOrders) > 0 { + sb.WriteString("## 最近完成的交易\n") + for i, order := range ctx.RecentOrders { + resultStr := "盈利" + if order.RealizedPnL < 0 { + resultStr = "亏损" + } + sb.WriteString(fmt.Sprintf("%d. %s %s | 入场%.4f 出场%.4f | %s: %+.2f USDT (%+.2f%%) | %s\n", + i+1, order.Symbol, order.Side, + order.EntryPrice, order.ExitPrice, + resultStr, order.RealizedPnL, order.PnLPct, + order.FilledAt)) + } + sb.WriteString("\n") + } + // 候选币种(完整市场数据) sb.WriteString(fmt.Sprintf("## 候选币种 (%d个)\n\n", len(ctx.MarketDataMap))) displayedCount := 0 @@ -504,20 +543,6 @@ func buildUserPrompt(ctx *Context) string { } sb.WriteString("\n") - // 夏普比率(直接传值,不要复杂格式化) - if ctx.Performance != nil { - // 直接从interface{}中提取SharpeRatio - type PerformanceData struct { - SharpeRatio float64 `json:"sharpe_ratio"` - } - var perfData PerformanceData - if jsonData, err := json.Marshal(ctx.Performance); err == nil { - if err := json.Unmarshal(jsonData, &perfData); err == nil { - sb.WriteString(fmt.Sprintf("## 📊 夏普比率: %.2f\n\n", perfData.SharpeRatio)) - } - } - } - sb.WriteString("---\n\n") sb.WriteString("现在请分析并输出决策(思维链 + JSON)\n") @@ -556,20 +581,20 @@ func parseFullDecisionResponse(aiResponse string, accountEquity float64, btcEthL func extractCoTTrace(response string) string { // 方法1: 优先尝试提取 标签内容 if match := reReasoningTag.FindStringSubmatch(response); match != nil && len(match) > 1 { - log.Printf("✓ 使用 标签提取思维链") + logger.Infof("✓ 使用 标签提取思维链") return strings.TrimSpace(match[1]) } // 方法2: 如果没有 标签,但有 标签,提取 之前的内容 if decisionIdx := strings.Index(response, ""); decisionIdx > 0 { - log.Printf("✓ 提取 标签之前的内容作为思维链") + logger.Infof("✓ 提取 标签之前的内容作为思维链") return strings.TrimSpace(response[:decisionIdx]) } // 方法3: 后备方案 - 查找JSON数组的开始位置 jsonStart := strings.Index(response, "[") if jsonStart > 0 { - log.Printf("⚠️ 使用旧版格式([ 字符分离)提取思维链") + logger.Infof("⚠️ 使用旧版格式([ 字符分离)提取思维链") return strings.TrimSpace(response[:jsonStart]) } @@ -591,11 +616,11 @@ func extractDecisions(response string) ([]Decision, error) { var jsonPart string if match := reDecisionTag.FindStringSubmatch(s); match != nil && len(match) > 1 { jsonPart = strings.TrimSpace(match[1]) - log.Printf("✓ 使用 标签提取JSON") + logger.Infof("✓ 使用 标签提取JSON") } else { // 后备方案:使用整个响应 jsonPart = s - log.Printf("⚠️ 未找到 标签,使用全文搜索JSON") + logger.Infof("⚠️ 未找到 标签,使用全文搜索JSON") } // 修复 jsonPart 中的全角字符 @@ -621,7 +646,7 @@ func extractDecisions(response string) ([]Decision, error) { jsonContent := strings.TrimSpace(reJSONArray.FindString(jsonPart)) if jsonContent == "" { // 🔧 安全回退 (Safe Fallback):当AI只输出思维链没有JSON时,生成保底决策(避免系统崩溃) - log.Printf("⚠️ [SafeFallback] AI未输出JSON决策,进入安全等待模式 (AI response without JSON, entering safe wait mode)") + logger.Infof("⚠️ [SafeFallback] AI未输出JSON决策,进入安全等待模式 (AI response without JSON, entering safe wait mode)") // 提取思维链摘要(最多 240 字符) cotSummary := jsonPart @@ -773,15 +798,12 @@ func findMatchingBracket(s string, start int) int { func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int) error { // 验证action validActions := map[string]bool{ - "open_long": true, - "open_short": true, - "close_long": true, - "close_short": true, - "update_stop_loss": true, - "update_take_profit": true, - "partial_close": true, - "hold": true, - "wait": true, + "open_long": true, + "open_short": true, + "close_long": true, + "close_short": true, + "hold": true, + "wait": true, } if !validActions[d.Action] { @@ -803,7 +825,7 @@ func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoi return fmt.Errorf("杠杆必须大于0: %d", d.Leverage) } if d.Leverage > maxLeverage { - log.Printf("⚠️ [Leverage Fallback] %s 杠杆超限 (%dx > %dx),自动调整为上限值 %dx", + logger.Infof("⚠️ [Leverage Fallback] %s 杠杆超限 (%dx > %dx),自动调整为上限值 %dx", d.Symbol, d.Leverage, maxLeverage, maxLeverage) d.Leverage = maxLeverage // 自动修正为上限值 } @@ -883,26 +905,5 @@ func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoi } } - // 动态调整止损验证 - if d.Action == "update_stop_loss" { - if d.NewStopLoss <= 0 { - return fmt.Errorf("新止损价格必须大于0: %.2f", d.NewStopLoss) - } - } - - // 动态调整止盈验证 - if d.Action == "update_take_profit" { - if d.NewTakeProfit <= 0 { - return fmt.Errorf("新止盈价格必须大于0: %.2f", d.NewTakeProfit) - } - } - - // 部分平仓验证 - if d.Action == "partial_close" { - if d.ClosePercentage <= 0 || d.ClosePercentage > 100 { - return fmt.Errorf("平仓百分比必须在0-100之间: %.1f", d.ClosePercentage) - } - } - return nil } diff --git a/decision/prompt_test.go b/decision/prompt_test.go index 21c64830..69bec67f 100644 --- a/decision/prompt_test.go +++ b/decision/prompt_test.go @@ -13,9 +13,6 @@ func TestBuildSystemPrompt_ContainsAllValidActions(t *testing.T) { "open_short", "close_long", "close_short", - "update_stop_loss", - "update_take_profit", - "partial_close", "hold", "wait", } @@ -30,21 +27,3 @@ func TestBuildSystemPrompt_ContainsAllValidActions(t *testing.T) { } } } - -// TestBuildSystemPrompt_ActionListCompleteness 测试 action 列表的完整性 -func TestBuildSystemPrompt_ActionListCompleteness(t *testing.T) { - prompt := buildSystemPrompt(1000.0, 10, 5, "default", "") - - // 检查是否包含关键的缺失 action - missingActions := []string{ - "update_stop_loss", - "update_take_profit", - "partial_close", - } - - for _, action := range missingActions { - if !strings.Contains(prompt, action) { - t.Errorf("Prompt 缺少关键 action: %s(这会导致 AI 返回无效决策)", action) - } - } -} diff --git a/decision/validate_test.go b/decision/validate_test.go index d7e89229..468f9778 100644 --- a/decision/validate_test.go +++ b/decision/validate_test.go @@ -99,185 +99,6 @@ func TestLeverageFallback(t *testing.T) { } } -// TestUpdateStopLossValidation 测试 update_stop_loss 动作的字段验证 -func TestUpdateStopLossValidation(t *testing.T) { - tests := []struct { - name string - decision Decision - wantError bool - errorMsg string - }{ - { - name: "正确使用new_stop_loss字段", - decision: Decision{ - Symbol: "SOLUSDT", - Action: "update_stop_loss", - NewStopLoss: 155.5, - Reasoning: "移动止损至保本位", - }, - wantError: false, - }, - { - name: "new_stop_loss为0应该报错", - decision: Decision{ - Symbol: "SOLUSDT", - Action: "update_stop_loss", - NewStopLoss: 0, - Reasoning: "测试错误情况", - }, - wantError: true, - errorMsg: "新止损价格必须大于0", - }, - { - name: "new_stop_loss为负数应该报错", - decision: Decision{ - Symbol: "SOLUSDT", - Action: "update_stop_loss", - NewStopLoss: -100, - Reasoning: "测试错误情况", - }, - wantError: true, - errorMsg: "新止损价格必须大于0", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateDecision(&tt.decision, 1000.0, 10, 5) - - if (err != nil) != tt.wantError { - t.Errorf("validateDecision() error = %v, wantError %v", err, tt.wantError) - return - } - - if tt.wantError && err != nil { - if tt.errorMsg != "" && !contains(err.Error(), tt.errorMsg) { - t.Errorf("错误信息不匹配: got %q, want to contain %q", err.Error(), tt.errorMsg) - } - } - }) - } -} - -// TestUpdateTakeProfitValidation 测试 update_take_profit 动作的字段验证 -func TestUpdateTakeProfitValidation(t *testing.T) { - tests := []struct { - name string - decision Decision - wantError bool - errorMsg string - }{ - { - name: "正确使用new_take_profit字段", - decision: Decision{ - Symbol: "BTCUSDT", - Action: "update_take_profit", - NewTakeProfit: 98000, - Reasoning: "调整止盈至关键阻力位", - }, - wantError: false, - }, - { - name: "new_take_profit为0应该报错", - decision: Decision{ - Symbol: "BTCUSDT", - Action: "update_take_profit", - NewTakeProfit: 0, - Reasoning: "测试错误情况", - }, - wantError: true, - errorMsg: "新止盈价格必须大于0", - }, - { - name: "new_take_profit为负数应该报错", - decision: Decision{ - Symbol: "BTCUSDT", - Action: "update_take_profit", - NewTakeProfit: -1000, - Reasoning: "测试错误情况", - }, - wantError: true, - errorMsg: "新止盈价格必须大于0", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateDecision(&tt.decision, 1000.0, 10, 5) - - if (err != nil) != tt.wantError { - t.Errorf("validateDecision() error = %v, wantError %v", err, tt.wantError) - return - } - - if tt.wantError && err != nil { - if tt.errorMsg != "" && !contains(err.Error(), tt.errorMsg) { - t.Errorf("错误信息不匹配: got %q, want to contain %q", err.Error(), tt.errorMsg) - } - } - }) - } -} - -// TestPartialCloseValidation 测试 partial_close 动作的字段验证 -func TestPartialCloseValidation(t *testing.T) { - tests := []struct { - name string - decision Decision - wantError bool - errorMsg string - }{ - { - name: "正确使用close_percentage字段", - decision: Decision{ - Symbol: "ETHUSDT", - Action: "partial_close", - ClosePercentage: 50.0, - Reasoning: "锁定一半利润", - }, - wantError: false, - }, - { - name: "close_percentage为0应该报错", - decision: Decision{ - Symbol: "ETHUSDT", - Action: "partial_close", - ClosePercentage: 0, - Reasoning: "测试错误情况", - }, - wantError: true, - errorMsg: "平仓百分比必须在0-100之间", - }, - { - name: "close_percentage超过100应该报错", - decision: Decision{ - Symbol: "ETHUSDT", - Action: "partial_close", - ClosePercentage: 150, - Reasoning: "测试错误情况", - }, - wantError: true, - errorMsg: "平仓百分比必须在0-100之间", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateDecision(&tt.decision, 1000.0, 10, 5) - - if (err != nil) != tt.wantError { - t.Errorf("validateDecision() error = %v, wantError %v", err, tt.wantError) - return - } - - if tt.wantError && err != nil { - if tt.errorMsg != "" && !contains(err.Error(), tt.errorMsg) { - t.Errorf("错误信息不匹配: got %q, want to contain %q", err.Error(), tt.errorMsg) - } - } - }) - } -} // contains 检查字符串是否包含子串(辅助函数) func contains(s, substr string) bool { diff --git a/deploy_encryption.sh b/deploy_encryption.sh deleted file mode 100755 index 93633c1a..00000000 --- a/deploy_encryption.sh +++ /dev/null @@ -1,286 +0,0 @@ -#!/bin/bash -# NOFX 加密系統一鍵部署腳本 -# 使用方式: chmod +x deploy_encryption.sh && ./deploy_encryption.sh - -set -e # 遇到錯誤立即退出 - -# 顏色定義 -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# 輔助函數 -log_info() { - echo -e "${BLUE}ℹ️ $1${NC}" -} - -log_success() { - echo -e "${GREEN}✅ $1${NC}" -} - -log_warning() { - echo -e "${YELLOW}⚠️ $1${NC}" -} - -log_error() { - echo -e "${RED}❌ $1${NC}" -} - -# 檢查必要工具 -check_dependencies() { - log_info "檢查依賴工具..." - - if ! command -v go &> /dev/null; then - log_error "Go 未安裝,請先安裝 Go 1.21+" - exit 1 - fi - - if ! command -v npm &> /dev/null; then - log_error "npm 未安裝,請先安裝 Node.js 18+" - exit 1 - fi - - if ! command -v sqlite3 &> /dev/null; then - log_warning "sqlite3 未安裝,部分驗證功能不可用" - fi - - log_success "依賴檢查通過" -} - -# 備份數據庫 -backup_database() { - log_info "備份現有數據庫..." - - if [ -f "config.db" ]; then - BACKUP_FILE="config.db.pre_encryption.$(date +%Y%m%d_%H%M%S).backup" - cp config.db "$BACKUP_FILE" - log_success "數據庫已備份到: $BACKUP_FILE" - else - log_warning "未找到 config.db,跳過備份(首次安裝)" - fi -} - -# 創建密鑰目錄 -setup_secrets_dir() { - log_info "設置密鑰目錄..." - - if [ ! -d ".secrets" ]; then - mkdir -p .secrets - chmod 700 .secrets - log_success "密鑰目錄已創建: .secrets/" - else - log_warning "密鑰目錄已存在,跳過創建" - fi -} - -# 更新 .gitignore -update_gitignore() { - log_info "更新 .gitignore..." - - if ! grep -q ".secrets/" .gitignore 2>/dev/null; then - echo ".secrets/" >> .gitignore - log_success "已添加 .secrets/ 到 .gitignore" - fi - - if ! grep -q "config.db.backup" .gitignore 2>/dev/null; then - echo "config.db.*.backup" >> .gitignore - log_success "已添加備份檔案規則到 .gitignore" - fi -} - -# 安裝依賴 -install_dependencies() { - log_info "安裝 Go 依賴..." - go mod tidy - log_success "Go 依賴已更新" - - log_info "安裝前端依賴..." - cd web - if [ ! -d "node_modules" ]; then - npm install - fi - npm install tweetnacl tweetnacl-util @noble/secp256k1 --save - cd .. - log_success "前端依賴已安裝" -} - -# 運行測試 -run_tests() { - log_info "運行加密系統測試..." - - if go test ./crypto -v > /tmp/nofx_test.log 2>&1; then - log_success "加密系統測試通過" - cat /tmp/nofx_test.log | grep "✅" - else - log_error "加密系統測試失敗,詳情:" - cat /tmp/nofx_test.log - exit 1 - fi -} - -# 遷移數據 -migrate_data() { - log_info "遷移現有數據到加密格式..." - - if [ -f "config.db" ]; then - # 檢查是否已經加密過 - if sqlite3 config.db "SELECT api_key FROM exchanges LIMIT 1;" 2>/dev/null | grep -q "=="; then - log_warning "數據庫似乎已經加密過,跳過遷移" - read -p "是否強制重新遷移?(y/N): " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - return - fi - fi - - if go run scripts/migrate_encryption.go; then - log_success "數據遷移完成" - else - log_error "數據遷移失敗" - exit 1 - fi - else - log_warning "未找到數據庫,跳過遷移" - fi -} - -# 設置環境變數 -setup_env_vars() { - log_info "設置環境變數..." - - if [ -f ".secrets/master.key" ]; then - MASTER_KEY=$(cat .secrets/master.key) - - # 添加到當前 shell 配置 - SHELL_RC="$HOME/.bashrc" - if [ -f "$HOME/.zshrc" ]; then - SHELL_RC="$HOME/.zshrc" - fi - - if ! grep -q "NOFX_MASTER_KEY" "$SHELL_RC" 2>/dev/null; then - echo "" >> "$SHELL_RC" - echo "# NOFX 加密系統主密鑰" >> "$SHELL_RC" - echo "export NOFX_MASTER_KEY='$MASTER_KEY'" >> "$SHELL_RC" - log_success "主密鑰已添加到 $SHELL_RC" - else - log_warning "主密鑰已存在於 $SHELL_RC" - fi - - # 導出到當前 session - export NOFX_MASTER_KEY="$MASTER_KEY" - log_success "主密鑰已導出到當前 session" - else - log_warning "主密鑰文件未生成,請先運行應用初始化" - fi -} - -# 驗證部署 -verify_deployment() { - log_info "驗證部署結果..." - - # 1. 檢查密鑰檔案 - if [ -f ".secrets/rsa_private.pem" ] && [ -f ".secrets/rsa_public.pem" ] && [ -f ".secrets/master.key" ]; then - log_success "密鑰檔案完整" - else - log_error "密鑰檔案缺失,請檢查日誌" - return 1 - fi - - # 2. 檢查檔案權限 - PERM=$(stat -f "%Lp" .secrets 2>/dev/null || stat -c "%a" .secrets 2>/dev/null) - if [ "$PERM" = "700" ]; then - log_success "密鑰目錄權限正確 (700)" - else - log_warning "密鑰目錄權限為 $PERM,建議修改為 700" - chmod 700 .secrets - fi - - # 3. 檢查資料庫加密 - if [ -f "config.db" ] && command -v sqlite3 &> /dev/null; then - SAMPLE=$(sqlite3 config.db "SELECT api_key FROM exchanges WHERE api_key != '' LIMIT 1;" 2>/dev/null || echo "") - if echo "$SAMPLE" | grep -q "=="; then - log_success "數據庫密鑰已加密(Base64 格式)" - else - log_warning "數據庫可能未加密或無數據" - fi - fi - - log_success "部署驗證通過" -} - -# 打印後續步驟 -print_next_steps() { - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo -e "${GREEN}🎉 加密系統部署成功!${NC}" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "📝 後續步驟:" - echo "" - echo " 1️⃣ 啟動後端服務:" - echo " $ go run main.go" - echo "" - echo " 2️⃣ 啟動前端服務:" - echo " $ cd web && npm run dev" - echo "" - echo " 3️⃣ 驗證加密功能:" - echo " $ curl http://localhost:8080/api/crypto/public-key" - echo "" - echo " 4️⃣ 查看審計日誌:" - echo " $ sqlite3 config.db 'SELECT * FROM audit_logs ORDER BY timestamp DESC LIMIT 10;'" - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "⚠️ 重要提醒:" - echo "" - echo " • 請妥善保管 .secrets/ 目錄(已設置為 700 權限)" - echo " • 生產環境務必使用環境變數管理主密鑰" - echo " • 定期執行密鑰輪換(建議每季度一次)" - echo " • 數據庫備份已保存,驗證無誤後可手動刪除" - echo "" - echo "📚 詳細文檔:" - echo " - 快速開始: cat SECURITY_QUICKSTART.md" - echo " - 完整指南: cat ENCRYPTION_DEPLOYMENT.md" - echo "" -} - -# 主函數 -main() { - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo -e "${BLUE}🔐 NOFX 加密系統部署腳本${NC}" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # 確認執行 - log_warning "此腳本將:" - echo " 1. 備份現有數據庫" - echo " 2. 生成 RSA-4096 密鑰對" - echo " 3. 生成 AES-256 主密鑰" - echo " 4. 遷移現有數據到加密格式" - echo " 5. 設置環境變數" - echo "" - read -p "是否繼續?(y/N): " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - log_info "已取消部署" - exit 0 - fi - - # 執行部署步驟 - check_dependencies - backup_database - setup_secrets_dir - update_gitignore - install_dependencies - run_tests - migrate_data - setup_env_vars - verify_deployment - print_next_steps -} - -# 執行主函數 -main diff --git a/docker-compose.yml b/docker-compose.yml index 0fe50998..72be2ed1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,17 +11,17 @@ services: - "${NOFX_BACKEND_PORT:-8080}:8080" volumes: - ./config.json:/app/config.json:ro - - ./config.db:/app/config.db + - ./data.db:/app/data.db - ./beta_codes.txt:/app/beta_codes.txt:ro - ./decision_logs:/app/decision_logs - ./prompts:/app/prompts - - ./secrets:/app/secrets:ro # RSA密钥文件 - /etc/localtime:/etc/localtime:ro # Sync host time environment: - TZ=${NOFX_TIMEZONE:-Asia/Shanghai} # Set timezone - AI_MAX_TOKENS=4000 # AI响应的最大token数(默认2000,建议4000-8000) - DATA_ENCRYPTION_KEY=${DATA_ENCRYPTION_KEY} # 数据库加密密钥 - JWT_SECRET=${JWT_SECRET} # JWT认证密钥 + - RSA_PRIVATE_KEY=${RSA_PRIVATE_KEY} # RSA私钥(客户端加密) networks: - nofx-network healthcheck: diff --git a/logger/config.go b/logger/config.go index 32774558..f18eb041 100644 --- a/logger/config.go +++ b/logger/config.go @@ -1,21 +1,8 @@ package logger -import ( - "github.com/sirupsen/logrus" -) - // Config 日志配置(简化版) type Config struct { - Level string `json:"level"` // 日志级别: debug, info, warn, error (默认: info) - Telegram *TelegramConfig `json:"telegram"` // Telegram推送配置(可选) -} - -// TelegramConfig Telegram推送配置(简化版,高级参数使用默认值) -type TelegramConfig struct { - Enabled bool `json:"enabled"` // 是否启用(默认: false) - BotToken string `json:"bot_token"` // Bot Token - ChatID int64 `json:"chat_id"` // Chat ID - MinLevel string `json:"min_level"` // 最低日志级别,该级别及以上的日志会推送到Telegram(可选,默认: error) + Level string `json:"level"` // 日志级别: debug, info, warn, error (默认: info) } // SetDefaults 设置默认值 @@ -24,41 +11,3 @@ func (c *Config) SetDefaults() { c.Level = "info" } } - -// GetLogrusLevels 返回要推送到Telegram的日志级别 -// 根据配置的MinLevel返回该级别及以上的所有日志级别 -// 如果未配置或配置无效,默认返回error, fatal, panic(向后兼容) -func (tc *TelegramConfig) GetLogrusLevels() []logrus.Level { - // 如果未配置,使用默认值error(向后兼容) - minLevelStr := tc.MinLevel - if minLevelStr == "" { - minLevelStr = "error" - } - - // 解析配置的日志级别 - minLevel, err := logrus.ParseLevel(minLevelStr) - if err != nil { - // 如果解析失败,使用默认值error(向后兼容) - minLevel = logrus.ErrorLevel - } - - // 定义所有日志级别(从高到低:panic, fatal, error, warn, info, debug) - allLevels := []logrus.Level{ - logrus.PanicLevel, - logrus.FatalLevel, - logrus.ErrorLevel, - logrus.WarnLevel, - logrus.InfoLevel, - logrus.DebugLevel, - } - - // 返回所有大于等于minLevel的日志级别 - var result []logrus.Level - for _, level := range allLevels { - if level <= minLevel { - result = append(result, level) - } - } - - return result -} diff --git a/logger/config.telegram.json b/logger/config.telegram.json deleted file mode 100644 index 197c0802..00000000 --- a/logger/config.telegram.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "traders": [ - { - "id": "trader1", - "name": "AI Trader 1", - "enabled": true, - "ai_model": "deepseek", - "exchange": "binance", - "binance_api_key": "your_api_key", - "binance_secret_key": "your_secret_key", - "deepseek_key": "your_deepseek_key", - "initial_balance": 1000, - "scan_interval_minutes": 3 - } - ], - "use_default_coins": true, - "default_coins": ["BTCUSDT", "ETHUSDT", "SOLUSDT"], - "api_server_port": 8080, - "leverage": { - "btc_eth_leverage": 5, - "altcoin_leverage": 5 - }, - "log": { - "level": "info", - "telegram": { - "enabled": true, - "bot_token": "79472419:feafe231414", - "chat_id": -100323252626, - "min_level": "error" - } - }, - "_comment": "日志配置说明:level 可选值为 debug/info/warn/error,默认 info。telegram 部分作为可选配置, Telegram 推送默认为 error/fatal/panic 级别,min_level 如果设置为warn,则推送warn级别及以上的日志" -} diff --git a/logger/decision_logger.go b/logger/decision_logger.go deleted file mode 100644 index 2ac77c88..00000000 --- a/logger/decision_logger.go +++ /dev/null @@ -1,768 +0,0 @@ -package logger - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "math" - "os" - "path/filepath" - "time" -) - -// DecisionRecord 决策记录 -type DecisionRecord struct { - Timestamp time.Time `json:"timestamp"` // 决策时间 - CycleNumber int `json:"cycle_number"` // 周期编号 - SystemPrompt string `json:"system_prompt"` // 系统提示词(发送给AI的系统prompt) - InputPrompt string `json:"input_prompt"` // 发送给AI的输入prompt - CoTTrace string `json:"cot_trace"` // AI思维链(输出) - DecisionJSON string `json:"decision_json"` // 决策JSON - AccountState AccountSnapshot `json:"account_state"` // 账户状态快照 - Positions []PositionSnapshot `json:"positions"` // 持仓快照 - CandidateCoins []string `json:"candidate_coins"` // 候选币种列表 - Decisions []DecisionAction `json:"decisions"` // 执行的决策 - ExecutionLog []string `json:"execution_log"` // 执行日志 - Success bool `json:"success"` // 是否成功 - ErrorMessage string `json:"error_message"` // 错误信息(如果有) - // AIRequestDurationMs 记录 AI API 调用耗时(毫秒),方便评估调用性能 - AIRequestDurationMs int64 `json:"ai_request_duration_ms,omitempty"` -} - -// AccountSnapshot 账户状态快照 -type AccountSnapshot struct { - TotalBalance float64 `json:"total_balance"` - AvailableBalance float64 `json:"available_balance"` - TotalUnrealizedProfit float64 `json:"total_unrealized_profit"` - PositionCount int `json:"position_count"` - MarginUsedPct float64 `json:"margin_used_pct"` - InitialBalance float64 `json:"initial_balance"` // 记录当时的初始余额基准 -} - -// PositionSnapshot 持仓快照 -type PositionSnapshot struct { - Symbol string `json:"symbol"` - Side string `json:"side"` - PositionAmt float64 `json:"position_amt"` - EntryPrice float64 `json:"entry_price"` - MarkPrice float64 `json:"mark_price"` - UnrealizedProfit float64 `json:"unrealized_profit"` - Leverage float64 `json:"leverage"` - LiquidationPrice float64 `json:"liquidation_price"` -} - -// DecisionAction 决策动作 -type DecisionAction struct { - Action string `json:"action"` // open_long, open_short, close_long, close_short, update_stop_loss, update_take_profit, partial_close - Symbol string `json:"symbol"` // 币种 - Quantity float64 `json:"quantity"` // 数量(部分平仓时使用) - Leverage int `json:"leverage"` // 杠杆(开仓时) - Price float64 `json:"price"` // 执行价格 - OrderID int64 `json:"order_id"` // 订单ID - Timestamp time.Time `json:"timestamp"` // 执行时间 - Success bool `json:"success"` // 是否成功 - Error string `json:"error"` // 错误信息 -} - -// IDecisionLogger 决策日志记录器接口 -type IDecisionLogger interface { - // LogDecision 记录决策 - LogDecision(record *DecisionRecord) error - // GetLatestRecords 获取最近N条记录(按时间正序:从旧到新) - GetLatestRecords(n int) ([]*DecisionRecord, error) - // GetRecordByDate 获取指定日期的所有记录 - GetRecordByDate(date time.Time) ([]*DecisionRecord, error) - // CleanOldRecords 清理N天前的旧记录 - CleanOldRecords(days int) error - // GetStatistics 获取统计信息 - GetStatistics() (*Statistics, error) - // AnalyzePerformance 分析最近N个周期的交易表现 - AnalyzePerformance(lookbackCycles int) (*PerformanceAnalysis, error) - // SetCycleNumber 允许恢复内部计数(用于回测恢复) - SetCycleNumber(n int) -} - -// DecisionLogger 决策日志记录器 -type DecisionLogger struct { - logDir string - cycleNumber int -} - -// NewDecisionLogger 创建决策日志记录器 -func NewDecisionLogger(logDir string) IDecisionLogger { - if logDir == "" { - logDir = "decision_logs" - } - - // 确保日志目录存在(使用安全权限:只有所有者可访问) - if err := os.MkdirAll(logDir, 0700); err != nil { - fmt.Printf("⚠ 创建日志目录失败: %v\n", err) - } - - // 强制设置目录权限(即使目录已存在)- 确保安全 - if err := os.Chmod(logDir, 0700); err != nil { - fmt.Printf("⚠ 设置日志目录权限失败: %v\n", err) - } - - return &DecisionLogger{ - logDir: logDir, - cycleNumber: 0, - } -} - -// SetCycleNumber 允许外部恢复内部的周期计数(用于回测恢复)。 -func (l *DecisionLogger) SetCycleNumber(n int) { - if n > 0 { - l.cycleNumber = n - } -} - -// LogDecision 记录决策 -func (l *DecisionLogger) LogDecision(record *DecisionRecord) error { - l.cycleNumber++ - record.CycleNumber = l.cycleNumber - if record.Timestamp.IsZero() { - record.Timestamp = time.Now().UTC() - } else { - record.Timestamp = record.Timestamp.UTC() - } - - // 生成文件名:decision_YYYYMMDD_HHMMSS_cycleN.json - filename := fmt.Sprintf("decision_%s_cycle%d.json", - record.Timestamp.Format("20060102_150405"), - record.CycleNumber) - - filepath := filepath.Join(l.logDir, filename) - - // 序列化为JSON(带缩进,方便阅读) - data, err := json.MarshalIndent(record, "", " ") - if err != nil { - return fmt.Errorf("序列化决策记录失败: %w", err) - } - - // 写入文件(使用安全权限:只有所有者可读写) - if err := ioutil.WriteFile(filepath, data, 0600); err != nil { - return fmt.Errorf("写入决策记录失败: %w", err) - } - - fmt.Printf("📝 决策记录已保存: %s\n", filename) - return nil -} - -// GetLatestRecords 获取最近N条记录(按时间正序:从旧到新) -func (l *DecisionLogger) GetLatestRecords(n int) ([]*DecisionRecord, error) { - files, err := ioutil.ReadDir(l.logDir) - if err != nil { - return nil, fmt.Errorf("读取日志目录失败: %w", err) - } - - // 先按修改时间倒序收集(最新的在前) - var records []*DecisionRecord - count := 0 - for i := len(files) - 1; i >= 0 && count < n; i-- { - file := files[i] - if file.IsDir() { - continue - } - - filepath := filepath.Join(l.logDir, file.Name()) - data, err := ioutil.ReadFile(filepath) - if err != nil { - continue - } - - var record DecisionRecord - if err := json.Unmarshal(data, &record); err != nil { - continue - } - - records = append(records, &record) - count++ - } - - // 反转数组,让时间从旧到新排列(用于图表显示) - for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 { - records[i], records[j] = records[j], records[i] - } - - return records, nil -} - -// GetRecordByDate 获取指定日期的所有记录 -func (l *DecisionLogger) GetRecordByDate(date time.Time) ([]*DecisionRecord, error) { - dateStr := date.Format("20060102") - pattern := filepath.Join(l.logDir, fmt.Sprintf("decision_%s_*.json", dateStr)) - - files, err := filepath.Glob(pattern) - if err != nil { - return nil, fmt.Errorf("查找日志文件失败: %w", err) - } - - var records []*DecisionRecord - for _, filepath := range files { - data, err := ioutil.ReadFile(filepath) - if err != nil { - continue - } - - var record DecisionRecord - if err := json.Unmarshal(data, &record); err != nil { - continue - } - - records = append(records, &record) - } - - return records, nil -} - -// CleanOldRecords 清理N天前的旧记录 -func (l *DecisionLogger) CleanOldRecords(days int) error { - cutoffTime := time.Now().AddDate(0, 0, -days) - - files, err := ioutil.ReadDir(l.logDir) - if err != nil { - return fmt.Errorf("读取日志目录失败: %w", err) - } - - removedCount := 0 - for _, file := range files { - if file.IsDir() { - continue - } - - if file.ModTime().Before(cutoffTime) { - filepath := filepath.Join(l.logDir, file.Name()) - if err := os.Remove(filepath); err != nil { - fmt.Printf("⚠ 删除旧记录失败 %s: %v\n", file.Name(), err) - continue - } - removedCount++ - } - } - - if removedCount > 0 { - fmt.Printf("🗑️ 已清理 %d 条旧记录(%d天前)\n", removedCount, days) - } - - return nil -} - -// GetStatistics 获取统计信息 -func (l *DecisionLogger) GetStatistics() (*Statistics, error) { - files, err := ioutil.ReadDir(l.logDir) - if err != nil { - return nil, fmt.Errorf("读取日志目录失败: %w", err) - } - - stats := &Statistics{} - - for _, file := range files { - if file.IsDir() { - continue - } - - filepath := filepath.Join(l.logDir, file.Name()) - data, err := ioutil.ReadFile(filepath) - if err != nil { - continue - } - - var record DecisionRecord - if err := json.Unmarshal(data, &record); err != nil { - continue - } - - stats.TotalCycles++ - - for _, action := range record.Decisions { - if action.Success { - switch action.Action { - case "open_long", "open_short": - stats.TotalOpenPositions++ - case "close_long", "close_short", "auto_close_long", "auto_close_short": - stats.TotalClosePositions++ - // 🔧 BUG FIX:partial_close 不計入 TotalClosePositions,避免重複計數 - // case "partial_close": // 不計數,因為只有完全平倉才算一次 - // update_stop_loss 和 update_take_profit 不計入統計 - } - } - } - - if record.Success { - stats.SuccessfulCycles++ - } else { - stats.FailedCycles++ - } - } - - return stats, nil -} - -// Statistics 统计信息 -type Statistics struct { - TotalCycles int `json:"total_cycles"` - SuccessfulCycles int `json:"successful_cycles"` - FailedCycles int `json:"failed_cycles"` - TotalOpenPositions int `json:"total_open_positions"` - TotalClosePositions int `json:"total_close_positions"` -} - -// TradeOutcome 单笔交易结果 -type TradeOutcome struct { - Symbol string `json:"symbol"` // 币种 - Side string `json:"side"` // long/short - Quantity float64 `json:"quantity"` // 仓位数量 - Leverage int `json:"leverage"` // 杠杆倍数 - OpenPrice float64 `json:"open_price"` // 开仓价 - ClosePrice float64 `json:"close_price"` // 平仓价 - PositionValue float64 `json:"position_value"` // 仓位价值(quantity × openPrice) - MarginUsed float64 `json:"margin_used"` // 保证金使用(positionValue / leverage) - PnL float64 `json:"pn_l"` // 盈亏(USDT) - PnLPct float64 `json:"pn_l_pct"` // 盈亏百分比(相对保证金) - Duration string `json:"duration"` // 持仓时长 - OpenTime time.Time `json:"open_time"` // 开仓时间 - CloseTime time.Time `json:"close_time"` // 平仓时间 - WasStopLoss bool `json:"was_stop_loss"` // 是否止损 -} - -// PerformanceAnalysis 交易表现分析 -type PerformanceAnalysis struct { - TotalTrades int `json:"total_trades"` // 总交易数 - WinningTrades int `json:"winning_trades"` // 盈利交易数 - LosingTrades int `json:"losing_trades"` // 亏损交易数 - WinRate float64 `json:"win_rate"` // 胜率 - AvgWin float64 `json:"avg_win"` // 平均盈利 - AvgLoss float64 `json:"avg_loss"` // 平均亏损 - ProfitFactor float64 `json:"profit_factor"` // 盈亏比 - SharpeRatio float64 `json:"sharpe_ratio"` // 夏普比率(风险调整后收益) - RecentTrades []TradeOutcome `json:"recent_trades"` // 最近N笔交易 - SymbolStats map[string]*SymbolPerformance `json:"symbol_stats"` // 各币种表现 - BestSymbol string `json:"best_symbol"` // 表现最好的币种 - WorstSymbol string `json:"worst_symbol"` // 表现最差的币种 -} - -// SymbolPerformance 币种表现统计 -type SymbolPerformance struct { - Symbol string `json:"symbol"` // 币种 - TotalTrades int `json:"total_trades"` // 交易次数 - WinningTrades int `json:"winning_trades"` // 盈利次数 - LosingTrades int `json:"losing_trades"` // 亏损次数 - WinRate float64 `json:"win_rate"` // 胜率 - TotalPnL float64 `json:"total_pn_l"` // 总盈亏 - AvgPnL float64 `json:"avg_pn_l"` // 平均盈亏 -} - -// AnalyzePerformance 分析最近N个周期的交易表现 -func (l *DecisionLogger) AnalyzePerformance(lookbackCycles int) (*PerformanceAnalysis, error) { - records, err := l.GetLatestRecords(lookbackCycles) - if err != nil { - return nil, fmt.Errorf("读取历史记录失败: %w", err) - } - - if len(records) == 0 { - return &PerformanceAnalysis{ - RecentTrades: []TradeOutcome{}, - SymbolStats: make(map[string]*SymbolPerformance), - }, nil - } - - analysis := &PerformanceAnalysis{ - RecentTrades: []TradeOutcome{}, - SymbolStats: make(map[string]*SymbolPerformance), - } - - // 追踪持仓状态:symbol_side -> {side, openPrice, openTime, quantity, leverage} - openPositions := make(map[string]map[string]interface{}) - - // 为了避免开仓记录在窗口外导致匹配失败,需要先从所有历史记录中找出未平仓的持仓 - // 获取更多历史记录来构建完整的持仓状态(使用更大的窗口) - allRecords, err := l.GetLatestRecords(lookbackCycles * 3) // 扩大3倍窗口 - if err == nil && len(allRecords) > len(records) { - // 先从扩大的窗口中收集所有开仓记录 - for _, record := range allRecords { - for _, action := range record.Decisions { - if !action.Success { - continue - } - - symbol := action.Symbol - side := "" - if action.Action == "open_long" || action.Action == "close_long" || action.Action == "partial_close" || action.Action == "auto_close_long" { - side = "long" - } else if action.Action == "open_short" || action.Action == "close_short" || action.Action == "auto_close_short" { - side = "short" - } - - // partial_close 需要根據持倉判斷方向 - if action.Action == "partial_close" && side == "" { - for key, pos := range openPositions { - if posSymbol, _ := pos["side"].(string); key == symbol+"_"+posSymbol { - side = posSymbol - break - } - } - } - - posKey := symbol + "_" + side - - switch action.Action { - case "open_long", "open_short": - // 记录开仓 - openPositions[posKey] = map[string]interface{}{ - "side": side, - "openPrice": action.Price, - "openTime": action.Timestamp, - "quantity": action.Quantity, - "leverage": action.Leverage, - } - case "close_long", "close_short", "auto_close_long", "auto_close_short": - // 移除已平仓记录 - delete(openPositions, posKey) - // partial_close 不處理,保留持倉記錄 - } - } - } - } - - // 遍历分析窗口内的记录,生成交易结果 - for _, record := range records { - for _, action := range record.Decisions { - if !action.Success { - continue - } - - symbol := action.Symbol - side := "" - if action.Action == "open_long" || action.Action == "close_long" || action.Action == "partial_close" || action.Action == "auto_close_long" { - side = "long" - } else if action.Action == "open_short" || action.Action == "close_short" || action.Action == "auto_close_short" { - side = "short" - } - - // partial_close 需要根據持倉判斷方向 - if action.Action == "partial_close" { - // 從 openPositions 中查找持倉方向 - for key, pos := range openPositions { - if posSymbol, _ := pos["side"].(string); key == symbol+"_"+posSymbol { - side = posSymbol - break - } - } - } - - posKey := symbol + "_" + side // 使用symbol_side作为key,区分多空持仓 - - switch action.Action { - case "open_long", "open_short": - // 更新开仓记录(可能已经在预填充时记录过了) - openPositions[posKey] = map[string]interface{}{ - "side": side, - "openPrice": action.Price, - "openTime": action.Timestamp, - "quantity": action.Quantity, - "leverage": action.Leverage, - "remainingQuantity": action.Quantity, // 🔧 BUG FIX:追蹤剩餘數量 - "accumulatedPnL": 0.0, // 🔧 BUG FIX:累積部分平倉盈虧 - "partialCloseCount": 0, // 🔧 BUG FIX:部分平倉次數 - "partialCloseVolume": 0.0, // 🔧 BUG FIX:部分平倉總量 - } - - case "close_long", "close_short", "partial_close", "auto_close_long", "auto_close_short": - // 查找对应的开仓记录(可能来自预填充或当前窗口) - if openPos, exists := openPositions[posKey]; exists { - openPrice := openPos["openPrice"].(float64) - openTime := openPos["openTime"].(time.Time) - side := openPos["side"].(string) - quantity := openPos["quantity"].(float64) - leverage := openPos["leverage"].(int) - - // 🔧 BUG FIX:取得追蹤字段(若不存在則初始化) - remainingQty, _ := openPos["remainingQuantity"].(float64) - if remainingQty == 0 { - remainingQty = quantity // 兼容舊數據(沒有 remainingQuantity 字段) - } - accumulatedPnL, _ := openPos["accumulatedPnL"].(float64) - partialCloseCount, _ := openPos["partialCloseCount"].(int) - partialCloseVolume, _ := openPos["partialCloseVolume"].(float64) - - // 对于 partial_close,使用实际平仓数量;否则使用剩余仓位数量 - actualQuantity := remainingQty - if action.Action == "partial_close" { - actualQuantity = action.Quantity - } - - // 计算本次平仓的盈亏(USDT) - var pnl float64 - if side == "long" { - pnl = actualQuantity * (action.Price - openPrice) - } else { - pnl = actualQuantity * (openPrice - action.Price) - } - - // 🔧 BUG FIX:處理 partial_close 聚合邏輯 - if action.Action == "partial_close" { - // 累積盈虧和數量 - accumulatedPnL += pnl - remainingQty -= actualQuantity - partialCloseCount++ - partialCloseVolume += actualQuantity - - // 更新 openPositions(保留持倉記錄,但更新追蹤數據) - openPos["remainingQuantity"] = remainingQty - openPos["accumulatedPnL"] = accumulatedPnL - openPos["partialCloseCount"] = partialCloseCount - openPos["partialCloseVolume"] = partialCloseVolume - - // 判斷是否已完全平倉 - if remainingQty <= 0.0001 { // 使用小閾值避免浮點誤差 - // ✅ 完全平倉:記錄為一筆完整交易 - positionValue := quantity * openPrice - marginUsed := positionValue / float64(leverage) - pnlPct := 0.0 - if marginUsed > 0 { - pnlPct = (accumulatedPnL / marginUsed) * 100 - } - - outcome := TradeOutcome{ - Symbol: symbol, - Side: side, - Quantity: quantity, // 使用原始總量 - Leverage: leverage, - OpenPrice: openPrice, - ClosePrice: action.Price, // 最後一次平倉價格 - PositionValue: positionValue, - MarginUsed: marginUsed, - PnL: accumulatedPnL, // 🔧 使用累積盈虧 - PnLPct: pnlPct, - Duration: action.Timestamp.Sub(openTime).String(), - OpenTime: openTime, - CloseTime: action.Timestamp, - } - - analysis.RecentTrades = append(analysis.RecentTrades, outcome) - analysis.TotalTrades++ // 🔧 只在完全平倉時計數 - - // 分类交易 - if accumulatedPnL > 0 { - analysis.WinningTrades++ - analysis.AvgWin += accumulatedPnL - } else if accumulatedPnL < 0 { - analysis.LosingTrades++ - analysis.AvgLoss += accumulatedPnL - } - - // 更新币种统计 - if _, exists := analysis.SymbolStats[symbol]; !exists { - analysis.SymbolStats[symbol] = &SymbolPerformance{ - Symbol: symbol, - } - } - stats := analysis.SymbolStats[symbol] - stats.TotalTrades++ - stats.TotalPnL += accumulatedPnL - if accumulatedPnL > 0 { - stats.WinningTrades++ - } else if accumulatedPnL < 0 { - stats.LosingTrades++ - } - - // 刪除持倉記錄 - delete(openPositions, posKey) - } - // ⚠️ 否則不做任何操作(等待後續 partial_close 或 full close) - - } else { - // 🔧 完全平倉(close_long/close_short/auto_close) - // 如果之前有部分平倉,需要加上累積的 PnL - totalPnL := accumulatedPnL + pnl - - positionValue := quantity * openPrice - marginUsed := positionValue / float64(leverage) - pnlPct := 0.0 - if marginUsed > 0 { - pnlPct = (totalPnL / marginUsed) * 100 - } - - outcome := TradeOutcome{ - Symbol: symbol, - Side: side, - Quantity: quantity, // 使用原始總量 - Leverage: leverage, - OpenPrice: openPrice, - ClosePrice: action.Price, - PositionValue: positionValue, - MarginUsed: marginUsed, - PnL: totalPnL, // 🔧 包含之前部分平倉的 PnL - PnLPct: pnlPct, - Duration: action.Timestamp.Sub(openTime).String(), - OpenTime: openTime, - CloseTime: action.Timestamp, - } - - analysis.RecentTrades = append(analysis.RecentTrades, outcome) - analysis.TotalTrades++ - - // 分类交易 - if totalPnL > 0 { - analysis.WinningTrades++ - analysis.AvgWin += totalPnL - } else if totalPnL < 0 { - analysis.LosingTrades++ - analysis.AvgLoss += totalPnL - } - - // 更新币种统计 - if _, exists := analysis.SymbolStats[symbol]; !exists { - analysis.SymbolStats[symbol] = &SymbolPerformance{ - Symbol: symbol, - } - } - stats := analysis.SymbolStats[symbol] - stats.TotalTrades++ - stats.TotalPnL += totalPnL - if totalPnL > 0 { - stats.WinningTrades++ - } else if totalPnL < 0 { - stats.LosingTrades++ - } - - // 刪除持倉記錄 - delete(openPositions, posKey) - } - } - } - } - } - - // 计算统计指标 - if analysis.TotalTrades > 0 { - analysis.WinRate = (float64(analysis.WinningTrades) / float64(analysis.TotalTrades)) * 100 - - // 计算总盈利和总亏损 - totalWinAmount := analysis.AvgWin // 当前是累加的总和 - totalLossAmount := analysis.AvgLoss // 当前是累加的总和(负数) - - if analysis.WinningTrades > 0 { - analysis.AvgWin /= float64(analysis.WinningTrades) - } - if analysis.LosingTrades > 0 { - analysis.AvgLoss /= float64(analysis.LosingTrades) - } - - // Profit Factor = 总盈利 / 总亏损(绝对值) - // 注意:totalLossAmount 是负数,所以取负号得到绝对值 - if totalLossAmount != 0 { - analysis.ProfitFactor = totalWinAmount / (-totalLossAmount) - } else if totalWinAmount > 0 { - // 只有盈利没有亏损的情况,设置为一个很大的值表示完美策略 - analysis.ProfitFactor = 999.0 - } - } - - // 计算各币种胜率和平均盈亏 - bestPnL := -999999.0 - worstPnL := 999999.0 - for symbol, stats := range analysis.SymbolStats { - if stats.TotalTrades > 0 { - stats.WinRate = (float64(stats.WinningTrades) / float64(stats.TotalTrades)) * 100 - stats.AvgPnL = stats.TotalPnL / float64(stats.TotalTrades) - - if stats.TotalPnL > bestPnL { - bestPnL = stats.TotalPnL - analysis.BestSymbol = symbol - } - if stats.TotalPnL < worstPnL { - worstPnL = stats.TotalPnL - analysis.WorstSymbol = symbol - } - } - } - - // 只保留最近的交易(倒序:最新的在前) - if len(analysis.RecentTrades) > 10 { - // 反转数组,让最新的在前 - for i, j := 0, len(analysis.RecentTrades)-1; i < j; i, j = i+1, j-1 { - analysis.RecentTrades[i], analysis.RecentTrades[j] = analysis.RecentTrades[j], analysis.RecentTrades[i] - } - analysis.RecentTrades = analysis.RecentTrades[:10] - } else if len(analysis.RecentTrades) > 0 { - // 反转数组 - for i, j := 0, len(analysis.RecentTrades)-1; i < j; i, j = i+1, j-1 { - analysis.RecentTrades[i], analysis.RecentTrades[j] = analysis.RecentTrades[j], analysis.RecentTrades[i] - } - } - - // 计算夏普比率(需要至少2个数据点) - analysis.SharpeRatio = l.calculateSharpeRatio(records) - - return analysis, nil -} - -// calculateSharpeRatio 计算夏普比率 -// 基于账户净值的变化计算风险调整后收益 -func (l *DecisionLogger) calculateSharpeRatio(records []*DecisionRecord) float64 { - if len(records) < 2 { - return 0.0 - } - - // 提取每个周期的账户净值 - // 注意:TotalBalance字段实际存储的是TotalEquity(账户总净值) - // TotalUnrealizedProfit字段实际存储的是TotalPnL(相对初始余额的盈亏) - var equities []float64 - for _, record := range records { - // 直接使用TotalBalance,因为它已经是完整的账户净值 - equity := record.AccountState.TotalBalance - if equity > 0 { - equities = append(equities, equity) - } - } - - if len(equities) < 2 { - return 0.0 - } - - // 计算周期收益率(period returns) - var returns []float64 - for i := 1; i < len(equities); i++ { - if equities[i-1] > 0 { - periodReturn := (equities[i] - equities[i-1]) / equities[i-1] - returns = append(returns, periodReturn) - } - } - - if len(returns) == 0 { - return 0.0 - } - - // 计算平均收益率 - sumReturns := 0.0 - for _, r := range returns { - sumReturns += r - } - meanReturn := sumReturns / float64(len(returns)) - - // 计算收益率标准差 - sumSquaredDiff := 0.0 - for _, r := range returns { - diff := r - meanReturn - sumSquaredDiff += diff * diff - } - variance := sumSquaredDiff / float64(len(returns)) - stdDev := math.Sqrt(variance) - - // 避免除以零 - if stdDev == 0 { - if meanReturn > 0 { - return 999.0 // 无波动的正收益 - } else if meanReturn < 0 { - return -999.0 // 无波动的负收益 - } - return 0.0 - } - - // 计算夏普比率(假设无风险利率为0) - // 注:直接返回周期级别的夏普比率(非年化),正常范围 -2 到 +2 - sharpeRatio := meanReturn / stdDev - return sharpeRatio -} diff --git a/logger/logger.go b/logger/logger.go index 527c46e2..fd5b87b7 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -1,7 +1,6 @@ package logger import ( - "nofx/config" "os" "github.com/sirupsen/logrus" @@ -10,11 +9,20 @@ import ( var ( // Log 全局logger实例 Log *logrus.Logger - - // telegramHook 保存hook引用,用于优雅关闭 - telegramHook *TelegramHook ) +func init() { + // 自动初始化默认 logger,确保在 Init 被调用前也能使用 + Log = logrus.New() + Log.SetLevel(logrus.InfoLevel) + Log.SetFormatter(&logrus.TextFormatter{ + FullTimestamp: true, + TimestampFormat: "2006-01-02 15:04:05", + ForceColors: true, + }) + Log.SetOutput(os.Stdout) +} + // ============================================================================ // 初始化函数 // ============================================================================ @@ -52,26 +60,6 @@ func Init(cfg *Config) error { // 启用调用位置信息 Log.SetReportCaller(true) - // 添加Telegram Hook(可选) - if cfg.Telegram != nil && cfg.Telegram.Enabled { - if err := setupTelegramHook(cfg.Telegram); err != nil { - Log.Warnf("初始化Telegram推送失败,将继续使用普通日志: %v", err) - } - } - - return nil -} - -// setupTelegramHook 设置Telegram Hook -func setupTelegramHook(telegramCfg *TelegramConfig) error { - hook, err := NewTelegramHook(telegramCfg) - if err != nil { - return err - } - - Log.AddHook(hook) - telegramHook = hook - Log.Info("✅ Telegram日志推送已启用") return nil } @@ -81,69 +69,9 @@ func InitWithSimpleConfig(level string) error { return Init(&Config{Level: level}) } -// InitWithTelegram 使用Telegram配置初始化logger -func InitWithTelegram(botToken string, chatID int64) error { - return Init(&Config{ - Level: "info", - Telegram: &TelegramConfig{ - Enabled: true, - BotToken: botToken, - ChatID: chatID, - }, - }) -} - -// InitFromLogConfig 从config.LogConfig初始化logger -func InitFromLogConfig(logConfig *config.LogConfig) error { - if logConfig == nil { - return InitWithSimpleConfig("info") - } - - cfg := &Config{ - Level: logConfig.Level, - } - - if cfg.Level == "" { - cfg.Level = "info" - } - - // 如果启用了Telegram,添加配置 - if logConfig.Telegram != nil && logConfig.Telegram.Enabled { - if botToken := logConfig.Telegram.BotToken; botToken != "" && logConfig.Telegram.ChatID != 0 { - cfg.Telegram = &TelegramConfig{ - Enabled: true, - BotToken: botToken, - ChatID: logConfig.Telegram.ChatID, - MinLevel: logConfig.Telegram.MinLevel, - } - } - } - - return Init(cfg) -} - -// InitFromParams 从参数初始化logger -// 适用于不依赖config包的场景 -func InitFromParams(level string, telegramEnabled bool, botToken string, chatID int64) error { - cfg := &Config{Level: level} - - if telegramEnabled && botToken != "" && chatID != 0 { - cfg.Telegram = &TelegramConfig{ - Enabled: true, - BotToken: botToken, - ChatID: chatID, - } - } - - return Init(cfg) -} - -// Shutdown 优雅关闭logger(主要用于关闭Telegram发送器) +// Shutdown 优雅关闭logger func Shutdown() { - if telegramHook != nil { - telegramHook.Stop() - telegramHook = nil - } + // 预留用于未来扩展 } // ============================================================================ @@ -208,3 +136,32 @@ func Panic(args ...interface{}) { func Panicf(format string, args ...interface{}) { Log.Panicf(format, args...) } + +// ============================================================================ +// MCP Logger 适配器 +// ============================================================================ + +// MCPLogger 适配器,使 MCP 包使用全局 logger +// 实现 mcp.Logger 接口 +type MCPLogger struct{} + +// NewMCPLogger 创建 MCP 日志适配器 +func NewMCPLogger() *MCPLogger { + return &MCPLogger{} +} + +func (l *MCPLogger) Debugf(format string, args ...any) { + Log.Debugf(format, args...) +} + +func (l *MCPLogger) Infof(format string, args ...any) { + Log.Infof(format, args...) +} + +func (l *MCPLogger) Warnf(format string, args ...any) { + Log.Warnf(format, args...) +} + +func (l *MCPLogger) Errorf(format string, args ...any) { + Log.Errorf(format, args...) +} diff --git a/logger/telegram_hook.go b/logger/telegram_hook.go deleted file mode 100644 index e8477f47..00000000 --- a/logger/telegram_hook.go +++ /dev/null @@ -1,158 +0,0 @@ -package logger - -import ( - "fmt" - "runtime" - "strings" - - "github.com/sirupsen/logrus" -) - -// TelegramHook 实现logrus.Hook接口,将日志推送到Telegram -type TelegramHook struct { - sender *TelegramSender - levels []logrus.Level - enabled bool -} - -// NewTelegramHook 创建Telegram Hook -func NewTelegramHook(config *TelegramConfig) (*TelegramHook, error) { - if !config.Enabled { - return &TelegramHook{enabled: false}, nil - } - - if config.BotToken == "" || config.ChatID == 0 { - return nil, fmt.Errorf("telegram配置不完整: bot_token和chat_id不能为空") - } - - // 创建发送器(使用默认参数) - sender, err := NewTelegramSender(config.BotToken, config.ChatID) - if err != nil { - return nil, fmt.Errorf("创建telegram发送器失败: %w", err) - } - - hook := &TelegramHook{ - sender: sender, - levels: config.GetLogrusLevels(), - enabled: true, - } - - return hook, nil -} - -// Levels 返回需要触发的日志级别 -func (h *TelegramHook) Levels() []logrus.Level { - if !h.enabled { - return []logrus.Level{} - } - return h.levels -} - -// Fire 当日志触发时调用 -func (h *TelegramHook) Fire(entry *logrus.Entry) error { - if !h.enabled { - return nil - } - - // 格式化消息 - message := h.formatMessage(entry) - - // 异步发送(非阻塞) - h.sender.SendAsync(message) - - return nil -} - -// formatMessage 格式化日志消息为Telegram格式 -func (h *TelegramHook) formatMessage(entry *logrus.Entry) string { - // 级别emoji - levelEmoji := h.getLevelEmoji(entry.Level) - - // 基本信息 - var builder strings.Builder - builder.WriteString(fmt.Sprintf("%s *%s*: 系统日志警报\n", levelEmoji, strings.ToUpper(entry.Level.String()))) - builder.WriteString(fmt.Sprintf("📝 消息: `%s`\n", escapeMarkdown(entry.Message))) - - // 字段信息 - if len(entry.Data) > 0 { - builder.WriteString("📊 字段:\n") - for key, value := range entry.Data { - builder.WriteString(fmt.Sprintf(" • %s: `%v`\n", key, value)) - } - } - - // 调用位置 - if entry.HasCaller() { - file := entry.Caller.File - // 只保留相对路径 - if idx := strings.Index(file, "nofx/"); idx >= 0 { - file = file[idx:] - } - builder.WriteString(fmt.Sprintf("📍 位置: `%s:%d`\n", file, entry.Caller.Line)) - } else { - // 如果entry没有caller,手动获取 - if _, file, line, ok := runtime.Caller(8); ok { - if idx := strings.Index(file, "nofx/"); idx >= 0 { - file = file[idx:] - } - builder.WriteString(fmt.Sprintf("📍 位置: `%s:%d`\n", file, line)) - } - } - - // 时间戳 - builder.WriteString(fmt.Sprintf("🕐 时间: `%s`", entry.Time.Format("2006-01-02 15:04:05"))) - - return builder.String() -} - -// getLevelEmoji 获取日志级别对应的emoji -func (h *TelegramHook) getLevelEmoji(level logrus.Level) string { - switch level { - case logrus.PanicLevel: - return "🔴" - case logrus.FatalLevel: - return "🔴" - case logrus.ErrorLevel: - return "🟠" - case logrus.WarnLevel: - return "🟡" - case logrus.InfoLevel: - return "🟢" - case logrus.DebugLevel: - return "🔵" - default: - return "⚪" - } -} - -// escapeMarkdown 转义Markdown特殊字符 -func escapeMarkdown(text string) string { - replacer := strings.NewReplacer( - "_", "\\_", - "*", "\\*", - "[", "\\[", - "]", "\\]", - "(", "\\(", - ")", "\\)", - "~", "\\~", - "`", "\\`", - ">", "\\>", - "#", "\\#", - "+", "\\+", - "-", "\\-", - "=", "\\=", - "|", "\\|", - "{", "\\{", - "}", "\\}", - ".", "\\.", - "!", "\\!", - ) - return replacer.Replace(text) -} - -// Stop 停止Hook(优雅关闭) -func (h *TelegramHook) Stop() { - if h.enabled && h.sender != nil { - h.sender.Stop() - } -} diff --git a/logger/telegram_sender.go b/logger/telegram_sender.go deleted file mode 100644 index 6658d9f2..00000000 --- a/logger/telegram_sender.go +++ /dev/null @@ -1,120 +0,0 @@ -package logger - -import ( - "fmt" - "sync" - "time" - - tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" -) - -// TelegramSender Telegram消息发送器(异步) -type TelegramSender struct { - bot *tgbotapi.BotAPI - chatID int64 - msgChan chan string - retryCount int - retryInterval time.Duration - wg sync.WaitGroup - stopChan chan struct{} - once sync.Once -} - -// NewTelegramSender 创建Telegram发送器(使用默认参数) -func NewTelegramSender(botToken string, chatID int64) (*TelegramSender, error) { - bot, err := tgbotapi.NewBotAPI(botToken) - if err != nil { - return nil, fmt.Errorf("创建telegram bot失败: %w", err) - } - - // 设置为静默模式(不打印bot信息) - bot.Debug = false - - sender := &TelegramSender{ - bot: bot, - chatID: chatID, - msgChan: make(chan string, 20), // 固定缓冲区大小: 20 - retryCount: 3, // 固定重试次数: 3 - retryInterval: 3 * time.Second, // 固定重试间隔: 3秒 - stopChan: make(chan struct{}), - } - - // 启动异步发送协程 - sender.Start() - - return sender, nil -} - -// Start 启动异步发送协程 -func (s *TelegramSender) Start() { - s.wg.Add(1) - go s.listenAndSend() -} - -// SendAsync 异步发送消息(非阻塞) -func (s *TelegramSender) SendAsync(message string) { - select { - case s.msgChan <- message: - // 成功写入缓冲区 - default: - // 缓冲区满,丢弃消息(不阻塞主流程) - fmt.Printf("[Telegram] 消息缓冲区已满,消息被丢弃\n") - } -} - -// listenAndSend 监听channel并发送消息 -func (s *TelegramSender) listenAndSend() { - defer s.wg.Done() - - for { - select { - case msg := <-s.msgChan: - s.sendWithRetry(msg) - case <-s.stopChan: - // 清空缓冲区后退出 - for len(s.msgChan) > 0 { - msg := <-s.msgChan - s.sendWithRetry(msg) - } - return - } - } -} - -// sendWithRetry 发送消息(带重试) -func (s *TelegramSender) sendWithRetry(message string) { - var err error - for i := 0; i < s.retryCount; i++ { - err = s.send(message) - if err == nil { - return // 发送成功 - } - - // 重试前等待 - if i < s.retryCount-1 { - time.Sleep(s.retryInterval) - } - } - - // 所有重试都失败 - if err != nil { - fmt.Printf("[Telegram] 发送消息失败(已重试%d次): %v\n", s.retryCount, err) - } -} - -// send 发送单条消息 -func (s *TelegramSender) send(message string) error { - msg := tgbotapi.NewMessage(s.chatID, message) - msg.ParseMode = tgbotapi.ModeMarkdown - - _, err := s.bot.Send(msg) - return err -} - -// Stop 停止发送器(优雅关闭) -func (s *TelegramSender) Stop() { - s.once.Do(func() { - close(s.stopChan) - s.wg.Wait() - }) -} diff --git a/main.go b/main.go index f456684d..89027e24 100644 --- a/main.go +++ b/main.go @@ -3,21 +3,24 @@ package main import ( "encoding/json" "fmt" - "log" "nofx/api" "nofx/auth" "nofx/backtest" "nofx/config" "nofx/crypto" + "nofx/logger" "nofx/manager" "nofx/market" "nofx/mcp" "nofx/pool" + "nofx/store" + "nofx/trader" "os" "os/signal" "strconv" "strings" "syscall" + "time" "github.com/joho/godotenv" ) @@ -44,7 +47,7 @@ type ConfigFile struct { func loadConfigFile() (*ConfigFile, error) { // 检查config.json是否存在 if _, err := os.Stat("config.json"); os.IsNotExist(err) { - log.Printf("📄 config.json不存在,使用默认配置") + logger.Info("📄 config.json不存在,使用默认配置") return &ConfigFile{}, nil } @@ -64,12 +67,12 @@ func loadConfigFile() (*ConfigFile, error) { } // syncConfigToDatabase 将配置同步到数据库 -func syncConfigToDatabase(database *config.Database, configFile *ConfigFile) error { +func syncConfigToDatabase(st *store.Store, configFile *ConfigFile) error { if configFile == nil { return nil } - log.Printf("🔄 开始同步config.json到数据库...") + logger.Info("🔄 开始同步config.json到数据库...") // 同步各配置项到数据库 configs := map[string]string{ @@ -106,24 +109,24 @@ func syncConfigToDatabase(database *config.Database, configFile *ConfigFile) err // 更新数据库配置 for key, value := range configs { - if err := database.SetSystemConfig(key, value); err != nil { - log.Printf("⚠️ 更新配置 %s 失败: %v", key, err) + if err := st.SystemConfig().Set(key, value); err != nil { + logger.Warnf("⚠️ 更新配置 %s 失败: %v", key, err) } else { - log.Printf("✓ 同步配置: %s = %s", key, value) + logger.Infof("✓ 同步配置: %s = %s", key, value) } } - log.Printf("✅ config.json同步完成") + logger.Info("✅ config.json同步完成") return nil } // loadBetaCodesToDatabase 加载内测码文件到数据库 -func loadBetaCodesToDatabase(database *config.Database) error { +func loadBetaCodesToDatabase(st *store.Store) error { betaCodeFile := "beta_codes.txt" // 检查内测码文件是否存在 if _, err := os.Stat(betaCodeFile); os.IsNotExist(err) { - log.Printf("📄 内测码文件 %s 不存在,跳过加载", betaCodeFile) + logger.Infof("📄 内测码文件 %s 不存在,跳过加载", betaCodeFile) return nil } @@ -133,37 +136,39 @@ func loadBetaCodesToDatabase(database *config.Database) error { return fmt.Errorf("获取内测码文件信息失败: %w", err) } - log.Printf("🔄 发现内测码文件 %s (%.1f KB),开始加载...", betaCodeFile, float64(fileInfo.Size())/1024) + logger.Infof("🔄 发现内测码文件 %s (%.1f KB),开始加载...", betaCodeFile, float64(fileInfo.Size())/1024) // 加载内测码到数据库 - err = database.LoadBetaCodesFromFile(betaCodeFile) + err = st.BetaCode().LoadFromFile(betaCodeFile) if err != nil { return fmt.Errorf("加载内测码失败: %w", err) } // 显示统计信息 - total, used, err := database.GetBetaCodeStats() + total, used, err := st.BetaCode().GetStats() if err != nil { - log.Printf("⚠️ 获取内测码统计失败: %v", err) + logger.Warnf("⚠️ 获取内测码统计失败: %v", err) } else { - log.Printf("✅ 内测码加载完成: 总计 %d 个,已使用 %d 个,剩余 %d 个", total, used, total-used) + logger.Infof("✅ 内测码加载完成: 总计 %d 个,已使用 %d 个,剩余 %d 个", total, used, total-used) } return nil } func main() { - fmt.Println("╔════════════════════════════════════════════════════════════╗") - fmt.Println("║ 🤖 AI多模型交易系统 - 支持 DeepSeek & Qwen ║") - fmt.Println("╚════════════════════════════════════════════════════════════╝") - fmt.Println() - // Load environment variables from .env file if present (for local/dev runs) // In Docker Compose, variables are injected by the runtime and this is harmless. _ = godotenv.Load() + // 初始化日志 + logger.Init(nil) + + logger.Info("╔════════════════════════════════════════════════════════════╗") + logger.Info("║ 🤖 AI多模型交易系统 - 支持 DeepSeek & Qwen ║") + logger.Info("╚════════════════════════════════════════════════════════════╝") + // 初始化数据库配置 - dbPath := "config.db" + dbPath := "data.db" if len(os.Args) > 1 { dbPath = os.Args[1] } @@ -171,163 +176,174 @@ func main() { // 读取配置文件 configFile, err := loadConfigFile() if err != nil { - log.Fatalf("❌ 读取config.json失败: %v", err) + logger.Fatalf("❌ 读取config.json失败: %v", err) } - log.Printf("📋 初始化配置数据库: %s", dbPath) - database, err := config.NewDatabase(dbPath) + logger.Infof("📋 初始化配置数据库: %s", dbPath) + st, err := store.New(dbPath) if err != nil { - log.Fatalf("❌ 初始化数据库失败: %v", err) + logger.Fatalf("❌ 初始化数据库失败: %v", err) } - defer database.Close() - backtest.UseDatabase(database.Conn()) + defer st.Close() + backtest.UseDatabase(st.DB()) // 初始化加密服务 - log.Printf("🔐 初始化加密服务...") - cryptoService, err := crypto.NewCryptoService("secrets/rsa_key") + logger.Info("🔐 初始化加密服务...") + cryptoService, err := crypto.NewCryptoService() if err != nil { - log.Fatalf("❌ 初始化加密服务失败: %v", err) + logger.Fatalf("❌ 初始化加密服务失败: %v", err) } - database.SetCryptoService(cryptoService) - log.Printf("✅ 加密服务初始化成功") + // 创建加密/解密包装函数 + encryptFunc := func(plaintext string) string { + if plaintext == "" { + return plaintext + } + encrypted, err := cryptoService.EncryptForStorage(plaintext) + if err != nil { + logger.Warnf("⚠️ 加密失败: %v", err) + return plaintext + } + return encrypted + } + decryptFunc := func(encrypted string) string { + if encrypted == "" { + return encrypted + } + if !cryptoService.IsEncryptedStorageValue(encrypted) { + return encrypted + } + decrypted, err := cryptoService.DecryptFromStorage(encrypted) + if err != nil { + logger.Warnf("⚠️ 解密失败: %v", err) + return encrypted + } + return decrypted + } + st.SetCryptoFuncs(encryptFunc, decryptFunc) + logger.Info("✅ 加密服务初始化成功") // 同步config.json到数据库 - if err := syncConfigToDatabase(database, configFile); err != nil { - log.Printf("⚠️ 同步config.json到数据库失败: %v", err) + if err := syncConfigToDatabase(st, configFile); err != nil { + logger.Warnf("⚠️ 同步config.json到数据库失败: %v", err) } // 加载内测码到数据库 - if err := loadBetaCodesToDatabase(database); err != nil { - log.Printf("⚠️ 加载内测码到数据库失败: %v", err) + if err := loadBetaCodesToDatabase(st); err != nil { + logger.Warnf("⚠️ 加载内测码到数据库失败: %v", err) } // 获取系统配置 - useDefaultCoinsStr, _ := database.GetSystemConfig("use_default_coins") + useDefaultCoinsStr, _ := st.SystemConfig().Get("use_default_coins") useDefaultCoins := useDefaultCoinsStr == "true" - apiPortStr, _ := database.GetSystemConfig("api_server_port") + apiPortStr, _ := st.SystemConfig().Get("api_server_port") // 设置JWT密钥(优先使用环境变量) jwtSecret := strings.TrimSpace(os.Getenv("JWT_SECRET")) if jwtSecret == "" { // 回退到数据库配置 - jwtSecret, _ = database.GetSystemConfig("jwt_secret") + jwtSecret, _ = st.SystemConfig().Get("jwt_secret") if jwtSecret == "" { jwtSecret = "your-jwt-secret-key-change-in-production-make-it-long-and-random" - log.Printf("⚠️ 使用默认JWT密钥,建议使用加密设置脚本生成安全密钥") + logger.Warn("⚠️ 使用默认JWT密钥,建议使用加密设置脚本生成安全密钥") } else { - log.Printf("🔑 使用数据库中JWT密钥") + logger.Info("🔑 使用数据库中JWT密钥") } } else { - log.Printf("🔑 使用环境变量JWT密钥") + logger.Info("🔑 使用环境变量JWT密钥") } auth.SetJWTSecret(jwtSecret) // 管理员模式下需要管理员密码,缺失则退出 - log.Printf("✓ 配置数据库初始化成功") - fmt.Println() + logger.Info("✓ 配置数据库初始化成功") // 从数据库读取默认主流币种列表 - defaultCoinsJSON, _ := database.GetSystemConfig("default_coins") + defaultCoinsJSON, _ := st.SystemConfig().Get("default_coins") var defaultCoins []string if defaultCoinsJSON != "" { // 尝试从JSON解析 if err := json.Unmarshal([]byte(defaultCoinsJSON), &defaultCoins); err != nil { - log.Printf("⚠️ 解析default_coins配置失败: %v,使用硬编码默认值", err) + logger.Warnf("⚠️ 解析default_coins配置失败: %v,使用硬编码默认值", err) defaultCoins = []string{"BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "XRPUSDT", "DOGEUSDT", "ADAUSDT", "HYPEUSDT"} } else { - log.Printf("✓ 从数据库加载默认币种列表(共%d个): %v", len(defaultCoins), defaultCoins) + logger.Infof("✓ 从数据库加载默认币种列表(共%d个): %v", len(defaultCoins), defaultCoins) } } else { // 如果数据库中没有配置,使用硬编码默认值 defaultCoins = []string{"BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "XRPUSDT", "DOGEUSDT", "ADAUSDT", "HYPEUSDT"} - log.Printf("⚠️ 数据库中未配置default_coins,使用硬编码默认值") + logger.Warn("⚠️ 数据库中未配置default_coins,使用硬编码默认值") } pool.SetDefaultCoins(defaultCoins) // 设置是否使用默认主流币种 pool.SetUseDefaultCoins(useDefaultCoins) if useDefaultCoins { - log.Printf("✓ 已启用默认主流币种列表") + logger.Info("✓ 已启用默认主流币种列表") } // 设置币种池API URL - coinPoolAPIURL, _ := database.GetSystemConfig("coin_pool_api_url") + coinPoolAPIURL, _ := st.SystemConfig().Get("coin_pool_api_url") if coinPoolAPIURL != "" { pool.SetCoinPoolAPI(coinPoolAPIURL) - log.Printf("✓ 已配置AI500币种池API") + logger.Info("✓ 已配置AI500币种池API") } - oiTopAPIURL, _ := database.GetSystemConfig("oi_top_api_url") + oiTopAPIURL, _ := st.SystemConfig().Get("oi_top_api_url") if oiTopAPIURL != "" { pool.SetOITopAPI(oiTopAPIURL) - log.Printf("✓ 已配置OI Top API") + logger.Info("✓ 已配置OI Top API") } // 创建TraderManager 与 BacktestManager cfgForAI, cfgErr := config.LoadConfig("config.json") if cfgErr != nil { - log.Printf("⚠️ 加载config.json用于AI客户端失败: %v", cfgErr) + logger.Warnf("⚠️ 加载config.json用于AI客户端失败: %v", cfgErr) } traderManager := manager.NewTraderManager() mcpClient := newSharedMCPClient(cfgForAI) backtestManager := backtest.NewManager(mcpClient) if err := backtestManager.RestoreRuns(); err != nil { - log.Printf("⚠️ 恢复历史回测失败: %v", err) + logger.Warnf("⚠️ 恢复历史回测失败: %v", err) } // 从数据库加载所有交易员到内存 - err = traderManager.LoadTradersFromDatabase(database) + err = traderManager.LoadTradersFromStore(st) if err != nil { - log.Fatalf("❌ 加载交易员失败: %v", err) + logger.Fatalf("❌ 加载交易员失败: %v", err) } // 获取数据库中的所有交易员配置(用于显示,使用default用户) - traders, err := database.GetTraders("default") + traders, err := st.Trader().List("default") if err != nil { - log.Fatalf("❌ 获取交易员列表失败: %v", err) + logger.Fatalf("❌ 获取交易员列表失败: %v", err) } // 显示加载的交易员信息 - fmt.Println() - fmt.Println("🤖 数据库中的AI交易员配置:") + logger.Info("🤖 数据库中的AI交易员配置:") if len(traders) == 0 { - fmt.Println(" • 暂无配置的交易员,请通过Web界面创建") + logger.Info(" • 暂无配置的交易员,请通过Web界面创建") } else { for _, trader := range traders { status := "停止" if trader.IsRunning { status = "运行中" } - fmt.Printf(" • %s (%s + %s) - 初始资金: %.0f USDT [%s]\n", + logger.Infof(" • %s (%s + %s) - 初始资金: %.0f USDT [%s]", trader.Name, strings.ToUpper(trader.AIModelID), strings.ToUpper(trader.ExchangeID), trader.InitialBalance, status) } } - // 创建初始化上下文 - // TODO : 传入实际配置, 现在并未实际使用,未来所有模块初始化都将通过上下文传递配置 - // ctx := bootstrap.NewContext(&config.Config{}) - - // // 执行所有初始化钩子 - // if err := bootstrap.Run(ctx); err != nil { - // log.Fatalf("初始化失败: %v", err) - // } - - fmt.Println() - fmt.Println("🤖 AI全权决策模式:") - fmt.Printf(" • AI将自主决定每笔交易的杠杆倍数(山寨币最高5倍,BTC/ETH最高5倍)\n") - fmt.Println(" • AI将自主决定每笔交易的仓位大小") - fmt.Println(" • AI将自主设置止损和止盈价格") - fmt.Println(" • AI将基于市场数据、技术指标、账户状态做出全面分析") - fmt.Println() - fmt.Println("⚠️ 风险提示: AI自动交易有风险,建议小额资金测试!") - fmt.Println() - fmt.Println("按 Ctrl+C 停止运行") - fmt.Println(strings.Repeat("=", 60)) - fmt.Println() + logger.Info("🤖 AI全权决策模式:") + logger.Info(" • AI将自主决定每笔交易的杠杆倍数(山寨币最高5倍,BTC/ETH最高5倍)") + logger.Info(" • AI将自主决定每笔交易的仓位大小") + logger.Info(" • AI将自主设置止损和止盈价格") + logger.Info(" • AI将基于市场数据、技术指标、账户状态做出全面分析") + logger.Warn("⚠️ 风险提示: AI自动交易有风险,建议小额资金测试!") + logger.Info("按 Ctrl+C 停止运行") + logger.Info(strings.Repeat("=", 60)) // 获取API服务器端口(优先级:环境变量 > 数据库配置 > 默认值) apiPort := 8080 // 默认端口 @@ -336,30 +352,38 @@ func main() { if envPort := strings.TrimSpace(os.Getenv("NOFX_BACKEND_PORT")); envPort != "" { if port, err := strconv.Atoi(envPort); err == nil && port > 0 { apiPort = port - log.Printf("🔌 使用环境变量端口: %d (NOFX_BACKEND_PORT)", apiPort) + logger.Infof("🔌 使用环境变量端口: %d (NOFX_BACKEND_PORT)", apiPort) } else { - log.Printf("⚠️ 环境变量 NOFX_BACKEND_PORT 无效: %s", envPort) + logger.Warnf("⚠️ 环境变量 NOFX_BACKEND_PORT 无效: %s", envPort) } } else if apiPortStr != "" { // 2. 从数据库配置读取(config.json 同步过来的) if port, err := strconv.Atoi(apiPortStr); err == nil && port > 0 { apiPort = port - log.Printf("🔌 使用数据库配置端口: %d (api_server_port)", apiPort) + logger.Infof("🔌 使用数据库配置端口: %d (api_server_port)", apiPort) } } else { - log.Printf("🔌 使用默认端口: %d", apiPort) + logger.Infof("🔌 使用默认端口: %d", apiPort) } + // 启动订单同步管理器 + orderSyncManager := trader.NewOrderSyncManager(st, 10*time.Second) + orderSyncManager.Start() + + // 启动仓位同步管理器(检测手动平仓等变化) + positionSyncManager := trader.NewPositionSyncManager(st, 10*time.Second) + positionSyncManager.Start() + // 创建并启动API服务器 - apiServer := api.NewServer(traderManager, database, cryptoService, backtestManager, apiPort) + apiServer := api.NewServer(traderManager, st, cryptoService, backtestManager, apiPort) go func() { if err := apiServer.Start(); err != nil { - log.Printf("❌ API服务器错误: %v", err) + logger.Errorf("❌ API服务器错误: %v", err) } }() // 启动流行情数据 - 默认使用所有交易员设置的币种 如果没有设置币种 则优先使用系统默认 - go market.NewWSMonitor(150).Start(database.GetCustomCoins()) + go market.NewWSMonitor(150).Start(st.Trader().GetCustomCoins()) //go market.NewWSMonitor(150).Start([]string{}) //这里是一个使用方式 传入空的话 则使用market市场的所有币种 // 设置优雅退出 sigChan := make(chan os.Signal, 1) @@ -370,33 +394,36 @@ func main() { // 等待退出信号 <-sigChan - fmt.Println() - fmt.Println() - log.Println("📛 收到退出信号,正在优雅关闭...") + logger.Info("📛 收到退出信号,正在优雅关闭...") // 步骤 1: 停止所有交易员 - log.Println("⏸️ 停止所有交易员...") + logger.Info("⏸️ 停止所有交易员...") traderManager.StopAll() - log.Println("✅ 所有交易员已停止") + logger.Info("✅ 所有交易员已停止") - // 步骤 2: 关闭 API 服务器 - log.Println("🛑 停止 API 服务器...") + // 步骤 2: 停止订单同步管理器和仓位同步管理器 + logger.Info("📦 停止订单同步管理器...") + orderSyncManager.Stop() + logger.Info("📊 停止仓位同步管理器...") + positionSyncManager.Stop() + + // 步骤 3: 关闭 API 服务器 + logger.Info("🛑 停止 API 服务器...") if err := apiServer.Shutdown(); err != nil { - log.Printf("⚠️ 关闭 API 服务器时出错: %v", err) + logger.Warnf("⚠️ 关闭 API 服务器时出错: %v", err) } else { - log.Println("✅ API 服务器已安全关闭") + logger.Info("✅ API 服务器已安全关闭") } - // 步骤 3: 关闭数据库连接 (确保所有写入完成) - log.Println("💾 关闭数据库连接...") - if err := database.Close(); err != nil { - log.Printf("❌ 关闭数据库失败: %v", err) + // 步骤 4: 关闭数据库连接 (确保所有写入完成) + logger.Info("💾 关闭数据库连接...") + if err := st.Close(); err != nil { + logger.Errorf("❌ 关闭数据库失败: %v", err) } else { - log.Println("✅ 数据库已安全关闭,所有数据已持久化") + logger.Info("✅ 数据库已安全关闭,所有数据已持久化") } - fmt.Println() - fmt.Println("👋 感谢使用AI交易系统!") + logger.Info("👋 感谢使用AI交易系统!") } func newSharedMCPClient(cfg *config.Config) mcp.AIClient { diff --git a/manager/trader_manager.go b/manager/trader_manager.go index d6c93f61..ec510ba6 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -4,8 +4,8 @@ import ( "context" "encoding/json" "fmt" - "log" - "nofx/config" + "nofx/logger" + "nofx/store" "nofx/trader" "sort" "strconv" @@ -38,371 +38,6 @@ func NewTraderManager() *TraderManager { } } -// LoadTradersFromDatabase 从数据库加载所有交易员到内存 -func (tm *TraderManager) LoadTradersFromDatabase(database *config.Database) error { - tm.mu.Lock() - defer tm.mu.Unlock() - - // 获取所有用户 - userIDs, err := database.GetAllUsers() - if err != nil { - return fmt.Errorf("获取用户列表失败: %w", err) - } - - log.Printf("📋 发现 %d 个用户,开始加载所有交易员配置...", len(userIDs)) - - var allTraders []*config.TraderRecord - for _, userID := range userIDs { - // 获取每个用户的交易员 - traders, err := database.GetTraders(userID) - if err != nil { - log.Printf("⚠️ 获取用户 %s 的交易员失败: %v", userID, err) - continue - } - log.Printf("📋 用户 %s: %d 个交易员", userID, len(traders)) - allTraders = append(allTraders, traders...) - } - - log.Printf("📋 总共加载 %d 个交易员配置", len(allTraders)) - - // 获取系统配置(不包含信号源,信号源现在为用户级别) - maxDailyLossStr, _ := database.GetSystemConfig("max_daily_loss") - maxDrawdownStr, _ := database.GetSystemConfig("max_drawdown") - stopTradingMinutesStr, _ := database.GetSystemConfig("stop_trading_minutes") - defaultCoinsStr, _ := database.GetSystemConfig("default_coins") - - // 解析配置 - maxDailyLoss := 10.0 // 默认值 - if val, err := strconv.ParseFloat(maxDailyLossStr, 64); err == nil { - maxDailyLoss = val - } - - maxDrawdown := 20.0 // 默认值 - if val, err := strconv.ParseFloat(maxDrawdownStr, 64); err == nil { - maxDrawdown = val - } - - stopTradingMinutes := 60 // 默认值 - if val, err := strconv.Atoi(stopTradingMinutesStr); err == nil { - stopTradingMinutes = val - } - - // 解析默认币种列表 - var defaultCoins []string - if defaultCoinsStr != "" { - if err := json.Unmarshal([]byte(defaultCoinsStr), &defaultCoins); err != nil { - log.Printf("⚠️ 解析默认币种配置失败: %v,使用空列表", err) - defaultCoins = []string{} - } - } - - // 为每个交易员获取AI模型和交易所配置 - for _, traderCfg := range allTraders { - // 获取AI模型配置(使用交易员所属的用户ID) - aiModels, err := database.GetAIModels(traderCfg.UserID) - if err != nil { - log.Printf("⚠️ 获取AI模型配置失败: %v", err) - continue - } - - var aiModelCfg *config.AIModelConfig - // 优先精确匹配 model.ID(新版逻辑) - for _, model := range aiModels { - if model.ID == traderCfg.AIModelID { - aiModelCfg = model - break - } - } - // 如果没有精确匹配,尝试匹配 provider(兼容旧数据) - if aiModelCfg == nil { - for _, model := range aiModels { - if model.Provider == traderCfg.AIModelID { - aiModelCfg = model - log.Printf("⚠️ 交易员 %s 使用旧版 provider 匹配: %s -> %s", traderCfg.Name, traderCfg.AIModelID, model.ID) - break - } - } - } - - if aiModelCfg == nil { - log.Printf("⚠️ 交易员 %s 的AI模型 %s 不存在,跳过", traderCfg.Name, traderCfg.AIModelID) - continue - } - - if !aiModelCfg.Enabled { - log.Printf("⚠️ 交易员 %s 的AI模型 %s 未启用,跳过", traderCfg.Name, traderCfg.AIModelID) - continue - } - - // 获取交易所配置(使用交易员所属的用户ID) - exchanges, err := database.GetExchanges(traderCfg.UserID) - if err != nil { - log.Printf("⚠️ 获取交易所配置失败: %v", err) - continue - } - - var exchangeCfg *config.ExchangeConfig - for _, exchange := range exchanges { - if exchange.ID == traderCfg.ExchangeID { - exchangeCfg = exchange - break - } - } - - if exchangeCfg == nil { - log.Printf("⚠️ 交易员 %s 的交易所 %s 不存在,跳过", traderCfg.Name, traderCfg.ExchangeID) - continue - } - - if !exchangeCfg.Enabled { - log.Printf("⚠️ 交易员 %s 的交易所 %s 未启用,跳过", traderCfg.Name, traderCfg.ExchangeID) - continue - } - - // 获取用户信号源配置 - var coinPoolURL, oiTopURL string - if userSignalSource, err := database.GetUserSignalSource(traderCfg.UserID); err == nil { - coinPoolURL = userSignalSource.CoinPoolURL - oiTopURL = userSignalSource.OITopURL - } else { - // 如果用户没有配置信号源,使用空字符串 - log.Printf("🔍 用户 %s 暂未配置信号源", traderCfg.UserID) - } - - // 添加到TraderManager - err = tm.addTraderFromDB(traderCfg, aiModelCfg, exchangeCfg, coinPoolURL, oiTopURL, maxDailyLoss, maxDrawdown, stopTradingMinutes, defaultCoins, database, traderCfg.UserID) - if err != nil { - log.Printf("❌ 添加交易员 %s 失败: %v", traderCfg.Name, err) - continue - } - } - - log.Printf("✓ 成功加载 %d 个交易员到内存", len(tm.traders)) - return nil -} - -// addTraderFromConfig 内部方法:从配置添加交易员(不加锁,因为调用方已加锁) -func (tm *TraderManager) addTraderFromDB(traderCfg *config.TraderRecord, aiModelCfg *config.AIModelConfig, exchangeCfg *config.ExchangeConfig, coinPoolURL, oiTopURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, defaultCoins []string, database *config.Database, userID string) error { - if _, exists := tm.traders[traderCfg.ID]; exists { - return fmt.Errorf("trader ID '%s' 已存在", traderCfg.ID) - } - - // 处理交易币种列表 - var tradingCoins []string - if traderCfg.TradingSymbols != "" { - // 解析逗号分隔的交易币种列表 - symbols := strings.Split(traderCfg.TradingSymbols, ",") - for _, symbol := range symbols { - symbol = strings.TrimSpace(symbol) - if symbol != "" { - tradingCoins = append(tradingCoins, symbol) - } - } - } - - // 如果没有指定交易币种,使用默认币种 - if len(tradingCoins) == 0 { - tradingCoins = defaultCoins - } - - // 根据交易员配置决定是否使用信号源 - var effectiveCoinPoolURL string - if traderCfg.UseCoinPool && coinPoolURL != "" { - effectiveCoinPoolURL = coinPoolURL - log.Printf("✓ 交易员 %s 启用 COIN POOL 信号源: %s", traderCfg.Name, coinPoolURL) - } - - // 构建AutoTraderConfig - traderConfig := trader.AutoTraderConfig{ - ID: traderCfg.ID, - Name: traderCfg.Name, - AIModel: aiModelCfg.Provider, // 使用provider作为模型标识 - Exchange: exchangeCfg.ID, // 使用exchange ID - BinanceAPIKey: "", - BinanceSecretKey: "", - HyperliquidPrivateKey: "", - HyperliquidTestnet: exchangeCfg.Testnet, - CoinPoolAPIURL: effectiveCoinPoolURL, - UseQwen: aiModelCfg.Provider == "qwen", - DeepSeekKey: "", - QwenKey: "", - CustomAPIURL: aiModelCfg.CustomAPIURL, // 自定义API URL - CustomModelName: aiModelCfg.CustomModelName, // 自定义模型名称 - ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute, - InitialBalance: traderCfg.InitialBalance, - BTCETHLeverage: traderCfg.BTCETHLeverage, - AltcoinLeverage: traderCfg.AltcoinLeverage, - MaxDailyLoss: maxDailyLoss, - MaxDrawdown: maxDrawdown, - StopTradingTime: time.Duration(stopTradingMinutes) * time.Minute, - IsCrossMargin: traderCfg.IsCrossMargin, - DefaultCoins: defaultCoins, - TradingCoins: tradingCoins, - SystemPromptTemplate: traderCfg.SystemPromptTemplate, // 系统提示词模板 - } - - // 根据交易所类型设置API密钥 - if exchangeCfg.ID == "binance" { - traderConfig.BinanceAPIKey = exchangeCfg.APIKey - traderConfig.BinanceSecretKey = exchangeCfg.SecretKey - } else if exchangeCfg.ID == "bybit" { - traderConfig.BybitAPIKey = exchangeCfg.APIKey - traderConfig.BybitSecretKey = exchangeCfg.SecretKey - } else if exchangeCfg.ID == "hyperliquid" { - traderConfig.HyperliquidPrivateKey = exchangeCfg.APIKey // hyperliquid用APIKey存储private key - traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr - } else if exchangeCfg.ID == "aster" { - traderConfig.AsterUser = exchangeCfg.AsterUser - traderConfig.AsterSigner = exchangeCfg.AsterSigner - traderConfig.AsterPrivateKey = exchangeCfg.AsterPrivateKey - } else if exchangeCfg.ID == "lighter" { - traderConfig.LighterPrivateKey = exchangeCfg.LighterPrivateKey - traderConfig.LighterWalletAddr = exchangeCfg.LighterWalletAddr - traderConfig.LighterTestnet = exchangeCfg.Testnet - } - - // 根据AI模型设置API密钥 - if aiModelCfg.Provider == "qwen" { - traderConfig.QwenKey = aiModelCfg.APIKey - } else if aiModelCfg.Provider == "deepseek" { - traderConfig.DeepSeekKey = aiModelCfg.APIKey - } - - // 创建trader实例 - at, err := trader.NewAutoTrader(traderConfig, database, userID) - if err != nil { - return fmt.Errorf("创建trader失败: %w", err) - } - - // 设置自定义prompt(如果有) - if traderCfg.CustomPrompt != "" { - at.SetCustomPrompt(traderCfg.CustomPrompt) - at.SetOverrideBasePrompt(traderCfg.OverrideBasePrompt) - if traderCfg.OverrideBasePrompt { - log.Printf("✓ 已设置自定义交易策略prompt (覆盖基础prompt)") - } else { - log.Printf("✓ 已设置自定义交易策略prompt (补充基础prompt)") - } - } - - tm.traders[traderCfg.ID] = at - log.Printf("✓ Trader '%s' (%s + %s) 已加载到内存", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ID) - return nil -} - -// AddTrader 从数据库配置添加trader (移除旧版兼容性) - -// AddTraderFromDB 从数据库配置添加trader -func (tm *TraderManager) AddTraderFromDB(traderCfg *config.TraderRecord, aiModelCfg *config.AIModelConfig, exchangeCfg *config.ExchangeConfig, coinPoolURL, oiTopURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, defaultCoins []string, database *config.Database, userID string) error { - tm.mu.Lock() - defer tm.mu.Unlock() - - if _, exists := tm.traders[traderCfg.ID]; exists { - return fmt.Errorf("trader ID '%s' 已存在", traderCfg.ID) - } - - // 处理交易币种列表 - var tradingCoins []string - if traderCfg.TradingSymbols != "" { - // 解析逗号分隔的交易币种列表 - symbols := strings.Split(traderCfg.TradingSymbols, ",") - for _, symbol := range symbols { - symbol = strings.TrimSpace(symbol) - if symbol != "" { - tradingCoins = append(tradingCoins, symbol) - } - } - } - - // 如果没有指定交易币种,使用默认币种 - if len(tradingCoins) == 0 { - tradingCoins = defaultCoins - } - - // 根据交易员配置决定是否使用信号源 - var effectiveCoinPoolURL string - if traderCfg.UseCoinPool && coinPoolURL != "" { - effectiveCoinPoolURL = coinPoolURL - log.Printf("✓ 交易员 %s 启用 COIN POOL 信号源: %s", traderCfg.Name, coinPoolURL) - } - - // 构建AutoTraderConfig - traderConfig := trader.AutoTraderConfig{ - ID: traderCfg.ID, - Name: traderCfg.Name, - AIModel: aiModelCfg.Provider, // 使用provider作为模型标识 - Exchange: exchangeCfg.ID, // 使用exchange ID - BinanceAPIKey: "", - BinanceSecretKey: "", - HyperliquidPrivateKey: "", - HyperliquidTestnet: exchangeCfg.Testnet, - CoinPoolAPIURL: effectiveCoinPoolURL, - UseQwen: aiModelCfg.Provider == "qwen", - DeepSeekKey: "", - QwenKey: "", - CustomAPIURL: aiModelCfg.CustomAPIURL, // 自定义API URL - CustomModelName: aiModelCfg.CustomModelName, // 自定义模型名称 - ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute, - InitialBalance: traderCfg.InitialBalance, - BTCETHLeverage: traderCfg.BTCETHLeverage, - AltcoinLeverage: traderCfg.AltcoinLeverage, - MaxDailyLoss: maxDailyLoss, - MaxDrawdown: maxDrawdown, - StopTradingTime: time.Duration(stopTradingMinutes) * time.Minute, - IsCrossMargin: traderCfg.IsCrossMargin, - DefaultCoins: defaultCoins, - TradingCoins: tradingCoins, - } - - // 根据交易所类型设置API密钥 - if exchangeCfg.ID == "binance" { - traderConfig.BinanceAPIKey = exchangeCfg.APIKey - traderConfig.BinanceSecretKey = exchangeCfg.SecretKey - } else if exchangeCfg.ID == "bybit" { - traderConfig.BybitAPIKey = exchangeCfg.APIKey - traderConfig.BybitSecretKey = exchangeCfg.SecretKey - } else if exchangeCfg.ID == "hyperliquid" { - traderConfig.HyperliquidPrivateKey = exchangeCfg.APIKey // hyperliquid用APIKey存储private key - traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr - } else if exchangeCfg.ID == "aster" { - traderConfig.AsterUser = exchangeCfg.AsterUser - traderConfig.AsterSigner = exchangeCfg.AsterSigner - traderConfig.AsterPrivateKey = exchangeCfg.AsterPrivateKey - } else if exchangeCfg.ID == "lighter" { - traderConfig.LighterPrivateKey = exchangeCfg.LighterPrivateKey - traderConfig.LighterWalletAddr = exchangeCfg.LighterWalletAddr - traderConfig.LighterTestnet = exchangeCfg.Testnet - } - - // 根据AI模型设置API密钥 - if aiModelCfg.Provider == "qwen" { - traderConfig.QwenKey = aiModelCfg.APIKey - } else if aiModelCfg.Provider == "deepseek" { - traderConfig.DeepSeekKey = aiModelCfg.APIKey - } - - // 创建trader实例 - at, err := trader.NewAutoTrader(traderConfig, database, userID) - if err != nil { - return fmt.Errorf("创建trader失败: %w", err) - } - - // 设置自定义prompt(如果有) - if traderCfg.CustomPrompt != "" { - at.SetCustomPrompt(traderCfg.CustomPrompt) - at.SetOverrideBasePrompt(traderCfg.OverrideBasePrompt) - if traderCfg.OverrideBasePrompt { - log.Printf("✓ 已设置自定义交易策略prompt (覆盖基础prompt)") - } else { - log.Printf("✓ 已设置自定义交易策略prompt (补充基础prompt)") - } - } - - tm.traders[traderCfg.ID] = at - log.Printf("✓ Trader '%s' (%s + %s) 已添加", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ID) - return nil -} - // GetTrader 获取指定ID的trader func (tm *TraderManager) GetTrader(id string) (*trader.AutoTrader, error) { tm.mu.RLock() @@ -444,12 +79,12 @@ func (tm *TraderManager) StartAll() { tm.mu.RLock() defer tm.mu.RUnlock() - log.Println("🚀 启动所有Trader...") + logger.Info("🚀 启动所有Trader...") for id, t := range tm.traders { go func(traderID string, at *trader.AutoTrader) { - log.Printf("▶️ 启动 %s...", at.GetName()) + logger.Infof("▶️ 启动 %s...", at.GetName()) if err := at.Run(); err != nil { - log.Printf("❌ %s 运行错误: %v", at.GetName(), err) + logger.Infof("❌ %s 运行错误: %v", at.GetName(), err) } }(id, t) } @@ -460,7 +95,7 @@ func (tm *TraderManager) StopAll() { tm.mu.RLock() defer tm.mu.RUnlock() - log.Println("⏹ 停止所有Trader...") + logger.Info("⏹ 停止所有Trader...") for _, t := range tm.traders { t.Stop() } @@ -514,7 +149,7 @@ func (tm *TraderManager) GetCompetitionData() (map[string]interface{}, error) { cachedData[k] = v } tm.competitionCache.mu.RUnlock() - log.Printf("📋 返回竞赛数据缓存 (缓存时间: %.1fs)", time.Since(tm.competitionCache.timestamp).Seconds()) + logger.Infof("📋 返回竞赛数据缓存 (缓存时间: %.1fs)", time.Since(tm.competitionCache.timestamp).Seconds()) return cachedData, nil } tm.competitionCache.mu.RUnlock() @@ -528,7 +163,7 @@ func (tm *TraderManager) GetCompetitionData() (map[string]interface{}, error) { } tm.mu.RUnlock() - log.Printf("🔄 重新获取竞赛数据,交易员数量: %d", len(allTraders)) + logger.Infof("🔄 重新获取竞赛数据,交易员数量: %d", len(allTraders)) // 并发获取交易员数据 traders := tm.getConcurrentTraderData(allTraders) @@ -618,7 +253,7 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [ } case err := <-errorChan: // 获取账户信息失败 - log.Printf("⚠️ 获取交易员 %s 账户信息失败: %v", trader.GetID(), err) + logger.Infof("⚠️ 获取交易员 %s 账户信息失败: %v", trader.GetID(), err) traderData = map[string]interface{}{ "trader_id": trader.GetID(), "trader_name": trader.GetName(), @@ -635,7 +270,7 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [ } case <-ctx.Done(): // 超时 - log.Printf("⏰ 获取交易员 %s 账户信息超时", trader.GetID()) + logger.Infof("⏰ 获取交易员 %s 账户信息超时", trader.GetID()) traderData = map[string]interface{}{ "trader_id": trader.GetID(), "trader_name": trader.GetName(), @@ -695,63 +330,46 @@ func (tm *TraderManager) GetTopTradersData() (map[string]interface{}, error) { return result, nil } -// isUserTrader 检查trader是否属于指定用户 -func isUserTrader(traderID, userID string) bool { - // trader ID格式: userID_traderName 或 randomUUID_modelName - // 为了兼容性,我们检查前缀 - if len(traderID) >= len(userID) && traderID[:len(userID)] == userID { - return true + +// RemoveTrader 从内存中移除指定的trader(不影响数据库) +// 用于更新trader配置时强制重新加载 +func (tm *TraderManager) RemoveTrader(traderID string) { + tm.mu.Lock() + defer tm.mu.Unlock() + + if _, exists := tm.traders[traderID]; exists { + delete(tm.traders, traderID) + logger.Infof("✓ Trader %s 已从内存中移除", traderID) } - // 对于老的default用户,所有没有明确用户前缀的都属于default - if userID == "default" && !containsUserPrefix(traderID) { - return true - } - return false } -// containsUserPrefix 检查trader ID是否包含用户前缀 -func containsUserPrefix(traderID string) bool { - // 检查是否包含邮箱格式的前缀(user@example.com_traderName) - for i, ch := range traderID { - if ch == '@' { - // 找到@符号,说明可能是email前缀 - return true - } - if ch == '_' && i > 0 { - // 找到下划线但前面没有@,可能是UUID或其他格式 - break - } - } - return false -} - -// LoadUserTraders 为特定用户加载交易员到内存 -func (tm *TraderManager) LoadUserTraders(database *config.Database, userID string) error { +// LoadUserTradersFromStore 为特定用户从store加载交易员到内存 +func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string) error { tm.mu.Lock() defer tm.mu.Unlock() // 获取指定用户的所有交易员 - traders, err := database.GetTraders(userID) + traders, err := st.Trader().List(userID) if err != nil { return fmt.Errorf("获取用户 %s 的交易员列表失败: %w", userID, err) } - log.Printf("📋 为用户 %s 加载交易员配置: %d 个", userID, len(traders)) + logger.Infof("📋 为用户 %s 加载交易员配置: %d 个", userID, len(traders)) - // 获取系统配置(不包含信号源,信号源现在为用户级别) - maxDailyLossStr, _ := database.GetSystemConfig("max_daily_loss") - maxDrawdownStr, _ := database.GetSystemConfig("max_drawdown") - stopTradingMinutesStr, _ := database.GetSystemConfig("stop_trading_minutes") - defaultCoinsStr, _ := database.GetSystemConfig("default_coins") + // 获取系统配置 + maxDailyLossStr, _ := st.SystemConfig().Get("max_daily_loss") + maxDrawdownStr, _ := st.SystemConfig().Get("max_drawdown") + stopTradingMinutesStr, _ := st.SystemConfig().Get("stop_trading_minutes") + defaultCoinsStr, _ := st.SystemConfig().Get("default_coins") // 获取用户信号源配置 var coinPoolURL, oiTopURL string - if userSignalSource, err := database.GetUserSignalSource(userID); err == nil { - coinPoolURL = userSignalSource.CoinPoolURL - oiTopURL = userSignalSource.OITopURL - log.Printf("📡 加载用户 %s 的信号源配置: COIN POOL=%s, OI TOP=%s", userID, coinPoolURL, oiTopURL) + if signalSource, err := st.SignalSource().Get(userID); err == nil { + coinPoolURL = signalSource.CoinPoolURL + oiTopURL = signalSource.OITopURL + logger.Infof("📡 加载用户 %s 的信号源配置: COIN POOL=%s, OI TOP=%s", userID, coinPoolURL, oiTopURL) } else { - log.Printf("🔍 用户 %s 暂未配置信号源", userID) + logger.Infof("🔍 用户 %s 暂未配置信号源", userID) } // 解析配置 @@ -774,22 +392,21 @@ func (tm *TraderManager) LoadUserTraders(database *config.Database, userID strin var defaultCoins []string if defaultCoinsStr != "" { if err := json.Unmarshal([]byte(defaultCoinsStr), &defaultCoins); err != nil { - log.Printf("⚠️ 解析默认币种配置失败: %v,使用空列表", err) + logger.Infof("⚠️ 解析默认币种配置失败: %v,使用空列表", err) defaultCoins = []string{} } } - // 🔧 性能优化:在循环外只查询一次AI模型和交易所配置 - // 避免在循环中重复查询相同的数据,减少数据库压力和锁持有时间 - aiModels, err := database.GetAIModels(userID) + // 获取AI模型和交易所列表(在循环外只查询一次) + aiModels, err := st.AIModel().List(userID) if err != nil { - log.Printf("⚠️ 获取用户 %s 的AI模型配置失败: %v", userID, err) + logger.Infof("⚠️ 获取用户 %s 的AI模型配置失败: %v", userID, err) return fmt.Errorf("获取AI模型配置失败: %w", err) } - exchanges, err := database.GetExchanges(userID) + exchanges, err := st.Exchange().List(userID) if err != nil { - log.Printf("⚠️ 获取用户 %s 的交易所配置失败: %v", userID, err) + logger.Infof("⚠️ 获取用户 %s 的交易所配置失败: %v", userID, err) return fmt.Errorf("获取交易所配置失败: %w", err) } @@ -797,43 +414,39 @@ func (tm *TraderManager) LoadUserTraders(database *config.Database, userID strin for _, traderCfg := range traders { // 检查是否已经加载过这个交易员 if _, exists := tm.traders[traderCfg.ID]; exists { - log.Printf("⚠️ 交易员 %s 已经加载,跳过", traderCfg.Name) + logger.Infof("⚠️ 交易员 %s 已经加载,跳过", traderCfg.Name) continue } // 从已查询的列表中查找AI模型配置 - - var aiModelCfg *config.AIModelConfig - // 优先精确匹配 model.ID(新版逻辑) + var aiModelCfg *store.AIModel for _, model := range aiModels { if model.ID == traderCfg.AIModelID { aiModelCfg = model break } } - // 如果没有精确匹配,尝试匹配 provider(兼容旧数据) if aiModelCfg == nil { for _, model := range aiModels { if model.Provider == traderCfg.AIModelID { aiModelCfg = model - log.Printf("⚠️ 交易员 %s 使用旧版 provider 匹配: %s -> %s", traderCfg.Name, traderCfg.AIModelID, model.ID) break } } } if aiModelCfg == nil { - log.Printf("⚠️ 交易员 %s 的AI模型 %s 不存在,跳过", traderCfg.Name, traderCfg.AIModelID) + logger.Infof("⚠️ 交易员 %s 的AI模型 %s 不存在,跳过", traderCfg.Name, traderCfg.AIModelID) continue } if !aiModelCfg.Enabled { - log.Printf("⚠️ 交易员 %s 的AI模型 %s 未启用,跳过", traderCfg.Name, traderCfg.AIModelID) + logger.Infof("⚠️ 交易员 %s 的AI模型 %s 未启用,跳过", traderCfg.Name, traderCfg.AIModelID) continue } // 从已查询的列表中查找交易所配置 - var exchangeCfg *config.ExchangeConfig + var exchangeCfg *store.Exchange for _, exchange := range exchanges { if exchange.ID == traderCfg.ExchangeID { exchangeCfg = exchange @@ -842,134 +455,59 @@ func (tm *TraderManager) LoadUserTraders(database *config.Database, userID strin } if exchangeCfg == nil { - log.Printf("⚠️ 交易员 %s 的交易所 %s 不存在,跳过", traderCfg.Name, traderCfg.ExchangeID) + logger.Infof("⚠️ 交易员 %s 的交易所 %s 不存在,跳过", traderCfg.Name, traderCfg.ExchangeID) continue } if !exchangeCfg.Enabled { - log.Printf("⚠️ 交易员 %s 的交易所 %s 未启用,跳过", traderCfg.Name, traderCfg.ExchangeID) + logger.Infof("⚠️ 交易员 %s 的交易所 %s 未启用,跳过", traderCfg.Name, traderCfg.ExchangeID) continue } // 使用现有的方法加载交易员 - err = tm.loadSingleTrader(traderCfg, aiModelCfg, exchangeCfg, coinPoolURL, oiTopURL, maxDailyLoss, maxDrawdown, stopTradingMinutes, defaultCoins, database, userID) + err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, coinPoolURL, oiTopURL, maxDailyLoss, maxDrawdown, stopTradingMinutes, defaultCoins, st) if err != nil { - log.Printf("⚠️ 加载交易员 %s 失败: %v", traderCfg.Name, err) + logger.Infof("⚠️ 加载交易员 %s 失败: %v", traderCfg.Name, err) } } return nil } -// LoadTraderByID 加载指定ID的单个交易员到内存 -// 此方法会自动查询所需的所有配置(AI模型、交易所、系统配置等) -// 参数: -// - database: 数据库实例 -// - userID: 用户ID -// - traderID: 交易员ID -// -// 返回: -// - error: 如果交易员不存在、配置无效或加载失败则返回错误 -func (tm *TraderManager) LoadTraderByID(database *config.Database, userID, traderID string) error { +// LoadTradersFromStore 从store加载所有交易员到内存(新版API) +func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error { tm.mu.Lock() defer tm.mu.Unlock() - // 1. 检查是否已加载 - if _, exists := tm.traders[traderID]; exists { - log.Printf("⚠️ 交易员 %s 已经加载,跳过", traderID) - return nil - } - - // 2. 查询交易员配置 - traders, err := database.GetTraders(userID) + // 获取所有用户 + userIDs, err := st.User().GetAllIDs() if err != nil { - return fmt.Errorf("获取交易员列表失败: %w", err) + return fmt.Errorf("获取用户列表失败: %w", err) } - var traderCfg *config.TraderRecord - for _, t := range traders { - if t.ID == traderID { - traderCfg = t - break + logger.Infof("📋 发现 %d 个用户,开始加载所有交易员配置...", len(userIDs)) + + var allTraders []*store.Trader + for _, userID := range userIDs { + // 获取每个用户的交易员 + traders, err := st.Trader().List(userID) + if err != nil { + logger.Infof("⚠️ 获取用户 %s 的交易员失败: %v", userID, err) + continue } + logger.Infof("📋 用户 %s: %d 个交易员", userID, len(traders)) + allTraders = append(allTraders, traders...) } - if traderCfg == nil { - return fmt.Errorf("交易员 %s 不存在", traderID) - } + logger.Infof("📋 总共加载 %d 个交易员配置", len(allTraders)) - // 3. 查询AI模型配置 - aiModels, err := database.GetAIModels(userID) - if err != nil { - return fmt.Errorf("获取AI模型配置失败: %w", err) - } + // 获取系统配置 + maxDailyLossStr, _ := st.SystemConfig().Get("max_daily_loss") + maxDrawdownStr, _ := st.SystemConfig().Get("max_drawdown") + stopTradingMinutesStr, _ := st.SystemConfig().Get("stop_trading_minutes") + defaultCoinsStr, _ := st.SystemConfig().Get("default_coins") - var aiModelCfg *config.AIModelConfig - // 优先精确匹配 model.ID - for _, model := range aiModels { - if model.ID == traderCfg.AIModelID { - aiModelCfg = model - break - } - } - // 如果没有精确匹配,尝试匹配 provider(兼容旧数据) - if aiModelCfg == nil { - for _, model := range aiModels { - if model.Provider == traderCfg.AIModelID { - aiModelCfg = model - log.Printf("⚠️ 交易员 %s 使用旧版 provider 匹配: %s -> %s", traderCfg.Name, traderCfg.AIModelID, model.ID) - break - } - } - } - - if aiModelCfg == nil { - return fmt.Errorf("AI模型 %s 不存在", traderCfg.AIModelID) - } - - if !aiModelCfg.Enabled { - return fmt.Errorf("AI模型 %s 未启用", traderCfg.AIModelID) - } - - // 4. 查询交易所配置 - exchanges, err := database.GetExchanges(userID) - if err != nil { - return fmt.Errorf("获取交易所配置失败: %w", err) - } - - var exchangeCfg *config.ExchangeConfig - for _, exchange := range exchanges { - if exchange.ID == traderCfg.ExchangeID { - exchangeCfg = exchange - break - } - } - - if exchangeCfg == nil { - return fmt.Errorf("交易所 %s 不存在", traderCfg.ExchangeID) - } - - if !exchangeCfg.Enabled { - return fmt.Errorf("交易所 %s 未启用", traderCfg.ExchangeID) - } - - // 5. 查询系统配置 - maxDailyLossStr, _ := database.GetSystemConfig("max_daily_loss") - maxDrawdownStr, _ := database.GetSystemConfig("max_drawdown") - stopTradingMinutesStr, _ := database.GetSystemConfig("stop_trading_minutes") - defaultCoinsStr, _ := database.GetSystemConfig("default_coins") - - // 6. 查询用户信号源配置 - var coinPoolURL, oiTopURL string - if userSignalSource, err := database.GetUserSignalSource(userID); err == nil { - coinPoolURL = userSignalSource.CoinPoolURL - oiTopURL = userSignalSource.OITopURL - log.Printf("📡 加载用户 %s 的信号源配置: COIN POOL=%s, OI TOP=%s", userID, coinPoolURL, oiTopURL) - } else { - log.Printf("🔍 用户 %s 暂未配置信号源", userID) - } - - // 7. 解析系统配置 + // 解析配置 maxDailyLoss := 10.0 // 默认值 if val, err := strconv.ParseFloat(maxDailyLossStr, 64); err == nil { maxDailyLoss = val @@ -989,34 +527,104 @@ func (tm *TraderManager) LoadTraderByID(database *config.Database, userID, trade var defaultCoins []string if defaultCoinsStr != "" { if err := json.Unmarshal([]byte(defaultCoinsStr), &defaultCoins); err != nil { - log.Printf("⚠️ 解析默认币种配置失败: %v,使用空列表", err) + logger.Infof("⚠️ 解析默认币种配置失败: %v,使用空列表", err) defaultCoins = []string{} } } - // 8. 调用私有方法加载交易员 - log.Printf("📋 加载单个交易员: %s (%s)", traderCfg.Name, traderID) - return tm.loadSingleTrader( - traderCfg, - aiModelCfg, - exchangeCfg, - coinPoolURL, - oiTopURL, - maxDailyLoss, - maxDrawdown, - stopTradingMinutes, - defaultCoins, - database, - userID, - ) + // 为每个交易员获取AI模型和交易所配置 + for _, traderCfg := range allTraders { + // 获取AI模型配置 + aiModels, err := st.AIModel().List(traderCfg.UserID) + if err != nil { + logger.Infof("⚠️ 获取AI模型配置失败: %v", err) + continue + } + + var aiModelCfg *store.AIModel + // 优先精确匹配 model.ID + for _, model := range aiModels { + if model.ID == traderCfg.AIModelID { + aiModelCfg = model + break + } + } + // 如果没有精确匹配,尝试匹配 provider(兼容旧数据) + if aiModelCfg == nil { + for _, model := range aiModels { + if model.Provider == traderCfg.AIModelID { + aiModelCfg = model + logger.Infof("⚠️ 交易员 %s 使用旧版 provider 匹配: %s -> %s", traderCfg.Name, traderCfg.AIModelID, model.ID) + break + } + } + } + + if aiModelCfg == nil { + logger.Infof("⚠️ 交易员 %s 的AI模型 %s 不存在,跳过", traderCfg.Name, traderCfg.AIModelID) + continue + } + + if !aiModelCfg.Enabled { + logger.Infof("⚠️ 交易员 %s 的AI模型 %s 未启用,跳过", traderCfg.Name, traderCfg.AIModelID) + continue + } + + // 获取交易所配置 + exchanges, err := st.Exchange().List(traderCfg.UserID) + if err != nil { + logger.Infof("⚠️ 获取交易所配置失败: %v", err) + continue + } + + var exchangeCfg *store.Exchange + for _, exchange := range exchanges { + if exchange.ID == traderCfg.ExchangeID { + exchangeCfg = exchange + break + } + } + + if exchangeCfg == nil { + logger.Infof("⚠️ 交易员 %s 的交易所 %s 不存在,跳过", traderCfg.Name, traderCfg.ExchangeID) + continue + } + + if !exchangeCfg.Enabled { + logger.Infof("⚠️ 交易员 %s 的交易所 %s 未启用,跳过", traderCfg.Name, traderCfg.ExchangeID) + continue + } + + // 获取用户信号源配置 + var coinPoolURL, oiTopURL string + if signalSource, err := st.SignalSource().Get(traderCfg.UserID); err == nil { + coinPoolURL = signalSource.CoinPoolURL + oiTopURL = signalSource.OITopURL + } else { + logger.Infof("🔍 用户 %s 暂未配置信号源", traderCfg.UserID) + } + + // 添加到TraderManager + err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, coinPoolURL, oiTopURL, maxDailyLoss, maxDrawdown, stopTradingMinutes, defaultCoins, st) + if err != nil { + logger.Infof("❌ 添加交易员 %s 失败: %v", traderCfg.Name, err) + continue + } + } + + logger.Infof("✓ 成功加载 %d 个交易员到内存", len(tm.traders)) + return nil } -// loadSingleTrader 加载单个交易员(从现有代码提取的公共逻辑) -func (tm *TraderManager) loadSingleTrader(traderCfg *config.TraderRecord, aiModelCfg *config.AIModelConfig, exchangeCfg *config.ExchangeConfig, coinPoolURL, oiTopURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, defaultCoins []string, database *config.Database, userID string) error { +// addTraderFromStore 内部方法:从store配置添加交易员 +func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg *store.AIModel, exchangeCfg *store.Exchange, coinPoolURL, oiTopURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, defaultCoins []string, st *store.Store) error { + if _, exists := tm.traders[traderCfg.ID]; exists { + return fmt.Errorf("trader ID '%s' 已存在", traderCfg.ID) + } + // 处理交易币种列表 var tradingCoins []string if traderCfg.TradingSymbols != "" { - // 解析逗号分隔的交易币种列表 symbols := strings.Split(traderCfg.TradingSymbols, ",") for _, symbol := range symbols { symbol = strings.TrimSpace(symbol) @@ -1035,48 +643,54 @@ func (tm *TraderManager) loadSingleTrader(traderCfg *config.TraderRecord, aiMode var effectiveCoinPoolURL string if traderCfg.UseCoinPool && coinPoolURL != "" { effectiveCoinPoolURL = coinPoolURL - log.Printf("✓ 交易员 %s 启用 COIN POOL 信号源: %s", traderCfg.Name, coinPoolURL) + logger.Infof("✓ 交易员 %s 启用 COIN POOL 信号源: %s", traderCfg.Name, coinPoolURL) } // 构建AutoTraderConfig traderConfig := trader.AutoTraderConfig{ - ID: traderCfg.ID, - Name: traderCfg.Name, - AIModel: aiModelCfg.Provider, // 使用provider作为模型标识 - Exchange: exchangeCfg.ID, // 使用exchange ID - InitialBalance: traderCfg.InitialBalance, - BTCETHLeverage: traderCfg.BTCETHLeverage, - AltcoinLeverage: traderCfg.AltcoinLeverage, - ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute, - CoinPoolAPIURL: effectiveCoinPoolURL, - CustomAPIURL: aiModelCfg.CustomAPIURL, // 自定义API URL - CustomModelName: aiModelCfg.CustomModelName, // 自定义模型名称 - UseQwen: aiModelCfg.Provider == "qwen", - MaxDailyLoss: maxDailyLoss, - MaxDrawdown: maxDrawdown, - StopTradingTime: time.Duration(stopTradingMinutes) * time.Minute, - IsCrossMargin: traderCfg.IsCrossMargin, - DefaultCoins: defaultCoins, - TradingCoins: tradingCoins, - SystemPromptTemplate: traderCfg.SystemPromptTemplate, // 系统提示词模板 - HyperliquidTestnet: exchangeCfg.Testnet, // Hyperliquid测试网 + ID: traderCfg.ID, + Name: traderCfg.Name, + AIModel: aiModelCfg.Provider, + Exchange: exchangeCfg.ID, + BinanceAPIKey: "", + BinanceSecretKey: "", + HyperliquidPrivateKey: "", + HyperliquidTestnet: exchangeCfg.Testnet, + CoinPoolAPIURL: effectiveCoinPoolURL, + UseQwen: aiModelCfg.Provider == "qwen", + DeepSeekKey: "", + QwenKey: "", + CustomAPIURL: aiModelCfg.CustomAPIURL, + CustomModelName: aiModelCfg.CustomModelName, + ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute, + InitialBalance: traderCfg.InitialBalance, + BTCETHLeverage: traderCfg.BTCETHLeverage, + AltcoinLeverage: traderCfg.AltcoinLeverage, + MaxDailyLoss: maxDailyLoss, + MaxDrawdown: maxDrawdown, + StopTradingTime: time.Duration(stopTradingMinutes) * time.Minute, + IsCrossMargin: traderCfg.IsCrossMargin, + DefaultCoins: defaultCoins, + TradingCoins: tradingCoins, + SystemPromptTemplate: traderCfg.SystemPromptTemplate, } // 根据交易所类型设置API密钥 - if exchangeCfg.ID == "binance" { + switch exchangeCfg.ID { + case "binance": traderConfig.BinanceAPIKey = exchangeCfg.APIKey traderConfig.BinanceSecretKey = exchangeCfg.SecretKey - } else if exchangeCfg.ID == "bybit" { + case "bybit": traderConfig.BybitAPIKey = exchangeCfg.APIKey traderConfig.BybitSecretKey = exchangeCfg.SecretKey - } else if exchangeCfg.ID == "hyperliquid" { - traderConfig.HyperliquidPrivateKey = exchangeCfg.APIKey // hyperliquid用APIKey存储private key + case "hyperliquid": + traderConfig.HyperliquidPrivateKey = exchangeCfg.APIKey traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr - } else if exchangeCfg.ID == "aster" { + case "aster": traderConfig.AsterUser = exchangeCfg.AsterUser traderConfig.AsterSigner = exchangeCfg.AsterSigner traderConfig.AsterPrivateKey = exchangeCfg.AsterPrivateKey - } else if exchangeCfg.ID == "lighter" { + case "lighter": traderConfig.LighterPrivateKey = exchangeCfg.LighterPrivateKey traderConfig.LighterWalletAddr = exchangeCfg.LighterWalletAddr traderConfig.LighterTestnet = exchangeCfg.Testnet @@ -1090,7 +704,7 @@ func (tm *TraderManager) loadSingleTrader(traderCfg *config.TraderRecord, aiMode } // 创建trader实例 - at, err := trader.NewAutoTrader(traderConfig, database, userID) + at, err := trader.NewAutoTrader(traderConfig, st, traderCfg.UserID) if err != nil { return fmt.Errorf("创建trader失败: %w", err) } @@ -1100,25 +714,13 @@ func (tm *TraderManager) loadSingleTrader(traderCfg *config.TraderRecord, aiMode at.SetCustomPrompt(traderCfg.CustomPrompt) at.SetOverrideBasePrompt(traderCfg.OverrideBasePrompt) if traderCfg.OverrideBasePrompt { - log.Printf("✓ 已设置自定义交易策略prompt (覆盖基础prompt)") + logger.Infof("✓ 已设置自定义交易策略prompt (覆盖基础prompt)") } else { - log.Printf("✓ 已设置自定义交易策略prompt (补充基础prompt)") + logger.Infof("✓ 已设置自定义交易策略prompt (补充基础prompt)") } } tm.traders[traderCfg.ID] = at - log.Printf("✓ Trader '%s' (%s + %s) 已为用户加载到内存", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ID) + logger.Infof("✓ Trader '%s' (%s + %s) 已加载到内存", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ID) return nil } - -// RemoveTrader 从内存中移除指定的trader(不影响数据库) -// 用于更新trader配置时强制重新加载 -func (tm *TraderManager) RemoveTrader(traderID string) { - tm.mu.Lock() - defer tm.mu.Unlock() - - if _, exists := tm.traders[traderID]; exists { - delete(tm.traders, traderID) - log.Printf("✓ Trader %s 已从内存中移除", traderID) - } -} diff --git a/market/data.go b/market/data.go index 32a9f8c4..6a151391 100644 --- a/market/data.go +++ b/market/data.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" "io" - "log" + "nofx/logger" "math" "strconv" "strings" @@ -38,7 +38,7 @@ func Get(symbol string) (*Data, error) { // Data staleness detection: Prevent DOGEUSDT-style price freeze issues if isStaleData(klines3m, symbol) { - log.Printf("⚠️ WARNING: %s detected stale data (consecutive price freeze), skipping symbol", symbol) + logger.Infof("⚠️ WARNING: %s detected stale data (consecutive price freeze), skipping symbol", symbol) return nil, fmt.Errorf("%s data is stale, possible cache failure", symbol) } @@ -633,11 +633,11 @@ func isStaleData(klines []Kline, symbol string) bool { } if allVolumeZero { - log.Printf("⚠️ %s stale data confirmed: price freeze + zero volume", symbol) + logger.Infof("⚠️ %s stale data confirmed: price freeze + zero volume", symbol) return true } // Price frozen but has volume: might be extremely low volatility market, allow but log warning - log.Printf("⚠️ %s detected extreme price stability (no fluctuation for %d consecutive periods), but volume is normal", symbol, stalePriceThreshold) + logger.Infof("⚠️ %s detected extreme price stability (no fluctuation for %d consecutive periods), but volume is normal", symbol, stalePriceThreshold) return false } diff --git a/mcp/config.go b/mcp/config.go index a32686a5..d235a28d 100644 --- a/mcp/config.go +++ b/mcp/config.go @@ -5,6 +5,8 @@ import ( "os" "strconv" "time" + + "nofx/logger" ) // Config 客户端配置(集中管理所有配置) @@ -44,8 +46,8 @@ func DefaultConfig() *Config { Timeout: DefaultTimeout, RetryableErrors: retryableErrors, - // 默认依赖 - Logger: &defaultLogger{}, + // 默认依赖(使用全局 logger) + Logger: logger.NewMCPLogger(), HTTPClient: &http.Client{Timeout: DefaultTimeout}, } } diff --git a/mcp/logger.go b/mcp/logger.go index 863310db..e12aa206 100644 --- a/mcp/logger.go +++ b/mcp/logger.go @@ -1,9 +1,8 @@ package mcp -import "log" - // Logger 日志接口(抽象依赖) // 使用 Printf 风格的方法名,方便集成 logrus、zap 等主流日志库 +// 默认使用全局 logger 包(见 mcp/config.go) type Logger interface { Debugf(format string, args ...any) Infof(format string, args ...any) @@ -11,25 +10,6 @@ type Logger interface { Errorf(format string, args ...any) } -// defaultLogger 默认日志实现(包装标准库 log) -type defaultLogger struct{} - -func (l *defaultLogger) Debugf(format string, args ...any) { - log.Printf("[DEBUG] "+format, args...) -} - -func (l *defaultLogger) Infof(format string, args ...any) { - log.Printf("[INFO] "+format, args...) -} - -func (l *defaultLogger) Warnf(format string, args ...any) { - log.Printf("[WARN] "+format, args...) -} - -func (l *defaultLogger) Errorf(format string, args ...any) { - log.Printf("[ERROR] "+format, args...) -} - // noopLogger 空日志实现(测试时使用) type noopLogger struct{} @@ -42,27 +22,3 @@ func (l *noopLogger) Errorf(format string, args ...any) {} func NewNoopLogger() Logger { return &noopLogger{} } - -// ============================================================ -// 适配第三方日志库示例 -// ============================================================ - -// Logrus 适配示例: -// type LogrusLogger struct { -// logger *logrus.Logger -// } -// -// func (l *LogrusLogger) Infof(format string, args ...any) { -// l.logger.Infof(format, args...) -// } -// -// Zap 适配示例: -// type ZapLogger struct { -// logger *zap.Logger -// } -// -// func (l *ZapLogger) Infof(format string, args ...any) { -// l.logger.Sugar().Infof(format, args...) -// } -// -// 然后通过 WithLogger(logger) 注入 diff --git a/screenshots/competition-page.png b/screenshots/competition-page.png deleted file mode 100644 index dad13d4e1534de1b9d2937773b1552659b4e4808..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 389583 zcmdqJc|6p8`!=p@$r?#WxFTICLde*YrK}}mEJH(Kk}dXb@`cw9*ILo=mQ!Zejx!(g9#&+v?XIcaN1dcBZBxwEhoJ~k zQBF?AYKHSq(){LRD%PB0<6X27G^boJB$omg0)Fj(e~Fo=4A}qo%LlJyJkq-Gk5A-< zA|e0y;O^zaxPN@m;mYK2=%1b>^&e?D{7(r8s8o9^7pq2AAL%feGD0*T3hW0-mB#%Lp5$G!ugI0J) z*)>i3qYwY_hzwJftibQt7#=))Z>=y9-1}AFA0w~f7tPG@)*7bT%KJ}2`}{`7h=Jj4 z6V3u|)0chspWly80E9uS=6Js0h5!B)26;DVHTfpH`T5~5i1{$;?1QLiTdL1WmwFzB zok7wDKTg9i@G&qHiuIk`oxFbHzkkA!o3(a)(h^5bJ^O0+iO%}qxnWEi1A~F&|HuC} zQ!WU)zyYa*v71ZRCez|3wXUBXsk7b2ZHxJsE^NPjf64}5QE2@}1}g1smQ03XL#p>^ z>dBtLHtPdrzNurED6Z5kkC?1|`d1jkfYz#Pb>G+ULAu!ey#7AqKOI2p3dcm=J)4Im zR~A01i_x!oBc7&c<&n;QzH^2iS+6{By)U<;CZpZDQJrB?l8&qwh-7>czA6#--%$&^ zU1+CFJoe(!1s+NI!w~xp_1Bavzd|5<<0n4fk)%h}>ksJmi8yaor(p6DN0;@^Um4_QE!-o$#gMmD9zW^{l!My|uqN855sXA>U<*Rji!!PqV7}>F^?D zs-x()tbXwPJrE?5g<{X<6GT~0MplJtsjsA@kZaS*i&`#_58&HK2U?x>0tfqXO;GC> zt#`SqA3?5+xQm&$l?%8F-N44`#L3E~lVy&xH|O54Cdf~#(JZOE#P?px`t=ij%>v{}EB zF0LePeqifaqOJQ((Uh$mpA5Dkorcen7cWkr*KU35D&BYMv3y>)NvtJmP_~y1QP}Hc zN?@do4L&&4BJm2FyWvj+Jx3WmJ5SrF6-|#+X&WZ(4@eV0wU7!8O&(}BbATVZxkz2zpUY2N&Z~Xfw zi`KfpgMEJ(H|#c2ImhyULr4gpm;s;T)>=#2zB&CyD}NBkmXuK~G!x7cxU`|vulC*a zYHf(UCB)uk)Rn=1-*>IIgo~}AsJEnF%-yM-dv%`O;?#b4MUj=lo8TvGZ8z>L&6;Z9 zo6IsF+0bf&#Cfu;o^!SkpVeJ%*xu6zuNbP@AJH;z3RW!Zd++Hn66yS7Q~@n^J1KEvRbd!@cGdO$vDlsqA6Xn)A80ps+fYJz zP!srV#lctR+^E$%BQ+lS)Wpi^$^^J}=+8Axcs@%sbr@Slq^NyaGu_A>3v3R6~8shtODuh(x zMz9;Np2#eg_-HByHi+r0-;GJnA#7*4DK1%ear;{o4a?b7Xn2X38`m_fPU59XCJN3r zeq7!%MpxgzBMp35i4f5fmwCv~?^pW$={bOo$8-GtanJwyxE;j^g+%;Ao05;-1MM=$ z!usB3=9msnaOVXs{#8kM`C>&h>z;0|W@zd&Bcgjan`@A;*;k__y(Ke|qj|k0I5V^q z4>|KvX*8aYf%OL`@Wl=tDzbnRb?f rzvDY!NR36aRryLB5|nrIc>H)k(m#-px> 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

79@2}zh6|o@g9RqjG{LsU;$8edVg&!A~c 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 ja&^>T}41RY-(K;TIpByLIdN#I;dhQf; z*O;qIdfu9IoBDty+}Sfai%;&AlYQN*SAExVO(@lI;k>CGx>8k9snC)a|Fvv==xwvp z;7se?zJB>n^ScMBcj#NtZ$0Q$G`F0S#~nMphoxbklSJALf>7%>z>3Eu7^C(|RAxM@ zBNsCT%OkK+voaXe4SP6;p%$$`;jr|j<9YOD=Xuah$rGJ5FCSFewCr(6=e*vto^s^g zL1Wr|8)q=ROTmYHZaG?_!bEw!WW@_zg zZJOG%rQxsEe#H*i>b2H}YMYD&=rWpGpZXiO`S0i2#EKdi8o)Su((9ZUZRuLV8F*mK zr^kG8CLyRY7E*SE6p2iQiCnxFILzPkDI1SQYiaf^7N9HRUnrY&*v7Gy!4rv6jDf0x zHm`=L3k3WJ-HN+kRTg*qyQO@jUg=D_v7SJ?vWj4tsC$35K0A*3Rb)2RtH^?Qc5{S; z?2jrd?|8SFlQ+h2c!M&M8lQQR+k3BOjmgEHN8m4TAQLrKoILJoAQ`@$w4sNFF1`5F zW}SlTOo8Tv!%BZ#|D`8+9yf5my}FWF^RiC=x%z>DU)ZYv?c9vBbU$P782P87c$ECk z_!Cf3+b(lNjwkCh)9CH1xUw(?7viWwk%S*F8rN zd{wj*^nImq1taJ}2??KY9^t#0_B-gmI*P;247OosIx3+P;q$d1 z`I^3Qy;=9(1E!7~g#s+|D^sh52qZOb;|G;taFJodl(nhOXRP$7ylSJYP^P{th{Q-E zua=NmyI3=ey`bRLR*u$8_b6Pk+Ip02(QeDz{4LvyeRC=# zh6LyGVaZSHG29sv!dJ+%oc8#7-?Bzd zzGCm1wFc8~<-*huVzH9EMsSv>a_O&y4d0LwD@r8C(z?2|#DN(mXI71dvQS;(F)QX~ zK)m(S+<}|w6Zd#^LTa?rHduy&+Rf16-fI`+qhM2G1liL?E7y(Kf6*$koqJIyHCBn3 z!y{`*N0tiHwsb$et~I&*UumzASeqBW<80zswjz% z3H(qj`%dF}d+GJ^!G-J| zQPaqO@9!luv)L9f=#Pn0Ehwask^xOlMwXXF#@s&pICl=G4<_@C#XbT zUb=$>&+GiXv(;mJ)t?>L<(93b+v|oOv>^dLlMp6D!^|nmZT>6{4|zJcgt-x`#<;wM4XeB+O>KbKk$98KB9&CNhJxUmV4jOxrO|dR;&9;J0;+8j? zFA+IzZ-)5Xf>ViPyI@f5sw!{9;De4AOn#plr`FTL`LR|3+Nw~bWtJ@1++X+@(4@m} zPG6Js8PAyf{3Jp=8J}0Z@Tt%`McE;V(M0j{Vm`@*-A~_lD?@*MJN3yjDYM?l&#A#8 z$VohteuNi|u$vcs4ps#&&&gwNc!VA2YTjjAgsl@6afqHWl z0D|Bhvg{MvPPs%{|Kk#quc|GWGznb+lXV3ClL%}znWw`z$I8+vHfn*B4r~VfQbzPL zsI(L2vcAwoO|Yn%zu~flFj;Q3IN|Ab;YUr?S@VO^6EaD6NX$KcRE>HF+6Vmd<2^ND zh*Yh9>g~E$vojIxbyo80Gydtt0Q6x5{ew-J`(&x7521m)p`kKjH1g^>KjJuJhEWT6x3bLp=*sS?lZI)`U#X# zB{J%pX0maHrU6bNMI7jW?3tnCr$}2%7#eH#XmodsTGT6j9F>`*pn<18-GL0QJ#Fm* z*1uVAMZ-Y66$aw1JXE|QZ?Ht(bHZ6-ymiY_0&ITLi1F6>>Wu1ad4uT+b3(bY6-d}>+6Kkr{2&SjL|VXgZ;Qw;P8T;T6(R5 z;FYT1o+iUn9=I8Q`OViaKd`iC0(4uh=jNZs``l=%6bh9;FE#2T(4M?o{JE72n8Crv zCNm1QZZX(sUB#K^4(zow_ro!2Iuh=;MTEqMpS<~DhB6SlBhRFPmp<9v5wUhr;Oo#{ z9c8p&N3P1NEba&geBBX74eMnH=@RoeGDt)n2^eBM??GxM3GUH^IH>jInf!+*`X}C{ zr@E2jM0od6!n75JcNH_v9^$89t3-$j$cmr`CA-it57}#Q1%_+KTTJB6cRG^%(mkTk24fbvJnjH-3?Wc38raLj8xCZU6hs)Heh7>Vhxc68- z@3@uOpsn&F=5H%A1!!fiz=_VIPViJ&`M$^z%z(!{8D--y-cphi)6%(ZDAm<}%1$TF zUDM?u{S(Ert#+(j@an6kybQN=@U&2?!fkkoasztV)yCP=XF&a#mT3WvXL?mq^XJL6 z4^wx5oC`@01mtMsH>cB?f5_2)hiJ@_G}M-%l>sD8Ls0g-KpH8&1d^sjB{>A*82udi zoR~CySe)J;_9tnY0+OcmVfi1b_xatsW7lqJ3#hxdOvj5bA{;={G&gQrh8)ZZ`}`7` zZAGa{9fhpWs+hSwOO9`JHg9>FhQWJZvi&p9xVD(z8NFh~r@-A*e61F})Uf`U+iV-4 zX>6zw{+SAs^pVH?O4or7+dquuYSAm_&LFVao0LA7DsFTiZpM@{z%0nok1|lY;LaV( zd<#cKH|CF&+FW9+A}*NI{-C3Sv$r4qyw^^kD$rz~{U>TFy68EKgSrkxEOA26Oco4q zrjyo*?cRN*EjsBVVTGVjBDWnn=A9gE+v3Zo9#f!H(ex04K?HKG7{o}mBb^DBF~n+R zIZ&>1`?Nmmqg%Jkv>*aMvrvxj0(^bsXo;5}*9yu4Vdo+f?Y{QM8wMno-8c7GC zizP1D3n>kxM>e}4wc>%1CNN{h6J<^nRLx4Rc29x&pVvA@2ifbpwePNmTlo8>^eu8W z!#-OBDJ<=a9|s5rJ^BT;VI*zotNCf4btP!jDZX+L<|>QHh9 zXt(@Fz{&BXoUWUzIchP*#}>QpPAF2LP#`wv4X>~p>I4`Ci zcwe5t3Quf_EF!T-!p@Q{jtVPn#Tw%>%Rij|CpEa&<)NHq*10U;wiZQ3k>SOP+F~0NP6j;@cUik zEosVzqp{K(p58}kH|E~mJHdvan)uFs;!gGL`t`7;8m2^J!C8l}rlYZ85_Z|5Yb19_ z#=CBxsXbUBtSseeo;aK{0F$_wEB+z9>73t(IMK!*-@{i@Tld)T2E$}85WQqCj0VY$ z?&Q*DCH*A9@Dj5I zLwiyBw>P58L$2>t!adYpF0sg5eg-K^&%IDkw`y*8F3!}a=5TkR)YXgZA>5of{wD6p zG;_HM>Xt50erw5;)4uREvw1D94tt#lVqLYKt_8$__PEFDY$=D6{L{PWk;5M}det88 z{*Uvh79!FzuyQ9*cyM0iH;0hO51e)FT zByIWD^;9oYs+6vK!=j;e(ta1 zJx*4hT=Th(0(e}uA_}-S( z1PSk#^UPG!*B;&|NHu<#d^yWV?sz~T2nda>JPNY|}>i!s+% zbe__r=f7$sXM>H)jOevc*|-D#29)Amx6%+;p%%0NVEdh<5WH+Q~{)GIz^7NVAwQb(Kl<)dr3zBRGe804%nKjXl$Rjlgp;Rg)>w< zOU%ioN3wtJI`;z;5Fh#l+ceLj^;;uYtK?a0%pz2K$x+9YZ&<3vRsc8*#^MI&-Q<9i zfX*ZHRc;k9iVo!om+Jxq|-QdqZ`ZCeid}s@_vayM3>LV}g zkujvKm)LHn+#{S9eG7Ph`m*R1)8GI0L)eI3mhtgAvAuBEV%*gi)-+lt+EAm@PJ(o= zLGw3E`$SI^d#D8)G8=b=->}$KTIt+tt-l=U#?E%GPw+*cOV{&PdOGpKiy~Z2hq#*i z+pQnMSJpzp3%k4ZS?&5wKB5NUhASx}-ulU>vyawnD`tLEPu5pTBf z4;KCuCMQ(_MgO*s#KA+vkDARbsgF3Ny!Gi#s>=1iL|eP%hM9g)CNx%OF}tW*Q>Qut zdMf&e4Ij*Szp$_Nt1)*iMf0q|dOliTUf2_Yu6eO8P%gaOkf~!(l{uj88A*_hpG35l zj+8~5hrF0_YX7cTAd+0Xnw7|mnWnQ0QKlDErx1v4_{yhVVxj1LzC2e1u(G51@_67= z(8aG2k9nC)XPm_SFmafq)0urfCsevWPRN{cBf3dY?9t^0L+;NdVL>mhDQHZ8AY48_ zGjOc}hg5g%=EawrY*;jRi|6R)7n(Qk?WLD2<@7kV$SgaAfZ@J(r&`{vrDz#vci^OF z&FZ`WQQJCvp&{5rw_x;xHc73@wR?py({mbCyM=f1e4-n1)1K2l%Kv5>s5%An5|0|nqGqEuH z`=q{;({(m}TT(aC=IJwuC;gK|`X`}4w1gd>au+|m%s)I@E3zUpNW&D%qH8GypGI)e z^;Z3{t{$954ub^NATWFZDWl;lWCW+L_0Bo%fZ%Pa!6cYqW%Wsn2(ITW8KH}FgjAa9 z%Rlz4C_MKL$yYk773;4SQvvqci&vZM>%Ho0LxCGg$}0ZA$;Zxi*jb+XP5 zBrQHsDyYLE1bC<+S)u*faOXa=Lmc^LtD z8m*j7`0^x19aT#tPp`Vi>H6H8*Wzyo+>|i@y~+hNnpfS+qH~e9k+7W)Ik7ybDZ7UN zFjWb9l}nX95xM`eV2erlI}#l&dvY9BqGn(W-`1>+P#&L$&!0#R}gASr;ElQbt&vbT1$AGJguo3k`HHHd|q{mjdN^l`URCW%17Rrm3;yx{XhmCqn5ge+??vgvTDeyWUu9&1-S6` z+@Kp~><#9szAY*|%21P|8T~A<)TRE!S=OoI*$u@#UlBFEON`ipx-K2^#K#q`lo{0vw=PtgvHAfVx&HLrB`?o zcE|Sc2)Ci1rq9c2rW1Ngyv!ipp=4xHn{r?^-q zYNghUUgYtRov!MElY@p>cF^+tjo9)G$!^a?gL2<~5J1-FY7aHC9x6@K{ znYIDb;HRi~1e7QZ_m(q*J^be$cOcby0|x)QO8{d2Cf*QfMg><~O{8WwG z4vFhrBkA@EaH-V<3 z^C{pZAO@T-lx@Rr6P@7NN>A}TWeank`+!i!p+R-7Q-(J>5Ws=ppo*C%V}<2Z`zI$* z#e9y@sLA8o;`|zuak|EX)RSDn_qTWe$NF^?FoWkj7Ji1MZ+vD0lelF~w{i%o?>*j(KMLQ7=b+IKNB7D(divACd^`hGUqd{ce z%wf>Y_4Q8~bEitMv6WvV-}es|ADzzgSMRT_4C7u%p1khsyZF24Ns`4wU3=b&=L<=P zUd6J9^nh$^vRVUN0S&I-B<7OvUYL1pxcWBOJSPFj$*=oV= zGo>^O{;yt76=}O`U)nPGlC&x6{WbC-og;qW5y*1Cf7io;dw&O+9oDTxkUpAHemOfd zS3gF;zPNL0FT*U>E?Mq>>H#ne%RacXFcigTa!}xpx$7xfU0Ht}qgm%bMWy$;^VQW@ zZP4aC5CE675U4Z%&M2gx2C~;;$2C_I39O(uPVI7MUl^#lsscgnvE-{A0?M?=&1Hu+ZYl(U?T?`C%Y^}L7;FY96Z`x2pF}(Z4)B2cq$WeJ<$4c& zudW{i)Lr@$ME=Jder2Ea-SI17{9Ntc%J|#?HKQq?Ky&Pw3JP4hwo&ojj>0I~LlEUE zdt&7Djb?j4>E2|=9Nf6eT3}@fCvXFg5HJ;BJX9xj{X#x_N0>y$%aQYkr}J~yx#UOj z&N`QB?DmY~o#*bxij=a{!KP3G z)%R5|XpfU-Xtgc%g9Y`x(%#6))bSs*C+DTP_t|X6Ob;W6Ck>8< zofK=hx(uCuTYp<`7S8*2{?hVvop*6*`FqMDYy9t&>YdN?uw)hUa0Wxj7CPO+6))9W z$L$OeW2ARma(F$MC=u}ZQcOv2=>N(%|9>sxY}p_xgRjJ&K=ka7u^*DD-r0Nu@fsd= z>OOdtTe!9fTV(b1DbT*Xk5QB$TwwnZe@_27Dty~!?I3-91;IXF!&bR6rEJpagr<$+ zJJbtU%n|-d2`PI3gKWC^+-ce72gL}%(OLseQsP)gO;^GJb&~8UyL~0@Fdydz+{!`Y z@eTosGnQWchld>)Wd+P$>zhPKxoaMG(P{Fp6p(K1;t@R4fL5SmG$M(p)OV$hvS4RC z)LJ*S7Y__^Y-q+fZ`wcd2*_!;p!bVjIN~9-BKM4r#+z{=5>+P59 zqyyD)|)^;R{>ebPvH$#kXYF{h( zFLCB3Vv6!OPWkneU&g^1#o0Z|W6!t*@Qp`+zhZqy^piIcGb44EJPDI^-Fxi7X$Mg7 zjHa#W^?IC{JS?2=`c;x^)2UKa_h>yg_wR1c4lr(S4VcI8SuxKZpvWgGTTUeJ(t5g$ z@86hCAax~#lEyn*o~rSdJ5}RsbgD)?lUAlCNB!3bSl5??U78GEzck5iA>J6qEmrTx zEkt(VS`{>>`?gDEDdWe&zRcRYyL*O6r-BzXyV{4FUs}DXlvOTscOl#-D5pkM6l;;o ztS2m%9AroB`_KAzVz3Q+E?7$9+y7|SNoVLm+b8`z2L9$WnlO8_!ZwzqTx-fA&Q%Wm z90tAUJAB17VR%g9b@KGc`OP?Ea#Xs)2Tnh2_GpReLVRk0#)G654T=;K=HRqtmOL+1 z*I$dCm_S)G3&He&e%bMSSg9J8s;l&OFN8{7L9wTf2Z~#OGwmx zT*QH1iOj#e=vmQ9yj(npZv+rL#qjGlF&Ty(Pq4}r`5HZiV6 zFX?cLQ(9Ge{iBfO;dH4_i08}zW)fV|Zwx}ECqDTl&Dne%nqB$T*HGM8F;n)X5G(*+ zS5o2VxSAgwy<{|S>ntto7gkm_={NE%b-2-<_LLI5Oi{ zC_j!amz-9Ahk|fe&T{@>z*tS>)qgV)YSezl9Lr>iJMq`qwjXeR-|YV3y)t&OeTVuh z+jw@L^SaL4CVsM~_LY}qZMN&k|BVFsiwhV(4G;qV9>igQ6Ay8&0dX-9%(M3|+JJM$ z8GNNqM&+JPQProa^(Z9)fXSw zWoqq6e$>{|nj4PsU`&KwfV)<#@e-3rWA2NQo4HV1d%>k9IgRp* z_RPidvmJR}8gEqw7#?9FZf)Kj9mvG?vtDQ!{0Tz#iXrIbU|m?tj{y(=w=AG7WJ#m2 zREjN;bsXVD508oGJ3amxK%K2BJhN^`eyM`CeKrB6?p!-oxp^(s>6&Qm+z-%TfsYy! zP9BkO^;_&(G*F7BNQ2NBO!FeC7qd2h{sKysHTU6&b)trQ3~S#a&t}?&6@TNx5OwchTG1JnI#<0giPd)j1eY zojKcyROjRfVc=Ge=`@x@FVYSP1PtbdAe}r`?kp%V*Al7DO&~%4U)}0p?zTfOkomay)yuU(tA~A{;%^VHkZnh8 z2duaCzy&0{tXOpMNbk;JMs=;nLAba__kun4f)sfX-2$8<|Q;1UvE2K5dJ8p5_h|`lN0xG^MW#D*9VfN%z0t3Vr=UX>U?y+pXr8l(#<{0f#+(%m8W-xhNJ zb00fASc|e~+U|p>&1wu$9AAbj*;X1~@!a~>xY7dDZ=J}vi9~Mi)8jH_Je~(D94jqe zlubU%a55Rx^Ap>@vvy3j!cQ5aox)cpV?f*)M4lPj3xTqhn=M{Hu4?-V7hy;hYb1I9 zA*+SpSM>QEY{f3@;lNs>{aDM1q=0*8li@?Q?t#6%WRJl(dXY1ThJRjGa z`yO<>XrG1r>1n2%A)4L!$jkX&56la1+6va+=i+GT=~>i-etFQiym`MJsZPc$0=k-U z5vbo*1m7VdD1@|4FhlJ%ox-j3jQ-PV!+8qSDqMcEDv&qdvZ6)%xNEzX6nf@sZKOqr zxGfgr`Z96Ytk~M z2Ex5%W?V}f!Af+uPj5!bfD=K66KYx@0-m!Pq7CpqD<;#Zqm8(}B#*biJI8MBv;V~o zzwEKhb@Xr{IWyJ>zQ5jw-|57As zAc;CFok~hi5NJ)K_gyzjNViA6tT)(s2uun!1kP^DKqk^I9%qt&%_~2;Oi6uU9wGGB zwqkeon{;!uyb(A+9%0kXl^1;M+^$p!O0bm~zHqVgoPLK7{ph)(d;`6=ez|!1Oq%S= zUY$e0)}-+`QbM>B4h5o=lpk#%%D;ss(BPz964~*y1VeB8&lTM65NChq%jkF5eclW> zjN^BMaBk@^Tyy1s@`}QUX?&FJof0Z*=V*t_Ps+-G9#0{W(2Qk6tenHEhY&Uut`!td z71g2>`uQSgo=jgfGIRfxkgEy;_`qClU=6lJhe5jDDj8C2DmD468vRbyyt||*{ov{~ zU4cd`UPj~Tuhf|TpgE`PxyMD)xT*u1^N&rnMO>gOh&4A_1)FJ|oI(FU-6Cn}Z;K!R zs@DD2f2djm^Ay<~^f)A$K!YS$-a8-hPnE!X#f%DY@vYPkZRAf zbhj`7(&ZnVGpJEN#yPvb`4614^Qr~S8lQRjfVx=A6Ii1RqHmE*0(lzy)&}3`G;nfA zsUvSoBNv;SwC(-(hCF6&MY~U5z=N+++F(L%`Q)vUkM{wbXdoFgW{xUwF~<7%s$p__jdFA%&@Oi1#Xyq8LtOd-$ImJ%QGbeOyK24T;8@#&Z%G%s4sFIgrgI>$b|6l0Hd*E30Pr?t5}xQ6{rb)vtak7dFb#XPwkYQ z0)?FRgW}Q$dk3GH9zLD8!Z+g<4 zN9BBI!@ayx%tKTY4n39IH{cbLl+5G^SaI`>wxP=h2i1B9LW=R5szM@fKua)ekz(!f5{%M(N3-9f^ zFO>D%;sPYaNCb(wfw=6$I&+d5aWfgP_r34BUUT1ddXdz1OLe^|W$VT37@bACW(_~P zEmP|yJ+n$JFE{$3m!+L=a6BHSM&2vq2ObgIL(Eg7$a1MZ+43o2zIr+R6pje$y&=Rs ztjClEIKvKy=5x6UbqDc7&>RH`$MsU@8(8rOv)GTn>TI2?AS?q@(;dD*b1o9zL9)X&2NHtAje?XU{0~ihGp?rG>UXKD(^m zpH(jV3E7~1k~ehMFeG)BE^kOZqyLP?!hErOjkfc{YRr|Gl`ldl^LdRnkw_KEUGgo9 zR4ngP6$e`MBY2=nqC!uLa4wDbe8qXkO6&Ymg|_4XIm^fTzNH@AKbJXSu`$1SJhE^g zrTOIX-!T#boY_;Ke(3oVhe-boXL`QecM7?ZI*P+nw2jX|r_qwjvy|umCS}pfsFy`+ zd>P~Vj_Mj>sRelFciA0IXe&sZ$qYpvo&3R*{B#oF_--ev5Pv7gjEfcU-D=tWpAxDc z?T1s&y^Ppdvf5I*P z#G9Ea$*dg_WGWBvQt?7j=8uxL-a53r1hsEH=J`#M%7tQJzr*sM-i`mglcFuyoHlwr zPVcJl#%{3jcaU4&fCoghwz2(h?7eqXlWqGgXaS|Eh!jOZk)lX|7mzMR6r>0!AS9GT zx)_QGp@k-bphy>xt^y)8AtCgD^d>|=5?TQ15ITes+C2Dv@9%uyIcH|hnwdXlt(moW z*76S)3*vL%*R}Vxuf0_k1EtRLGHj(+UaWRM2wG`wIypvVo(`SSgB|;NVE1^N@~RU( zLu`0j-*_I#d%ctJD6s=w!!ANM@WmpgA7>q9n~-eNnsd3xPP8#^7s0m~m~jA2-R(23 zr4kA&dMaPE^U8)#I1z`=ILp)SxmGu?!5bWq!?z+)N_)B$2?|~2VNFwDUw`&ywF0rm zC9hXrBd#j7;EhndgHy&(kG_u~U|^@!-Q4@hr`aRljOe8as>ChazZBO6Lv!YdYtApy zeC{idRuS4i`|d+OK&k8gWvHlxtxvz6ht&|hRqho{$QMT^D*~X<ujAs0rZ*%d#j;CGJK5D7e}6dP^6cv81XzB;ywU~a0lSrp z+Pjb^rh(L+IlplsKRV1bNPVYov93qtU7lJRqB1 zK=_vWr}=2oP%X%NK)!()DNPWpo#r9+*ovctzDo0h^HAtpoN>&PWrJ2}pOnP%wRdkb zI_6(Op1|Iyw;3(J@_I*jzqF=A)$1sHvaIHXi%csDKg#-fI4@qdIaDP2o!bKVPFLyc z%ocz#<_Bwud#ogDO$7PFi!*P|#4{Jtt7;WLyTK=;5Xz7%jnt0eBt3onu&Ix9r{W0m zrSy()SWW+~Rqm-!_If5#4J1xSvTKvB`fo5!4C1#`1iC2h?ua8~D7B#bAunIaDaYCgf+Z~!yN6ue5kXZZyTMei)w3!2 z`K=tBmWtRKc9^k9-DNPl;(z%1(#Y0`@=Q&N3!YrQT)_op%Xk$G*7~0hU$ei?#kbYj zaZ&QhkBLkXKjr8IBuU@?*I*S`K3Vamf0nCUIhK4*7$;;ss^b?OZ8&8B&py(#7``uL zqj5WOS1*%*eZX4<9Q|~UUg-7;KXw)r=A<7sBJa9=q6`0mI&!?iYXFE4Fb~!D_+aVH zS8DvfQ27!VC{*jYY}(U!4xgu;@u3Y0`7EIb6CSZh6Z0YyhLSfAx#_j@_D}3UmK0ar zVAsg*olg7fK2W#q+C8Mlz&0yi@KR-)<$=6;0)G5wK-ff|37EO-u+MsY+n%z>Xur4~ zC|9%boy+&JdpPa{-{y4YSCO~B=xz1zL(Iiqe)~^ksRW*tUKKm@uy{%M(v@D3Y*N@p z)6b6r?>rKf;|&g2BBg}dzhz>D_^P7sGJmG)@sa%FVpR;dSmh|-RU@y6Ws_O~OQ+)= zvUiU{cp1%nMN|2+93lAv7*BQ}Ea?|udn)|5jTO6g!x7YqS%b41ctsqy{aI-(UUiBY z^;^k$(|rdJBnbb3b^J(nZEc*Y7M4xv$$wc{r6x%;?xfB-gQ}-Hm(KFxE4A*6*L+oH z`25c%(<#KGAh;EYkSWIeDp0 zZ<^Ttz>LxY`Xh2=%e`L}e9k7A+z$qez;Kkl#Q zG%J;-=Zha%I|m5{g_zX;ZS9Qn0(_m*MIFu)m$kjlG@4*{DS1J6jSnOL`UyB92mL+F z%r43}2l3TS5rvlC|MyJa5*D3La>+SdTBbe-!=v5f2MK1uliUeMm`1qiluoh3i$6e+ zmi(r45Y)g1gcj)hvte+p;ejq||4h==vwKoUrXBzBtPqa^gF zzO9*St!WU?c~HGU4m~)=60a|YeaC*HaAeBfwW^)P0`y&DQw-L{al+-{7X1i_C)UfkD~ z4z#zSX0hp)RBrO?5EHDYf|4P?j>#x5?tbIa-($InGw2zI8yp^pHduZ6o=*p^?QmR< zt#@2n$g(A(k2W5&0g)wOzorgWjM;oHRD`+}BWtJ~LtNY*b|ixVhk*~@9}TPL8hs^a z8<|CA0Xz0$KNFwcyU+v#<-W;c)rBWKZ|j?*zu?fv2~{7Q$@FnM8C`S}{>)6AFO z6pE~wBuK5s`PN^)hZ_{Jc^BTFlg6COrYGIbrL)NQ^f(s^*aM8j$V~t$0*k=>;*C4( z_K(?d%lAf7lpEwj?`*VMj|7A&G*O1Cs>W7tWVkN9JxM=USUS*b9@>Y&V>`UhtZiTk zp~I+PW0BPi>biK1P4$6ifLv3Bcq?BZrNfi#ma$%=`Adb_yeq9_;pJa1!c2qD{7-j= zYj{bn$2p^@0twgd8uLvKJonh$|5Di=4Un?Er?0cDXQ004WzMQB{h6VE2h*c0v zkr~!MuO+Fyh*%2lHao&~T4~V4(m%MaH&Js|pfT@9Z^%b(pzqmm<}YfA9lxI#p|cd-o*E#GMB!7urmC*B_GmC@pCqP!|?@GN~iNt z3G5>XsKVUeQqaIL75V3wj)|-O4mhTvZ{Ae_m?dA7@QB@(|6YQUP`R?Qe0)!S4EBD{ zGVXuf$O^*efhyEKQBloZ$_me6SAPwVu~TTl?!R4(PvBrG=x5YU+~@ z5Fen4Nw>8aKBpAn(gF@X6&~JPYrhA(AUUg?a#frL^r-V`$R}4aFuR>@PV=tiAU5D6#hOQDW_DAhGuG--)#&7l@g0HiW$F zSkNNymjqk``>SsNyUjX(dsW>A3@@03-)mTEezx4$JiS3B-RUQMD#GM+9FhOF+7=ge zM+M1BJmblCU^zx>_C{=vEyN0vTkIZO!t(Yn>naIU;aM!+WM>KXe-rpp1-AQ#-vm;6 zKHPtP1&+sByvyd${2*zYcpvdW1uIyir=4N>C$jdeVBA;DFV8&hCP^`G!Uuny0Nu6d z0Pvki%zZFQ%4v05(m?=-tW^XeYiWPoXGF!k{dv70#XuMMOvm%+J^&gEl~OiaL&5jcKv4~y5UU$x5pIrdmjJfx%a>n;@;sJ$U$u2$W{A7ewmhbzjk^hfblm+lzzVh#?&MN5~gk$A9;_)4a77s z7thn$I|`Z3rR_xoMEu2NHRpY_8cX9r<;<$(J&|u-tdoRiu-h0SLV9r%50zLw5xKI6 zmirPO?!b2U<1aYH>j?%DZd6{rtW!V&Y8AWVVq}Y5%a#dGh_T=_Q{C3#01^uuns?J( zKBvjl4==N8qv>7p?y)lz8g%S6W@*9Lq(=@24J)a$B<~x3u^f=45GV&1L0& z%eu*hh`srkEloQ|z$E5N)-CN19p+C2wb>tVCoOO7*GTv;&bFlXrYw(lD~?DsUvYB= zzwtgx=}wvVXKL}>PIBjUsC;Gf3(Rz|!&h`t6Xl^W12N^1)CyZn1+BdCOPn-TSQ70- z8f_i`QS2gair7R;2Zz3;-RXWCvNG9bE?%)`G1`X@f0XCGv@V%1AX+N3vR4=0>npm>e&wi%CjHij{PDp*LbVZ zgMzCCU{s11StEx9h^3>;bY`5BCPOrN#31Eos^TwGy4CxwE^!(Z)Y8H2`O?ReehKd= zg;RP+4fG~;RZF0%<}MOmuRb5Kki$@1AqPh*Fcw@En)zxLj?&xWvc%i=OUI>E9^OfC zcBp%DG0Ti?sZwNex&wWp>&!4;EltTjV#?&aO?Zu(fyksqyN$i8%&bWD4{d@vanawD zBr8*fS;lW0Y{^ypsMVa)o$;gZV1RuIx)Y=#O%40j&nA5PaYA6yv=IG8zv zZvwU34%rg9DtXJ&*nCmoGY&qeY9mUKst2r$$AGPG27x|hg&a`5kV0X8`#604McnC4hGy)?LBWtb-q4W#SMv7UAoml1k$`yY57XA- zTxblD&G8N-4AHBSG4DLDws89~pYdbvOtpe9wLiZd+Yiy8|F-Zcu(EdSTWZCu=Slim z!mAS-ieLVDzKot>|1^5$V6QtMopAM5#iD^KtGS>>Pr;2ru{&1pA5JR^qUr z2wSl?n;+Hsx6=G)-8mJ^1halMG%CI!Ah|%I>B9~xqa9rjcK4xHsG~vrrau^48{)p1%0F6A)Rw_=- z3t^yI7Ng#O;(W%h(3P8&Z4Jj%Bwj;-fbldTB_1j~VioP1&$V%j98= z+zM2dN5gDlVNEA-Xh)mFDU;axGeTZA|5$Ay%Vi&mai<0vGAJX7;xJ1 zm8v?7$-(9q)yfYkd0@(CG>J0SB4l8(fS`5Ur9i9qbKhwjIJ!kXzNn_6!E+fxs54oUt zlFUVjv~Ky@AiYIcn$WGrTiVt}VNW)=xH&!@9in(1_D5S>SK_6(_MJZ++n0Y7=>Ndz zpJ$^AI8Rk|87efw`p&(O{+bXlG^~lqG5NJ`?vrOj^2KsyIb06n_|o>?-3p_QxvhEj z%1z0l{T}K-?cwI(d4_Bm@sVAvnzPJea)<6O2${0AA5p~zaa#u=Zi_or0UuykE~|X6 z*{{o!s4AMI3m-A{bQGEPa%J!-pK$_oNI(l0tJWQEYwUtUN;1v&S&qJF*4*`dVJ#W12fTRJLmEX? z!^#tF5P8X!&BB+2DqT;_k<~BB18~^K2w#5p`&ED`1z&Y zJ0UJ$T$N}ZH4gJ^9C9sYfZy$~f3$6X>{#G2t^0S4@=i<=oBi^SSv|*09Doz;-Dd@w zBg30WpDqV=X;DzB2~S^l^@zU3zP#hY(}H0Tk;4P)*be<#?*w;Y(b{(JIqSKXwWNK=7d$P|rfIjg`kB#s=Zd0c394%%FNY}6x~?GM?U|h9l$dpm zv53iM?-6}xD+D_V4h-_ip?cM$|5P--3zU2iHQ~lm6!kh^Y3clBuW1c5gBhp+Q_h_G z!LZVgcYJO^3Y;1rX$@zT8R6^V-jR+Kh&!8rnVjEBzfhpn-UH8*CHS@>ewuf#srQ46 zk|!=ruT5fOFIL&n4SeE{_--b}S2yj41AAD=K-~SKj9T*GDsN|sFAmA9+6VnIm9^E9 zS$>+7q5cv+aU1h|qLD(`m>2ks0~Ie-I&wmV4e_7|IYk2FDo4v(fx8J=0}fe-{ymC9 zVDq(D!aa{{>ncT(Xu=PJ{M1Cf`aC4L4O%3wOEb8m#zV1Q~$Haw4_b1B+e$O#uv7F$Kof7-Wmz{HVm&y;jXQ}PZlTT*Kx4N#Q zu-n}3ZZO-#8IH89e*RMQP|4+I6&#m-UrDgq;ccEHQlDuQQT`vQRQ?#bWWBoO{ogv7 z5%N>wrCnE}m8Q079WL$4-s7Wf2d}pd#UI?-C)FH|+DDCUGLex%bdN1ncF(D7*QOy| z_9RgiYDJBd1$fjbjVY~#s)+Kt8Es;C$9=+6vNhe7xww^?peVjOv~{3AgKJU)jd+SD zt2wAVj$}x~(o5Q3s3t}K7!dWg!09m+D_ci;3Yx!mDrFhs0=;?TM*@|oUDOp6Zz$Ej zbAuSpK7fTY-SxEj(XW<2G!tXBBmD-{pvGh?aXVQ6zL^#@IutaM#d!a52HqR6wOTyM zslM*q_z3VPTx1n?dS&FZKb%E+`GDrff*(gmgLY@5l_*pI+KZw%g+jd;kd z^*PV;w4^L!XIGHE18Xi%c1H$5@z^G~2V7dOWHm;8jlH+8~3M5`&<7BAd#U+nx%IPVeHb=~n}brDlB zMwf=lIL~@dF`7ABie3aIu?O$Ln}3{C8*NiHnzHG_M5eg6rh6Gf(&e`57Z$G`#@ZNY zev=)gs(MVB)FdcT$t29VfX&ki0fc+{v2@Hk7$;suZ|K1tPOH&(BcathMJ_*;WQTpu z`E@n4+20SKHOXpTYx8Ob`4ihUH2wx)I72oE5AZk-e zE?g1ONiYt5$Y!N@gSoof|7im3w9a~ZJl-$1-z=sCeTx|#$3m--mr0t(>Lx%N8mSV6 zwQ4@ug8ksp76Xy+3W2XeKG?}V5;`YVrS~c097l`b9H^#LJHa3)(F=yU=h$TLkR;HO zFfF82HXP%h@tV$#k#h~+AD(RzR_Megd24dW(h%5waTaZeeBl0mVURqz#e$5%>jZfH)UTJSOKd+Q_A zr;Nruc9TK^07~l@<|9WE9SY-Lu5%J>3Mo%+)gwVFZRI&Hy(&t4M5z?{tx$kk zQ-hzrhV@vy-uTrmB#SuRc3qVClrPJQiU@0@i2#+>0~YCYHoTDM+GiYd(Sm~CkLPf` zthk?p(#M0175CSrPBEheCp+?ZUVSjl*^29M&Otl@ea8%&ZulgLV>K>py=ZZ*k!AFAttbBy{?_kmB}ZEY}nVJ+X~6Py(yTk4@@JS9l7^X{teUZ_H?>{ip3 za+cHz+*D3p;@T=Ga9KMsADjq13^TS`t(d&>eUvZ*^_Sfp!;adYzCmy+>KjjW7%Tj4 ze5?+qml*uxmH~)v*oWMKy`dFemjxS7R|z_Tvqh_*F3sq5jC;1b(@BZqF zOSs6LHRj3Hnu_q*wMoJu()+FFq+=>Bn#QV;crh-qQ-twyOEnE8$K(^9v3C_M>3Se9 z{{V8Rl1I=I5%FjqkJKIvS(P~ru-ac&-8BufqlfS zU$@-*W{SiPs!lj|$$fgeqo}DKUM>5B>{n=QB$3NP#pw)uICzueek_fBK?eKi@fcJ`yb zQ2L%5wK*{`JTC|@qZ^}T{}Hp&o)$&!f)L5-{hG12pXWU4qtpk@J{-8&5~GuO(V;|k z&;F~=Yjc=LFv(`H^}6FL{-m?-K@}d0cZ)c}i|z>!A+F}>)hnibwu~|ha!`pG<9cTs zL(hsQ1}n(gU>&rw2pODox<1>axmHN&$J05S1NY|=O|zFcBoj<8?JR~xqy=dC#GBY2 zgol{$nfn*ETMgilG(y}6$o9O-_q7*SDvEmKaT`KpL6{05B ztB;w9Xx)s+0aaC|(VJA6yheupxv+2Kz6s7R^84~{7xsVu3a-s`Mwr}DtA0Tce>)1b!V@f$j@tTfLli;IXC26zy+IlM<;%H5!t49u-{yNybf9XzmsI%p zHZ!WX#4!PT&4=mhE1jbv305l+7+ORLLQ0_IXIm7kbZ?Gen(UqoT zPls&jhvukWWNU*3|E4x(#f(g!I*On(tFlf5{)2YM{xs`jKYid`&YdIIqMR-dl-{DR zMYdYO-yJXNJyCECaN8SVp)-{)J>TXr%ITcqshPjHt2g3MA+u~Zkgz1HgFfAKy}%t3 znEjsW2er!{l>u!*2qJ^A8a($caAAz7eWW5f}eBGd@suhodX-w~YNrnM@|duzK-LamSE!G*N9e?SLm` z4@2eM<>bAAs>z;PjYJL_HfDIRZ^KbH&Q~Hf^cKE(l#NT_^y0MG zGFTxH#uYf9x;o~(FrtH;Y{TC3INUcN+hpDIKSR3?{^AyGHjy@ZPl0HfJ;RLFS&F5& z?@qdbL|tRYl9z&dxqkRD(?79*nw0(N%%<=pCo95%plD>1T858kgw|52xm$e^BsG{K5$3 zYRADQKH$=x)co5aD5CL^X8fW-mO<@Syw?)6kUiD^i00u~IQtf(eTs*-HPv=|OH)?M zz`o4pw^TyNP41#P8p;00YEdv8O-`_+jO!Higa_YzF5aM4UczM=-lc3Sz2XuwxXAi9wRJKe0BEZpZ;t2(q(p2*^~xiUm#S9AGd?asGR-!y6;zkj zY34fAOOyD1hV(mp0m#hT8o=B6nEgM#cGgt|z_6RUDTIUCg_+ue3$Ls>tPbxy;wuGM z5@?u`Ca6;H#&PlLmrR&FZV%5(3A8ys{$`nnVc#Ob6zW)IOKxjL0?A>gZyci|*0HoZ zA2$$JR-2%NXrVL$QhFCoqcg0J7MCVq8QP;FfwRe;=BRG1JZbOi3r&&~Wo8Vnl5dvi znh6c7D*a_v#n0Ew%h_Vf!&W$YTr~T-8P$T#0N^-^(&)`XN_L@2psdH$#bG29vy}+Qw~~gE z@GLqy`5vRPEkcydIUya?aEx>4FXp*EAPU$mBM~C#P>ge(Ay{qMwPm=XgE!ty#JY3B z%mj6E8w5vkzWmcgYZxY2%4$_kt-B?EhXjeT0dZd|gJ17yg!m4*a57_@^(TQVTlWI$ z4083(+>=WH37wp|eZ4>+*zgJgf!RoI@BWxg?Ecs^fHs+&RPwUK>5M~)p|Y!$5qg(E zbu>l|GFuP45*w{xj(Ks}LqwLC8IEp}1Z9B(7>Qj+-Eyws8j_QgjS6Kh3i2dFJR;Ii z$h2S+O27VjD>q=t9Ki;beb}X2#tBPX60i5^K*NO;5r^I&a!*kHUz%kC!w8es&P3p7 zTrR)oPepcdcc8Afa{7g28Mjt_XzLnx{Aps7)MrCp%5n4St8!M}Vrn$mt$eD?WnrIC zIMI)|3d}kT5ajb_Lu2jv4HLCF5U0)XD_3-?QHdDUa4@h7%)Q00;7>phoM()aEZ{sai!hvGoUTF3AUiqe3h%FY?` zZk>HVE3f|=GOm`ZV+YY%T<)p9H#VRE^9@fcEdG&ySwxa1gC)jtc?H?yu*L zfDn8h$w%FE#1ZrVpC{z;bOzVbj_tMjAkqO%f1vCj;!swV4x(9dfc>ze`lqV_H3CFd zNmr06n^U_m(iV}ty)26No>qGXNiPX`T({gAI6Uw`oGl|Xn_&F^)ARo~>3I#IG|{H| z{Mv0(*`rD^r&(AtYkC@HoTgmFa{2c&z(+pIN1>sKM4PLH`qTWGuY8{7@f%`hB78rRr2fu_H`ZAU#_eJQkX^-x-p|HDGy^|wh z{<}GG6ZV?Qew0ku&k9eK9Vd_ZJ)QTH?W({|Vh22PfqL%X<=T4{yBnPpS7jWlNC!%8 z+Pt+ZeNRbF&egfJ9AlRT`=JMYp|$y&aPaYnP7(5JLt1EYf4-dtDtQMs-3XeGB%OVL zUK>2cpZS&1#C*3Q;NV1I12BpzdGxPQ)CrHs44v(nc)~fF)ZYa9!Ihh;06zjyA@2@Y z{`uz-E2187%8oh97#2*=3iUfroSDB7aCdgJ;a|);By1|%__Nc@9b@eOz^RvRcc33@ zzDtv4_3ThU(~W%9GAPV?v|*LhXX==y;B|O5`q|JKlOE9r36|uu@`>~o5FgRylarsr z@KqIKmzQ%J{i@m;6+|Kzam2h4MSF-%}0%)~p1FroiKi z8&4n0E)>n0Z8))c?(~D|O|_WOwiQtbV=Yd{hNW!aCY&*YU7d;k&iuA-NsyGeP2yPv zdgpr6pP0O36*$+?TY(>w7s^$LQ<+6E;M3(W5PNmHg z1--8+{4}ZPy?4W#3O0sUbbvv0?zG)x^?5ITd2VY9+&$+u$^6=1&$m8bST5@pek}-A zse9JiNV}zjpL)A%jn@n-ETo-EuK@I1oE>P;lG?4Q56&5(UJAP`oc7rh{iZC~3L1Wf4jv0shxtw^*h`@8#`>O@Nywk}dQ)*{r$C^&=n{}@tI5AYe5i@` zhQ-?wpO{)B6l)*kafPESE)c9GN}a|Dbh$X^b_2(eI!TpqU|!&m^{e?Ui8R-zRS$<) zkr4x+YJ;yn5N=nG^b6x6kr%XrKw8*d&BOgH&xh#pNx_WJ1??6OAO#telx-|8o)-LM zOW~#En$#l?B`R$X(M8SHg1x%|2us$5-qO2`s@?4sBNXq(G)-0Cqq$EK+h&SLx;+%W zisXWUt#bqf@#%8qp;;UOSHHyr$s%kQ=G3}m4U9fKz~7k`G^uosncebRAKDm1-f)1` zF5GhA^Qg0UqXe&0h}_8Tqb|gIDtC??Y?}-;dx@8)FK9p!k;rAxnnF10uI$zm4{vC< z);lFD<|^H)R|$}q^Iew03CotL&K6O2;S_))(fx-b8RPo2A|BYVUc+i`sH}1&wL04W zZ1jQj)cVN-`M`{m`%SF`SwukHV|lmkJtYcJnat-bssfoth4ssS*2y(D{Q z>-7H2PU>eh$5Ay9rxl?~@A9kL5041+@X!B6p!e=SUE88l`xXd`4s++X+Dy5gM8i9c z%OktlwRhjXyg!>NdeqzE-`I3Hw^8yC24U1oZvUFf_SZa>xV=zi_nD*q*)8u&rGDe` z#n7zQbPGd?-npTB?H%o?Pv>^LioTib z$^WKO2kjiH>A;-TLzxl6Cn)wT8*1|V4gzCf1?Hrs;*ROmu>j6FGXZ#IINHBm(vi&m zPOqatz`1>u<>KG03ZYAt8wYgzHG=Phf^7yKgf2i(0Lf^5>z_Vdlj#XwF zopm7<__ll{m2%EDM{F+$(RtziX`K}Q7&kJ5F?8suz^~f}$rN|5^)>`BmgWAxG?sPT zA+I0aj}uh2{WZla?Yz5a3|CLSpYrkn^LJ^A>qiO{cUffvn9mu-}w(@X-Ah1yROARyVVmvnt^VJ&OWg4NOG}d-Yxvfc~3a zNMrXE#GeP$i8S8a)Ut9>@2SrNeb4yrL$}w1;MQ- zOQ6sIV^FQuZ_zHHx{zRM6^=!bw_5@O_lz5rT2OCagO^=d1y&OHQpdfD;<8CbkFS0c0pyGZHbPpakJ(g4s6k_6Njt(2IQXz*OQbxyFmqG=A! zxV=u1xk7LBqku6qX4@P(WBUYoi8D5r%)H7@cEehrO}PP2X!$rwr8e#?J7cQ4&j`2evw+_9_y<t{@Oz=x?hoLkOh zU*2yS11y3jR`=YF=1noTnI(+7etS8&4aGq70Zj+@A#`a1Z@se%^QWG%tK%5YOIaJJ zJs7*tO>wpo_7X(SMO^KLi{Zxy$7r@DoXo{Q{Q2nVlP#IEZsyX_Zu9Xw#VxuAI??M92P)P4h0f!jrcu-?PPC&-SUm*%SlE7A#9y5m( zN45lT@Mz2tOg~}Ii~`%wGJV@+j%-f~_m_PXc+|z!^56RBp^;UO)5uRt8-x2Z4r%(Y z7EDhc4zw@)!Q+2^FTKl0l>QOQNI0VO5r3nM#-G>dYpYbJ3wnocbQBb>H}dMB-u`I% z84Cy{qW$7P0h}#oE=bN>_Oh49u?#ZuM|Z_eSTK^FR*5!n%S+D!-Pu_kRNJIF=>+go zMjQv7tin-g>w&f%4%t7e?>XWKT8UGnJUY-D4Y!Pt9x71Y+9~vGJn3I0Uhu=2k zVpFs4lrx8?2?@MDb+HbI8JgjX6k@~78Q%)YQwBvV?NR4s9xI0ALAf~Z+qY}TN1Ag3lUDq16{)_O1)|HV^)k%8tU=pwV4HKm!DsYi_^8Du$Olke;(V z1yssjhHUl0*nG0#_RD$qXj`y+f*?3RI6=-75GMUzgFoTyC?fX;q;J>>hoik^>*0Jg zl}a!ZLA&aeK~6T46t|_2M7!m^Nz%aSjD5bU@#M=G7vPVIW#el+g~@GlQ}GI2LT=}B zVbWeV?Cq@$YIro^WMhXJ7&+iyn<$ZXu$mhyBgY?ZJF7p>{iL<<66dDaF+X z(^a$nps^8Ok}SqdsGo7}5~;4!Q%Uto+TK3!dE9A$zKO5F6vyKaeiN{=LIsYF(=)4X z`~hTAi{b!rNy7AUGvQcaZ+ZiBk$*cTe+cpdhml%TAB?~~V20nRAD zSfdne><{g=f-5bwpXlnLG;)8Sj$Fw|GS&<$dn5uXE!3`C=Ed|UTLn^p>^ESL^j+rUE8|F~`xtUr$j~%T z&-2}yI8W1}&MHW0_HHNJ!rfv95ev?z!q$EO5hbkk38xs4fKGAz>2O?Tprs;J=Y)$E zfqq1Yr$^*%@f*z?+b5r&904HHstIbb7{T$U)_WcR3B;41(l)#36hrpz=#+T_m<0ko zjbWg8x?C!Cie-ljPX2Ni=Y*1LI5spYI@KJ#XDw&Prh>MsxV#MS4zwmiwMJm4 z;>@!j^^TO`SX&}^JRQ8OxVm>lPSpeC)IA}hNkr?=O-R-m#q^;9#c*^?BNM36LNhzn z-#*uaQy7SwO#@sGGZ|fpt*97*ZujR73FfayBu z&or*jZ~Ud@C91_tbrK$!KV!=y>A_cqgs*ViC91A1M3Vz*jbty) zMZ;d#FrZi3tQKg^#L2^HrPRH3@B3+}b);sCDMc{RLMuOKIe@0fro0L=3sVh(2)&3>uT9Mavc`#I|x z-{QsO{OX4784G7Itjv7GS}_!IyuxHQy@T(#8EIAC^&k>?^|Sxx7m>2atthN2Z33o8 z>u#fFc2UvctZn(kW{za&MfyBDRRzcu`l^qNS$4UXmbjT1{z zJl!-d5^Nz~>NT0mH9Hc1L&artN2JJBo(71DI$#0ekoWkp*bc21JZJy+h8K5fC2SNl z0GCAnflGf5T)^kY$8Ra#{In!4cgtSvPgQaQXopBZ4*U1>M+fWqdSJW*J%+1|WG*&Q z*Blt|?4g%G_%=?$tfuJIXt7r$Tr|n7?Mr(Do;7=HBLz%*|BqJ4ls}|Mi4M>`?U5Wm(u40-#shL8zbsaYlwD@3 zxsa|EpwFEM;LJyzg? zw6(a2iB{=Cg4&a)e}hf1V5fI6kKGLL0*QPnrCPtg$#b1Ps>q;MfNq#f^))DIWvRRx7)YFg;=kpY@l@=ACw1X^uxDQ(P>E7CthWLMQJX3Pgv%*pafzG=&U+fu9^YTqSVSkyb1k7t2z zi9@Lz2b9BEsSVyuDr%%&B`49`KLVJDzXWwzc=S;pTbB6!*Q6xJVUJ22SecGHVh*JL zISON3+^Ze$Y2l6;E*mqd$G3N*GETk$WvcaPJK&c%Pzvu=zs0u;iZ zb78BtutQQwt?lBpre@5nhyFoK_KM8K_k;i;m)_k3Si_Q7gMkX(rLq4FC+4 zBuXVNX@(VtHn~DV?^8Tgb#hyc{St$JS*_yW_I1Q%IS<8=QFn$yjUR$_vVHsPk;^Mm zb9Z{&Pquw42CmC{=BJ~fi|$?65(S%B)**Sw*qL3iwnFz|v*fLB3;TkiwZ_vPcH#{^ zJ$dA<&f>O8zsj~A5znk4J}Tfx%xSqeJY#FDW$NfOyo2q>L)PrCfcWs^7p~jKA4i+|oe!uP<-gue%u_zk%|f;xNMv6zJ!2(}EuVOjN8AI^W+OK(EVQGN&Ik6Cyo zSXj?cpn&K|Vbd_U-#k?soJL1H6X(`j*kqEk8;%~4Tu5%yBM0#6l$DI#V(AfO^dxh< zT(<=upE1=Rtmu0F5)U@N6W?l&{UNY%e<)o4I9~X#oJLC z60GZPY7*Z>y6$pYZGppNloc<=czkVTm>J{y8PRPPhU)@G&==DES9N3WMimvECe@uB z^+`y7eRx=tibV|pyCBNFhNJs@%rPd=8FL-0$v*aNcxf>%&a#hKdumA{9}xQOsJ=K8 z;P+yYh*FuPXpG3y}$$BZJlC?YuIu769nJs?6TBs68q%|3sXW9 zQuLAoX?nb)sQqxTx5AARyUTkZlcwlDe*#-RD0?sum%3K{^+@;spSu}kL)8|Ah0DMR z75wLf{-^lq%gyx&@rDzyV2(Y~3A5NI)2BJCb~&wzS&<&-aRs_p0)rn6H{0iLua27e ztp5o4iMazzu!BZZj#pr#paRP0bTSIp8liv>U57c#qC^OKS#kg89LT*?TQfKGiSTwB zBk(#JXl6=Hc$w5o_uF-#H8jrnM0q=gW|OYbLxOou#VU!~boVW%tpU&plFjPDk3N;w zyR-!0{o_F08J1NO&K}!>G_1jyV()eYvM9LishiY#0?H>eo!R%atVi_>Um_$7p3_Im z?9O)ImV7oOU|Dh-LM+Jevgi-_(&5+XbGuAd%SUvjs&;$u%D*8ewdb8t9X_`r9wxUJ zo)6FN2AWc*_j}I+_Z&PSUL5nAG9BpsnN|Uvbl)0(cCbtFe zKhdPRzX?ZCdjs$r{)U`SnO8X~QavFixF~DCB8d|Y?-iJfUxA;0!?H53ID+z(S7xOK zo*_x88GF(&XlKofCh39QjDz$ni2POk?>Z>(2!83S;w(M`xnn_-8YE>NDWt5rrAK-J z&7Iw{|Jr3*LA2dI*-R#Gzv?^~eYz^!r`UTqvlBi(S!9nzwam9Pri)L&@n0Fk%Ec_4xO<;sC&Pov6PEypR^_ zA^q?lfE1N%)ds?~?+60gj>;tg#=-~Kb+H4h&Ajt}72l^#uA`2Q%g{o@5lyiOFv`)^ zy(n1wlFyjgz&-st8Ar7DM(UP7&CHa+qAoc<#>QGJ8a>Zk&`C3KsYp@Y1rSQhOf|(` zpVrfE>H&zwyZx4Z_b3a1vL$tYtT5wIC3rT^08aZ&7l8B-cT`%9i)LUjV=UUU}{Qug4pFM&%s`o3;OYG{# zJlr3VSxv=1W6IvYFMr*=i?cOVcLjP+(!OjG(WfBX_z#vDkaUEhFLG~MV1F`M-j1(k z8B$(N1&bTl5Q>E9$T1Su%!SSw4*)ydix{Ar`2Z~A#TE>B0zy#c^f)0_b-&H&zvn*ZzR&M>pWmm$A9IeR@p?bs zkLP21OWRf(bPrO;!Gkpb>?Tc#_ zb($3F_jmS?Rsqkq@txh3M=JXEFcwPTZA5Rf4|H+5 z#)HH01JqsRsB(uxzLlo>O)Sl`ouV%5+;FARlBD_Z?33?RJ?^DCM&|3>JTt*o_~4-z zzH)!!Q%lf%L(S=ahkl928QhCMEdc5HGjvDx1C1<4dL*MT=f81or9%;QpP%*XPh=ih zcls9W7XIr-0K6!Rsn;F+B~~**mioMdw_T|B`9|ciudM7!vQ|#DTe3@tL+L%>Dog{L(Me#2q>G)W?+=oy?`O|V zLG`|b9IeuNfe+NBzC&f?M)uHZjP<>S9{1WzU3_KYU(%iUf$vN;YU&%|<#Cp&s~onf zOKbACw)RA9uT;`Q2Ic)0pUNZ8K`~=jorjxG_K8L76N)A6TP99CXz+T3w3;L=2F4@Y{C&i#AAz}6Uc zDF)^Zv5(%fyC1oBXwj7;>OqzeoCcS&928O@!A1K zm#cG8_AsnO9U*}`A7|(~D*Tu_igLEDN^_A)@2>}oN2fn~31nHT_Y4}|S%`mth$U<< zHjp8wIk1TiAbVF0z7Qu<(Tz$)*u`JC_$ODohcspMY7e)#?nD3PcO_1>JIgtaQiWAP z6_;bxZFd#|*QKLa8fX1W1=G_B_1M})^Qk)<8ftgcE44gu?PkM=aha@1#TGR86q z*iqr5GZz-@qd?a8+jGG_)q3?4D9FrN3ZB|Ay`BCCLJ~ghW_E5#t`yH>lfO1v{eGe~ zdx1ZRenWVPM-SA_$G{|IQ*E5NpN8Zm`AY>YSFhw0sp1Z53$m|l_1Q+;vJ;0Qow#ZJ z%vI_Wjq9AHV`%SaBK^xHt#c&@wj;p>7`p8LTSJ$quq)N^XG{X5(fG^5Ms~h{pLN5R zQ1)*SF5@YVI~6!(b?TeUDk#QuK)s-4!5WAfc+i|1NOC zA{HzUCn>eJ{isKHXn*EQwk(VW!r=8Y#1M|=lHP13QkV4IYc$ua@iKmOTrn`3ZfI)) zma??y z-d&rgP#^W4AMfJx>_qZgi&Q5T<$f`%_)UHDKH$HVI+jj)SX|OP+?YM@pC}0Y;TC`U z!}%DR;Y!2VHoAIWsdu;VxTU{oB%lZW2ZPtT*mKDz6GD(I7|Ue z10wUsThNiVb^)a+ZF2t}r7(L2U;mfD@qenok@_`5p*bpZXzTi0hepI76!9l#VIt0H z16aCkpor^n`m?}+)txLPm$mmIKG$bzqFcvj&|U1LBIpALQilXnmWoPY=8VEgZKubZ;3-h_rH2AqfE5r=g*{V~o zZ>uH<#R}z7ja?;q5@6)t65!evpB-x(CW4{K*fKS&boqHgYL%nuEJt+(SF@=x$U z+~xfLQKa~oCRn_2E?e*C%%6;qqKO)aBio_3G6QXoR;}*ZufJ`r>4AKVxt`sloCG;0 z_A)knT7G7r-d^_XI*$*MXGrdMOWCb;EgF} zOSy#9hKPQm$-lOgdiedM;l(r;X^=@v(I<7QaQ}|PQ7H!>_h=GVc$FH5_nXTE%JfP_ zK#c3+u~b_gJA>?gm#1$2wcp?<67nX39A$S`IL|@zzHQ$nUKAVB{@$(Y1aNR^{_rSY z(K_7(Q@N+m6!l)E@Wj^TiaOCYw=l075vc!U;(di?zO(eaXkLU#DsqeFq8}>dW(NWU zO3n`NFWJkVS!$QQ1Jrcq9~4tR*=y`4R^f>eibo@Tk9sbC?AZTqzqgu{{UOiFu(u-4 zToBwd_#W1rb=e2Rv7K#FE-;Gbr?U?{Iofo!br&U%-y1yac;Kkb z$lXAg)L5d;2W0ge6t?*;$#)ZvNvFX1KHVuf6#XU6No?6;mCTEW<+Y> zP$@Lx(G<^Vp4el=j~D5JVfS6~NU2zpQ=5)8-3SS)R*{McFxwmkkRGVFC{&AU9MszT zDw_+v_j8#49axU}p`W|uXVXQ+gDvAf?&tcA{j7xHWt68M{rwrkb9;nqJeQPf$*TDo zQD(YJL9*_OI%{sT3m2%Z-EF)+->~n7xXd4xmr53bZ?i@$zc_Yq-|&@yGr-!ur6>Y7 zv0}O6y!j6s<8P(C|4LE(ADE(mr-)w1toNse z?Tx^Yj?&-Ebc=q_!Pl)8?sKrC>7ahJ_JJsooE{t5yP(m{Jsh&QBb4&Y&Z%t5Xj-f$ z)%6DKBoE#k;9{8mlr)z&nz0>yA$seIVW|u6VQbtix+kaJ9KtI$X?5VwKRxld&YV3+2mFICT=ET#+s)V1TP7-Yu&Wn?+UPZ%->l2BhLZF zyIw2~U89sz+t-{mJOB zIy@3M@i9N6!p6Gb@y1{m#zX00>ZqZ^LE*K*-S0BP=8w$;*{*n}oLrm*5&1FKTTf*l zh7EC|O+9i5R{BOdCBU6=<<)$Dvi_k=v9{X%xHk}oD0LOD4N%f34{aXMkb zQz{lz1|{!MaG=OVmWkg}|C$jHD06ofx{W-foXvhUK<{AcHxGP`oI?3nwMp^Il0YW-0q>` z?YwSH1JIYYimsg)U&8IOOL}G*szU-I_vdsI;*L4MWy%VVp4mWOg^_eTw zt;FI{s*b8Jm+gxe;r-4FR2@T0vwv{0OPGpH4v+BfM(a9AMPU+L zWZb^X%+mv4#j0#G%}NXV)_0_0NpIaL;D>bDC|jBisU~4rb&3sQoS1*v3J{CaHYpbqb9GkNjVxbKPzxAhmwb^Sj%z-{tKNx?!(C-qH zxf+Ic!L8->YxeVp|En^iJBgl;8r%C%{+PpR7^k{u{$oJs|JfM^eMYKa;RG_&4gT^r zuf=14hO-U|)=Tr!TzAgYrw^uH7lv#%EGKOLjIi60`kJX`Sd=0ZF+H?- zKfHF>h%^c$f2I_>;!k;F{Ak$Sb3)~T8ls^iIw`MwG}3K7!2gA2-y0I$!cYm=?w&?> z1hPNKj7|uFE{EFdoGc4vTFt`K)vVbHP^Lb2Cw4shlGB{ywmv zCQI9HgmSqf68rB+mG{?w2^QKQ9UqL3ynl|UVsNmaSe?)9ggg#?BM|;QIFnpt@*E{eeLt~52iAy(1 ziUZy$+PN>J*-z^Fe_n@xobzvg<(%CLtIzpHl!)#sud*6?t*UXK*Q=M>%JU?F+`OQf zNA{b~CH@i)#$_{mez}s;{mYW2Z5o#{uA7*rI$MCamND+Zm{+QD(_6n@3F_&zli4XP z4E5vhwo7{cHi+J}3o>0?(Md9bl_1zlmlt@-`sW~pw?vb*^rKZY6T29)w(Xb)NIsv9 zOYT2Av!Ye6#d9Z>)GA>jZQ3oBjgM0(Nd3@O-(!Xe(w+f_IAAG^-z8Fh*Xv|<^tYd> ziz8%H=(orTTJ4b&zA32=;dA;?;cXLvg?vdeQ|Z;5=(=2Ur-{@2Z*WSiP^drNkSnlA_16y=DwtGh)R>D zLhG!~lkQUP$BZV_<+8J>J^z?q<=K*WM%t@==Qsb4k&Sqe+r#pedz|_iw+?`UXRH0p zS^qmz&&Olx&;E<4|6j(`cl=`NB_@A=HI5!Dt{%uV&a?_;ulnr3H-77quMh>cp4-#R zBO7wH%eWmX@5<^n$$vt{g78I{R0!x9x=*iP8T6>!9dgrGQ$E#YBxo*k$-~wo(lKPg z#%C8Ux+XbnxgXP+Ju1m_y?UfySx5nF4sOTcjzQ4EHOO8+UivBP?DUJ>e)3fQsrxf+@a5dZ6;h&73q z6W^A%>up^zo&v_9pN61+Dvf-xVahXpcg8;#?@4d7`zSp4Ae&M>=rj*Pw$j z@Lvr6zZm@gj=>KT<}XvI7O8ihorTHy?hTYE2sv*h8jnNV1YJV8ZvAmH@j>qDFwDpV z&nX0=Oc|wS@tnG6HXz|qqq7`HalhTb=WwT#`q8_xJ}ncyCbkrxowqSL(+?B7<5Z&j zETi4HGz=`_Vm41C9|4eGy~SbkZ*(oE{VK(m%LQ+9QOeY;6C&10?|*rS_X4K@+%`%; z>Yx4F|5P<_O~@^rWBkZ!>=%S*Dz|9eza?(kCtplx*-+>zRu$Fnn9Y-p+AjN@1#00k z2X*b-;h85FZG%m2Dn+H{eW;3_z}#FWoJBlOxV9IQ|2p&L2h)T1{8J^Sjb3eM!o_Is z3eQg`v;}~_AfrqI6{Fi`-MbM796~2E0D?D_j~tV9Ee~ zE7bY1Z&V{wQGxp)i6`Rv!55Lrj{Y>`eCYyaoJoO=^uocTbUGg>xPVmbbog{Rf9r8$ zoKr{wtn~#~@G;6-BJHGdoi)ntzJPYTO=nF?nRs8169QP$2bBTuLN(H`9=ms&lQM%h z-1`&1Up%GQ1)iXm)s$vY@Iaxty&O;r#^o(MSI*8fXmN#Yz+~2sxjYSHyN5tWcIKby zom?i#m48{#J8;<%{!|&W=fTd(@M@~4aakPFC6T`n{7X-+`h(WdR}c^i-u5UL9-Nj8 z)VrWz^$Eqqhh2C6qu2kBFXm~tAEVB$EhRZ;wW@eO|Hq`}e{yrhg4?&!&)YYT<_^c6 zv+kQ4r4xgKhb~uz$9Qv>?icAQW^7{!ooJr0Fgr*z;G;1xpFhwHGH+ZVp$I_|Gl(Loy4_P-KnDUps*QJa)?!RLktn>}SNpLoNqW*`L zVliLoiuLA}o_+%AUbzX=ZiPJ8eBuoKy*mE5)HnTh z^<((71m~o%lr%docAx))LG@)q;Crmqqf+^KkK;8}o;wok9e|ZZ`(&0)K{PL&tbfai zcz`}JYEul_IKL^7rm&x~vs+OLuRk+PhhOfj*q-PSJ@I1|OL~7fdhO8sg|a``N2}jz zH%9xHR2fl4ucr-2(>aUp*|zqdPrOlQZ7Wdb6?Ybq>Tn3XkdH|Y55g;7{PNbHQBNJy znV2%58^83;jAZ;K7_g3n@QcvqNm4J&6BzX1=x0{N)jIc8-tg z=W-kQor^m)K#`W9>r;(=Jknt1x4LibvcQAMV7b|j7c9jcWS*9Kq2*dif51IlaExv5 zyx01=8+h0yMDXf5(v4owwZwOnWPjhM)YQE6{Q(e3<@q^Edj-x{$BQrM4^&|X`z3ds zSu8uN#7(%hzUP*#a9`1MVuC$zolPn*RwUaHZL63)IlYf+PJGWNWjD=8hRbALLL-if zsi)qL2!F)g)K#2G3HtZKN|zz&nw0nuIt?S!q{orePO6 z3d%0?oPoN2tRxdmMmf-G7^fW}QmbM7ydPbB(JVAoM4-P3OQD7_Xv)@182mt4e{+D$kr{{kve%R@uVycT&>xyw%fXHXr zEW^yiA;K%AFdfI+!gX9q1#p6}xF_oD+rnc6F+2-L(rc27AA(f%ecV4{AI%}kNwu$V zm3o7d3YLiTBpJj21t5BbM{fC2#w-nLxk@|*{StJAUH<|mN znsWX!R!Clcq$3H^`@bdLvf$yn?iLW4A^*a6icf%NZTlCBZt%13=M=(-?FT}Z_j@ZK z&s4SvH8o7n8x#;e^vOwK4_jkD1ij_H=DvV~ zmlrD&cn<3Jt*MG0Df*1K*N6N~Yis$-3V&u*hjwCO~%oJTqIodA5M%HVn!QnF`H|*~gOAzpND|yt+yCAF6<-!{LRSij%gG;y_o(*)C3o6etf5BRd5wgwP+B80x=F z0@a!XFws7#CR>2m%J7((6zo-jL-YJ zF?RlJne6QrQ)-}@Z7g)Hn%Lb11(RR=2Hl)`b>kOm-tNqU?`r9CzP*k9qG10YB2eyF z(+Sa^uJq%{4j5N{yrL#BLsvc>JmCgPWfx~*A44u5PtH$@1RZ_VPGF8YOecBOp=sPaplq3fH0>Z>0rJCNq<6Bo=7nW0m`tP}O&A2S@h>4sM> zpqF-%S~q1z+BV-u4R~nG79j^hJf6|*LIp@Q_==9X7*q3!eGUwGzW{JG_r2T6E)(il zI&BI%A(4D^5OO-3?jia9ht=HBu4fg3u^ZdzwSus94TFuwlSP7{A@D^HaQtj*+!PtH zSt4sG@)LS?dH54(ibmd@$IE6P0nfo(llZE9F}$Jhe`*8>_^v-ejaV=EJ!%_BTE>2= zg8bXV5*P$I(Z9fED{Xmeg$L=#l^^KyJB7dT?|z2(qchy%ghgYu$vKbzb+zkj8-2Ci zN*xsy1FJ_qUAJ<(DVn}dY@e7|pSAF#oja~7L~gP4{`y<8so*(=MA`WZ{gNa~$#ZbF}^N^y1ZROufpcHX+aZshTc3^ll__mJA?1cX5PoX;A(*B$D>h168A zZ_348#9Z%QZjtaPOdORUv}di4<VMTu1B+HL#LT+{7Oc5c`O8RgH+WjQ~%BR+99yt#PSXb(lR-65^eocpy- z3=u*n)YM5*k@*sp*qCIwNYi#dLUFp*UbfyP-RKhUhvaS_8>@1^EBREAi8nZTiVv%e zRm7vZZa_{3-A>yNZ_4E>l^ctvtp2DPC@G=fo{y{acabKO zFTpv+#m4#aEvBZ>PowlyP$BMD}{oz%CGLyolLIhqbj8i51)GVw}-3*#XW}i?V${qO4A*p}RJ}`poTOSZQFossh z!ne`#45!WFJuuf5Z?yGVKJH~@ovpRxEPlr^Y2$t~eWHHyb06KjI*QG+<qG`*%+i-0ov?O&!l<)FM`_)2~Am!_XXUK3A2icRy(y|RO{>Z~$Z0aoz4 z3!^l@skByiT7S=twGt*7SqQ@w&@rE`2l>81NkF|OQ*~)$;<$`)jVek?K5Vh5l@VE2 zDezu|u}ALJV!EQ0dzEdbp6VSR&QD&E@D^oQrgB*k|M0Tb*JKUu2)UkQg8^y>j zk!^{B1qiva^`%4(KG9eBe*XBwBWn7mw0~du@p_+)D0{nL!;W7kU;pf--@r?XKtQzq zMDGPqWv1`_!dy^tWb{tQxDqzIb!68g0xm1(3JYF)10Ky_!X^{0G}&8Z3+=Ep90b_Fp(`^94rwY@6l z&+a1QMx*E}LD#dgMZ~XTx>EEruL{C5Fb~bW^rgv~W6omjf8OX8zUa`MrW&0;gYQ0P z1F4HA6GCwF=mO8d@BL}=52yM)8%j|aM=`Yne7HCzW>!!vZ^@Q7#7*})w_%Cjo$Hv zrR_Fcb>;?fAJI}aU8xgNW12~*vq1QRBa>-W&FuEMGl_;D@YdxnPTn4x4Bg|_Jw9e_ zoJ5&x*LahUFhhn2%$nexgFDC0@n*NGkWt4IW8BQZ3B>|LG#F=0;w}hFh&!O5QHKtl zgW05FoC-Wvf+2g2nL8aibH3S&K#P&(PQ5kHI;oGj%S5_Vg)8S+S5q;0W?fwzx;pm8 z?XViwknJ+-$Gr+p+gavrn5BfIs+l1Zyf;JN(A48y!baR~NUgO8^mZH@5j^2np@_fH zlWbf(Yf{o-I7A&=?1a3@7sKtc?v{tNm_K4|;VXZAUC8wf7^`5q>i2E#TG|o+igZ@v z?l+ygx8rvXOYCdXJO*CgMwDp_x#DqQ$}MlP3{E5?wn7kwadFa>(`bw*+bHQ5LQLeD z`QUOGOl{xJkiiMnLgZ%imvjzh<1CrUE~>@y)c#3QkY_`kc7Hc#_65Sw!KRjttj2;d zHmyocFryfh)4N2&YdvyTsq)*HcW42;_cj*U%7841vWe9qln~0+*^hRom;vBTcnJ4~ zSG@ME-b`a$WOSw%0J1aQ?)~C1rGY)!R`!y5L(1;eHN@x5?hZ@e%XTfg)a;|VGdFQ7 z5Dnijc21-%@N=uwM5@$|PQ+I4kx=_&q|?1>HzmG01NRhXr!$b3=`43ERWDuH!sq=V zv^lPO2zfI>{%uyOI8>W`w9{irxz#gfHH<^NoHW?jG2$xj(yiIITN~me3o%Nuz_9(A zVJO}AW9Ng$_{%5Lbtz+Vg6465yZ3aHo-aZj6uB zSl}bnWC?L)p6xSaJ%eN~kebKZFEY?QBDTVTR&opJlm^S%*h(_qiEzN@ zeyNGDhrx1{jMGPdm>8tSeXb>0`(u<%8fV=E87P8r5+ygsF*h-jqlXvwbPFTpgHEQW z$bBnH7LTbjESm2>dt`-NvaTZJPYCi_q3TNeNE9)Gnd^OyHK?x&a#it^t45g_!S%Yx zEZn#LRJqE*7>l;gWc)`*&BGsIH6w=T`5#Ua^zF8+`0jzq``(}03C0x8sybN;=AHs2 zERzOqFBJ~h>#Z)8*)i?mN|4usFA;vMsnBIj#K1QWAC62Y?A$Tzy90~s%c2K{&jgzz z$YU5ug0QPWe7e(_Dy8O-7G%4m&&QEh$}NzqE*kYdoikSvPN{rV23RUV(+nCTnI-o| z1X;O@X6XTqu{wmIC(6~22i$154WEm;?ewAR7A?TGQHlnQ#^egZ=sRRv@=vvFd2sE~ zHoaR9zdg9P({0D)p(AHMkAEK6)U>n-ocM>xKLD`!1YXv>naYb~;KQEkPgtFoB8D4J z05r_s7saSNsKw~uMA53|wsqYJALq-tsAv09{ZGNzO_4M&<}Ik``|+Zr=}V z69WuzF)Y*BZy!<=3$PqCcNBBoqTz#P|D^h5jix=q?JpzqYovV&y|rY>G@PVvF@!n zw7cf`UZuI~UTFVCxmKEpvKmE>=)5HEd>pdWepP~H)jwvV8rtBT9`W*duJ@@JQ?uM4 zyS`NV8GV;ZJ5oJLpeS3O`5VUll?WqsG>YLF$?(<&==G40>%BLpTJ-g9^*j|@mSTpF zglziQ7!kJjl=g&1*pBbdR>MacckyoKf8WpVZ^-=q{qTh8kp2Y5dBcBk;9x{Hes3gW zCX&%s70K{3DETgc*NuaRliB0$wi=Co`(nzsT3qNcDu<*KMjofZro@BJF}J|LTA`W0 z#VZ)foGIBMF&^&>R6EC zKQNTf5UkuKG8>m*j|k1wTD=d~;s&5-tI>9mw5y${ZA1B*CUf5#B1{4sRXXp<=hCo- z)6mbaUd|u#7!8NoJE=HH1^BIO@zKq$^((H`afn*_n!z@%UanH5LT21;t3B1Tl$$0K z!q}7f%JnODc9~Y=R-7vE%c1Z$ruTNiy(Phb-VYu*AMv);9Bacuyl;O?zs4~x?7$8d z+^{aU%67X+^7DrGyE8&?{@iJ(r;*zM?{vB5d~xTtP9BKW_DL1LS&THuTGpTV(1V<- z3Dv>WR)lwRKb*r2?S%a^h+X*JBkD0%b6k0-*~d0BF}Rde<1-#~DMkdi z)P*1SZk`|(_rIkN+j+Z}n&{*srcAqitP6X^tv=}3I@l;0@vhv}JSh`4``kP=AaaUAay#B<% zU(`J?TQ=3oWMgcHI%cP>`{mV<6Af38es z^tBpfe9e`x!_XCp6tg2jVB4uU6-9Y2ldUW_m()H17Fcs^&q*iGuD%Y9`aM>u7X!y; zEWt!|!MGTRsbf4kFD-#>$@qr2OWJUI);{J}G$m;^N7-RY&O|qQ3k4J;D=NDWEg5muzPT1cND`&3gXyI&@XWs$`#ZWinP6cny>t?gS4qcG?G!wZ5MHs zuD>Oj=HdnxpE?f8vcd0KvR7C>G7w!jXWM7Z&;n=_CO1VS?-#xkoiE>OkM;wb{6%qx zYSU@Gd-DCJ7K$7qfn3&WH|hxeP-TLjoZGV85>wB<@TNK(VWcxvcZRnCjKE8s<|Z7^ z5XtM2nX7PIWIou3wD?E+)l9VTPr=s1DRHu_V;zR}kl9`zt23yMzVx9z;x4)V;@6q8 z`b;~;us4wGq)L-A&U)_CY!SzMHqx{^2u){s9cS4=fGu5)BM3#;YEa)o&CSnL`O-!J z?u^^@7w#lzzH`q;tQIq57~YFm2b+v4CJcjOLYmO+j)-8z-5E}@G_a|)hjyLL79zBl zgpDN;`%+w!$oPOpash-nlB7xA+=g<%(u|_oTEwr*BLmhL>tc?}Rzazqa+q-z5e9f96c7GS9*Yh)dvN_4o^&ru&yynt)3QU^!#ZysfsE>YBuT z5%fwH(nK3|c~#$Bhg!CCIYY15Md(gQzEN3g=q-%hUKWHlJ~5Q*sie_hYEqx2 z{5IZXJ>Al45<`?FJ|9huP_#ivX5=Jmon2VdMFx_ZU=Cij+|sh)UbpUn)SR=mW0o^k z)#6>kH=R5K#M2wH$pM4JEet0*4Q; zz7*}hr)gx5k%31@X8v+3*RW<`7P$nF8ymWJ$?1qjt!t4=-6hB+k>_Djkh6~vRL$sF zuhGQ`g7U02q`@6|$C2%gx1r#KLKKxI_GCT~Rc4E(s5{SMZ8r|$RW7x`ko;{{!M&4j zLx=mv1ctS)TO1w7_^XbPe0Cv#1ASLqGdB3}0#9!{YkCsPmUYT@|BAUlY zUVT*m^;Am(9(;b{FIV68anRXRgNS?Do<*K(?ud|)dw6L5_Z;^<7dnHwjq*9?APZW1 zy}BzYW=y*rWVPj(#Ic}wd8Davqf?a`niSObbK93BiUlY&97I#uE(XPXxh5Qq=6JJu zQS`>a?bUegGn;Meek7n6fIvKM#+@PZ0q8xb;P_V+yN5=w7~=A z7!l;DC~J*3)*iyLBU=i|>vR9&O8;NtN@F#uaTb4brBByY1_g^@zVJv}rw(z8M<)2= z=8v#~{PTb~{kP-uLNEk%?NRB^9sZRd*Qnf3%DP?On;}^23M63p_VyBive7laGpppX z3$irfn+25fYze^FiDIDLCS@}vO`fN^w2Cv1BwPB!GZwz1Yi?`m35bz@fyq)#cW3xNwkFaNM-=*!=ec8wXSDW>c;mg2+r zL^7l7Ks7*)RqMdVprOW8OiFK`u!Yk_2m9*G*_Vo8o~ZZ!k&O2o{<^BU=CWl}`R?gH z&8KEI6O)&#U3wv}o!ueOhfFEV7z=>OvBHr8FeZ)ghy&iN&>WM}hUeUvLV>xmc?alR zjqJLIy#nu&OD*vZl}u?!Ce`<>$`E2oR}_wSr`&Lu%aUiU*rikCFYm%!w*m;;ue&&= z^oF^cWr|Ol32zzs=+5ZA5$K?a!sSO$LzTKAjiwzp8aESVtRbl^_;=-}w_PnnKMt#bIpq{fzg461Rz0Xt{C{^MFU19P989 zifQkpq`Rt*aMLASfd^TQBc%yLC5Hgj`Ccun<8z@iA!u=0j7qFQ&GZR7Co%gZL_Hqd zvQDpL43PRY`!_kHkC4mcaF8!~BMtuCj`H?HQ+HyW+lr+JVrrwq4YWbgnvBn~+A7>f6FRzm2X-z_yoI$5?Hy9?JLMogSb_Ccq3`AN#q^eo42QFNO{!sAQ zdnM&s_HL6A51Z|oCxk8sa(-e-Pk<#g)iEc(ta=jxFC)ua$3+{i+GopTR+H19nD0$6{*?29N~{Tc#2rurJhtpv4i1fz zY|pN3AK%s(p2Eslc4gd{O3B%+1#xnzR1ecc-a53QMMkbX{HR8whvDvlx(M$QlVeX!m_#93U`UEIYuC6UwQoy6_Y z?9d*)NS8Ke+M#N7)}!dSKFFIf0%B;-5{tsV3MoY(s?<2=@Sz91#~0PA=|_j=$FXhR z6jkQDmYP9MW#_9eho~l9e&YdPov($1h18^+LiTGeey`CO;8xRz3eLxS%#~Hfm9j0vF5@xD=4m*yWw?O+JmQk+WBP{9|4G`PK z){kfAk}FN{K`R3I<#WfJaw>J<1Q=>@8~x5yew)%so~&e2QzEZO|fBX2p2khd%KJHGxo@#Mp$VS@! zki=1~aDDQY*Xx+KE8I&qR}nQ!_3HCYt?@myn5@X>P30VGRPwe_Q{Z#cb(pgFVk# z+Zk_{D^IaWTYNekh3-Um%GHxxy|1d)l8fAXege{^5;mQ@h~DaM^F&8cqalED{yB9D z8q+PbwAuxKCQ~1g*47GX%12g+1$dLpy_4k%AXGs1kB26bAIx_CrF3*#WS_@c8#BN6 zt3&I_Wk$kx3^o&=haJvdX`1+YuAE$g;xSyv0_Tle=HMiDCyz*5Fl`K;1zue_2G`}H zqRA<7W$J~BWpYzRmgB0KVTA#ro)~DY`^mxI958Z}s9Bf^=>>QvYAytajh%BEu2QtRRWDo+--?0e*7%)!LHBD*0wcZl zEKZ<|Bw0}7xvlSYibq;T_Yw?kK#woCkXXyJ z$bnbG-NKF-4~z7@Jhs#CmMFN&mY`Is+`)i>Fy@O zKh9e&M*lecVd?IXi&NE7dbeA8TWWq@DnIE`phmp`FOd&ek2}hSHUh$=79T{c`}ED3 zuZ1<}hB!S+so^Wf+es}(4vV`&O*JZUnaT-o=PYb?s*AcP#lApj23qG$%H*2OF0!H?N^#SoKi1*3>Ehd4*i8&fl5Wh zSGIT|)8%5`VqC0VJ<(`NB@F#meE+4Mb>m$9QK3OUfR*YA6gez4nd;N>5+kGQmEqQcyLlL#Dl#wa3*3SUL{!% z)1^iZZ}wUUKv|0+MZ1=WH8NBsLV#)s7JeUY)P1g&Tv8i=_JOfvY!)*Vk}u0w=PrL? zh#GrbPElE(P$$bp>@n7%rs;B3c;j+9QpNKrxD92rXBgyBHTlG^$C0lcF1FRY#KYzB z{lact7jx7|NRG$jvD*E7_&8bOK8P7qrfS}@!8^Ny37@0Qnu29t*3((MI(zg?nKZGlCb<4hTq;bDol&PTTmB335&LeSn&cL%B zX+fs}`5cns3X2sz1nz|RRRR1yOo-d5_$fK~T(Oug6yK?5*?A2jbfKW&V_k`N2lrsk z>3#*)AZdFqH3iVqs*YStVv0aHo@@&iAmM!aK3O1AZ3_m|IsC)U;T>^;?RygbZ;oBg zPk1YN?U4Z1*2z0ULfCa-d{Rf= z>PeXSAOd6c!oW0Vnz{F zMFsS=e`MG7#JGC zeLKW5?dB`L;0t-=ZgX9SuLiBz|J^>lJFe`#J40LQB$>lq?kZ(|@)AkU-9N?hlB#Cx zhWQUQ5!&$_$5$gAchwa+b8NlSSG^V1^NO_34Uv*H4p19!G)nCBQl{% zfdjR|ESz>$g$X{66mJ8-y=6ynhw+fAH(Ze+fdx>P{5+-vJ{R8tfV(AxK)Ed4tLdH% zRAH9@rWJgI`%M8tpyeO7&vu?;L58a}N&Jv}p>7NEg`N~NU8zI-` zDs8@U6~Ak8wvyF zQ_vle$PdI&5z&h#T6=T-*b>O}m2(aAnsV8?rvQcPc5Qd&853Qo<=DxTK2fbpSffHS zQdb@!%_ms0NFi>jbEI$c0h{gcT$QgBthiHmkZV@9JLbA^cmVe5NufV> z@MC-S$iXRR=kZxEbVgu zb09hAN`Lanwgy{!48Yuq$cN>qN`;MbbFui3YG&_h1KXQm(s5(Y;SUw9j0)s$V1Vqa-Ae}SmAVzp zKk|bS(5P6*p`JN}N8t^`9ybg5y!<)H7oIRRpk;q5Y0A20Se^B~UxViaAeeiZ#Nxv8 z+9cta@> zhU!0B0#xRKB_Q~}wFD^fl{fR3CyV$&M{T|Uwi?j=6b{iq-s(bOGSdKXufh~f1gKTnk)uMS1trX-K$~dB-P{C zL(O@l>0|$1Ewe4(nSCCPrY(DF%ll8I0%UlC>gz`FN=2z-M?F^4o=vu6tTRA17nHFp+y_)v_1zZ&dh1gz z>&22!;!5mT02IQbCNcRLEO%?0fl8+K?Y|-K73@ba4YabKk0@AGWZbasBMc2B|4@be zn^OP{{0shaNK;?n6F{SvZCl%=y9RbGO@6qh4Te7$$HfQ2i2sgtg>|in^Vlx`MG{+* z&2tI(qR$Y9C?ugqF`Cakfz}V71JsH)1)mK>S;^$l(2{LFchqw-`ZX^F zaaF9qBB#*|pALqkRgVNwk{=K)pP6Gn7}_BM)GgSHtoPROlF^)NE9wf%#;s}SKs-jBl0;$}ZfzIGz{t6Tl-RBD(RD8-DNa*v`Q5N#gJ%%^J&#bp* z9FF*WB-b2<{fZfY-X#07*pR$<3P8b`+Eoj^)STPQ(=hE)`O2c_+FH}AbU#9c7LZFi zZW(il7Kf{_{|EcUkc&F(tpJeGbAwblcV zu;TQ>@0VhfD+_x?k-!<`ayT`d3)9uWZd{24xRb<6Oo_wi{$K2Uc{r5q`?pplTZ>fo zr>C-nRLC}zN)fW}6j{d-GIo>75=A8xF-gce1~c|i*@~F54P#4~!B}D#%NX-s)Am$P z-|zc--@kvyp~GRCnfto0>s&tPd4A6GZcEfJO$->k@`EG*9q`VuP?c!+-z0WL> z8Pv;+P(BxO?&Qo(6*)7xTLkvNfJt|Wtk%x?na4PL0uk@*KPmrGE`u$rqIv=>AX6E# zkkO^gBxA)@1;u*pbk%kVg!a?(8y5((p2^T6Kdhid+`(#9g;cj2=sN-?1_z9N13yjb zXY>wNx?AKn*E`z0$$zJjm@SFa3@*4rO*UdCY9BXvGcmCoZcPP1l~O54boT3@1ju!6B!Enq{D{m{28JQv@4IHjoBcVz>*R5Ar4 zf#w8s+)g=D9)0wJ&+QJX3CMT&=rp5hmu<-#3*IZ9K~Gi$Uxq@{9AIHx*<#`)4UY$E z>R2=5_K->u2!JJTMUZ30R1w_i0i(UD@>6fU0{j`1`kAxXU;H^ zeiltWBSLjn7m-_FBHF|@oGswDeL=K5>cz*$Ei;ZfBK(6^j*`cMR$eAr^8uOw=J{hq zlGLe{IY2P@!#tmr7Wkq$ z;(G<-(peub$h>{CTgF~UZuh=H$6~jTDa~16JgYv3wXGZ#@W*I$D1Fkw=dzHQzZaZh1jEF z6426q+#_hJ`6r@4($-D*Rt2n0{fBVFp84lu+W4x&@eq0-$Zj2VOty~5j6d)D2s6}) zTZ8#fd*n>yUw1v1xZ4224s6iLYgsV_qoxcrAX>?dL*-gF%uS~heI`7T0ae|k^T$(bBmJfD~KA&;)4g`TjJ3D?phZ$Lw z8i-KIv%rg66%+};oJ8a*&OWkMea@`~T=}h2hriPYnBp|?j60e4d%)zXABonVtQ;Jb zu7eXvpnq}_I-@*T_3UmT5;DKsR8QnFxuZ?FTx&UzRGLw=4s%yOK~zfS`%m~G{KJF11f!}Gu%CNX`1;}2V%-hJ zm}2v!Yt~OB8ZhNGCrYc)R|mWW@5Q#B>cL28D;{#SgT`%&spKdGVU|OyGLO>~?42ft z*K-KAl-D+~v>YN+*@CX;m+P%)jLt#qK zTujHHZ$_*143pz2$jdB$foL2xu8Gpic|uT50?sKsRV-IlAWxG>|9R9W0T zS>Yt;-Unzr%Iu}Sj|v`Hyj6OeXBcXd(RXn~TOVll@_P@5sl>FW$t?!$aH^R9@WuMB z-f0}MDPP@DFWcu@xc+1(h3|e3Z}4=k;zfD%)r8!2VPNP^=Sd3>LwJvQ>@; z+r>n9k&6sp2#5!#w)n5;PkuJsig}c-U6`pz)6Z+`u1YW*2fPMK7&KY_7C9gx6FTh` z642rnLGQ=$S3<8=>^5d}L3U>30R_0);ZrFd%2S>MmN> z<#t<=4fP?RTPTpG?klr)6DhPJi4v+bNjU$%$=d%-*8V$L+nju;9bbv@Z{(^Ok*f=t zu01rp?@2*DPgwUD<0b4p%#;l!`8)IP)2T^3*2E?O^sLpZoi7#qVxb&V_@}- z)g3qk>R12sfiXr5WUMEFTCcx64-K5PNS5y#okk1YJr*>FWvwV^C)c*|BzW+rk&lS zM=6(h4%qxzt=LP+trb=&3u~8+(`0vfTfHiE)@fJ9&e*i~BiX$_HoX9RKw9DnDe>wC zv+}`20@p$R%5g1Zh_SI_ ze1K6vzU!^Kl-C{(=xclythlw}I>1Ljc%qmJY*AD+>tnd0KjD?VIR5B+gO)`yW-paV zMxdD_>Gy23$ZRSc5687Isjrd4I9|Fs6ZC#dxEClG%tSUGtPQ$o$EfG95C?VIkOW)tg#)KMq&@IexjC5 zSqn)tcDl>`mLy=E?@9)3aK(pHCuI94+iDyEr=xOtvq>C3(iN6pSu^8l;&<0Q3s9V& zF`Kd^3I)R~Tln3A^52c0?Y?qsGUZ+zaeg6aybDA_(eL!OF1n-dS-6JFR(K4VjpDCK z_05i&NW4j)xS6;Z`Qz<}C!>pdf_mkO!zY!p%oIW%@pB<)m?-3|=T1nX(sX~6J6=E+ zAdjO5NSq>M@bYc88K4}-qiYJ>nK7TO5U##1^rT0kX zR1Rj=$L}kI%}%atpHDV&SvbpcjVmEB!8)RHKo~$8?F!q8hkzcgViRfAt<`(aJy_su z7}yh#ILs{H53KFNcyv}L)5~OKIVXdzw=*zp4{PyS!UD#|Z>GF*a~9N7yZ5qKU2Etc zjH}P+Hq-;_I?gtIR}&EoR11I$A3fLGoeVp4&Nf;V@uM)ef`^fUh?xNy#*Cb8}3-oLk&fj(rMEw(ccu zn!C*th>O1ALGODQ(C2UGqLl02*lS)%>?yZ(O{Q&ATI{vlrcJpObZvJN+8H2SALXK>b{-dXbBG9_gWzRR1=hTVb7#iX~79- zDPE3@al0iOU*0!wzUM@xexAhO=J@63mmy*f@n*Dlhun%K>JKK{Oob%tx?vg*c_(d% zA!9W20=&tn{B+{}YN8&fBK5J8qbtSZS4x=SU3lgx)S0Jt2eawi*=1Te1vw&^(uFNsyF9oXVX-Vqs>UZYfuaEb6IPTH4-qGspgj5 z0*b8C)~(Xw(5ewF)S8U7eM}6VxH~3yKBxEY@@sSp-)o<$%;V}L)-1f7(5hI?USkM# z&7$6?G@EIKsVBdtYv(tJ(pf*gAs9p10nrxX4e0m}FGJI@iT`VR=8hAH6gMIWF}c?6nTK4GkC=*YuY z>eICvlaV$=ycH;rXAB2UZwnSEedo)o-OIjuDAC)W#U@b-14N>4-{4q_cW$#q?JUM&p z3GRZAgl8b7(`jvDbd@IJ!?@?~Hz7MgoXUEv$O{REE`-dKU-hogwK`DhLK>9pYntTQ&J*(wfkV#}xA*{ah?1=2$CYBT);`^5_oItQ-_e zF=^Q=0YT4gDpo><6Rpi_Vj*1Cmib&Yi-lzanNW{n*9ZYqz(rNUpvaGyWM=iB+xYF6 z^~|t8)-&(#wr&3_?>xdJ;%fpOy(@XG=8Op&N(IAgKwY$2sb5U-fG|F^x6Ht)14Lao z-H)=M7vqqC&1^ZA$(GlH5J@T?-Wq=$^gum;2H22h)7yBT_4<0(^d=4DJ!e0gMKfFcal_)p4U4>#m*98Bjlv9` z@OGMoK2Fcs2en!y9i8cXrl(+e`RzqZD^bShqo9RcGbb;*H=3A|>TNLLS*qc%vYP2X zWSDJKs*x#y)@kZZ^bbtCU{m94{nxOnjk5fR-WP6cJ-i|Lj^8|V1D;k}G z)pz5TJ~<=&Y5tU>93swn9^3oKdyMduvzY&Pj zPc|B@R_h@E9g8?DE>vj?cvz_2VLe;D@7H)ePAjW;7ex1>W_%zjT*38dVrq$Vl+Y5vEmBp+y0##D>eX$HtBonxvmY%IZ4q8>Zi5-T?ok|VpEXXGs#TArw5p zrMb{q1cPISS}I^6OdjJuOc@y_LF@G&30hCN1JPEar}Egv>om2PxYX4^jCy`#yg7Co zZiHuMv^zIe9$DhoS+)JgI=VxzR*P51@U+V!m-aof;%%2LT1qktlFZL%SGuO;su!tU zz?zBQ)_%%0pc)lz)%cuy+vHw0)c<=CL&ot-5yNqC8_G;f&T@~3I8)D?2^5$*V~PnVDx`1_gXwiU+jaMj+9DQ2gYU_swhgf?>6-oE=e$bTGq z&JF!798Ce?=wgkPO(gxJ4a0(}tSnRzAYxphi5h1}Tr-c<)AKaeg1I9-z4g86eyru6iZ@Idz4CqxBa-pq*{QA1hy;Q8VqiYGuw{Q z57#azAKz!0!ERA3AXi;bnm<(t=0kLYK9L_;^DJywcgL5$QFBv7H8|e@Po;B=!L&*? zlz|p1I0_ZaR%F#C^!Ja8u(-96wNuR53iHo&KW-|#8`eFtViOR2M6&iv0|7g@z_<5a z_n9z=%m-AHsaIa~CfH)oX z{pML%sOkazPS@uHj8f(+Oft6cSxH1A)4<_?Ck1~z3R17y$ZeoN+D?uR29#VXX!ZKO z#4fRKWz{I8wrCwhh+4ocF@9$HjXuD%;6b%su7fvzE?ye!B(G z1L4J(&D9A|rLqpLV@{3v{_|OYIqBfb56fU^|5B3%qtW#%!56#B*8Zx9@xcUJX&0%F zS@qqSUalr5F|qTFTfQ%hMJ;(gp#9=3x}af)tHE{{@u=${kbf)BEx=r*KI7g-XVGM= z^P6iZb!aS96@&zWr;UrY+jR@A1gZ^g2-#CUNL&itpX%{nQ3%a2GX7OYclfEvr}~rE znX<3VR^**v|NX>SuFJHMjeRzC6L#cvSjuZf7pgO#&)hw)__5F4^iO`*oBnb;PP&yt zIEYL5$$l_Cgo%E@pHKKEH-m<{yQf^M&u4UJ+D@i7CYsHpDQ7P_MP~iD=a+Zd$a<1( zWXrH5vfU6R4{e21uuoL_3(iT;bBVrzornAVSOCkN`_>T?`NS2I z3;3osvSTONrM;;T%u~HueWW_(MEDQe^ARuxfjLZ@WO+n;jJUJ5y_usC<|%ay@#uv25NTEmgcaH$=-2RFXdy|cvY0|sBHeR1ejZAvq66xG4RDXP%D5`*Uy{| zn61j+MPA-EkD^3tB-7>!uMLdcH=&>WadDQLMGK`X73*JLzZ|WRSnOX`&Uo2A)j${+o^74#I5lYH3#S;8|CRCvjyi(#hcrEeT+ ziyi|E30}MAqj{8%v!mpR_0?;GNQ9eU^(wKYUA{Vh>DZ4evxttHClzFm6Osy0Vy! z8CfMm6;B5D^cJ&Ad_cdPR~+I8{w^@;r-IjM5vof%HJm-blM6_ed6s6rXp0@pu049j8Yi9 zDK+`i;Lys&NJ$QfdPsSYPT9xlroveH#c5T7auBh!R&Wq*Ng@3(X)N#0;0f1SwDAMi zI9hpZ!B`c`^vW^4sJjL94E!e2>90O7O-OBAl|^@8scUi%HG+Qe++)GowfBP?Rs=uS zzM*683Z1a+|1(1L-fg=~3j!_Z#zvYTKX*b}>M1RH$_q>wrNzpDMfE8MxA*put|S`u{*<3D zNxZnrR#&KDH07AW^3{?o%ZI$itIjKXu9*e4i#_nI{z1 zy}cFTI;{YPhR+WX$41uuW87FSZ3ad0kuh< zPL}IEojkVvXazRZwr{-UZ%e!GQv_JZ*R}N`gCTm60wQ8{f(R7?KY(~Y*6y&A*VczA zYNB;gz#1|ulyB&bNX2n_w@?r(6Qz9rdFlIQKo6MFv&a&}`o(84I03?W%NZTUwDSYAE@i`e;F z!|2X0kau#+&(d=S-2K6?cmLfdnI7V?t)Z&0p-e!5bNkP#7yXR8Qv2(Uf7i$Fk>0PH z07mmM6`MJD>f16er5HwPH-m|pq@iWC|Gd`!Va}gvo&*ZOq@e$_y#LVP^;1*Ve-_=# z1M@3c@ipN}PI@+b z@@`{0bxqXQDEZgD`$d5M0rqB{T>Pk=|Bj!CC*&{cbzq)Pzyelt&Vf0tlZZ3P$lw{) z9?HaXL>lMUiAq21Q=#Ha01oFqKshGVR7JN!dQA7jAE{tlm(vsFRcBXNL8^HP={_(^ zpvh@u@%Gs1Q?Z9gu|;+)4aH>lUACp)ihp35*c*OTr#eb3hVsw6=r7xi5CzZb5xtw0JTG>*%w#IOqU+|!}6V(w*t3(cI!7I3U z#9DocHkQ2G(v@b%`z%kFieLMLD9J- zk?}b>xn_l0Q!y;Wc$ZkDKM4=Qlc&4o*J!F91N3&GzN*z)kKWZ!jgZRa+#1?ovWJx- zd{vVgGEM^?mH5k%f6F5=sjvS7GAvC*3d<>mk{c(m^)@u2#~m$;t>Q0dczmbF{-o@t zDFwC@$szM8$sTJ{HD!&fb!4Fz{Iqx+J)x3OEwtn`x`v}Sn9%8}Ce^B43s{zhwBObJ zQM6{f@ryh1;gFeRRphg!K_l}*%Q!ULV-jJvQr@mw`yi|b8)lmKt7aA|21jKJEqy-J zZwH~*BMqk+Q^>W9N`}V3+Rk{I>e8%@&mw$mC!*Q9dEVcKrS@MY=|X4LE+^4O&MY`K z8^vIcSgkaVL&2?S$Fu?%7paQsQFY@a>t}nkK)wc8o}cVso({q`2U9m817B@{zvyFp z&<&BcljIDc9a>WA>z6ICZcu*k&aK=`p~qBG*X@UdOKSyD>dd|FG!k zxP?v?+q|PTW7XSvg;X{Y$f>uu+z=!u5uJ&YCCVmSJGyK)&UDWRX2b?NP?%mNYz9sn zEMDXWUQ7BHuXV|1WtYJqum@hT%c~x1Ct;I`kkQh*2E*{`5Slx++Z3_5=?FitaUddM zjwk+V(qEdYM@3jAMcsyGzP39&F!>k)YlR!TwJmm$G=+}ca=WI@$aC&#U+WvEOaax( zN=og`oJK4>!lUp>EBi8Wh*B=#Vwe!^9~V=oXjR`4M`=t}UCxDHi}h#V;D?n$Xk#V% z;q>>1AT|C&gu9cT*#(RJzDy%l@t4=c)J#CI_sm-Dup7RPpSxEv^QxomQi>Zdt!)SNFeDSlqowM*GcZL2_xL8y(TInar*Ot5u=t7@ zwVgCalSM_VNrdmpa>bqCfj;6YC(bHTU~Y!H_vEj_7QVFFo4)m7$%mX`xK^}P zam&4DV@6%Z*R|bfeV5+&I^YvlT3fCD0QZcua{a_=7CLMCENgs9=c;Oli-AnfE>17O z*KT^a4p8l827-n?81*fxR0CB5<>?e;wFjkqaA|_*rn;6&Sj^zIz^yF@P2jPUa+G~M zi8ZScgMF)=rHoDi#*!oUwAgN*pYddLrqFTQ8dnh7!*!0~siri?!P0Zen*i7Wqbz^1e;_b> zEWM^P1P%t8Tv|h3XsM=g6_eh;SDNs_X4FN-Gtp-3@Sy?nG>tg8)=Yex>*2W=OvS_T z{aJU5eP%!b!5XiMkf)C+^{YCn5D!^1)L{Iy=ARvq-r~)(lL#yN)9qw1W%iFavd#Nu z!42D$)r0EWqMY}3uuC4uL{@6ehIgShHK#H7?JxBB;YpjV1}o)eRq-;&;*QPTQUEDH zepA|ts6-#b>-4Tcy2oM_E!Mh#pSBzuLg;4zIT|L8&A%dYt*v_Wua0yIJzmAqp5s&z zTtQ25(=v#)PbNin#0YUXiiXL%J3{ir;KlO@IE1hxw{dk=o*|+HyGm�&h%Cu34R7ST6g{^3^Olnkgv2 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%0A^b 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≠%i3VJ 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 MIsegcBq;U209J*{oB#j- diff --git a/scripts/ENCRYPTION_README.md b/scripts/ENCRYPTION_README.md index f72a410e..672ad0d8 100644 --- a/scripts/ENCRYPTION_README.md +++ b/scripts/ENCRYPTION_README.md @@ -203,7 +203,7 @@ spec: ./scripts/generate_data_key.sh # 2. 备份旧数据库 -cp config.db config.db.backup +cp data.db data.db.backup # 3. 重启服务 (会自动处理密钥迁移) source .env && ./mars diff --git a/scripts/generate_data_key.sh b/scripts/generate_data_key.sh deleted file mode 100755 index 2e739162..00000000 --- a/scripts/generate_data_key.sh +++ /dev/null @@ -1,143 +0,0 @@ -#!/bin/bash - -# 数据加密密钥生成脚本 - 用于Mars AI交易系统数据库加密 -# 生成用于AES-256-GCM数据库加密的随机密钥 - -set -e # 遇到错误立即退出 - -# 颜色定义 -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -PURPLE='\033[0;35m' -NC='\033[0m' # No Color - -echo -e "${BLUE}╔══════════════════════════════════════════════════════════════════╗${NC}" -echo -e "${BLUE}║ Mars AI交易系统 安全密钥生成器 ║${NC}" -echo -e "${BLUE}║ AES-256-GCM数据密钥 + JWT认证密钥 ║${NC}" -echo -e "${BLUE}╚══════════════════════════════════════════════════════════════════╝${NC}" -echo - -# 检查是否安装了 OpenSSL -if ! command -v openssl &> /dev/null; then - echo -e "${RED}❌ 错误: 系统中未安装 OpenSSL${NC}" - echo -e "请安装 OpenSSL:" - echo -e " macOS: ${YELLOW}brew install openssl${NC}" - echo -e " Ubuntu/Debian: ${YELLOW}sudo apt-get install openssl${NC}" - echo -e " CentOS/RHEL: ${YELLOW}sudo yum install openssl${NC}" - exit 1 -fi - -echo -e "${GREEN}✓ OpenSSL 已安装: $(openssl version)${NC}" - -# 生成安全密钥 -echo -e "${BLUE}🔐 生成安全密钥...${NC}" -echo - -# 生成 AES-256 数据加密密钥 -echo -e "${YELLOW}1/2: 生成 AES-256 数据加密密钥...${NC}" -DATA_KEY=$(openssl rand -base64 32) -if [ $? -eq 0 ]; then - echo -e "${GREEN} ✓ 数据加密密钥生成成功${NC}" -else - echo -e "${RED} ❌ 数据加密密钥生成失败${NC}" - exit 1 -fi - -# 生成 JWT 认证密钥 -echo -e "${YELLOW}2/2: 生成 JWT 认证密钥...${NC}" -JWT_KEY=$(openssl rand -base64 64) -if [ $? -eq 0 ]; then - echo -e "${GREEN} ✓ JWT认证密钥生成成功${NC}" -else - echo -e "${RED} ❌ JWT认证密钥生成失败${NC}" - exit 1 -fi - -# 显示密钥 -echo -echo -e "${GREEN}🎉 安全密钥生成完成!${NC}" -echo -echo -e "${BLUE}📋 生成的密钥:${NC}" -echo -e "${PURPLE}1. 数据加密密钥 (AES-256):${NC}" -echo -e "${YELLOW}$DATA_KEY${NC}" -echo -echo -e "${PURPLE}2. JWT认证密钥 (512-bit):${NC}" -echo -e "${YELLOW}$JWT_KEY${NC}" -echo - -# 显示使用方法 -echo -e "${YELLOW}📋 使用方法:${NC}" -echo -echo -e "${BLUE}1. 环境变量设置:${NC}" -echo -e " export DATA_ENCRYPTION_KEY=\"$DATA_KEY\"" -echo -e " export JWT_SECRET=\"$JWT_KEY\"" -echo -echo -e "${BLUE}2. .env 文件设置:${NC}" -echo -e " DATA_ENCRYPTION_KEY=$DATA_KEY" -echo -e " JWT_SECRET=$JWT_KEY" -echo -echo -e "${BLUE}3. Docker环境设置:${NC}" -echo -e " docker run -e DATA_ENCRYPTION_KEY=\"$DATA_KEY\" -e JWT_SECRET=\"$JWT_KEY\" ..." -echo -echo -e "${BLUE}4. Kubernetes Secret:${NC}" -echo -e " kubectl create secret generic mars-crypto-key \\" -echo -e " --from-literal=DATA_ENCRYPTION_KEY=\"$DATA_KEY\" \\" -echo -e " --from-literal=JWT_SECRET=\"$JWT_KEY\"" -echo - -# 显示密钥特性 -echo -e "${BLUE}🔍 密钥特性:${NC}" -echo -e " • 数据加密: ${YELLOW}AES-256-GCM (256 bits)${NC}" -echo -e " • JWT认证: ${YELLOW}HS256 (512 bits)${NC}" -echo -e " • 格式: ${YELLOW}Base64 编码${NC}" -echo -e " • 用途: ${YELLOW}数据库加密 + 用户认证${NC}" - -# 安全提醒 -echo -echo -e "${RED}⚠️ 安全提醒:${NC}" -echo -e " • 请妥善保管此密钥,丢失后无法恢复加密的数据" -echo -e " • 不要将密钥提交到版本控制系统" -echo -e " • 建议在不同环境使用不同的密钥" -echo -e " • 定期更换密钥并重新加密数据" -echo -e " • 在生产环境中,建议使用密钥管理服务" - -echo -echo -e "${GREEN}✅ 数据加密密钥生成完成!${NC}" - -# 可选:保存到 .env 文件 -echo -read -p "是否将密钥保存到 .env 文件? [y/N]: " -n 1 -r -echo -if [[ $REPLY =~ ^[Yy]$ ]]; then - if [ -f ".env" ]; then - # 检查是否已存在 DATA_ENCRYPTION_KEY - if grep -q "^DATA_ENCRYPTION_KEY=" .env; then - echo -e "${YELLOW}⚠️ .env 文件中已存在 DATA_ENCRYPTION_KEY${NC}" - read -p "是否覆盖现有密钥? [y/N]: " -n 1 -r - echo - if [[ $REPLY =~ ^[Yy]$ ]]; then - # 替换现有密钥 - if [[ "$OSTYPE" == "darwin"* ]]; then - # macOS - sed -i '' "s/^DATA_ENCRYPTION_KEY=.*/DATA_ENCRYPTION_KEY=$RAW_KEY/" .env - else - # Linux - sed -i "s/^DATA_ENCRYPTION_KEY=.*/DATA_ENCRYPTION_KEY=$RAW_KEY/" .env - fi - echo -e "${GREEN}✓ .env 文件中的密钥已更新${NC}" - else - echo -e "${BLUE}ℹ️ 保持现有密钥不变${NC}" - fi - else - # 追加新密钥 - echo "DATA_ENCRYPTION_KEY=$RAW_KEY" >> .env - echo -e "${GREEN}✓ 密钥已保存到 .env 文件${NC}" - fi - else - # 创建新的 .env 文件 - echo "DATA_ENCRYPTION_KEY=$RAW_KEY" > .env - echo -e "${GREEN}✓ 密钥已保存到 .env 文件${NC}" - fi -fi \ No newline at end of file diff --git a/scripts/generate_rsa_keys.sh b/scripts/generate_rsa_keys.sh deleted file mode 100755 index 021a7cce..00000000 --- a/scripts/generate_rsa_keys.sh +++ /dev/null @@ -1,149 +0,0 @@ -#!/bin/bash - -# RSA密钥对生成脚本 - 用于Mars AI交易系统加密服务 -# 生成用于混合加密的RSA-2048密钥对 - -set -e # 遇到错误立即退出 - -# 颜色定义 -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# 配置 -RSA_KEY_SIZE=2048 -SECRETS_DIR="secrets" -PRIVATE_KEY_FILE="$SECRETS_DIR/rsa_key" -PUBLIC_KEY_FILE="$SECRETS_DIR/rsa_key.pub" - -echo -e "${BLUE}╔══════════════════════════════════════════════════════════════════╗${NC}" -echo -e "${BLUE}║ Mars AI交易系统 RSA密钥生成器 ║${NC}" -echo -e "${BLUE}║ RSA-2048 混合加密密钥对 ║${NC}" -echo -e "${BLUE}╚══════════════════════════════════════════════════════════════════╝${NC}" -echo - -# 检查是否安装了 OpenSSL -if ! command -v openssl &> /dev/null; then - echo -e "${RED}❌ 错误: 系统中未安装 OpenSSL${NC}" - echo -e "请安装 OpenSSL:" - echo -e " macOS: ${YELLOW}brew install openssl${NC}" - echo -e " Ubuntu/Debian: ${YELLOW}sudo apt-get install openssl${NC}" - echo -e " CentOS/RHEL: ${YELLOW}sudo yum install openssl${NC}" - exit 1 -fi - -echo -e "${GREEN}✓ OpenSSL 已安装: $(openssl version)${NC}" - -# 创建 secrets 目录 -if [ ! -d "$SECRETS_DIR" ]; then - echo -e "${YELLOW}📁 创建 $SECRETS_DIR 目录...${NC}" - mkdir -p "$SECRETS_DIR" - chmod 700 "$SECRETS_DIR" - echo -e "${GREEN}✓ 目录创建成功${NC}" -else - echo -e "${GREEN}✓ $SECRETS_DIR 目录已存在${NC}" -fi - -# 检查现有密钥 -if [ -f "$PRIVATE_KEY_FILE" ] || [ -f "$PUBLIC_KEY_FILE" ]; then - echo - echo -e "${YELLOW}⚠️ 检测到现有的RSA密钥文件:${NC}" - [ -f "$PRIVATE_KEY_FILE" ] && echo -e " • $PRIVATE_KEY_FILE" - [ -f "$PUBLIC_KEY_FILE" ] && echo -e " • $PUBLIC_KEY_FILE" - echo - read -p "是否覆盖现有密钥? [y/N]: " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo -e "${BLUE}ℹ️ 操作已取消${NC}" - exit 0 - fi - echo -e "${YELLOW}🗑️ 删除现有密钥文件...${NC}" - rm -f "$PRIVATE_KEY_FILE" "$PUBLIC_KEY_FILE" -fi - -echo -echo -e "${BLUE}🔐 开始生成 RSA-$RSA_KEY_SIZE 密钥对...${NC}" - -# 生成私钥 -echo -e "${YELLOW}📝 步骤 1/3: 生成 RSA 私钥 ($RSA_KEY_SIZE bits)...${NC}" -if openssl genrsa -out "$PRIVATE_KEY_FILE" $RSA_KEY_SIZE 2>/dev/null; then - echo -e "${GREEN}✓ 私钥生成成功${NC}" -else - echo -e "${RED}❌ 私钥生成失败${NC}" - exit 1 -fi - -# 设置私钥权限 -chmod 600 "$PRIVATE_KEY_FILE" -echo -e "${GREEN}✓ 私钥权限设置为 600${NC}" - -# 生成公钥 -echo -e "${YELLOW}📝 步骤 2/3: 从私钥提取公钥...${NC}" -if openssl rsa -in "$PRIVATE_KEY_FILE" -pubout -out "$PUBLIC_KEY_FILE" 2>/dev/null; then - echo -e "${GREEN}✓ 公钥生成成功${NC}" -else - echo -e "${RED}❌ 公钥生成失败${NC}" - exit 1 -fi - -# 设置公钥权限 -chmod 644 "$PUBLIC_KEY_FILE" -echo -e "${GREEN}✓ 公钥权限设置为 644${NC}" - -# 验证密钥 -echo -e "${YELLOW}📝 步骤 3/3: 验证密钥对...${NC}" -if openssl rsa -in "$PRIVATE_KEY_FILE" -check -noout 2>/dev/null; then - echo -e "${GREEN}✓ 私钥验证通过${NC}" -else - echo -e "${RED}❌ 私钥验证失败${NC}" - exit 1 -fi - -if openssl rsa -in "$PUBLIC_KEY_FILE" -pubin -text -noout &>/dev/null; then - echo -e "${GREEN}✓ 公钥验证通过${NC}" -else - echo -e "${RED}❌ 公钥验证失败${NC}" - exit 1 -fi - -# 显示密钥信息 -echo -echo -e "${GREEN}🎉 RSA密钥对生成成功!${NC}" -echo -echo -e "${BLUE}📋 密钥信息:${NC}" -echo -e " 私钥文件: ${YELLOW}$PRIVATE_KEY_FILE${NC}" -echo -e " 公钥文件: ${YELLOW}$PUBLIC_KEY_FILE${NC}" -echo -e " 密钥大小: ${YELLOW}$RSA_KEY_SIZE bits${NC}" -echo - -# 显示文件大小 -PRIVATE_SIZE=$(stat -f%z "$PRIVATE_KEY_FILE" 2>/dev/null || stat -c%s "$PRIVATE_KEY_FILE" 2>/dev/null || echo "未知") -PUBLIC_SIZE=$(stat -f%z "$PUBLIC_KEY_FILE" 2>/dev/null || stat -c%s "$PUBLIC_KEY_FILE" 2>/dev/null || echo "未知") - -echo -e "${BLUE}📏 文件大小:${NC}" -echo -e " 私钥: ${YELLOW}$PRIVATE_SIZE bytes${NC}" -echo -e " 公钥: ${YELLOW}$PUBLIC_SIZE bytes${NC}" - -# 显示公钥内容预览 -echo -echo -e "${BLUE}🔍 公钥内容预览:${NC}" -head -n 5 "$PUBLIC_KEY_FILE" | sed 's/^/ /' -echo -e " ${YELLOW}...${NC}" -tail -n 2 "$PUBLIC_KEY_FILE" | sed 's/^/ /' - -echo -echo -e "${GREEN}✅ RSA密钥对生成完成!${NC}" -echo -echo -e "${YELLOW}📋 使用说明:${NC}" -echo -e " 1. 私钥文件 ($PRIVATE_KEY_FILE) 用于服务器端解密" -echo -e " 2. 公钥文件 ($PUBLIC_KEY_FILE) 可以分发给客户端用于加密" -echo -e " 3. 确保私钥文件的安全性,不要泄露给第三方" -echo -e " 4. 在生产环境中,建议将私钥存储在安全的密钥管理服务中" -echo -echo -e "${RED}⚠️ 安全提醒:${NC}" -echo -e " • 私钥文件权限已设置为 600 (仅所有者可读写)" -echo -e " • 请定期备份密钥文件" -echo -e " • 建议在不同环境使用不同的密钥对" -echo \ No newline at end of file diff --git a/scripts/migrate_encryption.go b/scripts/migrate_encryption.go index f17fbe7e..2c4b9d38 100644 --- a/scripts/migrate_encryption.go +++ b/scripts/migrate_encryption.go @@ -12,71 +12,71 @@ import ( ) func main() { - log.Println("🔄 開始遷移數據庫到加密格式...") + log.Println("🔄 开始迁移数据库到加密格式...") - // 1. 檢查數據庫檔案 - dbPath := "config.db" + // 1. 检查数据库文件 + dbPath := "data.db" if len(os.Args) > 1 { dbPath = os.Args[1] } if _, err := os.Stat(dbPath); os.IsNotExist(err) { - log.Fatalf("❌ 數據庫檔案不存在: %s", dbPath) + log.Fatalf("❌ 数据库文件不存在: %s", dbPath) } - // 2. 備份數據庫 + // 2. 备份数据库 backupPath := fmt.Sprintf("%s.pre_encryption_backup", dbPath) - log.Printf("📦 備份數據庫到: %s", backupPath) + log.Printf("📦 备份数据库到: %s", backupPath) input, err := os.ReadFile(dbPath) if err != nil { - log.Fatalf("❌ 讀取數據庫失敗: %v", err) + log.Fatalf("❌ 读取数据库失败: %v", err) } if err := os.WriteFile(backupPath, input, 0600); err != nil { - log.Fatalf("❌ 備份失敗: %v", err) + log.Fatalf("❌ 备份失败: %v", err) } - // 3. 打開數據庫 + // 3. 打开数据库 db, err := sql.Open("sqlite", dbPath) if err != nil { - log.Fatalf("❌ 打開數據庫失敗: %v", err) + log.Fatalf("❌ 打开数据库失败: %v", err) } defer db.Close() - // 4. 初始化加密管理器 - em, err := crypto.GetEncryptionManager() + // 4. 初始化 CryptoService(从环境变量加载密钥) + cs, err := crypto.NewCryptoService() if err != nil { - log.Fatalf("❌ 初始化加密管理器失敗: %v", err) + log.Fatalf("❌ 初始化加密服务失败: %v", err) } - // 5. 遷移交易所配置 - if err := migrateExchanges(db, em); err != nil { - log.Fatalf("❌ 遷移交易所配置失敗: %v", err) + // 5. 迁移交易所配置 + if err := migrateExchanges(db, cs); err != nil { + log.Fatalf("❌ 迁移交易所配置失败: %v", err) } - // 6. 遷移 AI 模型配置 - if err := migrateAIModels(db, em); err != nil { - log.Fatalf("❌ 遷移 AI 模型配置失敗: %v", err) + // 6. 迁移 AI 模型配置 + if err := migrateAIModels(db, cs); err != nil { + log.Fatalf("❌ 迁移 AI 模型配置失败: %v", err) } - log.Println("✅ 數據遷移完成!") - log.Printf("📝 原始數據備份位於: %s", backupPath) - log.Println("⚠️ 請驗證系統功能正常後,手動刪除備份檔案") + log.Println("✅ 数据迁移完成!") + log.Printf("📝 原始数据备份位于: %s", backupPath) + log.Println("⚠️ 请验证系统功能正常后,手动删除备份文件") } -// migrateExchanges 遷移交易所配置 -func migrateExchanges(db *sql.DB, em *crypto.EncryptionManager) error { - log.Println("🔄 遷移交易所配置...") +// migrateExchanges 迁移交易所配置 +func migrateExchanges(db *sql.DB, cs *crypto.CryptoService) error { + log.Println("🔄 迁移交易所配置...") - // 查詢所有未加密的記錄(假設加密數據都包含 '==' Base64 特徵) + // 查询所有未加密的记录(加密数据以 ENC:v1: 开头) rows, err := db.Query(` SELECT user_id, id, api_key, secret_key, COALESCE(hyperliquid_private_key, ''), COALESCE(aster_private_key, '') FROM exchanges - WHERE (api_key != '' AND api_key NOT LIKE '%==%') - OR (secret_key != '' AND secret_key NOT LIKE '%==%') + WHERE (api_key != '' AND api_key NOT LIKE 'ENC:v1:%') + OR (secret_key != '' AND secret_key NOT LIKE 'ENC:v1:%') `) if err != nil { return err @@ -96,34 +96,34 @@ func migrateExchanges(db *sql.DB, em *crypto.EncryptionManager) error { return err } - // 加密每個字段 - encAPIKey, err := em.EncryptForDatabase(apiKey) + // 加密每个字段 + encAPIKey, err := cs.EncryptForStorage(apiKey) if err != nil { - return fmt.Errorf("加密 API Key 失敗: %w", err) + return fmt.Errorf("加密 API Key 失败: %w", err) } - encSecretKey, err := em.EncryptForDatabase(secretKey) + encSecretKey, err := cs.EncryptForStorage(secretKey) if err != nil { - return fmt.Errorf("加密 Secret Key 失敗: %w", err) + return fmt.Errorf("加密 Secret Key 失败: %w", err) } encHLPrivateKey := "" if hlPrivateKey != "" { - encHLPrivateKey, err = em.EncryptForDatabase(hlPrivateKey) + encHLPrivateKey, err = cs.EncryptForStorage(hlPrivateKey) if err != nil { - return fmt.Errorf("加密 Hyperliquid Private Key 失敗: %w", err) + return fmt.Errorf("加密 Hyperliquid Private Key 失败: %w", err) } } encAsterPrivateKey := "" if asterPrivateKey != "" { - encAsterPrivateKey, err = em.EncryptForDatabase(asterPrivateKey) + encAsterPrivateKey, err = cs.EncryptForStorage(asterPrivateKey) if err != nil { - return fmt.Errorf("加密 Aster Private Key 失敗: %w", err) + return fmt.Errorf("加密 Aster Private Key 失败: %w", err) } } - // 更新數據庫 + // 更新数据库 _, err = tx.Exec(` UPDATE exchanges SET api_key = ?, secret_key = ?, @@ -132,7 +132,7 @@ func migrateExchanges(db *sql.DB, em *crypto.EncryptionManager) error { `, encAPIKey, encSecretKey, encHLPrivateKey, encAsterPrivateKey, userID, exchangeID) if err != nil { - return fmt.Errorf("更新數據庫失敗: %w", err) + return fmt.Errorf("更新数据库失败: %w", err) } log.Printf(" ✓ 已加密: [%s] %s", userID, exchangeID) @@ -143,18 +143,18 @@ func migrateExchanges(db *sql.DB, em *crypto.EncryptionManager) error { return err } - log.Printf("✅ 已遷移 %d 個交易所配置", count) + log.Printf("✅ 已迁移 %d 个交易所配置", count) return nil } -// migrateAIModels 遷移 AI 模型配置 -func migrateAIModels(db *sql.DB, em *crypto.EncryptionManager) error { - log.Println("🔄 遷移 AI 模型配置...") +// migrateAIModels 迁移 AI 模型配置 +func migrateAIModels(db *sql.DB, cs *crypto.CryptoService) error { + log.Println("🔄 迁移 AI 模型配置...") rows, err := db.Query(` SELECT user_id, id, api_key FROM ai_models - WHERE api_key != '' AND api_key NOT LIKE '%==%' + WHERE api_key != '' AND api_key NOT LIKE 'ENC:v1:%' `) if err != nil { return err @@ -174,9 +174,9 @@ func migrateAIModels(db *sql.DB, em *crypto.EncryptionManager) error { return err } - encAPIKey, err := em.EncryptForDatabase(apiKey) + encAPIKey, err := cs.EncryptForStorage(apiKey) if err != nil { - return fmt.Errorf("加密 API Key 失敗: %w", err) + return fmt.Errorf("加密 API Key 失败: %w", err) } _, err = tx.Exec(` @@ -184,7 +184,7 @@ func migrateAIModels(db *sql.DB, em *crypto.EncryptionManager) error { `, encAPIKey, userID, modelID) if err != nil { - return fmt.Errorf("更新數據庫失敗: %w", err) + return fmt.Errorf("更新数据库失败: %w", err) } log.Printf(" ✓ 已加密: [%s] %s", userID, modelID) @@ -195,6 +195,6 @@ func migrateAIModels(db *sql.DB, em *crypto.EncryptionManager) error { return err } - log.Printf("✅ 已遷移 %d 個 AI 模型配置", count) + log.Printf("✅ 已迁移 %d 个 AI 模型配置", count) return nil } diff --git a/scripts/setup_encryption.sh b/scripts/setup_encryption.sh deleted file mode 100755 index ec371063..00000000 --- a/scripts/setup_encryption.sh +++ /dev/null @@ -1,319 +0,0 @@ -#!/bin/bash - -# Mars AI交易系统加密环境设置脚本 -# 一键生成RSA密钥对和数据加密密钥,完整设置加密环境 - -set -e # 遇到错误立即退出 - -# 颜色定义 -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -PURPLE='\033[0;35m' -CYAN='\033[0;36m' -NC='\033[0m' # No Color - -# 获取脚本所在目录 -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" - -echo -e "${PURPLE}╔════════════════════════════════════════════════════════════════════════╗${NC}" -echo -e "${PURPLE}║ Mars AI交易系统 ║${NC}" -echo -e "${PURPLE}║ 🔐 加密环境一键设置工具 ║${NC}" -echo -e "${PURPLE}║ ║${NC}" -echo -e "${PURPLE}║ 功能: 生成RSA密钥对 + 数据加密密钥 + 配置环境变量 ║${NC}" -echo -e "${PURPLE}╚════════════════════════════════════════════════════════════════════════╝${NC}" -echo - -# 检查依赖 -echo -e "${CYAN}🔍 检查系统依赖...${NC}" - -# 检查 OpenSSL -if ! command -v openssl &> /dev/null; then - echo -e "${RED}❌ 错误: 系统中未安装 OpenSSL${NC}" - echo -e "请安装 OpenSSL:" - echo -e " macOS: ${YELLOW}brew install openssl${NC}" - echo -e " Ubuntu/Debian: ${YELLOW}sudo apt-get install openssl${NC}" - echo -e " CentOS/RHEL: ${YELLOW}sudo yum install openssl${NC}" - exit 1 -fi - -echo -e "${GREEN}✓ OpenSSL: $(openssl version)${NC}" - -# 进入项目根目录 -cd "$PROJECT_ROOT" -echo -e "${GREEN}✓ 工作目录: $(pwd)${NC}" - -# 配置参数 -RSA_KEY_SIZE=2048 -SECRETS_DIR="secrets" -PRIVATE_KEY_FILE="$SECRETS_DIR/rsa_key" -PUBLIC_KEY_FILE="$SECRETS_DIR/rsa_key.pub" - -echo -echo -e "${BLUE}📋 配置参数:${NC}" -echo -e " • RSA密钥大小: ${YELLOW}$RSA_KEY_SIZE bits${NC}" -echo -e " • 私钥文件: ${YELLOW}$PRIVATE_KEY_FILE${NC}" -echo -e " • 公钥文件: ${YELLOW}$PUBLIC_KEY_FILE${NC}" -echo -e " • AES密钥: ${YELLOW}256 bits (自动生成)${NC}" - -# 询问用户确认 -echo -read -p "是否继续设置加密环境? [Y/n]: " -n 1 -r -echo -if [[ $REPLY =~ ^[Nn]$ ]]; then - echo -e "${BLUE}ℹ️ 操作已取消${NC}" - exit 0 -fi - -echo -echo -e "${CYAN}🚀 开始设置加密环境...${NC}" - -# ============= 步骤1: 创建目录 ============= -echo -echo -e "${YELLOW}📁 步骤 1/4: 创建必要目录...${NC}" - -if [ ! -d "$SECRETS_DIR" ]; then - mkdir -p "$SECRETS_DIR" - chmod 700 "$SECRETS_DIR" - echo -e "${GREEN}✓ 创建 $SECRETS_DIR 目录${NC}" -else - echo -e "${GREEN}✓ $SECRETS_DIR 目录已存在${NC}" -fi - -if [ ! -d "scripts" ]; then - mkdir -p "scripts" - echo -e "${GREEN}✓ 创建 scripts 目录${NC}" -else - echo -e "${GREEN}✓ scripts 目录已存在${NC}" -fi - -# ============= 步骤2: 生成RSA密钥对 ============= -echo -echo -e "${YELLOW}🔐 步骤 2/4: 生成 RSA-$RSA_KEY_SIZE 密钥对...${NC}" - -# 检查现有RSA密钥 -if [ -f "$PRIVATE_KEY_FILE" ] || [ -f "$PUBLIC_KEY_FILE" ]; then - echo -e "${YELLOW}⚠️ 检测到现有的RSA密钥文件${NC}" - read -p "是否重新生成RSA密钥? [y/N]: " -n 1 -r - echo - if [[ $REPLY =~ ^[Yy]$ ]]; then - rm -f "$PRIVATE_KEY_FILE" "$PUBLIC_KEY_FILE" - echo -e "${YELLOW}🗑️ 删除旧密钥${NC}" - else - echo -e "${BLUE}ℹ️ 保持现有RSA密钥${NC}" - RSA_SKIPPED=true - fi -fi - -if [ "$RSA_SKIPPED" != "true" ]; then - # 生成私钥 - echo -e " ${CYAN}生成RSA私钥...${NC}" - openssl genrsa -out "$PRIVATE_KEY_FILE" $RSA_KEY_SIZE 2>/dev/null - chmod 600 "$PRIVATE_KEY_FILE" - echo -e "${GREEN} ✓ 私钥生成完成${NC}" - - # 生成公钥 - echo -e " ${CYAN}提取RSA公钥...${NC}" - openssl rsa -in "$PRIVATE_KEY_FILE" -pubout -out "$PUBLIC_KEY_FILE" 2>/dev/null - chmod 644 "$PUBLIC_KEY_FILE" - echo -e "${GREEN} ✓ 公钥生成完成${NC}" - - # 验证密钥 - echo -e " ${CYAN}验证密钥对...${NC}" - openssl rsa -in "$PRIVATE_KEY_FILE" -check -noout 2>/dev/null - echo -e "${GREEN} ✓ 密钥验证通过${NC}" -fi - -# ============= 步骤3: 生成数据加密密钥和JWT密钥 ============= -echo -echo -e "${YELLOW}🔑 步骤 3/4: 生成 AES-256 数据加密密钥和JWT认证密钥...${NC}" - -# 检查现有密钥 -DATA_KEY_EXISTS=false -JWT_KEY_EXISTS=false - -if [ -f ".env" ]; then - if grep -q "^DATA_ENCRYPTION_KEY=" .env; then - DATA_KEY_EXISTS=true - fi - if grep -q "^JWT_SECRET=" .env; then - JWT_KEY_EXISTS=true - fi -fi - -if [ "$DATA_KEY_EXISTS" = "true" ] || [ "$JWT_KEY_EXISTS" = "true" ]; then - echo -e "${YELLOW}⚠️ 检测到现有的密钥配置${NC}" - if [ "$DATA_KEY_EXISTS" = "true" ]; then - echo -e " • 数据加密密钥已存在" - fi - if [ "$JWT_KEY_EXISTS" = "true" ]; then - echo -e " • JWT认证密钥已存在" - fi - read -p "是否重新生成所有密钥? [y/N]: " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo -e "${BLUE}ℹ️ 保持现有密钥${NC}" - KEY_SKIPPED=true - # 读取现有密钥 - if [ "$DATA_KEY_EXISTS" = "true" ]; then - DATA_KEY=$(grep "^DATA_ENCRYPTION_KEY=" .env | cut -d'=' -f2) - fi - if [ "$JWT_KEY_EXISTS" = "true" ]; then - JWT_KEY=$(grep "^JWT_SECRET=" .env | cut -d'=' -f2) - fi - fi -fi - -if [ "$KEY_SKIPPED" != "true" ]; then - # 生成新的密钥 - echo -e " ${CYAN}生成AES-256数据加密密钥...${NC}" - DATA_KEY=$(openssl rand -base64 32) - echo -e "${GREEN} ✓ 数据加密密钥生成完成${NC}" - - echo -e " ${CYAN}生成JWT认证密钥...${NC}" - JWT_KEY=$(openssl rand -base64 64) - echo -e "${GREEN} ✓ JWT认证密钥生成完成${NC}" - - # 保存到.env文件 - if [ -f ".env" ]; then - # 更新现有文件 - if grep -q "^DATA_ENCRYPTION_KEY=" .env; then - if [[ "$OSTYPE" == "darwin"* ]]; then - sed -i '' "s/^DATA_ENCRYPTION_KEY=.*/DATA_ENCRYPTION_KEY=$DATA_KEY/" .env - else - sed -i "s/^DATA_ENCRYPTION_KEY=.*/DATA_ENCRYPTION_KEY=$DATA_KEY/" .env - fi - else - echo "DATA_ENCRYPTION_KEY=$DATA_KEY" >> .env - fi - - if grep -q "^JWT_SECRET=" .env; then - # 使用替代分隔符避免 / 字符冲突,并用引号保护值 - if [[ "$OSTYPE" == "darwin"* ]]; then - sed -i '' "s|^JWT_SECRET=.*|JWT_SECRET=\"$JWT_KEY\"|" .env - else - sed -i "s|^JWT_SECRET=.*|JWT_SECRET=\"$JWT_KEY\"|" .env - fi - else - # 使用引号确保值在同一行 - printf "JWT_SECRET=\"%s\"\n" "$JWT_KEY" >> .env - fi - else - # 创建新文件 - echo "DATA_ENCRYPTION_KEY=$DATA_KEY" > .env - printf "JWT_SECRET=\"%s\"\n" "$JWT_KEY" >> .env - fi - chmod 600 .env - echo -e "${GREEN} ✓ 密钥已保存到 .env 文件${NC}" -elif [ "$DATA_KEY_EXISTS" != "true" ] || [ "$JWT_KEY_EXISTS" != "true" ]; then - # 生成缺失的密钥 - if [ "$DATA_KEY_EXISTS" != "true" ]; then - echo -e " ${CYAN}生成缺失的AES-256数据加密密钥...${NC}" - DATA_KEY=$(openssl rand -base64 32) - echo "DATA_ENCRYPTION_KEY=$DATA_KEY" >> .env - echo -e "${GREEN} ✓ 数据加密密钥生成完成${NC}" - fi - - if [ "$JWT_KEY_EXISTS" != "true" ]; then - echo -e " ${CYAN}生成缺失的JWT认证密钥...${NC}" - JWT_KEY=$(openssl rand -base64 64) - printf "JWT_SECRET=\"%s\"\n" "$JWT_KEY" >> .env - echo -e "${GREEN} ✓ JWT认证密钥生成完成${NC}" - fi - - chmod 600 .env - echo -e "${GREEN} ✓ 密钥已保存到 .env 文件${NC}" -fi - -# ============= 步骤4: 验证和总结 ============= -echo -echo -e "${YELLOW}✅ 步骤 4/4: 环境验证和总结...${NC}" - -# 验证文件存在性和权限 -echo -e " ${CYAN}验证文件和权限...${NC}" - -if [ -f "$PRIVATE_KEY_FILE" ]; then - PRIVATE_PERM=$(stat -f "%A" "$PRIVATE_KEY_FILE" 2>/dev/null || stat -c "%a" "$PRIVATE_KEY_FILE" 2>/dev/null) - echo -e "${GREEN} ✓ 私钥文件: $PRIVATE_KEY_FILE (权限: $PRIVATE_PERM)${NC}" -else - echo -e "${RED} ❌ 私钥文件不存在${NC}" - exit 1 -fi - -if [ -f "$PUBLIC_KEY_FILE" ]; then - PUBLIC_PERM=$(stat -f "%A" "$PUBLIC_KEY_FILE" 2>/dev/null || stat -c "%a" "$PUBLIC_KEY_FILE" 2>/dev/null) - echo -e "${GREEN} ✓ 公钥文件: $PUBLIC_KEY_FILE (权限: $PUBLIC_PERM)${NC}" -else - echo -e "${RED} ❌ 公钥文件不存在${NC}" - exit 1 -fi - -if [ -f ".env" ] && grep -q "^DATA_ENCRYPTION_KEY=" .env && grep -q "^JWT_SECRET=" .env; then - ENV_PERM=$(stat -f "%A" ".env" 2>/dev/null || stat -c "%a" ".env" 2>/dev/null) - echo -e "${GREEN} ✓ 环境文件: .env (权限: $ENV_PERM)${NC}" - echo -e "${GREEN} 包含: DATA_ENCRYPTION_KEY, JWT_SECRET${NC}" -else - echo -e "${RED} ❌ 环境文件不存在或缺少必要密钥${NC}" - exit 1 -fi - -# 测试密钥功能 -echo -e " ${CYAN}测试密钥功能...${NC}" -TEST_DATA="Hello Mars AI Trading System" -ENCRYPTED=$(echo "$TEST_DATA" | openssl rsautl -encrypt -pubin -inkey "$PUBLIC_KEY_FILE" | base64) -DECRYPTED=$(echo "$ENCRYPTED" | base64 -d | openssl rsautl -decrypt -inkey "$PRIVATE_KEY_FILE") - -if [ "$DECRYPTED" = "$TEST_DATA" ]; then - echo -e "${GREEN} ✓ RSA加密/解密测试通过${NC}" -else - echo -e "${RED} ❌ RSA加密/解密测试失败${NC}" - exit 1 -fi - -# 显示最终结果 -echo -echo -e "${GREEN}🎉 加密环境设置完成!${NC}" -echo -echo -e "${PURPLE}╔════════════════════════════════════════════════════════════════════════╗${NC}" -echo -e "${PURPLE}║ 设置完成摘要 ║${NC}" -echo -e "${PURPLE}╠════════════════════════════════════════════════════════════════════════╣${NC}" -echo -e "${PURPLE}║${NC} ${BLUE}RSA密钥对:${NC} ${PURPLE}║${NC}" -echo -e "${PURPLE}║${NC} 私钥: ${YELLOW}$PRIVATE_KEY_FILE${NC} ${PURPLE}║${NC}" -echo -e "${PURPLE}║${NC} 公钥: ${YELLOW}$PUBLIC_KEY_FILE${NC} ${PURPLE}║${NC}" -echo -e "${PURPLE}║${NC} 大小: ${YELLOW}$RSA_KEY_SIZE bits${NC} ${PURPLE}║${NC}" -echo -e "${PURPLE}║${NC} ${PURPLE}║${NC}" -echo -e "${PURPLE}║${NC} ${BLUE}安全密钥配置:${NC} ${PURPLE}║${NC}" -echo -e "${PURPLE}║${NC} 文件: ${YELLOW}.env${NC} ${PURPLE}║${NC}" -echo -e "${PURPLE}║${NC} 数据加密: ${YELLOW}DATA_ENCRYPTION_KEY (AES-256-GCM)${NC} ${PURPLE}║${NC}" -echo -e "${PURPLE}║${NC} JWT认证: ${YELLOW}JWT_SECRET (HS256)${NC} ${PURPLE}║${NC}" -echo -e "${PURPLE}╚════════════════════════════════════════════════════════════════════════╝${NC}" - -# 使用指南 -echo -echo -e "${BLUE}📋 使用指南:${NC}" -echo -echo -e "${YELLOW}1. 启动Mars AI交易系统:${NC}" -echo -e " source .env && ./mars" -echo -echo -e "${YELLOW}2. Docker部署:${NC}" -echo -e " docker run --env-file .env mars-ai-trading" -echo -echo -e "${YELLOW}3. 查看公钥内容:${NC}" -echo -e " cat $PUBLIC_KEY_FILE" -echo -echo -e "${YELLOW}4. 测试加密API:${NC}" -echo -e " curl http://localhost:8080/api/crypto/public-key" - -# 安全提醒 -echo -echo -e "${RED}🔒 安全提醒:${NC}" -echo -e " • 私钥文件 ($PRIVATE_KEY_FILE) 权限已设置为 600" -echo -e " • 环境文件 (.env) 权限已设置为 600" -echo -e " • 请勿将私钥和数据密钥提交到版本控制系统" -echo -e " • 建议在生产环境中使用密钥管理服务" -echo -e " • 定期备份密钥文件" - -echo -echo -e "${GREEN}✅ Mars AI交易系统加密环境设置完成!${NC}" \ No newline at end of file diff --git a/start.sh b/start.sh index b9a84bac..c6a860e9 100755 --- a/start.sh +++ b/start.sh @@ -14,6 +14,7 @@ RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' +CYAN='\033[0;36m' NC='\033[0m' # No Color # ------------------------------------------------------------------------ @@ -70,95 +71,109 @@ check_env() { if [ ! -f ".env" ]; then print_warning ".env 不存在,从模板复制..." cp .env.example .env - print_info "✓ 已使用默认环境变量创建 .env" - print_info "💡 如需修改端口等设置,可编辑 .env 文件" + print_info "已创建 .env 文件" fi print_success "环境变量文件存在" } # ------------------------------------------------------------------------ -# Validation: Encryption Environment (RSA Keys + Data Encryption Key) +# Helper: Check if env var is set and not placeholder # ------------------------------------------------------------------------ -check_encryption() { - local need_setup=false - - print_info "检查加密环境..." - - # 检查RSA密钥对 - if [ ! -f "secrets/rsa_key" ] || [ ! -f "secrets/rsa_key.pub" ]; then - print_warning "RSA密钥对不存在" - need_setup=true - fi - - # 检查数据加密密钥 - if [ ! -f ".env" ] || ! grep -q "^DATA_ENCRYPTION_KEY=" .env; then - print_warning "数据加密密钥未配置" - need_setup=true - fi - - # 检查JWT认证密钥 - if [ ! -f ".env" ] || ! grep -q "^JWT_SECRET=" .env; then - print_warning "JWT认证密钥未配置" - need_setup=true - fi - - # 如果需要设置加密环境,直接自动设置 - if [ "$need_setup" = "true" ]; then - print_info "🔐 检测到加密环境未配置,正在自动设置..." - print_info "加密环境用于保护敏感数据(API密钥、私钥等)" - echo "" +is_env_configured() { + local var_name="$1" + local value=$(grep "^${var_name}=" .env 2>/dev/null | cut -d'=' -f2-) - # 检查加密设置脚本是否存在 - if [ -f "scripts/setup_encryption.sh" ]; then - print_info "加密系统将保护: API密钥、私钥、Hyperliquid代理钱包" - echo "" + # 去除引号 + value=$(echo "$value" | tr -d '"'"'") - # 自动运行加密设置脚本 - echo -e "Y\nn\nn" | bash scripts/setup_encryption.sh - if [ $? -eq 0 ]; then - echo "" - print_success "🔐 加密环境设置完成!" - print_info " • RSA-2048密钥对已生成" - print_info " • AES-256数据加密密钥已配置" - print_info " • JWT认证密钥已配置" - print_info " • 所有敏感数据现在都受加密保护" - echo "" - else - print_error "加密环境设置失败" - exit 1 - fi + # 检查是否为空或占位符 + if [ -z "$value" ]; then + return 1 + fi + + # 检查是否是示例值 + case "$value" in + *your-*|*YOUR_*|*change-this*|*CHANGE_THIS*|*example*|*EXAMPLE*) + return 1 + ;; + esac + + return 0 +} + +# ------------------------------------------------------------------------ +# Helper: Generate and set env var in .env file +# ------------------------------------------------------------------------ +set_env_var() { + local var_name="$1" + local var_value="$2" + + # 如果变量已存在(即使是占位符),替换它 + if grep -q "^${var_name}=" .env 2>/dev/null; then + # macOS 和 Linux 兼容的 sed + if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' "s|^${var_name}=.*|${var_name}=${var_value}|" .env else - print_error "加密设置脚本不存在: scripts/setup_encryption.sh" - print_info "请手动运行: ./scripts/setup_encryption.sh" - exit 1 + sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" .env fi else - print_success "🔐 加密环境已配置" - print_info " • RSA密钥对: secrets/rsa_key + secrets/rsa_key.pub" - print_info " • 数据加密密钥: .env (DATA_ENCRYPTION_KEY)" - print_info " • JWT认证密钥: .env (JWT_SECRET)" - print_info " • 加密算法: RSA-OAEP-2048 + AES-256-GCM + HS256" - print_info " • 保护数据: API密钥、私钥、Hyperliquid代理钱包、用户认证" - - # 验证密钥文件权限 - if [ -f "secrets/rsa_key" ]; then - local perm=$(stat -f "%A" "secrets/rsa_key" 2>/dev/null || stat -c "%a" "secrets/rsa_key" 2>/dev/null) - if [ "$perm" != "600" ]; then - print_warning "修复RSA私钥权限..." - chmod 600 secrets/rsa_key - fi - fi - - if [ -f ".env" ]; then - local perm=$(stat -f "%A" ".env" 2>/dev/null || stat -c "%a" ".env" 2>/dev/null) - if [ "$perm" != "600" ]; then - print_warning "修复环境文件权限..." - chmod 600 .env - fi - fi + # 变量不存在,追加 + echo "${var_name}=${var_value}" >> .env fi } +# ------------------------------------------------------------------------ +# Validation: Encryption Keys in .env +# ------------------------------------------------------------------------ +check_encryption() { + print_info "检查加密密钥配置..." + + local generated=false + + # 检查并生成 JWT_SECRET + if ! is_env_configured "JWT_SECRET"; then + print_warning "JWT_SECRET 未配置,正在生成..." + local jwt_secret=$(openssl rand -base64 32) + set_env_var "JWT_SECRET" "$jwt_secret" + print_success "JWT_SECRET 已生成" + generated=true + fi + + # 检查并生成 DATA_ENCRYPTION_KEY + if ! is_env_configured "DATA_ENCRYPTION_KEY"; then + print_warning "DATA_ENCRYPTION_KEY 未配置,正在生成..." + local data_key=$(openssl rand -base64 32) + set_env_var "DATA_ENCRYPTION_KEY" "$data_key" + print_success "DATA_ENCRYPTION_KEY 已生成" + generated=true + fi + + # 检查并生成 RSA_PRIVATE_KEY + if ! is_env_configured "RSA_PRIVATE_KEY"; then + print_warning "RSA_PRIVATE_KEY 未配置,正在生成..." + # 生成 RSA 密钥并转换为单行格式(\n 替换为 \\n) + local rsa_key=$(openssl genrsa 2048 2>/dev/null | awk '{printf "%s\\n", $0}') + set_env_var "RSA_PRIVATE_KEY" "\"$rsa_key\"" + print_success "RSA_PRIVATE_KEY 已生成" + generated=true + fi + + if [ "$generated" = true ]; then + echo "" + print_success "所有缺失的密钥已自动生成并保存到 .env" + print_warning "请妥善保管 .env 文件,不要提交到版本控制系统" + echo "" + fi + + print_success "加密密钥检查完成" + print_info " • JWT_SECRET: OK" + print_info " • DATA_ENCRYPTION_KEY: OK" + print_info " • RSA_PRIVATE_KEY: OK" + + # 修复 .env 文件权限 + chmod 600 .env 2>/dev/null || true +} + # ------------------------------------------------------------------------ # Validation: Configuration File (config.json) - BASIC SETTINGS ONLY # ------------------------------------------------------------------------ @@ -166,9 +181,7 @@ check_config() { if [ ! -f "config.json" ]; then print_warning "config.json 不存在,从模板复制..." cp config.json.example config.json - print_info "✓ 已使用默认配置创建 config.json" - print_info "💡 如需修改基础设置(杠杆大小、开仓币种、管理员模式、JWT密钥等),可编辑 config.json" - print_info "💡 模型/交易所/交易员配置请使用Web界面" + print_info "已使用默认配置创建 config.json" fi print_success "配置文件存在" } @@ -178,101 +191,55 @@ check_config() { # ------------------------------------------------------------------------ read_env_vars() { if [ -f ".env" ]; then - # 读取端口配置,设置默认值 NOFX_FRONTEND_PORT=$(grep "^NOFX_FRONTEND_PORT=" .env 2>/dev/null | cut -d'=' -f2 || echo "3000") NOFX_BACKEND_PORT=$(grep "^NOFX_BACKEND_PORT=" .env 2>/dev/null | cut -d'=' -f2 || echo "8080") - - # 去除可能的引号和空格 + NOFX_FRONTEND_PORT=$(echo "$NOFX_FRONTEND_PORT" | tr -d '"'"'" | tr -d ' ') NOFX_BACKEND_PORT=$(echo "$NOFX_BACKEND_PORT" | tr -d '"'"'" | tr -d ' ') - - # 如果为空则使用默认值 + NOFX_FRONTEND_PORT=${NOFX_FRONTEND_PORT:-3000} NOFX_BACKEND_PORT=${NOFX_BACKEND_PORT:-8080} else - # 如果.env不存在,使用默认端口 NOFX_FRONTEND_PORT=3000 NOFX_BACKEND_PORT=8080 fi } # ------------------------------------------------------------------------ -# Validation: Database File (config.db) +# Validation: Database File (data.db) # ------------------------------------------------------------------------ check_database() { - if [ -d "config.db" ]; then - # 如果存在的是目录,删除它 - print_warning "config.db 是目录而非文件,正在删除目录..." - rm -rf config.db - print_info "✓ 已删除目录,现在创建文件..." - install -m 600 /dev/null config.db - print_success "✓ 已创建空数据库文件(权限: 600),系统将在启动时初始化" - elif [ ! -f "config.db" ]; then - # 如果不存在文件,创建它 + if [ -d "data.db" ]; then + print_warning "data.db 是目录而非文件,正在删除目录..." + rm -rf data.db + install -m 600 /dev/null data.db + print_success "已创建空数据库文件" + elif [ ! -f "data.db" ]; then print_warning "数据库文件不存在,创建空数据库文件..." - # 创建空文件以避免Docker创建目录(使用安全权限600) - install -m 600 /dev/null config.db - print_info "✓ 已创建空数据库文件(权限: 600),系统将在启动时初始化" + install -m 600 /dev/null data.db + print_info "已创建空数据库文件,系统将在启动时初始化" else - # 文件存在 print_success "数据库文件存在" fi } -# ------------------------------------------------------------------------ -# Build: Frontend (Node.js Based) -# ------------------------------------------------------------------------ -# build_frontend() { -# print_info "检查前端构建环境..." - -# if ! command -v node &> /dev/null; then -# print_error "Node.js 未安装!请先安装 Node.js" -# exit 1 -# fi - -# if ! command -v npm &> /dev/null; then -# print_error "npm 未安装!请先安装 npm" -# exit 1 -# fi - -# print_info "正在构建前端..." -# cd web - -# print_info "安装 Node.js 依赖..." -# npm install - -# print_info "构建前端应用..." -# npm run build - -# cd .. -# print_success "前端构建完成" -# } - # ------------------------------------------------------------------------ # Service Management: Start # ------------------------------------------------------------------------ start() { print_info "正在启动 NOFX AI Trading System..." - # 读取环境变量 read_env_vars - # 确保必要的文件和目录存在(修复 Docker volume 挂载问题) - if [ ! -f "config.db" ]; then + if [ ! -f "data.db" ]; then print_info "创建数据库文件..." - install -m 600 /dev/null config.db + install -m 600 /dev/null data.db fi if [ ! -d "decision_logs" ]; then print_info "创建日志目录..." install -m 700 -d decision_logs fi - # Auto-build frontend if missing or forced - # if [ ! -d "web/dist" ] || [ "$1" == "--build" ]; then - # build_frontend - # fi - - # Rebuild images if flag set if [ "$1" == "--build" ]; then print_info "重新构建镜像..." $COMPOSE_CMD up -d --build @@ -322,9 +289,8 @@ logs() { # Monitoring: Status # ------------------------------------------------------------------------ status() { - # 读取环境变量 read_env_vars - + print_info "服务状态:" $COMPOSE_CMD ps echo "" @@ -358,18 +324,42 @@ update() { } # ------------------------------------------------------------------------ -# Encryption: Manual Setup +# Command: Regenerate all keys (force) # ------------------------------------------------------------------------ -setup_encryption_manual() { - print_info "🔐 手动设置加密环境" - - if [ -f "scripts/setup_encryption.sh" ]; then - bash scripts/setup_encryption.sh - else - print_error "加密设置脚本不存在: scripts/setup_encryption.sh" - print_info "请确保项目文件完整" - exit 1 +regenerate_keys() { + print_warning "这将重新生成所有加密密钥!" + print_warning "如果已有加密数据,重新生成后将无法解密!" + echo "" + read -p "确认重新生成?(yes/no): " confirm + if [ "$confirm" != "yes" ]; then + print_info "已取消" + return fi + + check_env + + print_info "正在生成新的密钥..." + + # 生成 JWT_SECRET + local jwt_secret=$(openssl rand -base64 32) + set_env_var "JWT_SECRET" "$jwt_secret" + print_success "JWT_SECRET 已生成" + + # 生成 DATA_ENCRYPTION_KEY + local data_key=$(openssl rand -base64 32) + set_env_var "DATA_ENCRYPTION_KEY" "$data_key" + print_success "DATA_ENCRYPTION_KEY 已生成" + + # 生成 RSA_PRIVATE_KEY + local rsa_key=$(openssl genrsa 2048 2>/dev/null | awk '{printf "%s\\n", $0}') + set_env_var "RSA_PRIVATE_KEY" "\"$rsa_key\"" + print_success "RSA_PRIVATE_KEY 已生成" + + chmod 600 .env 2>/dev/null || true + + echo "" + print_success "所有密钥已重新生成并保存到 .env" + print_warning "请妥善保管 .env 文件" } # ------------------------------------------------------------------------ @@ -388,18 +378,16 @@ show_help() { echo " status 查看服务状态" echo " clean 清理所有容器和数据" echo " update 更新代码并重启" - echo " setup-encryption 设置加密环境(RSA密钥+数据加密)" + echo " regenerate-keys 重新生成所有加密密钥(慎用)" echo " help 显示此帮助信息" echo "" echo "示例:" echo " ./start.sh start --build # 构建并启动" echo " ./start.sh logs backend # 查看后端日志" echo " ./start.sh status # 查看状态" - echo " ./start.sh setup-encryption # 手动设置加密环境" echo "" - echo "🔐 关于加密:" - echo " 系统自动检测加密环境,首次运行时会自动设置" - echo " 手动设置: ./scripts/setup_encryption.sh" + echo "首次使用:" + echo " 直接运行 ./start.sh 即可,缺失的密钥会自动生成" } # ------------------------------------------------------------------------ @@ -434,8 +422,8 @@ main() { update) update ;; - setup-encryption) - setup_encryption_manual + regenerate-keys) + regenerate_keys ;; help|--help|-h) show_help @@ -449,4 +437,4 @@ main() { } # Execute Main -main "$@" \ No newline at end of file +main "$@" diff --git a/store/ai_model.go b/store/ai_model.go new file mode 100644 index 00000000..d8f0594c --- /dev/null +++ b/store/ai_model.go @@ -0,0 +1,294 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "nofx/logger" + "strings" + "time" +) + +// AIModelStore AI模型存储 +type AIModelStore struct { + db *sql.DB + encryptFunc func(string) string + decryptFunc func(string) string +} + +// AIModel AI模型配置 +type AIModel struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Name string `json:"name"` + Provider string `json:"provider"` + Enabled bool `json:"enabled"` + APIKey string `json:"apiKey"` + CustomAPIURL string `json:"customApiUrl"` + CustomModelName string `json:"customModelName"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (s *AIModelStore) initTables() error { + _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS ai_models ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL DEFAULT 'default', + name TEXT NOT NULL, + provider TEXT NOT NULL, + enabled BOOLEAN DEFAULT 0, + api_key TEXT DEFAULT '', + custom_api_url TEXT DEFAULT '', + custom_model_name TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `) + if err != nil { + return err + } + + // 触发器 + _, err = s.db.Exec(` + CREATE TRIGGER IF NOT EXISTS update_ai_models_updated_at + AFTER UPDATE ON ai_models + BEGIN + UPDATE ai_models SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END + `) + if err != nil { + return err + } + + // 向后兼容:添加可能缺失的列 + s.db.Exec(`ALTER TABLE ai_models ADD COLUMN custom_api_url TEXT DEFAULT ''`) + s.db.Exec(`ALTER TABLE ai_models ADD COLUMN custom_model_name TEXT DEFAULT ''`) + + return nil +} + +func (s *AIModelStore) initDefaultData() error { + models := []struct { + id, name, provider string + }{ + {"deepseek", "DeepSeek", "deepseek"}, + {"qwen", "Qwen", "qwen"}, + } + + for _, model := range models { + _, err := s.db.Exec(` + INSERT OR IGNORE INTO ai_models (id, user_id, name, provider, enabled) + VALUES (?, 'default', ?, ?, 0) + `, model.id, model.name, model.provider) + if err != nil { + return fmt.Errorf("初始化AI模型失败: %w", err) + } + } + return nil +} + +func (s *AIModelStore) encrypt(plaintext string) string { + if s.encryptFunc != nil { + return s.encryptFunc(plaintext) + } + return plaintext +} + +func (s *AIModelStore) decrypt(encrypted string) string { + if s.decryptFunc != nil { + return s.decryptFunc(encrypted) + } + return encrypted +} + +// List 获取用户的AI模型列表 +func (s *AIModelStore) List(userID string) ([]*AIModel, error) { + rows, err := s.db.Query(` + SELECT id, user_id, name, provider, enabled, api_key, + COALESCE(custom_api_url, '') as custom_api_url, + COALESCE(custom_model_name, '') as custom_model_name, + created_at, updated_at + FROM ai_models WHERE user_id = ? ORDER BY id + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + models := make([]*AIModel, 0) + for rows.Next() { + var model AIModel + var createdAt, updatedAt string + err := rows.Scan( + &model.ID, &model.UserID, &model.Name, &model.Provider, + &model.Enabled, &model.APIKey, &model.CustomAPIURL, &model.CustomModelName, + &createdAt, &updatedAt, + ) + if err != nil { + return nil, err + } + model.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + model.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + model.APIKey = s.decrypt(model.APIKey) + models = append(models, &model) + } + return models, nil +} + +// Get 获取单个AI模型 +func (s *AIModelStore) Get(userID, modelID string) (*AIModel, error) { + if modelID == "" { + return nil, fmt.Errorf("模型ID不能为空") + } + + candidates := []string{} + if userID != "" { + candidates = append(candidates, userID) + } + if userID != "default" { + candidates = append(candidates, "default") + } + if len(candidates) == 0 { + candidates = append(candidates, "default") + } + + for _, uid := range candidates { + var model AIModel + var createdAt, updatedAt string + err := s.db.QueryRow(` + SELECT id, user_id, name, provider, enabled, api_key, + COALESCE(custom_api_url, ''), COALESCE(custom_model_name, ''), created_at, updated_at + FROM ai_models WHERE user_id = ? AND id = ? LIMIT 1 + `, uid, modelID).Scan( + &model.ID, &model.UserID, &model.Name, &model.Provider, + &model.Enabled, &model.APIKey, &model.CustomAPIURL, &model.CustomModelName, + &createdAt, &updatedAt, + ) + if err == nil { + model.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + model.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + model.APIKey = s.decrypt(model.APIKey) + return &model, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + } + return nil, sql.ErrNoRows +} + +// GetDefault 获取默认启用的AI模型 +func (s *AIModelStore) GetDefault(userID string) (*AIModel, error) { + if userID == "" { + userID = "default" + } + model, err := s.firstEnabled(userID) + if err == nil { + return model, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + if userID != "default" { + return s.firstEnabled("default") + } + return nil, fmt.Errorf("请先在系统中配置可用的AI模型") +} + +func (s *AIModelStore) firstEnabled(userID string) (*AIModel, error) { + var model AIModel + var createdAt, updatedAt string + err := s.db.QueryRow(` + SELECT id, user_id, name, provider, enabled, api_key, + COALESCE(custom_api_url, ''), COALESCE(custom_model_name, ''), created_at, updated_at + FROM ai_models WHERE user_id = ? AND enabled = 1 + ORDER BY datetime(updated_at) DESC, id ASC LIMIT 1 + `, userID).Scan( + &model.ID, &model.UserID, &model.Name, &model.Provider, + &model.Enabled, &model.APIKey, &model.CustomAPIURL, &model.CustomModelName, + &createdAt, &updatedAt, + ) + if err != nil { + return nil, err + } + model.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + model.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + model.APIKey = s.decrypt(model.APIKey) + return &model, nil +} + +// Update 更新AI模型,不存在则创建 +func (s *AIModelStore) Update(userID, id string, enabled bool, apiKey, customAPIURL, customModelName string) error { + // 先尝试精确匹配ID + var existingID string + err := s.db.QueryRow(`SELECT id FROM ai_models WHERE user_id = ? AND id = ? LIMIT 1`, userID, id).Scan(&existingID) + if err == nil { + encryptedAPIKey := s.encrypt(apiKey) + _, err = s.db.Exec(` + UPDATE ai_models SET enabled = ?, api_key = ?, custom_api_url = ?, custom_model_name = ?, updated_at = datetime('now') + WHERE id = ? AND user_id = ? + `, enabled, encryptedAPIKey, customAPIURL, customModelName, existingID, userID) + return err + } + + // 尝试兼容旧逻辑:将id作为provider查找 + provider := id + err = s.db.QueryRow(`SELECT id FROM ai_models WHERE user_id = ? AND provider = ? LIMIT 1`, userID, provider).Scan(&existingID) + if err == nil { + logger.Warnf("⚠️ 使用旧版 provider 匹配更新模型: %s -> %s", provider, existingID) + encryptedAPIKey := s.encrypt(apiKey) + _, err = s.db.Exec(` + UPDATE ai_models SET enabled = ?, api_key = ?, custom_api_url = ?, custom_model_name = ?, updated_at = datetime('now') + WHERE id = ? AND user_id = ? + `, enabled, encryptedAPIKey, customAPIURL, customModelName, existingID, userID) + return err + } + + // 创建新记录 + if provider == id && (provider == "deepseek" || provider == "qwen") { + provider = id + } else { + parts := strings.Split(id, "_") + if len(parts) >= 2 { + provider = parts[len(parts)-1] + } else { + provider = id + } + } + + var name string + err = s.db.QueryRow(`SELECT name FROM ai_models WHERE provider = ? LIMIT 1`, provider).Scan(&name) + if err != nil { + if provider == "deepseek" { + name = "DeepSeek AI" + } else if provider == "qwen" { + name = "Qwen AI" + } else { + name = provider + " AI" + } + } + + newModelID := id + if id == provider { + newModelID = fmt.Sprintf("%s_%s", userID, provider) + } + + logger.Infof("✓ 创建新的 AI 模型配置: ID=%s, Provider=%s, Name=%s", newModelID, provider, name) + encryptedAPIKey := s.encrypt(apiKey) + _, err = s.db.Exec(` + INSERT INTO ai_models (id, user_id, name, provider, enabled, api_key, custom_api_url, custom_model_name, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + `, newModelID, userID, name, provider, enabled, encryptedAPIKey, customAPIURL, customModelName) + return err +} + +// Create 创建AI模型 +func (s *AIModelStore) Create(userID, id, name, provider string, enabled bool, apiKey, customAPIURL string) error { + _, err := s.db.Exec(` + INSERT OR IGNORE INTO ai_models (id, user_id, name, provider, enabled, api_key, custom_api_url) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, id, userID, name, provider, enabled, apiKey, customAPIURL) + return err +} diff --git a/store/backtest.go b/store/backtest.go new file mode 100644 index 00000000..89ecb14d --- /dev/null +++ b/store/backtest.go @@ -0,0 +1,583 @@ +package store + +import ( + "database/sql" + "encoding/json" + "fmt" + "time" +) + +// BacktestStore 回测数据存储 +type BacktestStore struct { + db *sql.DB +} + +// RunState 回测状态 +type RunState string + +const ( + RunStateCreated RunState = "created" + RunStateRunning RunState = "running" + RunStatePaused RunState = "paused" + RunStateCompleted RunState = "completed" + RunStateFailed RunState = "failed" +) + +// RunMetadata 回测元数据 +type RunMetadata struct { + RunID string `json:"run_id"` + UserID string `json:"user_id"` + Version int `json:"version"` + State RunState `json:"state"` + Label string `json:"label"` + LastError string `json:"last_error"` + Summary RunSummary `json:"summary"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// RunSummary 回测摘要 +type RunSummary struct { + SymbolCount int `json:"symbol_count"` + DecisionTF string `json:"decision_tf"` + ProcessedBars int `json:"processed_bars"` + ProgressPct float64 `json:"progress_pct"` + EquityLast float64 `json:"equity_last"` + MaxDrawdownPct float64 `json:"max_drawdown_pct"` + Liquidated bool `json:"liquidated"` + LiquidationNote string `json:"liquidation_note"` +} + +// EquityPoint 权益点 +type EquityPoint struct { + Timestamp int64 `json:"timestamp"` + Equity float64 `json:"equity"` + Available float64 `json:"available"` + PnL float64 `json:"pnl"` + PnLPct float64 `json:"pnl_pct"` + DrawdownPct float64 `json:"drawdown_pct"` + Cycle int `json:"cycle"` +} + +// TradeEvent 交易事件 +type TradeEvent struct { + Timestamp int64 `json:"timestamp"` + Symbol string `json:"symbol"` + Action string `json:"action"` + Side string `json:"side"` + Quantity float64 `json:"quantity"` + Price float64 `json:"price"` + Fee float64 `json:"fee"` + Slippage float64 `json:"slippage"` + OrderValue float64 `json:"order_value"` + RealizedPnL float64 `json:"realized_pnl"` + Leverage int `json:"leverage"` + Cycle int `json:"cycle"` + PositionAfter float64 `json:"position_after"` + LiquidationFlag bool `json:"liquidation_flag"` + Note string `json:"note"` +} + +// RunIndexEntry 回测索引条目 +type RunIndexEntry struct { + RunID string `json:"run_id"` + State string `json:"state"` + Symbols []string `json:"symbols"` + DecisionTF string `json:"decision_tf"` + EquityLast float64 `json:"equity_last"` + MaxDrawdownPct float64 `json:"max_drawdown_pct"` + StartTS int64 `json:"start_ts"` + EndTS int64 `json:"end_ts"` + CreatedAtISO string `json:"created_at"` + UpdatedAtISO string `json:"updated_at"` +} + +// initTables 初始化回测相关表 +func (s *BacktestStore) initTables() error { + queries := []string{ + // 回测运行主表 + `CREATE TABLE IF NOT EXISTS backtest_runs ( + run_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL DEFAULT '', + config_json TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT 'created', + label TEXT DEFAULT '', + symbol_count INTEGER DEFAULT 0, + decision_tf TEXT DEFAULT '', + processed_bars INTEGER DEFAULT 0, + progress_pct REAL DEFAULT 0, + equity_last REAL DEFAULT 0, + max_drawdown_pct REAL DEFAULT 0, + liquidated BOOLEAN DEFAULT 0, + liquidation_note TEXT DEFAULT '', + prompt_template TEXT DEFAULT '', + custom_prompt TEXT DEFAULT '', + override_prompt BOOLEAN DEFAULT 0, + ai_provider TEXT DEFAULT '', + ai_model TEXT DEFAULT '', + last_error TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + + // 回测检查点 + `CREATE TABLE IF NOT EXISTS backtest_checkpoints ( + run_id TEXT PRIMARY KEY, + payload BLOB NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (run_id) REFERENCES backtest_runs(run_id) ON DELETE CASCADE + )`, + + // 回测权益曲线 + `CREATE TABLE IF NOT EXISTS backtest_equity ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + ts INTEGER NOT NULL, + equity REAL NOT NULL, + available REAL NOT NULL, + pnl REAL NOT NULL, + pnl_pct REAL NOT NULL, + dd_pct REAL NOT NULL, + cycle INTEGER NOT NULL, + FOREIGN KEY (run_id) REFERENCES backtest_runs(run_id) ON DELETE CASCADE + )`, + + // 回测交易记录 + `CREATE TABLE IF NOT EXISTS backtest_trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + ts INTEGER NOT NULL, + symbol TEXT NOT NULL, + action TEXT NOT NULL, + side TEXT DEFAULT '', + qty REAL DEFAULT 0, + price REAL DEFAULT 0, + fee REAL DEFAULT 0, + slippage REAL DEFAULT 0, + order_value REAL DEFAULT 0, + realized_pnl REAL DEFAULT 0, + leverage INTEGER DEFAULT 0, + cycle INTEGER DEFAULT 0, + position_after REAL DEFAULT 0, + liquidation BOOLEAN DEFAULT 0, + note TEXT DEFAULT '', + FOREIGN KEY (run_id) REFERENCES backtest_runs(run_id) ON DELETE CASCADE + )`, + + // 回测指标 + `CREATE TABLE IF NOT EXISTS backtest_metrics ( + run_id TEXT PRIMARY KEY, + payload BLOB NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (run_id) REFERENCES backtest_runs(run_id) ON DELETE CASCADE + )`, + + // 回测决策日志 + `CREATE TABLE IF NOT EXISTS backtest_decisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + cycle INTEGER NOT NULL, + payload BLOB NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (run_id) REFERENCES backtest_runs(run_id) ON DELETE CASCADE + )`, + + // 索引 + `CREATE INDEX IF NOT EXISTS idx_backtest_runs_state ON backtest_runs(state, updated_at)`, + `CREATE INDEX IF NOT EXISTS idx_backtest_equity_run_ts ON backtest_equity(run_id, ts)`, + `CREATE INDEX IF NOT EXISTS idx_backtest_trades_run_ts ON backtest_trades(run_id, ts)`, + `CREATE INDEX IF NOT EXISTS idx_backtest_decisions_run_cycle ON backtest_decisions(run_id, cycle)`, + } + + for _, query := range queries { + if _, err := s.db.Exec(query); err != nil { + return fmt.Errorf("执行SQL失败: %w", err) + } + } + + // 添加可能缺失的列(向后兼容) + s.addColumnIfNotExists("backtest_runs", "label", "TEXT DEFAULT ''") + s.addColumnIfNotExists("backtest_runs", "last_error", "TEXT DEFAULT ''") + s.addColumnIfNotExists("backtest_trades", "leverage", "INTEGER DEFAULT 0") + + return nil +} + +func (s *BacktestStore) addColumnIfNotExists(table, column, definition string) { + rows, err := s.db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table)) + if err != nil { + return + } + defer rows.Close() + + for rows.Next() { + var cid int + var name, ctype string + var notnull, pk int + var dflt interface{} + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { + continue + } + if name == column { + return // 列已存在 + } + } + + s.db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition)) +} + +// SaveCheckpoint 保存检查点 +func (s *BacktestStore) SaveCheckpoint(runID string, payload []byte) error { + _, err := s.db.Exec(` + INSERT INTO backtest_checkpoints (run_id, payload, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload, updated_at=CURRENT_TIMESTAMP + `, runID, payload) + return err +} + +// LoadCheckpoint 加载检查点 +func (s *BacktestStore) LoadCheckpoint(runID string) ([]byte, error) { + var payload []byte + err := s.db.QueryRow(`SELECT payload FROM backtest_checkpoints WHERE run_id = ?`, runID).Scan(&payload) + return payload, err +} + +// SaveRunMetadata 保存运行元数据 +func (s *BacktestStore) SaveRunMetadata(meta *RunMetadata) error { + created := meta.CreatedAt.UTC().Format(time.RFC3339) + updated := meta.UpdatedAt.UTC().Format(time.RFC3339) + userID := meta.UserID + + if _, err := s.db.Exec(` + INSERT INTO backtest_runs (run_id, user_id, label, last_error, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(run_id) DO NOTHING + `, meta.RunID, userID, meta.Label, meta.LastError, created, updated); err != nil { + return err + } + + _, err := s.db.Exec(` + UPDATE backtest_runs + SET user_id = ?, state = ?, symbol_count = ?, decision_tf = ?, processed_bars = ?, + progress_pct = ?, equity_last = ?, max_drawdown_pct = ?, liquidated = ?, + liquidation_note = ?, label = ?, last_error = ?, updated_at = ? + WHERE run_id = ? + `, userID, string(meta.State), meta.Summary.SymbolCount, meta.Summary.DecisionTF, + meta.Summary.ProcessedBars, meta.Summary.ProgressPct, meta.Summary.EquityLast, + meta.Summary.MaxDrawdownPct, meta.Summary.Liquidated, meta.Summary.LiquidationNote, + meta.Label, meta.LastError, updated, meta.RunID) + return err +} + +// LoadRunMetadata 加载运行元数据 +func (s *BacktestStore) LoadRunMetadata(runID string) (*RunMetadata, error) { + var ( + userID string + state string + label string + lastErr string + symbolCount int + decisionTF string + processedBars int + progressPct float64 + equityLast float64 + maxDD float64 + liquidated bool + liquidationNote string + createdISO string + updatedISO string + ) + + err := s.db.QueryRow(` + SELECT user_id, state, label, last_error, symbol_count, decision_tf, processed_bars, + progress_pct, equity_last, max_drawdown_pct, liquidated, liquidation_note, + created_at, updated_at + FROM backtest_runs WHERE run_id = ? + `, runID).Scan(&userID, &state, &label, &lastErr, &symbolCount, &decisionTF, + &processedBars, &progressPct, &equityLast, &maxDD, &liquidated, &liquidationNote, + &createdISO, &updatedISO) + if err != nil { + return nil, err + } + + meta := &RunMetadata{ + RunID: runID, + UserID: userID, + Version: 1, + State: RunState(state), + Label: label, + LastError: lastErr, + Summary: RunSummary{ + SymbolCount: symbolCount, + DecisionTF: decisionTF, + ProcessedBars: processedBars, + ProgressPct: progressPct, + EquityLast: equityLast, + MaxDrawdownPct: maxDD, + Liquidated: liquidated, + LiquidationNote: liquidationNote, + }, + } + + meta.CreatedAt, _ = time.Parse(time.RFC3339, createdISO) + meta.UpdatedAt, _ = time.Parse(time.RFC3339, updatedISO) + + return meta, nil +} + +// ListRunIDs 列出所有运行ID +func (s *BacktestStore) ListRunIDs() ([]string, error) { + rows, err := s.db.Query(`SELECT run_id FROM backtest_runs ORDER BY datetime(updated_at) DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + + var ids []string + for rows.Next() { + var runID string + if err := rows.Scan(&runID); err != nil { + return nil, err + } + ids = append(ids, runID) + } + return ids, rows.Err() +} + +// AppendEquityPoint 添加权益点 +func (s *BacktestStore) AppendEquityPoint(runID string, point EquityPoint) error { + _, err := s.db.Exec(` + INSERT INTO backtest_equity (run_id, ts, equity, available, pnl, pnl_pct, dd_pct, cycle) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, runID, point.Timestamp, point.Equity, point.Available, point.PnL, + point.PnLPct, point.DrawdownPct, point.Cycle) + return err +} + +// LoadEquityPoints 加载权益点 +func (s *BacktestStore) LoadEquityPoints(runID string) ([]EquityPoint, error) { + rows, err := s.db.Query(` + SELECT ts, equity, available, pnl, pnl_pct, dd_pct, cycle + FROM backtest_equity WHERE run_id = ? ORDER BY ts ASC + `, runID) + if err != nil { + return nil, err + } + defer rows.Close() + + points := make([]EquityPoint, 0) + for rows.Next() { + var point EquityPoint + if err := rows.Scan(&point.Timestamp, &point.Equity, &point.Available, + &point.PnL, &point.PnLPct, &point.DrawdownPct, &point.Cycle); err != nil { + return nil, err + } + points = append(points, point) + } + return points, rows.Err() +} + +// AppendTradeEvent 添加交易事件 +func (s *BacktestStore) AppendTradeEvent(runID string, event TradeEvent) error { + _, err := s.db.Exec(` + INSERT INTO backtest_trades (run_id, ts, symbol, action, side, qty, price, fee, + slippage, order_value, realized_pnl, leverage, cycle, + position_after, liquidation, note) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, runID, event.Timestamp, event.Symbol, event.Action, event.Side, event.Quantity, + event.Price, event.Fee, event.Slippage, event.OrderValue, event.RealizedPnL, + event.Leverage, event.Cycle, event.PositionAfter, event.LiquidationFlag, event.Note) + return err +} + +// LoadTradeEvents 加载交易事件 +func (s *BacktestStore) LoadTradeEvents(runID string) ([]TradeEvent, error) { + rows, err := s.db.Query(` + SELECT ts, symbol, action, side, qty, price, fee, slippage, order_value, + realized_pnl, leverage, cycle, position_after, liquidation, note + FROM backtest_trades WHERE run_id = ? ORDER BY ts ASC + `, runID) + if err != nil { + return nil, err + } + defer rows.Close() + + events := make([]TradeEvent, 0) + for rows.Next() { + var event TradeEvent + if err := rows.Scan(&event.Timestamp, &event.Symbol, &event.Action, &event.Side, + &event.Quantity, &event.Price, &event.Fee, &event.Slippage, &event.OrderValue, + &event.RealizedPnL, &event.Leverage, &event.Cycle, &event.PositionAfter, + &event.LiquidationFlag, &event.Note); err != nil { + return nil, err + } + events = append(events, event) + } + return events, rows.Err() +} + +// SaveMetrics 保存指标 +func (s *BacktestStore) SaveMetrics(runID string, payload []byte) error { + _, err := s.db.Exec(` + INSERT INTO backtest_metrics (run_id, payload, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(run_id) DO UPDATE SET payload=excluded.payload, updated_at=CURRENT_TIMESTAMP + `, runID, payload) + return err +} + +// LoadMetrics 加载指标 +func (s *BacktestStore) LoadMetrics(runID string) ([]byte, error) { + var payload []byte + err := s.db.QueryRow(`SELECT payload FROM backtest_metrics WHERE run_id = ?`, runID).Scan(&payload) + return payload, err +} + +// SaveDecisionRecord 保存决策记录 +func (s *BacktestStore) SaveDecisionRecord(runID string, cycle int, payload []byte) error { + _, err := s.db.Exec(` + INSERT INTO backtest_decisions (run_id, cycle, payload) + VALUES (?, ?, ?) + `, runID, cycle, payload) + return err +} + +// LoadDecisionRecords 加载决策记录 +func (s *BacktestStore) LoadDecisionRecords(runID string, limit, offset int) ([]json.RawMessage, error) { + rows, err := s.db.Query(` + SELECT payload FROM backtest_decisions + WHERE run_id = ? + ORDER BY id DESC + LIMIT ? OFFSET ? + `, runID, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + records := make([]json.RawMessage, 0, limit) + for rows.Next() { + var payload []byte + if err := rows.Scan(&payload); err != nil { + return nil, err + } + records = append(records, json.RawMessage(payload)) + } + return records, rows.Err() +} + +// LoadLatestDecision 加载最新决策 +func (s *BacktestStore) LoadLatestDecision(runID string, cycle int) ([]byte, error) { + var query string + var args []interface{} + + if cycle > 0 { + query = `SELECT payload FROM backtest_decisions WHERE run_id = ? AND cycle = ? ORDER BY datetime(created_at) DESC LIMIT 1` + args = []interface{}{runID, cycle} + } else { + query = `SELECT payload FROM backtest_decisions WHERE run_id = ? ORDER BY datetime(created_at) DESC LIMIT 1` + args = []interface{}{runID} + } + + var payload []byte + err := s.db.QueryRow(query, args...).Scan(&payload) + return payload, err +} + +// UpdateProgress 更新进度 +func (s *BacktestStore) UpdateProgress(runID string, progressPct, equity float64, barIndex int, liquidated bool) error { + _, err := s.db.Exec(` + UPDATE backtest_runs + SET progress_pct = ?, equity_last = ?, processed_bars = ?, liquidated = ?, updated_at = CURRENT_TIMESTAMP + WHERE run_id = ? + `, progressPct, equity, barIndex, liquidated, runID) + return err +} + +// ListIndexEntries 列出索引条目 +func (s *BacktestStore) ListIndexEntries() ([]RunIndexEntry, error) { + rows, err := s.db.Query(` + SELECT run_id, state, symbol_count, decision_tf, equity_last, max_drawdown_pct, + created_at, updated_at, config_json + FROM backtest_runs + ORDER BY datetime(updated_at) DESC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []RunIndexEntry + for rows.Next() { + var entry RunIndexEntry + var symbolCnt int + var cfgJSON []byte + var createdISO, updatedISO string + + if err := rows.Scan(&entry.RunID, &entry.State, &symbolCnt, &entry.DecisionTF, + &entry.EquityLast, &entry.MaxDrawdownPct, &createdISO, &updatedISO, &cfgJSON); err != nil { + return nil, err + } + + entry.CreatedAtISO = createdISO + entry.UpdatedAtISO = updatedISO + entry.Symbols = make([]string, 0, symbolCnt) + + // 尝试从配置中提取更多信息 + if len(cfgJSON) > 0 { + var cfg struct { + Symbols []string `json:"symbols"` + StartTS int64 `json:"start_ts"` + EndTS int64 `json:"end_ts"` + } + if json.Unmarshal(cfgJSON, &cfg) == nil { + entry.Symbols = cfg.Symbols + entry.StartTS = cfg.StartTS + entry.EndTS = cfg.EndTS + } + } + + entries = append(entries, entry) + } + return entries, rows.Err() +} + +// DeleteRun 删除运行 +func (s *BacktestStore) DeleteRun(runID string) error { + _, err := s.db.Exec(`DELETE FROM backtest_runs WHERE run_id = ?`, runID) + return err +} + +// SaveConfig 保存配置 +func (s *BacktestStore) SaveConfig(runID, userID, template, customPrompt, provider, model string, override bool, configJSON []byte) error { + now := time.Now().UTC().Format(time.RFC3339) + if userID == "" { + userID = "default" + } + + _, err := s.db.Exec(` + INSERT INTO backtest_runs (run_id, user_id, config_json, prompt_template, custom_prompt, + override_prompt, ai_provider, ai_model, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(run_id) DO NOTHING + `, runID, userID, configJSON, template, customPrompt, override, provider, model, now, now) + if err != nil { + return err + } + + _, err = s.db.Exec(` + UPDATE backtest_runs + SET user_id = ?, config_json = ?, prompt_template = ?, custom_prompt = ?, + override_prompt = ?, ai_provider = ?, ai_model = ?, updated_at = CURRENT_TIMESTAMP + WHERE run_id = ? + `, userID, configJSON, template, customPrompt, override, provider, model, runID) + return err +} + +// LoadConfig 加载配置 +func (s *BacktestStore) LoadConfig(runID string) ([]byte, error) { + var payload []byte + err := s.db.QueryRow(`SELECT config_json FROM backtest_runs WHERE run_id = ?`, runID).Scan(&payload) + return payload, err +} diff --git a/store/beta_code.go b/store/beta_code.go new file mode 100644 index 00000000..dc4f3658 --- /dev/null +++ b/store/beta_code.go @@ -0,0 +1,121 @@ +package store + +import ( + "database/sql" + "fmt" + "nofx/logger" + "os" + "strings" +) + +// BetaCodeStore 内测码存储 +type BetaCodeStore struct { + db *sql.DB +} + +func (s *BetaCodeStore) initTables() error { + _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS beta_codes ( + code TEXT PRIMARY KEY, + used BOOLEAN DEFAULT 0, + used_by TEXT DEFAULT '', + used_at DATETIME DEFAULT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `) + return err +} + +// LoadFromFile 从文件加载内测码 +func (s *BetaCodeStore) LoadFromFile(filePath string) error { + content, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("读取内测码文件失败: %w", err) + } + + lines := strings.Split(string(content), "\n") + var codes []string + for _, line := range lines { + code := strings.TrimSpace(line) + if code != "" && !strings.HasPrefix(code, "#") { + codes = append(codes, code) + } + } + + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("开始事务失败: %w", err) + } + defer tx.Rollback() + + stmt, err := tx.Prepare(`INSERT OR IGNORE INTO beta_codes (code) VALUES (?)`) + if err != nil { + return fmt.Errorf("准备语句失败: %w", err) + } + defer stmt.Close() + + insertedCount := 0 + for _, code := range codes { + result, err := stmt.Exec(code) + if err != nil { + logger.Warnf("插入内测码 %s 失败: %v", code, err) + continue + } + if rowsAffected, _ := result.RowsAffected(); rowsAffected > 0 { + insertedCount++ + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("提交事务失败: %w", err) + } + + logger.Infof("✅ 成功加载 %d 个内测码到数据库 (总计 %d 个)", insertedCount, len(codes)) + return nil +} + +// Validate 验证内测码是否有效 +func (s *BetaCodeStore) Validate(code string) (bool, error) { + var used bool + err := s.db.QueryRow(`SELECT used FROM beta_codes WHERE code = ?`, code).Scan(&used) + if err != nil { + if err == sql.ErrNoRows { + return false, nil + } + return false, err + } + return !used, nil +} + +// Use 使用内测码 +func (s *BetaCodeStore) Use(code, userEmail string) error { + result, err := s.db.Exec(` + UPDATE beta_codes SET used = 1, used_by = ?, used_at = CURRENT_TIMESTAMP + WHERE code = ? AND used = 0 + `, userEmail, code) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + if rowsAffected == 0 { + return fmt.Errorf("内测码无效或已被使用") + } + return nil +} + +// GetStats 获取内测码统计 +func (s *BetaCodeStore) GetStats() (total, used int, err error) { + err = s.db.QueryRow(`SELECT COUNT(*) FROM beta_codes`).Scan(&total) + if err != nil { + return 0, 0, err + } + err = s.db.QueryRow(`SELECT COUNT(*) FROM beta_codes WHERE used = 1`).Scan(&used) + if err != nil { + return 0, 0, err + } + return total, used, nil +} diff --git a/store/decision.go b/store/decision.go new file mode 100644 index 00000000..7758deb0 --- /dev/null +++ b/store/decision.go @@ -0,0 +1,530 @@ +package store + +import ( + "database/sql" + "encoding/json" + "fmt" + "time" +) + +// DecisionStore 决策日志存储 +type DecisionStore struct { + db *sql.DB +} + +// DecisionRecord 决策记录 +type DecisionRecord struct { + ID int64 `json:"id"` + TraderID string `json:"trader_id"` + CycleNumber int `json:"cycle_number"` + Timestamp time.Time `json:"timestamp"` + SystemPrompt string `json:"system_prompt"` + InputPrompt string `json:"input_prompt"` + CoTTrace string `json:"cot_trace"` + DecisionJSON string `json:"decision_json"` + CandidateCoins []string `json:"candidate_coins"` + ExecutionLog []string `json:"execution_log"` + Success bool `json:"success"` + ErrorMessage string `json:"error_message"` + AIRequestDurationMs int64 `json:"ai_request_duration_ms"` + AccountState AccountSnapshot `json:"account_state"` + Positions []PositionSnapshot `json:"positions"` + Decisions []DecisionAction `json:"decisions"` +} + +// AccountSnapshot 账户状态快照 +type AccountSnapshot struct { + TotalBalance float64 `json:"total_balance"` + AvailableBalance float64 `json:"available_balance"` + TotalUnrealizedProfit float64 `json:"total_unrealized_profit"` + PositionCount int `json:"position_count"` + MarginUsedPct float64 `json:"margin_used_pct"` + InitialBalance float64 `json:"initial_balance"` +} + +// PositionSnapshot 持仓快照 +type PositionSnapshot struct { + Symbol string `json:"symbol"` + Side string `json:"side"` + PositionAmt float64 `json:"position_amt"` + EntryPrice float64 `json:"entry_price"` + MarkPrice float64 `json:"mark_price"` + UnrealizedProfit float64 `json:"unrealized_profit"` + Leverage float64 `json:"leverage"` + LiquidationPrice float64 `json:"liquidation_price"` +} + +// DecisionAction 决策动作 +type DecisionAction struct { + Action string `json:"action"` + Symbol string `json:"symbol"` + Quantity float64 `json:"quantity"` + Leverage int `json:"leverage"` + Price float64 `json:"price"` + OrderID int64 `json:"order_id"` + Timestamp time.Time `json:"timestamp"` + Success bool `json:"success"` + Error string `json:"error"` +} + +// Statistics 统计信息 +type Statistics struct { + TotalCycles int `json:"total_cycles"` + SuccessfulCycles int `json:"successful_cycles"` + FailedCycles int `json:"failed_cycles"` + TotalOpenPositions int `json:"total_open_positions"` + TotalClosePositions int `json:"total_close_positions"` +} + +// initTables 初始化决策相关表 +func (s *DecisionStore) initTables() error { + queries := []string{ + // 决策记录主表 + `CREATE TABLE IF NOT EXISTS decision_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trader_id TEXT NOT NULL, + cycle_number INTEGER NOT NULL, + timestamp DATETIME NOT NULL, + system_prompt TEXT DEFAULT '', + input_prompt TEXT DEFAULT '', + cot_trace TEXT DEFAULT '', + decision_json TEXT DEFAULT '', + candidate_coins TEXT DEFAULT '', + execution_log TEXT DEFAULT '', + success BOOLEAN DEFAULT 0, + error_message TEXT DEFAULT '', + ai_request_duration_ms INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + + // 账户状态快照表 + `CREATE TABLE IF NOT EXISTS decision_account_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + decision_id INTEGER NOT NULL, + total_balance REAL DEFAULT 0, + available_balance REAL DEFAULT 0, + total_unrealized_profit REAL DEFAULT 0, + position_count INTEGER DEFAULT 0, + margin_used_pct REAL DEFAULT 0, + initial_balance REAL DEFAULT 0, + FOREIGN KEY (decision_id) REFERENCES decision_records(id) ON DELETE CASCADE + )`, + + // 持仓快照表 + `CREATE TABLE IF NOT EXISTS decision_position_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + decision_id INTEGER NOT NULL, + symbol TEXT NOT NULL, + side TEXT DEFAULT '', + position_amt REAL DEFAULT 0, + entry_price REAL DEFAULT 0, + mark_price REAL DEFAULT 0, + unrealized_profit REAL DEFAULT 0, + leverage REAL DEFAULT 0, + liquidation_price REAL DEFAULT 0, + FOREIGN KEY (decision_id) REFERENCES decision_records(id) ON DELETE CASCADE + )`, + + // 决策动作表(订单详情) + `CREATE TABLE IF NOT EXISTS decision_actions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + decision_id INTEGER NOT NULL, + trader_id TEXT NOT NULL, + action TEXT NOT NULL, + symbol TEXT NOT NULL, + quantity REAL DEFAULT 0, + leverage INTEGER DEFAULT 0, + price REAL DEFAULT 0, + order_id INTEGER DEFAULT 0, + timestamp DATETIME NOT NULL, + success BOOLEAN DEFAULT 0, + error TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (decision_id) REFERENCES decision_records(id) ON DELETE CASCADE + )`, + + // 索引 + `CREATE INDEX IF NOT EXISTS idx_decision_records_trader_time ON decision_records(trader_id, timestamp DESC)`, + `CREATE INDEX IF NOT EXISTS idx_decision_records_timestamp ON decision_records(timestamp DESC)`, + `CREATE INDEX IF NOT EXISTS idx_decision_actions_trader ON decision_actions(trader_id, timestamp DESC)`, + `CREATE INDEX IF NOT EXISTS idx_decision_actions_symbol ON decision_actions(symbol, timestamp DESC)`, + } + + for _, query := range queries { + if _, err := s.db.Exec(query); err != nil { + return fmt.Errorf("执行SQL失败: %w", err) + } + } + + return nil +} + +// LogDecision 记录决策 +func (s *DecisionStore) LogDecision(record *DecisionRecord) error { + if record.Timestamp.IsZero() { + record.Timestamp = time.Now().UTC() + } else { + record.Timestamp = record.Timestamp.UTC() + } + + // 开始事务 + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("开始事务失败: %w", err) + } + defer tx.Rollback() + + // 序列化候选币种和执行日志为 JSON + candidateCoinsJSON, _ := json.Marshal(record.CandidateCoins) + executionLogJSON, _ := json.Marshal(record.ExecutionLog) + + // 插入决策记录主表 + result, err := tx.Exec(` + INSERT INTO decision_records ( + trader_id, cycle_number, timestamp, system_prompt, input_prompt, + cot_trace, decision_json, candidate_coins, execution_log, + success, error_message, ai_request_duration_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + record.TraderID, record.CycleNumber, record.Timestamp.Format(time.RFC3339), + record.SystemPrompt, record.InputPrompt, record.CoTTrace, record.DecisionJSON, + string(candidateCoinsJSON), string(executionLogJSON), + record.Success, record.ErrorMessage, record.AIRequestDurationMs, + ) + if err != nil { + return fmt.Errorf("插入决策记录失败: %w", err) + } + + decisionID, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("获取决策ID失败: %w", err) + } + record.ID = decisionID + + // 插入账户状态快照 + _, err = tx.Exec(` + INSERT INTO decision_account_snapshots ( + decision_id, total_balance, available_balance, total_unrealized_profit, + position_count, margin_used_pct, initial_balance + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `, + decisionID, record.AccountState.TotalBalance, record.AccountState.AvailableBalance, + record.AccountState.TotalUnrealizedProfit, record.AccountState.PositionCount, + record.AccountState.MarginUsedPct, record.AccountState.InitialBalance, + ) + if err != nil { + return fmt.Errorf("插入账户快照失败: %w", err) + } + + // 插入持仓快照 + for _, pos := range record.Positions { + _, err = tx.Exec(` + INSERT INTO decision_position_snapshots ( + decision_id, symbol, side, position_amt, entry_price, + mark_price, unrealized_profit, leverage, liquidation_price + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + decisionID, pos.Symbol, pos.Side, pos.PositionAmt, pos.EntryPrice, + pos.MarkPrice, pos.UnrealizedProfit, pos.Leverage, pos.LiquidationPrice, + ) + if err != nil { + return fmt.Errorf("插入持仓快照失败: %w", err) + } + } + + // 插入决策动作(订单详情) + for _, action := range record.Decisions { + actionTimestamp := action.Timestamp + if actionTimestamp.IsZero() { + actionTimestamp = record.Timestamp + } + _, err = tx.Exec(` + INSERT INTO decision_actions ( + decision_id, trader_id, action, symbol, quantity, leverage, + price, order_id, timestamp, success, error + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + decisionID, record.TraderID, action.Action, action.Symbol, action.Quantity, + action.Leverage, action.Price, action.OrderID, + actionTimestamp.Format(time.RFC3339), action.Success, action.Error, + ) + if err != nil { + return fmt.Errorf("插入决策动作失败: %w", err) + } + } + + // 提交事务 + if err := tx.Commit(); err != nil { + return fmt.Errorf("提交事务失败: %w", err) + } + + return nil +} + +// GetLatestRecords 获取指定交易员最近N条记录(按时间正序:从旧到新) +func (s *DecisionStore) GetLatestRecords(traderID string, n int) ([]*DecisionRecord, error) { + rows, err := s.db.Query(` + SELECT id, trader_id, cycle_number, timestamp, system_prompt, input_prompt, + cot_trace, decision_json, candidate_coins, execution_log, + success, error_message, ai_request_duration_ms + FROM decision_records + WHERE trader_id = ? + ORDER BY timestamp DESC + LIMIT ? + `, traderID, n) + if err != nil { + return nil, fmt.Errorf("查询决策记录失败: %w", err) + } + defer rows.Close() + + var records []*DecisionRecord + for rows.Next() { + record, err := s.scanDecisionRecord(rows) + if err != nil { + continue + } + records = append(records, record) + } + + // 填充关联数据 + for _, record := range records { + s.fillRecordDetails(record) + } + + // 反转数组,让时间从旧到新排列 + for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 { + records[i], records[j] = records[j], records[i] + } + + return records, nil +} + +// GetAllLatestRecords 获取所有交易员最近N条记录 +func (s *DecisionStore) GetAllLatestRecords(n int) ([]*DecisionRecord, error) { + rows, err := s.db.Query(` + SELECT id, trader_id, cycle_number, timestamp, system_prompt, input_prompt, + cot_trace, decision_json, candidate_coins, execution_log, + success, error_message, ai_request_duration_ms + FROM decision_records + ORDER BY timestamp DESC + LIMIT ? + `, n) + if err != nil { + return nil, fmt.Errorf("查询决策记录失败: %w", err) + } + defer rows.Close() + + var records []*DecisionRecord + for rows.Next() { + record, err := s.scanDecisionRecord(rows) + if err != nil { + continue + } + records = append(records, record) + } + + // 反转数组 + for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 { + records[i], records[j] = records[j], records[i] + } + + return records, nil +} + +// GetRecordsByDate 获取指定交易员指定日期的所有记录 +func (s *DecisionStore) GetRecordsByDate(traderID string, date time.Time) ([]*DecisionRecord, error) { + dateStr := date.Format("2006-01-02") + + rows, err := s.db.Query(` + SELECT id, trader_id, cycle_number, timestamp, system_prompt, input_prompt, + cot_trace, decision_json, candidate_coins, execution_log, + success, error_message, ai_request_duration_ms + FROM decision_records + WHERE trader_id = ? AND DATE(timestamp) = ? + ORDER BY timestamp ASC + `, traderID, dateStr) + if err != nil { + return nil, fmt.Errorf("查询决策记录失败: %w", err) + } + defer rows.Close() + + var records []*DecisionRecord + for rows.Next() { + record, err := s.scanDecisionRecord(rows) + if err != nil { + continue + } + records = append(records, record) + } + + return records, nil +} + +// CleanOldRecords 清理N天前的旧记录 +func (s *DecisionStore) CleanOldRecords(traderID string, days int) (int64, error) { + cutoffTime := time.Now().AddDate(0, 0, -days).Format(time.RFC3339) + + result, err := s.db.Exec(` + DELETE FROM decision_records + WHERE trader_id = ? AND timestamp < ? + `, traderID, cutoffTime) + if err != nil { + return 0, fmt.Errorf("清理旧记录失败: %w", err) + } + + return result.RowsAffected() +} + +// GetStatistics 获取指定交易员的统计信息 +func (s *DecisionStore) GetStatistics(traderID string) (*Statistics, error) { + stats := &Statistics{} + + err := s.db.QueryRow(` + SELECT COUNT(*) FROM decision_records WHERE trader_id = ? + `, traderID).Scan(&stats.TotalCycles) + if err != nil { + return nil, fmt.Errorf("查询总周期数失败: %w", err) + } + + err = s.db.QueryRow(` + SELECT COUNT(*) FROM decision_records WHERE trader_id = ? AND success = 1 + `, traderID).Scan(&stats.SuccessfulCycles) + if err != nil { + return nil, fmt.Errorf("查询成功周期数失败: %w", err) + } + stats.FailedCycles = stats.TotalCycles - stats.SuccessfulCycles + + err = s.db.QueryRow(` + SELECT COUNT(*) FROM decision_actions + WHERE trader_id = ? AND success = 1 AND action IN ('open_long', 'open_short') + `, traderID).Scan(&stats.TotalOpenPositions) + if err != nil { + return nil, fmt.Errorf("查询开仓次数失败: %w", err) + } + + err = s.db.QueryRow(` + SELECT COUNT(*) FROM decision_actions + WHERE trader_id = ? AND success = 1 AND action IN ('close_long', 'close_short', 'auto_close_long', 'auto_close_short') + `, traderID).Scan(&stats.TotalClosePositions) + if err != nil { + return nil, fmt.Errorf("查询平仓次数失败: %w", err) + } + + return stats, nil +} + +// GetAllStatistics 获取所有交易员的统计信息 +func (s *DecisionStore) GetAllStatistics() (*Statistics, error) { + stats := &Statistics{} + + s.db.QueryRow(`SELECT COUNT(*) FROM decision_records`).Scan(&stats.TotalCycles) + s.db.QueryRow(`SELECT COUNT(*) FROM decision_records WHERE success = 1`).Scan(&stats.SuccessfulCycles) + stats.FailedCycles = stats.TotalCycles - stats.SuccessfulCycles + + s.db.QueryRow(` + SELECT COUNT(*) FROM decision_actions + WHERE success = 1 AND action IN ('open_long', 'open_short') + `).Scan(&stats.TotalOpenPositions) + + s.db.QueryRow(` + SELECT COUNT(*) FROM decision_actions + WHERE success = 1 AND action IN ('close_long', 'close_short', 'auto_close_long', 'auto_close_short') + `).Scan(&stats.TotalClosePositions) + + return stats, nil +} + +// GetLastCycleNumber 获取指定交易员的最后周期编号 +func (s *DecisionStore) GetLastCycleNumber(traderID string) (int, error) { + var cycleNumber int + err := s.db.QueryRow(` + SELECT COALESCE(MAX(cycle_number), 0) FROM decision_records WHERE trader_id = ? + `, traderID).Scan(&cycleNumber) + if err != nil { + return 0, err + } + return cycleNumber, nil +} + +// scanDecisionRecord 从行中扫描决策记录 +func (s *DecisionStore) scanDecisionRecord(rows *sql.Rows) (*DecisionRecord, error) { + var record DecisionRecord + var timestampStr string + var candidateCoinsJSON, executionLogJSON string + + err := rows.Scan( + &record.ID, &record.TraderID, &record.CycleNumber, ×tampStr, + &record.SystemPrompt, &record.InputPrompt, &record.CoTTrace, + &record.DecisionJSON, &candidateCoinsJSON, &executionLogJSON, + &record.Success, &record.ErrorMessage, &record.AIRequestDurationMs, + ) + if err != nil { + return nil, err + } + + record.Timestamp, _ = time.Parse(time.RFC3339, timestampStr) + json.Unmarshal([]byte(candidateCoinsJSON), &record.CandidateCoins) + json.Unmarshal([]byte(executionLogJSON), &record.ExecutionLog) + + return &record, nil +} + +// fillRecordDetails 填充决策记录的关联数据 +func (s *DecisionStore) fillRecordDetails(record *DecisionRecord) { + // 查询账户状态 + s.db.QueryRow(` + SELECT total_balance, available_balance, total_unrealized_profit, + position_count, margin_used_pct, initial_balance + FROM decision_account_snapshots + WHERE decision_id = ? + `, record.ID).Scan( + &record.AccountState.TotalBalance, + &record.AccountState.AvailableBalance, + &record.AccountState.TotalUnrealizedProfit, + &record.AccountState.PositionCount, + &record.AccountState.MarginUsedPct, + &record.AccountState.InitialBalance, + ) + + // 查询持仓快照 + posRows, err := s.db.Query(` + SELECT symbol, side, position_amt, entry_price, mark_price, + unrealized_profit, leverage, liquidation_price + FROM decision_position_snapshots + WHERE decision_id = ? + `, record.ID) + if err == nil { + defer posRows.Close() + for posRows.Next() { + var pos PositionSnapshot + posRows.Scan( + &pos.Symbol, &pos.Side, &pos.PositionAmt, &pos.EntryPrice, + &pos.MarkPrice, &pos.UnrealizedProfit, &pos.Leverage, + &pos.LiquidationPrice, + ) + record.Positions = append(record.Positions, pos) + } + } + + // 查询决策动作 + actionRows, err := s.db.Query(` + SELECT action, symbol, quantity, leverage, price, order_id, + timestamp, success, error + FROM decision_actions + WHERE decision_id = ? + `, record.ID) + if err == nil { + defer actionRows.Close() + for actionRows.Next() { + var action DecisionAction + var timestampStr string + actionRows.Scan( + &action.Action, &action.Symbol, &action.Quantity, + &action.Leverage, &action.Price, &action.OrderID, + ×tampStr, &action.Success, &action.Error, + ) + action.Timestamp, _ = time.Parse(time.RFC3339, timestampStr) + record.Decisions = append(record.Decisions, action) + } + } +} diff --git a/store/exchange.go b/store/exchange.go new file mode 100644 index 00000000..ee532c1b --- /dev/null +++ b/store/exchange.go @@ -0,0 +1,245 @@ +package store + +import ( + "database/sql" + "fmt" + "nofx/logger" + "strings" + "time" +) + +// ExchangeStore 交易所存储 +type ExchangeStore struct { + db *sql.DB + encryptFunc func(string) string + decryptFunc func(string) string +} + +// Exchange 交易所配置 +type Exchange struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Name string `json:"name"` + Type string `json:"type"` + Enabled bool `json:"enabled"` + APIKey string `json:"apiKey"` + SecretKey string `json:"secretKey"` + Testnet bool `json:"testnet"` + HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` + AsterUser string `json:"asterUser"` + AsterSigner string `json:"asterSigner"` + AsterPrivateKey string `json:"asterPrivateKey"` + LighterWalletAddr string `json:"lighterWalletAddr"` + LighterPrivateKey string `json:"lighterPrivateKey"` + LighterAPIKeyPrivateKey string `json:"lighterAPIKeyPrivateKey"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (s *ExchangeStore) initTables() error { + _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS exchanges ( + id TEXT NOT NULL, + user_id TEXT NOT NULL DEFAULT 'default', + name TEXT NOT NULL, + type TEXT NOT NULL, + enabled BOOLEAN DEFAULT 0, + api_key TEXT DEFAULT '', + secret_key TEXT DEFAULT '', + testnet BOOLEAN DEFAULT 0, + hyperliquid_wallet_addr TEXT DEFAULT '', + aster_user TEXT DEFAULT '', + aster_signer TEXT DEFAULT '', + aster_private_key TEXT DEFAULT '', + lighter_wallet_addr TEXT DEFAULT '', + lighter_private_key TEXT DEFAULT '', + lighter_api_key_private_key TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id, user_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `) + if err != nil { + return err + } + + // 触发器 + _, err = s.db.Exec(` + CREATE TRIGGER IF NOT EXISTS update_exchanges_updated_at + AFTER UPDATE ON exchanges + BEGIN + UPDATE exchanges SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id AND user_id = NEW.user_id; + END + `) + return err +} + +func (s *ExchangeStore) initDefaultData() error { + exchanges := []struct { + id, name, typ string + }{ + {"binance", "Binance Futures", "binance"}, + {"bybit", "Bybit Futures", "bybit"}, + {"hyperliquid", "Hyperliquid", "hyperliquid"}, + {"aster", "Aster DEX", "aster"}, + {"lighter", "LIGHTER DEX", "lighter"}, + } + + for _, exchange := range exchanges { + _, err := s.db.Exec(` + INSERT OR IGNORE INTO exchanges (id, user_id, name, type, enabled) + VALUES (?, 'default', ?, ?, 0) + `, exchange.id, exchange.name, exchange.typ) + if err != nil { + return fmt.Errorf("初始化交易所失败: %w", err) + } + } + return nil +} + +func (s *ExchangeStore) encrypt(plaintext string) string { + if s.encryptFunc != nil { + return s.encryptFunc(plaintext) + } + return plaintext +} + +func (s *ExchangeStore) decrypt(encrypted string) string { + if s.decryptFunc != nil { + return s.decryptFunc(encrypted) + } + return encrypted +} + +// List 获取用户的交易所列表 +func (s *ExchangeStore) List(userID string) ([]*Exchange, error) { + rows, err := s.db.Query(` + SELECT id, user_id, name, type, enabled, api_key, secret_key, testnet, + COALESCE(hyperliquid_wallet_addr, '') as hyperliquid_wallet_addr, + COALESCE(aster_user, '') as aster_user, + COALESCE(aster_signer, '') as aster_signer, + COALESCE(aster_private_key, '') as aster_private_key, + COALESCE(lighter_wallet_addr, '') as lighter_wallet_addr, + COALESCE(lighter_private_key, '') as lighter_private_key, + COALESCE(lighter_api_key_private_key, '') as lighter_api_key_private_key, + created_at, updated_at + FROM exchanges WHERE user_id = ? ORDER BY id + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + exchanges := make([]*Exchange, 0) + for rows.Next() { + var e Exchange + var createdAt, updatedAt string + err := rows.Scan( + &e.ID, &e.UserID, &e.Name, &e.Type, + &e.Enabled, &e.APIKey, &e.SecretKey, &e.Testnet, + &e.HyperliquidWalletAddr, &e.AsterUser, &e.AsterSigner, &e.AsterPrivateKey, + &e.LighterWalletAddr, &e.LighterPrivateKey, &e.LighterAPIKeyPrivateKey, + &createdAt, &updatedAt, + ) + if err != nil { + return nil, err + } + e.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + e.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + e.APIKey = s.decrypt(e.APIKey) + e.SecretKey = s.decrypt(e.SecretKey) + e.AsterPrivateKey = s.decrypt(e.AsterPrivateKey) + e.LighterPrivateKey = s.decrypt(e.LighterPrivateKey) + e.LighterAPIKeyPrivateKey = s.decrypt(e.LighterAPIKeyPrivateKey) + exchanges = append(exchanges, &e) + } + return exchanges, nil +} + +// Update 更新交易所配置 +func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKey string, testnet bool, + hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey string) error { + + logger.Debugf("🔧 ExchangeStore.Update: userID=%s, id=%s, enabled=%v", userID, id, enabled) + + setClauses := []string{ + "enabled = ?", + "testnet = ?", + "hyperliquid_wallet_addr = ?", + "aster_user = ?", + "aster_signer = ?", + "lighter_wallet_addr = ?", + "updated_at = datetime('now')", + } + args := []interface{}{enabled, testnet, hyperliquidWalletAddr, asterUser, asterSigner, lighterWalletAddr} + + if apiKey != "" { + setClauses = append(setClauses, "api_key = ?") + args = append(args, s.encrypt(apiKey)) + } + if secretKey != "" { + setClauses = append(setClauses, "secret_key = ?") + args = append(args, s.encrypt(secretKey)) + } + if asterPrivateKey != "" { + setClauses = append(setClauses, "aster_private_key = ?") + args = append(args, s.encrypt(asterPrivateKey)) + } + if lighterPrivateKey != "" { + setClauses = append(setClauses, "lighter_private_key = ?") + args = append(args, s.encrypt(lighterPrivateKey)) + } + + args = append(args, id, userID) + query := fmt.Sprintf(`UPDATE exchanges SET %s WHERE id = ? AND user_id = ?`, strings.Join(setClauses, ", ")) + + result, err := s.db.Exec(query, args...) + if err != nil { + return err + } + + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + // 创建新记录 + var name, typ string + switch id { + case "binance": + name, typ = "Binance Futures", "cex" + case "bybit": + name, typ = "Bybit Futures", "cex" + case "hyperliquid": + name, typ = "Hyperliquid", "dex" + case "aster": + name, typ = "Aster DEX", "dex" + case "lighter": + name, typ = "LIGHTER DEX", "dex" + default: + name, typ = id+" Exchange", "cex" + } + + _, err = s.db.Exec(` + INSERT INTO exchanges (id, user_id, name, type, enabled, api_key, secret_key, testnet, + hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key, + lighter_wallet_addr, lighter_private_key, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + `, id, userID, name, typ, enabled, s.encrypt(apiKey), s.encrypt(secretKey), testnet, + hyperliquidWalletAddr, asterUser, asterSigner, s.encrypt(asterPrivateKey), + lighterWalletAddr, s.encrypt(lighterPrivateKey)) + return err + } + return nil +} + +// Create 创建交易所配置 +func (s *ExchangeStore) Create(userID, id, name, typ string, enabled bool, apiKey, secretKey string, testnet bool, + hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey string) error { + _, err := s.db.Exec(` + INSERT OR IGNORE INTO exchanges (id, user_id, name, type, enabled, api_key, secret_key, testnet, + hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key, + lighter_wallet_addr, lighter_private_key) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', '') + `, id, userID, name, typ, enabled, s.encrypt(apiKey), s.encrypt(secretKey), testnet, + hyperliquidWalletAddr, asterUser, asterSigner, s.encrypt(asterPrivateKey)) + return err +} diff --git a/store/order.go b/store/order.go new file mode 100644 index 00000000..68649b47 --- /dev/null +++ b/store/order.go @@ -0,0 +1,511 @@ +package store + +import ( + "database/sql" + "fmt" + "math" + "time" +) + +// TraderOrder 交易员订单记录 +type TraderOrder struct { + ID int64 `json:"id"` + TraderID string `json:"trader_id"` // 交易员ID + OrderID string `json:"order_id"` // 交易所订单ID + ClientOrderID string `json:"client_order_id"` // 客户端订单ID + Symbol string `json:"symbol"` // 交易对 + Side string `json:"side"` // BUY/SELL + PositionSide string `json:"position_side"` // LONG/SHORT/BOTH + Action string `json:"action"` // open_long/close_long/open_short/close_short + OrderType string `json:"order_type"` // MARKET/LIMIT + Quantity float64 `json:"quantity"` // 订单数量 + Price float64 `json:"price"` // 订单价格 + AvgPrice float64 `json:"avg_price"` // 实际成交均价 + ExecutedQty float64 `json:"executed_qty"` // 已成交数量 + Leverage int `json:"leverage"` // 杠杆倍数 + Status string `json:"status"` // NEW/FILLED/CANCELED/EXPIRED + Fee float64 `json:"fee"` // 手续费 + FeeAsset string `json:"fee_asset"` // 手续费资产 + RealizedPnL float64 `json:"realized_pnl"` // 已实现盈亏(平仓时) + EntryPrice float64 `json:"entry_price"` // 开仓价(平仓时记录) + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + FilledAt time.Time `json:"filled_at"` // 成交时间 +} + +// TraderStats 交易统计指标 +type TraderStats struct { + TotalTrades int `json:"total_trades"` // 总交易数(已平仓) + WinTrades int `json:"win_trades"` // 盈利交易数 + LossTrades int `json:"loss_trades"` // 亏损交易数 + WinRate float64 `json:"win_rate"` // 胜率 (%) + ProfitFactor float64 `json:"profit_factor"` // 盈亏比 + SharpeRatio float64 `json:"sharpe_ratio"` // 夏普比 + TotalPnL float64 `json:"total_pnl"` // 总盈亏 + TotalFee float64 `json:"total_fee"` // 总手续费 + AvgWin float64 `json:"avg_win"` // 平均盈利 + AvgLoss float64 `json:"avg_loss"` // 平均亏损 + MaxDrawdownPct float64 `json:"max_drawdown_pct"` // 最大回撤 (%) +} + +// CompletedOrder 已完成订单(用于AI输入) +type CompletedOrder struct { + Symbol string `json:"symbol"` // 交易对 + Action string `json:"action"` // close_long/close_short + Side string `json:"side"` // long/short + Quantity float64 `json:"quantity"` // 数量 + EntryPrice float64 `json:"entry_price"` // 开仓价 + ExitPrice float64 `json:"exit_price"` // 平仓价 + RealizedPnL float64 `json:"realized_pnl"` // 已实现盈亏 + PnLPct float64 `json:"pnl_pct"` // 盈亏百分比 + Fee float64 `json:"fee"` // 手续费 + Leverage int `json:"leverage"` // 杠杆 + FilledAt time.Time `json:"filled_at"` // 成交时间 +} + +// OrderStore 订单存储 +type OrderStore struct { + db *sql.DB +} + +// NewOrderStore 创建订单存储实例 +func NewOrderStore(db *sql.DB) *OrderStore { + return &OrderStore{db: db} +} + +// InitTables 初始化订单表 +func (s *OrderStore) InitTables() error { + _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS trader_orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trader_id TEXT NOT NULL, + order_id TEXT NOT NULL, + client_order_id TEXT DEFAULT '', + symbol TEXT NOT NULL, + side TEXT NOT NULL, + position_side TEXT DEFAULT '', + action TEXT NOT NULL, + order_type TEXT DEFAULT 'MARKET', + quantity REAL NOT NULL, + price REAL DEFAULT 0, + avg_price REAL DEFAULT 0, + executed_qty REAL DEFAULT 0, + leverage INTEGER DEFAULT 1, + status TEXT DEFAULT 'NEW', + fee REAL DEFAULT 0, + fee_asset TEXT DEFAULT 'USDT', + realized_pnl REAL DEFAULT 0, + entry_price REAL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + filled_at DATETIME, + UNIQUE(trader_id, order_id) + ) + `) + if err != nil { + return fmt.Errorf("创建trader_orders表失败: %w", err) + } + + // 创建索引 + indices := []string{ + `CREATE INDEX IF NOT EXISTS idx_trader_orders_trader ON trader_orders(trader_id)`, + `CREATE INDEX IF NOT EXISTS idx_trader_orders_status ON trader_orders(trader_id, status)`, + `CREATE INDEX IF NOT EXISTS idx_trader_orders_symbol ON trader_orders(trader_id, symbol)`, + `CREATE INDEX IF NOT EXISTS idx_trader_orders_filled ON trader_orders(trader_id, filled_at DESC)`, + } + for _, idx := range indices { + if _, err := s.db.Exec(idx); err != nil { + return fmt.Errorf("创建索引失败: %w", err) + } + } + + return nil +} + +// Create 创建订单记录 +func (s *OrderStore) Create(order *TraderOrder) error { + now := time.Now().Format(time.RFC3339) + result, err := s.db.Exec(` + INSERT INTO trader_orders ( + trader_id, order_id, client_order_id, symbol, side, position_side, + action, order_type, quantity, price, avg_price, executed_qty, + leverage, status, fee, fee_asset, realized_pnl, entry_price, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + order.TraderID, order.OrderID, order.ClientOrderID, order.Symbol, + order.Side, order.PositionSide, order.Action, order.OrderType, + order.Quantity, order.Price, order.AvgPrice, order.ExecutedQty, + order.Leverage, order.Status, order.Fee, order.FeeAsset, + order.RealizedPnL, order.EntryPrice, now, now, + ) + if err != nil { + return fmt.Errorf("创建订单记录失败: %w", err) + } + + id, _ := result.LastInsertId() + order.ID = id + return nil +} + +// Update 更新订单记录 +func (s *OrderStore) Update(order *TraderOrder) error { + now := time.Now().Format(time.RFC3339) + filledAt := "" + if !order.FilledAt.IsZero() { + filledAt = order.FilledAt.Format(time.RFC3339) + } + + _, err := s.db.Exec(` + UPDATE trader_orders SET + avg_price = ?, executed_qty = ?, status = ?, fee = ?, + realized_pnl = ?, entry_price = ?, updated_at = ?, filled_at = ? + WHERE trader_id = ? AND order_id = ? + `, + order.AvgPrice, order.ExecutedQty, order.Status, order.Fee, + order.RealizedPnL, order.EntryPrice, now, filledAt, + order.TraderID, order.OrderID, + ) + if err != nil { + return fmt.Errorf("更新订单记录失败: %w", err) + } + return nil +} + +// GetByOrderID 根据订单ID获取订单 +func (s *OrderStore) GetByOrderID(traderID, orderID string) (*TraderOrder, error) { + var order TraderOrder + var createdAt, updatedAt, filledAt sql.NullString + + err := s.db.QueryRow(` + SELECT id, trader_id, order_id, client_order_id, symbol, side, position_side, + action, order_type, quantity, price, avg_price, executed_qty, + leverage, status, fee, fee_asset, realized_pnl, entry_price, + created_at, updated_at, filled_at + FROM trader_orders WHERE trader_id = ? AND order_id = ? + `, traderID, orderID).Scan( + &order.ID, &order.TraderID, &order.OrderID, &order.ClientOrderID, + &order.Symbol, &order.Side, &order.PositionSide, &order.Action, + &order.OrderType, &order.Quantity, &order.Price, &order.AvgPrice, + &order.ExecutedQty, &order.Leverage, &order.Status, &order.Fee, + &order.FeeAsset, &order.RealizedPnL, &order.EntryPrice, + &createdAt, &updatedAt, &filledAt, + ) + if err != nil { + return nil, err + } + + if createdAt.Valid { + order.CreatedAt, _ = time.Parse(time.RFC3339, createdAt.String) + } + if updatedAt.Valid { + order.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt.String) + } + if filledAt.Valid { + order.FilledAt, _ = time.Parse(time.RFC3339, filledAt.String) + } + + return &order, nil +} + +// GetLatestOpenOrder 获取某币种最近的开仓订单(用于计算平仓盈亏) +func (s *OrderStore) GetLatestOpenOrder(traderID, symbol, side string) (*TraderOrder, error) { + // side: long -> 找 open_long, short -> 找 open_short + action := "open_long" + if side == "short" { + action = "open_short" + } + + var order TraderOrder + var createdAt, updatedAt, filledAt sql.NullString + + err := s.db.QueryRow(` + SELECT id, trader_id, order_id, client_order_id, symbol, side, position_side, + action, order_type, quantity, price, avg_price, executed_qty, + leverage, status, fee, fee_asset, realized_pnl, entry_price, + created_at, updated_at, filled_at + FROM trader_orders + WHERE trader_id = ? AND symbol = ? AND action = ? AND status = 'FILLED' + ORDER BY filled_at DESC LIMIT 1 + `, traderID, symbol, action).Scan( + &order.ID, &order.TraderID, &order.OrderID, &order.ClientOrderID, + &order.Symbol, &order.Side, &order.PositionSide, &order.Action, + &order.OrderType, &order.Quantity, &order.Price, &order.AvgPrice, + &order.ExecutedQty, &order.Leverage, &order.Status, &order.Fee, + &order.FeeAsset, &order.RealizedPnL, &order.EntryPrice, + &createdAt, &updatedAt, &filledAt, + ) + if err != nil { + return nil, err + } + + if createdAt.Valid { + order.CreatedAt, _ = time.Parse(time.RFC3339, createdAt.String) + } + if updatedAt.Valid { + order.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt.String) + } + if filledAt.Valid { + order.FilledAt, _ = time.Parse(time.RFC3339, filledAt.String) + } + + return &order, nil +} + +// GetRecentCompletedOrders 获取最近已完成的平仓订单 +func (s *OrderStore) GetRecentCompletedOrders(traderID string, limit int) ([]CompletedOrder, error) { + rows, err := s.db.Query(` + SELECT symbol, action, side, executed_qty, entry_price, avg_price, + realized_pnl, fee, leverage, filled_at + FROM trader_orders + WHERE trader_id = ? AND status = 'FILLED' + AND (action = 'close_long' OR action = 'close_short') + ORDER BY filled_at DESC + LIMIT ? + `, traderID, limit) + if err != nil { + return nil, fmt.Errorf("查询已完成订单失败: %w", err) + } + defer rows.Close() + + var orders []CompletedOrder + for rows.Next() { + var o CompletedOrder + var filledAt sql.NullString + var side sql.NullString + + err := rows.Scan( + &o.Symbol, &o.Action, &side, &o.Quantity, &o.EntryPrice, &o.ExitPrice, + &o.RealizedPnL, &o.Fee, &o.Leverage, &filledAt, + ) + if err != nil { + continue + } + + // 根据action推断side + if o.Action == "close_long" { + o.Side = "long" + } else if o.Action == "close_short" { + o.Side = "short" + } else if side.Valid { + o.Side = side.String + } + + // 计算盈亏百分比 + if o.EntryPrice > 0 { + if o.Side == "long" { + o.PnLPct = (o.ExitPrice - o.EntryPrice) / o.EntryPrice * 100 * float64(o.Leverage) + } else { + o.PnLPct = (o.EntryPrice - o.ExitPrice) / o.EntryPrice * 100 * float64(o.Leverage) + } + } + + if filledAt.Valid { + o.FilledAt, _ = time.Parse(time.RFC3339, filledAt.String) + } + + orders = append(orders, o) + } + + return orders, nil +} + +// GetTraderStats 获取交易统计指标 +func (s *OrderStore) GetTraderStats(traderID string) (*TraderStats, error) { + stats := &TraderStats{} + + // 查询所有已完成的平仓订单 + rows, err := s.db.Query(` + SELECT realized_pnl, fee, filled_at + FROM trader_orders + WHERE trader_id = ? AND status = 'FILLED' + AND (action = 'close_long' OR action = 'close_short') + ORDER BY filled_at ASC + `, traderID) + if err != nil { + return nil, fmt.Errorf("查询订单统计失败: %w", err) + } + defer rows.Close() + + var pnls []float64 + var totalWin, totalLoss float64 + + for rows.Next() { + var pnl, fee float64 + var filledAt sql.NullString + if err := rows.Scan(&pnl, &fee, &filledAt); err != nil { + continue + } + + stats.TotalTrades++ + stats.TotalPnL += pnl + stats.TotalFee += fee + pnls = append(pnls, pnl) + + if pnl > 0 { + stats.WinTrades++ + totalWin += pnl + } else if pnl < 0 { + stats.LossTrades++ + totalLoss += math.Abs(pnl) + } + } + + // 计算胜率 + if stats.TotalTrades > 0 { + stats.WinRate = float64(stats.WinTrades) / float64(stats.TotalTrades) * 100 + } + + // 计算盈亏比 + if totalLoss > 0 { + stats.ProfitFactor = totalWin / totalLoss + } + + // 计算平均盈亏 + if stats.WinTrades > 0 { + stats.AvgWin = totalWin / float64(stats.WinTrades) + } + if stats.LossTrades > 0 { + stats.AvgLoss = totalLoss / float64(stats.LossTrades) + } + + // 计算夏普比(使用盈亏序列) + if len(pnls) > 1 { + stats.SharpeRatio = calculateSharpeRatio(pnls) + } + + // 计算最大回撤 + if len(pnls) > 0 { + stats.MaxDrawdownPct = calculateMaxDrawdown(pnls) + } + + return stats, nil +} + +// calculateSharpeRatio 计算夏普比 +func calculateSharpeRatio(pnls []float64) float64 { + if len(pnls) < 2 { + return 0 + } + + // 计算平均收益 + var sum float64 + for _, pnl := range pnls { + sum += pnl + } + mean := sum / float64(len(pnls)) + + // 计算标准差 + var variance float64 + for _, pnl := range pnls { + variance += (pnl - mean) * (pnl - mean) + } + stdDev := math.Sqrt(variance / float64(len(pnls)-1)) + + if stdDev == 0 { + return 0 + } + + // 夏普比 = 平均收益 / 标准差 + return mean / stdDev +} + +// calculateMaxDrawdown 计算最大回撤 +func calculateMaxDrawdown(pnls []float64) float64 { + if len(pnls) == 0 { + return 0 + } + + // 计算累计权益曲线 + var cumulative float64 + var peak float64 + var maxDD float64 + + for _, pnl := range pnls { + cumulative += pnl + if cumulative > peak { + peak = cumulative + } + if peak > 0 { + dd := (peak - cumulative) / peak * 100 + if dd > maxDD { + maxDD = dd + } + } + } + + return maxDD +} + +// GetPendingOrders 获取未成交的订单(用于轮询) +func (s *OrderStore) GetPendingOrders(traderID string) ([]*TraderOrder, error) { + rows, err := s.db.Query(` + SELECT id, trader_id, order_id, client_order_id, symbol, side, position_side, + action, order_type, quantity, price, avg_price, executed_qty, + leverage, status, fee, fee_asset, realized_pnl, entry_price, + created_at, updated_at, filled_at + FROM trader_orders + WHERE trader_id = ? AND status = 'NEW' + ORDER BY created_at ASC + `, traderID) + if err != nil { + return nil, fmt.Errorf("查询未成交订单失败: %w", err) + } + defer rows.Close() + + return s.scanOrders(rows) +} + +// GetAllPendingOrders 获取所有未成交的订单(用于全局同步) +func (s *OrderStore) GetAllPendingOrders() ([]*TraderOrder, error) { + rows, err := s.db.Query(` + SELECT id, trader_id, order_id, client_order_id, symbol, side, position_side, + action, order_type, quantity, price, avg_price, executed_qty, + leverage, status, fee, fee_asset, realized_pnl, entry_price, + created_at, updated_at, filled_at + FROM trader_orders + WHERE status = 'NEW' + ORDER BY trader_id, created_at ASC + `) + if err != nil { + return nil, fmt.Errorf("查询未成交订单失败: %w", err) + } + defer rows.Close() + + return s.scanOrders(rows) +} + +// scanOrders 扫描订单行到结构体 +func (s *OrderStore) scanOrders(rows *sql.Rows) ([]*TraderOrder, error) { + var orders []*TraderOrder + for rows.Next() { + var order TraderOrder + var createdAt, updatedAt, filledAt sql.NullString + + err := rows.Scan( + &order.ID, &order.TraderID, &order.OrderID, &order.ClientOrderID, + &order.Symbol, &order.Side, &order.PositionSide, &order.Action, + &order.OrderType, &order.Quantity, &order.Price, &order.AvgPrice, + &order.ExecutedQty, &order.Leverage, &order.Status, &order.Fee, + &order.FeeAsset, &order.RealizedPnL, &order.EntryPrice, + &createdAt, &updatedAt, &filledAt, + ) + if err != nil { + continue + } + + if createdAt.Valid { + order.CreatedAt, _ = time.Parse(time.RFC3339, createdAt.String) + } + if updatedAt.Valid { + order.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt.String) + } + if filledAt.Valid { + order.FilledAt, _ = time.Parse(time.RFC3339, filledAt.String) + } + + orders = append(orders, &order) + } + + return orders, nil +} diff --git a/store/position.go b/store/position.go new file mode 100644 index 00000000..4d7310e7 --- /dev/null +++ b/store/position.go @@ -0,0 +1,473 @@ +package store + +import ( + "database/sql" + "fmt" + "math" + "time" +) + +// TraderPosition 仓位记录(完整的开平仓追踪) +type TraderPosition struct { + ID int64 `json:"id"` + TraderID string `json:"trader_id"` + Symbol string `json:"symbol"` + Side string `json:"side"` // LONG/SHORT + Quantity float64 `json:"quantity"` // 开仓数量 + EntryPrice float64 `json:"entry_price"` // 开仓均价 + EntryOrderID string `json:"entry_order_id"` // 开仓订单ID + EntryTime time.Time `json:"entry_time"` // 开仓时间 + ExitPrice float64 `json:"exit_price"` // 平仓均价 + ExitOrderID string `json:"exit_order_id"` // 平仓订单ID + ExitTime *time.Time `json:"exit_time"` // 平仓时间 + RealizedPnL float64 `json:"realized_pnl"` // 已实现盈亏 + Fee float64 `json:"fee"` // 手续费 + Leverage int `json:"leverage"` // 杠杆倍数 + Status string `json:"status"` // OPEN/CLOSED + CloseReason string `json:"close_reason"` // 平仓原因: ai_decision/manual/stop_loss/take_profit + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// PositionStore 仓位存储 +type PositionStore struct { + db *sql.DB +} + +// NewPositionStore 创建仓位存储实例 +func NewPositionStore(db *sql.DB) *PositionStore { + return &PositionStore{db: db} +} + +// InitTables 初始化仓位表 +func (s *PositionStore) InitTables() error { + _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS trader_positions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trader_id TEXT NOT NULL, + symbol TEXT NOT NULL, + side TEXT NOT NULL, + quantity REAL NOT NULL, + entry_price REAL NOT NULL, + entry_order_id TEXT DEFAULT '', + entry_time DATETIME NOT NULL, + exit_price REAL DEFAULT 0, + exit_order_id TEXT DEFAULT '', + exit_time DATETIME, + realized_pnl REAL DEFAULT 0, + fee REAL DEFAULT 0, + leverage INTEGER DEFAULT 1, + status TEXT DEFAULT 'OPEN', + close_reason TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `) + if err != nil { + return fmt.Errorf("创建trader_positions表失败: %w", err) + } + + // 创建索引 + indices := []string{ + `CREATE INDEX IF NOT EXISTS idx_positions_trader ON trader_positions(trader_id)`, + `CREATE INDEX IF NOT EXISTS idx_positions_status ON trader_positions(trader_id, status)`, + `CREATE INDEX IF NOT EXISTS idx_positions_symbol ON trader_positions(trader_id, symbol, side, status)`, + `CREATE INDEX IF NOT EXISTS idx_positions_entry ON trader_positions(trader_id, entry_time DESC)`, + `CREATE INDEX IF NOT EXISTS idx_positions_exit ON trader_positions(trader_id, exit_time DESC)`, + } + for _, idx := range indices { + if _, err := s.db.Exec(idx); err != nil { + return fmt.Errorf("创建索引失败: %w", err) + } + } + + return nil +} + +// Create 创建仓位记录(开仓时调用) +func (s *PositionStore) Create(pos *TraderPosition) error { + now := time.Now() + pos.CreatedAt = now + pos.UpdatedAt = now + pos.Status = "OPEN" + + result, err := s.db.Exec(` + INSERT INTO trader_positions ( + trader_id, symbol, side, quantity, entry_price, entry_order_id, + entry_time, leverage, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + pos.TraderID, pos.Symbol, pos.Side, pos.Quantity, pos.EntryPrice, + pos.EntryOrderID, pos.EntryTime.Format(time.RFC3339), pos.Leverage, + pos.Status, now.Format(time.RFC3339), now.Format(time.RFC3339), + ) + if err != nil { + return fmt.Errorf("创建仓位记录失败: %w", err) + } + + id, _ := result.LastInsertId() + pos.ID = id + return nil +} + +// ClosePosition 平仓(更新仓位记录) +func (s *PositionStore) ClosePosition(id int64, exitPrice float64, exitOrderID string, realizedPnL float64, fee float64, closeReason string) error { + now := time.Now() + _, err := s.db.Exec(` + UPDATE trader_positions SET + exit_price = ?, exit_order_id = ?, exit_time = ?, + realized_pnl = ?, fee = ?, status = 'CLOSED', + close_reason = ?, updated_at = ? + WHERE id = ? + `, + exitPrice, exitOrderID, now.Format(time.RFC3339), + realizedPnL, fee, closeReason, now.Format(time.RFC3339), id, + ) + if err != nil { + return fmt.Errorf("更新仓位记录失败: %w", err) + } + return nil +} + +// GetOpenPositions 获取所有未平仓位 +func (s *PositionStore) GetOpenPositions(traderID string) ([]*TraderPosition, error) { + rows, err := s.db.Query(` + SELECT id, trader_id, symbol, side, quantity, entry_price, entry_order_id, + entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee, + leverage, status, close_reason, created_at, updated_at + FROM trader_positions + WHERE trader_id = ? AND status = 'OPEN' + ORDER BY entry_time DESC + `, traderID) + if err != nil { + return nil, fmt.Errorf("查询未平仓位失败: %w", err) + } + defer rows.Close() + + return s.scanPositions(rows) +} + +// GetOpenPositionBySymbol 获取指定币种方向的未平仓位 +func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) (*TraderPosition, error) { + var pos TraderPosition + var entryTime, exitTime, createdAt, updatedAt sql.NullString + + err := s.db.QueryRow(` + SELECT id, trader_id, symbol, side, quantity, entry_price, entry_order_id, + entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee, + leverage, status, close_reason, created_at, updated_at + FROM trader_positions + WHERE trader_id = ? AND symbol = ? AND side = ? AND status = 'OPEN' + ORDER BY entry_time DESC LIMIT 1 + `, traderID, symbol, side).Scan( + &pos.ID, &pos.TraderID, &pos.Symbol, &pos.Side, &pos.Quantity, + &pos.EntryPrice, &pos.EntryOrderID, &entryTime, &pos.ExitPrice, + &pos.ExitOrderID, &exitTime, &pos.RealizedPnL, &pos.Fee, + &pos.Leverage, &pos.Status, &pos.CloseReason, &createdAt, &updatedAt, + ) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + + s.parsePositionTimes(&pos, entryTime, exitTime, createdAt, updatedAt) + return &pos, nil +} + +// GetClosedPositions 获取已平仓位(历史记录) +func (s *PositionStore) GetClosedPositions(traderID string, limit int) ([]*TraderPosition, error) { + rows, err := s.db.Query(` + SELECT id, trader_id, symbol, side, quantity, entry_price, entry_order_id, + entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee, + leverage, status, close_reason, created_at, updated_at + FROM trader_positions + WHERE trader_id = ? AND status = 'CLOSED' + ORDER BY exit_time DESC + LIMIT ? + `, traderID, limit) + if err != nil { + return nil, fmt.Errorf("查询已平仓位失败: %w", err) + } + defer rows.Close() + + return s.scanPositions(rows) +} + +// GetAllOpenPositions 获取所有trader的未平仓位(用于全局同步) +func (s *PositionStore) GetAllOpenPositions() ([]*TraderPosition, error) { + rows, err := s.db.Query(` + SELECT id, trader_id, symbol, side, quantity, entry_price, entry_order_id, + entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee, + leverage, status, close_reason, created_at, updated_at + FROM trader_positions + WHERE status = 'OPEN' + ORDER BY trader_id, entry_time DESC + `) + if err != nil { + return nil, fmt.Errorf("查询所有未平仓位失败: %w", err) + } + defer rows.Close() + + return s.scanPositions(rows) +} + +// GetPositionStats 获取仓位统计(简单版) +func (s *PositionStore) GetPositionStats(traderID string) (map[string]interface{}, error) { + stats := make(map[string]interface{}) + + // 总交易数 + var totalTrades, winTrades int + var totalPnL, totalFee float64 + + err := s.db.QueryRow(` + SELECT + COUNT(*) as total, + SUM(CASE WHEN realized_pnl > 0 THEN 1 ELSE 0 END) as wins, + COALESCE(SUM(realized_pnl), 0) as total_pnl, + COALESCE(SUM(fee), 0) as total_fee + FROM trader_positions + WHERE trader_id = ? AND status = 'CLOSED' + `, traderID).Scan(&totalTrades, &winTrades, &totalPnL, &totalFee) + if err != nil { + return nil, err + } + + stats["total_trades"] = totalTrades + stats["win_trades"] = winTrades + stats["total_pnl"] = totalPnL + stats["total_fee"] = totalFee + if totalTrades > 0 { + stats["win_rate"] = float64(winTrades) / float64(totalTrades) * 100 + } else { + stats["win_rate"] = 0.0 + } + + return stats, nil +} + +// GetFullStats 获取完整的交易统计(与 TraderStats 兼容) +func (s *PositionStore) GetFullStats(traderID string) (*TraderStats, error) { + stats := &TraderStats{} + + // 查询所有已平仓位 + rows, err := s.db.Query(` + SELECT realized_pnl, fee, exit_time + FROM trader_positions + WHERE trader_id = ? AND status = 'CLOSED' + ORDER BY exit_time ASC + `, traderID) + if err != nil { + return nil, fmt.Errorf("查询仓位统计失败: %w", err) + } + defer rows.Close() + + var pnls []float64 + var totalWin, totalLoss float64 + + for rows.Next() { + var pnl, fee float64 + var exitTime sql.NullString + if err := rows.Scan(&pnl, &fee, &exitTime); err != nil { + continue + } + + stats.TotalTrades++ + stats.TotalPnL += pnl + stats.TotalFee += fee + pnls = append(pnls, pnl) + + if pnl > 0 { + stats.WinTrades++ + totalWin += pnl + } else if pnl < 0 { + stats.LossTrades++ + totalLoss += -pnl // 转为正数 + } + } + + // 计算胜率 + if stats.TotalTrades > 0 { + stats.WinRate = float64(stats.WinTrades) / float64(stats.TotalTrades) * 100 + } + + // 计算盈亏比 + if totalLoss > 0 { + stats.ProfitFactor = totalWin / totalLoss + } + + // 计算平均盈亏 + if stats.WinTrades > 0 { + stats.AvgWin = totalWin / float64(stats.WinTrades) + } + if stats.LossTrades > 0 { + stats.AvgLoss = totalLoss / float64(stats.LossTrades) + } + + // 计算夏普比 + if len(pnls) > 1 { + stats.SharpeRatio = calculateSharpeRatioFromPnls(pnls) + } + + // 计算最大回撤 + if len(pnls) > 0 { + stats.MaxDrawdownPct = calculateMaxDrawdownFromPnls(pnls) + } + + return stats, nil +} + +// RecentTrade 最近的交易记录(用于AI输入) +type RecentTrade struct { + Symbol string `json:"symbol"` + Side string `json:"side"` // long/short + EntryPrice float64 `json:"entry_price"` + ExitPrice float64 `json:"exit_price"` + RealizedPnL float64 `json:"realized_pnl"` + PnLPct float64 `json:"pnl_pct"` + ExitTime string `json:"exit_time"` +} + +// GetRecentTrades 获取最近的已平仓交易 +func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTrade, error) { + rows, err := s.db.Query(` + SELECT symbol, side, entry_price, exit_price, realized_pnl, leverage, exit_time + FROM trader_positions + WHERE trader_id = ? AND status = 'CLOSED' + ORDER BY exit_time DESC + LIMIT ? + `, traderID, limit) + if err != nil { + return nil, fmt.Errorf("查询最近交易失败: %w", err) + } + defer rows.Close() + + var trades []RecentTrade + for rows.Next() { + var t RecentTrade + var leverage int + var exitTime sql.NullString + + err := rows.Scan(&t.Symbol, &t.Side, &t.EntryPrice, &t.ExitPrice, &t.RealizedPnL, &leverage, &exitTime) + if err != nil { + continue + } + + // 转换 side 格式 + if t.Side == "LONG" { + t.Side = "long" + } else if t.Side == "SHORT" { + t.Side = "short" + } + + // 计算盈亏百分比 + if t.EntryPrice > 0 { + if t.Side == "long" { + t.PnLPct = (t.ExitPrice - t.EntryPrice) / t.EntryPrice * 100 * float64(leverage) + } else { + t.PnLPct = (t.EntryPrice - t.ExitPrice) / t.EntryPrice * 100 * float64(leverage) + } + } + + // 格式化时间 + if exitTime.Valid { + if parsed, err := time.Parse(time.RFC3339, exitTime.String); err == nil { + t.ExitTime = parsed.Format("01-02 15:04") + } + } + + trades = append(trades, t) + } + + return trades, nil +} + +// calculateSharpeRatioFromPnls 计算夏普比 +func calculateSharpeRatioFromPnls(pnls []float64) float64 { + if len(pnls) < 2 { + return 0 + } + + var sum float64 + for _, pnl := range pnls { + sum += pnl + } + mean := sum / float64(len(pnls)) + + var variance float64 + for _, pnl := range pnls { + variance += (pnl - mean) * (pnl - mean) + } + stdDev := math.Sqrt(variance / float64(len(pnls)-1)) + + if stdDev == 0 { + return 0 + } + + return mean / stdDev +} + +// calculateMaxDrawdownFromPnls 计算最大回撤 +func calculateMaxDrawdownFromPnls(pnls []float64) float64 { + if len(pnls) == 0 { + return 0 + } + + var cumulative, peak, maxDD float64 + for _, pnl := range pnls { + cumulative += pnl + if cumulative > peak { + peak = cumulative + } + if peak > 0 { + dd := (peak - cumulative) / peak * 100 + if dd > maxDD { + maxDD = dd + } + } + } + + return maxDD +} + +// scanPositions 扫描仓位行到结构体 +func (s *PositionStore) scanPositions(rows *sql.Rows) ([]*TraderPosition, error) { + var positions []*TraderPosition + for rows.Next() { + var pos TraderPosition + var entryTime, exitTime, createdAt, updatedAt sql.NullString + + err := rows.Scan( + &pos.ID, &pos.TraderID, &pos.Symbol, &pos.Side, &pos.Quantity, + &pos.EntryPrice, &pos.EntryOrderID, &entryTime, &pos.ExitPrice, + &pos.ExitOrderID, &exitTime, &pos.RealizedPnL, &pos.Fee, + &pos.Leverage, &pos.Status, &pos.CloseReason, &createdAt, &updatedAt, + ) + if err != nil { + continue + } + + s.parsePositionTimes(&pos, entryTime, exitTime, createdAt, updatedAt) + positions = append(positions, &pos) + } + + return positions, nil +} + +// parsePositionTimes 解析时间字段 +func (s *PositionStore) parsePositionTimes(pos *TraderPosition, entryTime, exitTime, createdAt, updatedAt sql.NullString) { + if entryTime.Valid { + pos.EntryTime, _ = time.Parse(time.RFC3339, entryTime.String) + } + if exitTime.Valid { + t, _ := time.Parse(time.RFC3339, exitTime.String) + pos.ExitTime = &t + } + if createdAt.Valid { + pos.CreatedAt, _ = time.Parse(time.RFC3339, createdAt.String) + } + if updatedAt.Valid { + pos.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt.String) + } +} diff --git a/store/signal_source.go b/store/signal_source.go new file mode 100644 index 00000000..6f0cc0e5 --- /dev/null +++ b/store/signal_source.go @@ -0,0 +1,86 @@ +package store + +import ( + "database/sql" + "time" +) + +// SignalSourceStore 信号源存储 +type SignalSourceStore struct { + db *sql.DB +} + +// SignalSource 用户信号源配置 +type SignalSource struct { + ID int `json:"id"` + UserID string `json:"user_id"` + CoinPoolURL string `json:"coin_pool_url"` + OITopURL string `json:"oi_top_url"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (s *SignalSourceStore) initTables() error { + _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS user_signal_sources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + coin_pool_url TEXT DEFAULT '', + oi_top_url TEXT DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(user_id) + ) + `) + if err != nil { + return err + } + + // 触发器 + _, err = s.db.Exec(` + CREATE TRIGGER IF NOT EXISTS update_user_signal_sources_updated_at + AFTER UPDATE ON user_signal_sources + BEGIN + UPDATE user_signal_sources SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END + `) + return err +} + +// Create 创建信号源配置 +func (s *SignalSourceStore) Create(userID, coinPoolURL, oiTopURL string) error { + _, err := s.db.Exec(` + INSERT OR REPLACE INTO user_signal_sources (user_id, coin_pool_url, oi_top_url, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + `, userID, coinPoolURL, oiTopURL) + return err +} + +// Get 获取信号源配置 +func (s *SignalSourceStore) Get(userID string) (*SignalSource, error) { + var source SignalSource + var createdAt, updatedAt string + err := s.db.QueryRow(` + SELECT id, user_id, coin_pool_url, oi_top_url, created_at, updated_at + FROM user_signal_sources WHERE user_id = ? + `, userID).Scan( + &source.ID, &source.UserID, &source.CoinPoolURL, &source.OITopURL, + &createdAt, &updatedAt, + ) + if err != nil { + return nil, err + } + source.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + source.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + return &source, nil +} + +// Update 更新信号源配置 +func (s *SignalSourceStore) Update(userID, coinPoolURL, oiTopURL string) error { + _, err := s.db.Exec(` + UPDATE user_signal_sources SET coin_pool_url = ?, oi_top_url = ?, updated_at = CURRENT_TIMESTAMP + WHERE user_id = ? + `, coinPoolURL, oiTopURL, userID) + return err +} diff --git a/store/store.go b/store/store.go new file mode 100644 index 00000000..4c327ead --- /dev/null +++ b/store/store.go @@ -0,0 +1,319 @@ +// Package store 提供统一的数据库存储层 +// 所有数据库操作都应该通过这个包进行 +package store + +import ( + "database/sql" + "fmt" + "nofx/logger" + "sync" + + _ "modernc.org/sqlite" +) + +// Store 统一的数据存储接口 +type Store struct { + db *sql.DB + + // 子存储(延迟初始化) + user *UserStore + aiModel *AIModelStore + exchange *ExchangeStore + trader *TraderStore + systemConfig *SystemConfigStore + betaCode *BetaCodeStore + signalSource *SignalSourceStore + decision *DecisionStore + backtest *BacktestStore + order *OrderStore + position *PositionStore + + // 加密函数 + encryptFunc func(string) string + decryptFunc func(string) string + + mu sync.RWMutex +} + +// New 创建新的 Store 实例 +func New(dbPath string) (*Store, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("打开数据库失败: %w", err) + } + + // SQLite 配置 + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + + // 启用外键约束 + if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil { + db.Close() + return nil, fmt.Errorf("启用外键失败: %w", err) + } + + // 使用 DELETE 模式(传统模式)以确保 Docker bind mount 兼容性 + // 注意:WAL 模式在 macOS Docker 下会导致数据同步问题 + if _, err := db.Exec("PRAGMA journal_mode=DELETE"); err != nil { + db.Close() + return nil, fmt.Errorf("设置journal_mode失败: %w", err) + } + + // 设置 synchronous=FULL + if _, err := db.Exec("PRAGMA synchronous=FULL"); err != nil { + db.Close() + return nil, fmt.Errorf("设置synchronous失败: %w", err) + } + + // 设置 busy_timeout + if _, err := db.Exec("PRAGMA busy_timeout = 5000"); err != nil { + db.Close() + return nil, fmt.Errorf("设置busy_timeout失败: %w", err) + } + + s := &Store{db: db} + + // 初始化所有表结构 + if err := s.initTables(); err != nil { + db.Close() + return nil, fmt.Errorf("初始化表结构失败: %w", err) + } + + // 初始化默认数据 + if err := s.initDefaultData(); err != nil { + db.Close() + return nil, fmt.Errorf("初始化默认数据失败: %w", err) + } + + logger.Info("✅ 数据库已启用 DELETE 模式和 FULL 同步") + return s, nil +} + +// NewFromDB 从现有数据库连接创建 Store +func NewFromDB(db *sql.DB) *Store { + return &Store{db: db} +} + +// SetCryptoFuncs 设置加密解密函数 +func (s *Store) SetCryptoFuncs(encrypt, decrypt func(string) string) { + s.mu.Lock() + defer s.mu.Unlock() + s.encryptFunc = encrypt + s.decryptFunc = decrypt + + // 更新已初始化的子存储 + if s.aiModel != nil { + s.aiModel.encryptFunc = encrypt + s.aiModel.decryptFunc = decrypt + } + if s.exchange != nil { + s.exchange.encryptFunc = encrypt + s.exchange.decryptFunc = decrypt + } + if s.trader != nil { + s.trader.decryptFunc = decrypt + } +} + +// initTables 初始化所有数据库表 +func (s *Store) initTables() error { + // 按依赖顺序初始化 + if err := s.User().initTables(); err != nil { + return fmt.Errorf("初始化用户表失败: %w", err) + } + if err := s.AIModel().initTables(); err != nil { + return fmt.Errorf("初始化AI模型表失败: %w", err) + } + if err := s.Exchange().initTables(); err != nil { + return fmt.Errorf("初始化交易所表失败: %w", err) + } + if err := s.Trader().initTables(); err != nil { + return fmt.Errorf("初始化交易员表失败: %w", err) + } + if err := s.SystemConfig().initTables(); err != nil { + return fmt.Errorf("初始化系统配置表失败: %w", err) + } + if err := s.BetaCode().initTables(); err != nil { + return fmt.Errorf("初始化内测码表失败: %w", err) + } + if err := s.SignalSource().initTables(); err != nil { + return fmt.Errorf("初始化信号源表失败: %w", err) + } + if err := s.Decision().initTables(); err != nil { + return fmt.Errorf("初始化决策日志表失败: %w", err) + } + if err := s.Backtest().initTables(); err != nil { + return fmt.Errorf("初始化回测表失败: %w", err) + } + if err := s.Order().InitTables(); err != nil { + return fmt.Errorf("初始化订单表失败: %w", err) + } + if err := s.Position().InitTables(); err != nil { + return fmt.Errorf("初始化仓位表失败: %w", err) + } + return nil +} + +// initDefaultData 初始化默认数据 +func (s *Store) initDefaultData() error { + if err := s.AIModel().initDefaultData(); err != nil { + return err + } + if err := s.Exchange().initDefaultData(); err != nil { + return err + } + if err := s.SystemConfig().initDefaultData(); err != nil { + return err + } + return nil +} + +// User 获取用户存储 +func (s *Store) User() *UserStore { + s.mu.Lock() + defer s.mu.Unlock() + if s.user == nil { + s.user = &UserStore{db: s.db} + } + return s.user +} + +// AIModel 获取AI模型存储 +func (s *Store) AIModel() *AIModelStore { + s.mu.Lock() + defer s.mu.Unlock() + if s.aiModel == nil { + s.aiModel = &AIModelStore{ + db: s.db, + encryptFunc: s.encryptFunc, + decryptFunc: s.decryptFunc, + } + } + return s.aiModel +} + +// Exchange 获取交易所存储 +func (s *Store) Exchange() *ExchangeStore { + s.mu.Lock() + defer s.mu.Unlock() + if s.exchange == nil { + s.exchange = &ExchangeStore{ + db: s.db, + encryptFunc: s.encryptFunc, + decryptFunc: s.decryptFunc, + } + } + return s.exchange +} + +// Trader 获取交易员存储 +func (s *Store) Trader() *TraderStore { + s.mu.Lock() + defer s.mu.Unlock() + if s.trader == nil { + s.trader = &TraderStore{ + db: s.db, + decryptFunc: s.decryptFunc, + } + } + return s.trader +} + +// SystemConfig 获取系统配置存储 +func (s *Store) SystemConfig() *SystemConfigStore { + s.mu.Lock() + defer s.mu.Unlock() + if s.systemConfig == nil { + s.systemConfig = &SystemConfigStore{db: s.db} + } + return s.systemConfig +} + +// BetaCode 获取内测码存储 +func (s *Store) BetaCode() *BetaCodeStore { + s.mu.Lock() + defer s.mu.Unlock() + if s.betaCode == nil { + s.betaCode = &BetaCodeStore{db: s.db} + } + return s.betaCode +} + +// SignalSource 获取信号源存储 +func (s *Store) SignalSource() *SignalSourceStore { + s.mu.Lock() + defer s.mu.Unlock() + if s.signalSource == nil { + s.signalSource = &SignalSourceStore{db: s.db} + } + return s.signalSource +} + +// Decision 获取决策日志存储 +func (s *Store) Decision() *DecisionStore { + s.mu.Lock() + defer s.mu.Unlock() + if s.decision == nil { + s.decision = &DecisionStore{db: s.db} + } + return s.decision +} + +// Backtest 获取回测数据存储 +func (s *Store) Backtest() *BacktestStore { + s.mu.Lock() + defer s.mu.Unlock() + if s.backtest == nil { + s.backtest = &BacktestStore{db: s.db} + } + return s.backtest +} + +// Order 获取订单存储 +func (s *Store) Order() *OrderStore { + s.mu.Lock() + defer s.mu.Unlock() + if s.order == nil { + s.order = NewOrderStore(s.db) + } + return s.order +} + +// Position 获取仓位存储 +func (s *Store) Position() *PositionStore { + s.mu.Lock() + defer s.mu.Unlock() + if s.position == nil { + s.position = NewPositionStore(s.db) + } + return s.position +} + +// Close 关闭数据库连接 +func (s *Store) Close() error { + return s.db.Close() +} + +// DB 获取底层数据库连接(仅用于兼容旧代码,逐步废弃) +// Deprecated: 使用 Store 的方法代替 +func (s *Store) DB() *sql.DB { + return s.db +} + +// Transaction 执行事务 +func (s *Store) Transaction(fn func(tx *sql.Tx) error) error { + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("开始事务失败: %w", err) + } + + if err := fn(tx); err != nil { + tx.Rollback() + return err + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("提交事务失败: %w", err) + } + return nil +} diff --git a/store/system_config.go b/store/system_config.go new file mode 100644 index 00000000..45fd0401 --- /dev/null +++ b/store/system_config.go @@ -0,0 +1,70 @@ +package store + +import ( + "database/sql" +) + +// SystemConfigStore 系统配置存储 +type SystemConfigStore struct { + db *sql.DB +} + +func (s *SystemConfigStore) initTables() error { + _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS system_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `) + if err != nil { + return err + } + + // 触发器 + _, err = s.db.Exec(` + CREATE TRIGGER IF NOT EXISTS update_system_config_updated_at + AFTER UPDATE ON system_config + BEGIN + UPDATE system_config SET updated_at = CURRENT_TIMESTAMP WHERE key = NEW.key; + END + `) + return err +} + +func (s *SystemConfigStore) initDefaultData() error { + configs := map[string]string{ + "beta_mode": "false", + "api_server_port": "8080", + "use_default_coins": "true", + "default_coins": `["BTCUSDT","ETHUSDT","SOLUSDT","BNBUSDT","XRPUSDT","DOGEUSDT","ADAUSDT","HYPEUSDT"]`, + "max_daily_loss": "10.0", + "max_drawdown": "20.0", + "stop_trading_minutes": "60", + "btc_eth_leverage": "5", + "altcoin_leverage": "5", + "jwt_secret": "", + "registration_enabled": "true", + } + + for key, value := range configs { + _, err := s.db.Exec(`INSERT OR IGNORE INTO system_config (key, value) VALUES (?, ?)`, key, value) + if err != nil { + return err + } + } + return nil +} + +// Get 获取配置值 +func (s *SystemConfigStore) Get(key string) (string, error) { + var value string + err := s.db.QueryRow(`SELECT value FROM system_config WHERE key = ?`, key).Scan(&value) + return value, err +} + +// Set 设置配置值 +func (s *SystemConfigStore) Set(key, value string) error { + _, err := s.db.Exec(`INSERT OR REPLACE INTO system_config (key, value) VALUES (?, ?)`, key, value) + return err +} diff --git a/store/trader.go b/store/trader.go new file mode 100644 index 00000000..e951640e --- /dev/null +++ b/store/trader.go @@ -0,0 +1,344 @@ +package store + +import ( + "database/sql" + "encoding/json" + "nofx/logger" + "nofx/market" + "slices" + "strings" + "time" +) + +// TraderStore 交易员存储 +type TraderStore struct { + db *sql.DB + decryptFunc func(string) string +} + +// Trader 交易员配置 +type Trader struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Name string `json:"name"` + AIModelID string `json:"ai_model_id"` + ExchangeID string `json:"exchange_id"` + InitialBalance float64 `json:"initial_balance"` + ScanIntervalMinutes int `json:"scan_interval_minutes"` + IsRunning bool `json:"is_running"` + BTCETHLeverage int `json:"btc_eth_leverage"` + AltcoinLeverage int `json:"altcoin_leverage"` + TradingSymbols string `json:"trading_symbols"` + UseCoinPool bool `json:"use_coin_pool"` + UseOITop bool `json:"use_oi_top"` + CustomPrompt string `json:"custom_prompt"` + OverrideBasePrompt bool `json:"override_base_prompt"` + SystemPromptTemplate string `json:"system_prompt_template"` + IsCrossMargin bool `json:"is_cross_margin"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TraderFullConfig 交易员完整配置(包含AI模型和交易所) +type TraderFullConfig struct { + Trader *Trader + AIModel *AIModel + Exchange *Exchange +} + +func (s *TraderStore) initTables() error { + _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS traders ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL DEFAULT 'default', + name TEXT NOT NULL, + ai_model_id TEXT NOT NULL, + exchange_id TEXT NOT NULL, + initial_balance REAL NOT NULL, + scan_interval_minutes INTEGER DEFAULT 3, + is_running BOOLEAN DEFAULT 0, + btc_eth_leverage INTEGER DEFAULT 5, + altcoin_leverage INTEGER DEFAULT 5, + trading_symbols TEXT DEFAULT '', + use_coin_pool BOOLEAN DEFAULT 0, + use_oi_top BOOLEAN DEFAULT 0, + custom_prompt TEXT DEFAULT '', + override_base_prompt BOOLEAN DEFAULT 0, + system_prompt_template TEXT DEFAULT 'default', + is_cross_margin BOOLEAN DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `) + if err != nil { + return err + } + + // 触发器 + _, err = s.db.Exec(` + CREATE TRIGGER IF NOT EXISTS update_traders_updated_at + AFTER UPDATE ON traders + BEGIN + UPDATE traders SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END + `) + if err != nil { + return err + } + + // 向后兼容 + alterQueries := []string{ + `ALTER TABLE traders ADD COLUMN custom_prompt TEXT DEFAULT ''`, + `ALTER TABLE traders ADD COLUMN override_base_prompt BOOLEAN DEFAULT 0`, + `ALTER TABLE traders ADD COLUMN is_cross_margin BOOLEAN DEFAULT 1`, + `ALTER TABLE traders ADD COLUMN btc_eth_leverage INTEGER DEFAULT 5`, + `ALTER TABLE traders ADD COLUMN altcoin_leverage INTEGER DEFAULT 5`, + `ALTER TABLE traders ADD COLUMN trading_symbols TEXT DEFAULT ''`, + `ALTER TABLE traders ADD COLUMN use_coin_pool BOOLEAN DEFAULT 0`, + `ALTER TABLE traders ADD COLUMN use_oi_top BOOLEAN DEFAULT 0`, + `ALTER TABLE traders ADD COLUMN system_prompt_template TEXT DEFAULT 'default'`, + } + for _, q := range alterQueries { + s.db.Exec(q) + } + + return nil +} + +func (s *TraderStore) decrypt(encrypted string) string { + if s.decryptFunc != nil { + return s.decryptFunc(encrypted) + } + return encrypted +} + +// Create 创建交易员 +func (s *TraderStore) Create(trader *Trader) error { + _, err := s.db.Exec(` + INSERT INTO traders (id, user_id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, + is_running, btc_eth_leverage, altcoin_leverage, trading_symbols, use_coin_pool, + use_oi_top, custom_prompt, override_base_prompt, system_prompt_template, is_cross_margin) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, trader.ID, trader.UserID, trader.Name, trader.AIModelID, trader.ExchangeID, trader.InitialBalance, + trader.ScanIntervalMinutes, trader.IsRunning, trader.BTCETHLeverage, trader.AltcoinLeverage, + trader.TradingSymbols, trader.UseCoinPool, trader.UseOITop, trader.CustomPrompt, + trader.OverrideBasePrompt, trader.SystemPromptTemplate, trader.IsCrossMargin) + return err +} + +// List 获取用户的交易员列表 +func (s *TraderStore) List(userID string) ([]*Trader, error) { + rows, err := s.db.Query(` + SELECT id, user_id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, is_running, + COALESCE(btc_eth_leverage, 5), COALESCE(altcoin_leverage, 5), COALESCE(trading_symbols, ''), + COALESCE(use_coin_pool, 0), COALESCE(use_oi_top, 0), COALESCE(custom_prompt, ''), + COALESCE(override_base_prompt, 0), COALESCE(system_prompt_template, 'default'), + COALESCE(is_cross_margin, 1), created_at, updated_at + FROM traders WHERE user_id = ? ORDER BY created_at DESC + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var traders []*Trader + for rows.Next() { + var t Trader + var createdAt, updatedAt string + err := rows.Scan( + &t.ID, &t.UserID, &t.Name, &t.AIModelID, &t.ExchangeID, + &t.InitialBalance, &t.ScanIntervalMinutes, &t.IsRunning, + &t.BTCETHLeverage, &t.AltcoinLeverage, &t.TradingSymbols, + &t.UseCoinPool, &t.UseOITop, &t.CustomPrompt, &t.OverrideBasePrompt, + &t.SystemPromptTemplate, &t.IsCrossMargin, &createdAt, &updatedAt, + ) + if err != nil { + return nil, err + } + t.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + t.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + traders = append(traders, &t) + } + return traders, nil +} + +// UpdateStatus 更新交易员运行状态 +func (s *TraderStore) UpdateStatus(userID, id string, isRunning bool) error { + _, err := s.db.Exec(`UPDATE traders SET is_running = ? WHERE id = ? AND user_id = ?`, isRunning, id, userID) + return err +} + +// Update 更新交易员配置 +func (s *TraderStore) Update(trader *Trader) error { + _, err := s.db.Exec(` + UPDATE traders SET + name = ?, ai_model_id = ?, exchange_id = ?, scan_interval_minutes = ?, + btc_eth_leverage = ?, altcoin_leverage = ?, trading_symbols = ?, + custom_prompt = ?, override_base_prompt = ?, system_prompt_template = ?, + is_cross_margin = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND user_id = ? + `, trader.Name, trader.AIModelID, trader.ExchangeID, trader.ScanIntervalMinutes, + trader.BTCETHLeverage, trader.AltcoinLeverage, trader.TradingSymbols, + trader.CustomPrompt, trader.OverrideBasePrompt, trader.SystemPromptTemplate, + trader.IsCrossMargin, trader.ID, trader.UserID) + return err +} + +// UpdateInitialBalance 更新初始余额 +func (s *TraderStore) UpdateInitialBalance(userID, id string, newBalance float64) error { + _, err := s.db.Exec(`UPDATE traders SET initial_balance = ? WHERE id = ? AND user_id = ?`, newBalance, id, userID) + return err +} + +// UpdateCustomPrompt 更新自定义提示词 +func (s *TraderStore) UpdateCustomPrompt(userID, id string, customPrompt string, overrideBase bool) error { + _, err := s.db.Exec(`UPDATE traders SET custom_prompt = ?, override_base_prompt = ? WHERE id = ? AND user_id = ?`, + customPrompt, overrideBase, id, userID) + return err +} + +// Delete 删除交易员 +func (s *TraderStore) Delete(userID, id string) error { + _, err := s.db.Exec(`DELETE FROM traders WHERE id = ? AND user_id = ?`, id, userID) + return err +} + +// GetFullConfig 获取交易员完整配置 +func (s *TraderStore) GetFullConfig(userID, traderID string) (*TraderFullConfig, error) { + var trader Trader + var aiModel AIModel + var exchange Exchange + var traderCreatedAt, traderUpdatedAt string + var aiModelCreatedAt, aiModelUpdatedAt string + var exchangeCreatedAt, exchangeUpdatedAt string + + err := s.db.QueryRow(` + SELECT + t.id, t.user_id, t.name, t.ai_model_id, t.exchange_id, t.initial_balance, t.scan_interval_minutes, t.is_running, + COALESCE(t.btc_eth_leverage, 5), COALESCE(t.altcoin_leverage, 5), COALESCE(t.trading_symbols, ''), + COALESCE(t.use_coin_pool, 0), COALESCE(t.use_oi_top, 0), COALESCE(t.custom_prompt, ''), + COALESCE(t.override_base_prompt, 0), COALESCE(t.system_prompt_template, 'default'), + COALESCE(t.is_cross_margin, 1), t.created_at, t.updated_at, + a.id, a.user_id, a.name, a.provider, a.enabled, a.api_key, + COALESCE(a.custom_api_url, ''), COALESCE(a.custom_model_name, ''), a.created_at, a.updated_at, + e.id, e.user_id, e.name, e.type, e.enabled, e.api_key, e.secret_key, e.testnet, + COALESCE(e.hyperliquid_wallet_addr, ''), COALESCE(e.aster_user, ''), COALESCE(e.aster_signer, ''), + COALESCE(e.aster_private_key, ''), COALESCE(e.lighter_wallet_addr, ''), COALESCE(e.lighter_private_key, ''), + COALESCE(e.lighter_api_key_private_key, ''), e.created_at, e.updated_at + FROM traders t + JOIN ai_models a ON t.ai_model_id = a.id AND t.user_id = a.user_id + JOIN exchanges e ON t.exchange_id = e.id AND t.user_id = e.user_id + WHERE t.id = ? AND t.user_id = ? + `, traderID, userID).Scan( + &trader.ID, &trader.UserID, &trader.Name, &trader.AIModelID, &trader.ExchangeID, + &trader.InitialBalance, &trader.ScanIntervalMinutes, &trader.IsRunning, + &trader.BTCETHLeverage, &trader.AltcoinLeverage, &trader.TradingSymbols, + &trader.UseCoinPool, &trader.UseOITop, &trader.CustomPrompt, &trader.OverrideBasePrompt, + &trader.SystemPromptTemplate, &trader.IsCrossMargin, &traderCreatedAt, &traderUpdatedAt, + &aiModel.ID, &aiModel.UserID, &aiModel.Name, &aiModel.Provider, &aiModel.Enabled, &aiModel.APIKey, + &aiModel.CustomAPIURL, &aiModel.CustomModelName, &aiModelCreatedAt, &aiModelUpdatedAt, + &exchange.ID, &exchange.UserID, &exchange.Name, &exchange.Type, &exchange.Enabled, + &exchange.APIKey, &exchange.SecretKey, &exchange.Testnet, &exchange.HyperliquidWalletAddr, + &exchange.AsterUser, &exchange.AsterSigner, &exchange.AsterPrivateKey, + &exchange.LighterWalletAddr, &exchange.LighterPrivateKey, &exchange.LighterAPIKeyPrivateKey, + &exchangeCreatedAt, &exchangeUpdatedAt, + ) + if err != nil { + return nil, err + } + + trader.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", traderCreatedAt) + trader.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", traderUpdatedAt) + aiModel.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", aiModelCreatedAt) + aiModel.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", aiModelUpdatedAt) + exchange.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", exchangeCreatedAt) + exchange.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", exchangeUpdatedAt) + + // 解密 + aiModel.APIKey = s.decrypt(aiModel.APIKey) + exchange.APIKey = s.decrypt(exchange.APIKey) + exchange.SecretKey = s.decrypt(exchange.SecretKey) + exchange.AsterPrivateKey = s.decrypt(exchange.AsterPrivateKey) + exchange.LighterPrivateKey = s.decrypt(exchange.LighterPrivateKey) + exchange.LighterAPIKeyPrivateKey = s.decrypt(exchange.LighterAPIKeyPrivateKey) + + return &TraderFullConfig{ + Trader: &trader, + AIModel: &aiModel, + Exchange: &exchange, + }, nil +} + +// GetCustomCoins 获取所有交易员自定义币种 +func (s *TraderStore) GetCustomCoins() []string { + var symbol string + var symbols []string + _ = s.db.QueryRow(` + SELECT GROUP_CONCAT(trading_symbols, ',') as symbol + FROM traders WHERE trading_symbols != '' + `).Scan(&symbol) + + // 如果没有自定义币种,返回默认币种 + if symbol == "" { + var symbolJSON string + _ = s.db.QueryRow(`SELECT value FROM system_config WHERE key = 'default_coins'`).Scan(&symbolJSON) + if symbolJSON != "" { + if err := json.Unmarshal([]byte(symbolJSON), &symbols); err != nil { + logger.Warnf("⚠️ 解析default_coins配置失败: %v,使用硬编码默认值", err) + symbols = []string{"BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT"} + } + } else { + symbols = []string{"BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT"} + } + return symbols + } + + // 处理并去重币种列表 + for _, s := range strings.Split(symbol, ",") { + if s == "" { + continue + } + coin := market.Normalize(s) + if !slices.Contains(symbols, coin) { + symbols = append(symbols, coin) + } + } + return symbols +} + +// ListAll 获取所有用户的交易员列表 +func (s *TraderStore) ListAll() ([]*Trader, error) { + rows, err := s.db.Query(` + SELECT id, user_id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, is_running, + COALESCE(btc_eth_leverage, 5), COALESCE(altcoin_leverage, 5), COALESCE(trading_symbols, ''), + COALESCE(use_coin_pool, 0), COALESCE(use_oi_top, 0), COALESCE(custom_prompt, ''), + COALESCE(override_base_prompt, 0), COALESCE(system_prompt_template, 'default'), + COALESCE(is_cross_margin, 1), created_at, updated_at + FROM traders ORDER BY created_at DESC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var traders []*Trader + for rows.Next() { + var t Trader + var createdAt, updatedAt string + err := rows.Scan( + &t.ID, &t.UserID, &t.Name, &t.AIModelID, &t.ExchangeID, + &t.InitialBalance, &t.ScanIntervalMinutes, &t.IsRunning, + &t.BTCETHLeverage, &t.AltcoinLeverage, &t.TradingSymbols, + &t.UseCoinPool, &t.UseOITop, &t.CustomPrompt, &t.OverrideBasePrompt, + &t.SystemPromptTemplate, &t.IsCrossMargin, &createdAt, &updatedAt, + ) + if err != nil { + return nil, err + } + t.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + t.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + traders = append(traders, &t) + } + return traders, nil +} diff --git a/store/user.go b/store/user.go new file mode 100644 index 00000000..6e9c993f --- /dev/null +++ b/store/user.go @@ -0,0 +1,164 @@ +package store + +import ( + "crypto/rand" + "database/sql" + "encoding/base32" + "time" +) + +// UserStore 用户存储 +type UserStore struct { + db *sql.DB +} + +// User 用户 +type User struct { + ID string `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"-"` + OTPSecret string `json:"-"` + OTPVerified bool `json:"otp_verified"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// GenerateOTPSecret 生成OTP密钥 +func GenerateOTPSecret() (string, error) { + secret := make([]byte, 20) + _, err := rand.Read(secret) + if err != nil { + return "", err + } + return base32.StdEncoding.EncodeToString(secret), nil +} + +func (s *UserStore) initTables() error { + _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + otp_secret TEXT, + otp_verified BOOLEAN DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `) + if err != nil { + return err + } + + // 触发器 + _, err = s.db.Exec(` + CREATE TRIGGER IF NOT EXISTS update_users_updated_at + AFTER UPDATE ON users + BEGIN + UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END + `) + if err != nil { + return err + } + + return nil +} + +// Create 创建用户 +func (s *UserStore) Create(user *User) error { + _, err := s.db.Exec(` + INSERT INTO users (id, email, password_hash, otp_secret, otp_verified) + VALUES (?, ?, ?, ?, ?) + `, user.ID, user.Email, user.PasswordHash, user.OTPSecret, user.OTPVerified) + return err +} + +// GetByEmail 通过邮箱获取用户 +func (s *UserStore) GetByEmail(email string) (*User, error) { + var user User + var createdAt, updatedAt string + err := s.db.QueryRow(` + SELECT id, email, password_hash, otp_secret, otp_verified, created_at, updated_at + FROM users WHERE email = ? + `, email).Scan( + &user.ID, &user.Email, &user.PasswordHash, &user.OTPSecret, + &user.OTPVerified, &createdAt, &updatedAt, + ) + if err != nil { + return nil, err + } + user.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + user.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + return &user, nil +} + +// GetByID 通过ID获取用户 +func (s *UserStore) GetByID(userID string) (*User, error) { + var user User + var createdAt, updatedAt string + err := s.db.QueryRow(` + SELECT id, email, password_hash, otp_secret, otp_verified, created_at, updated_at + FROM users WHERE id = ? + `, userID).Scan( + &user.ID, &user.Email, &user.PasswordHash, &user.OTPSecret, + &user.OTPVerified, &createdAt, &updatedAt, + ) + if err != nil { + return nil, err + } + user.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + user.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + return &user, nil +} + +// GetAllIDs 获取所有用户ID +func (s *UserStore) GetAllIDs() ([]string, error) { + rows, err := s.db.Query(`SELECT id FROM users ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + + var userIDs []string + for rows.Next() { + var userID string + if err := rows.Scan(&userID); err != nil { + return nil, err + } + userIDs = append(userIDs, userID) + } + return userIDs, nil +} + +// UpdateOTPVerified 更新OTP验证状态 +func (s *UserStore) UpdateOTPVerified(userID string, verified bool) error { + _, err := s.db.Exec(`UPDATE users SET otp_verified = ? WHERE id = ?`, verified, userID) + return err +} + +// UpdatePassword 更新密码 +func (s *UserStore) UpdatePassword(userID, passwordHash string) error { + _, err := s.db.Exec(` + UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? + `, passwordHash, userID) + return err +} + +// EnsureAdmin 确保admin用户存在 +func (s *UserStore) EnsureAdmin() error { + var count int + err := s.db.QueryRow(`SELECT COUNT(*) FROM users WHERE id = 'admin'`).Scan(&count) + if err != nil { + return err + } + if count > 0 { + return nil + } + return s.Create(&User{ + ID: "admin", + Email: "admin@localhost", + PasswordHash: "", + OTPSecret: "", + OTPVerified: true, + }) +} diff --git a/trader/aster_trader.go b/trader/aster_trader.go index e33c1b0e..7a172739 100644 --- a/trader/aster_trader.go +++ b/trader/aster_trader.go @@ -8,7 +8,7 @@ import ( "errors" "fmt" "io" - "log" + "nofx/logger" "math" "math/big" "net/http" @@ -469,13 +469,13 @@ func (t *AsterTrader) GetBalance() (map[string]interface{}, error) { } if !foundUSDT { - log.Printf("⚠️ 未找到USDT资产记录!") + logger.Infof("⚠️ 未找到USDT资产记录!") } // 获取持仓计算保证金占用和真实未实现盈亏 positions, err := t.GetPositions() if err != nil { - log.Printf("⚠️ 获取持仓信息失败: %v", err) + logger.Infof("⚠️ 获取持仓信息失败: %v", err) // fallback: 无法获取持仓时使用简单计算 return map[string]interface{}{ "totalWalletBalance": crossWalletBalance, @@ -577,7 +577,7 @@ func (t *AsterTrader) GetPositions() ([]map[string]interface{}, error) { func (t *AsterTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { // 开仓前先取消所有挂单,防止残留挂单导致仓位叠加 if err := t.CancelAllOrders(symbol); err != nil { - log.Printf(" ⚠ 取消挂单失败(继续开仓): %v", err) + logger.Infof(" ⚠ 取消挂单失败(继续开仓): %v", err) } // 先设置杠杆 @@ -614,7 +614,7 @@ func (t *AsterTrader) OpenLong(symbol string, quantity float64, leverage int) (m priceStr := t.formatFloatWithPrecision(formattedPrice, prec.PricePrecision) qtyStr := t.formatFloatWithPrecision(formattedQty, prec.QuantityPrecision) - log.Printf(" 📏 精度处理: 价格 %.8f -> %s (精度=%d), 数量 %.8f -> %s (精度=%d)", + logger.Infof(" 📏 精度处理: 价格 %.8f -> %s (精度=%d), 数量 %.8f -> %s (精度=%d)", limitPrice, priceStr, prec.PricePrecision, quantity, qtyStr, prec.QuantityPrecision) params := map[string]interface{}{ @@ -644,7 +644,7 @@ func (t *AsterTrader) OpenLong(symbol string, quantity float64, leverage int) (m func (t *AsterTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { // 开仓前先取消所有挂单,防止残留挂单导致仓位叠加 if err := t.CancelAllOrders(symbol); err != nil { - log.Printf(" ⚠ 取消挂单失败(继续开仓): %v", err) + logger.Infof(" ⚠ 取消挂单失败(继续开仓): %v", err) } // 先设置杠杆 @@ -681,7 +681,7 @@ func (t *AsterTrader) OpenShort(symbol string, quantity float64, leverage int) ( priceStr := t.formatFloatWithPrecision(formattedPrice, prec.PricePrecision) qtyStr := t.formatFloatWithPrecision(formattedQty, prec.QuantityPrecision) - log.Printf(" 📏 精度处理: 价格 %.8f -> %s (精度=%d), 数量 %.8f -> %s (精度=%d)", + logger.Infof(" 📏 精度处理: 价格 %.8f -> %s (精度=%d), 数量 %.8f -> %s (精度=%d)", limitPrice, priceStr, prec.PricePrecision, quantity, qtyStr, prec.QuantityPrecision) params := map[string]interface{}{ @@ -726,7 +726,7 @@ func (t *AsterTrader) CloseLong(symbol string, quantity float64) (map[string]int if quantity == 0 { return nil, fmt.Errorf("没有找到 %s 的多仓", symbol) } - log.Printf(" 📊 获取到多仓数量: %.8f", quantity) + logger.Infof(" 📊 获取到多仓数量: %.8f", quantity) } price, err := t.GetMarketPrice(symbol) @@ -756,7 +756,7 @@ func (t *AsterTrader) CloseLong(symbol string, quantity float64) (map[string]int priceStr := t.formatFloatWithPrecision(formattedPrice, prec.PricePrecision) qtyStr := t.formatFloatWithPrecision(formattedQty, prec.QuantityPrecision) - log.Printf(" 📏 精度处理: 价格 %.8f -> %s (精度=%d), 数量 %.8f -> %s (精度=%d)", + logger.Infof(" 📏 精度处理: 价格 %.8f -> %s (精度=%d), 数量 %.8f -> %s (精度=%d)", limitPrice, priceStr, prec.PricePrecision, quantity, qtyStr, prec.QuantityPrecision) params := map[string]interface{}{ @@ -779,11 +779,11 @@ func (t *AsterTrader) CloseLong(symbol string, quantity float64) (map[string]int return nil, err } - log.Printf("✓ 平多仓成功: %s 数量: %s", symbol, qtyStr) + logger.Infof("✓ 平多仓成功: %s 数量: %s", symbol, qtyStr) // 平仓后取消该币种的所有挂单(止损止盈单) if err := t.CancelAllOrders(symbol); err != nil { - log.Printf(" ⚠ 取消挂单失败: %v", err) + logger.Infof(" ⚠ 取消挂单失败: %v", err) } return result, nil @@ -809,7 +809,7 @@ func (t *AsterTrader) CloseShort(symbol string, quantity float64) (map[string]in if quantity == 0 { return nil, fmt.Errorf("没有找到 %s 的空仓", symbol) } - log.Printf(" 📊 获取到空仓数量: %.8f", quantity) + logger.Infof(" 📊 获取到空仓数量: %.8f", quantity) } price, err := t.GetMarketPrice(symbol) @@ -839,7 +839,7 @@ func (t *AsterTrader) CloseShort(symbol string, quantity float64) (map[string]in priceStr := t.formatFloatWithPrecision(formattedPrice, prec.PricePrecision) qtyStr := t.formatFloatWithPrecision(formattedQty, prec.QuantityPrecision) - log.Printf(" 📏 精度处理: 价格 %.8f -> %s (精度=%d), 数量 %.8f -> %s (精度=%d)", + logger.Infof(" 📏 精度处理: 价格 %.8f -> %s (精度=%d), 数量 %.8f -> %s (精度=%d)", limitPrice, priceStr, prec.PricePrecision, quantity, qtyStr, prec.QuantityPrecision) params := map[string]interface{}{ @@ -862,11 +862,11 @@ func (t *AsterTrader) CloseShort(symbol string, quantity float64) (map[string]in return nil, err } - log.Printf("✓ 平空仓成功: %s 数量: %s", symbol, qtyStr) + logger.Infof("✓ 平空仓成功: %s 数量: %s", symbol, qtyStr) // 平仓后取消该币种的所有挂单(止损止盈单) if err := t.CancelAllOrders(symbol); err != nil { - log.Printf(" ⚠ 取消挂单失败: %v", err) + logger.Infof(" ⚠ 取消挂单失败: %v", err) } return result, nil @@ -892,30 +892,30 @@ func (t *AsterTrader) SetMarginMode(symbol string, isCrossMargin bool) error { // 如果错误表示无需更改,忽略错误 if strings.Contains(err.Error(), "No need to change") || strings.Contains(err.Error(), "Margin type cannot be changed") { - log.Printf(" ✓ %s 仓位模式已是 %s 或有持仓无法更改", symbol, marginType) + logger.Infof(" ✓ %s 仓位模式已是 %s 或有持仓无法更改", symbol, marginType) return nil } // 检测多资产模式(错误码 -4168) if strings.Contains(err.Error(), "Multi-Assets mode") || strings.Contains(err.Error(), "-4168") || strings.Contains(err.Error(), "4168") { - log.Printf(" ⚠️ %s 检测到多资产模式,强制使用全仓模式", symbol) - log.Printf(" 💡 提示:如需使用逐仓模式,请在交易所关闭多资产模式") + logger.Infof(" ⚠️ %s 检测到多资产模式,强制使用全仓模式", symbol) + logger.Infof(" 💡 提示:如需使用逐仓模式,请在交易所关闭多资产模式") return nil } // 检测统一账户 API if strings.Contains(err.Error(), "unified") || strings.Contains(err.Error(), "portfolio") || strings.Contains(err.Error(), "Portfolio") { - log.Printf(" ❌ %s 检测到统一账户 API,无法进行合约交易", symbol) + logger.Infof(" ❌ %s 检测到统一账户 API,无法进行合约交易", symbol) return fmt.Errorf("请使用「现货与合约交易」API 权限,不要使用「统一账户 API」") } - log.Printf(" ⚠️ 设置仓位模式失败: %v", err) + logger.Infof(" ⚠️ 设置仓位模式失败: %v", err) // 不返回错误,让交易继续 return nil } - log.Printf(" ✓ %s 仓位模式已设置为 %s", symbol, marginType) + logger.Infof(" ✓ %s 仓位模式已设置为 %s", symbol, marginType) return nil } @@ -1075,19 +1075,19 @@ func (t *AsterTrader) CancelStopLossOrders(symbol string) error { if err != nil { errMsg := fmt.Sprintf("订单ID %d: %v", int64(orderID), err) cancelErrors = append(cancelErrors, fmt.Errorf("%s", errMsg)) - log.Printf(" ⚠ 取消止损单失败: %s", errMsg) + logger.Infof(" ⚠ 取消止损单失败: %s", errMsg) continue } canceledCount++ - log.Printf(" ✓ 已取消止损单 (订单ID: %d, 类型: %s, 方向: %s)", int64(orderID), orderType, positionSide) + logger.Infof(" ✓ 已取消止损单 (订单ID: %d, 类型: %s, 方向: %s)", int64(orderID), orderType, positionSide) } } if canceledCount == 0 && len(cancelErrors) == 0 { - log.Printf(" ℹ %s 没有止损单需要取消", symbol) + logger.Infof(" ℹ %s 没有止损单需要取消", symbol) } else if canceledCount > 0 { - log.Printf(" ✓ 已取消 %s 的 %d 个止损单", symbol, canceledCount) + logger.Infof(" ✓ 已取消 %s 的 %d 个止损单", symbol, canceledCount) } // 如果所有取消都失败了,返回错误 @@ -1134,19 +1134,19 @@ func (t *AsterTrader) CancelTakeProfitOrders(symbol string) error { if err != nil { errMsg := fmt.Sprintf("订单ID %d: %v", int64(orderID), err) cancelErrors = append(cancelErrors, fmt.Errorf("%s", errMsg)) - log.Printf(" ⚠ 取消止盈单失败: %s", errMsg) + logger.Infof(" ⚠ 取消止盈单失败: %s", errMsg) continue } canceledCount++ - log.Printf(" ✓ 已取消止盈单 (订单ID: %d, 类型: %s, 方向: %s)", int64(orderID), orderType, positionSide) + logger.Infof(" ✓ 已取消止盈单 (订单ID: %d, 类型: %s, 方向: %s)", int64(orderID), orderType, positionSide) } } if canceledCount == 0 && len(cancelErrors) == 0 { - log.Printf(" ℹ %s 没有止盈单需要取消", symbol) + logger.Infof(" ℹ %s 没有止盈单需要取消", symbol) } else if canceledCount > 0 { - log.Printf(" ✓ 已取消 %s 的 %d 个止盈单", symbol, canceledCount) + logger.Infof(" ✓ 已取消 %s 的 %d 个止盈单", symbol, canceledCount) } // 如果所有取消都失败了,返回错误 @@ -1203,20 +1203,20 @@ func (t *AsterTrader) CancelStopOrders(symbol string) error { _, err := t.request("DELETE", "/fapi/v3/order", cancelParams) if err != nil { - log.Printf(" ⚠ 取消订单 %d 失败: %v", int64(orderID), err) + logger.Infof(" ⚠ 取消订单 %d 失败: %v", int64(orderID), err) continue } canceledCount++ - log.Printf(" ✓ 已取消 %s 的止盈/止损单 (订单ID: %d, 类型: %s)", + logger.Infof(" ✓ 已取消 %s 的止盈/止损单 (订单ID: %d, 类型: %s)", symbol, int64(orderID), orderType) } } if canceledCount == 0 { - log.Printf(" ℹ %s 没有止盈/止损单需要取消", symbol) + logger.Infof(" ℹ %s 没有止盈/止损单需要取消", symbol) } else { - log.Printf(" ✓ 已取消 %s 的 %d 个止盈/止损单", symbol, canceledCount) + logger.Infof(" ✓ 已取消 %s 的 %d 个止盈/止损单", symbol, canceledCount) } return nil @@ -1230,3 +1230,52 @@ func (t *AsterTrader) FormatQuantity(symbol string, quantity float64) (string, e } return fmt.Sprintf("%v", formatted), nil } + +// GetOrderStatus 获取订单状态 +func (t *AsterTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) { + params := map[string]interface{}{ + "symbol": symbol, + "orderId": orderID, + } + + body, err := t.request("GET", "/fapi/v3/order", params) + if err != nil { + return nil, fmt.Errorf("获取订单状态失败: %w", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("解析订单响应失败: %w", err) + } + + // 标准化返回字段 + response := map[string]interface{}{ + "orderId": result["orderId"], + "symbol": result["symbol"], + "status": result["status"], + "side": result["side"], + "type": result["type"], + "time": result["time"], + "updateTime": result["updateTime"], + "commission": 0.0, // Aster 可能需要单独查询 + } + + // 解析数值字段 + if avgPrice, ok := result["avgPrice"].(string); ok { + if v, err := strconv.ParseFloat(avgPrice, 64); err == nil { + response["avgPrice"] = v + } + } else if avgPrice, ok := result["avgPrice"].(float64); ok { + response["avgPrice"] = avgPrice + } + + if executedQty, ok := result["executedQty"].(string); ok { + if v, err := strconv.ParseFloat(executedQty, 64); err == nil { + response["executedQty"] = v + } + } else if executedQty, ok := result["executedQty"].(float64); ok { + response["executedQty"] = executedQty + } + + return response, nil +} diff --git a/trader/auto_trader.go b/trader/auto_trader.go index 86ca1cd8..af5d3c55 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -3,13 +3,13 @@ package trader import ( "encoding/json" "fmt" - "log" + "nofx/logger" "math" "nofx/decision" - "nofx/logger" "nofx/market" "nofx/mcp" "nofx/pool" + "nofx/store" "strings" "sync" "time" @@ -96,7 +96,8 @@ type AutoTrader struct { config AutoTraderConfig trader Trader // 使用Trader接口(支持多平台) mcpClient mcp.AIClient - decisionLogger logger.IDecisionLogger // 决策日志记录器 + store *store.Store // 数据存储(决策记录等) + cycleNumber int // 当前周期编号 initialBalance float64 dailyPnL float64 customPrompt string // 自定义交易策略prompt @@ -115,12 +116,12 @@ type AutoTrader struct { peakPnLCache map[string]float64 // 最高收益缓存 (symbol -> 峰值盈亏百分比) peakPnLCacheMutex sync.RWMutex // 缓存读写锁 lastBalanceSyncTime time.Time // 上次余额同步时间 - database interface{} // 数据库引用(用于自动更新余额) userID string // 用户ID } // NewAutoTrader 创建自动交易器 -func NewAutoTrader(config AutoTraderConfig, database interface{}, userID string) (*AutoTrader, error) { +// st 参数用于存储决策记录到数据库 +func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*AutoTrader, error) { // 设置默认值 if config.ID == "" { config.ID = "default_trader" @@ -142,24 +143,24 @@ func NewAutoTrader(config AutoTraderConfig, database interface{}, userID string) if config.AIModel == "custom" { // 使用自定义API mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName) - log.Printf("🤖 [%s] 使用自定义AI API: %s (模型: %s)", config.Name, config.CustomAPIURL, config.CustomModelName) + logger.Infof("🤖 [%s] 使用自定义AI API: %s (模型: %s)", config.Name, config.CustomAPIURL, config.CustomModelName) } else if config.UseQwen || config.AIModel == "qwen" { // 使用Qwen (支持自定义URL和Model) mcpClient = mcp.NewQwenClient() mcpClient.SetAPIKey(config.QwenKey, config.CustomAPIURL, config.CustomModelName) if config.CustomAPIURL != "" || config.CustomModelName != "" { - log.Printf("🤖 [%s] 使用阿里云Qwen AI (自定义URL: %s, 模型: %s)", config.Name, config.CustomAPIURL, config.CustomModelName) + logger.Infof("🤖 [%s] 使用阿里云Qwen AI (自定义URL: %s, 模型: %s)", config.Name, config.CustomAPIURL, config.CustomModelName) } else { - log.Printf("🤖 [%s] 使用阿里云Qwen AI", config.Name) + logger.Infof("🤖 [%s] 使用阿里云Qwen AI", config.Name) } } else { // 默认使用DeepSeek (支持自定义URL和Model) mcpClient = mcp.NewDeepSeekClient() mcpClient.SetAPIKey(config.DeepSeekKey, config.CustomAPIURL, config.CustomModelName) if config.CustomAPIURL != "" || config.CustomModelName != "" { - log.Printf("🤖 [%s] 使用DeepSeek AI (自定义URL: %s, 模型: %s)", config.Name, config.CustomAPIURL, config.CustomModelName) + logger.Infof("🤖 [%s] 使用DeepSeek AI (自定义URL: %s, 模型: %s)", config.Name, config.CustomAPIURL, config.CustomModelName) } else { - log.Printf("🤖 [%s] 使用DeepSeek AI", config.Name) + logger.Infof("🤖 [%s] 使用DeepSeek AI", config.Name) } } @@ -182,33 +183,33 @@ func NewAutoTrader(config AutoTraderConfig, database interface{}, userID string) if !config.IsCrossMargin { marginModeStr = "逐仓" } - log.Printf("📊 [%s] 仓位模式: %s", config.Name, marginModeStr) + logger.Infof("📊 [%s] 仓位模式: %s", config.Name, marginModeStr) switch config.Exchange { case "binance": - log.Printf("🏦 [%s] 使用币安合约交易", config.Name) + logger.Infof("🏦 [%s] 使用币安合约交易", config.Name) trader = NewFuturesTrader(config.BinanceAPIKey, config.BinanceSecretKey, userID) case "bybit": - log.Printf("🏦 [%s] 使用Bybit合约交易", config.Name) + logger.Infof("🏦 [%s] 使用Bybit合约交易", config.Name) trader = NewBybitTrader(config.BybitAPIKey, config.BybitSecretKey) case "hyperliquid": - log.Printf("🏦 [%s] 使用Hyperliquid交易", config.Name) + logger.Infof("🏦 [%s] 使用Hyperliquid交易", config.Name) trader, err = NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet) if err != nil { return nil, fmt.Errorf("初始化Hyperliquid交易器失败: %w", err) } case "aster": - log.Printf("🏦 [%s] 使用Aster交易", config.Name) + logger.Infof("🏦 [%s] 使用Aster交易", config.Name) trader, err = NewAsterTrader(config.AsterUser, config.AsterSigner, config.AsterPrivateKey) if err != nil { return nil, fmt.Errorf("初始化Aster交易器失败: %w", err) } case "lighter": - log.Printf("🏦 [%s] 使用LIGHTER交易", config.Name) + logger.Infof("🏦 [%s] 使用LIGHTER交易", config.Name) // 優先使用 V2(需要 API Key) if config.LighterAPIKeyPrivateKey != "" { - log.Printf("✓ 使用 LIGHTER SDK (V2) - 完整簽名支持") + logger.Infof("✓ 使用 LIGHTER SDK (V2) - 完整簽名支持") trader, err = NewLighterTraderV2( config.LighterPrivateKey, config.LighterWalletAddr, @@ -220,7 +221,7 @@ func NewAutoTrader(config AutoTraderConfig, database interface{}, userID string) } } else { // 降級使用 V1(基本HTTP實現) - log.Printf("⚠️ 使用 LIGHTER 基本實現 (V1) - 功能受限,請配置 API Key") + logger.Infof("⚠️ 使用 LIGHTER 基本實現 (V1) - 功能受限,請配置 API Key") trader, err = NewLighterTrader(config.LighterPrivateKey, config.LighterWalletAddr, config.LighterTestnet) if err != nil { return nil, fmt.Errorf("初始化LIGHTER交易器(V1)失败: %w", err) @@ -235,9 +236,12 @@ func NewAutoTrader(config AutoTraderConfig, database interface{}, userID string) return nil, fmt.Errorf("初始金额必须大于0,请在配置中设置InitialBalance") } - // 初始化决策日志记录器(使用trader ID创建独立目录) - logDir := fmt.Sprintf("decision_logs/%s", config.ID) - decisionLogger := logger.NewDecisionLogger(logDir) + // 获取最后的周期编号(用于恢复) + var cycleNumber int + if st != nil { + cycleNumber, _ = st.Decision().GetLastCycleNumber(config.ID) + logger.Infof("📊 [%s] 决策记录将存储到数据库", config.Name) + } // 设置默认系统提示词模板 systemPromptTemplate := config.SystemPromptTemplate @@ -254,7 +258,8 @@ func NewAutoTrader(config AutoTraderConfig, database interface{}, userID string) config: config, trader: trader, mcpClient: mcpClient, - decisionLogger: decisionLogger, + store: st, + cycleNumber: cycleNumber, initialBalance: config.InitialBalance, systemPromptTemplate: systemPromptTemplate, defaultCoins: config.DefaultCoins, @@ -268,8 +273,7 @@ func NewAutoTrader(config AutoTraderConfig, database interface{}, userID string) monitorWg: sync.WaitGroup{}, peakPnLCache: make(map[string]float64), peakPnLCacheMutex: sync.RWMutex{}, - lastBalanceSyncTime: time.Now(), // 初始化为当前时间 - database: database, + lastBalanceSyncTime: time.Now(), userID: userID, }, nil } @@ -280,10 +284,10 @@ func (at *AutoTrader) Run() error { at.stopMonitorCh = make(chan struct{}) at.startTime = time.Now() - log.Println("🚀 AI驱动自动交易系统启动") - log.Printf("💰 初始余额: %.2f USDT", at.initialBalance) - log.Printf("⚙️ 扫描间隔: %v", at.config.ScanInterval) - log.Println("🤖 AI将全权决定杠杆、仓位大小、止损止盈等参数") + logger.Info("🚀 AI驱动自动交易系统启动") + logger.Infof("💰 初始余额: %.2f USDT", at.initialBalance) + logger.Infof("⚙️ 扫描间隔: %v", at.config.ScanInterval) + logger.Info("🤖 AI将全权决定杠杆、仓位大小、止损止盈等参数") at.monitorWg.Add(1) defer at.monitorWg.Done() @@ -295,17 +299,17 @@ func (at *AutoTrader) Run() error { // 首次立即执行 if err := at.runCycle(); err != nil { - log.Printf("❌ 执行失败: %v", err) + logger.Infof("❌ 执行失败: %v", err) } for at.isRunning { select { case <-ticker.C: if err := at.runCycle(); err != nil { - log.Printf("❌ 执行失败: %v", err) + logger.Infof("❌ 执行失败: %v", err) } case <-at.stopMonitorCh: - log.Printf("[%s] ⏹ 收到停止信号,退出自动交易主循环", at.name) + logger.Infof("[%s] ⏹ 收到停止信号,退出自动交易主循环", at.name) return nil } } @@ -321,19 +325,19 @@ func (at *AutoTrader) Stop() { at.isRunning = false close(at.stopMonitorCh) // 通知监控goroutine停止 at.monitorWg.Wait() // 等待监控goroutine结束 - log.Println("⏹ 自动交易系统停止") + logger.Info("⏹ 自动交易系统停止") } // runCycle 运行一个交易周期(使用AI全权决策) func (at *AutoTrader) runCycle() error { at.callCount++ - log.Print("\n" + strings.Repeat("=", 70) + "\n") - log.Printf("⏰ %s - AI决策周期 #%d", time.Now().Format("2006-01-02 15:04:05"), at.callCount) - log.Println(strings.Repeat("=", 70)) + logger.Info("\n" + strings.Repeat("=", 70) + "\n") + logger.Infof("⏰ %s - AI决策周期 #%d", time.Now().Format("2006-01-02 15:04:05"), at.callCount) + logger.Info(strings.Repeat("=", 70)) // 创建决策记录 - record := &logger.DecisionRecord{ + record := &store.DecisionRecord{ ExecutionLog: []string{}, Success: true, } @@ -341,10 +345,10 @@ func (at *AutoTrader) runCycle() error { // 1. 检查是否需要停止交易 if time.Now().Before(at.stopUntil) { remaining := at.stopUntil.Sub(time.Now()) - log.Printf("⏸ 风险控制:暂停交易中,剩余 %.0f 分钟", remaining.Minutes()) + logger.Infof("⏸ 风险控制:暂停交易中,剩余 %.0f 分钟", remaining.Minutes()) record.Success = false record.ErrorMessage = fmt.Sprintf("风险控制暂停中,剩余 %.0f 分钟", remaining.Minutes()) - at.decisionLogger.LogDecision(record) + at.saveDecision(record) return nil } @@ -352,7 +356,7 @@ func (at *AutoTrader) runCycle() error { if time.Since(at.lastResetTime) > 24*time.Hour { at.dailyPnL = 0 at.lastResetTime = time.Now() - log.Println("📅 日盈亏已重置") + logger.Info("📅 日盈亏已重置") } // 4. 收集交易上下文 @@ -360,12 +364,12 @@ func (at *AutoTrader) runCycle() error { if err != nil { record.Success = false record.ErrorMessage = fmt.Sprintf("构建交易上下文失败: %v", err) - at.decisionLogger.LogDecision(record) + at.saveDecision(record) return fmt.Errorf("构建交易上下文失败: %w", err) } // 保存账户状态快照 - record.AccountState = logger.AccountSnapshot{ + record.AccountState = store.AccountSnapshot{ TotalBalance: ctx.Account.TotalEquity - ctx.Account.UnrealizedPnL, AvailableBalance: ctx.Account.AvailableBalance, TotalUnrealizedProfit: ctx.Account.UnrealizedPnL, @@ -376,7 +380,7 @@ func (at *AutoTrader) runCycle() error { // 保存持仓快照 for _, pos := range ctx.Positions { - record.Positions = append(record.Positions, logger.PositionSnapshot{ + record.Positions = append(record.Positions, store.PositionSnapshot{ Symbol: pos.Symbol, Side: pos.Side, PositionAmt: pos.Quantity, @@ -388,21 +392,21 @@ func (at *AutoTrader) runCycle() error { }) } - log.Print(strings.Repeat("=", 70)) + logger.Info(strings.Repeat("=", 70)) for _, coin := range ctx.CandidateCoins { record.CandidateCoins = append(record.CandidateCoins, coin.Symbol) } - log.Printf("📊 账户净值: %.2f USDT | 可用: %.2f USDT | 持仓: %d", + logger.Infof("📊 账户净值: %.2f USDT | 可用: %.2f USDT | 持仓: %d", ctx.Account.TotalEquity, ctx.Account.AvailableBalance, ctx.Account.PositionCount) // 5. 调用AI获取完整决策 - log.Printf("🤖 正在请求AI分析并决策... [模板: %s]", at.systemPromptTemplate) + logger.Infof("🤖 正在请求AI分析并决策... [模板: %s]", at.systemPromptTemplate) decision, err := decision.GetFullDecisionWithCustomPrompt(ctx, at.mcpClient, at.customPrompt, at.overrideBasePrompt, at.systemPromptTemplate) if decision != nil && decision.AIRequestDurationMs > 0 { record.AIRequestDurationMs = decision.AIRequestDurationMs - log.Printf("⏱️ AI调用耗时: %.2f 秒", float64(record.AIRequestDurationMs)/1000) + logger.Infof("⏱️ AI调用耗时: %.2f 秒", float64(record.AIRequestDurationMs)/1000) record.ExecutionLog = append(record.ExecutionLog, fmt.Sprintf("AI调用耗时: %d ms", record.AIRequestDurationMs)) } @@ -424,65 +428,65 @@ func (at *AutoTrader) runCycle() error { // 打印系统提示词和AI思维链(即使有错误,也要输出以便调试) if decision != nil { - log.Print("\n" + strings.Repeat("=", 70) + "\n") - log.Printf("📋 系统提示词 [模板: %s] (错误情况)", at.systemPromptTemplate) - log.Println(strings.Repeat("=", 70)) - log.Println(decision.SystemPrompt) - log.Println(strings.Repeat("=", 70)) + logger.Info("\n" + strings.Repeat("=", 70) + "\n") + logger.Infof("📋 系统提示词 [模板: %s] (错误情况)", at.systemPromptTemplate) + logger.Info(strings.Repeat("=", 70)) + logger.Info(decision.SystemPrompt) + logger.Info(strings.Repeat("=", 70)) if decision.CoTTrace != "" { - log.Print("\n" + strings.Repeat("-", 70) + "\n") - log.Println("💭 AI思维链分析(错误情况):") - log.Println(strings.Repeat("-", 70)) - log.Println(decision.CoTTrace) - log.Println(strings.Repeat("-", 70)) + logger.Info("\n" + strings.Repeat("-", 70) + "\n") + logger.Info("💭 AI思维链分析(错误情况):") + logger.Info(strings.Repeat("-", 70)) + logger.Info(decision.CoTTrace) + logger.Info(strings.Repeat("-", 70)) } } - at.decisionLogger.LogDecision(record) + at.saveDecision(record) return fmt.Errorf("获取AI决策失败: %w", err) } // // 5. 打印系统提示词 - // log.Printf("\n" + strings.Repeat("=", 70)) - // log.Printf("📋 系统提示词 [模板: %s]", at.systemPromptTemplate) - // log.Println(strings.Repeat("=", 70)) - // log.Println(decision.SystemPrompt) - // log.Printf(strings.Repeat("=", 70) + "\n") + // logger.Infof("\n" + strings.Repeat("=", 70)) + // logger.Infof("📋 系统提示词 [模板: %s]", at.systemPromptTemplate) + // logger.Info(strings.Repeat("=", 70)) + // logger.Info(decision.SystemPrompt) + // logger.Infof(strings.Repeat("=", 70) + "\n") // 6. 打印AI思维链 - // log.Printf("\n" + strings.Repeat("-", 70)) - // log.Println("💭 AI思维链分析:") - // log.Println(strings.Repeat("-", 70)) - // log.Println(decision.CoTTrace) - // log.Printf(strings.Repeat("-", 70) + "\n") + // logger.Infof("\n" + strings.Repeat("-", 70)) + // logger.Info("💭 AI思维链分析:") + // logger.Info(strings.Repeat("-", 70)) + // logger.Info(decision.CoTTrace) + // logger.Infof(strings.Repeat("-", 70) + "\n") // 7. 打印AI决策 - // log.Printf("📋 AI决策列表 (%d 个):\n", len(decision.Decisions)) + // logger.Infof("📋 AI决策列表 (%d 个):\n", len(decision.Decisions)) // for i, d := range decision.Decisions { - // log.Printf(" [%d] %s: %s - %s", i+1, d.Symbol, d.Action, d.Reasoning) + // logger.Infof(" [%d] %s: %s - %s", i+1, d.Symbol, d.Action, d.Reasoning) // if d.Action == "open_long" || d.Action == "open_short" { - // log.Printf(" 杠杆: %dx | 仓位: %.2f USDT | 止损: %.4f | 止盈: %.4f", + // logger.Infof(" 杠杆: %dx | 仓位: %.2f USDT | 止损: %.4f | 止盈: %.4f", // d.Leverage, d.PositionSizeUSD, d.StopLoss, d.TakeProfit) // } // } - log.Println() - log.Print(strings.Repeat("-", 70)) + logger.Info() + logger.Info(strings.Repeat("-", 70)) // 8. 对决策排序:确保先平仓后开仓(防止仓位叠加超限) - log.Print(strings.Repeat("-", 70)) + logger.Info(strings.Repeat("-", 70)) // 8. 对决策排序:确保先平仓后开仓(防止仓位叠加超限) sortedDecisions := sortDecisionsByPriority(decision.Decisions) - log.Println("🔄 执行顺序(已优化): 先平仓→后开仓") + logger.Info("🔄 执行顺序(已优化): 先平仓→后开仓") for i, d := range sortedDecisions { - log.Printf(" [%d] %s %s", i+1, d.Symbol, d.Action) + logger.Infof(" [%d] %s %s", i+1, d.Symbol, d.Action) } - log.Println() + logger.Info() // 执行决策并记录结果 for _, d := range sortedDecisions { - actionRecord := logger.DecisionAction{ + actionRecord := store.DecisionAction{ Action: d.Action, Symbol: d.Symbol, Quantity: 0, @@ -493,7 +497,7 @@ func (at *AutoTrader) runCycle() error { } if err := at.executeDecisionWithRecord(&d, &actionRecord); err != nil { - log.Printf("❌ 执行决策失败 (%s %s): %v", d.Symbol, d.Action, err) + logger.Infof("❌ 执行决策失败 (%s %s): %v", d.Symbol, d.Action, err) actionRecord.Error = err.Error() record.ExecutionLog = append(record.ExecutionLog, fmt.Sprintf("❌ %s %s 失败: %v", d.Symbol, d.Action, err)) } else { @@ -507,8 +511,8 @@ func (at *AutoTrader) runCycle() error { } // 9. 保存决策记录 - if err := at.decisionLogger.LogDecision(record); err != nil { - log.Printf("⚠ 保存决策记录失败: %v", err) + if err := at.saveDecision(record); err != nil { + logger.Infof("⚠ 保存决策记录失败: %v", err) } return nil @@ -636,16 +640,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) { marginUsedPct = (totalMarginUsed / totalEquity) * 100 } - // 5. 分析历史表现(最近100个周期,避免长期持仓的交易记录丢失) - // 假设每3分钟一个周期,100个周期 = 5小时,足够覆盖大部分交易 - performance, err := at.decisionLogger.AnalyzePerformance(100) - if err != nil { - log.Printf("⚠️ 分析历史表现失败: %v", err) - // 不影响主流程,继续执行(但设置performance为nil以避免传递错误数据) - performance = nil - } - - // 6. 构建上下文 + // 5. 构建上下文 ctx := &decision.Context{ CurrentTime: time.Now().Format("2006-01-02 15:04:05"), RuntimeMinutes: int(time.Since(at.startTime).Minutes()), @@ -664,14 +659,45 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) { }, Positions: positionInfos, CandidateCoins: candidateCoins, - Performance: performance, // 添加历史表现分析 + } + + // 6. 添加交易统计和历史订单(如果store可用) + if at.store != nil { + // 获取交易统计(使用新的 positions 表) + if stats, err := at.store.Position().GetFullStats(at.id); err == nil { + ctx.TradingStats = &decision.TradingStats{ + TotalTrades: stats.TotalTrades, + WinRate: stats.WinRate, + ProfitFactor: stats.ProfitFactor, + SharpeRatio: stats.SharpeRatio, + TotalPnL: stats.TotalPnL, + AvgWin: stats.AvgWin, + AvgLoss: stats.AvgLoss, + MaxDrawdownPct: stats.MaxDrawdownPct, + } + } + + // 获取最近10条已平仓交易(使用新的 positions 表) + if recentTrades, err := at.store.Position().GetRecentTrades(at.id, 10); err == nil { + for _, trade := range recentTrades { + ctx.RecentOrders = append(ctx.RecentOrders, decision.RecentOrder{ + Symbol: trade.Symbol, + Side: trade.Side, + EntryPrice: trade.EntryPrice, + ExitPrice: trade.ExitPrice, + RealizedPnL: trade.RealizedPnL, + PnLPct: trade.PnLPct, + FilledAt: trade.ExitTime, + }) + } + } } return ctx, nil } // executeDecisionWithRecord 执行AI决策并记录详细信息 -func (at *AutoTrader) executeDecisionWithRecord(decision *decision.Decision, actionRecord *logger.DecisionAction) error { +func (at *AutoTrader) executeDecisionWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error { switch decision.Action { case "open_long": return at.executeOpenLongWithRecord(decision, actionRecord) @@ -681,12 +707,6 @@ func (at *AutoTrader) executeDecisionWithRecord(decision *decision.Decision, act return at.executeCloseLongWithRecord(decision, actionRecord) case "close_short": return at.executeCloseShortWithRecord(decision, actionRecord) - case "update_stop_loss": - return at.executeUpdateStopLossWithRecord(decision, actionRecord) - case "update_take_profit": - return at.executeUpdateTakeProfitWithRecord(decision, actionRecord) - case "partial_close": - return at.executePartialCloseWithRecord(decision, actionRecord) case "hold", "wait": // 无需执行,仅记录 return nil @@ -696,8 +716,8 @@ func (at *AutoTrader) executeDecisionWithRecord(decision *decision.Decision, act } // executeOpenLongWithRecord 执行开多仓并记录详细信息 -func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, actionRecord *logger.DecisionAction) error { - log.Printf(" 📈 开多仓: %s", decision.Symbol) +func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error { + logger.Infof(" 📈 开多仓: %s", decision.Symbol) // ⚠️ 关键:检查是否已有同币种同方向持仓,如果有则拒绝开仓(防止仓位叠加超限) positions, err := at.trader.GetPositions() @@ -743,7 +763,7 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, act // 设置仓位模式 if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil { - log.Printf(" ⚠️ 设置仓位模式失败: %v", err) + logger.Infof(" ⚠️ 设置仓位模式失败: %v", err) // 继续执行,不影响交易 } @@ -758,7 +778,10 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, act actionRecord.OrderID = orderID } - log.Printf(" ✓ 开仓成功,订单ID: %v, 数量: %.4f", order["orderId"], quantity) + logger.Infof(" ✓ 开仓成功,订单ID: %v, 数量: %.4f", order["orderId"], quantity) + + // 记录订单到数据库并轮询确认 + at.recordAndConfirmOrder(order, decision.Symbol, "open_long", quantity, marketData.CurrentPrice, decision.Leverage, 0) // 记录开仓时间 posKey := decision.Symbol + "_long" @@ -766,18 +789,18 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, act // 设置止损止盈 if err := at.trader.SetStopLoss(decision.Symbol, "LONG", quantity, decision.StopLoss); err != nil { - log.Printf(" ⚠ 设置止损失败: %v", err) + logger.Infof(" ⚠ 设置止损失败: %v", err) } if err := at.trader.SetTakeProfit(decision.Symbol, "LONG", quantity, decision.TakeProfit); err != nil { - log.Printf(" ⚠ 设置止盈失败: %v", err) + logger.Infof(" ⚠ 设置止盈失败: %v", err) } return nil } // executeOpenShortWithRecord 执行开空仓并记录详细信息 -func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, actionRecord *logger.DecisionAction) error { - log.Printf(" 📉 开空仓: %s", decision.Symbol) +func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error { + logger.Infof(" 📉 开空仓: %s", decision.Symbol) // ⚠️ 关键:检查是否已有同币种同方向持仓,如果有则拒绝开仓(防止仓位叠加超限) positions, err := at.trader.GetPositions() @@ -823,7 +846,7 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, ac // 设置仓位模式 if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil { - log.Printf(" ⚠️ 设置仓位模式失败: %v", err) + logger.Infof(" ⚠️ 设置仓位模式失败: %v", err) // 继续执行,不影响交易 } @@ -838,7 +861,10 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, ac actionRecord.OrderID = orderID } - log.Printf(" ✓ 开仓成功,订单ID: %v, 数量: %.4f", order["orderId"], quantity) + logger.Infof(" ✓ 开仓成功,订单ID: %v, 数量: %.4f", order["orderId"], quantity) + + // 记录订单到数据库并轮询确认 + at.recordAndConfirmOrder(order, decision.Symbol, "open_short", quantity, marketData.CurrentPrice, decision.Leverage, 0) // 记录开仓时间 posKey := decision.Symbol + "_short" @@ -846,18 +872,18 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, ac // 设置止损止盈 if err := at.trader.SetStopLoss(decision.Symbol, "SHORT", quantity, decision.StopLoss); err != nil { - log.Printf(" ⚠ 设置止损失败: %v", err) + logger.Infof(" ⚠ 设置止损失败: %v", err) } if err := at.trader.SetTakeProfit(decision.Symbol, "SHORT", quantity, decision.TakeProfit); err != nil { - log.Printf(" ⚠ 设置止盈失败: %v", err) + logger.Infof(" ⚠ 设置止盈失败: %v", err) } return nil } // executeCloseLongWithRecord 执行平多仓并记录详细信息 -func (at *AutoTrader) executeCloseLongWithRecord(decision *decision.Decision, actionRecord *logger.DecisionAction) error { - log.Printf(" 🔄 平多仓: %s", decision.Symbol) +func (at *AutoTrader) executeCloseLongWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error { + logger.Infof(" 🔄 平多仓: %s", decision.Symbol) // 获取当前价格 marketData, err := market.Get(decision.Symbol) @@ -866,6 +892,16 @@ func (at *AutoTrader) executeCloseLongWithRecord(decision *decision.Decision, ac } actionRecord.Price = marketData.CurrentPrice + // 获取开仓价格(用于计算盈亏) + var entryPrice float64 + var quantity float64 + if at.store != nil { + if openOrder, err := at.store.Order().GetLatestOpenOrder(at.id, decision.Symbol, "long"); err == nil { + entryPrice = openOrder.AvgPrice + quantity = openOrder.ExecutedQty + } + } + // 平仓 order, err := at.trader.CloseLong(decision.Symbol, 0) // 0 = 全部平仓 if err != nil { @@ -877,13 +913,16 @@ func (at *AutoTrader) executeCloseLongWithRecord(decision *decision.Decision, ac actionRecord.OrderID = orderID } - log.Printf(" ✓ 平仓成功") + // 记录订单到数据库并轮询确认 + at.recordAndConfirmOrder(order, decision.Symbol, "close_long", quantity, marketData.CurrentPrice, 0, entryPrice) + + logger.Infof(" ✓ 平仓成功") return nil } // executeCloseShortWithRecord 执行平空仓并记录详细信息 -func (at *AutoTrader) executeCloseShortWithRecord(decision *decision.Decision, actionRecord *logger.DecisionAction) error { - log.Printf(" 🔄 平空仓: %s", decision.Symbol) +func (at *AutoTrader) executeCloseShortWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error { + logger.Infof(" 🔄 平空仓: %s", decision.Symbol) // 获取当前价格 marketData, err := market.Get(decision.Symbol) @@ -892,6 +931,16 @@ func (at *AutoTrader) executeCloseShortWithRecord(decision *decision.Decision, a } actionRecord.Price = marketData.CurrentPrice + // 获取开仓价格(用于计算盈亏) + var entryPrice float64 + var quantity float64 + if at.store != nil { + if openOrder, err := at.store.Order().GetLatestOpenOrder(at.id, decision.Symbol, "short"); err == nil { + entryPrice = openOrder.AvgPrice + quantity = openOrder.ExecutedQty + } + } + // 平仓 order, err := at.trader.CloseShort(decision.Symbol, 0) // 0 = 全部平仓 if err != nil { @@ -903,302 +952,10 @@ func (at *AutoTrader) executeCloseShortWithRecord(decision *decision.Decision, a actionRecord.OrderID = orderID } - log.Printf(" ✓ 平仓成功") - return nil -} - -// executeUpdateStopLossWithRecord 执行调整止损并记录详细信息 -func (at *AutoTrader) executeUpdateStopLossWithRecord(decision *decision.Decision, actionRecord *logger.DecisionAction) error { - log.Printf(" 🎯 调整止损: %s → %.2f", decision.Symbol, decision.NewStopLoss) - - // 获取当前价格 - marketData, err := market.Get(decision.Symbol) - if err != nil { - return err - } - actionRecord.Price = marketData.CurrentPrice - - // 获取当前持仓 - positions, err := at.trader.GetPositions() - if err != nil { - return fmt.Errorf("获取持仓失败: %w", err) - } - - // 查找目标持仓 - var targetPosition map[string]interface{} - for _, pos := range positions { - symbol, _ := pos["symbol"].(string) - posAmt, _ := pos["positionAmt"].(float64) - if symbol == decision.Symbol && posAmt != 0 { - targetPosition = pos - break - } - } - - if targetPosition == nil { - return fmt.Errorf("持仓不存在: %s", decision.Symbol) - } - - // 获取持仓方向和数量 - side, _ := targetPosition["side"].(string) - positionSide := strings.ToUpper(side) - positionAmt, _ := targetPosition["positionAmt"].(float64) - - // 验证新止损价格合理性 - if positionSide == "LONG" && decision.NewStopLoss >= marketData.CurrentPrice { - return fmt.Errorf("多单止损必须低于当前价格 (当前: %.2f, 新止损: %.2f)", marketData.CurrentPrice, decision.NewStopLoss) - } - if positionSide == "SHORT" && decision.NewStopLoss <= marketData.CurrentPrice { - return fmt.Errorf("空单止损必须高于当前价格 (当前: %.2f, 新止损: %.2f)", marketData.CurrentPrice, decision.NewStopLoss) - } - - // ⚠️ 防御性检查:检测是否存在双向持仓(不应该出现,但提供保护) - var hasOppositePosition bool - oppositeSide := "" - for _, pos := range positions { - symbol, _ := pos["symbol"].(string) - posSide, _ := pos["side"].(string) - posAmt, _ := pos["positionAmt"].(float64) - if symbol == decision.Symbol && posAmt != 0 && strings.ToUpper(posSide) != positionSide { - hasOppositePosition = true - oppositeSide = strings.ToUpper(posSide) - break - } - } - - if hasOppositePosition { - log.Printf(" 🚨 警告:检测到 %s 存在双向持仓(%s + %s),这违反了策略规则", - decision.Symbol, positionSide, oppositeSide) - log.Printf(" 🚨 取消止损单将影响两个方向的订单,请检查是否为用户手动操作导致") - log.Printf(" 🚨 建议:手动平掉其中一个方向的持仓,或检查系统是否有BUG") - } - - // 取消旧的止损单(只删除止损单,不影响止盈单) - // 注意:如果存在双向持仓,这会删除两个方向的止损单 - if err := at.trader.CancelStopLossOrders(decision.Symbol); err != nil { - log.Printf(" ⚠ 取消旧止损单失败: %v", err) - // 不中断执行,继续设置新止损 - } - - // 调用交易所 API 修改止损 - quantity := math.Abs(positionAmt) - err = at.trader.SetStopLoss(decision.Symbol, positionSide, quantity, decision.NewStopLoss) - if err != nil { - return fmt.Errorf("修改止损失败: %w", err) - } - - log.Printf(" ✓ 止损已调整: %.2f (当前价格: %.2f)", decision.NewStopLoss, marketData.CurrentPrice) - return nil -} - -// executeUpdateTakeProfitWithRecord 执行调整止盈并记录详细信息 -func (at *AutoTrader) executeUpdateTakeProfitWithRecord(decision *decision.Decision, actionRecord *logger.DecisionAction) error { - log.Printf(" 🎯 调整止盈: %s → %.2f", decision.Symbol, decision.NewTakeProfit) - - // 获取当前价格 - marketData, err := market.Get(decision.Symbol) - if err != nil { - return err - } - actionRecord.Price = marketData.CurrentPrice - - // 获取当前持仓 - positions, err := at.trader.GetPositions() - if err != nil { - return fmt.Errorf("获取持仓失败: %w", err) - } - - // 查找目标持仓 - var targetPosition map[string]interface{} - for _, pos := range positions { - symbol, _ := pos["symbol"].(string) - posAmt, _ := pos["positionAmt"].(float64) - if symbol == decision.Symbol && posAmt != 0 { - targetPosition = pos - break - } - } - - if targetPosition == nil { - return fmt.Errorf("持仓不存在: %s", decision.Symbol) - } - - // 获取持仓方向和数量 - side, _ := targetPosition["side"].(string) - positionSide := strings.ToUpper(side) - positionAmt, _ := targetPosition["positionAmt"].(float64) - - // 验证新止盈价格合理性 - if positionSide == "LONG" && decision.NewTakeProfit <= marketData.CurrentPrice { - return fmt.Errorf("多单止盈必须高于当前价格 (当前: %.2f, 新止盈: %.2f)", marketData.CurrentPrice, decision.NewTakeProfit) - } - if positionSide == "SHORT" && decision.NewTakeProfit >= marketData.CurrentPrice { - return fmt.Errorf("空单止盈必须低于当前价格 (当前: %.2f, 新止盈: %.2f)", marketData.CurrentPrice, decision.NewTakeProfit) - } - - // ⚠️ 防御性检查:检测是否存在双向持仓(不应该出现,但提供保护) - var hasOppositePosition bool - oppositeSide := "" - for _, pos := range positions { - symbol, _ := pos["symbol"].(string) - posSide, _ := pos["side"].(string) - posAmt, _ := pos["positionAmt"].(float64) - if symbol == decision.Symbol && posAmt != 0 && strings.ToUpper(posSide) != positionSide { - hasOppositePosition = true - oppositeSide = strings.ToUpper(posSide) - break - } - } - - if hasOppositePosition { - log.Printf(" 🚨 警告:检测到 %s 存在双向持仓(%s + %s),这违反了策略规则", - decision.Symbol, positionSide, oppositeSide) - log.Printf(" 🚨 取消止盈单将影响两个方向的订单,请检查是否为用户手动操作导致") - log.Printf(" 🚨 建议:手动平掉其中一个方向的持仓,或检查系统是否有BUG") - } - - // 取消旧的止盈单(只删除止盈单,不影响止损单) - // 注意:如果存在双向持仓,这会删除两个方向的止盈单 - if err := at.trader.CancelTakeProfitOrders(decision.Symbol); err != nil { - log.Printf(" ⚠ 取消旧止盈单失败: %v", err) - // 不中断执行,继续设置新止盈 - } - - // 调用交易所 API 修改止盈 - quantity := math.Abs(positionAmt) - err = at.trader.SetTakeProfit(decision.Symbol, positionSide, quantity, decision.NewTakeProfit) - if err != nil { - return fmt.Errorf("修改止盈失败: %w", err) - } - - log.Printf(" ✓ 止盈已调整: %.2f (当前价格: %.2f)", decision.NewTakeProfit, marketData.CurrentPrice) - return nil -} - -// executePartialCloseWithRecord 执行部分平仓并记录详细信息 -func (at *AutoTrader) executePartialCloseWithRecord(decision *decision.Decision, actionRecord *logger.DecisionAction) error { - log.Printf(" 📊 部分平仓: %s %.1f%%", decision.Symbol, decision.ClosePercentage) - - // 验证百分比范围 - if decision.ClosePercentage <= 0 || decision.ClosePercentage > 100 { - return fmt.Errorf("平仓百分比必须在 0-100 之间,当前: %.1f", decision.ClosePercentage) - } - - // 获取当前价格 - marketData, err := market.Get(decision.Symbol) - if err != nil { - return err - } - actionRecord.Price = marketData.CurrentPrice - - // 获取当前持仓 - positions, err := at.trader.GetPositions() - if err != nil { - return fmt.Errorf("获取持仓失败: %w", err) - } - - // 查找目标持仓 - var targetPosition map[string]interface{} - for _, pos := range positions { - symbol, _ := pos["symbol"].(string) - posAmt, _ := pos["positionAmt"].(float64) - if symbol == decision.Symbol && posAmt != 0 { - targetPosition = pos - break - } - } - - if targetPosition == nil { - return fmt.Errorf("持仓不存在: %s", decision.Symbol) - } - - // 获取持仓方向和数量 - side, _ := targetPosition["side"].(string) - positionSide := strings.ToUpper(side) - positionAmt, _ := targetPosition["positionAmt"].(float64) - - // 计算平仓数量 - totalQuantity := math.Abs(positionAmt) - closeQuantity := totalQuantity * (decision.ClosePercentage / 100.0) - actionRecord.Quantity = closeQuantity - - // ✅ Layer 2: 最小仓位检查(防止产生小额剩余) - markPrice, ok := targetPosition["markPrice"].(float64) - if !ok || markPrice <= 0 { - return fmt.Errorf("无法解析当前价格,无法执行最小仓位检查") - } - - currentPositionValue := totalQuantity * markPrice - remainingQuantity := totalQuantity - closeQuantity - remainingValue := remainingQuantity * markPrice - - const MIN_POSITION_VALUE = 10.0 // 最小持仓价值 10 USDT(對齊交易所底线,小仓位建议直接全平) - - if remainingValue > 0 && remainingValue <= MIN_POSITION_VALUE { - log.Printf("⚠️ 检测到 partial_close 后剩余仓位 %.2f USDT < %.0f USDT", - remainingValue, MIN_POSITION_VALUE) - log.Printf(" → 当前仓位价值: %.2f USDT, 平仓 %.1f%%, 剩余: %.2f USDT", - currentPositionValue, decision.ClosePercentage, remainingValue) - log.Printf(" → 自动修正为全部平仓,避免产生无法平仓的小额剩余") - - // 🔄 自动修正为全部平仓 - if positionSide == "LONG" { - decision.Action = "close_long" - log.Printf(" ✓ 已修正为: close_long") - return at.executeCloseLongWithRecord(decision, actionRecord) - } else { - decision.Action = "close_short" - log.Printf(" ✓ 已修正为: close_short") - return at.executeCloseShortWithRecord(decision, actionRecord) - } - } - - // 执行平仓 - var order map[string]interface{} - if positionSide == "LONG" { - order, err = at.trader.CloseLong(decision.Symbol, closeQuantity) - } else { - order, err = at.trader.CloseShort(decision.Symbol, closeQuantity) - } - - if err != nil { - return fmt.Errorf("部分平仓失败: %w", err) - } - - // 记录订单ID - if orderID, ok := order["orderId"].(int64); ok { - actionRecord.OrderID = orderID - } - - log.Printf(" ✓ 部分平仓成功: 平仓 %.4f (%.1f%%), 剩余 %.4f", - closeQuantity, decision.ClosePercentage, remainingQuantity) - - // ✅ Step 4: 恢复止盈止损(防止剩余仓位裸奔) - // 重要:币安等交易所在部分平仓后会自动取消原有的 TP/SL 订单(因为数量不匹配) - // 如果 AI 提供了新的止损止盈价格,则为剩余仓位重新设置保护 - if decision.NewStopLoss > 0 { - log.Printf(" → 为剩余仓位 %.4f 恢复止损单: %.2f", remainingQuantity, decision.NewStopLoss) - err = at.trader.SetStopLoss(decision.Symbol, positionSide, remainingQuantity, decision.NewStopLoss) - if err != nil { - log.Printf(" ⚠️ 恢复止损失败: %v(不影响平仓结果)", err) - } - } - - if decision.NewTakeProfit > 0 { - log.Printf(" → 为剩余仓位 %.4f 恢复止盈单: %.2f", remainingQuantity, decision.NewTakeProfit) - err = at.trader.SetTakeProfit(decision.Symbol, positionSide, remainingQuantity, decision.NewTakeProfit) - if err != nil { - log.Printf(" ⚠️ 恢复止盈失败: %v(不影响平仓结果)", err) - } - } - - // 如果 AI 没有提供新的止盈止损,记录警告 - if decision.NewStopLoss <= 0 && decision.NewTakeProfit <= 0 { - log.Printf(" ⚠️⚠️⚠️ 警告: 部分平仓后AI未提供新的止盈止损价格") - log.Printf(" → 剩余仓位 %.4f (价值 %.2f USDT) 目前没有止盈止损保护", remainingQuantity, remainingValue) - log.Printf(" → 建议: 在 partial_close 决策中包含 new_stop_loss 和 new_take_profit 字段") - } + // 记录订单到数据库并轮询确认 + at.recordAndConfirmOrder(order, decision.Symbol, "close_short", quantity, marketData.CurrentPrice, 0, entryPrice) + logger.Infof(" ✓ 平仓成功") return nil } @@ -1242,9 +999,32 @@ func (at *AutoTrader) GetSystemPromptTemplate() string { return at.systemPromptTemplate } -// GetDecisionLogger 获取决策日志记录器 -func (at *AutoTrader) GetDecisionLogger() logger.IDecisionLogger { - return at.decisionLogger +// saveDecision 保存决策记录到数据库 +func (at *AutoTrader) saveDecision(record *store.DecisionRecord) error { + if at.store == nil { + return nil // 没有 store 时静默忽略 + } + + at.cycleNumber++ + record.CycleNumber = at.cycleNumber + record.TraderID = at.id + + if record.Timestamp.IsZero() { + record.Timestamp = time.Now().UTC() + } + + if err := at.store.Decision().LogDecision(record); err != nil { + logger.Infof("⚠️ 保存决策记录失败: %v", err) + return err + } + + logger.Infof("📝 决策记录已保存: trader=%s, cycle=%d", at.id, at.cycleNumber) + return nil +} + +// GetStore 获取数据存储(用于外部访问决策记录等) +func (at *AutoTrader) GetStore() *store.Store { + return at.store } // GetStatus 获取系统状态(用于API) @@ -1324,7 +1104,7 @@ func (at *AutoTrader) GetAccountInfo() (map[string]interface{}, error) { // 验证未实现盈亏的一致性(API值 vs 从持仓计算) diff := math.Abs(totalUnrealizedProfit - totalUnrealizedPnLCalculated) if diff > 0.1 { // 允许0.01 USDT的误差 - log.Printf("⚠️ 未实现盈亏不一致: API=%.4f, 计算=%.4f, 差异=%.4f", + logger.Infof("⚠️ 未实现盈亏不一致: API=%.4f, 计算=%.4f, 差异=%.4f", totalUnrealizedProfit, totalUnrealizedPnLCalculated, diff) } @@ -1333,7 +1113,7 @@ func (at *AutoTrader) GetAccountInfo() (map[string]interface{}, error) { if at.initialBalance > 0 { totalPnLPct = (totalPnL / at.initialBalance) * 100 } else { - log.Printf("⚠️ Initial Balance异常: %.2f,无法计算PNL百分比", at.initialBalance) + logger.Infof("⚠️ Initial Balance异常: %.2f,无法计算PNL百分比", at.initialBalance) } marginUsedPct := 0.0 @@ -1428,14 +1208,12 @@ func sortDecisionsByPriority(decisions []decision.Decision) []decision.Decision // 定义优先级 getActionPriority := func(action string) int { switch action { - case "close_long", "close_short", "partial_close": - return 1 // 最高优先级:先平仓(包括部分平仓) - case "update_stop_loss", "update_take_profit": - return 2 // 调整持仓止盈止损 + case "close_long", "close_short": + return 1 // 最高优先级:先平仓 case "open_long", "open_short": - return 3 // 次优先级:后开仓 + return 2 // 次优先级:后开仓 case "hold", "wait": - return 4 // 最低优先级:观望 + return 3 // 最低优先级:观望 default: return 999 // 未知动作放最后 } @@ -1472,7 +1250,7 @@ func (at *AutoTrader) getCandidateCoins() ([]decision.CandidateCoin, error) { Sources: []string{"default"}, // 标记为数据库默认币种 }) } - log.Printf("📋 [%s] 使用数据库默认币种: %d个币种 %v", + logger.Infof("📋 [%s] 使用数据库默认币种: %d个币种 %v", at.name, len(candidateCoins), at.defaultCoins) return candidateCoins, nil } else { @@ -1493,7 +1271,7 @@ func (at *AutoTrader) getCandidateCoins() ([]decision.CandidateCoin, error) { }) } - log.Printf("📋 [%s] 数据库无默认币种配置,使用AI500+OI Top: AI500前%d + OI_Top20 = 总计%d个候选币种", + logger.Infof("📋 [%s] 数据库无默认币种配置,使用AI500+OI Top: AI500前%d + OI_Top20 = 总计%d个候选币种", at.name, ai500Limit, len(candidateCoins)) return candidateCoins, nil } @@ -1509,7 +1287,7 @@ func (at *AutoTrader) getCandidateCoins() ([]decision.CandidateCoin, error) { }) } - log.Printf("📋 [%s] 使用自定义币种: %d个币种 %v", + logger.Infof("📋 [%s] 使用自定义币种: %d个币种 %v", at.name, len(candidateCoins), at.tradingCoins) return candidateCoins, nil } @@ -1537,14 +1315,14 @@ func (at *AutoTrader) startDrawdownMonitor() { ticker := time.NewTicker(1 * time.Minute) // 每分钟检查一次 defer ticker.Stop() - log.Println("📊 启动持仓回撤监控(每分钟检查一次)") + logger.Info("📊 启动持仓回撤监控(每分钟检查一次)") for { select { case <-ticker.C: at.checkPositionDrawdown() case <-at.stopMonitorCh: - log.Println("⏹ 停止持仓回撤监控") + logger.Info("⏹ 停止持仓回撤监控") return } } @@ -1556,7 +1334,7 @@ func (at *AutoTrader) checkPositionDrawdown() { // 获取当前持仓 positions, err := at.trader.GetPositions() if err != nil { - log.Printf("❌ 回撤监控:获取持仓失败: %v", err) + logger.Infof("❌ 回撤监控:获取持仓失败: %v", err) return } @@ -1608,20 +1386,20 @@ func (at *AutoTrader) checkPositionDrawdown() { // 检查平仓条件:收益大于5%且回撤超过40% if currentPnLPct > 5.0 && drawdownPct >= 40.0 { - log.Printf("🚨 触发回撤平仓条件: %s %s | 当前收益: %.2f%% | 最高收益: %.2f%% | 回撤: %.2f%%", + logger.Infof("🚨 触发回撤平仓条件: %s %s | 当前收益: %.2f%% | 最高收益: %.2f%% | 回撤: %.2f%%", symbol, side, currentPnLPct, peakPnLPct, drawdownPct) // 执行平仓 if err := at.emergencyClosePosition(symbol, side); err != nil { - log.Printf("❌ 回撤平仓失败 (%s %s): %v", symbol, side, err) + logger.Infof("❌ 回撤平仓失败 (%s %s): %v", symbol, side, err) } else { - log.Printf("✅ 回撤平仓成功: %s %s", symbol, side) + logger.Infof("✅ 回撤平仓成功: %s %s", symbol, side) // 平仓后清理该持仓的缓存 at.ClearPeakPnLCache(symbol, side) } } else if currentPnLPct > 5.0 { // 记录接近平仓条件的情况(用于调试) - log.Printf("📊 回撤监控: %s %s | 收益: %.2f%% | 最高: %.2f%% | 回撤: %.2f%%", + logger.Infof("📊 回撤监控: %s %s | 收益: %.2f%% | 最高: %.2f%% | 回撤: %.2f%%", symbol, side, currentPnLPct, peakPnLPct, drawdownPct) } } @@ -1635,13 +1413,13 @@ func (at *AutoTrader) emergencyClosePosition(symbol, side string) error { if err != nil { return err } - log.Printf("✅ 紧急平多仓成功,订单ID: %v", order["orderId"]) + logger.Infof("✅ 紧急平多仓成功,订单ID: %v", order["orderId"]) case "short": order, err := at.trader.CloseShort(symbol, 0) // 0 = 全部平仓 if err != nil { return err } - log.Printf("✅ 紧急平空仓成功,订单ID: %v", order["orderId"]) + logger.Infof("✅ 紧急平空仓成功,订单ID: %v", order["orderId"]) default: return fmt.Errorf("未知的持仓方向: %s", side) } @@ -1687,3 +1465,135 @@ func (at *AutoTrader) ClearPeakPnLCache(symbol, side string) { posKey := symbol + "_" + side delete(at.peakPnLCache, posKey) } + +// recordAndConfirmOrder 记录订单并轮询确认状态 +// action: open_long, open_short, close_long, close_short +// entryPrice: 平仓时的开仓价(开仓时为0) +func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{}, symbol, action string, quantity float64, price float64, leverage int, entryPrice float64) { + if at.store == nil { + return + } + + // 获取订单ID(支持多种类型) + var orderID string + switch v := orderResult["orderId"].(type) { + case int64: + orderID = fmt.Sprintf("%d", v) + case float64: + orderID = fmt.Sprintf("%.0f", v) + case string: + orderID = v + default: + orderID = fmt.Sprintf("%v", v) + } + + if orderID == "" || orderID == "0" { + logger.Infof(" ⚠️ 订单ID为空,跳过记录") + return + } + + // 确定 side 和 positionSide + var side, positionSide string + switch action { + case "open_long": + side = "BUY" + positionSide = "LONG" + case "close_long": + side = "SELL" + positionSide = "LONG" + case "open_short": + side = "SELL" + positionSide = "SHORT" + case "close_short": + side = "BUY" + positionSide = "SHORT" + } + + // 创建订单记录 + order := &store.TraderOrder{ + TraderID: at.id, + OrderID: orderID, + Symbol: symbol, + Side: side, + PositionSide: positionSide, + Action: action, + OrderType: "MARKET", + Quantity: quantity, + Price: price, + Leverage: leverage, + Status: "NEW", + EntryPrice: entryPrice, + } + + // 保存到数据库 + if err := at.store.Order().Create(order); err != nil { + logger.Infof(" ⚠️ 记录订单失败: %v", err) + return + } + + logger.Infof(" 📝 订单已记录 (ID: %s, action: %s)", orderID, action) + + // 记录仓位变化 + at.recordPositionChange(orderID, symbol, positionSide, action, quantity, price, leverage, entryPrice) +} + +// recordPositionChange 记录仓位变化(开仓创建记录,平仓更新记录) +func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string, quantity, price float64, leverage int, entryPrice float64) { + if at.store == nil { + return + } + + switch action { + case "open_long", "open_short": + // 开仓:创建新的仓位记录 + pos := &store.TraderPosition{ + TraderID: at.id, + Symbol: symbol, + Side: side, // LONG or SHORT + Quantity: quantity, + EntryPrice: price, + EntryOrderID: orderID, + EntryTime: time.Now(), + Leverage: leverage, + Status: "OPEN", + } + if err := at.store.Position().Create(pos); err != nil { + logger.Infof(" ⚠️ 记录仓位失败: %v", err) + } else { + logger.Infof(" 📊 仓位已记录 [%s] %s %s @ %.4f", at.id[:8], symbol, side, price) + } + + case "close_long", "close_short": + // 平仓:找到对应的开仓记录并更新 + openPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, side) + if err != nil || openPos == nil { + logger.Infof(" ⚠️ 找不到对应的开仓记录 (%s %s)", symbol, side) + return + } + + // 计算盈亏 + var realizedPnL float64 + if side == "LONG" { + realizedPnL = (price - openPos.EntryPrice) * openPos.Quantity + } else { + realizedPnL = (openPos.EntryPrice - price) * openPos.Quantity + } + + // 更新仓位记录 + err = at.store.Position().ClosePosition( + openPos.ID, + price, // exitPrice + orderID, // exitOrderID + realizedPnL, + 0, // fee (暂不计算) + "ai_decision", + ) + if err != nil { + logger.Infof(" ⚠️ 更新仓位失败: %v", err) + } else { + logger.Infof(" 📊 仓位已平仓 [%s] %s %s @ %.4f → %.4f, PnL: %.2f", + at.id[:8], symbol, side, openPos.EntryPrice, price, realizedPnL) + } + } +} + diff --git a/trader/auto_trader_test.go b/trader/auto_trader_test.go index 9316981f..8981ca81 100644 --- a/trader/auto_trader_test.go +++ b/trader/auto_trader_test.go @@ -8,9 +8,9 @@ import ( "time" "nofx/decision" - "nofx/logger" "nofx/market" "nofx/pool" + "nofx/store" "github.com/agiledragon/gomonkey/v2" "github.com/stretchr/testify/suite" @@ -30,8 +30,7 @@ type AutoTraderTestSuite struct { // Mock 依赖 mockTrader *MockTrader - mockDB *MockDatabase - mockLogger logger.IDecisionLogger + mockStore *store.Store // gomonkey patches patches *gomonkey.Patches @@ -65,10 +64,9 @@ func (s *AutoTraderTestSuite) SetupTest() { positions: []map[string]interface{}{}, } - s.mockDB = &MockDatabase{} - // 创建临时决策日志记录器 - s.mockLogger = logger.NewDecisionLogger("/tmp/test_decision_logs") + // 创建临时store(使用nil表示测试中不需要实际的store) + s.mockStore = nil // 设置默认配置 s.config = AutoTraderConfig{ @@ -93,7 +91,7 @@ func (s *AutoTraderTestSuite) SetupTest() { config: s.config, trader: s.mockTrader, mcpClient: nil, // 测试中不需要实际的 MCP Client - decisionLogger: s.mockLogger, + store: s.mockStore, initialBalance: s.config.InitialBalance, systemPromptTemplate: s.config.SystemPromptTemplate, defaultCoins: []string{"BTC", "ETH"}, @@ -106,7 +104,6 @@ func (s *AutoTraderTestSuite) SetupTest() { stopMonitorCh: make(chan struct{}), peakPnLCache: make(map[string]float64), lastBalanceSyncTime: time.Now(), - database: s.mockDB, userID: "test_user", } } @@ -134,9 +131,8 @@ func (s *AutoTraderTestSuite) TestSortDecisionsByPriority() { {Action: "open_long", Symbol: "BTCUSDT"}, {Action: "close_short", Symbol: "ETHUSDT"}, {Action: "hold", Symbol: "BNBUSDT"}, - {Action: "update_stop_loss", Symbol: "SOLUSDT"}, {Action: "open_short", Symbol: "ADAUSDT"}, - {Action: "partial_close", Symbol: "DOGEUSDT"}, + {Action: "close_long", Symbol: "DOGEUSDT"}, }, }, } @@ -150,14 +146,12 @@ func (s *AutoTraderTestSuite) TestSortDecisionsByPriority() { // 验证优先级是否递增 getActionPriority := func(action string) int { switch action { - case "close_long", "close_short", "partial_close": + case "close_long", "close_short": return 1 - case "update_stop_loss", "update_take_profit": - return 2 case "open_long", "open_short": - return 3 + return 2 case "hold", "wait": - return 4 + return 3 default: return 999 } @@ -413,14 +407,14 @@ func (s *AutoTraderTestSuite) TestExecuteOpenPosition() { existingSide string availBalance float64 expectedErr string - executeFn func(*decision.Decision, *logger.DecisionAction) error + executeFn func(*decision.Decision, *store.DecisionAction) error }{ { name: "成功开多仓", action: "open_long", expectedOrder: 123456, availBalance: 8000.0, - executeFn: func(d *decision.Decision, a *logger.DecisionAction) error { + executeFn: func(d *decision.Decision, a *store.DecisionAction) error { return s.autoTrader.executeOpenLongWithRecord(d, a) }, }, @@ -429,7 +423,7 @@ func (s *AutoTraderTestSuite) TestExecuteOpenPosition() { action: "open_short", expectedOrder: 123457, availBalance: 8000.0, - executeFn: func(d *decision.Decision, a *logger.DecisionAction) error { + executeFn: func(d *decision.Decision, a *store.DecisionAction) error { return s.autoTrader.executeOpenShortWithRecord(d, a) }, }, @@ -438,7 +432,7 @@ func (s *AutoTraderTestSuite) TestExecuteOpenPosition() { action: "open_long", availBalance: 0.0, expectedErr: "保证金不足", - executeFn: func(d *decision.Decision, a *logger.DecisionAction) error { + executeFn: func(d *decision.Decision, a *store.DecisionAction) error { return s.autoTrader.executeOpenLongWithRecord(d, a) }, }, @@ -447,7 +441,7 @@ func (s *AutoTraderTestSuite) TestExecuteOpenPosition() { action: "open_short", availBalance: 0.0, expectedErr: "保证金不足", - executeFn: func(d *decision.Decision, a *logger.DecisionAction) error { + executeFn: func(d *decision.Decision, a *store.DecisionAction) error { return s.autoTrader.executeOpenShortWithRecord(d, a) }, }, @@ -457,7 +451,7 @@ func (s *AutoTraderTestSuite) TestExecuteOpenPosition() { existingSide: "long", availBalance: 8000.0, expectedErr: "已有多仓", - executeFn: func(d *decision.Decision, a *logger.DecisionAction) error { + executeFn: func(d *decision.Decision, a *store.DecisionAction) error { return s.autoTrader.executeOpenLongWithRecord(d, a) }, }, @@ -467,7 +461,7 @@ func (s *AutoTraderTestSuite) TestExecuteOpenPosition() { existingSide: "short", availBalance: 8000.0, expectedErr: "已有空仓", - executeFn: func(d *decision.Decision, a *logger.DecisionAction) error { + executeFn: func(d *decision.Decision, a *store.DecisionAction) error { return s.autoTrader.executeOpenShortWithRecord(d, a) }, }, @@ -488,7 +482,7 @@ func (s *AutoTraderTestSuite) TestExecuteOpenPosition() { } decision := &decision.Decision{Action: tt.action, Symbol: "BTCUSDT", PositionSizeUSD: 1000.0, Leverage: 10} - actionRecord := &logger.DecisionAction{Action: tt.action, Symbol: "BTCUSDT"} + actionRecord := &store.DecisionAction{Action: tt.action, Symbol: "BTCUSDT"} err := tt.executeFn(decision, actionRecord) @@ -516,14 +510,14 @@ func (s *AutoTraderTestSuite) TestExecuteClosePosition() { action string currentPrice float64 expectedOrder int64 - executeFn func(*decision.Decision, *logger.DecisionAction) error + executeFn func(*decision.Decision, *store.DecisionAction) error }{ { name: "成功平多仓", action: "close_long", currentPrice: 51000.0, expectedOrder: 123458, - executeFn: func(d *decision.Decision, a *logger.DecisionAction) error { + executeFn: func(d *decision.Decision, a *store.DecisionAction) error { return s.autoTrader.executeCloseLongWithRecord(d, a) }, }, @@ -532,7 +526,7 @@ func (s *AutoTraderTestSuite) TestExecuteClosePosition() { action: "close_short", currentPrice: 49000.0, expectedOrder: 123459, - executeFn: func(d *decision.Decision, a *logger.DecisionAction) error { + executeFn: func(d *decision.Decision, a *store.DecisionAction) error { return s.autoTrader.executeCloseShortWithRecord(d, a) }, }, @@ -546,7 +540,7 @@ func (s *AutoTraderTestSuite) TestExecuteClosePosition() { }) decision := &decision.Decision{Action: tt.action, Symbol: "BTCUSDT"} - actionRecord := &logger.DecisionAction{Action: tt.action, Symbol: "BTCUSDT"} + actionRecord := &store.DecisionAction{Action: tt.action, Symbol: "BTCUSDT"} err := tt.executeFn(decision, actionRecord) @@ -557,221 +551,6 @@ func (s *AutoTraderTestSuite) TestExecuteClosePosition() { } } -// TestExecuteUpdateStopOrTakeProfit 测试更新止损/止盈(多空通用) -func (s *AutoTraderTestSuite) TestExecuteUpdateStopOrTakeProfit() { - // 使用指针变量来控制 market.Get 的返回值 - var testPrice *float64 - s.patches.ApplyFunc(market.Get, func(symbol string) (*market.Data, error) { - price := 50000.0 - if testPrice != nil { - price = *testPrice - } - return &market.Data{Symbol: symbol, CurrentPrice: price}, nil - }) - - tests := []struct { - name string - action string - symbol string - side string - currentPrice float64 - newPrice float64 - hasPosition bool - expectedErr string - executeFn func(*decision.Decision, *logger.DecisionAction) error - }{ - { - name: "成功更新多头止损", - action: "update_stop_loss", - symbol: "BTCUSDT", - side: "long", - currentPrice: 52000.0, - newPrice: 51000.0, - hasPosition: true, - executeFn: func(d *decision.Decision, a *logger.DecisionAction) error { - return s.autoTrader.executeUpdateStopLossWithRecord(d, a) - }, - }, - { - name: "成功更新空头止损", - action: "update_stop_loss", - symbol: "ETHUSDT", - side: "short", - currentPrice: 2900.0, - newPrice: 2950.0, - hasPosition: true, - executeFn: func(d *decision.Decision, a *logger.DecisionAction) error { - return s.autoTrader.executeUpdateStopLossWithRecord(d, a) - }, - }, - { - name: "成功更新多头止盈", - action: "update_take_profit", - symbol: "BTCUSDT", - side: "long", - currentPrice: 52000.0, - newPrice: 55000.0, - hasPosition: true, - executeFn: func(d *decision.Decision, a *logger.DecisionAction) error { - return s.autoTrader.executeUpdateTakeProfitWithRecord(d, a) - }, - }, - { - name: "成功更新空头止盈", - action: "update_take_profit", - symbol: "ETHUSDT", - side: "short", - currentPrice: 2900.0, - newPrice: 2800.0, - hasPosition: true, - executeFn: func(d *decision.Decision, a *logger.DecisionAction) error { - return s.autoTrader.executeUpdateTakeProfitWithRecord(d, a) - }, - }, - { - name: "多头止损价格不合理", - action: "update_stop_loss", - symbol: "BTCUSDT", - side: "long", - currentPrice: 50000.0, - newPrice: 51000.0, - hasPosition: true, - expectedErr: "多单止损必须低于当前价格", - executeFn: func(d *decision.Decision, a *logger.DecisionAction) error { - return s.autoTrader.executeUpdateStopLossWithRecord(d, a) - }, - }, - { - name: "多头止盈价格不合理", - action: "update_take_profit", - symbol: "BTCUSDT", - side: "long", - currentPrice: 50000.0, - newPrice: 49000.0, - hasPosition: true, - expectedErr: "多单止盈必须高于当前价格", - executeFn: func(d *decision.Decision, a *logger.DecisionAction) error { - return s.autoTrader.executeUpdateTakeProfitWithRecord(d, a) - }, - }, - { - name: "止损_持仓不存在", - action: "update_stop_loss", - symbol: "BTCUSDT", - currentPrice: 50000.0, - newPrice: 49000.0, - hasPosition: false, - expectedErr: "持仓不存在", - executeFn: func(d *decision.Decision, a *logger.DecisionAction) error { - return s.autoTrader.executeUpdateStopLossWithRecord(d, a) - }, - }, - { - name: "止盈_持仓不存在", - action: "update_take_profit", - symbol: "BTCUSDT", - currentPrice: 50000.0, - newPrice: 55000.0, - hasPosition: false, - expectedErr: "持仓不存在", - executeFn: func(d *decision.Decision, a *logger.DecisionAction) error { - return s.autoTrader.executeUpdateTakeProfitWithRecord(d, a) - }, - }, - } - - for _, tt := range tests { - time.Sleep(time.Millisecond) - s.Run(tt.name, func() { - // 设置当前测试用例的价格 - testPrice = &tt.currentPrice - - if tt.hasPosition { - s.mockTrader.positions = []map[string]interface{}{ - {"symbol": tt.symbol, "side": tt.side, "positionAmt": 0.1}, - } - } else { - s.mockTrader.positions = []map[string]interface{}{} - } - - decision := &decision.Decision{Action: tt.action, Symbol: tt.symbol} - if tt.action == "update_stop_loss" { - decision.NewStopLoss = tt.newPrice - } else { - decision.NewTakeProfit = tt.newPrice - } - actionRecord := &logger.DecisionAction{Action: tt.action, Symbol: tt.symbol} - - err := tt.executeFn(decision, actionRecord) - - if tt.expectedErr != "" { - s.Error(err) - s.Contains(err.Error(), tt.expectedErr) - } else { - s.NoError(err) - s.Equal(tt.currentPrice, actionRecord.Price) - } - - // 恢复默认状态 - s.mockTrader.positions = []map[string]interface{}{} - }) - } -} - -func (s *AutoTraderTestSuite) TestExecutePartialCloseWithRecord() { - s.Run("成功部分平仓", func() { - // 设置持仓 - s.mockTrader.positions = []map[string]interface{}{ - { - "symbol": "BTCUSDT", - "side": "long", - "positionAmt": 0.1, - "entryPrice": 50000.0, - "markPrice": 52000.0, - }, - } - - // Mock market.Get - s.patches.ApplyFunc(market.Get, func(symbol string) (*market.Data, error) { - return &market.Data{ - Symbol: symbol, - CurrentPrice: 52000.0, - }, nil - }) - - decision := &decision.Decision{ - Action: "partial_close", - Symbol: "BTCUSDT", - ClosePercentage: 50.0, - } - - actionRecord := &logger.DecisionAction{ - Action: "partial_close", - Symbol: "BTCUSDT", - } - - err := s.autoTrader.executePartialCloseWithRecord(decision, actionRecord) - - s.NoError(err) - s.Equal(0.05, actionRecord.Quantity) // 50% of 0.1 - }) - - s.Run("无效的平仓百分比", func() { - decision := &decision.Decision{ - Action: "partial_close", - Symbol: "BTCUSDT", - ClosePercentage: 150.0, // 无效 - } - - actionRecord := &logger.DecisionAction{} - - err := s.autoTrader.executePartialCloseWithRecord(decision, actionRecord) - - s.Error(err) - s.Contains(err.Error(), "平仓百分比必须在 0-100 之间") - }) -} - // ============================================================ // 层次 10: executeDecisionWithRecord 路由测试 // ============================================================ @@ -792,7 +571,7 @@ func (s *AutoTraderTestSuite) TestExecuteDecisionWithRecord() { PositionSizeUSD: 1000.0, Leverage: 10, } - actionRecord := &logger.DecisionAction{} + actionRecord := &store.DecisionAction{} err := s.autoTrader.executeDecisionWithRecord(decision, actionRecord) s.NoError(err) @@ -803,7 +582,7 @@ func (s *AutoTraderTestSuite) TestExecuteDecisionWithRecord() { Action: "close_long", Symbol: "BTCUSDT", } - actionRecord := &logger.DecisionAction{} + actionRecord := &store.DecisionAction{} err := s.autoTrader.executeDecisionWithRecord(decision, actionRecord) s.NoError(err) @@ -814,7 +593,7 @@ func (s *AutoTraderTestSuite) TestExecuteDecisionWithRecord() { Action: "hold", Symbol: "BTCUSDT", } - actionRecord := &logger.DecisionAction{} + actionRecord := &store.DecisionAction{} err := s.autoTrader.executeDecisionWithRecord(decision, actionRecord) s.NoError(err) @@ -825,7 +604,7 @@ func (s *AutoTraderTestSuite) TestExecuteDecisionWithRecord() { Action: "unknown_action", Symbol: "BTCUSDT", } - actionRecord := &logger.DecisionAction{} + actionRecord := &store.DecisionAction{} err := s.autoTrader.executeDecisionWithRecord(decision, actionRecord) s.Error(err) diff --git a/trader/binance_futures.go b/trader/binance_futures.go index f2489f6b..1d5a256e 100644 --- a/trader/binance_futures.go +++ b/trader/binance_futures.go @@ -5,8 +5,8 @@ import ( "crypto/rand" "encoding/hex" "fmt" - "log" "nofx/hook" + "nofx/logger" "strconv" "strings" "sync" @@ -80,7 +80,7 @@ func NewFuturesTrader(apiKey, secretKey string, userId string) *FuturesTrader { // 设置双向持仓模式(Hedge Mode) // 这是必需的,因为代码中使用了 PositionSide (LONG/SHORT) if err := trader.setDualSidePosition(); err != nil { - log.Printf("⚠️ 设置双向持仓模式失败: %v (如果已是双向模式则忽略此警告)", err) + logger.Infof("⚠️ 设置双向持仓模式失败: %v (如果已是双向模式则忽略此警告)", err) } return trader @@ -96,15 +96,15 @@ func (t *FuturesTrader) setDualSidePosition() error { if err != nil { // 如果错误信息包含"No need to change",说明已经是双向持仓模式 if strings.Contains(err.Error(), "No need to change position side") { - log.Printf(" ✓ 账户已是双向持仓模式(Hedge Mode)") + logger.Infof(" ✓ 账户已是双向持仓模式(Hedge Mode)") return nil } // 其他错误则返回(但在调用方不会中断初始化) return err } - log.Printf(" ✓ 账户已切换为双向持仓模式(Hedge Mode)") - log.Printf(" ℹ️ 双向持仓模式允许同时持有多单和空单") + logger.Infof(" ✓ 账户已切换为双向持仓模式(Hedge Mode)") + logger.Infof(" ℹ️ 双向持仓模式允许同时持有多单和空单") return nil } @@ -112,14 +112,14 @@ func (t *FuturesTrader) setDualSidePosition() error { func syncBinanceServerTime(client *futures.Client) { serverTime, err := client.NewServerTimeService().Do(context.Background()) if err != nil { - log.Printf("⚠️ 同步币安服务器时间失败: %v", err) + logger.Infof("⚠️ 同步币安服务器时间失败: %v", err) return } now := time.Now().UnixMilli() offset := now - serverTime client.TimeOffset = offset - log.Printf("⏱ 已同步币安服务器时间,偏移 %dms", offset) + logger.Infof("⏱ 已同步币安服务器时间,偏移 %dms", offset) } // GetBalance 获取账户余额(带缓存) @@ -129,16 +129,16 @@ func (t *FuturesTrader) GetBalance() (map[string]interface{}, error) { if t.cachedBalance != nil && time.Since(t.balanceCacheTime) < t.cacheDuration { cacheAge := time.Since(t.balanceCacheTime) t.balanceCacheMutex.RUnlock() - log.Printf("✓ 使用缓存的账户余额(缓存时间: %.1f秒前)", cacheAge.Seconds()) + logger.Infof("✓ 使用缓存的账户余额(缓存时间: %.1f秒前)", cacheAge.Seconds()) return t.cachedBalance, nil } t.balanceCacheMutex.RUnlock() // 缓存过期或不存在,调用API - log.Printf("🔄 缓存过期,正在调用币安API获取账户余额...") + logger.Infof("🔄 缓存过期,正在调用币安API获取账户余额...") account, err := t.client.NewGetAccountService().Do(context.Background()) if err != nil { - log.Printf("❌ 币安API调用失败: %v", err) + logger.Infof("❌ 币安API调用失败: %v", err) return nil, fmt.Errorf("获取账户信息失败: %w", err) } @@ -147,7 +147,7 @@ func (t *FuturesTrader) GetBalance() (map[string]interface{}, error) { result["availableBalance"], _ = strconv.ParseFloat(account.AvailableBalance, 64) result["totalUnrealizedProfit"], _ = strconv.ParseFloat(account.TotalUnrealizedProfit, 64) - log.Printf("✓ 币安API返回: 总余额=%s, 可用=%s, 未实现盈亏=%s", + logger.Infof("✓ 币安API返回: 总余额=%s, 可用=%s, 未实现盈亏=%s", account.TotalWalletBalance, account.AvailableBalance, account.TotalUnrealizedProfit) @@ -168,13 +168,13 @@ func (t *FuturesTrader) GetPositions() ([]map[string]interface{}, error) { if t.cachedPositions != nil && time.Since(t.positionsCacheTime) < t.cacheDuration { cacheAge := time.Since(t.positionsCacheTime) t.positionsCacheMutex.RUnlock() - log.Printf("✓ 使用缓存的持仓信息(缓存时间: %.1f秒前)", cacheAge.Seconds()) + logger.Infof("✓ 使用缓存的持仓信息(缓存时间: %.1f秒前)", cacheAge.Seconds()) return t.cachedPositions, nil } t.positionsCacheMutex.RUnlock() // 缓存过期或不存在,调用API - log.Printf("🔄 缓存过期,正在调用币安API获取持仓信息...") + logger.Infof("🔄 缓存过期,正在调用币安API获取持仓信息...") positions, err := t.client.NewGetPositionRiskService().Do(context.Background()) if err != nil { return nil, fmt.Errorf("获取持仓失败: %w", err) @@ -238,31 +238,31 @@ func (t *FuturesTrader) SetMarginMode(symbol string, isCrossMargin bool) error { if err != nil { // 如果错误信息包含"No need to change",说明仓位模式已经是目标值 if contains(err.Error(), "No need to change margin type") { - log.Printf(" ✓ %s 仓位模式已是 %s", symbol, marginModeStr) + logger.Infof(" ✓ %s 仓位模式已是 %s", symbol, marginModeStr) return nil } // 如果有持仓,无法更改仓位模式,但不影响交易 if contains(err.Error(), "Margin type cannot be changed if there exists position") { - log.Printf(" ⚠️ %s 有持仓,无法更改仓位模式,继续使用当前模式", symbol) + logger.Infof(" ⚠️ %s 有持仓,无法更改仓位模式,继续使用当前模式", symbol) return nil } // 检测多资产模式(错误码 -4168) if contains(err.Error(), "Multi-Assets mode") || contains(err.Error(), "-4168") || contains(err.Error(), "4168") { - log.Printf(" ⚠️ %s 检测到多资产模式,强制使用全仓模式", symbol) - log.Printf(" 💡 提示:如需使用逐仓模式,请在币安关闭多资产模式") + logger.Infof(" ⚠️ %s 检测到多资产模式,强制使用全仓模式", symbol) + logger.Infof(" 💡 提示:如需使用逐仓模式,请在币安关闭多资产模式") return nil } // 检测统一账户 API(Portfolio Margin) if contains(err.Error(), "unified") || contains(err.Error(), "portfolio") || contains(err.Error(), "Portfolio") { - log.Printf(" ❌ %s 检测到统一账户 API,无法进行合约交易", symbol) + logger.Infof(" ❌ %s 检测到统一账户 API,无法进行合约交易", symbol) return fmt.Errorf("请使用「现货与合约交易」API 权限,不要使用「统一账户 API」") } - log.Printf(" ⚠️ 设置仓位模式失败: %v", err) + logger.Infof(" ⚠️ 设置仓位模式失败: %v", err) // 不返回错误,让交易继续 return nil } - log.Printf(" ✓ %s 仓位模式已设置为 %s", symbol, marginModeStr) + logger.Infof(" ✓ %s 仓位模式已设置为 %s", symbol, marginModeStr) return nil } @@ -284,7 +284,7 @@ func (t *FuturesTrader) SetLeverage(symbol string, leverage int) error { // 如果当前杠杆已经是目标杠杆,跳过 if currentLeverage == leverage && currentLeverage > 0 { - log.Printf(" ✓ %s 杠杆已是 %dx,无需切换", symbol, leverage) + logger.Infof(" ✓ %s 杠杆已是 %dx,无需切换", symbol, leverage) return nil } @@ -297,16 +297,16 @@ func (t *FuturesTrader) SetLeverage(symbol string, leverage int) error { if err != nil { // 如果错误信息包含"No need to change",说明杠杆已经是目标值 if contains(err.Error(), "No need to change") { - log.Printf(" ✓ %s 杠杆已是 %dx", symbol, leverage) + logger.Infof(" ✓ %s 杠杆已是 %dx", symbol, leverage) return nil } return fmt.Errorf("设置杠杆失败: %w", err) } - log.Printf(" ✓ %s 杠杆已切换为 %dx", symbol, leverage) + logger.Infof(" ✓ %s 杠杆已切换为 %dx", symbol, leverage) // 切换杠杆后等待5秒(避免冷却期错误) - log.Printf(" ⏱ 等待5秒冷却期...") + logger.Infof(" ⏱ 等待5秒冷却期...") time.Sleep(5 * time.Second) return nil @@ -316,7 +316,7 @@ func (t *FuturesTrader) SetLeverage(symbol string, leverage int) error { func (t *FuturesTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { // 先取消该币种的所有委托单(清理旧的止损止盈单) if err := t.CancelAllOrders(symbol); err != nil { - log.Printf(" ⚠ 取消旧委托单失败(可能没有委托单): %v", err) + logger.Infof(" ⚠ 取消旧委托单失败(可能没有委托单): %v", err) } // 设置杠杆 @@ -357,8 +357,8 @@ func (t *FuturesTrader) OpenLong(symbol string, quantity float64, leverage int) return nil, fmt.Errorf("开多仓失败: %w", err) } - log.Printf("✓ 开多仓成功: %s 数量: %s", symbol, quantityStr) - log.Printf(" 订单ID: %d", order.OrderID) + logger.Infof("✓ 开多仓成功: %s 数量: %s", symbol, quantityStr) + logger.Infof(" 订单ID: %d", order.OrderID) result := make(map[string]interface{}) result["orderId"] = order.OrderID @@ -371,7 +371,7 @@ func (t *FuturesTrader) OpenLong(symbol string, quantity float64, leverage int) func (t *FuturesTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { // 先取消该币种的所有委托单(清理旧的止损止盈单) if err := t.CancelAllOrders(symbol); err != nil { - log.Printf(" ⚠ 取消旧委托单失败(可能没有委托单): %v", err) + logger.Infof(" ⚠ 取消旧委托单失败(可能没有委托单): %v", err) } // 设置杠杆 @@ -412,8 +412,8 @@ func (t *FuturesTrader) OpenShort(symbol string, quantity float64, leverage int) return nil, fmt.Errorf("开空仓失败: %w", err) } - log.Printf("✓ 开空仓成功: %s 数量: %s", symbol, quantityStr) - log.Printf(" 订单ID: %d", order.OrderID) + logger.Infof("✓ 开空仓成功: %s 数量: %s", symbol, quantityStr) + logger.Infof(" 订单ID: %d", order.OrderID) result := make(map[string]interface{}) result["orderId"] = order.OrderID @@ -463,11 +463,11 @@ func (t *FuturesTrader) CloseLong(symbol string, quantity float64) (map[string]i return nil, fmt.Errorf("平多仓失败: %w", err) } - log.Printf("✓ 平多仓成功: %s 数量: %s", symbol, quantityStr) + logger.Infof("✓ 平多仓成功: %s 数量: %s", symbol, quantityStr) // 平仓后取消该币种的所有挂单(止损止盈单) if err := t.CancelAllOrders(symbol); err != nil { - log.Printf(" ⚠ 取消挂单失败: %v", err) + logger.Infof(" ⚠ 取消挂单失败: %v", err) } result := make(map[string]interface{}) @@ -518,11 +518,11 @@ func (t *FuturesTrader) CloseShort(symbol string, quantity float64) (map[string] return nil, fmt.Errorf("平空仓失败: %w", err) } - log.Printf("✓ 平空仓成功: %s 数量: %s", symbol, quantityStr) + logger.Infof("✓ 平空仓成功: %s 数量: %s", symbol, quantityStr) // 平仓后取消该币种的所有挂单(止损止盈单) if err := t.CancelAllOrders(symbol); err != nil { - log.Printf(" ⚠ 取消挂单失败: %v", err) + logger.Infof(" ⚠ 取消挂单失败: %v", err) } result := make(map[string]interface{}) @@ -559,19 +559,19 @@ func (t *FuturesTrader) CancelStopLossOrders(symbol string) error { if err != nil { errMsg := fmt.Sprintf("订单ID %d: %v", order.OrderID, err) cancelErrors = append(cancelErrors, fmt.Errorf("%s", errMsg)) - log.Printf(" ⚠ 取消止损单失败: %s", errMsg) + logger.Infof(" ⚠ 取消止损单失败: %s", errMsg) continue } canceledCount++ - log.Printf(" ✓ 已取消止损单 (订单ID: %d, 类型: %s, 方向: %s)", order.OrderID, orderType, order.PositionSide) + logger.Infof(" ✓ 已取消止损单 (订单ID: %d, 类型: %s, 方向: %s)", order.OrderID, orderType, order.PositionSide) } } if canceledCount == 0 && len(cancelErrors) == 0 { - log.Printf(" ℹ %s 没有止损单需要取消", symbol) + logger.Infof(" ℹ %s 没有止损单需要取消", symbol) } else if canceledCount > 0 { - log.Printf(" ✓ 已取消 %s 的 %d 个止损单", symbol, canceledCount) + logger.Infof(" ✓ 已取消 %s 的 %d 个止损单", symbol, canceledCount) } // 如果所有取消都失败了,返回错误 @@ -609,19 +609,19 @@ func (t *FuturesTrader) CancelTakeProfitOrders(symbol string) error { if err != nil { errMsg := fmt.Sprintf("订单ID %d: %v", order.OrderID, err) cancelErrors = append(cancelErrors, fmt.Errorf("%s", errMsg)) - log.Printf(" ⚠ 取消止盈单失败: %s", errMsg) + logger.Infof(" ⚠ 取消止盈单失败: %s", errMsg) continue } canceledCount++ - log.Printf(" ✓ 已取消止盈单 (订单ID: %d, 类型: %s, 方向: %s)", order.OrderID, orderType, order.PositionSide) + logger.Infof(" ✓ 已取消止盈单 (订单ID: %d, 类型: %s, 方向: %s)", order.OrderID, orderType, order.PositionSide) } } if canceledCount == 0 && len(cancelErrors) == 0 { - log.Printf(" ℹ %s 没有止盈单需要取消", symbol) + logger.Infof(" ℹ %s 没有止盈单需要取消", symbol) } else if canceledCount > 0 { - log.Printf(" ✓ 已取消 %s 的 %d 个止盈单", symbol, canceledCount) + logger.Infof(" ✓ 已取消 %s 的 %d 个止盈单", symbol, canceledCount) } // 如果所有取消都失败了,返回错误 @@ -642,7 +642,7 @@ func (t *FuturesTrader) CancelAllOrders(symbol string) error { return fmt.Errorf("取消挂单失败: %w", err) } - log.Printf(" ✓ 已取消 %s 的所有挂单", symbol) + logger.Infof(" ✓ 已取消 %s 的所有挂单", symbol) return nil } @@ -674,20 +674,20 @@ func (t *FuturesTrader) CancelStopOrders(symbol string) error { Do(context.Background()) if err != nil { - log.Printf(" ⚠ 取消订单 %d 失败: %v", order.OrderID, err) + logger.Infof(" ⚠ 取消订单 %d 失败: %v", order.OrderID, err) continue } canceledCount++ - log.Printf(" ✓ 已取消 %s 的止盈/止损单 (订单ID: %d, 类型: %s)", + logger.Infof(" ✓ 已取消 %s 的止盈/止损单 (订单ID: %d, 类型: %s)", symbol, order.OrderID, orderType) } } if canceledCount == 0 { - log.Printf(" ℹ %s 没有止盈/止损单需要取消", symbol) + logger.Infof(" ℹ %s 没有止盈/止损单需要取消", symbol) } else { - log.Printf(" ✓ 已取消 %s 的 %d 个止盈/止损单", symbol, canceledCount) + logger.Infof(" ✓ 已取消 %s 的 %d 个止盈/止损单", symbol, canceledCount) } return nil @@ -748,13 +748,14 @@ func (t *FuturesTrader) SetStopLoss(symbol string, positionSide string, quantity Quantity(quantityStr). WorkingType(futures.WorkingTypeContractPrice). ClosePosition(true). + NewClientOrderID(getBrOrderID()). Do(context.Background()) if err != nil { return fmt.Errorf("设置止损失败: %w", err) } - log.Printf(" 止损价设置: %.4f", stopPrice) + logger.Infof(" 止损价设置: %.4f", stopPrice) return nil } @@ -786,13 +787,14 @@ func (t *FuturesTrader) SetTakeProfit(symbol string, positionSide string, quanti Quantity(quantityStr). WorkingType(futures.WorkingTypeContractPrice). ClosePosition(true). + NewClientOrderID(getBrOrderID()). Do(context.Background()) if err != nil { return fmt.Errorf("设置止盈失败: %w", err) } - log.Printf(" 止盈价设置: %.4f", takeProfitPrice) + logger.Infof(" 止盈价设置: %.4f", takeProfitPrice) return nil } @@ -836,14 +838,14 @@ func (t *FuturesTrader) GetSymbolPrecision(symbol string) (int, error) { if filter["filterType"] == "LOT_SIZE" { stepSize := filter["stepSize"].(string) precision := calculatePrecision(stepSize) - log.Printf(" %s 数量精度: %d (stepSize: %s)", symbol, precision, stepSize) + logger.Infof(" %s 数量精度: %d (stepSize: %s)", symbol, precision, stepSize) return precision, nil } } } } - log.Printf(" ⚠ %s 未找到精度信息,使用默认精度3", symbol) + logger.Infof(" ⚠ %s 未找到精度信息,使用默认精度3", symbol) return 3, nil // 默认精度为3 } @@ -915,3 +917,42 @@ func stringContains(s, substr string) bool { } return false } + +// GetOrderStatus 获取订单状态 +func (t *FuturesTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) { + // 将 orderID 转换为 int64 + orderIDInt, err := strconv.ParseInt(orderID, 10, 64) + if err != nil { + return nil, fmt.Errorf("无效的订单ID: %s", orderID) + } + + order, err := t.client.NewGetOrderService(). + Symbol(symbol). + OrderID(orderIDInt). + Do(context.Background()) + if err != nil { + return nil, fmt.Errorf("获取订单状态失败: %w", err) + } + + // 解析成交价格 + avgPrice, _ := strconv.ParseFloat(order.AvgPrice, 64) + executedQty, _ := strconv.ParseFloat(order.ExecutedQuantity, 64) + + result := map[string]interface{}{ + "orderId": order.OrderID, + "symbol": order.Symbol, + "status": string(order.Status), + "avgPrice": avgPrice, + "executedQty": executedQty, + "side": string(order.Side), + "type": string(order.Type), + "time": order.Time, + "updateTime": order.UpdateTime, + } + + // 币安合约的手续费需要通过 GetUserTrades 获取,这里暂时不获取 + // 后续可以通过 WebSocket 或单独查询获取 + result["commission"] = 0.0 + + return result, nil +} diff --git a/trader/bybit_trader.go b/trader/bybit_trader.go index 7c055d0b..cdebd65a 100644 --- a/trader/bybit_trader.go +++ b/trader/bybit_trader.go @@ -3,7 +3,7 @@ package trader import ( "context" "fmt" - "log" + "nofx/logger" "net/http" "strconv" "strings" @@ -55,7 +55,7 @@ func NewBybitTrader(apiKey, secretKey string) *BybitTrader { cacheDuration: 15 * time.Second, } - log.Printf("🔵 [Bybit] 交易器已初始化") + logger.Infof("🔵 [Bybit] 交易器已初始化") return trader } @@ -224,7 +224,7 @@ func (t *BybitTrader) GetPositions() ([]map[string]interface{}, error) { func (t *BybitTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { // 先设置杠杆 if err := t.SetLeverage(symbol, leverage); err != nil { - log.Printf("⚠️ [Bybit] 设置杠杆失败: %v", err) + logger.Infof("⚠️ [Bybit] 设置杠杆失败: %v", err) } params := map[string]interface{}{ @@ -251,7 +251,7 @@ func (t *BybitTrader) OpenLong(symbol string, quantity float64, leverage int) (m func (t *BybitTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { // 先设置杠杆 if err := t.SetLeverage(symbol, leverage); err != nil { - log.Printf("⚠️ [Bybit] 设置杠杆失败: %v", err) + logger.Infof("⚠️ [Bybit] 设置杠杆失败: %v", err) } params := map[string]interface{}{ @@ -485,7 +485,7 @@ func (t *BybitTrader) SetStopLoss(symbol string, positionSide string, quantity, return fmt.Errorf("设置止损失败: %s", result.RetMsg) } - log.Printf(" ✓ [Bybit] 止损单已设置: %s @ %.2f", symbol, stopPrice) + logger.Infof(" ✓ [Bybit] 止损单已设置: %s @ %.2f", symbol, stopPrice) return nil } @@ -528,7 +528,7 @@ func (t *BybitTrader) SetTakeProfit(symbol string, positionSide string, quantity return fmt.Errorf("设置止盈失败: %s", result.RetMsg) } - log.Printf(" ✓ [Bybit] 止盈单已设置: %s @ %.2f", symbol, takeProfitPrice) + logger.Infof(" ✓ [Bybit] 止盈单已设置: %s @ %.2f", symbol, takeProfitPrice) return nil } @@ -560,10 +560,10 @@ func (t *BybitTrader) CancelAllOrders(symbol string) error { // CancelStopOrders 取消所有止盈止损单 func (t *BybitTrader) CancelStopOrders(symbol string) error { if err := t.CancelStopLossOrders(symbol); err != nil { - log.Printf("⚠️ [Bybit] 取消止损单失败: %v", err) + logger.Infof("⚠️ [Bybit] 取消止损单失败: %v", err) } if err := t.CancelTakeProfitOrders(symbol); err != nil { - log.Printf("⚠️ [Bybit] 取消止盈单失败: %v", err) + logger.Infof("⚠️ [Bybit] 取消止盈单失败: %v", err) } return nil } @@ -604,6 +604,67 @@ func (t *BybitTrader) parseOrderResult(result *bybit.ServerResponse) (map[string }, nil } +// GetOrderStatus 获取订单状态 +func (t *BybitTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) { + params := map[string]interface{}{ + "category": "linear", + "symbol": symbol, + "orderId": orderID, + } + + result, err := t.client.NewUtaBybitServiceWithParams(params).GetOrderHistory(context.Background()) + if err != nil { + return nil, fmt.Errorf("获取订单状态失败: %w", err) + } + + if result.RetCode != 0 { + return nil, fmt.Errorf("API 错误: %s", result.RetMsg) + } + + resultData, ok := result.Result.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("返回格式错误") + } + + list, _ := resultData["list"].([]interface{}) + if len(list) == 0 { + return nil, fmt.Errorf("未找到订单 %s", orderID) + } + + order, _ := list[0].(map[string]interface{}) + + // 解析订单数据 + status, _ := order["orderStatus"].(string) + avgPriceStr, _ := order["avgPrice"].(string) + cumExecQtyStr, _ := order["cumExecQty"].(string) + cumExecFeeStr, _ := order["cumExecFee"].(string) + + avgPrice, _ := strconv.ParseFloat(avgPriceStr, 64) + executedQty, _ := strconv.ParseFloat(cumExecQtyStr, 64) + commission, _ := strconv.ParseFloat(cumExecFeeStr, 64) + + // 转换状态为统一格式 + unifiedStatus := status + switch status { + case "Filled": + unifiedStatus = "FILLED" + case "New", "Created": + unifiedStatus = "NEW" + case "Cancelled", "Rejected": + unifiedStatus = "CANCELED" + case "PartiallyFilled": + unifiedStatus = "PARTIALLY_FILLED" + } + + return map[string]interface{}{ + "orderId": orderID, + "status": unifiedStatus, + "avgPrice": avgPrice, + "executedQty": executedQty, + "commission": commission, + }, nil +} + func (t *BybitTrader) cancelConditionalOrders(symbol string, orderType string) error { // 先获取所有条件单 params := map[string]interface{}{ diff --git a/trader/hyperliquid_trader.go b/trader/hyperliquid_trader.go index 885ce0d8..d075c54d 100644 --- a/trader/hyperliquid_trader.go +++ b/trader/hyperliquid_trader.go @@ -5,7 +5,7 @@ import ( "crypto/ecdsa" "encoding/json" "fmt" - "log" + "nofx/logger" "strconv" "strings" "sync" @@ -56,14 +56,14 @@ func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool) // Check if user accidentally uses main wallet private key (security risk) if strings.EqualFold(walletAddr, agentAddr) { - log.Printf("⚠️⚠️⚠️ WARNING: Main wallet address (%s) matches Agent wallet address!", walletAddr) - log.Printf(" This indicates you may be using your main wallet private key, which poses extremely high security risks!") - log.Printf(" Recommendation: Immediately create a separate Agent Wallet on Hyperliquid official website") - log.Printf(" Reference: https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/nonces-and-api-wallets") + logger.Infof("⚠️⚠️⚠️ WARNING: Main wallet address (%s) matches Agent wallet address!", walletAddr) + logger.Infof(" This indicates you may be using your main wallet private key, which poses extremely high security risks!") + logger.Infof(" Recommendation: Immediately create a separate Agent Wallet on Hyperliquid official website") + logger.Infof(" Reference: https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/nonces-and-api-wallets") } else { - log.Printf("✓ Using Agent Wallet mode (secure)") - log.Printf(" └─ Agent wallet address: %s (for signing)", agentAddr) - log.Printf(" └─ Main wallet address: %s (holds funds)", walletAddr) + logger.Infof("✓ Using Agent Wallet mode (secure)") + logger.Infof(" └─ Agent wallet address: %s (for signing)", agentAddr) + logger.Infof(" └─ Main wallet address: %s (holds funds)", walletAddr) } ctx := context.Background() @@ -79,7 +79,7 @@ func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool) nil, // SpotMeta will be fetched automatically ) - log.Printf("✓ Hyperliquid交易器初始化成功 (testnet=%v, wallet=%s)", testnet, walletAddr) + logger.Infof("✓ Hyperliquid交易器初始化成功 (testnet=%v, wallet=%s)", testnet, walletAddr) // 获取meta信息(包含精度等配置) meta, err := exchange.Info().Meta(ctx) @@ -97,26 +97,26 @@ func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool) if agentBalance > 100 { // Critical: Agent wallet holds too much funds - log.Printf("🚨🚨🚨 CRITICAL SECURITY WARNING 🚨🚨🚨") - log.Printf(" Agent wallet balance: %.2f USDC (exceeds safe threshold of 100 USDC)", agentBalance) - log.Printf(" Agent wallet address: %s", agentAddr) - log.Printf(" ⚠️ Agent wallets should only be used for signing and hold minimal/zero balance") - log.Printf(" ⚠️ High balance in Agent wallet poses security risks") - log.Printf(" 📖 Reference: https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/nonces-and-api-wallets") - log.Printf(" 💡 Recommendation: Transfer funds to main wallet and keep Agent wallet balance near 0") + logger.Infof("🚨🚨🚨 CRITICAL SECURITY WARNING 🚨🚨🚨") + logger.Infof(" Agent wallet balance: %.2f USDC (exceeds safe threshold of 100 USDC)", agentBalance) + logger.Infof(" Agent wallet address: %s", agentAddr) + logger.Infof(" ⚠️ Agent wallets should only be used for signing and hold minimal/zero balance") + logger.Infof(" ⚠️ High balance in Agent wallet poses security risks") + logger.Infof(" 📖 Reference: https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/nonces-and-api-wallets") + logger.Infof(" 💡 Recommendation: Transfer funds to main wallet and keep Agent wallet balance near 0") return nil, fmt.Errorf("security check failed: Agent wallet balance too high (%.2f USDC), exceeds 100 USDC threshold", agentBalance) } else if agentBalance > 10 { // Warning: Agent wallet has some balance (acceptable but not ideal) - log.Printf("⚠️ Notice: Agent wallet address (%s) has some balance: %.2f USDC", agentAddr, agentBalance) - log.Printf(" While not critical, it's recommended to keep Agent wallet balance near 0 for security") + logger.Infof("⚠️ Notice: Agent wallet address (%s) has some balance: %.2f USDC", agentAddr, agentBalance) + logger.Infof(" While not critical, it's recommended to keep Agent wallet balance near 0 for security") } else { // OK: Agent wallet balance is safe - log.Printf("✓ Agent wallet balance is safe: %.2f USDC (near zero as recommended)", agentBalance) + logger.Infof("✓ Agent wallet balance is safe: %.2f USDC (near zero as recommended)", agentBalance) } } else if err != nil { // Failed to query agent balance - log warning but don't block initialization - log.Printf("⚠️ Could not verify Agent wallet balance (query failed): %v", err) - log.Printf(" Proceeding with initialization, but please manually verify Agent wallet balance is near 0") + logger.Infof("⚠️ Could not verify Agent wallet balance (query failed): %v", err) + logger.Infof(" Proceeding with initialization, but please manually verify Agent wallet balance is near 0") } } @@ -131,18 +131,18 @@ func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool) // GetBalance 获取账户余额 func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) { - log.Printf("🔄 正在调用Hyperliquid API获取账户余额...") + logger.Infof("🔄 正在调用Hyperliquid API获取账户余额...") // ✅ Step 1: 查询 Spot 现货账户余额 spotState, err := t.exchange.Info().SpotUserState(t.ctx, t.walletAddr) var spotUSDCBalance float64 = 0.0 if err != nil { - log.Printf("⚠️ 查询 Spot 余额失败(可能无现货资产): %v", err) + logger.Infof("⚠️ 查询 Spot 余额失败(可能无现货资产): %v", err) } else if spotState != nil && len(spotState.Balances) > 0 { for _, balance := range spotState.Balances { if balance.Coin == "USDC" { spotUSDCBalance, _ = strconv.ParseFloat(balance.Total, 64) - log.Printf("✓ 发现 Spot 现货余额: %.2f USDC", spotUSDCBalance) + logger.Infof("✓ 发现 Spot 现货余额: %.2f USDC", spotUSDCBalance) break } } @@ -151,7 +151,7 @@ func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) { // ✅ Step 2: 查询 Perpetuals 合约账户状态 accountState, err := t.exchange.Info().UserState(t.ctx, t.walletAddr) if err != nil { - log.Printf("❌ Hyperliquid Perpetuals API调用失败: %v", err) + logger.Infof("❌ Hyperliquid Perpetuals API调用失败: %v", err) return nil, fmt.Errorf("获取账户信息失败: %w", err) } @@ -179,8 +179,8 @@ func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) { // 🔍 调试:打印API返回的完整摘要结构 summaryJSON, _ := json.MarshalIndent(summary, " ", " ") - log.Printf("🔍 [DEBUG] Hyperliquid API %s 完整数据:", summaryType) - log.Printf("%s", string(summaryJSON)) + logger.Infof("🔍 [DEBUG] Hyperliquid API %s 完整数据:", summaryType) + logger.Infof("%s", string(summaryJSON)) // ⚠️ 关键修复:从所有持仓中累加真正的未实现盈亏 totalUnrealizedPnl := 0.0 @@ -204,7 +204,7 @@ func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) { withdrawable, err := strconv.ParseFloat(accountState.Withdrawable, 64) if err == nil && withdrawable > 0 { availableBalance = withdrawable - log.Printf("✓ 使用 Withdrawable 作为可用余额: %.2f", availableBalance) + logger.Infof("✓ 使用 Withdrawable 作为可用余额: %.2f", availableBalance) } } @@ -212,7 +212,7 @@ func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) { if availableBalance == 0 && accountState.Withdrawable == "" { availableBalance = accountValue - totalMarginUsed if availableBalance < 0 { - log.Printf("⚠️ 计算出的可用余额为负数 (%.2f),重置为 0", availableBalance) + logger.Infof("⚠️ 计算出的可用余额为负数 (%.2f),重置为 0", availableBalance) availableBalance = 0 } } @@ -227,16 +227,16 @@ func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) { result["totalUnrealizedProfit"] = totalUnrealizedPnl // 未实现盈亏(仅来自 Perpetuals) result["spotBalance"] = spotUSDCBalance // Spot 现货余额(单独返回) - log.Printf("✓ Hyperliquid 完整账户:") - log.Printf(" • Spot 现货余额: %.2f USDC (需手动转账到 Perpetuals 才能开仓)", spotUSDCBalance) - log.Printf(" • Perpetuals 合约净值: %.2f USDC (钱包%.2f + 未实现%.2f)", + logger.Infof("✓ Hyperliquid 完整账户:") + logger.Infof(" • Spot 现货余额: %.2f USDC (需手动转账到 Perpetuals 才能开仓)", spotUSDCBalance) + logger.Infof(" • Perpetuals 合约净值: %.2f USDC (钱包%.2f + 未实现%.2f)", accountValue, walletBalanceWithoutUnrealized, totalUnrealizedPnl) - log.Printf(" • Perpetuals 可用余额: %.2f USDC (可直接用于开仓)", availableBalance) - log.Printf(" • 保证金占用: %.2f USDC", totalMarginUsed) - log.Printf(" • 总资产 (Perp+Spot): %.2f USDC", totalWalletBalance) - log.Printf(" ⭐ 总资产: %.2f USDC | Perp 可用: %.2f USDC | Spot 余额: %.2f USDC", + logger.Infof(" • Perpetuals 可用余额: %.2f USDC (可直接用于开仓)", availableBalance) + logger.Infof(" • 保证金占用: %.2f USDC", totalMarginUsed) + logger.Infof(" • 总资产 (Perp+Spot): %.2f USDC", totalWalletBalance) + logger.Infof(" ⭐ 总资产: %.2f USDC | Perp 可用: %.2f USDC | Spot 余额: %.2f USDC", totalWalletBalance, availableBalance, spotUSDCBalance) return result, nil @@ -316,7 +316,7 @@ func (t *HyperliquidTrader) SetMarginMode(symbol string, isCrossMargin bool) err if !isCrossMargin { marginModeStr = "逐仓" } - log.Printf(" ✓ %s 将使用 %s 模式", symbol, marginModeStr) + logger.Infof(" ✓ %s 将使用 %s 模式", symbol, marginModeStr) return nil } @@ -332,7 +332,7 @@ func (t *HyperliquidTrader) SetLeverage(symbol string, leverage int) error { return fmt.Errorf("设置杠杆失败: %w", err) } - log.Printf(" ✓ %s 杠杆已切换为 %dx", symbol, leverage) + logger.Infof(" ✓ %s 杠杆已切换为 %dx", symbol, leverage) return nil } @@ -343,7 +343,7 @@ func (t *HyperliquidTrader) refreshMetaIfNeeded(coin string) error { return nil // Meta 正常,无需刷新 } - log.Printf("⚠️ %s 的 Asset ID 为 0,尝试刷新 Meta 信息...", coin) + logger.Infof("⚠️ %s 的 Asset ID 为 0,尝试刷新 Meta 信息...", coin) // 刷新 Meta 信息 meta, err := t.exchange.Info().Meta(t.ctx) @@ -356,7 +356,7 @@ func (t *HyperliquidTrader) refreshMetaIfNeeded(coin string) error { t.meta = meta t.metaMutex.Unlock() - log.Printf("✅ Meta 信息已刷新,包含 %d 个资产", len(meta.Universe)) + logger.Infof("✅ Meta 信息已刷新,包含 %d 个资产", len(meta.Universe)) // 验证刷新后的 Asset ID assetID = t.exchange.Info().NameToAsset(coin) @@ -367,7 +367,7 @@ func (t *HyperliquidTrader) refreshMetaIfNeeded(coin string) error { " 3. API 连接问题", coin) } - log.Printf("✅ 刷新后 Asset ID 检查通过: %s -> %d", coin, assetID) + logger.Infof("✅ 刷新后 Asset ID 检查通过: %s -> %d", coin, assetID) return nil } @@ -375,7 +375,7 @@ func (t *HyperliquidTrader) refreshMetaIfNeeded(coin string) error { func (t *HyperliquidTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { // 先取消该币种的所有委托单 if err := t.CancelAllOrders(symbol); err != nil { - log.Printf(" ⚠ 取消旧委托单失败: %v", err) + logger.Infof(" ⚠ 取消旧委托单失败: %v", err) } // 设置杠杆 @@ -394,11 +394,11 @@ func (t *HyperliquidTrader) OpenLong(symbol string, quantity float64, leverage i // ⚠️ 关键:根据币种精度要求,四舍五入数量 roundedQuantity := t.roundToSzDecimals(coin, quantity) - log.Printf(" 📏 数量精度处理: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin)) + logger.Infof(" 📏 数量精度处理: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin)) // ⚠️ 关键:价格也需要处理为5位有效数字 aggressivePrice := t.roundPriceToSigfigs(price * 1.01) - log.Printf(" 💰 价格精度处理: %.8f -> %.8f (5位有效数字)", price*1.01, aggressivePrice) + logger.Infof(" 💰 价格精度处理: %.8f -> %.8f (5位有效数字)", price*1.01, aggressivePrice) // 创建市价买入订单(使用IOC limit order with aggressive price) order := hyperliquid.CreateOrderRequest{ @@ -419,7 +419,7 @@ func (t *HyperliquidTrader) OpenLong(symbol string, quantity float64, leverage i return nil, fmt.Errorf("开多仓失败: %w", err) } - log.Printf("✓ 开多仓成功: %s 数量: %.4f", symbol, roundedQuantity) + logger.Infof("✓ 开多仓成功: %s 数量: %.4f", symbol, roundedQuantity) result := make(map[string]interface{}) result["orderId"] = 0 // Hyperliquid没有返回order ID @@ -433,7 +433,7 @@ func (t *HyperliquidTrader) OpenLong(symbol string, quantity float64, leverage i func (t *HyperliquidTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { // 先取消该币种的所有委托单 if err := t.CancelAllOrders(symbol); err != nil { - log.Printf(" ⚠ 取消旧委托单失败: %v", err) + logger.Infof(" ⚠ 取消旧委托单失败: %v", err) } // 设置杠杆 @@ -452,11 +452,11 @@ func (t *HyperliquidTrader) OpenShort(symbol string, quantity float64, leverage // ⚠️ 关键:根据币种精度要求,四舍五入数量 roundedQuantity := t.roundToSzDecimals(coin, quantity) - log.Printf(" 📏 数量精度处理: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin)) + logger.Infof(" 📏 数量精度处理: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin)) // ⚠️ 关键:价格也需要处理为5位有效数字 aggressivePrice := t.roundPriceToSigfigs(price * 0.99) - log.Printf(" 💰 价格精度处理: %.8f -> %.8f (5位有效数字)", price*0.99, aggressivePrice) + logger.Infof(" 💰 价格精度处理: %.8f -> %.8f (5位有效数字)", price*0.99, aggressivePrice) // 创建市价卖出订单 order := hyperliquid.CreateOrderRequest{ @@ -477,7 +477,7 @@ func (t *HyperliquidTrader) OpenShort(symbol string, quantity float64, leverage return nil, fmt.Errorf("开空仓失败: %w", err) } - log.Printf("✓ 开空仓成功: %s 数量: %.4f", symbol, roundedQuantity) + logger.Infof("✓ 开空仓成功: %s 数量: %.4f", symbol, roundedQuantity) result := make(map[string]interface{}) result["orderId"] = 0 @@ -519,11 +519,11 @@ func (t *HyperliquidTrader) CloseLong(symbol string, quantity float64) (map[stri // ⚠️ 关键:根据币种精度要求,四舍五入数量 roundedQuantity := t.roundToSzDecimals(coin, quantity) - log.Printf(" 📏 数量精度处理: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin)) + logger.Infof(" 📏 数量精度处理: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin)) // ⚠️ 关键:价格也需要处理为5位有效数字 aggressivePrice := t.roundPriceToSigfigs(price * 0.99) - log.Printf(" 💰 价格精度处理: %.8f -> %.8f (5位有效数字)", price*0.99, aggressivePrice) + logger.Infof(" 💰 价格精度处理: %.8f -> %.8f (5位有效数字)", price*0.99, aggressivePrice) // 创建平仓订单(卖出 + ReduceOnly) order := hyperliquid.CreateOrderRequest{ @@ -544,11 +544,11 @@ func (t *HyperliquidTrader) CloseLong(symbol string, quantity float64) (map[stri return nil, fmt.Errorf("平多仓失败: %w", err) } - log.Printf("✓ 平多仓成功: %s 数量: %.4f", symbol, roundedQuantity) + logger.Infof("✓ 平多仓成功: %s 数量: %.4f", symbol, roundedQuantity) // 平仓后取消该币种的所有挂单 if err := t.CancelAllOrders(symbol); err != nil { - log.Printf(" ⚠ 取消挂单失败: %v", err) + logger.Infof(" ⚠ 取消挂单失败: %v", err) } result := make(map[string]interface{}) @@ -591,11 +591,11 @@ func (t *HyperliquidTrader) CloseShort(symbol string, quantity float64) (map[str // ⚠️ 关键:根据币种精度要求,四舍五入数量 roundedQuantity := t.roundToSzDecimals(coin, quantity) - log.Printf(" 📏 数量精度处理: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin)) + logger.Infof(" 📏 数量精度处理: %.8f -> %.8f (szDecimals=%d)", quantity, roundedQuantity, t.getSzDecimals(coin)) // ⚠️ 关键:价格也需要处理为5位有效数字 aggressivePrice := t.roundPriceToSigfigs(price * 1.01) - log.Printf(" 💰 价格精度处理: %.8f -> %.8f (5位有效数字)", price*1.01, aggressivePrice) + logger.Infof(" 💰 价格精度处理: %.8f -> %.8f (5位有效数字)", price*1.01, aggressivePrice) // 创建平仓订单(买入 + ReduceOnly) order := hyperliquid.CreateOrderRequest{ @@ -616,11 +616,11 @@ func (t *HyperliquidTrader) CloseShort(symbol string, quantity float64) (map[str return nil, fmt.Errorf("平空仓失败: %w", err) } - log.Printf("✓ 平空仓成功: %s 数量: %.4f", symbol, roundedQuantity) + logger.Infof("✓ 平空仓成功: %s 数量: %.4f", symbol, roundedQuantity) // 平仓后取消该币种的所有挂单 if err := t.CancelAllOrders(symbol); err != nil { - log.Printf(" ⚠ 取消挂单失败: %v", err) + logger.Infof(" ⚠ 取消挂单失败: %v", err) } result := make(map[string]interface{}) @@ -637,7 +637,7 @@ func (t *HyperliquidTrader) CloseShort(symbol string, quantity float64) (map[str func (t *HyperliquidTrader) CancelStopLossOrders(symbol string) error { // Hyperliquid SDK 的 OpenOrder 结构不暴露 trigger 字段 // 无法区分止损和止盈单,因此取消该币种的所有挂单 - log.Printf(" ⚠️ Hyperliquid 无法区分止损/止盈单,将取消所有挂单") + logger.Infof(" ⚠️ Hyperliquid 无法区分止损/止盈单,将取消所有挂单") return t.CancelStopOrders(symbol) } @@ -645,7 +645,7 @@ func (t *HyperliquidTrader) CancelStopLossOrders(symbol string) error { func (t *HyperliquidTrader) CancelTakeProfitOrders(symbol string) error { // Hyperliquid SDK 的 OpenOrder 结构不暴露 trigger 字段 // 无法区分止损和止盈单,因此取消该币种的所有挂单 - log.Printf(" ⚠️ Hyperliquid 无法区分止损/止盈单,将取消所有挂单") + logger.Infof(" ⚠️ Hyperliquid 无法区分止损/止盈单,将取消所有挂单") return t.CancelStopOrders(symbol) } @@ -664,12 +664,12 @@ func (t *HyperliquidTrader) CancelAllOrders(symbol string) error { if order.Coin == coin { _, err := t.exchange.Cancel(t.ctx, coin, order.Oid) if err != nil { - log.Printf(" ⚠ 取消订单失败 (oid=%d): %v", order.Oid, err) + logger.Infof(" ⚠ 取消订单失败 (oid=%d): %v", order.Oid, err) } } } - log.Printf(" ✓ 已取消 %s 的所有挂单", symbol) + logger.Infof(" ✓ 已取消 %s 的所有挂单", symbol) return nil } @@ -691,7 +691,7 @@ func (t *HyperliquidTrader) CancelStopOrders(symbol string) error { if order.Coin == coin { _, err := t.exchange.Cancel(t.ctx, coin, order.Oid) if err != nil { - log.Printf(" ⚠ 取消订单失败 (oid=%d): %v", order.Oid, err) + logger.Infof(" ⚠ 取消订单失败 (oid=%d): %v", order.Oid, err) continue } canceledCount++ @@ -699,9 +699,9 @@ func (t *HyperliquidTrader) CancelStopOrders(symbol string) error { } if canceledCount == 0 { - log.Printf(" ℹ %s 没有挂单需要取消", symbol) + logger.Infof(" ℹ %s 没有挂单需要取消", symbol) } else { - log.Printf(" ✓ 已取消 %s 的 %d 个挂单(包括止盈/止损单)", symbol, canceledCount) + logger.Infof(" ✓ 已取消 %s 的 %d 个挂单(包括止盈/止损单)", symbol, canceledCount) } return nil @@ -762,7 +762,7 @@ func (t *HyperliquidTrader) SetStopLoss(symbol string, positionSide string, quan return fmt.Errorf("设置止损失败: %w", err) } - log.Printf(" 止损价设置: %.4f", roundedStopPrice) + logger.Infof(" 止损价设置: %.4f", roundedStopPrice) return nil } @@ -799,7 +799,7 @@ func (t *HyperliquidTrader) SetTakeProfit(symbol string, positionSide string, qu return fmt.Errorf("设置止盈失败: %w", err) } - log.Printf(" 止盈价设置: %.4f", roundedTakeProfitPrice) + logger.Infof(" 止盈价设置: %.4f", roundedTakeProfitPrice) return nil } @@ -820,7 +820,7 @@ func (t *HyperliquidTrader) getSzDecimals(coin string) int { defer t.metaMutex.RUnlock() if t.meta == nil { - log.Printf("⚠️ meta信息为空,使用默认精度4") + logger.Infof("⚠️ meta信息为空,使用默认精度4") return 4 // 默认精度 } @@ -831,7 +831,7 @@ func (t *HyperliquidTrader) getSzDecimals(coin string) int { } } - log.Printf("⚠️ 未找到 %s 的精度信息,使用默认精度4", coin) + logger.Infof("⚠️ 未找到 %s 的精度信息,使用默认精度4", coin) return 4 // 默认精度 } @@ -897,6 +897,53 @@ func convertSymbolToHyperliquid(symbol string) string { return symbol } +// GetOrderStatus 获取订单状态 +// Hyperliquid 使用 IOC 订单,通常立即成交或取消 +// 对于已完成的订单,需要查询历史记录 +func (t *HyperliquidTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) { + // Hyperliquid 的 IOC 订单几乎立即完成 + // 如果订单是通过本系统下单的,返回的 status 都是 FILLED + // 这里尝试查询开放订单来判断是否还在等待 + coin := convertSymbolToHyperliquid(symbol) + + // 首先检查是否在开放订单中 + openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr) + if err != nil { + // 如果查询失败,假设订单已完成 + return map[string]interface{}{ + "orderId": orderID, + "status": "FILLED", + "avgPrice": 0.0, + "executedQty": 0.0, + "commission": 0.0, + }, nil + } + + // 检查订单是否在开放订单列表中 + for _, order := range openOrders { + if order.Coin == coin && fmt.Sprintf("%d", order.Oid) == orderID { + // 订单仍在等待 + return map[string]interface{}{ + "orderId": orderID, + "status": "NEW", + "avgPrice": 0.0, + "executedQty": 0.0, + "commission": 0.0, + }, nil + } + } + + // 订单不在开放列表中,说明已完成或已取消 + // Hyperliquid IOC 订单如果不在开放列表中,通常是已成交 + return map[string]interface{}{ + "orderId": orderID, + "status": "FILLED", + "avgPrice": 0.0, // Hyperliquid 不直接返回成交价格,需要从持仓信息获取 + "executedQty": 0.0, + "commission": 0.0, + }, nil +} + // absFloat 返回浮点数的绝对值 func absFloat(x float64) float64 { if x < 0 { diff --git a/trader/interface.go b/trader/interface.go index 3d3a6e90..b1fa555f 100644 --- a/trader/interface.go +++ b/trader/interface.go @@ -50,4 +50,8 @@ type Trader interface { // FormatQuantity 格式化数量到正确的精度 FormatQuantity(symbol string, quantity float64) (string, error) + + // GetOrderStatus 获取订单状态 + // 返回: status(FILLED/NEW/CANCELED), avgPrice, executedQty, commission + GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) } diff --git a/trader/lighter_orders.go b/trader/lighter_orders.go index d16604a4..f95c67eb 100644 --- a/trader/lighter_orders.go +++ b/trader/lighter_orders.go @@ -5,7 +5,7 @@ import ( "encoding/json" "fmt" "io" - "log" + "nofx/logger" "net/http" ) @@ -62,7 +62,7 @@ func (t *LighterTrader) CreateOrder(symbol, side string, quantity, price float64 return "", err } - log.Printf("✓ LIGHTER订单已创建 - ID: %s, Symbol: %s, Side: %s, Qty: %.4f", + logger.Infof("✓ LIGHTER订单已创建 - ID: %s, Symbol: %s, Side: %s, Qty: %.4f", orderResp.OrderID, symbol, side, quantity) return orderResp.OrderID, nil @@ -143,7 +143,7 @@ func (t *LighterTrader) CancelOrder(symbol, orderID string) error { return fmt.Errorf("取消订单失败 (status %d): %s", resp.StatusCode, string(body)) } - log.Printf("✓ LIGHTER订单已取消 - ID: %s", orderID) + logger.Infof("✓ LIGHTER订单已取消 - ID: %s", orderID) return nil } @@ -160,18 +160,18 @@ func (t *LighterTrader) CancelAllOrders(symbol string) error { } if len(orders) == 0 { - log.Printf("✓ LIGHTER - 无需取消订单(无活跃订单)") + logger.Infof("✓ LIGHTER - 无需取消订单(无活跃订单)") return nil } // 批量取消 for _, order := range orders { if err := t.CancelOrder(symbol, order.OrderID); err != nil { - log.Printf("⚠️ 取消订单失败 (ID: %s): %v", order.OrderID, err) + logger.Infof("⚠️ 取消订单失败 (ID: %s): %v", order.OrderID, err) } } - log.Printf("✓ LIGHTER - 已取消 %d 个订单", len(orders)) + logger.Infof("✓ LIGHTER - 已取消 %d 个订单", len(orders)) return nil } @@ -223,8 +223,8 @@ func (t *LighterTrader) GetActiveOrders(symbol string) ([]OrderResponse, error) return orders, nil } -// GetOrderStatus 获取订单状态 -func (t *LighterTrader) GetOrderStatus(orderID string) (*OrderResponse, error) { +// GetOrderStatus 获取订单状态(实现 Trader 接口) +func (t *LighterTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) { if err := t.ensureAuthToken(); err != nil { return nil, fmt.Errorf("认证令牌无效: %w", err) } @@ -261,20 +261,37 @@ func (t *LighterTrader) GetOrderStatus(orderID string) (*OrderResponse, error) { return nil, fmt.Errorf("解析订单响应失败: %w", err) } - return &order, nil + // 转换状态为统一格式 + unifiedStatus := order.Status + switch order.Status { + case "filled": + unifiedStatus = "FILLED" + case "open": + unifiedStatus = "NEW" + case "cancelled": + unifiedStatus = "CANCELED" + } + + return map[string]interface{}{ + "orderId": order.OrderID, + "status": unifiedStatus, + "avgPrice": order.Price, + "executedQty": order.FilledQty, + "commission": 0.0, + }, nil } // CancelStopLossOrders 仅取消止损单(LIGHTER 暂无法区分,取消所有止盈止损单) func (t *LighterTrader) CancelStopLossOrders(symbol string) error { // LIGHTER 暂时无法区分止损和止盈单,取消所有止盈止损单 - log.Printf(" ⚠️ LIGHTER 无法区分止损/止盈单,将取消所有止盈止损单") + logger.Infof(" ⚠️ LIGHTER 无法区分止损/止盈单,将取消所有止盈止损单") return t.CancelStopOrders(symbol) } // CancelTakeProfitOrders 仅取消止盈单(LIGHTER 暂无法区分,取消所有止盈止损单) func (t *LighterTrader) CancelTakeProfitOrders(symbol string) error { // LIGHTER 暂时无法区分止损和止盈单,取消所有止盈止损单 - log.Printf(" ⚠️ LIGHTER 无法区分止损/止盈单,将取消所有止盈止损单") + logger.Infof(" ⚠️ LIGHTER 无法区分止损/止盈单,将取消所有止盈止损单") return t.CancelStopOrders(symbol) } @@ -295,12 +312,12 @@ func (t *LighterTrader) CancelStopOrders(symbol string) error { // TODO: 需要检查订单类型,只取消止盈止损单 // 暂时取消所有订单 if err := t.CancelOrder(symbol, order.OrderID); err != nil { - log.Printf("⚠️ 取消订单失败 (ID: %s): %v", order.OrderID, err) + logger.Infof("⚠️ 取消订单失败 (ID: %s): %v", order.OrderID, err) } else { canceledCount++ } } - log.Printf("✓ LIGHTER - 已取消 %d 个止盈止损单", canceledCount) + logger.Infof("✓ LIGHTER - 已取消 %d 个止盈止损单", canceledCount) return nil } diff --git a/trader/lighter_trader.go b/trader/lighter_trader.go index 66c427a1..7280550d 100644 --- a/trader/lighter_trader.go +++ b/trader/lighter_trader.go @@ -7,7 +7,7 @@ import ( "encoding/json" "fmt" "io" - "log" + "nofx/logger" "net/http" "strings" "sync" @@ -59,7 +59,7 @@ func NewLighterTrader(privateKeyHex string, walletAddr string, testnet bool) (*L // 从私钥派生钱包地址(如果未提供) if walletAddr == "" { walletAddr = crypto.PubkeyToAddress(*privateKey.Public().(*ecdsa.PublicKey)).Hex() - log.Printf("✓ 从私钥派生钱包地址: %s", walletAddr) + logger.Infof("✓ 从私钥派生钱包地址: %s", walletAddr) } // 选择API URL @@ -78,7 +78,7 @@ func NewLighterTrader(privateKeyHex string, walletAddr string, testnet bool) (*L symbolPrecision: make(map[string]SymbolPrecision), } - log.Printf("✓ LIGHTER交易器初始化成功 (testnet=%v, wallet=%s)", testnet, walletAddr) + logger.Infof("✓ LIGHTER交易器初始化成功 (testnet=%v, wallet=%s)", testnet, walletAddr) // 初始化账户信息(获取账户索引和API密钥) if err := trader.initializeAccount(); err != nil { @@ -100,7 +100,7 @@ func (t *LighterTrader) initializeAccount() error { t.accountIndex = accountInfo["index"].(int) t.accountMutex.Unlock() - log.Printf("✓ LIGHTER账户索引: %d", t.accountIndex) + logger.Infof("✓ LIGHTER账户索引: %d", t.accountIndex) // 2. 生成认证令牌(有效期8小时) if err := t.refreshAuthToken(); err != nil { @@ -153,7 +153,7 @@ func (t *LighterTrader) refreshAuthToken() error { // 临时实现:设置过期时间为8小时后 t.tokenExpiry = time.Now().Add(8 * time.Hour) - log.Printf("✓ 认证令牌已生成(有效期至: %s)", t.tokenExpiry.Format(time.RFC3339)) + logger.Infof("✓ 认证令牌已生成(有效期至: %s)", t.tokenExpiry.Format(time.RFC3339)) return nil } @@ -165,7 +165,7 @@ func (t *LighterTrader) ensureAuthToken() error { t.accountMutex.RUnlock() if expired { - log.Println("🔄 认证令牌即将过期,刷新中...") + logger.Info("🔄 认证令牌即将过期,刷新中...") return t.refreshAuthToken() } @@ -204,12 +204,12 @@ func (t *LighterTrader) GetExchangeType() string { // Close 关闭交易器 func (t *LighterTrader) Close() error { - log.Println("✓ LIGHTER交易器已关闭") + logger.Info("✓ LIGHTER交易器已关闭") return nil } // Run 运行交易器(实现Trader接口) func (t *LighterTrader) Run() error { - log.Println("⚠️ LIGHTER交易器的Run方法应由AutoTrader调用") + logger.Info("⚠️ LIGHTER交易器的Run方法应由AutoTrader调用") return fmt.Errorf("请使用AutoTrader管理交易器生命周期") } diff --git a/trader/lighter_trader_v2.go b/trader/lighter_trader_v2.go index f6510a40..673c3741 100644 --- a/trader/lighter_trader_v2.go +++ b/trader/lighter_trader_v2.go @@ -6,7 +6,7 @@ import ( "encoding/json" "fmt" "io" - "log" + "nofx/logger" "net/http" "strings" "sync" @@ -76,7 +76,7 @@ func NewLighterTraderV2(l1PrivateKeyHex, walletAddr, apiKeyPrivateKeyHex string, // 2. 如果沒有提供錢包地址,從私鑰派生 if walletAddr == "" { walletAddr = crypto.PubkeyToAddress(*l1PrivateKey.Public().(*ecdsa.PublicKey)).Hex() - log.Printf("✓ 從私鑰派生錢包地址: %s", walletAddr) + logger.Infof("✓ 從私鑰派生錢包地址: %s", walletAddr) } // 3. 確定 API URL 和 Chain ID @@ -112,8 +112,8 @@ func NewLighterTraderV2(l1PrivateKeyHex, walletAddr, apiKeyPrivateKeyHex string, // 6. 如果沒有 API Key,提示用戶需要生成 if apiKeyPrivateKeyHex == "" { - log.Printf("⚠️ 未提供 API Key 私鑰,請調用 GenerateAndRegisterAPIKey() 生成") - log.Printf(" 或者從 LIGHTER 官網獲取現有的 API Key") + logger.Infof("⚠️ 未提供 API Key 私鑰,請調用 GenerateAndRegisterAPIKey() 生成") + logger.Infof(" 或者從 LIGHTER 官網獲取現有的 API Key") return trader, nil } @@ -133,12 +133,12 @@ func NewLighterTraderV2(l1PrivateKeyHex, walletAddr, apiKeyPrivateKeyHex string, // 8. 驗證 API Key 是否正確 if err := trader.checkClient(); err != nil { - log.Printf("⚠️ API Key 驗證失敗: %v", err) - log.Printf(" 您可能需要重新生成 API Key 或檢查配置") + logger.Infof("⚠️ API Key 驗證失敗: %v", err) + logger.Infof(" 您可能需要重新生成 API Key 或檢查配置") return trader, err } - log.Printf("✓ LIGHTER 交易器初始化成功 (account=%d, apiKey=%d, testnet=%v)", + logger.Infof("✓ LIGHTER 交易器初始化成功 (account=%d, apiKey=%d, testnet=%v)", trader.accountIndex, trader.apiKeyIndex, testnet) return trader, nil @@ -156,7 +156,7 @@ func (t *LighterTraderV2) initializeAccount() error { t.accountIndex = accountInfo.AccountIndex t.accountMutex.Unlock() - log.Printf("✓ 賬戶索引: %d", t.accountIndex) + logger.Infof("✓ 賬戶索引: %d", t.accountIndex) return nil } @@ -214,7 +214,7 @@ func (t *LighterTraderV2) checkClient() error { return fmt.Errorf("API Key 不匹配:本地=%s, 服務器=%s", localPubKey, publicKey) } - log.Printf("✓ API Key 驗證通過") + logger.Infof("✓ API Key 驗證通過") return nil } @@ -249,7 +249,7 @@ func (t *LighterTraderV2) refreshAuthToken() error { t.tokenExpiry = deadline t.accountMutex.Unlock() - log.Printf("✓ 認證令牌已生成(有效期至: %s)", t.tokenExpiry.Format(time.RFC3339)) + logger.Infof("✓ 認證令牌已生成(有效期至: %s)", t.tokenExpiry.Format(time.RFC3339)) return nil } @@ -260,7 +260,7 @@ func (t *LighterTraderV2) ensureAuthToken() error { t.accountMutex.RUnlock() if expired { - log.Println("🔄 認證令牌即將過期,刷新中...") + logger.Info("🔄 認證令牌即將過期,刷新中...") return t.refreshAuthToken() } @@ -274,6 +274,6 @@ func (t *LighterTraderV2) GetExchangeType() string { // Cleanup 清理資源 func (t *LighterTraderV2) Cleanup() error { - log.Println("⏹ LIGHTER 交易器清理完成") + logger.Info("⏹ LIGHTER 交易器清理完成") return nil } diff --git a/trader/lighter_trader_v2_orders.go b/trader/lighter_trader_v2_orders.go index 1c207826..8ddf2687 100644 --- a/trader/lighter_trader_v2_orders.go +++ b/trader/lighter_trader_v2_orders.go @@ -5,7 +5,7 @@ import ( "encoding/json" "fmt" "io" - "log" + "nofx/logger" "net/http" "strconv" @@ -18,7 +18,7 @@ func (t *LighterTraderV2) SetStopLoss(symbol string, positionSide string, quanti return fmt.Errorf("TxClient 未初始化") } - log.Printf("🛑 LIGHTER 設置止損: %s %s qty=%.4f, stop=%.2f", symbol, positionSide, quantity, stopPrice) + logger.Infof("🛑 LIGHTER 設置止損: %s %s qty=%.4f, stop=%.2f", symbol, positionSide, quantity, stopPrice) // 確定訂單方向(做空止損用買單,做多止損用賣單) isAsk := (positionSide == "LONG" || positionSide == "long") @@ -29,7 +29,7 @@ func (t *LighterTraderV2) SetStopLoss(symbol string, positionSide string, quanti return fmt.Errorf("設置止損失敗: %w", err) } - log.Printf("✓ LIGHTER 止損已設置: %.2f", stopPrice) + logger.Infof("✓ LIGHTER 止損已設置: %.2f", stopPrice) return nil } @@ -39,7 +39,7 @@ func (t *LighterTraderV2) SetTakeProfit(symbol string, positionSide string, quan return fmt.Errorf("TxClient 未初始化") } - log.Printf("🎯 LIGHTER 設置止盈: %s %s qty=%.4f, tp=%.2f", symbol, positionSide, quantity, takeProfitPrice) + logger.Infof("🎯 LIGHTER 設置止盈: %s %s qty=%.4f, tp=%.2f", symbol, positionSide, quantity, takeProfitPrice) // 確定訂單方向(做空止盈用買單,做多止盈用賣單) isAsk := (positionSide == "LONG" || positionSide == "long") @@ -50,7 +50,7 @@ func (t *LighterTraderV2) SetTakeProfit(symbol string, positionSide string, quan return fmt.Errorf("設置止盈失敗: %w", err) } - log.Printf("✓ LIGHTER 止盈已設置: %.2f", takeProfitPrice) + logger.Infof("✓ LIGHTER 止盈已設置: %.2f", takeProfitPrice) return nil } @@ -71,7 +71,7 @@ func (t *LighterTraderV2) CancelAllOrders(symbol string) error { } if len(orders) == 0 { - log.Printf("✓ LIGHTER - 無需取消訂單(無活躍訂單)") + logger.Infof("✓ LIGHTER - 無需取消訂單(無活躍訂單)") return nil } @@ -79,27 +79,101 @@ func (t *LighterTraderV2) CancelAllOrders(symbol string) error { canceledCount := 0 for _, order := range orders { if err := t.CancelOrder(symbol, order.OrderID); err != nil { - log.Printf("⚠️ 取消訂單失敗 (ID: %s): %v", order.OrderID, err) + logger.Infof("⚠️ 取消訂單失敗 (ID: %s): %v", order.OrderID, err) } else { canceledCount++ } } - log.Printf("✓ LIGHTER - 已取消 %d 個訂單", canceledCount) + logger.Infof("✓ LIGHTER - 已取消 %d 個訂單", canceledCount) return nil } +// GetOrderStatus 獲取訂單狀態(實現 Trader 接口) +func (t *LighterTraderV2) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) { + // LIGHTER 使用市價單通常立即成交 + // 嘗試查詢訂單狀態 + if err := t.ensureAuthToken(); err != nil { + return nil, fmt.Errorf("認證令牌無效: %w", err) + } + + // 構建請求 URL + endpoint := fmt.Sprintf("%s/api/v1/order/%s", t.baseURL, orderID) + + req, err := http.NewRequest("GET", endpoint, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", t.authToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := t.client.Do(req) + if err != nil { + // 如果查詢失敗,假設訂單已完成 + return map[string]interface{}{ + "orderId": orderID, + "status": "FILLED", + "avgPrice": 0.0, + "executedQty": 0.0, + "commission": 0.0, + }, nil + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return map[string]interface{}{ + "orderId": orderID, + "status": "FILLED", + "avgPrice": 0.0, + "executedQty": 0.0, + "commission": 0.0, + }, nil + } + + var order OrderResponse + if err := json.Unmarshal(body, &order); err != nil { + return map[string]interface{}{ + "orderId": orderID, + "status": "FILLED", + "avgPrice": 0.0, + "executedQty": 0.0, + "commission": 0.0, + }, nil + } + + // 轉換狀態為統一格式 + unifiedStatus := order.Status + switch order.Status { + case "filled": + unifiedStatus = "FILLED" + case "open": + unifiedStatus = "NEW" + case "cancelled": + unifiedStatus = "CANCELED" + } + + return map[string]interface{}{ + "orderId": order.OrderID, + "status": unifiedStatus, + "avgPrice": order.Price, + "executedQty": order.FilledQty, + "commission": 0.0, + }, nil +} + // CancelStopLossOrders 僅取消止損單(實現 Trader 接口) func (t *LighterTraderV2) CancelStopLossOrders(symbol string) error { // LIGHTER 暫時無法區分止損和止盈單,取消所有止盈止損單 - log.Printf("⚠️ LIGHTER 無法區分止損/止盈單,將取消所有止盈止損單") + logger.Infof("⚠️ LIGHTER 無法區分止損/止盈單,將取消所有止盈止損單") return t.CancelStopOrders(symbol) } // CancelTakeProfitOrders 僅取消止盈單(實現 Trader 接口) func (t *LighterTraderV2) CancelTakeProfitOrders(symbol string) error { // LIGHTER 暫時無法區分止損和止盈單,取消所有止盈止損單 - log.Printf("⚠️ LIGHTER 無法區分止損/止盈單,將取消所有止盈止損單") + logger.Infof("⚠️ LIGHTER 無法區分止損/止盈單,將取消所有止盈止損單") return t.CancelStopOrders(symbol) } @@ -124,13 +198,13 @@ func (t *LighterTraderV2) CancelStopOrders(symbol string) error { // TODO: 檢查訂單類型,只取消止盈止損單 // 暫時取消所有訂單 if err := t.CancelOrder(symbol, order.OrderID); err != nil { - log.Printf("⚠️ 取消訂單失敗 (ID: %s): %v", order.OrderID, err) + logger.Infof("⚠️ 取消訂單失敗 (ID: %s): %v", order.OrderID, err) } else { canceledCount++ } } - log.Printf("✓ LIGHTER - 已取消 %d 個止盈止損單", canceledCount) + logger.Infof("✓ LIGHTER - 已取消 %d 個止盈止損單", canceledCount) return nil } @@ -186,7 +260,7 @@ func (t *LighterTraderV2) GetActiveOrders(symbol string) ([]OrderResponse, error return nil, fmt.Errorf("獲取活躍訂單失敗 (code %d): %s", apiResp.Code, apiResp.Message) } - log.Printf("✓ LIGHTER - 獲取到 %d 個活躍訂單", len(apiResp.Data)) + logger.Infof("✓ LIGHTER - 獲取到 %d 個活躍訂單", len(apiResp.Data)) return apiResp.Data, nil } @@ -235,7 +309,7 @@ func (t *LighterTraderV2) CancelOrder(symbol, orderID string) error { return fmt.Errorf("提交取消訂單失敗: %w", err) } - log.Printf("✓ LIGHTER訂單已取消 - ID: %s", orderID) + logger.Infof("✓ LIGHTER訂單已取消 - ID: %s", orderID) return nil } @@ -291,6 +365,6 @@ func (t *LighterTraderV2) submitCancelOrder(signedTx []byte) (map[string]interfa "status": "cancelled", } - log.Printf("✓ 取消訂單已提交到 LIGHTER - tx_hash: %v", sendResp.Data["tx_hash"]) + logger.Infof("✓ 取消訂單已提交到 LIGHTER - tx_hash: %v", sendResp.Data["tx_hash"]) return result, nil } diff --git a/trader/lighter_trader_v2_trading.go b/trader/lighter_trader_v2_trading.go index 36b13f55..00fe61c2 100644 --- a/trader/lighter_trader_v2_trading.go +++ b/trader/lighter_trader_v2_trading.go @@ -5,7 +5,7 @@ import ( "encoding/json" "fmt" "io" - "log" + "nofx/logger" "net/http" "time" @@ -18,11 +18,11 @@ func (t *LighterTraderV2) OpenLong(symbol string, quantity float64, leverage int return nil, fmt.Errorf("TxClient 未初始化,請先設置 API Key") } - log.Printf("📈 LIGHTER 開多倉: %s, qty=%.4f, leverage=%dx", symbol, quantity, leverage) + logger.Infof("📈 LIGHTER 開多倉: %s, qty=%.4f, leverage=%dx", symbol, quantity, leverage) // 1. 設置杠杆(如果需要) if err := t.SetLeverage(symbol, leverage); err != nil { - log.Printf("⚠️ 設置杠杆失敗: %v", err) + logger.Infof("⚠️ 設置杠杆失敗: %v", err) } // 2. 獲取市場價格 @@ -37,7 +37,7 @@ func (t *LighterTraderV2) OpenLong(symbol string, quantity float64, leverage int return nil, fmt.Errorf("開多倉失敗: %w", err) } - log.Printf("✓ LIGHTER 開多倉成功: %s @ %.2f", symbol, marketPrice) + logger.Infof("✓ LIGHTER 開多倉成功: %s @ %.2f", symbol, marketPrice) return map[string]interface{}{ "orderId": orderResult["orderId"], @@ -54,11 +54,11 @@ func (t *LighterTraderV2) OpenShort(symbol string, quantity float64, leverage in return nil, fmt.Errorf("TxClient 未初始化,請先設置 API Key") } - log.Printf("📉 LIGHTER 開空倉: %s, qty=%.4f, leverage=%dx", symbol, quantity, leverage) + logger.Infof("📉 LIGHTER 開空倉: %s, qty=%.4f, leverage=%dx", symbol, quantity, leverage) // 1. 設置杠杆 if err := t.SetLeverage(symbol, leverage); err != nil { - log.Printf("⚠️ 設置杠杆失敗: %v", err) + logger.Infof("⚠️ 設置杠杆失敗: %v", err) } // 2. 獲取市場價格 @@ -73,7 +73,7 @@ func (t *LighterTraderV2) OpenShort(symbol string, quantity float64, leverage in return nil, fmt.Errorf("開空倉失敗: %w", err) } - log.Printf("✓ LIGHTER 開空倉成功: %s @ %.2f", symbol, marketPrice) + logger.Infof("✓ LIGHTER 開空倉成功: %s @ %.2f", symbol, marketPrice) return map[string]interface{}{ "orderId": orderResult["orderId"], @@ -105,7 +105,7 @@ func (t *LighterTraderV2) CloseLong(symbol string, quantity float64) (map[string quantity = pos.Size } - log.Printf("🔻 LIGHTER 平多倉: %s, qty=%.4f", symbol, quantity) + logger.Infof("🔻 LIGHTER 平多倉: %s, qty=%.4f", symbol, quantity) // 創建市價賣出單平倉(reduceOnly=true) orderResult, err := t.CreateOrder(symbol, true, quantity, 0, "market") @@ -115,10 +115,10 @@ func (t *LighterTraderV2) CloseLong(symbol string, quantity float64) (map[string // 平倉後取消所有掛單 if err := t.CancelAllOrders(symbol); err != nil { - log.Printf("⚠️ 取消掛單失敗: %v", err) + logger.Infof("⚠️ 取消掛單失敗: %v", err) } - log.Printf("✓ LIGHTER 平多倉成功: %s", symbol) + logger.Infof("✓ LIGHTER 平多倉成功: %s", symbol) return map[string]interface{}{ "orderId": orderResult["orderId"], @@ -148,7 +148,7 @@ func (t *LighterTraderV2) CloseShort(symbol string, quantity float64) (map[strin quantity = pos.Size } - log.Printf("🔺 LIGHTER 平空倉: %s, qty=%.4f", symbol, quantity) + logger.Infof("🔺 LIGHTER 平空倉: %s, qty=%.4f", symbol, quantity) // 創建市價買入單平倉(reduceOnly=true) orderResult, err := t.CreateOrder(symbol, false, quantity, 0, "market") @@ -158,10 +158,10 @@ func (t *LighterTraderV2) CloseShort(symbol string, quantity float64) (map[strin // 平倉後取消所有掛單 if err := t.CancelAllOrders(symbol); err != nil { - log.Printf("⚠️ 取消掛單失敗: %v", err) + logger.Infof("⚠️ 取消掛單失敗: %v", err) } - log.Printf("✓ LIGHTER 平空倉成功: %s", symbol) + logger.Infof("✓ LIGHTER 平空倉成功: %s", symbol) return map[string]interface{}{ "orderId": orderResult["orderId"], @@ -235,7 +235,7 @@ func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float6 if isAsk { side = "sell" } - log.Printf("✓ LIGHTER訂單已創建: %s %s qty=%.4f", symbol, side, quantity) + logger.Infof("✓ LIGHTER訂單已創建: %s %s qty=%.4f", symbol, side, quantity) return orderResp, nil } @@ -315,7 +315,7 @@ func (t *LighterTraderV2) submitOrder(signedTx []byte) (map[string]interface{}, result["orderId"] = txHash } - log.Printf("✓ 訂單已提交到 LIGHTER - tx_hash: %v", sendResp.Data["tx_hash"]) + logger.Infof("✓ 訂單已提交到 LIGHTER - tx_hash: %v", sendResp.Data["tx_hash"]) return result, nil } @@ -334,7 +334,7 @@ func (t *LighterTraderV2) getMarketIndex(symbol string) (uint8, error) { markets, err := t.fetchMarketList() if err != nil { // 如果 API 失敗,回退到硬編碼映射 - log.Printf("⚠️ 從 API 獲取市場列表失敗,使用硬編碼映射: %v", err) + logger.Infof("⚠️ 從 API 獲取市場列表失敗,使用硬編碼映射: %v", err) return t.getFallbackMarketIndex(symbol) } @@ -412,7 +412,7 @@ func (t *LighterTraderV2) fetchMarketList() ([]MarketInfo, error) { } } - log.Printf("✓ 獲取到 %d 個市場", len(markets)) + logger.Infof("✓ 獲取到 %d 個市場", len(markets)) return markets, nil } @@ -428,7 +428,7 @@ func (t *LighterTraderV2) getFallbackMarketIndex(symbol string) (uint8, error) { } if index, ok := fallbackMap[symbol]; ok { - log.Printf("✓ 使用硬編碼市場索引: %s -> %d", symbol, index) + logger.Infof("✓ 使用硬編碼市場索引: %s -> %d", symbol, index) return index, nil } @@ -442,7 +442,7 @@ func (t *LighterTraderV2) SetLeverage(symbol string, leverage int) error { } // TODO: 使用SDK簽名並提交SetLeverage交易 - log.Printf("⚙️ 設置杠杆: %s = %dx", symbol, leverage) + logger.Infof("⚙️ 設置杠杆: %s = %dx", symbol, leverage) return nil // 暫時返回成功 } @@ -458,7 +458,7 @@ func (t *LighterTraderV2) SetMarginMode(symbol string, isCrossMargin bool) error modeStr = "全倉" } - log.Printf("⚙️ 設置倉位模式: %s = %s", symbol, modeStr) + logger.Infof("⚙️ 設置倉位模式: %s = %s", symbol, modeStr) // TODO: 使用SDK簽名並提交SetMarginMode交易 return nil diff --git a/trader/lighter_trading.go b/trader/lighter_trading.go index 26fab466..ee1a21cf 100644 --- a/trader/lighter_trading.go +++ b/trader/lighter_trading.go @@ -2,13 +2,13 @@ package trader import ( "fmt" - "log" + "nofx/logger" ) // OpenLong 开多仓 func (t *LighterTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { // TODO: 实现完整的开多仓逻辑 - log.Printf("🚧 LIGHTER OpenLong 暂未完全实现 (symbol=%s, qty=%.4f, leverage=%d)", symbol, quantity, leverage) + logger.Infof("🚧 LIGHTER OpenLong 暂未完全实现 (symbol=%s, qty=%.4f, leverage=%d)", symbol, quantity, leverage) // 使用市价买入单 orderID, err := t.CreateOrder(symbol, "buy", quantity, 0, "market") @@ -26,7 +26,7 @@ func (t *LighterTrader) OpenLong(symbol string, quantity float64, leverage int) // OpenShort 开空仓 func (t *LighterTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { // TODO: 实现完整的开空仓逻辑 - log.Printf("🚧 LIGHTER OpenShort 暂未完全实现 (symbol=%s, qty=%.4f, leverage=%d)", symbol, quantity, leverage) + logger.Infof("🚧 LIGHTER OpenShort 暂未完全实现 (symbol=%s, qty=%.4f, leverage=%d)", symbol, quantity, leverage) // 使用市价卖出单 orderID, err := t.CreateOrder(symbol, "sell", quantity, 0, "market") @@ -66,7 +66,7 @@ func (t *LighterTrader) CloseLong(symbol string, quantity float64) (map[string]i // 平仓后取消所有挂单 if err := t.CancelAllOrders(symbol); err != nil { - log.Printf(" ⚠ 取消挂单失败: %v", err) + logger.Infof(" ⚠ 取消挂单失败: %v", err) } return map[string]interface{}{ @@ -101,7 +101,7 @@ func (t *LighterTrader) CloseShort(symbol string, quantity float64) (map[string] // 平仓后取消所有挂单 if err := t.CancelAllOrders(symbol); err != nil { - log.Printf(" ⚠ 取消挂单失败: %v", err) + logger.Infof(" ⚠ 取消挂单失败: %v", err) } return map[string]interface{}{ @@ -114,7 +114,7 @@ func (t *LighterTrader) CloseShort(symbol string, quantity float64) (map[string] // SetStopLoss 设置止损单 func (t *LighterTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error { // TODO: 实现完整的止损单逻辑 - log.Printf("🚧 LIGHTER SetStopLoss 暂未完全实现 (symbol=%s, side=%s, qty=%.4f, stop=%.2f)", symbol, positionSide, quantity, stopPrice) + logger.Infof("🚧 LIGHTER SetStopLoss 暂未完全实现 (symbol=%s, side=%s, qty=%.4f, stop=%.2f)", symbol, positionSide, quantity, stopPrice) // 确定订单方向(做空止损用买单,做多止损用卖单) side := "sell" @@ -128,14 +128,14 @@ func (t *LighterTrader) SetStopLoss(symbol string, positionSide string, quantity return fmt.Errorf("设置止损失败: %w", err) } - log.Printf("✓ LIGHTER - 止损已设置: %.2f (side: %s)", stopPrice, side) + logger.Infof("✓ LIGHTER - 止损已设置: %.2f (side: %s)", stopPrice, side) return nil } // SetTakeProfit 设置止盈单 func (t *LighterTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error { // TODO: 实现完整的止盈单逻辑 - log.Printf("🚧 LIGHTER SetTakeProfit 暂未完全实现 (symbol=%s, side=%s, qty=%.4f, tp=%.2f)", symbol, positionSide, quantity, takeProfitPrice) + logger.Infof("🚧 LIGHTER SetTakeProfit 暂未完全实现 (symbol=%s, side=%s, qty=%.4f, tp=%.2f)", symbol, positionSide, quantity, takeProfitPrice) // 确定订单方向(做空止盈用买单,做多止盈用卖单) side := "sell" @@ -149,7 +149,7 @@ func (t *LighterTrader) SetTakeProfit(symbol string, positionSide string, quanti return fmt.Errorf("设置止盈失败: %w", err) } - log.Printf("✓ LIGHTER - 止盈已设置: %.2f (side: %s)", takeProfitPrice, side) + logger.Infof("✓ LIGHTER - 止盈已设置: %.2f (side: %s)", takeProfitPrice, side) return nil } @@ -160,7 +160,7 @@ func (t *LighterTrader) SetMarginMode(symbol string, isCrossMargin bool) error { if isCrossMargin { modeStr = "全仓" } - log.Printf("🚧 LIGHTER SetMarginMode 暂未实现 (symbol=%s, mode=%s)", symbol, modeStr) + logger.Infof("🚧 LIGHTER SetMarginMode 暂未实现 (symbol=%s, mode=%s)", symbol, modeStr) return nil } diff --git a/trader/order_sync.go b/trader/order_sync.go new file mode 100644 index 00000000..2562d0ed --- /dev/null +++ b/trader/order_sync.go @@ -0,0 +1,309 @@ +package trader + +import ( + "fmt" + "nofx/logger" + "nofx/store" + "sync" + "time" +) + +// OrderSyncManager 订单状态同步管理器 +// 负责定期扫描所有 NEW 状态的订单,并更新其状态 +type OrderSyncManager struct { + store *store.Store + interval time.Duration + stopCh chan struct{} + wg sync.WaitGroup + traderCache map[string]Trader // trader_id -> Trader 实例缓存 + configCache map[string]*store.TraderFullConfig // trader_id -> 配置缓存 + cacheMutex sync.RWMutex +} + +// NewOrderSyncManager 创建订单同步管理器 +func NewOrderSyncManager(st *store.Store, interval time.Duration) *OrderSyncManager { + if interval == 0 { + interval = 10 * time.Second + } + return &OrderSyncManager{ + store: st, + interval: interval, + stopCh: make(chan struct{}), + traderCache: make(map[string]Trader), + configCache: make(map[string]*store.TraderFullConfig), + } +} + +// Start 启动订单同步服务 +func (m *OrderSyncManager) Start() { + m.wg.Add(1) + go m.run() + logger.Info("📦 订单同步管理器已启动") +} + +// Stop 停止订单同步服务 +func (m *OrderSyncManager) Stop() { + close(m.stopCh) + m.wg.Wait() + + // 清理缓存 + m.cacheMutex.Lock() + m.traderCache = make(map[string]Trader) + m.configCache = make(map[string]*store.TraderFullConfig) + m.cacheMutex.Unlock() + + logger.Info("📦 订单同步管理器已停止") +} + +// run 主循环 +func (m *OrderSyncManager) run() { + defer m.wg.Done() + + // 启动时立即执行一次 + m.syncOrders() + + ticker := time.NewTicker(m.interval) + defer ticker.Stop() + + for { + select { + case <-m.stopCh: + return + case <-ticker.C: + m.syncOrders() + } + } +} + +// syncOrders 同步所有待处理订单 +func (m *OrderSyncManager) syncOrders() { + // 获取所有 NEW 状态的订单 + orders, err := m.store.Order().GetAllPendingOrders() + if err != nil { + logger.Infof("⚠️ 获取待处理订单失败: %v", err) + return + } + + if len(orders) == 0 { + return + } + + logger.Infof("📦 开始同步 %d 个待处理订单...", len(orders)) + + // 按 trader_id 分组 + ordersByTrader := make(map[string][]*store.TraderOrder) + for _, order := range orders { + ordersByTrader[order.TraderID] = append(ordersByTrader[order.TraderID], order) + } + + // 逐个 trader 处理 + for traderID, traderOrders := range ordersByTrader { + m.syncTraderOrders(traderID, traderOrders) + } +} + +// syncTraderOrders 同步单个 trader 的订单 +func (m *OrderSyncManager) syncTraderOrders(traderID string, orders []*store.TraderOrder) { + // 获取或创建 trader 实例 + trader, err := m.getOrCreateTrader(traderID) + if err != nil { + logger.Infof("⚠️ 获取 trader 实例失败 (ID: %s): %v", traderID, err) + return + } + + for _, order := range orders { + m.syncSingleOrder(trader, order) + } +} + +// syncSingleOrder 同步单个订单状态 +func (m *OrderSyncManager) syncSingleOrder(trader Trader, order *store.TraderOrder) { + status, err := trader.GetOrderStatus(order.Symbol, order.OrderID) + if err != nil { + // 查询失败,检查订单创建时间,超过一定时间假设已成交 + if time.Since(order.CreatedAt) > 5*time.Minute { + logger.Infof("⚠️ 订单查询超时,假设已成交 (ID: %s)", order.OrderID) + m.markOrderFilled(order, 0, 0, 0) + } + return + } + + statusStr, _ := status["status"].(string) + + switch statusStr { + case "FILLED": + avgPrice, _ := status["avgPrice"].(float64) + executedQty, _ := status["executedQty"].(float64) + commission, _ := status["commission"].(float64) + + // 如果 API 未返回数量,使用原始数量 + if executedQty == 0 { + executedQty = order.Quantity + } + + m.markOrderFilled(order, avgPrice, executedQty, commission) + + case "CANCELED", "EXPIRED": + order.Status = statusStr + if err := m.store.Order().Update(order); err != nil { + logger.Infof("⚠️ 更新订单状态失败: %v", err) + } else { + logger.Infof("📦 订单状态更新: %s (ID: %s)", statusStr, order.OrderID) + } + } +} + +// markOrderFilled 标记订单已成交 +func (m *OrderSyncManager) markOrderFilled(order *store.TraderOrder, avgPrice, executedQty, commission float64) { + // 如果 avgPrice 为 0,使用订单价格 + if avgPrice == 0 { + avgPrice = order.Price + } + if executedQty == 0 { + executedQty = order.Quantity + } + + // 计算已实现盈亏(仅平仓订单) + var realizedPnL float64 + if (order.Action == "close_long" || order.Action == "close_short") && order.EntryPrice > 0 && avgPrice > 0 { + if order.Action == "close_long" { + // 平多盈亏 = (平仓价 - 开仓价) * 数量 + realizedPnL = (avgPrice - order.EntryPrice) * executedQty + } else { + // 平空盈亏 = (开仓价 - 平仓价) * 数量 + realizedPnL = (order.EntryPrice - avgPrice) * executedQty + } + } + + order.AvgPrice = avgPrice + order.ExecutedQty = executedQty + order.Status = "FILLED" + order.Fee = commission + order.RealizedPnL = realizedPnL + order.FilledAt = time.Now() + + if err := m.store.Order().Update(order); err != nil { + logger.Infof("⚠️ 更新订单状态失败: %v", err) + } else { + if realizedPnL != 0 { + logger.Infof("✅ 订单已成交 (ID: %s, avgPrice: %.4f, qty: %.4f, PnL: %.2f)", + order.OrderID, avgPrice, executedQty, realizedPnL) + } else { + logger.Infof("✅ 订单已成交 (ID: %s, avgPrice: %.4f, qty: %.4f)", + order.OrderID, avgPrice, executedQty) + } + } +} + +// getOrCreateTrader 获取或创建 trader 实例 +func (m *OrderSyncManager) getOrCreateTrader(traderID string) (Trader, error) { + m.cacheMutex.RLock() + trader, exists := m.traderCache[traderID] + m.cacheMutex.RUnlock() + + if exists && trader != nil { + return trader, nil + } + + // 需要创建新的 trader 实例 + // 首先获取 trader 配置 + config, err := m.getTraderConfig(traderID) + if err != nil { + return nil, fmt.Errorf("获取 trader 配置失败: %w", err) + } + + // 根据交易所类型创建 trader + trader, err = m.createTrader(config) + if err != nil { + return nil, fmt.Errorf("创建 trader 实例失败: %w", err) + } + + m.cacheMutex.Lock() + m.traderCache[traderID] = trader + m.cacheMutex.Unlock() + + return trader, nil +} + +// getTraderConfig 获取 trader 配置 +func (m *OrderSyncManager) getTraderConfig(traderID string) (*store.TraderFullConfig, error) { + m.cacheMutex.RLock() + config, exists := m.configCache[traderID] + m.cacheMutex.RUnlock() + + if exists { + return config, nil + } + + // 从数据库获取 - 需要找到 trader 对应的 userID + // 首先查询所有 traders 找到对应的 userID + traders, err := m.store.Trader().ListAll() + if err != nil { + return nil, fmt.Errorf("获取 trader 列表失败: %w", err) + } + + var userID string + for _, t := range traders { + if t.ID == traderID { + userID = t.UserID + break + } + } + + if userID == "" { + return nil, fmt.Errorf("找不到 trader: %s", traderID) + } + + config, err = m.store.Trader().GetFullConfig(userID, traderID) + if err != nil { + return nil, err + } + + m.cacheMutex.Lock() + m.configCache[traderID] = config + m.cacheMutex.Unlock() + + return config, nil +} + +// createTrader 根据配置创建 trader 实例 +func (m *OrderSyncManager) createTrader(config *store.TraderFullConfig) (Trader, error) { + exchange := config.Exchange + + switch exchange.Type { + case "binance": + return NewFuturesTrader(exchange.APIKey, exchange.SecretKey, config.Trader.UserID), nil + + case "bybit": + return NewBybitTrader(exchange.APIKey, exchange.SecretKey), nil + + case "hyperliquid": + return NewHyperliquidTrader(exchange.SecretKey, exchange.HyperliquidWalletAddr, exchange.Testnet) + + case "aster": + return NewAsterTrader(exchange.AsterUser, exchange.AsterSigner, exchange.AsterPrivateKey) + + case "lighter": + if exchange.LighterAPIKeyPrivateKey != "" { + return NewLighterTraderV2( + exchange.LighterPrivateKey, + exchange.LighterWalletAddr, + exchange.LighterAPIKeyPrivateKey, + exchange.Testnet, + ) + } + return NewLighterTrader(exchange.LighterPrivateKey, exchange.LighterWalletAddr, exchange.Testnet) + + default: + return nil, fmt.Errorf("不支持的交易所类型: %s", exchange.Type) + } +} + +// InvalidateCache 使缓存失效(当配置变更时调用) +func (m *OrderSyncManager) InvalidateCache(traderID string) { + m.cacheMutex.Lock() + defer m.cacheMutex.Unlock() + + delete(m.traderCache, traderID) + delete(m.configCache, traderID) +} diff --git a/trader/partial_close_test.go b/trader/partial_close_test.go deleted file mode 100644 index 5b4b50be..00000000 --- a/trader/partial_close_test.go +++ /dev/null @@ -1,393 +0,0 @@ -package trader - -import ( - "fmt" - "nofx/decision" - "nofx/logger" - "testing" -) - -// MockPartialCloseTrader 用於測試 partial close 邏輯 -type MockPartialCloseTrader struct { - positions []map[string]interface{} - closePartialCalled bool - closeLongCalled bool - closeShortCalled bool - stopLossCalled bool - takeProfitCalled bool - lastStopLoss float64 - lastTakeProfit float64 -} - -func (m *MockPartialCloseTrader) GetPositions() ([]map[string]interface{}, error) { - return m.positions, nil -} - -func (m *MockPartialCloseTrader) ClosePartialLong(symbol string, quantity float64) (map[string]interface{}, error) { - m.closePartialCalled = true - return map[string]interface{}{"orderId": "12345"}, nil -} - -func (m *MockPartialCloseTrader) ClosePartialShort(symbol string, quantity float64) (map[string]interface{}, error) { - m.closePartialCalled = true - return map[string]interface{}{"orderId": "12345"}, nil -} - -func (m *MockPartialCloseTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) { - m.closeLongCalled = true - return map[string]interface{}{"orderId": "12346"}, nil -} - -func (m *MockPartialCloseTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) { - m.closeShortCalled = true - return map[string]interface{}{"orderId": "12346"}, nil -} - -func (m *MockPartialCloseTrader) SetStopLoss(symbol, side string, quantity, price float64) error { - m.stopLossCalled = true - m.lastStopLoss = price - return nil -} - -func (m *MockPartialCloseTrader) SetTakeProfit(symbol, side string, quantity, price float64) error { - m.takeProfitCalled = true - m.lastTakeProfit = price - return nil -} - -// TestPartialCloseMinPositionCheck 測試最小倉位檢查邏輯 -func TestPartialCloseMinPositionCheck(t *testing.T) { - tests := []struct { - name string - totalQuantity float64 - markPrice float64 - closePercentage float64 - expectFullClose bool // 是否應該觸發全平邏輯 - expectRemainValue float64 - }{ - { - name: "正常部分平倉_剩餘價值充足", - totalQuantity: 1.0, - markPrice: 100.0, - closePercentage: 50.0, - expectFullClose: false, - expectRemainValue: 50.0, // 剩餘 0.5 * 100 = 50 USDT - }, - { - name: "部分平倉_剩餘價值小於10USDT_應該全平", - totalQuantity: 0.2, - markPrice: 100.0, - closePercentage: 95.0, // 平倉 95%,剩餘 1 USDT (0.2 * 5% * 100) - expectFullClose: true, - expectRemainValue: 1.0, - }, - { - name: "部分平倉_剩餘價值剛好10USDT_應該全平", - totalQuantity: 1.0, - markPrice: 100.0, - closePercentage: 90.0, // 剩餘 10 USDT (1.0 * 10% * 100),邊界測試 (<=) - expectFullClose: true, - expectRemainValue: 10.0, - }, - { - name: "部分平倉_剩餘價值11USDT_不應全平", - totalQuantity: 1.1, - markPrice: 100.0, - closePercentage: 90.0, // 剩餘 11 USDT (1.1 * 10% * 100) - expectFullClose: false, - expectRemainValue: 11.0, - }, - { - name: "大倉位部分平倉_剩餘價值遠大於10USDT", - totalQuantity: 10.0, - markPrice: 1000.0, - closePercentage: 80.0, - expectFullClose: false, - expectRemainValue: 2000.0, // 剩餘 2 * 1000 = 2000 USDT - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // 計算剩餘價值 - closeQuantity := tt.totalQuantity * (tt.closePercentage / 100.0) - remainingQuantity := tt.totalQuantity - closeQuantity - remainingValue := remainingQuantity * tt.markPrice - - // 驗證計算(使用浮點數比較允許微小誤差) - const epsilon = 0.001 - if remainingValue-tt.expectRemainValue > epsilon || tt.expectRemainValue-remainingValue > epsilon { - t.Errorf("計算錯誤: 剩餘價值 = %.2f, 期望 = %.2f", - remainingValue, tt.expectRemainValue) - } - - // 驗證最小倉位檢查邏輯 - const MIN_POSITION_VALUE = 10.0 - shouldFullClose := remainingValue > 0 && remainingValue <= MIN_POSITION_VALUE - - if shouldFullClose != tt.expectFullClose { - t.Errorf("最小倉位檢查失敗: shouldFullClose = %v, 期望 = %v (剩餘價值 = %.2f USDT)", - shouldFullClose, tt.expectFullClose, remainingValue) - } - }) - } -} - -// TestPartialCloseWithStopLossTakeProfitRecovery 測試止盈止損恢復邏輯 -func TestPartialCloseWithStopLossTakeProfitRecovery(t *testing.T) { - tests := []struct { - name string - newStopLoss float64 - newTakeProfit float64 - expectStopLoss bool - expectTakeProfit bool - }{ - { - name: "有新止損和止盈_應該恢復兩者", - newStopLoss: 95.0, - newTakeProfit: 110.0, - expectStopLoss: true, - expectTakeProfit: true, - }, - { - name: "只有新止損_僅恢復止損", - newStopLoss: 95.0, - newTakeProfit: 0, - expectStopLoss: true, - expectTakeProfit: false, - }, - { - name: "只有新止盈_僅恢復止盈", - newStopLoss: 0, - newTakeProfit: 110.0, - expectStopLoss: false, - expectTakeProfit: true, - }, - { - name: "沒有新止損止盈_不恢復", - newStopLoss: 0, - newTakeProfit: 0, - expectStopLoss: false, - expectTakeProfit: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // 模擬止盈止損恢復邏輯 - stopLossRecovered := tt.newStopLoss > 0 - takeProfitRecovered := tt.newTakeProfit > 0 - - if stopLossRecovered != tt.expectStopLoss { - t.Errorf("止損恢復邏輯錯誤: recovered = %v, 期望 = %v", - stopLossRecovered, tt.expectStopLoss) - } - - if takeProfitRecovered != tt.expectTakeProfit { - t.Errorf("止盈恢復邏輯錯誤: recovered = %v, 期望 = %v", - takeProfitRecovered, tt.expectTakeProfit) - } - }) - } -} - -// TestPartialCloseEdgeCases 測試邊界情況 -func TestPartialCloseEdgeCases(t *testing.T) { - tests := []struct { - name string - closePercentage float64 - totalQuantity float64 - markPrice float64 - expectError bool - errorContains string - }{ - { - name: "平倉百分比為0_應該報錯", - closePercentage: 0, - totalQuantity: 1.0, - markPrice: 100.0, - expectError: true, - errorContains: "0-100", - }, - { - name: "平倉百分比超過100_應該報錯", - closePercentage: 101.0, - totalQuantity: 1.0, - markPrice: 100.0, - expectError: true, - errorContains: "0-100", - }, - { - name: "平倉百分比為負數_應該報錯", - closePercentage: -10.0, - totalQuantity: 1.0, - markPrice: 100.0, - expectError: true, - errorContains: "0-100", - }, - { - name: "正常範圍_不應報錯", - closePercentage: 50.0, - totalQuantity: 1.0, - markPrice: 100.0, - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // 模擬百分比驗證邏輯 - var err error - if tt.closePercentage <= 0 || tt.closePercentage > 100 { - err = fmt.Errorf("平仓百分比必须在 0-100 之间,当前: %.1f", tt.closePercentage) - } - - if tt.expectError { - if err == nil { - t.Errorf("期望報錯但沒有報錯") - } - } else { - if err != nil { - t.Errorf("不應報錯但報錯了: %v", err) - } - } - }) - } -} - -// TestPartialCloseIntegration 整合測試(使用 mock trader) -func TestPartialCloseIntegration(t *testing.T) { - tests := []struct { - name string - symbol string - side string - totalQuantity float64 - markPrice float64 - closePercentage float64 - newStopLoss float64 - newTakeProfit float64 - expectFullClose bool - expectStopLossCall bool - expectTakeProfitCall bool - }{ - { - name: "LONG倉_正常部分平倉_有止盈止損", - symbol: "BTCUSDT", - side: "LONG", - totalQuantity: 1.0, - markPrice: 50000.0, - closePercentage: 50.0, - newStopLoss: 48000.0, - newTakeProfit: 52000.0, - expectFullClose: false, - expectStopLossCall: true, - expectTakeProfitCall: true, - }, - { - name: "SHORT倉_剩餘價值過小_應自動全平", - symbol: "ETHUSDT", - side: "SHORT", - totalQuantity: 0.02, - markPrice: 3000.0, // 總價值 60 USDT - closePercentage: 95.0, // 剩餘 3 USDT < 10 USDT - newStopLoss: 3100.0, - newTakeProfit: 2900.0, - expectFullClose: true, - expectStopLossCall: false, // 全平不需要恢復止盈止損 - expectTakeProfitCall: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // 創建 mock trader - mockTrader := &MockPartialCloseTrader{ - positions: []map[string]interface{}{ - { - "symbol": tt.symbol, - "side": tt.side, - "quantity": tt.totalQuantity, - "markPrice": tt.markPrice, - }, - }, - } - - // 創建決策 - dec := &decision.Decision{ - Symbol: tt.symbol, - Action: "partial_close", - ClosePercentage: tt.closePercentage, - NewStopLoss: tt.newStopLoss, - NewTakeProfit: tt.newTakeProfit, - } - - // 創建 actionRecord - actionRecord := &logger.DecisionAction{} - - // 計算剩餘價值 - closeQuantity := tt.totalQuantity * (tt.closePercentage / 100.0) - remainingQuantity := tt.totalQuantity - closeQuantity - remainingValue := remainingQuantity * tt.markPrice - - // 驗證最小倉位檢查 - const MIN_POSITION_VALUE = 10.0 - shouldFullClose := remainingValue > 0 && remainingValue <= MIN_POSITION_VALUE - - if shouldFullClose != tt.expectFullClose { - t.Errorf("最小倉位檢查不符: shouldFullClose = %v, 期望 = %v (剩餘 %.2f USDT)", - shouldFullClose, tt.expectFullClose, remainingValue) - } - - // 模擬執行邏輯 - if shouldFullClose { - // 應該轉為全平 - if tt.side == "LONG" { - mockTrader.CloseLong(tt.symbol, tt.totalQuantity) - } else { - mockTrader.CloseShort(tt.symbol, tt.totalQuantity) - } - } else { - // 正常部分平倉 - if tt.side == "LONG" { - mockTrader.ClosePartialLong(tt.symbol, closeQuantity) - } else { - mockTrader.ClosePartialShort(tt.symbol, closeQuantity) - } - - // 恢復止盈止損 - if dec.NewStopLoss > 0 { - mockTrader.SetStopLoss(tt.symbol, tt.side, remainingQuantity, dec.NewStopLoss) - } - if dec.NewTakeProfit > 0 { - mockTrader.SetTakeProfit(tt.symbol, tt.side, remainingQuantity, dec.NewTakeProfit) - } - } - - // 驗證調用 - if tt.expectFullClose { - if !mockTrader.closeLongCalled && !mockTrader.closeShortCalled { - t.Error("期望調用全平但沒有調用") - } - if mockTrader.closePartialCalled { - t.Error("不應該調用部分平倉") - } - } else { - if !mockTrader.closePartialCalled { - t.Error("期望調用部分平倉但沒有調用") - } - } - - if mockTrader.stopLossCalled != tt.expectStopLossCall { - t.Errorf("止損調用不符: called = %v, 期望 = %v", - mockTrader.stopLossCalled, tt.expectStopLossCall) - } - - if mockTrader.takeProfitCalled != tt.expectTakeProfitCall { - t.Errorf("止盈調用不符: called = %v, 期望 = %v", - mockTrader.takeProfitCalled, tt.expectTakeProfitCall) - } - - _ = actionRecord // 避免未使用警告 - }) - } -} diff --git a/trader/position_sync.go b/trader/position_sync.go new file mode 100644 index 00000000..30c2ca56 --- /dev/null +++ b/trader/position_sync.go @@ -0,0 +1,318 @@ +package trader + +import ( + "fmt" + "nofx/logger" + "nofx/store" + "sync" + "time" +) + +// PositionSyncManager 仓位状态同步管理器 +// 负责定期同步交易所仓位,检测手动平仓等变化 +type PositionSyncManager struct { + store *store.Store + interval time.Duration + stopCh chan struct{} + wg sync.WaitGroup + traderCache map[string]Trader // trader_id -> Trader 实例缓存 + configCache map[string]*store.TraderFullConfig // trader_id -> 配置缓存 + cacheMutex sync.RWMutex +} + +// NewPositionSyncManager 创建仓位同步管理器 +func NewPositionSyncManager(st *store.Store, interval time.Duration) *PositionSyncManager { + if interval == 0 { + interval = 10 * time.Second + } + return &PositionSyncManager{ + store: st, + interval: interval, + stopCh: make(chan struct{}), + traderCache: make(map[string]Trader), + configCache: make(map[string]*store.TraderFullConfig), + } +} + +// Start 启动仓位同步服务 +func (m *PositionSyncManager) Start() { + m.wg.Add(1) + go m.run() + logger.Info("📊 仓位同步管理器已启动") +} + +// Stop 停止仓位同步服务 +func (m *PositionSyncManager) Stop() { + close(m.stopCh) + m.wg.Wait() + + // 清理缓存 + m.cacheMutex.Lock() + m.traderCache = make(map[string]Trader) + m.configCache = make(map[string]*store.TraderFullConfig) + m.cacheMutex.Unlock() + + logger.Info("📊 仓位同步管理器已停止") +} + +// run 主循环 +func (m *PositionSyncManager) run() { + defer m.wg.Done() + + // 启动时立即执行一次 + m.syncPositions() + + ticker := time.NewTicker(m.interval) + defer ticker.Stop() + + for { + select { + case <-m.stopCh: + return + case <-ticker.C: + m.syncPositions() + } + } +} + +// syncPositions 同步所有仓位状态 +func (m *PositionSyncManager) syncPositions() { + // 获取所有 OPEN 状态的仓位 + localPositions, err := m.store.Position().GetAllOpenPositions() + if err != nil { + logger.Infof("⚠️ 获取本地仓位失败: %v", err) + return + } + + if len(localPositions) == 0 { + return + } + + // 按 trader_id 分组 + positionsByTrader := make(map[string][]*store.TraderPosition) + for _, pos := range localPositions { + positionsByTrader[pos.TraderID] = append(positionsByTrader[pos.TraderID], pos) + } + + // 逐个 trader 处理 + for traderID, traderPositions := range positionsByTrader { + m.syncTraderPositions(traderID, traderPositions) + } +} + +// syncTraderPositions 同步单个 trader 的仓位 +func (m *PositionSyncManager) syncTraderPositions(traderID string, localPositions []*store.TraderPosition) { + // 获取或创建 trader 实例 + trader, err := m.getOrCreateTrader(traderID) + if err != nil { + logger.Infof("⚠️ 获取 trader 实例失败 (ID: %s): %v", traderID, err) + return + } + + // 获取交易所当前仓位 + exchangePositions, err := trader.GetPositions() + if err != nil { + logger.Infof("⚠️ 获取交易所仓位失败 (ID: %s): %v", traderID, err) + return + } + + // 构建交易所仓位 map: symbol_side -> position + exchangeMap := make(map[string]map[string]interface{}) + for _, pos := range exchangePositions { + symbol, _ := pos["symbol"].(string) + side, _ := pos["positionSide"].(string) + if symbol == "" || side == "" { + continue + } + key := fmt.Sprintf("%s_%s", symbol, side) + exchangeMap[key] = pos + } + + // 对比本地和交易所仓位 + for _, localPos := range localPositions { + key := fmt.Sprintf("%s_%s", localPos.Symbol, localPos.Side) + exchangePos, exists := exchangeMap[key] + + if !exists { + // 交易所没有这个仓位了 → 已被平仓 + m.closeLocalPosition(localPos, trader, "manual") + continue + } + + // 检查数量是否为0或很小 + qty := getFloatFromMap(exchangePos, "positionAmt") + if qty < 0 { + qty = -qty // 空仓数量是负的 + } + + if qty < 0.0000001 { + // 数量为0,仓位已平 + m.closeLocalPosition(localPos, trader, "manual") + } + } +} + +// closeLocalPosition 标记本地仓位为已平仓 +func (m *PositionSyncManager) closeLocalPosition(pos *store.TraderPosition, trader Trader, reason string) { + // 尝试获取最后成交价作为平仓价 + exitPrice := pos.EntryPrice // 默认用开仓价 + + // 尝试从交易所获取最新价格 + if price, err := trader.GetMarketPrice(pos.Symbol); err == nil && price > 0 { + exitPrice = price + } + + // 计算盈亏 + var realizedPnL float64 + if pos.Side == "LONG" { + realizedPnL = (exitPrice - pos.EntryPrice) * pos.Quantity + } else { + realizedPnL = (pos.EntryPrice - exitPrice) * pos.Quantity + } + + // 更新数据库 + err := m.store.Position().ClosePosition( + pos.ID, + exitPrice, + "", // 手动平仓没有订单ID + realizedPnL, + 0, // 手动平仓无法获取手续费 + reason, + ) + + if err != nil { + logger.Infof("⚠️ 更新仓位状态失败: %v", err) + } else { + logger.Infof("📊 仓位已平仓 [%s] %s %s @ %.4f → %.4f, PnL: %.2f (%s)", + pos.TraderID[:8], pos.Symbol, pos.Side, pos.EntryPrice, exitPrice, realizedPnL, reason) + } +} + +// getOrCreateTrader 获取或创建 trader 实例 +func (m *PositionSyncManager) getOrCreateTrader(traderID string) (Trader, error) { + m.cacheMutex.RLock() + trader, exists := m.traderCache[traderID] + m.cacheMutex.RUnlock() + + if exists && trader != nil { + return trader, nil + } + + // 需要创建新的 trader 实例 + config, err := m.getTraderConfig(traderID) + if err != nil { + return nil, fmt.Errorf("获取 trader 配置失败: %w", err) + } + + trader, err = m.createTrader(config) + if err != nil { + return nil, fmt.Errorf("创建 trader 实例失败: %w", err) + } + + m.cacheMutex.Lock() + m.traderCache[traderID] = trader + m.cacheMutex.Unlock() + + return trader, nil +} + +// getTraderConfig 获取 trader 配置 +func (m *PositionSyncManager) getTraderConfig(traderID string) (*store.TraderFullConfig, error) { + m.cacheMutex.RLock() + config, exists := m.configCache[traderID] + m.cacheMutex.RUnlock() + + if exists { + return config, nil + } + + // 从数据库获取 + traders, err := m.store.Trader().ListAll() + if err != nil { + return nil, fmt.Errorf("获取 trader 列表失败: %w", err) + } + + var userID string + for _, t := range traders { + if t.ID == traderID { + userID = t.UserID + break + } + } + + if userID == "" { + return nil, fmt.Errorf("找不到 trader: %s", traderID) + } + + config, err = m.store.Trader().GetFullConfig(userID, traderID) + if err != nil { + return nil, err + } + + m.cacheMutex.Lock() + m.configCache[traderID] = config + m.cacheMutex.Unlock() + + return config, nil +} + +// createTrader 根据配置创建 trader 实例 +func (m *PositionSyncManager) createTrader(config *store.TraderFullConfig) (Trader, error) { + exchange := config.Exchange + + switch exchange.Type { + case "binance": + return NewFuturesTrader(exchange.APIKey, exchange.SecretKey, config.Trader.UserID), nil + + case "bybit": + return NewBybitTrader(exchange.APIKey, exchange.SecretKey), nil + + case "hyperliquid": + return NewHyperliquidTrader(exchange.SecretKey, exchange.HyperliquidWalletAddr, exchange.Testnet) + + case "aster": + return NewAsterTrader(exchange.AsterUser, exchange.AsterSigner, exchange.AsterPrivateKey) + + case "lighter": + if exchange.LighterAPIKeyPrivateKey != "" { + return NewLighterTraderV2( + exchange.LighterPrivateKey, + exchange.LighterWalletAddr, + exchange.LighterAPIKeyPrivateKey, + exchange.Testnet, + ) + } + return NewLighterTrader(exchange.LighterPrivateKey, exchange.LighterWalletAddr, exchange.Testnet) + + default: + return nil, fmt.Errorf("不支持的交易所类型: %s", exchange.Type) + } +} + +// InvalidateCache 使缓存失效 +func (m *PositionSyncManager) InvalidateCache(traderID string) { + m.cacheMutex.Lock() + defer m.cacheMutex.Unlock() + + delete(m.traderCache, traderID) + delete(m.configCache, traderID) +} + +// getFloatFromMap 从 map 中获取 float64 值 +func getFloatFromMap(m map[string]interface{}, key string) float64 { + if v, ok := m[key]; ok { + switch val := v.(type) { + case float64: + return val + case int64: + return float64(val) + case int: + return float64(val) + case string: + var f float64 + fmt.Sscanf(val, "%f", &f) + return f + } + } + return 0 +} diff --git a/web/package-lock.json b/web/package-lock.json index 857e30ce..72290fd2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -121,7 +121,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -453,7 +452,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -477,7 +475,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2037,7 +2034,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2158,7 +2156,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", "devOptional": true, - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2169,7 +2166,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2210,7 +2206,6 @@ "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/types": "8.46.3", @@ -2535,7 +2530,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2969,7 +2963,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -3697,7 +3690,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -4015,7 +4009,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4076,7 +4069,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -5590,7 +5582,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -5619,7 +5610,6 @@ "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.1.0", "data-urls": "^5.0.0", @@ -5994,6 +5984,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6581,7 +6572,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6735,7 +6725,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -6765,6 +6754,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -6780,6 +6770,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -6790,6 +6781,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -6802,7 +6794,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/prop-types": { "version": "15.8.1", @@ -6859,7 +6852,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -6871,7 +6863,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -8063,7 +8054,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, - "peer": true, "engines": { "node": ">=12" }, @@ -8280,7 +8270,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8431,7 +8420,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -9036,7 +9024,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, - "peer": true, "engines": { "node": ">=12" }, @@ -9573,7 +9560,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -9987,7 +9973,6 @@ "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/src/App.tsx b/web/src/App.tsx index 81453d5c..2347082a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import useSWR from 'swr' import { api } from './lib/api' -import { EquityChart } from './components/EquityChart' +import { ChartTabs } from './components/ChartTabs' import { AITradersPage } from './components/AITradersPage' import { LoginPage } from './components/LoginPage' import { RegisterPage } from './components/RegisterPage' @@ -10,7 +10,6 @@ import { CompetitionPage } from './components/CompetitionPage' import { LandingPage } from './pages/LandingPage' import { FAQPage } from './pages/FAQPage' import HeaderBar from './components/HeaderBar' -import AILearning from './components/AILearning' import { LanguageProvider, useLanguage } from './contexts/LanguageContext' import { AuthProvider, useAuth } from './contexts/AuthContext' import { ConfirmDialogProvider } from './components/ConfirmDialog' @@ -780,9 +779,9 @@ function TraderDetailsPage({
{/* 左侧:图表 + 持仓 */}
- {/* Equity Chart */} + {/* Chart Tabs (Equity / K-line) */}
- +
{/* Current Positions */} @@ -1002,10 +1001,6 @@ function TraderDetailsPage({ {/* 右侧结束 */}
- {/* AI Learning & Performance Analysis */} -
- -
) } diff --git a/web/src/components/AILearning.tsx b/web/src/components/AILearning.tsx deleted file mode 100644 index a10f8f14..00000000 --- a/web/src/components/AILearning.tsx +++ /dev/null @@ -1,1142 +0,0 @@ -import useSWR from 'swr' -import { useLanguage } from '../contexts/LanguageContext' -import { t } from '../i18n/translations' -import { stripLeadingIcons } from '../lib/text' -import { api } from '../lib/api' -import { - Brain, - BarChart3, - TrendingUp, - TrendingDown, - Sparkles, - Coins, - Trophy, - ScrollText, - Lightbulb, -} from 'lucide-react' - -interface TradeOutcome { - symbol: string - side: string - quantity: number - leverage: number - open_price: number - close_price: number - position_value: number - margin_used: number - pn_l: number - pn_l_pct: number - duration: string - open_time: string - close_time: string - was_stop_loss: boolean -} - -interface SymbolPerformance { - symbol: string - total_trades: number - winning_trades: number - losing_trades: number - win_rate: number - total_pn_l: number - avg_pn_l: number -} - -interface PerformanceAnalysis { - total_trades: number - winning_trades: number - losing_trades: number - win_rate: number - avg_win: number - avg_loss: number - profit_factor: number - sharpe_ratio: number - recent_trades: TradeOutcome[] - symbol_stats: { [key: string]: SymbolPerformance } - best_symbol: string - worst_symbol: string -} - -interface AILearningProps { - traderId: string -} - -export default function AILearning({ traderId }: AILearningProps) { - const { language } = useLanguage() - const { data: performance, error } = useSWR( - traderId ? `performance-${traderId}` : 'performance', - () => api.getPerformance(traderId), - { - refreshInterval: 30000, // 30秒刷新(AI学习分析数据更新频率较低) - revalidateOnFocus: false, - dedupingInterval: 20000, - } - ) - - if (error) { - return ( -
-
- {stripLeadingIcons(t('loadingError', language))} -
-
- ) - } - - if (!performance) { - return ( -
-
- {t('loading', language)} -
-
- ) - } - - if (!performance || performance.total_trades === 0) { - return ( -
-
- -

- {t('aiLearning', language)} -

-
-
{t('noCompleteData', language)}
-
- ) - } - - const symbolStats = performance.symbol_stats || {} - const symbolStatsList = Object.values(symbolStats) - .filter((stat) => stat != null) - .sort((a, b) => (b.total_pn_l || 0) - (a.total_pn_l || 0)) - - return ( -
- {/* 标题区 - 优化设计 */} -
-
-
-
- -
-
-

- {t('aiLearning', language)} -

-

- {t('tradesAnalyzed', language, { - count: performance.total_trades, - })} -

-
-
-
- - {/* 核心指标卡片 - 4列网格 */} -
- {/* 总交易数 */} -
-
-
-
- {t('totalTrades', language)} -
-
- {performance.total_trades} -
-
- Trades -
-
-
- - {/* 胜率 */} -
= 50 - ? 'linear-gradient(135deg, rgba(16, 185, 129, 0.2) 0%, rgba(30, 35, 41, 0.8) 100%)' - : 'linear-gradient(135deg, rgba(248, 113, 113, 0.2) 0%, rgba(30, 35, 41, 0.8) 100%)', - border: `1px solid ${(performance.win_rate || 0) >= 50 ? 'rgba(16, 185, 129, 0.4)' : 'rgba(248, 113, 113, 0.4)'}`, - boxShadow: `0 4px 16px ${(performance.win_rate || 0) >= 50 ? 'rgba(16, 185, 129, 0.2)' : 'rgba(248, 113, 113, 0.2)'}`, - }} - > -
= 50 ? '#10B981' : '#F87171'} 0%, transparent 70%)`, - filter: 'blur(20px)', - }} - /> -
-
= 50 ? '#6EE7B7' : '#FCA5A5', - }} - > - {t('winRate', language)} -
-
= 50 ? '#10B981' : '#F87171', - }} - > - {(performance.win_rate || 0).toFixed(1)}% -
-
- {performance.winning_trades || 0}W /{' '} - {performance.losing_trades || 0}L -
-
-
- - {/* 平均盈利 */} -
-
-
-
- {t('avgWin', language)} -
-
- +{(performance.avg_win || 0).toFixed(2)} -
-
- USDT Average -
-
-
- - {/* 平均亏损 */} -
-
-
-
- {t('avgLoss', language)} -
-
- {(performance.avg_loss || 0).toFixed(2)} -
-
- USDT Average -
-
-
-
- - {/* 关键指标:夏普比率 & 盈亏比 - 2列网格 */} -
- {/* 夏普比率 */} -
-
-
-
-
- -
-
-
- 夏普比率 -
-
- 风险调整后收益 · AI自我进化指标 -
-
-
- -
-
= 2 - ? '#10B981' - : (performance.sharpe_ratio || 0) >= 1 - ? '#22D3EE' - : (performance.sharpe_ratio || 0) >= 0 - ? '#F0B90B' - : '#F87171', - textShadow: '0 4px 12px rgba(0, 0, 0, 0.3)', - }} - > - {performance.sharpe_ratio - ? performance.sharpe_ratio.toFixed(2) - : 'N/A'} -
- - {performance.sharpe_ratio !== undefined && ( -
-
= 2 - ? '#10B981' - : (performance.sharpe_ratio || 0) >= 1 - ? '#22D3EE' - : (performance.sharpe_ratio || 0) >= 0 - ? '#F0B90B' - : '#F87171', - background: - (performance.sharpe_ratio || 0) >= 2 - ? 'rgba(16, 185, 129, 0.2)' - : (performance.sharpe_ratio || 0) >= 1 - ? 'rgba(34, 211, 238, 0.2)' - : (performance.sharpe_ratio || 0) >= 0 - ? 'rgba(240, 185, 11, 0.2)' - : 'rgba(248, 113, 113, 0.2)', - }} - > - {performance.sharpe_ratio >= 2 - ? '🟢 卓越表现' - : performance.sharpe_ratio >= 1 - ? '🟢 良好表现' - : performance.sharpe_ratio >= 0 - ? '🟡 波动较大' - : '🔴 需要调整'} -
-
- )} -
- - {performance.sharpe_ratio !== undefined && ( -
-
- {performance.sharpe_ratio >= 2 && - '✨ AI策略非常有效!风险调整后收益优异,可适度扩大仓位但保持纪律。'} - {performance.sharpe_ratio >= 1 && - performance.sharpe_ratio < 2 && - '✅ 策略表现稳健,风险收益平衡良好,继续保持当前策略。'} - {performance.sharpe_ratio >= 0 && - performance.sharpe_ratio < 1 && - '⚠️ 收益为正但波动较大,AI正在优化策略,降低风险。'} - {performance.sharpe_ratio < 0 && - '🚨 当前策略需要调整!AI已自动进入保守模式,减少仓位和交易频率。'} -
-
- )} -
-
- - {/* 盈亏比 */} -
-
-
-
-
- -
-
-
- {t('profitFactor', language)} -
-
- {t('avgWinDivLoss', language)} -
-
-
- -
-
= 2.0 - ? '#10B981' - : (performance.profit_factor || 0) >= 1.5 - ? '#F0B90B' - : (performance.profit_factor || 0) >= 1.0 - ? '#FB923C' - : '#F87171', - textShadow: '0 4px 12px rgba(0, 0, 0, 0.3)', - }} - > - {(performance.profit_factor || 0) > 0 - ? (performance.profit_factor || 0).toFixed(2) - : 'N/A'} -
- -
-
= 2.0 - ? '#10B981' - : (performance.profit_factor || 0) >= 1.5 - ? '#F0B90B' - : '#94A3B8', - background: - (performance.profit_factor || 0) >= 2.0 - ? 'rgba(16, 185, 129, 0.2)' - : (performance.profit_factor || 0) >= 1.5 - ? 'rgba(240, 185, 11, 0.2)' - : 'rgba(148, 163, 184, 0.2)', - }} - > - {(performance.profit_factor || 0) >= 2.0 && - t('excellent', language)} - {(performance.profit_factor || 0) >= 1.5 && - (performance.profit_factor || 0) < 2.0 && - t('good', language)} - {(performance.profit_factor || 0) >= 1.0 && - (performance.profit_factor || 0) < 1.5 && - t('fair', language)} - {(performance.profit_factor || 0) > 0 && - (performance.profit_factor || 0) < 1.0 && - t('poor', language)} -
-
-
- -
-
- {(performance.profit_factor || 0) >= 2.0 && - '🔥 盈利能力出色!每亏1元能赚' + - (performance.profit_factor || 0).toFixed(1) + - '元,AI策略表现优异。'} - {(performance.profit_factor || 0) >= 1.5 && - (performance.profit_factor || 0) < 2.0 && - '✓ 策略稳定盈利,盈亏比健康,继续保持纪律性交易。'} - {(performance.profit_factor || 0) >= 1.0 && - (performance.profit_factor || 0) < 1.5 && - '⚠️ 策略略有盈利但需优化,AI正在调整仓位和止损策略。'} - {(performance.profit_factor || 0) > 0 && - (performance.profit_factor || 0) < 1.0 && - '❌ 平均亏损大于盈利,需要调整策略或降低交易频率。'} -
-
-
-
-
- - {/* 最佳/最差币种 - 独立行 */} - {(performance.best_symbol || performance.worst_symbol) && ( -
- {performance.best_symbol && ( -
-
- - - {t('bestPerformer', language)} - -
-
- {performance.best_symbol} -
- {symbolStats[performance.best_symbol] && ( -
- {symbolStats[performance.best_symbol].total_pn_l > 0 - ? '+' - : ''} - {symbolStats[performance.best_symbol].total_pn_l.toFixed(2)}{' '} - USDT {t('pnl', language)} -
- )} -
- )} - - {performance.worst_symbol && ( -
-
- - - {t('worstPerformer', language)} - -
-
- {performance.worst_symbol} -
- {symbolStats[performance.worst_symbol] && ( -
- {symbolStats[performance.worst_symbol].total_pn_l > 0 - ? '+' - : ''} - {symbolStats[performance.worst_symbol].total_pn_l.toFixed(2)}{' '} - USDT {t('pnl', language)} -
- )} -
- )} -
- )} - - {/* 币种表现 & 历史成交 - 左右分屏 2列布局 */} -
- {/* 左侧:币种表现统计表格 */} - {symbolStatsList.length > 0 && ( -
-
-

- {' '} - {stripLeadingIcons(t('symbolPerformance', language))} -

-
-
- - - - - - - - - - - - {symbolStatsList.map((stat, idx) => ( - 0 - ? '1px solid rgba(99, 102, 241, 0.1)' - : 'none', - }} - > - - - - - - - ))} - -
- Symbol - - Trades - - Win Rate - - Total P&L (USDT) - - Avg P&L (USDT) -
- - {stat.symbol} - - - {stat.total_trades} - = 50 ? '#10B981' : '#F87171', - }} - > - {(stat.win_rate || 0).toFixed(1)}% - 0 ? '#10B981' : '#F87171', - }} - > - {(stat.total_pn_l || 0) > 0 ? '+' : ''} - {(stat.total_pn_l || 0).toFixed(2)} - 0 ? '#10B981' : '#F87171', - }} - > - {(stat.avg_pn_l || 0) > 0 ? '+' : ''} - {(stat.avg_pn_l || 0).toFixed(2)} -
-
-
- )} - - {/* 右侧:历史成交记录 */} -
-
-
- -
-

- {t('tradeHistory', language)} -

-

- {performance?.recent_trades && - performance.recent_trades.length > 0 - ? t('completedTrades', language, { - count: performance.recent_trades.length, - }) - : t('completedTradesWillAppear', language)} -

-
-
-
- -
- {performance?.recent_trades && - performance.recent_trades.length > 0 ? ( - performance.recent_trades.map( - (trade: TradeOutcome, idx: number) => { - const isProfitable = trade.pn_l >= 0 - const isRecent = idx === 0 - - return ( -
-
-
- - {trade.symbol} - - - {trade.side.toUpperCase()} - - {isRecent && ( - - {t('latest', language)} - - )} -
-
- {isProfitable ? '+' : ''} - {trade.pn_l_pct.toFixed(2)}% -
-
- -
-
-
- {t('entry', language)} -
-
- {trade.open_price.toFixed(4)} -
-
-
-
- {t('exit', language)} -
-
- {trade.close_price.toFixed(4)} -
-
-
- - {/* Position Details */} -
-
-
Quantity
-
- {trade.quantity ? trade.quantity.toFixed(4) : '-'} -
-
-
-
Leverage
-
- {trade.leverage ? `${trade.leverage}x` : '-'} -
-
-
-
Position Value
-
- {trade.position_value - ? `$${trade.position_value.toFixed(2)}` - : '-'} -
-
-
-
Margin Used
-
- {trade.margin_used - ? `$${trade.margin_used.toFixed(2)}` - : '-'} -
-
-
- -
-
- P&L - - {isProfitable ? '+' : ''} - {trade.pn_l.toFixed(2)} USDT - -
-
- -
- ⏱️ {formatDuration(trade.duration)} - {trade.was_stop_loss && ( - - {t('stopLoss', language)} - - )} -
- -
- {new Date(trade.close_time).toLocaleString('en-US', { - month: 'short', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - })} -
-
- ) - } - ) - ) : ( -
-
- -
-
- {t('noCompletedTrades', language)} -
-
- )} -
-
-
- - {/* AI学习说明 - 现代化设计 */} -
-
-
- -
-
-

- {stripLeadingIcons(t('howAILearns', language))} -

-
-
- - - {t('aiLearningPoint1', language)} - -
-
- - - {t('aiLearningPoint2', language)} - -
-
- - - {t('aiLearningPoint3', language)} - -
-
- - - {t('aiLearningPoint4', language)} - -
-
-
-
-
-
- ) -} - -// 格式化持仓时长 -function formatDuration(duration: string | undefined): string { - if (!duration) return '-' - - const match = duration.match(/(\d+h)?(\d+m)?(\d+\.?\d*s)?/) - if (!match) return duration - - const hours = match[1] || '' - const minutes = match[2] || '' - const seconds = match[3] || '' - - let result = '' - if (hours) result += hours.replace('h', '小时') - if (minutes) result += minutes.replace('m', '分') - if (!hours && seconds) result += seconds.replace(/(\d+)\.?\d*s/, '$1秒') - - return result || duration -} diff --git a/web/src/components/ChartTabs.tsx b/web/src/components/ChartTabs.tsx new file mode 100644 index 00000000..29f8d98b --- /dev/null +++ b/web/src/components/ChartTabs.tsx @@ -0,0 +1,89 @@ +import { useState } from 'react' +import { EquityChart } from './EquityChart' +import { TradingViewChart } from './TradingViewChart' +import { useLanguage } from '../contexts/LanguageContext' +import { t } from '../i18n/translations' +import { BarChart3, CandlestickChart } from 'lucide-react' + +interface ChartTabsProps { + traderId: string +} + +type ChartTab = 'equity' | 'kline' + +export function ChartTabs({ traderId }: ChartTabsProps) { + const { language } = useLanguage() + const [activeTab, setActiveTab] = useState('equity') + + console.log('[ChartTabs] rendering, activeTab:', activeTab) + + return ( +
+ {/* Tab Headers - 这是Tab切换按钮区域 */} +
+ + + +
+ + {/* Tab Content */} +
+ {activeTab === 'equity' ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/web/src/components/DecisionCard.tsx b/web/src/components/DecisionCard.tsx index 96d713d1..9b4b74cf 100644 --- a/web/src/components/DecisionCard.tsx +++ b/web/src/components/DecisionCard.tsx @@ -126,6 +126,11 @@ export function DecisionCard({ decision, language }: DecisionCardProps) { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81', } + : action.action === 'wait' || action.action === 'hold' + ? { + background: 'rgba(132, 142, 156, 0.1)', + color: '#848E9C', + } : { background: 'rgba(248, 113, 113, 0.1)', color: '#F87171', diff --git a/web/src/components/EquityChart.tsx b/web/src/components/EquityChart.tsx index f8beb1d5..13de9921 100644 --- a/web/src/components/EquityChart.tsx +++ b/web/src/components/EquityChart.tsx @@ -33,9 +33,10 @@ interface EquityPoint { interface EquityChartProps { traderId?: string + embedded?: boolean // 嵌入模式(不显示外层卡片) } -export function EquityChart({ traderId }: EquityChartProps) { +export function EquityChart({ traderId, embedded = false }: EquityChartProps) { const { language } = useLanguage() const { user, token } = useAuth() const [displayMode, setDisplayMode] = useState<'dollar' | 'percent'>('dollar') @@ -62,7 +63,7 @@ export function EquityChart({ traderId }: EquityChartProps) { if (error) { return ( -
+
-

- {t('accountEquityCurve', language)} -

+
+ {!embedded && ( +

+ {t('accountEquityCurve', language)} +

+ )}
@@ -193,16 +196,18 @@ export function EquityChart({ traderId }: EquityChartProps) { } return ( -
+
{/* Header */}
-

- {t('accountEquityCurve', language)} -

+ {!embedded && ( +

+ {t('accountEquityCurve', language)} +

+ )}
('login') const [email, setEmail] = useState('') const [password, setPassword] = useState('') @@ -236,7 +234,9 @@ export function LoginPage() {
+ + {showExchangeDropdown && ( +
+ {EXCHANGES.map((ex) => ( + + ))} +
+ )} +
+ + {/* Symbol Selector */} +
+ + + {showSymbolDropdown && ( +
+ {/* Custom Input */} +
+
+ setCustomSymbol(e.target.value.toUpperCase())} + onKeyDown={(e) => e.key === 'Enter' && handleCustomSymbolSubmit()} + placeholder={t('enterSymbol', language)} + className="flex-1 px-3 py-1.5 rounded text-sm" + style={{ + background: '#0B0E11', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + /> + +
+
+ + {/* Popular Symbols */} +
+
+ {t('popularSymbols', language)} +
+
+ {POPULAR_SYMBOLS.map((sym) => ( + + ))} +
+
+
+ )} +
+ + {/* Interval Selector */} +
+ {INTERVALS.map((int) => ( + + ))} +
+ + {/* Fullscreen Toggle */} + +
+
+ + {/* Chart Container */} +
+ + {/* Click outside to close dropdowns */} + {(showExchangeDropdown || showSymbolDropdown) && ( +
{ + setShowExchangeDropdown(false) + setShowSymbolDropdown(false) + }} + /> + )} +
+ ) +} + +// 使用 memo 避免不必要的重渲染 +export const TradingViewChart = memo(TradingViewChartComponent) diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index f83cf052..e9110a9a 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -83,6 +83,13 @@ export const translations = { currentGap: 'Current Gap', count: '{count} pts', + // TradingView Chart + marketChart: 'Market Chart', + enterSymbol: 'Enter symbol...', + popularSymbols: 'Popular Symbols', + fullscreen: 'Fullscreen', + exitFullscreen: 'Exit Fullscreen', + // Backtest Page backtestPage: { title: 'Backtest Lab', @@ -264,40 +271,6 @@ export const translations = { pnl: 'P&L', pos: 'Pos', - // AI Learning - aiLearning: 'AI Learning & Reflection', - tradesAnalyzed: '{count} trades analyzed · Real-time evolution', - latestReflection: 'Latest Reflection', - fullCoT: 'Full Chain of Thought', - totalTrades: 'Total Trades', - winRate: 'Win Rate', - avgWin: 'Avg Win', - avgLoss: 'Avg Loss', - profitFactor: 'Profit Factor', - avgWinDivLoss: 'Avg Win ÷ Avg Loss', - excellent: '🔥 Excellent - Strong profitability', - good: '✓ Good - Stable profits', - fair: '⚠️ Fair - Needs optimization', - poor: '❌ Poor - Losses exceed gains', - bestPerformer: 'Best Performer', - worstPerformer: 'Worst Performer', - symbolPerformance: 'Symbol Performance', - tradeHistory: 'Trade History', - completedTrades: 'Recent {count} completed trades', - noCompletedTrades: 'No completed trades yet', - completedTradesWillAppear: 'Completed trades will appear here', - entry: 'Entry', - exit: 'Exit', - stopLoss: 'Stop Loss', - latest: 'Latest', - - // AI Learning Description - howAILearns: 'How AI Learns & Evolves', - aiLearningPoint1: 'Analyzes last 20 trading cycles before each decision', - aiLearningPoint2: 'Identifies best & worst performing symbols', - aiLearningPoint3: 'Optimizes position sizing based on win rate', - aiLearningPoint4: 'Avoids repeating past mistakes', - // AI Traders Management manageAITraders: 'Manage your AI trading bots', aiModels: 'AI Models', @@ -499,9 +472,6 @@ export const translations = { // Loading & Error loading: 'Loading...', - loadingError: '⚠️ Failed to load AI learning data', - noCompleteData: - 'No complete trading data (needs to complete open → close cycle)', // AI Traders Page - Additional inUse: 'In Use', @@ -954,7 +924,7 @@ export const translations = { // Data & Privacy faqDataStorage: 'Where is my data stored?', faqDataStorageAnswer: - 'All data is stored locally on your machine in SQLite databases: config.db (trader configurations), trading.db (trade history), and decision_logs/ (AI decision records).', + 'All data is stored locally on your machine in SQLite databases: data.db (all configurations and trade history), and decision_logs/ (AI decision records).', faqApiKeySecurity: 'Is my API key secure?', faqApiKeySecurityAnswer: @@ -1109,6 +1079,13 @@ export const translations = { currentGap: '当前差距', count: '{count} 个', + // TradingView Chart + marketChart: '行情图表', + enterSymbol: '输入币种...', + popularSymbols: '热门币种', + fullscreen: '全屏', + exitFullscreen: '退出全屏', + // Backtest Page backtestPage: { title: '回测实验室', @@ -1288,40 +1265,6 @@ export const translations = { pnl: '收益', pos: '持仓', - // AI Learning - aiLearning: 'AI学习与反思', - tradesAnalyzed: '已分析 {count} 笔交易 · 实时演化', - latestReflection: '最新反思', - fullCoT: '📋 完整思维链', - totalTrades: '总交易数', - winRate: '胜率', - avgWin: '平均盈利', - avgLoss: '平均亏损', - profitFactor: '盈亏比', - avgWinDivLoss: '平均盈利 ÷ 平均亏损', - excellent: '🔥 优秀 - 盈利能力强', - good: '✓ 良好 - 稳定盈利', - fair: '⚠️ 一般 - 需要优化', - poor: '❌ 较差 - 亏损超过盈利', - bestPerformer: '最佳表现', - worstPerformer: '最差表现', - symbolPerformance: '📊 币种表现', - tradeHistory: '历史成交', - completedTrades: '最近 {count} 笔已完成交易', - noCompletedTrades: '暂无完成的交易', - completedTradesWillAppear: '已完成的交易将显示在这里', - entry: '入场', - exit: '出场', - stopLoss: '止损', - latest: '最新', - - // AI Learning Description - howAILearns: '💡 AI如何学习和进化', - aiLearningPoint1: '每次决策前分析最近20个交易周期', - aiLearningPoint2: '识别表现最好和最差的币种', - aiLearningPoint3: '根据胜率优化仓位大小', - aiLearningPoint4: '避免重复过去的错误', - // AI Traders Management manageAITraders: '管理您的AI交易机器人', aiModels: 'AI模型', @@ -1512,8 +1455,6 @@ export const translations = { // Loading & Error loading: '加载中...', - loadingError: '⚠️ 加载AI学习数据失败', - noCompleteData: '暂无完整交易数据(需要完成开仓→平仓的完整周期)', // AI Traders Page - Additional inUse: '正在使用', @@ -1927,7 +1868,7 @@ export const translations = { // Data & Privacy faqDataStorage: '我的数据存储在哪里?', faqDataStorageAnswer: - '所有数据都本地存储在您的机器上,使用 SQLite 数据库:config.db(交易员配置)、trading.db(交易历史)、decision_logs/(AI 决策记录)。', + '所有数据都本地存储在您的机器上,使用 SQLite 数据库:data.db(所有配置和交易历史)、decision_logs/(AI 决策记录)。', faqApiKeySecurity: 'API 密钥安全吗?', faqApiKeySecurityAnswer: diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index d2d9d8bf..29bad0ee 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -337,16 +337,6 @@ export const api = { return result.data! }, - // 获取AI学习表现分析(支持trader_id) - async getPerformance(traderId?: string): Promise { - const url = traderId - ? `${API_BASE}/performance?trader_id=${traderId}` - : `${API_BASE}/performance` - const result = await httpClient.get(url) - if (!result.success) throw new Error('获取AI学习数据失败') - return result.data! - }, - // 获取竞赛数据(无需认证) async getCompetition(): Promise { const result = await httpClient.get( diff --git a/web/src/pages/TraderDashboard.tsx b/web/src/pages/TraderDashboard.tsx index d66a141b..83d5a187 100644 --- a/web/src/pages/TraderDashboard.tsx +++ b/web/src/pages/TraderDashboard.tsx @@ -2,8 +2,7 @@ import { useEffect, useState } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import useSWR from 'swr' import { api } from '../lib/api' -import { EquityChart } from '../components/EquityChart' -import AILearning from '../components/AILearning' +import { ChartTabs } from '../components/ChartTabs' import { useLanguage } from '../contexts/LanguageContext' import { useAuth } from '../contexts/AuthContext' import { t, type Language } from '../i18n/translations' @@ -419,9 +418,9 @@ export default function TraderDashboard() {
{/* 左侧:图表 + 持仓 */}
- {/* Equity Chart */} + {/* Chart Tabs (Equity / K-line) */}
- +
{/* Current Positions */} @@ -669,10 +668,6 @@ export default function TraderDashboard() {
- {/* AI Learning & Performance Analysis */} -
- -
) }